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/),
|
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).
|
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
|
## [0.3.0] - 2026-08-03
|
||||||
|
|
||||||
Prepared for public release as a HACS custom repository.
|
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
|
- `open_order_free_delivery_remaining` reaching `0.00` always coincides with a
|
||||||
`0.00` delivery fee
|
`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
|
## How it works
|
||||||
|
|
||||||
Fresh Harvest is not on Shopify, Farmigo, or Local Line — the page metadata
|
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()
|
return "sign out" in body.lower()
|
||||||
|
|
||||||
async def async_get_snapshot(self) -> AccountSnapshot:
|
async def async_fetch(self, path: str) -> str:
|
||||||
"""Fetch and parse the dashboard, re-authenticating once if needed."""
|
"""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:
|
if not self._authenticated:
|
||||||
await self.async_login()
|
await self.async_login()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await self._get(DASHBOARD)
|
body = await self._get(path)
|
||||||
if not self._signed_in(body):
|
if not self._signed_in(body):
|
||||||
self._authenticated = False
|
self._authenticated = False
|
||||||
await self.async_login()
|
await self.async_login()
|
||||||
body = await self._get(DASHBOARD)
|
body = await self._get(path)
|
||||||
if not self._signed_in(body):
|
if not self._signed_in(body):
|
||||||
raise FreshHarvestAuthError("could not hold a signed-in session")
|
raise FreshHarvestAuthError("could not hold a signed-in session")
|
||||||
except aiohttp.ClientError as err:
|
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