From dce33918b70f16bcd192910de2dcd63e2d37410a Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 19:50:12 -0400 Subject: [PATCH] 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. --- scripts/fill_redefine_cart.py | 239 ++++++++++++++++++++++++++++++++++ src/scraper/fill_cart.py | 203 +++++++++++++++++++++++++++++ src/scraper/parse_menu.py | 16 ++- tests/test_fill_cart.py | 137 +++++++++++++++++++ 4 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 scripts/fill_redefine_cart.py create mode 100644 src/scraper/fill_cart.py create mode 100644 tests/test_fill_cart.py diff --git a/scripts/fill_redefine_cart.py b/scripts/fill_redefine_cart.py new file mode 100644 index 0000000..595a5fc --- /dev/null +++ b/scripts/fill_redefine_cart.py @@ -0,0 +1,239 @@ +"""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 (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 + page = browser.new_page() + 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 + + failed: list[str] = [] + posted = [] + for item in 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 + posted.append(item) + + added = 0 + 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) + print(f"Added {added}. Failed {len(failed)}. Skipped {len(plan.skipped)}.") + _wait_for_review(page) + return 1 if failed or plan.skipped else 0 + finally: + try: + browser.close() + except PlaywrightError: + pass + + +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()) diff --git a/src/scraper/fill_cart.py b/src/scraper/fill_cart.py new file mode 100644 index 0000000..042aaff --- /dev/null +++ b/src/scraper/fill_cart.py @@ -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 diff --git a/src/scraper/parse_menu.py b/src/scraper/parse_menu.py index 5d6bd96..6b905cb 100644 --- a/src/scraper/parse_menu.py +++ b/src/scraper/parse_menu.py @@ -30,16 +30,28 @@ class MenuParseError(RuntimeError): """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}) try: 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: 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) +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") + return products + + def parse_menu_html(page: str, *, menu_url: str, scraped_at: str | None = None) -> dict: products = _extract_json_attr(page, PRODUCTS_MARKER) newest = _extract_json_attr(page, NEWEST_MARKER) diff --git a/tests/test_fill_cart.py b/tests/test_fill_cart.py new file mode 100644 index 0000000..1edc16f --- /dev/null +++ b/tests/test_fill_cart.py @@ -0,0 +1,137 @@ +"""CSV parsing and menu matching for the Redefine cart filler.""" + +from scraper.fill_cart import build_plan, cart_lines, error_text, quantity_for +from scraper.parse_menu import extract_catalog_products + +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 = """ + + """ + + 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 + + +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