195 lines
6.8 KiB
Python
195 lines
6.8 KiB
Python
"""Campus laundry bookings (4ka.inbicst.ru) as time commitments.
|
|
|
|
The site has no API and only offers Yandex SSO, so authentication *is* the
|
|
session cookie you copy out of your browser — the approach jira-helper settled
|
|
on. On the booking grid every slot is a `div.period`, and the ones you booked
|
|
carry an extra `my` class, which is the only thing that makes "my laundry
|
|
today" findable.
|
|
|
|
These are not todos you tick off: they are blocks of time that will interrupt
|
|
your pomodoros, so the UI shows them on the timer page and the daily report
|
|
mentions them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, time
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
from bs4 import BeautifulSoup
|
|
|
|
from app.settings_schema import LaundrySourceSettings
|
|
from app.sources.base import SourceError, TodoItem
|
|
|
|
_PROFILE_PATH = "/profile/"
|
|
_GRID_PATH = "/"
|
|
_SESSION_COOKIE = "sessionid"
|
|
_COOKIE_PAIR_RE = re.compile(r"^[A-Za-z0-9_.\-]+=")
|
|
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–—]\s*(\d{1,2}):(\d{2})")
|
|
_USER_AGENT = "Mozilla/5.0 (compatible; kadence laundry reader)"
|
|
|
|
_EXPIRED_HINT = (
|
|
"the laundry session cookie is no longer valid. Sign in at {base_url} in "
|
|
"your browser, copy the fresh `sessionid` cookie and paste it into "
|
|
"Settings → Sources → Laundry."
|
|
)
|
|
|
|
|
|
def parse_cookie_header(raw: str) -> dict[str, str]:
|
|
"""Accept either a bare `sessionid` value or a whole copied cookie string."""
|
|
if not _COOKIE_PAIR_RE.match(raw):
|
|
return {_SESSION_COOKIE: raw}
|
|
cookies: dict[str, str] = {}
|
|
for part in raw.split(";"):
|
|
name, _, value = part.strip().partition("=")
|
|
if name.strip() and value.strip():
|
|
cookies[name.strip()] = value.strip()
|
|
return cookies
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class LaundrySlot:
|
|
location: str
|
|
machine: str
|
|
time_range: str
|
|
start: time | None
|
|
finished: bool
|
|
current: bool
|
|
refunded: bool
|
|
|
|
@property
|
|
def is_upcoming(self) -> bool:
|
|
return not self.finished and not self.refunded
|
|
|
|
|
|
class LaundrySource:
|
|
name = "laundry"
|
|
|
|
def __init__(self, config: LaundrySourceSettings, *, timeout: int = 30) -> None:
|
|
if not config.session_cookie:
|
|
raise SourceError("The laundry reader needs a `sessionid` cookie.")
|
|
cookies = parse_cookie_header(config.session_cookie)
|
|
if _SESSION_COOKIE not in cookies:
|
|
raise SourceError(
|
|
f"The laundry cookie has no {_SESSION_COOKIE!r} value in it."
|
|
)
|
|
self._base_url = config.base_url.rstrip("/")
|
|
host = urlsplit(self._base_url).hostname or ""
|
|
jar = httpx.Cookies()
|
|
for key, value in cookies.items():
|
|
jar.set(key, value, domain=host, path="/")
|
|
self._client = httpx.Client(
|
|
base_url=self._base_url,
|
|
cookies=jar,
|
|
headers={"User-Agent": _USER_AGENT},
|
|
timeout=timeout,
|
|
follow_redirects=False,
|
|
)
|
|
|
|
def _check_session(self) -> None:
|
|
"""An expired cookie silently serves the anonymous page, where none of
|
|
the slots are marked as yours — indistinguishable from "nothing booked".
|
|
So verify first."""
|
|
try:
|
|
resp = self._client.get(_PROFILE_PATH)
|
|
except httpx.HTTPError as exc:
|
|
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
|
|
if resp.status_code != 200:
|
|
raise SourceError(_EXPIRED_HINT.format(base_url=self._base_url))
|
|
|
|
def fetch(self) -> list[TodoItem]:
|
|
self._check_session()
|
|
try:
|
|
resp = self._client.get(_GRID_PATH)
|
|
except httpx.HTTPError as exc:
|
|
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
|
|
if not resp.is_success:
|
|
raise SourceError(f"Laundry grid -> {resp.status_code}")
|
|
|
|
_, slots = parse_my_slots(resp.text)
|
|
today = date.today()
|
|
return [
|
|
TodoItem(
|
|
source=self.name,
|
|
external_id=f"{today.isoformat()}:{slot.machine}:{slot.time_range}",
|
|
title=f"{slot.time_range} — {slot.machine} ({slot.location})",
|
|
project="Laundry",
|
|
status="running now" if slot.current else "booked",
|
|
due_date=today,
|
|
raw={
|
|
"start": slot.start.isoformat() if slot.start else None,
|
|
"current": slot.current,
|
|
},
|
|
)
|
|
for slot in slots
|
|
if slot.is_upcoming
|
|
]
|
|
|
|
def check(self) -> str:
|
|
self._check_session()
|
|
return f"signed in at {self._base_url}"
|
|
|
|
def complete(self, item: TodoItem) -> bool:
|
|
return False # the washing machine finishes on its own
|
|
|
|
def close(self) -> None:
|
|
self._client.close()
|
|
|
|
|
|
def _text(node: Any) -> str:
|
|
return node.get_text(strip=True) if node is not None else ""
|
|
|
|
|
|
def _parse_start(time_range: str) -> time | None:
|
|
match = _TIME_RANGE_RE.search(time_range)
|
|
if not match:
|
|
return None
|
|
hour, minute = int(match.group(1)), int(match.group(2))
|
|
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
|
return None
|
|
return time(hour, minute)
|
|
|
|
|
|
def parse_my_slots(html: str) -> tuple[str, list[LaundrySlot]]:
|
|
"""Pull your own slots off a booking grid. Returns `(day_label, slots)`."""
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
day = soup.select_one(".date-machines")
|
|
if day is None:
|
|
raise SourceError(
|
|
"No booking grid in the response — the layout changed, or the "
|
|
"session was not accepted."
|
|
)
|
|
day_label = (day.get("id") or "").strip()
|
|
|
|
slots: list[LaundrySlot] = []
|
|
for block in day.select(".machines"):
|
|
location = _text(block.select_one(".floor-title")) or "?"
|
|
for machine in block.select(".machine"):
|
|
machine_name = _text(machine.select_one(".machine-title")) or "?"
|
|
for period in machine.select(".period.my"):
|
|
classes = set(period.get("class") or [])
|
|
time_range = _text(period.find("div")) or "?"
|
|
slots.append(
|
|
LaundrySlot(
|
|
location=location,
|
|
machine=machine_name,
|
|
time_range=time_range,
|
|
start=_parse_start(time_range),
|
|
finished="finished" in classes,
|
|
current="current" in classes,
|
|
refunded="refunded" in classes,
|
|
)
|
|
)
|
|
|
|
slots.sort(key=lambda slot: (slot.start is None, slot.start or time.min, slot.machine))
|
|
return day_label, slots
|
|
|
|
|
|
def slot_is_now(slot: LaundrySlot, moment: datetime | None = None) -> bool:
|
|
"""Whether the machine is running at `moment` (used by the timer page)."""
|
|
return slot.current
|