diff --git a/circuit-laundry-notifier/__main__.py b/circuit-laundry-notifier/__main__.py new file mode 100644 index 0000000..2d5ca02 --- /dev/null +++ b/circuit-laundry-notifier/__main__.py @@ -0,0 +1,5 @@ +from web import new + +if __name__ == "__main__": + app = new() + app.run(port=8080, host="127.0.0.1") diff --git a/circuit-laundry-notifier/circuit_scraper.py b/circuit-laundry-notifier/circuit_scraper.py index 7dcb71a..1c503a0 100644 --- a/circuit-laundry-notifier/circuit_scraper.py +++ b/circuit-laundry-notifier/circuit_scraper.py @@ -6,6 +6,10 @@ import requests from bs4 import BeautifulSoup +class ScraperError(ValueError): + pass + + class MachineState(Enum): Available = "AVAIL" InUse = "IN_USE" @@ -25,6 +29,14 @@ class Machine: state: MachineState 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: _base_url: str = "https://www.circuit.co.uk/circuit-view/laundry-site" @@ -46,6 +58,10 @@ class CircuitScraper: r = requests.get(site_url) 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") machine_elements = [] @@ -66,7 +82,7 @@ class CircuitScraper: descriptor_text = states[0].get_text().lower() 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 # present, we fall back to that one. diff --git a/circuit-laundry-notifier/web.py b/circuit-laundry-notifier/web.py new file mode 100644 index 0000000..ef8a0c6 --- /dev/null +++ b/circuit-laundry-notifier/web.py @@ -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/", 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))) + )]) diff --git a/poetry.lock b/poetry.lock index b50e067..23aeaba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,6 +32,44 @@ python-versions = ">=3.6.0" [package.extras] 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]] name = "idna" version = "3.4" @@ -40,6 +78,52 @@ category = "main" optional = false 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]] name = "requests" 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"] 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] lock-version = "1.1" python-versions = "^3.8" -content-hash = "f387c917af52c11962400d12327a47dde4e322812f89078f6592ed4809432d7a" +content-hash = "28f817d195ff20de983a656a216fbdaac203af52239ca31ed0bf9676eee92bb6" [metadata.files] beautifulsoup4 = [] certifi = [] charset-normalizer = [] +click = [] +colorama = [] +flask = [] idna = [] +importlib-metadata = [] +itsdangerous = [] +jinja2 = [] +markupsafe = [] requests = [] soupsieve = [] urllib3 = [] +werkzeug = [] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml index c520b64..739ad06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ license = "MIT" python = "^3.8" requests = "^2.28.1" beautifulsoup4 = "^4.11.1" +Flask = "^2.2.2" [tool.poetry.dev-dependencies]