"""Native Patreon JSON:API client (build step 1 of the native ingester). Clean-room reimplementation of the Patreon `/api/posts` read path. This is a plain, synchronous client over `requests` — the orchestrator already wraps sync importer calls, so nothing here needs to be async (mirrors patreon_resolver.py, which wraps its sync lookup in run_in_executor at the call site). Scope (build step 1): fetch + page + parse only. This module is NOT wired into download_service yet — that is a later step. The public surface here exists so the later step can drive it: - PatreonClient(cookies_path).iter_posts(campaign_id) → (post, included_index, page_cursor) - extract_media(post, included_index) → list[MediaItem] - parse_cursor_from_url(url) → cursor Drift detection is loud on purpose: Patreon ships JSON:API and the shapes we depend on (top-level `data`, media resources carrying `file_name`/`url`) are the contract. If a response comes back as an HTML login page or a media resource is missing the fields we resolve against, we raise PatreonDriftError rather than silently yielding empty media — so the later import step surfaces "Patreon changed something" instead of "creator has no posts". FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API. """ from __future__ import annotations import http.cookiejar import logging import os import re import time from collections.abc import Iterator from dataclasses import dataclass from html import unescape from pathlib import Path from urllib.parse import parse_qs, urlsplit import requests from ..utils.paths import safe_ext log = logging.getLogger(__name__) _POSTS_URL = "https://www.patreon.com/api/posts" _USER_AGENT = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" ) _TIMEOUT_SECONDS = 30.0 # 429 backoff (plan #703): ride out a transient API rate-limit instead of # failing the whole walk (which would stamp RATE_LIMITED → platform-wide # cooldown → every Patreon source dark). Honor the server's `Retry-After`; # otherwise exponential base·2^(n-1), capped. Only after the retries are # exhausted does the 429 propagate as terminal RATE_LIMITED. _MAX_429_RETRIES = 3 _BACKOFF_BASE_SECONDS = 2.0 _BACKOFF_CAP_SECONDS = 30.0 def _retry_after_seconds( resp: requests.Response, attempt: int, *, base: float = _BACKOFF_BASE_SECONDS, cap: float = _BACKOFF_CAP_SECONDS, ) -> float: """Backoff delay for a 429: the `Retry-After` seconds header if present and numeric, else exponential `base·2^(attempt-1)`, both capped. (HTTP-date form of Retry-After is rare here and falls through to exponential.)""" header = resp.headers.get("Retry-After") if header: try: return min(float(header), cap) except (TypeError, ValueError): pass return min(base * (2 ** max(0, attempt - 1)), cap) # JSON:API request contract (observed from real traffic — see module plan). _INCLUDE = ( "campaign,access_rules,attachments,attachments_media,audio,images,media," "native_video_insights,user,user_defined_tags,ti_checks" ) _FIELDS_POST = ( "content,post_file,image,post_type,published_at,title,url,patreon_url," "current_user_can_view" ) _FIELDS_MEDIA = "id,image_urls,download_url,metadata,file_name" _FIELDS_CAMPAIGN = "name,url" # A CDN download URL embeds a 32-char hex (MD5) path segment; that segment is # Patreon's stable per-file identity and is what we dedup + ledger against. # Same role gallery-dl's _filehash plays. Match the FIRST 32-hex run anywhere # in the URL (path or query); real Patreon CDN URLs carry exactly one. _FILEHASH_RE = re.compile(r"([0-9a-fA-F]{32})") # Inline post `content` is HTML; images are emitted as . # Pull every src; downstream dedup collapses any that duplicate a gallery item # by filehash. Tolerant of attribute ordering and single/double quotes. _CONTENT_IMG_RE = re.compile(r"]*?\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE) class PatreonAPIError(Exception): """Base for native Patreon client failures. `status_code` carries the HTTP status when the failure was an HTTP response (None for transport-level / parse failures), so the ingester can map it to a DownloadResult.error_type (429 → rate_limited, 404 → not_found, …). `retry_after` carries the server's `Retry-After` seconds on a terminal 429, so the platform cooldown can match the server's hint instead of a flat default (plan #708 B1). """ def __init__( self, message: str, *, status_code: int | None = None, retry_after: float | None = None, ): super().__init__(message) self.status_code = status_code self.retry_after = retry_after class PatreonAuthError(PatreonAPIError): """Authentication / authorization failure — missing or expired session cookies, an insufficient pledge tier, or an HTML login/challenge page served where JSON was expected. DISTINCT from drift: the fix is rotating the credential, not updating the ingester. Maps to error_type 'auth_error'. """ class PatreonDriftError(PatreonAPIError): """A JSON response did not match the JSON:API shape we depend on. Raised for: a missing top-level `data` list, `data` not a list, or a media resource lacking the `file_name`/`url` fields we resolve against. Fail loud so the import step flags API drift (ingester needs update) instead of silently importing nothing. An HTML-login / non-JSON body is auth, not drift — that raises PatreonAuthError. """ @dataclass class MediaItem: """One resolved downloadable item belonging to a post. Fields: url — the CDN/download URL to fetch. filename — media `file_name` when present; otherwise the URL basename (NEVER a network call). Bounded/sane extension. kind — one of: "images", "image_large", "attachments", "postfile", "content". Mirrors gallery-dl's `files` content-type names so the later step can honor the same per-source content_types. filehash — the 32-char hex (MD5) segment from the CDN URL, or None if the URL carries no such segment. Used for in-post dedup and the cross-run seen-ledger. post_id — the owning post's id (so a flattened media list stays traceable to its post). """ url: str filename: str kind: str filehash: str | None post_id: str def _load_session(cookies_path: str | Path | None) -> requests.Session: session = requests.Session() session.headers.update( { "User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json", } ) if cookies_path and os.path.isfile(str(cookies_path)): try: jar = http.cookiejar.MozillaCookieJar(str(cookies_path)) jar.load(ignore_discard=True, ignore_expires=True) session.cookies = jar # type: ignore[assignment] except (OSError, http.cookiejar.LoadError) as exc: log.warning("Could not load Patreon cookies from %s: %s", cookies_path, exc) return session def _filehash(url: str) -> str | None: if not url: return None match = _FILEHASH_RE.search(url) return match.group(1).lower() if match else None def _basename_from_url(url: str) -> str: """Derive a sane filename from a URL when the media has no file_name. Strips query/fragment, takes the path basename, and drops a junk extension (the importer._safe_ext gotcha) so we never write base64 noise as a name. Falls back to the filehash, then to "file". """ path = urlsplit(url).path base = os.path.basename(path) if base: ext = safe_ext(base) stem = base[: -len(Path(base).suffix)] if Path(base).suffix else base # Keep the stem bounded; URL-encoded stems can be enormous. stem = stem[:120] or "file" return f"{stem}{ext}" fh = _filehash(url) return fh or "file" def parse_cursor_from_url(url: str | None) -> str | None: """Extract the `page[cursor]` query param from a links.next URL.""" if not url: return None query = urlsplit(url).query values = parse_qs(query).get("page[cursor]") if values and values[0]: return values[0] return None class PatreonClient: """Synchronous Patreon JSON:API read client. Construct with a path to a Netscape cookies.txt (the same file CredentialService.get_cookies_path materializes). Cookies are loaded into a requests.Session; no secure-context APIs are used. """ def __init__( self, cookies_path: str | Path | None, *, request_sleep: float = 0.0, max_retries: int = _MAX_429_RETRIES, ): self.cookies_path = str(cookies_path) if cookies_path else None self._session = _load_session(cookies_path) # Politeness: seconds to sleep before each /api/posts page fetch (paces # the rate-limited API endpoint). 0 = no pacing. plan #703. self._request_sleep = request_sleep or 0.0 self._max_retries = max_retries # -- request ----------------------------------------------------------- def _params(self, campaign_id: str, cursor: str | None) -> dict[str, str]: params = { "include": _INCLUDE, "fields[post]": _FIELDS_POST, "fields[media]": _FIELDS_MEDIA, "fields[campaign]": _FIELDS_CAMPAIGN, "filter[campaign_id]": campaign_id, "filter[contains_exclusive_posts]": "true", "filter[is_draft]": "false", "sort": "-published_at", "json-api-version": "1.0", } if cursor: params["page[cursor]"] = cursor return params def _fetch(self, campaign_id: str, cursor: str | None) -> dict: if self._request_sleep > 0: time.sleep(self._request_sleep) # pace the API endpoint attempt = 0 while True: try: resp = self._session.get( _POSTS_URL, params=self._params(campaign_id, cursor), timeout=_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise PatreonAPIError( f"Patreon posts request failed (campaign_id={campaign_id}): {exc}" ) from exc # Transient rate-limit: back off and retry rather than failing the # whole walk. Only a PERSISTENT 429 (retries exhausted) falls # through to the terminal RATE_LIMITED raise below. if resp.status_code == 429 and attempt < self._max_retries: attempt += 1 delay = _retry_after_seconds(resp, attempt) log.warning( "Patreon 429 (campaign_id=%s) — backing off %.1fs (retry %d/%d)", campaign_id, delay, attempt, self._max_retries, ) time.sleep(delay) continue break if resp.status_code in (401, 403): # Auth rejected — expired/missing cookies or an insufficient tier. # Actionable as "rotate credentials", so it's auth, not drift/http. raise PatreonAuthError( f"Patreon posts API returned HTTP {resp.status_code} — auth " f"rejected (cookies expired or tier insufficient; " f"campaign_id={campaign_id})", status_code=resp.status_code, ) if resp.status_code != 200: # A persistent 429 (retries exhausted) is terminal RATE_LIMITED — carry # the server's raw Retry-After seconds so the cooldown matches its hint # (plan #708 B1). Header is uncapped here; the cooldown clamps it. retry_after = None if resp.status_code == 429: hdr = resp.headers.get("Retry-After") if hdr: try: retry_after = float(hdr) except (TypeError, ValueError): retry_after = None raise PatreonAPIError( f"Patreon posts API returned HTTP {resp.status_code} " f"(campaign_id={campaign_id})", status_code=resp.status_code, retry_after=retry_after, ) try: payload = resp.json() except ValueError as exc: # A non-JSON body here is almost always the HTML login/challenge # page served when cookies are missing/expired — that is an AUTH # failure (rotate cookies), not API drift (update the ingester) and # not a transient network error. raise PatreonAuthError( "Patreon posts API returned a non-JSON response (likely an " f"HTML login/challenge page — session expired; " f"campaign_id={campaign_id}): {exc}" ) from exc return payload # -- parsing ----------------------------------------------------------- @staticmethod def _transform(response: dict) -> dict: """Flatten the JSON:API `included` array for relationship resolution. Returns a dict keyed by `(type, id)` → that resource's `attributes` (so a post's relationships can be resolved in O(1)). Missing/oddly shaped `included` entries are skipped rather than fatal — drift detection for the top-level shape lives in _validate_response. """ index: dict[tuple[str, str], dict] = {} for inc in response.get("included") or []: if not isinstance(inc, dict): continue rtype = inc.get("type") rid = inc.get("id") if rtype is None or rid is None: continue index[(str(rtype), str(rid))] = inc.get("attributes") or {} return index @staticmethod def _validate_response(response: dict) -> None: if not isinstance(response, dict): raise PatreonDriftError("Patreon response was not a JSON object") if "data" not in response: raise PatreonDriftError("Patreon response missing top-level 'data' key") data = response.get("data") if not isinstance(data, list): raise PatreonDriftError("Patreon response 'data' was not a list") def _related_ids(self, post: dict, rel_name: str) -> list[str]: rels = post.get("relationships") or {} rel = rels.get(rel_name) or {} data = rel.get("data") if isinstance(data, dict): # to-one relationship data = [data] if not isinstance(data, list): return [] ids: list[str] = [] for ref in data: if isinstance(ref, dict) and ref.get("id") is not None: ids.append(str(ref["id"])) return ids @staticmethod def _media_url(attrs: dict) -> str | None: """Pick the best fetchable URL for a media resource. Prefer the full-size `download_url`; fall back to the largest `image_urls` size. gallery-dl prefers download_url too, only dipping into image_urls when a smaller configured size is requested — FC always wants the original, so download_url first. """ download_url = attrs.get("download_url") if isinstance(download_url, str) and download_url: return download_url image_urls = attrs.get("image_urls") if isinstance(image_urls, dict): for key in ("original", "full", "large", "default"): candidate = image_urls.get(key) if isinstance(candidate, str) and candidate: return candidate # Otherwise take any non-empty string value. for candidate in image_urls.values(): if isinstance(candidate, str) and candidate: return candidate return None def _media_item( self, attrs: dict, kind: str, post_id: str, *, require_file_name: bool ) -> MediaItem: url = self._media_url(attrs) if not url: raise PatreonDriftError( f"Patreon media (post {post_id}, kind={kind}) had no resolvable URL " f"(no download_url / image_urls)" ) file_name = attrs.get("file_name") if require_file_name and not (isinstance(file_name, str) and file_name): raise PatreonDriftError( f"Patreon media resource (post {post_id}, kind={kind}) missing " f"'file_name'" ) filename = file_name if isinstance(file_name, str) and file_name else _basename_from_url(url) return MediaItem( url=url, filename=filename, kind=kind, filehash=_filehash(url), post_id=post_id, ) def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]: """Resolve all downloadable media for one post. Walks the kinds in the same order gallery-dl does — images, image_large (post cover), attachments, postfile, content (inline ) — and dedups within the post by filehash (first wins). The image_large cover commonly duplicates a gallery image; deduping by filehash collapses them to the gallery item (encountered first). """ post_id = str(post.get("id") or "") attrs = post.get("attributes") or {} items: list[MediaItem] = [] def _resolve_rel(rel_name: str, kind: str) -> None: for mid in self._related_ids(post, rel_name): media_attrs = included_index.get(("media", mid)) if media_attrs is None: # Referenced but not in `included`: a media id with no # resource is drift (we asked for include=media). raise PatreonDriftError( f"Patreon post {post_id} references media {mid} " f"({rel_name}) not present in 'included'" ) items.append( self._media_item( media_attrs, kind, post_id, require_file_name=True ) ) # 1. gallery images _resolve_rel("images", "images") # 2. image_large — the post-level cover (`image.large_url`). Not a # media relationship; it lives on the post attributes. image = attrs.get("image") if isinstance(image, dict): large_url = image.get("large_url") or image.get("url") if isinstance(large_url, str) and large_url: items.append( MediaItem( url=large_url, filename=_basename_from_url(large_url), kind="image_large", filehash=_filehash(large_url), post_id=post_id, ) ) # 3. attachments _resolve_rel("attachments_media", "attachments") # 4. postfile — the post's primary attached file (`post_file`). post_file = attrs.get("post_file") if isinstance(post_file, dict): pf_url = post_file.get("url") or post_file.get("download_url") if isinstance(pf_url, str) and pf_url: pf_name = post_file.get("name") filename = ( pf_name if isinstance(pf_name, str) and pf_name else _basename_from_url(pf_url) ) items.append( MediaItem( url=pf_url, filename=filename, kind="postfile", filehash=_filehash(pf_url), post_id=post_id, ) ) # 5. content — inline in the post HTML body. content = attrs.get("content") if isinstance(content, str) and content: for raw_src in _CONTENT_IMG_RE.findall(content): src = unescape(raw_src) if not src: continue items.append( MediaItem( url=src, filename=_basename_from_url(src), kind="content", filehash=_filehash(src), post_id=post_id, ) ) return _dedup_by_filehash(items) @staticmethod def post_meta(post: dict) -> dict: """Title + published date for a post — for the preview sample (plan #708 B4). Part of the client contract `ingest_core.Ingester.preview` calls.""" attrs = post.get("attributes") or {} title = attrs.get("title") published = attrs.get("published_at") return { "title": title if isinstance(title, str) else None, "date": published if isinstance(published, str) else None, } # -- iteration --------------------------------------------------------- def iter_posts( self, campaign_id: str, cursor: str | None = None ) -> Iterator[tuple[dict, dict, str | None]]: """Yield (post, included_index, page_cursor) for every post in the feed. Pages newest→oldest via `links.next`, validating each response for drift before yielding. The triple gives a caller everything it needs to resolve and checkpoint without re-fetching: - post — the raw post resource. - included_index — the page's flattened `included` (the same object for every post on a page), to pass straight to extract_media(post, included_index). - page_cursor — the cursor that FETCHED this post's page (None for the first page). The caller checkpoints THIS value, matching the existing backfill cursor logic where the saved cursor re-fetches the page being processed (so a chunk cut mid-page resumes the page, not the one after it). """ current_cursor = cursor while True: response = self._fetch(campaign_id, current_cursor) self._validate_response(response) page_cursor = current_cursor included_index = self._transform(response) for post in response.get("data") or []: if isinstance(post, dict): yield post, included_index, page_cursor next_url = (response.get("links") or {}).get("next") next_cursor = parse_cursor_from_url(next_url) if not next_cursor: return current_cursor = next_cursor # -- verify ------------------------------------------------------------ def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]: """Cheap auth probe: fetch the first `/api/posts` page and report whether the credential authenticated, WITHOUT downloading anything. Returns `(ok, message)` matching the credential-verify contract: - True — authenticated (the feed returned a valid JSON:API page). - False — the credential was rejected (PatreonAuthError: 401/403, or an HTML login page → cookies expired / tier insufficient). - None — inconclusive: API drift (our parser is stale, not a cred problem) or a transient network/HTTP error. """ try: response = self._fetch(campaign_id, None) self._validate_response(response) except PatreonAuthError as exc: return False, f"Patreon rejected the credential — {exc}" except PatreonDriftError as exc: return None, f"Couldn't verify — Patreon's API shape changed: {exc}" except PatreonAPIError as exc: return None, f"Couldn't verify (network/HTTP issue): {exc}" return True, "Credentials valid — the Patreon feed authenticated." def _dedup_by_filehash(items: list[MediaItem]) -> list[MediaItem]: """Drop later items sharing a filehash with an earlier one (first wins). Items with no filehash (None) are never deduped against each other — we can't prove they're the same file, so keep them all. """ seen: set[str] = set() out: list[MediaItem] = [] for item in items: if item.filehash is not None: if item.filehash in seen: continue seen.add(item.filehash) out.append(item) return out