Add basic API for machine states

Signed-off-by: AKP <tom@tdpain.net>
This commit is contained in:
akp 2022-11-08 01:29:49 +00:00
parent 3924467a30
commit 87987553e4
No known key found for this signature in database
GPG key ID: AA5726202C8879B7
5 changed files with 167 additions and 2 deletions

View file

@ -0,0 +1,5 @@
from web import new
if __name__ == "__main__":
app = new()
app.run(port=8080, host="127.0.0.1")

View file

@ -6,6 +6,10 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
class ScraperError(ValueError):
pass
class MachineState(Enum): class MachineState(Enum):
Available = "AVAIL" Available = "AVAIL"
InUse = "IN_USE" InUse = "IN_USE"
@ -25,6 +29,14 @@ class Machine:
state: MachineState state: MachineState
minutes_remaining: Optional[int] minutes_remaining: Optional[int]
def to_dict(self) -> Dict[str, Union[str, Optional[int]]]:
return {
"number": self.number,
"type": self.type.value,
"state": self.state.value,
"minutes_remaining": self.minutes_remaining,
}
class CircuitScraper: class CircuitScraper:
_base_url: str = "https://www.circuit.co.uk/circuit-view/laundry-site" _base_url: str = "https://www.circuit.co.uk/circuit-view/laundry-site"
@ -46,6 +58,10 @@ class CircuitScraper:
r = requests.get(site_url) r = requests.get(site_url)
r.raise_for_status() r.raise_for_status()
# Instead of a nice 404, a bad site ID redirects us to a /circuit-view/site-unavailable with a HTTP 200.
if "unavailable" in r.url:
raise ScraperError("Unavailable")
soup = BeautifulSoup(r.content, "html.parser") soup = BeautifulSoup(r.content, "html.parser")
machine_elements = [] machine_elements = []
@ -66,7 +82,7 @@ class CircuitScraper:
descriptor_text = states[0].get_text().lower() descriptor_text = states[0].get_text().lower()
machine.type = MachineType.Dryer if "dryer" in descriptor_text else MachineType.Washer machine.type = MachineType.Dryer if "dryer" in descriptor_text else MachineType.Washer
machine.number = descriptor_text.replace("washer", "").replace("dryer", "").strip() machine.number = descriptor_text.replace("washer", "").replace("dryer", "").strip().upper()
# Note that CircuitScraper._class_washer is included on every item, hence if it's none of the other ones are # Note that CircuitScraper._class_washer is included on every item, hence if it's none of the other ones are
# present, we fall back to that one. # present, we fall back to that one.

View file

@ -0,0 +1,24 @@
import flask
from circuit_scraper import CircuitScraper, ScraperError
def new() -> flask.Flask:
app = flask.Flask(__name__)
app.add_url_rule("/api/v1/machines/<site_id>", view_func=_api_get_machines, methods=["GET"])
return app
def _api_get_machines(site_id: str):
try:
machines = CircuitScraper.get_site_machine_states(site_id)
except ScraperError:
return flask.jsonify({"ok": False, "message": "This laundry room is unavailable via CircuitView."}), 400
return flask.jsonify([machine.to_dict() for machine in sorted(
machines,
# It was annoying me that the machines weren't sorted by number. This sorts the machines by number, taking into
# account the fact that some machine numbers include letters, which are quietly ignored.
key=lambda x: int("".join(filter(lambda y: y.isdigit(), x.number)))
)])

121
poetry.lock generated
View file

@ -32,6 +32,44 @@ python-versions = ">=3.6.0"
[package.extras] [package.extras]
unicode_backport = ["unicodedata2"] unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "flask"
version = "2.2.2"
description = "A simple framework for building complex web applications."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=8.0"
importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""}
itsdangerous = ">=2.0"
Jinja2 = ">=3.0"
Werkzeug = ">=2.2.2"
[package.extras]
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.4" version = "3.4"
@ -40,6 +78,52 @@ category = "main"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "5.0.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
perf = ["ipython"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
[[package]]
name = "itsdangerous"
version = "2.1.2"
description = "Safely pass data to untrusted environments and back."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markupsafe"
version = "2.1.1"
description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.28.1" version = "2.28.1"
@ -79,16 +163,51 @@ brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "werkzeug"
version = "2.2.2"
description = "The comprehensive WSGI web application library."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=2.1.1"
[package.extras]
watchdog = ["watchdog"]
[[package]]
name = "zipp"
version = "3.10.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "f387c917af52c11962400d12327a47dde4e322812f89078f6592ed4809432d7a" content-hash = "28f817d195ff20de983a656a216fbdaac203af52239ca31ed0bf9676eee92bb6"
[metadata.files] [metadata.files]
beautifulsoup4 = [] beautifulsoup4 = []
certifi = [] certifi = []
charset-normalizer = [] charset-normalizer = []
click = []
colorama = []
flask = []
idna = [] idna = []
importlib-metadata = []
itsdangerous = []
jinja2 = []
markupsafe = []
requests = [] requests = []
soupsieve = [] soupsieve = []
urllib3 = [] urllib3 = []
werkzeug = []
zipp = []

View file

@ -9,6 +9,7 @@ license = "MIT"
python = "^3.8" python = "^3.8"
requests = "^2.28.1" requests = "^2.28.1"
beautifulsoup4 = "^4.11.1" beautifulsoup4 = "^4.11.1"
Flask = "^2.2.2"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]