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.
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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:]))
|
||||
Reference in New Issue
Block a user