mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 04:13:12 +00:00
feat(cart): add a local Redefine cart filler (#220)
* feat(cart): add a local Redefine cart filler Match the admin order-list CSV to the live menu and add each meal to a guest cart so the weekly bulk order does not have to be typed in by hand. * fix(cart): report cart failures without a traceback A closed window or a failed cart request during the fill now prints a short failure and the Added/Failed/Skipped summary instead of crashing. * fix(cart): fail cleanly on a bad CSV and a closed browser A non-UTF-8 order list and a Playwright error while opening the page now print a short message and exit 1 instead of a traceback. * fix(cart): account for a closed window and an empty menu A closed page during add marks the remaining meals as not attempted, and an empty menu catalog raises the same error as the menu parser.
This commit is contained in:
parent
cc506f3c1f
commit
bf1f3ea182
5 changed files with 762 additions and 3 deletions
271
scripts/fill_redefine_cart.py
Normal file
271
scripts/fill_redefine_cart.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""Fill a Redefine Meals guest cart from an admin order-list CSV.
|
||||||
|
|
||||||
|
Opens a visible browser, adds each matched meal, then stops on the cart page.
|
||||||
|
Log in and check out in that window. Press Enter when finished, or close the
|
||||||
|
window. The script does not store a password and does not open checkout itself.
|
||||||
|
|
||||||
|
python3 scripts/fill_redefine_cart.py order-list-2026-W39.csv
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
from playwright.sync_api import Error as PlaywrightError # noqa: E402
|
||||||
|
from playwright.sync_api import sync_playwright # noqa: E402
|
||||||
|
|
||||||
|
from scraper.fill_cart import ( # noqa: E402
|
||||||
|
FillCartError,
|
||||||
|
FillPlan,
|
||||||
|
build_plan,
|
||||||
|
cart_lines,
|
||||||
|
error_text,
|
||||||
|
quantity_for,
|
||||||
|
)
|
||||||
|
from scraper.parse_menu import ( # noqa: E402
|
||||||
|
MenuParseError,
|
||||||
|
extract_catalog_products,
|
||||||
|
fetch_menu_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_MENU_URL = "https://www.redefinemeals.com/menu"
|
||||||
|
_CART_JS = """
|
||||||
|
async ({ method, path, body }) => {
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
};
|
||||||
|
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
|
||||||
|
if (match) {
|
||||||
|
headers["X-XSRF-TOKEN"] = decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
const init = { method, credentials: "same-origin", headers };
|
||||||
|
if (body !== null && body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const response = await fetch(path, init);
|
||||||
|
const text = await response.text();
|
||||||
|
let data = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
data = { message: text.slice(0, 200) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: response.ok, status: response.status, data };
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
sys.stderr.reconfigure(line_buffering=True)
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("csv_path", type=Path, help="Admin order-list CSV")
|
||||||
|
parser.add_argument("--menu-url", default=DEFAULT_MENU_URL)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
csv_text = args.csv_path.read_text(encoding="utf-8-sig")
|
||||||
|
menu_html = fetch_menu_html(args.menu_url)
|
||||||
|
products = extract_catalog_products(menu_html)
|
||||||
|
plan = build_plan(csv_text, products)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
print("CSV must be UTF-8.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except (OSError, FillCartError, MenuParseError) as exc:
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
_print_plan(plan)
|
||||||
|
if not plan.adds:
|
||||||
|
print("Nothing matched. Not opening a browser.")
|
||||||
|
return 1 if plan.skipped else 0
|
||||||
|
|
||||||
|
return _fill(plan, args.menu_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill(plan: FillPlan, menu_url: str) -> int:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
try:
|
||||||
|
browser = playwright.chromium.launch(headless=False)
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
print(f"Browser failed: {exc}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"Install the browser with: playwright install chromium",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
return _fill_browser(browser, plan, menu_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_browser(browser, plan: FillPlan, menu_url: str) -> int:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
page = browser.new_page()
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
print(f"Failed: {exc}")
|
||||||
|
print(f"Added 0. Failed 1. Skipped {len(plan.skipped)}.")
|
||||||
|
return 1
|
||||||
|
return _fill_page(page, plan, menu_url)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
except PlaywrightError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_page(page, plan: FillPlan, menu_url: str) -> int:
|
||||||
|
failed: list[str] = []
|
||||||
|
added = 0
|
||||||
|
ready = False
|
||||||
|
try:
|
||||||
|
page.goto(menu_url, wait_until="domcontentloaded", timeout=60000)
|
||||||
|
cart = _cart_request(page, "GET", "/api/cart")
|
||||||
|
existing = cart_lines(cart)
|
||||||
|
if existing:
|
||||||
|
print("The cart already has items:")
|
||||||
|
for line in existing:
|
||||||
|
print(f" {line.name} x{line.quantity}")
|
||||||
|
if not _confirm_clear():
|
||||||
|
print("Exiting without adding.")
|
||||||
|
return 1
|
||||||
|
cleared = _cart_request(page, "POST", "/api/cart/clear", {})
|
||||||
|
if cart_lines(cleared):
|
||||||
|
print("The cart still has items after clear. Exiting without adding.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
posted = []
|
||||||
|
page_closed = False
|
||||||
|
for index, item in enumerate(plan.adds):
|
||||||
|
try:
|
||||||
|
_cart_request(
|
||||||
|
page,
|
||||||
|
"POST",
|
||||||
|
"/api/cart/add",
|
||||||
|
{
|
||||||
|
"uuid": item.uuid,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"properties": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
failed.append(f"{item.name}: {exc}")
|
||||||
|
print(f"Failed: {item.name}: {exc}")
|
||||||
|
continue
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
failed.append(f"{item.name}: {exc}")
|
||||||
|
print(f"Failed: {item.name}: {exc}")
|
||||||
|
for rest in plan.adds[index + 1 :]:
|
||||||
|
message = f"{rest.name}: not attempted"
|
||||||
|
failed.append(message)
|
||||||
|
print(f"Failed: {message}")
|
||||||
|
page_closed = True
|
||||||
|
break
|
||||||
|
posted.append(item)
|
||||||
|
|
||||||
|
if not page_closed:
|
||||||
|
try:
|
||||||
|
current = cart_lines(_cart_request(page, "GET", "/api/cart"))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
failed.append(f"Could not read the cart: {exc}")
|
||||||
|
print(f"Failed: Could not read the cart: {exc}")
|
||||||
|
else:
|
||||||
|
for item in posted:
|
||||||
|
found = quantity_for(current, item.uuid)
|
||||||
|
if found != item.quantity:
|
||||||
|
message = (
|
||||||
|
f"{item.name}: requested {item.quantity}, cart has {found}"
|
||||||
|
)
|
||||||
|
failed.append(message)
|
||||||
|
print(f"Failed: {message}")
|
||||||
|
else:
|
||||||
|
added += 1
|
||||||
|
origin = _origin(page.url or menu_url)
|
||||||
|
page.goto(f"{origin}/cart", wait_until="domcontentloaded", timeout=60000)
|
||||||
|
ready = True
|
||||||
|
except (RuntimeError, PlaywrightError) as exc:
|
||||||
|
failed.append(str(exc))
|
||||||
|
print(f"Failed: {exc}")
|
||||||
|
print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.")
|
||||||
|
if ready:
|
||||||
|
_wait_for_review(page)
|
||||||
|
return 1 if failed or plan.skipped else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cart_request(page, method: str, path: str, body: object | None = None) -> object:
|
||||||
|
result = page.evaluate(_CART_JS, {"method": method, "path": path, "body": body})
|
||||||
|
if not isinstance(result, dict) or not result.get("ok"):
|
||||||
|
status = result.get("status") if isinstance(result, dict) else 0
|
||||||
|
data = result.get("data") if isinstance(result, dict) else None
|
||||||
|
raise RuntimeError(error_text(int(status or 0), data))
|
||||||
|
return result.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_clear() -> bool:
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
print("Refusing to clear a cart without a typed yes.")
|
||||||
|
return False
|
||||||
|
answer = input("Type yes to clear the cart and add this order: ")
|
||||||
|
return answer.strip().casefold() == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_review(page) -> None:
|
||||||
|
print(
|
||||||
|
"Cart is ready. Log in and check out in this browser window.\n"
|
||||||
|
"Press Enter here after you are done. Closing the window also stops the script."
|
||||||
|
)
|
||||||
|
finished = threading.Event()
|
||||||
|
|
||||||
|
def _wait_for_enter() -> None:
|
||||||
|
try:
|
||||||
|
input()
|
||||||
|
except EOFError:
|
||||||
|
pass
|
||||||
|
finished.set()
|
||||||
|
|
||||||
|
if sys.stdin.isatty():
|
||||||
|
threading.Thread(target=_wait_for_enter, daemon=True).start()
|
||||||
|
|
||||||
|
while not finished.is_set():
|
||||||
|
try:
|
||||||
|
closed = page.is_closed()
|
||||||
|
except PlaywrightError:
|
||||||
|
return
|
||||||
|
if closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
page.wait_for_timeout(250)
|
||||||
|
except PlaywrightError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _print_plan(plan: FillPlan) -> None:
|
||||||
|
if plan.ignored_zero:
|
||||||
|
print(f"Ignored {plan.ignored_zero} items with quantity 0.")
|
||||||
|
if plan.adds:
|
||||||
|
print("Adding:")
|
||||||
|
for item in plan.adds:
|
||||||
|
print(f" {item.name} x{item.quantity}")
|
||||||
|
if plan.skipped:
|
||||||
|
print("Skipped:")
|
||||||
|
for line in plan.skipped:
|
||||||
|
print(f" {line.name}: {line.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(url: str) -> str:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
return f"{parts.scheme}://{parts.netloc}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
203
src/scraper/fill_cart.py
Normal file
203
src/scraper/fill_cart.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""Match an admin order-list CSV to the Redefine menu catalog.
|
||||||
|
|
||||||
|
The browser script uses this plan. Nothing here contacts the site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
_FORMULA_PREFIX = set("=+-@\t\r")
|
||||||
|
|
||||||
|
|
||||||
|
class FillCartError(ValueError):
|
||||||
|
"""The order-list CSV cannot be planned."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrderLine:
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlannedAdd:
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkippedLine:
|
||||||
|
name: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FillPlan:
|
||||||
|
adds: tuple[PlannedAdd, ...]
|
||||||
|
skipped: tuple[SkippedLine, ...]
|
||||||
|
ignored_zero: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CartLine:
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
product_uuid: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(csv_text: str, products: list) -> FillPlan:
|
||||||
|
parsed = _parse_order_list(csv_text)
|
||||||
|
adds, skipped = _match(parsed.lines, products)
|
||||||
|
return FillPlan(
|
||||||
|
adds=tuple(adds),
|
||||||
|
skipped=parsed.skipped + tuple(skipped),
|
||||||
|
ignored_zero=parsed.ignored_zero,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cart_lines(cart: object) -> list[CartLine]:
|
||||||
|
if not isinstance(cart, dict):
|
||||||
|
return []
|
||||||
|
items = cart.get("items")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
lines = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
product = item.get("product") if isinstance(item.get("product"), dict) else {}
|
||||||
|
name = str(product.get("name") or item.get("name") or "Unknown item").strip()
|
||||||
|
product_uuid = product.get("uuid") or item.get("uuid")
|
||||||
|
lines.append(
|
||||||
|
CartLine(
|
||||||
|
name=name or "Unknown item",
|
||||||
|
quantity=_as_int(item.get("quantity")),
|
||||||
|
product_uuid=str(product_uuid) if product_uuid else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def quantity_for(lines: list[CartLine], product_uuid: str) -> int:
|
||||||
|
return sum(line.quantity for line in lines if line.product_uuid == product_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def error_text(status: int, data: object) -> str:
|
||||||
|
"""Short cart-API failure text. Vendor bodies can include stack traces."""
|
||||||
|
message = ""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raw = data.get("message")
|
||||||
|
if isinstance(raw, str):
|
||||||
|
message = " ".join(raw.split())
|
||||||
|
if message:
|
||||||
|
return f"HTTP {status}: {message[:200]}"
|
||||||
|
return f"HTTP {status}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ParsedCsv:
|
||||||
|
lines: tuple[OrderLine, ...]
|
||||||
|
skipped: tuple[SkippedLine, ...]
|
||||||
|
ignored_zero: int
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_order_list(csv_text: str) -> _ParsedCsv:
|
||||||
|
text = csv_text.lstrip("\ufeff")
|
||||||
|
if not text.strip():
|
||||||
|
raise FillCartError("CSV is empty")
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
raise FillCartError("CSV must have Item and Quantity columns")
|
||||||
|
fields = {(name or "").strip().casefold(): name for name in reader.fieldnames}
|
||||||
|
if "item" not in fields or "quantity" not in fields:
|
||||||
|
raise FillCartError("CSV must have Item and Quantity columns")
|
||||||
|
|
||||||
|
totals: dict[str, int] = {}
|
||||||
|
display: dict[str, str] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
skipped: list[SkippedLine] = []
|
||||||
|
ignored_zero = 0
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
raw_name = row.get(fields["item"]) or ""
|
||||||
|
name = _unescape_item(str(raw_name).strip())
|
||||||
|
raw_qty = str(row.get(fields["quantity"]) or "").strip()
|
||||||
|
if not name and not raw_qty:
|
||||||
|
continue
|
||||||
|
if not name:
|
||||||
|
skipped.append(SkippedLine("(blank)", "missing a name"))
|
||||||
|
continue
|
||||||
|
if not raw_qty.isdigit():
|
||||||
|
skipped.append(SkippedLine(name, f"invalid quantity {raw_qty!r}"))
|
||||||
|
continue
|
||||||
|
quantity = int(raw_qty)
|
||||||
|
if quantity == 0:
|
||||||
|
ignored_zero += 1
|
||||||
|
continue
|
||||||
|
key = name.casefold()
|
||||||
|
if key not in totals:
|
||||||
|
order.append(key)
|
||||||
|
display[key] = name
|
||||||
|
totals[key] = 0
|
||||||
|
totals[key] += quantity
|
||||||
|
|
||||||
|
lines = tuple(OrderLine(display[key], totals[key]) for key in order)
|
||||||
|
return _ParsedCsv(lines=lines, skipped=tuple(skipped), ignored_zero=ignored_zero)
|
||||||
|
|
||||||
|
|
||||||
|
def _unescape_item(name: str) -> str:
|
||||||
|
if len(name) > 1 and name[0] == "'" and name[1] in _FORMULA_PREFIX:
|
||||||
|
return name[1:]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _match(
|
||||||
|
lines: tuple[OrderLine, ...], products: list
|
||||||
|
) -> tuple[list[PlannedAdd], list[SkippedLine]]:
|
||||||
|
available: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
unavailable: set[str] = set()
|
||||||
|
for product in products:
|
||||||
|
if not isinstance(product, dict):
|
||||||
|
continue
|
||||||
|
name = str(product.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
key = name.casefold()
|
||||||
|
if product.get("available") is False:
|
||||||
|
unavailable.add(key)
|
||||||
|
continue
|
||||||
|
uuid = str(product.get("uuid") or "").strip()
|
||||||
|
if not uuid:
|
||||||
|
continue
|
||||||
|
available.setdefault(key, []).append((name, uuid))
|
||||||
|
|
||||||
|
adds: list[PlannedAdd] = []
|
||||||
|
skipped: list[SkippedLine] = []
|
||||||
|
for line in lines:
|
||||||
|
key = line.name.casefold()
|
||||||
|
matches = available.get(key, [])
|
||||||
|
if len(matches) == 1:
|
||||||
|
adds.append(PlannedAdd(matches[0][0], line.quantity, matches[0][1]))
|
||||||
|
elif len(matches) > 1:
|
||||||
|
skipped.append(SkippedLine(line.name, "matches more than one menu item"))
|
||||||
|
elif key in unavailable:
|
||||||
|
skipped.append(SkippedLine(line.name, "unavailable on the menu"))
|
||||||
|
else:
|
||||||
|
skipped.append(SkippedLine(line.name, "not on the menu"))
|
||||||
|
return adds, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: object) -> int:
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
|
||||||
|
return int(text)
|
||||||
|
return 0
|
||||||
|
|
@ -30,16 +30,30 @@ class MenuParseError(RuntimeError):
|
||||||
"""The menu page did not contain a usable catalog."""
|
"""The menu page did not contain a usable catalog."""
|
||||||
|
|
||||||
|
|
||||||
def fetch_menu(url: str, *, timeout: float = 30) -> dict:
|
def fetch_menu_html(url: str, *, timeout: float = 30) -> str:
|
||||||
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
page = response.read().decode("utf-8", "replace")
|
return response.read().decode("utf-8", "replace")
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise MenuParseError(f"Failed to fetch {url}") from exc
|
raise MenuParseError(f"Failed to fetch {url}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_menu(url: str, *, timeout: float = 30) -> dict:
|
||||||
|
page = fetch_menu_html(url, timeout=timeout)
|
||||||
return parse_menu_html(page, menu_url=url)
|
return parse_menu_html(page, menu_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_catalog_products(page: str) -> list:
|
||||||
|
"""Return the raw product objects embedded on the menu page."""
|
||||||
|
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
||||||
|
if not isinstance(products, list):
|
||||||
|
raise MenuParseError(":products must be a JSON array")
|
||||||
|
if not products:
|
||||||
|
raise MenuParseError("Menu catalog is empty")
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
|
def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict:
|
||||||
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
products = _extract_json_attr(page, PRODUCTS_MARKER)
|
||||||
newest = _extract_json_attr(page, NEWEST_MARKER)
|
newest = _extract_json_attr(page, NEWEST_MARKER)
|
||||||
|
|
|
||||||
265
tests/test_fill_cart.py
Normal file
265
tests/test_fill_cart.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""CSV parsing and menu matching for the Redefine cart filler."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import Error as PlaywrightError
|
||||||
|
|
||||||
|
from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for
|
||||||
|
from scraper.parse_menu import extract_catalog_products
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "fill_redefine_cart.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("fill_redefine_cart", _SCRIPT)
|
||||||
|
fill_redefine_cart = importlib.util.module_from_spec(_spec)
|
||||||
|
assert _spec.loader is not None
|
||||||
|
_spec.loader.exec_module(fill_redefine_cart)
|
||||||
|
|
||||||
|
PRODUCTS = [
|
||||||
|
{
|
||||||
|
"name": "Korean Steak Bowl",
|
||||||
|
"uuid": "korean-id",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sale Bowl",
|
||||||
|
"uuid": "sale-id",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hidden Bowl",
|
||||||
|
"uuid": "hidden-id",
|
||||||
|
"available": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Twin Bowl",
|
||||||
|
"uuid": "twin-a",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "twin bowl",
|
||||||
|
"uuid": "twin-b",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "No Id Bowl",
|
||||||
|
"uuid": "",
|
||||||
|
"available": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_matches_names_and_skips_the_rest():
|
||||||
|
csv_text = "\n".join(
|
||||||
|
[
|
||||||
|
"Item,Quantity",
|
||||||
|
"korean steak bowl,4",
|
||||||
|
"Sale Bowl,2",
|
||||||
|
"Sale Bowl,3",
|
||||||
|
"Hidden Bowl,1",
|
||||||
|
"Twin Bowl,8",
|
||||||
|
"Missing Bowl,1",
|
||||||
|
"No Id Bowl,1",
|
||||||
|
"=cmd,1",
|
||||||
|
"Zero Bowl,0",
|
||||||
|
"Bad Qty,nope",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# The admin exporter prefixes formula-like names with an apostrophe.
|
||||||
|
csv_text = csv_text.replace("=cmd", "'=cmd")
|
||||||
|
|
||||||
|
plan = build_plan(csv_text, PRODUCTS)
|
||||||
|
|
||||||
|
assert [(item.name, item.quantity, item.uuid) for item in plan.adds] == [
|
||||||
|
("Korean Steak Bowl", 4, "korean-id"),
|
||||||
|
("Sale Bowl", 5, "sale-id"),
|
||||||
|
]
|
||||||
|
assert [(line.name, line.reason) for line in plan.skipped] == [
|
||||||
|
("Bad Qty", "invalid quantity 'nope'"),
|
||||||
|
("Hidden Bowl", "unavailable on the menu"),
|
||||||
|
("Twin Bowl", "matches more than one menu item"),
|
||||||
|
("Missing Bowl", "not on the menu"),
|
||||||
|
("No Id Bowl", "not on the menu"),
|
||||||
|
("=cmd", "not on the menu"),
|
||||||
|
]
|
||||||
|
assert plan.ignored_zero == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_reads_quoted_fields_and_a_byte_order_mark():
|
||||||
|
csv_text = '\ufeffItem,Quantity\r\n"Bowl, Large",2\r\n'
|
||||||
|
products = [{"name": "Bowl, Large", "uuid": "bowl", "available": True}]
|
||||||
|
|
||||||
|
plan = build_plan(csv_text, products)
|
||||||
|
|
||||||
|
assert plan.adds[0].name == "Bowl, Large"
|
||||||
|
assert plan.adds[0].quantity == 2
|
||||||
|
assert plan.skipped == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_requires_the_admin_columns():
|
||||||
|
try:
|
||||||
|
build_plan("Meal,Qty\nSoup,1\n", PRODUCTS)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "Item and Quantity" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a column error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_catalog_products_reads_the_menu_attribute():
|
||||||
|
page = """
|
||||||
|
<orders-page
|
||||||
|
:products='[{"name":"Korean Steak Bowl","uuid":"korean-id","available":true}]'
|
||||||
|
:newest-ids='[]'
|
||||||
|
></orders-page>
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert extract_catalog_products(page)[0]["uuid"] == "korean-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cart_lines_use_the_product_uuid_and_quantity():
|
||||||
|
cart = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"uuid": "line-1",
|
||||||
|
"quantity": "4",
|
||||||
|
"product": {"name": "Korean Steak Bowl", "uuid": "korean-id"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = cart_lines(cart)
|
||||||
|
|
||||||
|
assert lines[0].name == "Korean Steak Bowl"
|
||||||
|
assert lines[0].product_uuid == "korean-id"
|
||||||
|
assert quantity_for(lines, "korean-id") == 4
|
||||||
|
assert quantity_for(lines, "line-1") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _CartPage:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self._responses = list(responses)
|
||||||
|
|
||||||
|
def goto(self, url, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def evaluate(self, script, payload):
|
||||||
|
response = self._responses.pop(0)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_rejects_a_non_utf8_csv(tmp_path, capsys):
|
||||||
|
path = tmp_path / "order.csv"
|
||||||
|
path.write_bytes(b"Item,Quantity\nCaf\xe9 Bowl,1\n")
|
||||||
|
|
||||||
|
code = fill_redefine_cart.main([str(path)])
|
||||||
|
error = capsys.readouterr().err
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert error.strip() == "CSV must be UTF-8."
|
||||||
|
assert "Traceback" not in error
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_browser_prints_a_summary_when_the_page_cannot_open(capsys):
|
||||||
|
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
|
||||||
|
|
||||||
|
class ClosedBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def new_page(self):
|
||||||
|
raise PlaywrightError("browser has been closed")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
browser = ClosedBrowser()
|
||||||
|
code = fill_redefine_cart._fill_browser(
|
||||||
|
browser, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert browser.closed
|
||||||
|
assert "Traceback" not in output
|
||||||
|
assert "Added 0. Failed 1. Skipped 0." in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_page_prints_a_summary_when_the_window_closes_mid_add(capsys):
|
||||||
|
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
|
||||||
|
page = _CartPage(
|
||||||
|
[
|
||||||
|
{"ok": True, "status": 200, "data": {"items": []}},
|
||||||
|
PlaywrightError("Target page, context or browser has been closed"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
code = fill_redefine_cart._fill_page(
|
||||||
|
page, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "Traceback" not in output
|
||||||
|
assert "Added 0. Failed 1. Skipped 0." in output
|
||||||
|
assert "Cart is ready" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_page_counts_items_left_when_the_window_closes(capsys):
|
||||||
|
plan = build_plan(
|
||||||
|
"Item,Quantity\nKorean Steak Bowl,1\nSale Bowl,2\n",
|
||||||
|
PRODUCTS,
|
||||||
|
)
|
||||||
|
page = _CartPage(
|
||||||
|
[
|
||||||
|
{"ok": True, "status": 200, "data": {"items": []}},
|
||||||
|
PlaywrightError("Target page, context or browser has been closed"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
code = fill_redefine_cart._fill_page(
|
||||||
|
page, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "Korean Steak Bowl:" in output
|
||||||
|
assert "Sale Bowl: not attempted" in output
|
||||||
|
assert "Added 0. Failed 2. Skipped 0." in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_page_prints_a_summary_when_the_first_cart_read_fails(capsys):
|
||||||
|
plan = build_plan("Item,Quantity\nKorean Steak Bowl,1\n", PRODUCTS)
|
||||||
|
page = _CartPage(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"status": 500,
|
||||||
|
"data": {"message": "cart unavailable", "trace": [{"file": "x"}]},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
code = fill_redefine_cart._fill_page(
|
||||||
|
page, plan, "https://www.redefinemeals.com/menu"
|
||||||
|
)
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
|
assert code == 1
|
||||||
|
assert "HTTP 500: cart unavailable" in output
|
||||||
|
assert "x" not in output.split("Failed:", 1)[-1]
|
||||||
|
assert "Added 0. Failed 1. Skipped 0." in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_text_keeps_the_message_and_drops_the_trace():
|
||||||
|
body = {
|
||||||
|
"message": "Product is not available.",
|
||||||
|
"exception": "HttpException",
|
||||||
|
"file": "/home/app/secret.php",
|
||||||
|
"trace": [{"file": "/home/app/secret.php"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
text = error_text(422, body)
|
||||||
|
|
||||||
|
assert text == "HTTP 422: Product is not available."
|
||||||
|
assert "secret.php" not in text
|
||||||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scraper.parse_menu import MenuParseError, parse_menu_html
|
from scraper.parse_menu import MenuParseError, extract_catalog_products, parse_menu_html
|
||||||
|
|
||||||
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
|
FIXTURE = Path(__file__).resolve().parent / "fixtures" / "menu_page.html"
|
||||||
|
|
||||||
|
|
@ -43,6 +43,12 @@ def test_parse_menu_html_rejects_a_missing_catalog():
|
||||||
parse_menu_html("<html></html>", menu_url="https://example.com/menu")
|
parse_menu_html("<html></html>", menu_url="https://example.com/menu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_catalog_products_rejects_an_empty_catalog():
|
||||||
|
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||||
|
with pytest.raises(MenuParseError, match="empty"):
|
||||||
|
extract_catalog_products(page)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_menu_html_rejects_an_empty_catalog():
|
def test_parse_menu_html_rejects_an_empty_catalog():
|
||||||
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
page = "<orders-page :products='[]' :newest-ids='[]'></orders-page>"
|
||||||
with pytest.raises(MenuParseError, match="empty"):
|
with pytest.raises(MenuParseError, match="empty"):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue