Add the write-action layer and a daily upstream compatibility check
Upstream compatibility / compat (push) Failing after 7s
Validate / hassfest (push) Failing after 25s
Validate / HACS (push) Failing after 17s
Validate / pytest (push) Successful in 9s

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.
This commit is contained in:
flan
2026-08-03 20:02:58 +00:00
parent 3e880ff2c1
commit 213c16d98e
6 changed files with 776 additions and 6 deletions
+106
View File
@@ -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"<!-- COMPAT:START -->\n_Last checked {stamp}._\n\n{matrix}\n<!-- COMPAT:END -->"
readme = pathlib.Path("README.md")
text = readme.read_text()
new = re.sub(r"<!-- COMPAT:START -->.*<!-- COMPAT:END -->", 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
+19
View File
@@ -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.
+37
View File
@@ -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.
<!-- COMPAT:START -->
_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 |
<!-- COMPAT:END -->
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
+384
View File
@@ -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))
+13 -6
View File
@@ -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))
+217
View File
@@ -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:]))