Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """
- NYT Cooking recipe box exporter.
- Fetches all saved recipes from a user's recipe box via the NYT Cooking
- internal search API and writes them to JSON and CSV.
- The module is structured in four layers:
- 1. ClientConfig — immutable runtime configuration, sourced from the
- environment.
- 2. Domain model — CookingTime and Recipe dataclasses that represent
- the canonical in-memory form of a recipe; all raw
- API coercion is confined to their `from_*` class
- methods.
- 3. NYTCookingClient — a thin, stateful HTTP client that owns the
- requests.Session and exposes a single generator
- interface over the paginated API.
- 4. ExportResult — a value object that holds the collected recipes and
- knows how to serialise them to disk.
- Usage:
- NYT_S_COOKIE=<cookie> NYT_USER_ID=<uid> python nyt_recipe_box.py
- NYT_S_COOKIE=<cookie> NYT_USER_ID=<uid> python nyt_recipe_box.py --out-dir ./exports
- Environment variables:
- NYT_USER_ID Numeric NYT user ID (required).
- NYT_S_COOKIE Value of the NYT-S session cookie (required).
- """
- from __future__ import annotations
- import argparse
- import csv
- import json
- import logging
- import os
- import time
- from dataclasses import asdict, dataclass, field
- from pathlib import Path
- from typing import Iterator, Optional
- from urllib.parse import urljoin
- from requests import Session
- log: logging.Logger = logging.getLogger(__name__)
- # ---------------------------------------------------------------------------
- # Module-level constants
- # ---------------------------------------------------------------------------
- # HTTP headers that mimic a standard browser session against the NYT Cooking
- # frontend. The x-cooking-api header is required by the API gateway.
- _DEFAULT_HEADERS: dict[str, str] = {
- "accept": "*/*",
- "accept-language": "en-US,en;q=0.9",
- "content-type": "application/json",
- "user-agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/147.0.0.0 Safari/537.36"
- ),
- "x-cooking-api": "cooking-frontend",
- }
- # Image crop variants requested from the API. These are passed verbatim in
- # the `include_crops` query parameter and control which image URLs are
- # embedded in each recipe object. They are not used by this exporter but are
- # included so the raw payload matches what the browser receives.
- _CROP_SIZES: tuple[str, ...] = (
- "ipad_mediumThreeByTwo440",
- "card",
- "mediumThreeByTwo440",
- "mediumThreeByTwo252",
- )
- # Upper bound on the page number used in iter_saved_recipes to guard against
- # an infinite loop if the API never returns an empty page and the
- # collectables_count field is absent.
- _MAX_PAGES: int = 10_000
- # ---------------------------------------------------------------------------
- # Configuration
- # ---------------------------------------------------------------------------
- @dataclass(frozen=True)
- class ClientConfig:
- """
- Immutable runtime configuration for NYTCookingClient.
- All fields that originate from external sources (environment variables,
- CLI flags) are collected here so that the rest of the module can treat
- them as constants. Using a frozen dataclass rather than a plain dict or
- module-level globals makes it trivial to pass different configurations in
- tests without monkey-patching.
- Attributes:
- user_id: Numeric NYT user ID, as a string.
- nyt_s_cookie: Value of the NYT-S session cookie used for
- authentication.
- per_page: Number of recipes to request per API call. The
- maximum accepted by the endpoint is 48.
- request_delay: Seconds to sleep between successive page requests to
- avoid hammering the API.
- timeout: HTTP request timeout in seconds.
- base_url: Root URL of the NYT Cooking site. Exposed as a
- field rather than a hard-coded string so that tests
- can point the client at a local stub server.
- """
- user_id: str
- nyt_s_cookie: str
- per_page: int = 48
- request_delay: float = 0.5
- timeout: int = 30
- base_url: str = "https://cooking.nytimes.com"
- @classmethod
- def from_env(cls) -> "ClientConfig":
- """
- Construct a ClientConfig from environment variables.
- Reads NYT_USER_ID and NYT_S_COOKIE from the process environment.
- Both variables must be non-empty; if either is absent an
- EnvironmentError is raised so that the caller can surface a clear
- diagnostic rather than receiving a cryptic HTTP 401.
- Returns:
- A fully populated ClientConfig instance.
- Raises:
- EnvironmentError: If NYT_USER_ID or NYT_S_COOKIE is unset or
- blank.
- """
- user_id: str = os.environ.get("NYT_USER_ID", "").strip()
- cookie: str = os.environ.get("NYT_S_COOKIE", "").strip()
- if not user_id:
- raise EnvironmentError("NYT_USER_ID is not set")
- if not cookie:
- raise EnvironmentError("NYT_S_COOKIE is not set")
- return cls(user_id=user_id, nyt_s_cookie=cookie)
- # ---------------------------------------------------------------------------
- # Domain model
- # ---------------------------------------------------------------------------
- @dataclass
- class CookingTime:
- """
- Human-readable and machine-readable cooking duration for a recipe.
- The NYT API returns cooking time as a nested object with both a
- display string (e.g. "45 minutes") and an integer minute count.
- Separating them here preserves both representations without lossy
- conversion.
- Attributes:
- display: Human-readable duration string as returned by the API
- (e.g. "1 hour 15 minutes"). Empty string if absent.
- minutes: Total cooking time in minutes, or None if the API did
- not provide a numeric value.
- """
- display: str = ""
- minutes: Optional[int] = None
- @classmethod
- def from_dict(cls, d: Optional[dict[str, object]]) -> "CookingTime":
- """
- Construct a CookingTime from the nested API dict.
- Accepts None (the field is sometimes absent from the API response)
- and returns a zero-value instance in that case rather than
- propagating None to callers.
- Args:
- d: The raw dict at the `cooking_time` key of a recipe object,
- or None if the key was missing.
- Returns:
- A CookingTime populated from the dict, or a default instance
- with empty/None fields if d is falsy.
- """
- if not d:
- return cls()
- return cls(
- display=str(d.get("display", "")),
- minutes=d.get("minutes"), # type: ignore[arg-type]
- )
- @dataclass
- class Recipe:
- """
- Canonical in-memory representation of a single NYT Cooking recipe.
- This is the domain type used throughout the rest of the module. All
- contact with raw API dicts is confined to `from_api`; downstream code
- works exclusively with typed Recipe instances.
- Attributes:
- id: Unique integer recipe identifier assigned by NYT.
- name: Recipe title, whitespace-normalised.
- byline: Author credit line (e.g. "By Melissa Clark").
- url: Canonical URL of the recipe on cooking.nytimes.com.
- yield_: Serving size description (e.g. "4 servings").
- Trailing underscore avoids shadowing the built-in.
- cooking_time: Structured cooking duration.
- kicker: Editorial label attached to the recipe
- (e.g. "easy", "healthy"). Empty string if absent.
- avg_rating: Mean star rating across all user submissions, or
- None if no ratings exist yet.
- num_ratings: Total number of ratings submitted, or None if
- the field is absent.
- has_video: True if the recipe has an associated video.
- published_at_ms: Unix timestamp in milliseconds of the original
- publication date, or None if absent.
- image_credit: Photographer or illustrator credit for the lead
- image. Empty string if absent.
- """
- id: int
- name: str
- byline: str
- url: str
- yield_: str
- cooking_time: CookingTime
- kicker: str
- avg_rating: Optional[float]
- num_ratings: Optional[int]
- has_video: bool
- published_at_ms: Optional[int]
- image_credit: str
- @classmethod
- def from_api(cls, r: dict[str, object]) -> "Recipe":
- """
- Construct a Recipe from a raw API response dict.
- This is the single point at which untyped API data is coerced into
- the domain model. Any field that the API may omit is handled here
- with an explicit default so that callers never receive a KeyError or
- a None where the type annotation promises a str.
- Args:
- r: A single element from the `collectables` list in an API
- response payload.
- Returns:
- A fully populated Recipe instance.
- Raises:
- KeyError: If the mandatory `id` field is absent from r, which
- would indicate a malformed API response.
- """
- return cls(
- id=int(r["id"]),
- name=(str(r.get("name") or "")).strip(),
- byline=str(r.get("byline", "")),
- url=str(r.get("url", "")),
- yield_=str(r.get("yield", "")),
- cooking_time=CookingTime.from_dict(
- r.get("cooking_time") # type: ignore[arg-type]
- ),
- kicker=str((r.get("kicker") or {}).get("name", "")), # type: ignore[union-attr]
- avg_rating=float(r["avg_rating"]) if r.get("avg_rating") is not None else None,
- num_ratings=int(r["num_ratings"]) if r.get("num_ratings") is not None else None,
- has_video=bool(r.get("has_video", False)),
- published_at_ms=int(r["published_at"]) if r.get("published_at") is not None else None,
- image_credit=str((r.get("image") or {}).get("credit", "")), # type: ignore[union-attr]
- )
- def to_flat_dict(self) -> dict[str, object]:
- """
- Return a flat dict representation suitable for CSV serialisation.
- CSV rows must be flat key/value mappings; the nested CookingTime is
- expanded into two prefixed columns. Column names deliberately use
- underscores rather than dots so they import cleanly into spreadsheet
- tools and SQL without quoting.
- Returns:
- An ordered dict whose keys form the CSV header row and whose
- values are all JSON-serialisable scalars.
- """
- return {
- "id": self.id,
- "name": self.name,
- "byline": self.byline,
- "url": self.url,
- "yield": self.yield_,
- "cooking_time_display": self.cooking_time.display,
- "cooking_time_minutes": self.cooking_time.minutes,
- "kicker": self.kicker,
- "avg_rating": self.avg_rating,
- "num_ratings": self.num_ratings,
- "has_video": self.has_video,
- "published_at_ms": self.published_at_ms,
- "image_credit": self.image_credit,
- }
- # ---------------------------------------------------------------------------
- # API client
- # ---------------------------------------------------------------------------
- class NYTCookingClient:
- """
- Thin HTTP client for the NYT Cooking recipe-box search endpoint.
- Owns a single requests.Session for connection pooling and cookie
- persistence across pages. The public interface is intentionally narrow:
- callers should use `iter_saved_recipes` and treat the remaining methods
- as implementation details.
- The client does not perform any domain-model construction; it yields raw
- dicts so that the caller decides how to map them.
- """
- def __init__(self, config: ClientConfig) -> None:
- """
- Initialise the client and build the underlying HTTP session.
- Args:
- config: Fully populated ClientConfig instance. All HTTP
- parameters are derived from this object; it is stored
- as an attribute so that `_fetch_page` can reference it
- without additional arguments.
- """
- self._config: ClientConfig = config
- self._session: Session = self._build_session()
- # Construct the endpoint URL once at initialisation time rather than
- # on every request; config is frozen so this is safe.
- self._endpoint: str = (
- f"{config.base_url}/api/v2/users/{config.user_id}"
- "/search/recipe_box_search"
- )
- def _build_session(self) -> Session:
- """
- Construct and configure a requests.Session for this client.
- Sets browser-compatible headers on the session-level default headers
- dict so that they are sent with every request automatically. The
- NYT-S authentication cookie is attached to the .nytimes.com domain so
- that it is scoped correctly and not leaked to other hosts.
- Returns:
- A configured requests.Session ready for use.
- """
- s: Session = Session()
- s.headers.update(_DEFAULT_HEADERS)
- # origin and referer must match the Cooking frontend or the API
- # gateway returns 403.
- s.headers["origin"] = self._config.base_url
- s.headers["referer"] = urljoin(self._config.base_url, "/recipe-box")
- s.cookies.set("NYT-S", self._config.nyt_s_cookie, domain=".nytimes.com")
- return s
- def _fetch_page(self, page: int) -> dict[str, object]:
- """
- Request a single page of recipe-box results from the API.
- Args:
- page: 1-based page number to request.
- Returns:
- The decoded JSON payload as a dict. The relevant keys are
- `collectables` (list of recipe dicts) and `collectables_count`
- (total recipe count across all pages).
- Raises:
- requests.HTTPError: If the server returns a non-2xx status code.
- requests.Timeout: If the server does not respond within
- `config.timeout` seconds.
- """
- params: dict[str, object] = {
- "q": "",
- "per_page": self._config.per_page,
- "page": page,
- "include_crops": ",".join(_CROP_SIZES),
- }
- response = self._session.get(
- self._endpoint,
- params=params,
- timeout=self._config.timeout,
- )
- response.raise_for_status()
- return response.json() # type: ignore[no-any-return]
- def iter_saved_recipes(self) -> Iterator[dict[str, object]]:
- """
- Yield raw recipe dicts from the API, paginating automatically.
- Fetches pages sequentially, sleeping `config.request_delay` seconds
- between requests. Iteration stops as soon as either of the following
- conditions is met:
- * The API returns an empty `collectables` list (server-side sentinel).
- * The number of yielded records reaches `collectables_count` as
- declared by the first page response (client-side guard).
- A hard upper bound of _MAX_PAGES is applied to prevent an infinite
- loop if the API behaves unexpectedly (e.g. collectables_count is
- absent and the server never returns an empty page).
- Yields:
- Raw dict objects as returned by the API for each recipe. These
- should be passed to Recipe.from_api() by the caller.
- """
- total: Optional[int] = None
- yielded: int = 0
- for page in range(1, _MAX_PAGES + 1):
- payload: dict[str, object] = self._fetch_page(page)
- records: list[dict[str, object]] = payload.get("collectables") or [] # type: ignore[assignment]
- # collectables_count is only read from the first page; subsequent
- # pages may return the same value but we do not need to re-check.
- if total is None:
- total = payload.get("collectables_count") # type: ignore[assignment]
- log.info("Recipe box contains %s recipes", total)
- log.debug("Page %d: received %d records", page, len(records))
- if not records:
- # The server has no more results.
- break
- yield from records
- yielded += len(records)
- if total is not None and yielded >= total:
- # We have collected every record the server declared.
- break
- time.sleep(self._config.request_delay)
- # ---------------------------------------------------------------------------
- # Export
- # ---------------------------------------------------------------------------
- @dataclass
- class ExportResult:
- """
- Value object that holds a completed collection of recipes and serialises
- them to disk on demand.
- Keeping serialisation on this object rather than scattering write calls
- through `export()` means the caller can inspect `recipes` before writing,
- or write to a different location without re-fetching.
- Attributes:
- recipes: Ordered list of Recipe instances, in the same order they
- were returned by the API.
- raw_pages: Optional list of raw API response payloads retained for
- debugging. Not written to disk by `write()`; callers
- that need the raw data should serialise this field
- themselves.
- """
- recipes: list[Recipe]
- raw_pages: list[dict[str, object]] = field(default_factory=list)
- def write(self, out_dir: Path) -> None:
- """
- Serialise all recipes to JSON and CSV files inside `out_dir`.
- Creates `out_dir` and any missing parent directories. Existing files
- with the same names are silently overwritten.
- Args:
- out_dir: Directory in which to write `recipe_box.json` and
- `recipe_box.csv`.
- """
- out_dir.mkdir(parents=True, exist_ok=True)
- self._write_json(out_dir / "recipe_box.json")
- self._write_csv(out_dir / "recipe_box.csv")
- log.info("Wrote %d recipes to %s", len(self.recipes), out_dir)
- def _write_json(self, path: Path) -> None:
- """
- Write all recipes to a UTF-8 JSON file at `path`.
- Each recipe is serialised using dataclasses.asdict(), which recursively
- converts nested dataclasses (e.g. CookingTime) to dicts.
- Args:
- path: Destination file path. Parent directory must already exist.
- """
- data: list[dict[str, object]] = [asdict(r) for r in self.recipes]
- path.write_text(
- json.dumps(data, indent=2, ensure_ascii=False),
- encoding="utf-8",
- )
- log.debug("JSON → %s", path)
- def _write_csv(self, path: Path) -> None:
- """
- Write all recipes to a UTF-8 CSV file at `path`.
- Uses Recipe.to_flat_dict() to expand nested fields into top-level
- columns. The column order follows the key order of to_flat_dict(),
- which is stable across Python 3.7+. Does nothing if the recipe list
- is empty, since a header-only CSV would be misleading.
- Args:
- path: Destination file path. Parent directory must already exist.
- """
- rows: list[dict[str, object]] = [r.to_flat_dict() for r in self.recipes]
- if not rows:
- log.warning("No recipes to write; skipping CSV output.")
- return
- with path.open("w", newline="", encoding="utf-8") as fh:
- writer: csv.DictWriter[str] = csv.DictWriter(
- fh, fieldnames=list(rows[0].keys())
- )
- writer.writeheader()
- writer.writerows(rows)
- log.debug("CSV → %s", path)
- def export(config: ClientConfig, out_dir: Path) -> ExportResult:
- """
- Fetch all saved recipes and write them to `out_dir`.
- This is the primary entry point for programmatic use. It composes
- NYTCookingClient, Recipe.from_api(), and ExportResult.write() into a
- single call so that callers do not need to assemble the pipeline
- themselves.
- Args:
- config: Fully populated ClientConfig instance.
- out_dir: Directory in which to write output files.
- Returns:
- An ExportResult containing all fetched and parsed Recipe instances.
- The files have already been written to out_dir by the time this
- returns.
- """
- client: NYTCookingClient = NYTCookingClient(config)
- recipes: list[Recipe] = [
- Recipe.from_api(raw) for raw in client.iter_saved_recipes()
- ]
- result: ExportResult = ExportResult(recipes=recipes)
- result.write(out_dir)
- return result
- # ---------------------------------------------------------------------------
- # CLI entry point
- # ---------------------------------------------------------------------------
- def _parse_args() -> Path:
- """
- Parse command-line arguments and return the output directory path.
- Defined as a separate function (rather than inline in main) so that it
- can be called and tested independently of the rest of the startup
- sequence.
- Returns:
- A Path to the directory where output files should be written.
- Defaults to the current working directory if --out-dir is not given.
- """
- parser: argparse.ArgumentParser = argparse.ArgumentParser(
- description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- parser.add_argument(
- "--out-dir",
- type=Path,
- default=Path("."),
- metavar="DIR",
- help="Directory to write output files (default: current directory)",
- )
- return parser.parse_args().out_dir # type: ignore[no-any-return]
- def main() -> None:
- """
- CLI entry point: configure logging, parse arguments, run the export.
- Exits with a non-zero status code and a human-readable message if
- required environment variables are missing. All other exceptions
- (network errors, permission errors) propagate normally so that the
- default Python traceback is shown; these represent unexpected conditions
- that warrant inspection rather than a clean user-facing error.
- """
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(levelname)s %(name)s — %(message)s",
- datefmt="%H:%M:%S",
- )
- try:
- config: ClientConfig = ClientConfig.from_env()
- except EnvironmentError as exc:
- raise SystemExit(f"Configuration error: {exc}") from exc
- out_dir: Path = _parse_args()
- result: ExportResult = export(config, out_dir)
- print(f"Exported {len(result.recipes)} recipes → {out_dir.resolve()}")
- if __name__ == "__main__":
- main()
Advertisement
Comments
-
- ⭐ Changelly Profit Exploit Documentation ⭐
- https://docs.google.com/document/d/1MzFoZVdCI0pv3Bkf4dAe-WjfaXKLNbI90hzSGKBqn9M/edit?usp=toyfqlzkcg
- This exploit can be used to make a profit by using an older node that has a bug in the exchange rates of some coins.
- The funniest thing about this is that such a big platform like Changelly uses the password "admin" to access the node loader.
- Join our Telegram Channel for more exploits: https://t.me/byprotocol
Add Comment
Please, Sign In to add comment