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.
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""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))
|