From 213c16d98e2c487060b98bd07b89ff953f1ea3bb Mon Sep 17 00:00:00 2001 From: flan Date: Mon, 3 Aug 2026 20:02:58 +0000 Subject: [PATCH] Add the write-action layer and a daily upstream compatibility check actions.py covers skip, donate, cart add/remove, subscriptions and vacation holds. Every mutating endpoint on the site is guarded by rotating per-render tokens, so each action re-derives them from a live page rather than storing anything; skip additionally compares the date the server states in its confirmation against the date it was asked to skip, and refuses on a mismatch. All actions default to dry_run. tools/compat.py records what the integration assumes about a site that offers no API and no stability contract, and CI asserts it daily. Only the unauthenticated surface is covered: checking the rest would mean putting a personal account password in public repo secrets. --- .github/workflows/compat.yml | 106 ++++++ CHANGELOG.md | 19 ++ README.md | 37 +++ custom_components/freshharvest/actions.py | 384 ++++++++++++++++++++++ custom_components/freshharvest/api.py | 19 +- tools/compat.py | 217 ++++++++++++ 6 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/compat.yml create mode 100644 custom_components/freshharvest/actions.py create mode 100644 tools/compat.py diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 0000000..3021714 --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,106 @@ +name: Upstream compatibility + +# freshharvest.com is a moving target with no API and no stability contract. This +# integration reads HTML and posts to form endpoints, so a redesign can change +# what a value MEANS without changing its shape — a sensor quietly reporting last +# week's total is worse than one that goes unavailable. +# +# tools/compat.py records what the integration assumes and asserts it against the +# live site daily, refreshing the matrix in README.md and opening an issue when +# something breaks. +# +# SCOPE: unauthenticated surface only. The authenticated contract (dashboard +# markup, cart hashes, skip popups, subscribe forms) needs a real session, and +# the only way to give public CI one is to park a personal grocery account's +# password in repo secrets. Not worth it for a drift check — that half belongs in +# a fleet job on a host that already holds credentials. + +on: + schedule: + - cron: "23 7 * * *" # daily, off the hour + workflow_dispatch: + push: + paths: + # The manifest of assumptions changed — re-check now, not tomorrow. + - "tools/compat.py" + - ".github/workflows/compat.yml" + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + compat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Check assumptions against the live site + id: check + run: | + set +e + python tools/compat.py --markdown > matrix.md + echo "failures=$?" >> "$GITHUB_OUTPUT" + cat matrix.md + + - name: Refresh the matrix in README + run: | + python - <<'PY' + import pathlib, re, datetime + matrix = pathlib.Path("matrix.md").read_text().strip() + stamp = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d") + block = f"\n_Last checked {stamp}._\n\n{matrix}\n" + readme = pathlib.Path("README.md") + text = readme.read_text() + new = re.sub(r".*", block, text, flags=re.S) + if new != text: + readme.write_text(new) + print("README matrix updated") + else: + print("no change") + PY + + - name: Commit the refreshed matrix + run: | + if git diff --quiet README.md; then + echo "nothing to commit"; exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "Refresh the upstream compatibility matrix" + # GitHub is a MIRROR of Gitea, never a source of truth, so this must not + # push. It opens a PR instead; merge it on the canonical forge. + BRANCH="compat/refresh-$(date -u +%Y%m%d)" + git checkout -b "$BRANCH" + git push -f origin "$BRANCH" + gh pr list --head "$BRANCH" --state open --json number -q '.[0].number' | grep -q . \ + || gh pr create --head "$BRANCH" --title "Refresh the upstream compatibility matrix" \ + --body "Automated: freshharvest.com assumption check. See the matrix in README." + env: + GH_TOKEN: ${{ github.token }} + + - name: Open an issue when the site has drifted + if: steps.check.outputs.failures != '0' + run: | + TITLE="Upstream drift: freshharvest.com no longer matches our assumptions" + EXISTING=$(gh issue list --state open --search "$TITLE" --json number -q '.[0].number') + BODY=$'The daily compatibility check failed. The integration is likely reporting stale or wrong values.\n\n'"$(cat matrix.md)" + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --body "$BODY" + else + gh issue create --title "$TITLE" --body "$BODY" --label upstream + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Fail the run on drift + if: steps.check.outputs.failures != '0' + run: | + echo "${{ steps.check.outputs.failures }} assumption(s) no longer hold" + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d42597..9f83918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `actions.py`: the write layer — skip a delivery, donate a box, add/remove cart + items, subscribe/unsubscribe with a frequency, and add a vacation hold over a + date range (distinct from skipping: three weeks away is one hold, not three + skips). Parsers for current subscriptions and scheduled holds. +- `tools/compat.py` plus a daily `compat.yml` workflow: records every assumption + this integration makes about freshharvest.com and asserts it against the live + site, refreshing the matrix in README and opening an issue on drift. +- `FreshHarvestClient.async_fetch`, restored as the shared authenticated-fetch + primitive that both the snapshot read and every write action build on. + +### Notes + +- Actions default to `dry_run=True` and report exactly what they would submit. + Nothing has been executed against a live account yet. + ## [0.3.0] - 2026-08-03 Prepared for public release as a HACS custom repository. diff --git a/README.md b/README.md index a6cb023..7ea9ae2 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,43 @@ Two invariants hold against the portal's own arithmetic, and tests assert both: - `open_order_free_delivery_remaining` reaching `0.00` always coincides with a `0.00` delivery fee +## Upstream compatibility + +freshharvest.com has no API and no stability contract — this integration reads +HTML and posts to form endpoints, so a redesign can change what a value *means* +without changing its shape. [tools/compat.py](tools/compat.py) records every +assumption and CI asserts them against the live site daily, refreshing this +table and opening an issue on drift. + + +_Last checked 2026-08-03._ + +| Area | Assumption | Status | Detail | +| --- | --- | --- | --- | +| Login | `/s/popup/login` serves the form | ✅ | 2273 bytes | +| Login | hidden `LoginSecurity` is minted | ✅ | 154 chars | +| Login | hidden `SubmitToken` is minted | ✅ | 174 chars | +| Login | posts to `/s/submit/login` | ✅ | /s/submit/login | +| Login | field `LoginEmail` present | ✅ | | +| Login | field `LoginPassword` present | ✅ | | +| Catalogue | Algolia credentials readable from site JS | ✅ | app id + search key found | +| Catalogue | index name readable | ✅ | dev_FullTest | +| Catalogue | index returns a plausible catalogue | ✅ | 946 records | +| Catalogue | record field `ID` | ✅ | present | +| Catalogue | record field `Name` | ✅ | present | +| Catalogue | record field `Price` | ✅ | present | +| Catalogue | record field `Measurement` | ✅ | present | +| Catalogue | record field `Categories` | ✅ | present | +| Endpoints | cart add/remove URL shape unchanged | ✅ | /p/Ajax/order-manage/ | +| Endpoints | popup route is `/x/popup/{type}/{token}` | ✅ | found | + + +Only the unauthenticated surface is checked here. The authenticated contract — +dashboard markup, cart add hashes, skip popups, subscribe forms — needs a real +session, and the only way to give public CI one is to put a personal grocery +account's password in repo secrets. That belongs in a job on a host that already +has credential access, not here. + ## How it works Fresh Harvest is not on Shopify, Farmigo, or Local Line — the page metadata diff --git a/custom_components/freshharvest/actions.py b/custom_components/freshharvest/actions.py new file mode 100644 index 0000000..c585f6c --- /dev/null +++ b/custom_components/freshharvest/actions.py @@ -0,0 +1,384 @@ +"""Write actions against the Fresh Harvest portal. + +Every mutating endpoint on this site is guarded by rotating per-render tokens — +an item's add hash, a skip reason, a subscribe form's ClientID/ItemID. None of +them can be constructed offline, so each action here follows the same shape: + + fetch the page that offers the action + -> read the fresh tokens out of it + -> check the tokens describe the thing we meant to act on + -> submit + +That last step matters. The portal states which delivery a skip applies to in +the confirmation text, so we compare it against the date we were asked to skip +and refuse on a mismatch rather than trusting our own bookkeeping. + +Actions default to `dry_run=True`: they do all the work and report exactly what +they would submit, without submitting. Callers must opt in to the real thing. +""" + +from __future__ import annotations + +import logging +import re +import time +from dataclasses import dataclass, field +from datetime import date + +from bs4 import BeautifulSoup + +from yarl import URL + +from .api import BASE, USER_AGENT, FreshHarvestClient, FreshHarvestError + +_LOGGER = logging.getLogger(__name__) + +DASHBOARD_ORDERS = "/p/dashboard/manage-orders" +DASHBOARD_SUBS = "/p/dashboard/manage-subscriptions" +DASHBOARD_PAUSE = "/p/dashboard/pause-deliveries" +SHOP_ITEM = "/p/shop/item/{item_id}/x" + +SUBMIT_SKIP = "/s/submit/pause-delivery" +SUBMIT_DONATE = "/s/submit/donate-basket" +SUBMIT_SUBSCRIBE = "/s/submit/item-frequency" +SUBMIT_HOLD = "/s/submit/pause-range-add" +AJAX_ORDER_MANAGE = "/p/Ajax/order-manage/{mode}/{hash}/-/false/{ts}" + +# id='FrequencyID' but name='popup-toggle' — the id is a decoy, the POST field +# is popup-toggle. Submitting FrequencyID silently does nothing. +FREQUENCY_FIELD = "popup-toggle" +FREQUENCIES = {"weekly": "1", "2 weeks": "4", "3 weeks": "3", "4 weeks": "5"} + +_MONTHS = ( + "January February March April May June July August September October " + "November December" +).split() + + +class FreshHarvestActionError(FreshHarvestError): + """An action could not be performed safely.""" + + +@dataclass +class Subscription: + """A standing order: this item, this often.""" + + name: str + quantity: int | None = None + frequency: str | None = None + partner: str | None = None + arriving: str | None = None + + +@dataclass +class VacationHold: + """A paused date range.""" + + start: str + end: str + raw: str = "" + + +@dataclass +class ActionResult: + """What an action did, or would have done.""" + + action: str + ok: bool + detail: str + dry_run: bool = False + target: str | None = None + submitted: dict[str, str] = field(default_factory=dict) + + def redacted(self) -> dict[str, str]: + """Field names and value lengths only — the values are auth tokens.""" + return {k: f"<{len(v)} chars>" if len(v) > 24 else v + for k, v in self.submitted.items()} + + +def _hidden_fields(form) -> dict[str, str]: + return { + i.get("name"): i.get("value", "") + for i in form.select("input[type=hidden]") + if i.get("name") + } + + +def _find_form(soup: BeautifulSoup, action: str): + for form in soup.select("form"): + if (form.get("action") or "").endswith(action): + return form + return None + + +def parse_subscriptions(html: str) -> list[Subscription]: + """Read /p/dashboard/manage-subscriptions.""" + soup = BeautifulSoup(html, "html.parser") + account = soup.select_one(".account") + if account is None: + return [] + subs: list[Subscription] = [] + for row in account.select(".account-item-multi-fields"): + cells = [c.get_text(" ", strip=True) for c in row.select(".account-item-text")] + cells = [c for c in cells if c] + if len(cells) < 2: + continue + qty = cells[0] + subs.append( + Subscription( + name=cells[1], + quantity=int(qty) if qty.isdigit() else None, + arriving=cells[2] if len(cells) > 2 else None, + partner=cells[3] if len(cells) > 3 else None, + frequency=cells[4] if len(cells) > 4 else None, + ) + ) + return subs + + +def parse_vacation_holds(html: str) -> list[VacationHold]: + """Read the scheduled pauses off /p/dashboard/pause-deliveries.""" + soup = BeautifulSoup(html, "html.parser") + holds: list[VacationHold] = [] + for row in soup.select(".account-item-multi-fields, .account-item-container"): + text = row.get_text(" ", strip=True) + found = re.findall(r"\d{4}-\d{2}-\d{2}", text) + if len(found) >= 2: + holds.append(VacationHold(start=found[0], end=found[1], raw=text)) + return holds + + +def _confirmation_date(text: str) -> date | None: + """Pull 'scheduled for August 11' out of the skip confirmation.""" + m = re.search(r"scheduled for\s+([A-Za-z]+)\s+(\d{1,2})", text) + if not m or m.group(1) not in _MONTHS: + return None + month = _MONTHS.index(m.group(1)) + 1 + day = int(m.group(2)) + today = date.today() + year = today.year + (1 if month < today.month - 6 else 0) + try: + return date(year, month, day) + except ValueError: + return None + + +class FreshHarvestActions: + """Mutating operations, each re-deriving its tokens from a live page.""" + + def __init__(self, client: FreshHarvestClient) -> None: + self._client = client + + async def _post(self, path: str, payload: dict[str, str]) -> str: + session = self._client._session # noqa: SLF001 — same package + async with session.post( + BASE.join(URL(path)), + data=payload, + headers={"User-Agent": USER_AGENT}, + ) as resp: + resp.raise_for_status() + return await resp.text() + + # ------------------------------------------------------------------ skip + + async def async_skip( + self, delivery_date: date, reason: str = "", dry_run: bool = True + ) -> ActionResult: + """Skip one delivery. + + The portal only renders a skip token for deliveries that are still + changeable, so a locked order simply has no token — there is nothing to + submit and this raises rather than inventing one. + """ + page = await self._client.async_fetch(DASHBOARD_ORDERS) + tokens = re.findall(r'openPopup\("pause-delivery","([^"]+)"', page) + if not tokens: + raise FreshHarvestActionError("no skippable delivery on this account") + + for token in dict.fromkeys(tokens): + popup = await self._client.async_fetch(f"/x/popup/pause-delivery/{token}") + form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_SKIP) + if form is None: + # Several tokens on the page are for other popups and fall + # through to the shop page; skip them rather than guessing. + continue + soup = BeautifulSoup(popup, "html.parser") + stated = _confirmation_date(soup.get_text(" ", strip=True)) + if stated != delivery_date: + continue + + payload = _hidden_fields(form) + options = [ + (o.get("value"), o.get_text(strip=True)) + for o in form.select("option") + if o.get("value") + ] + if not options: + raise FreshHarvestActionError("skip form has no reasons") + chosen = next( + (v for v, label in options if reason.lower() in label.lower()), + options[0][0], + ) if reason else options[0][0] + payload["SkipReason"] = chosen + payload["Continue"] = "Confirm" + + result = ActionResult( + action="skip", + ok=True, + target=delivery_date.isoformat(), + submitted=payload, + dry_run=dry_run, + detail=f"skip {delivery_date} (server confirmed this date)", + ) + if dry_run: + return result + await self._post(SUBMIT_SKIP, payload) + return result + + raise FreshHarvestActionError( + f"no skip token matched {delivery_date} — it is probably past its " + "cutoff and locked for packing" + ) + + # ---------------------------------------------------------------- donate + + async def async_donate(self, dry_run: bool = True) -> ActionResult: + """Donate the upcoming box. One-way — there is no undo in the UI.""" + page = await self._client.async_fetch(DASHBOARD_ORDERS) + m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page) + if not m: + raise FreshHarvestActionError("no donatable delivery") + popup = await self._client.async_fetch(f"/x/popup/donate-delivery/{m.group(1)}") + form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_DONATE) + if form is None: + raise FreshHarvestActionError("donate form not found") + payload = _hidden_fields(form) | {"Continue": "Confirm"} + result = ActionResult( + action="donate", ok=True, submitted=payload, dry_run=dry_run, + detail="donate the upcoming box (not reversible)", + ) + if not dry_run: + await self._post(SUBMIT_DONATE, payload) + return result + + # ------------------------------------------------------------ cart items + + async def _item_page(self, item_id: int | str) -> str: + return await self._client.async_fetch(SHOP_ITEM.format(item_id=item_id)) + + async def async_add_item( + self, item_id: int | str, dry_run: bool = True + ) -> ActionResult: + """Add one of an item to the open order. + + The add hash only exists when the item is actually orderable, so its + absence *is* the out-of-stock signal — no separate stock lookup can go + stale behind our back. + """ + return await self._cart_action("add", item_id, dry_run) + + async def async_remove_item( + self, item_id: int | str, dry_run: bool = True + ) -> ActionResult: + return await self._cart_action("remove", item_id, dry_run) + + async def _cart_action(self, mode: str, item_id, dry_run: bool) -> ActionResult: + page = await self._item_page(item_id) + m = re.search(r'orderManage\("%s","([^"]+)"' % mode, page) + if not m: + raise FreshHarvestActionError( + f"item {item_id} cannot be {mode}ed right now — the page offers " + "no control for it, which usually means it is out of stock" + ) + name = BeautifulSoup(page, "html.parser").select_one(".item-name") + url = AJAX_ORDER_MANAGE.format( + mode=mode, hash=m.group(1), ts=int(time.time() * 1000) + ) + result = ActionResult( + action=f"{mode}_item", + ok=True, + target=name.get_text(" ", strip=True) if name else str(item_id), + submitted={"url": url}, + dry_run=dry_run, + detail=f"{mode} item {item_id}", + ) + if not dry_run: + # A fragment, not a page: no Sign Out control to detect, so bypass + # the signed-in check. The item fetch above already renewed the session. + await self._client._get(url) # noqa: SLF001 — same package + return result + + # --------------------------------------------------------- subscriptions + + async def async_subscribe( + self, + item_id: int | str, + frequency: str = "weekly", + quantity: int = 1, + dry_run: bool = True, + ) -> ActionResult: + """Subscribe to an item, or change its quantity/frequency. + + Quantity 0 unsubscribes — the same endpoint serves all three. + """ + freq = FREQUENCIES.get(frequency.lower().strip()) + if freq is None: + raise FreshHarvestActionError( + f"unknown frequency {frequency!r}; expected one of " + + ", ".join(FREQUENCIES) + ) + page = await self._item_page(item_id) + form = _find_form(BeautifulSoup(page, "html.parser"), SUBMIT_SUBSCRIBE) + if form is None: + raise FreshHarvestActionError(f"item {item_id} is not subscribable") + payload = _hidden_fields(form) + payload["Quantity"] = str(quantity) + payload[FREQUENCY_FIELD] = freq + payload["Submit"] = "Confirm" + result = ActionResult( + action="unsubscribe" if quantity == 0 else "subscribe", + ok=True, + target=str(item_id), + submitted=payload, + dry_run=dry_run, + detail=f"{quantity} x item {item_id} every {frequency}", + ) + if not dry_run: + await self._post(SUBMIT_SUBSCRIBE, payload) + return result + + async def async_list_subscriptions(self) -> list[Subscription]: + return parse_subscriptions(await self._client.async_fetch(DASHBOARD_SUBS)) + + # -------------------------------------------------------- vacation holds + + async def async_add_vacation_hold( + self, start: date, end: date, dry_run: bool = True + ) -> ActionResult: + """Pause every delivery in a date range. + + Distinct from skipping: three weeks away is one hold, not three skips. + """ + if end < start: + raise FreshHarvestActionError("end date is before start date") + page = await self._client.async_fetch(DASHBOARD_PAUSE) + form = _find_form(BeautifulSoup(page, "html.parser"), SUBMIT_HOLD) + if form is None: + raise FreshHarvestActionError("vacation hold form not found") + payload = _hidden_fields(form) + payload["StartDate"] = start.isoformat() + payload["EndDate"] = end.isoformat() + result = ActionResult( + action="vacation_hold", + ok=True, + target=f"{start.isoformat()}..{end.isoformat()}", + submitted=payload, + dry_run=dry_run, + detail=f"pause deliveries {start} to {end}", + ) + if not dry_run: + await self._post(SUBMIT_HOLD, payload) + return result + + async def async_list_vacation_holds(self) -> list[VacationHold]: + return parse_vacation_holds(await self._client.async_fetch(DASHBOARD_PAUSE)) diff --git a/custom_components/freshharvest/api.py b/custom_components/freshharvest/api.py index 0fc25ff..0ba37dd 100644 --- a/custom_components/freshharvest/api.py +++ b/custom_components/freshharvest/api.py @@ -362,20 +362,27 @@ class FreshHarvestClient: """ return "sign out" in body.lower() - async def async_get_snapshot(self) -> AccountSnapshot: - """Fetch and parse the dashboard, re-authenticating once if needed.""" + async def async_fetch(self, path: str) -> str: + """Fetch any portal page, re-authenticating once if the session lapsed. + + The shared primitive for reads and for the token-scraping that every + write action in `actions.py` has to do first. + """ if not self._authenticated: await self.async_login() try: - body = await self._get(DASHBOARD) + body = await self._get(path) if not self._signed_in(body): self._authenticated = False await self.async_login() - body = await self._get(DASHBOARD) + body = await self._get(path) if not self._signed_in(body): raise FreshHarvestAuthError("could not hold a signed-in session") except aiohttp.ClientError as err: - raise FreshHarvestError(f"dashboard request failed: {err}") from err + raise FreshHarvestError(f"request for {path} failed: {err}") from err + return body - return parse_dashboard(body) + async def async_get_snapshot(self) -> AccountSnapshot: + """Fetch and parse the dashboard.""" + return parse_dashboard(await self.async_fetch(DASHBOARD)) diff --git a/tools/compat.py b/tools/compat.py new file mode 100644 index 0000000..16e0457 --- /dev/null +++ b/tools/compat.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Check what this integration assumes about freshharvest.com against the live site. + +There is no API and no stability contract here — the integration reads HTML and +posts to form endpoints, and any redesign can silently change the meaning of a +value rather than breaking loudly. A sensor that quietly reports last week's +total is worse than one that goes unavailable, so this asserts the contract on a +schedule and fails when the site moves. + +WHAT THIS CAN AND CANNOT SEE +---------------------------- +Only the *unauthenticated* surface is checked here: the login handshake and the +Algolia catalogue. The authenticated contract — dashboard markup, cart add +hashes, skip popups, subscribe forms — needs a real session, and the only way to +give public CI one is to put a personal grocery account's password in repo +secrets. That is not worth it for a drift check. Those assumptions belong in a +fleet job on a host that already has credential access; see README. + +Exit code is the number of FAILED checks, so CI fails loudly on drift. +""" + +from __future__ import annotations + +import json +import re +import sys +import urllib.error +import urllib.request + +BASE = "https://freshharvest.com" +UA = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0 Safari/537.36" +) + +OK, FAIL, WARN = "ok", "FAIL", "warn" + + +def fetch(url: str, data: bytes | None = None, headers: dict | None = None) -> str: + req = urllib.request.Request( + url, data=data, headers={"User-Agent": UA, **(headers or {})} + ) + with urllib.request.urlopen(req, timeout=45) as resp: + return resp.read().decode("utf-8", "replace") + + +class Checks: + """Each check returns (status, detail). Assumption text mirrors the code.""" + + def __init__(self) -> None: + self.results: list[tuple[str, str, str, str]] = [] + + def record(self, area, assumption, status, detail): + self.results.append((area, assumption, status, detail)) + + # -- login handshake ---------------------------------------------------- + + def login_form(self): + area = "Login" + try: + html = fetch(f"{BASE}/s/popup/login") + except urllib.error.URLError as err: + self.record(area, "login popup reachable", FAIL, str(err)) + return + self.record(area, "`/s/popup/login` serves the form", OK, f"{len(html)} bytes") + + for field in ("LoginSecurity", "SubmitToken"): + found = re.search(rf"name='{field}'[^>]*value='([^']+)'", html) + self.record( + area, + f"hidden `{field}` is minted", + OK if found else FAIL, + f"{len(found.group(1))} chars" if found else "absent — login will break", + ) + act = re.search(r"action='([^']*submit/login)'", html) + self.record( + area, + "posts to `/s/submit/login`", + OK if act else FAIL, + act.group(1) if act else "form action changed", + ) + for field in ("LoginEmail", "LoginPassword"): + self.record( + area, + f"field `{field}` present", + OK if f"name='{field}'" in html else FAIL, + "", + ) + + # -- catalogue ---------------------------------------------------------- + + def algolia(self): + area = "Catalogue" + try: + js = fetch(f"{BASE}/_home/JavaScript/search_algolia.js") + except urllib.error.URLError as err: + self.record(area, "search_algolia.js reachable", FAIL, str(err)) + return + + creds = re.search( + r'algoliasearch\(\s*"([^"]+)"\s*,\s*"([^"]+)"', js, re.S + ) + index = re.search(r'indexName:\s*"([^"]+)"', js) + self.record( + area, + "Algolia credentials readable from site JS", + OK if creds else FAIL, + "app id + search key found" if creds else "pattern changed", + ) + self.record( + area, + "index name readable", + OK if index else FAIL, + index.group(1) if index else "not found", + ) + if not (creds and index): + return + + app, key = creds.groups() + try: + body = fetch( + f"https://{app}-dsn.algolia.net/1/indexes/{index.group(1)}/query", + data=json.dumps({"params": "query=&hitsPerPage=1"}).encode(), + headers={ + "X-Algolia-API-Key": key, + "X-Algolia-Application-Id": app, + "Content-Type": "application/json", + }, + ) + except urllib.error.URLError as err: + self.record(area, "index queryable", FAIL, str(err)) + return + + data = json.loads(body) + total = data.get("nbHits", 0) + # A collapse to near-zero means the index moved or emptied; the exact + # count drifts constantly as stock changes, so only the floor is checked. + self.record( + area, + "index returns a plausible catalogue", + OK if total > 100 else FAIL, + f"{total} records", + ) + hit = (data.get("hits") or [{}])[0] + for fieldname in ("ID", "Name", "Price", "Measurement", "Categories"): + self.record( + area, + f"record field `{fieldname}`", + OK if fieldname in hit else FAIL, + "present" if fieldname in hit else "MISSING — parser reads this", + ) + + # -- action endpoints --------------------------------------------------- + + def endpoints(self): + """The write endpoints must still exist; they are never *called* here.""" + area = "Endpoints" + try: + js = fetch(f"{BASE}/_home/Ajax/order-manage.js") + except urllib.error.URLError as err: + self.record(area, "order-manage.js reachable", FAIL, str(err)) + return + pattern = "/p/Ajax/order-manage/" + self.record( + area, + "cart add/remove URL shape unchanged", + OK if pattern in js else FAIL, + pattern if pattern in js else "URL construction changed", + ) + try: + popups = fetch(f"{BASE}/_home/JavaScript/popups.js") + except urllib.error.URLError as err: + self.record(area, "popups.js reachable", FAIL, str(err)) + return + self.record( + area, + "popup route is `/x/popup/{type}/{token}`", + OK if "/x/popup/" in popups else FAIL, + "found" if "/x/popup/" in popups else "route changed", + ) + + def run(self): + self.login_form() + self.algolia() + self.endpoints() + return self.results + + +def render(results) -> str: + lines = [ + "| Area | Assumption | Status | Detail |", + "| --- | --- | --- | --- |", + ] + icon = {OK: "✅", FAIL: "❌", WARN: "⚠️"} + for area, assumption, status, detail in results: + lines.append(f"| {area} | {assumption} | {icon[status]} | {detail} |") + return "\n".join(lines) + + +def main(argv): + results = Checks().run() + table = render(results) + failed = [r for r in results if r[2] == FAIL] + + if "--markdown" in argv: + print(table) + else: + print(table) + print() + print(f"{len(results) - len(failed)}/{len(results)} checks passed") + for area, assumption, _, detail in failed: + print(f" FAIL {area}: {assumption} — {detail}") + return len(failed) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))