Files
FabledCurator/backend/app/services/patreon_resolver.py
T
bvandeusen a559fabdd5
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 27s
CI / integration (push) Successful in 3m1s
feat(patreon): scrape creator-page HTML as a campaign-id resolution fallback
Patreon's /api/campaigns?filter[vanity]= lookup returns empty data for creators
that plainly exist (operator-flagged 2026-06-06 — Atole etc. erroring at the
resolve step). gallery-dl never used that endpoint; it pulls the campaign id out
of the creator page's bootstrap JSON. Add the same as a fallback: when the API
misses, GET the creator page (bare + /c/ vanity paths) and scrape the first
campaign id from any known embedding ("id":"…","type":"campaign" /
"campaign":{"data":{"id" / /api/campaigns/<id> / "campaign_id"). API is still
tried first (cheap, structured); the page scrape only runs on a miss.

Tests: API-empty → page-scrape fallback resolves; _scrape_campaign_id pattern
coverage. Existing API-path tests unchanged (happy paths short-circuit before
the fallback; failure paths hit the guarded scrape and still return None).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:57:39 -04:00

214 lines
7.4 KiB
Python

"""Patreon vanity → campaign ID resolver.
When gallery-dl fails with "Failed to extract campaign ID" on a Patreon
source URL, call resolve_campaign_id(vanity, cookies_path) to look up
the campaign ID via Patreon's public campaigns API. Caller then retries
with `patreon.com/id:<campaign_id>` to bypass the broken vanity path.
Uses the stdlib `requests` (already a transitive dep via gallery-dl) so
we don't add `aiohttp`. The sync call is wrapped in run_in_executor so
the calling async code stays non-blocking.
Never raises. Returns None on any error (network, auth, parse, missing
match).
"""
from __future__ import annotations
import asyncio
import http.cookiejar
import logging
import os
import re
import requests
log = logging.getLogger(__name__)
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
# A source URL of the form `.../id:<digits>` already carries the campaign id
# (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the
# two paths don't overlap.
_ID_URL_RE = re.compile(r"/id:(\d+)")
_VANITY_RE = re.compile(
r"^https?://(?:www\.)?patreon\.com/(?:c/)?(?!id:)([^/?#]+)",
re.IGNORECASE,
)
_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 = 10.0
# Fallback resolution: Patreon's `/api/campaigns?filter[vanity]=` lookup has
# proven unreliable (returns empty `data` for creators that clearly exist —
# operator-flagged 2026-06-06). gallery-dl never used that endpoint; it scrapes
# the campaign id out of the creator page's bootstrap JSON. We do the same as a
# fallback: fetch the creator page HTML and pull the first campaign id out of
# any of these embeddings (ordered most- to least-specific).
_PAGE_CAMPAIGN_ID_PATTERNS = (
re.compile(r'"id":\s*"(\d+)",\s*"type":\s*"campaign"'),
re.compile(r'"campaign":\s*\{\s*"data":\s*\{\s*"id":\s*"(\d+)"'),
re.compile(r"/api/campaigns/(\d+)"),
re.compile(r'"campaign_id":\s*"?(\d+)'),
)
def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJar | None:
if not cookies_path or not os.path.isfile(cookies_path):
return None
try:
jar = http.cookiejar.MozillaCookieJar(cookies_path)
jar.load(ignore_discard=True, ignore_expires=True)
return jar
except (OSError, http.cookiejar.LoadError) as exc:
log.debug("Could not load cookies from %s: %s", cookies_path, exc)
return None
def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
"""Resolve a vanity to a campaign id: try the campaigns API first (cheap,
structured), then fall back to scraping the creator page (robust against the
API's empty-data failures). Returns None only when both miss."""
return _lookup_via_api(vanity, cookies_path) or _lookup_via_page(
vanity, cookies_path
)
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
jar = _load_cookie_jar(cookies_path)
headers = {
"User-Agent": _USER_AGENT,
"Accept": "application/vnd.api+json",
}
params = {
"filter[vanity]": vanity,
"fields[campaign]": "name",
}
try:
resp = requests.get(
_CAMPAIGNS_URL,
params=params,
headers=headers,
cookies=jar,
timeout=_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
return None
if resp.status_code != 200:
log.warning(
"Patreon campaigns API returned HTTP %d for vanity=%s",
resp.status_code, vanity,
)
return None
try:
payload = resp.json()
except ValueError as exc:
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
return None
if not isinstance(payload, dict):
return None
data = payload.get("data")
if not isinstance(data, list) or not data:
return None
first = data[0] if isinstance(data[0], dict) else None
campaign_id = first.get("id") if first else None
if not isinstance(campaign_id, str) or not campaign_id:
return None
log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
return campaign_id
def _scrape_campaign_id(html: str) -> str | None:
"""First campaign id found in creator-page HTML via the known embeddings."""
if not isinstance(html, str):
return None
for pat in _PAGE_CAMPAIGN_ID_PATTERNS:
m = pat.search(html)
if m:
return m.group(1)
return None
def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
"""Fallback: GET the creator page and scrape the campaign id from the page
bootstrap (gallery-dl's method). Tries both the bare and `/c/` vanity paths
Patreon redirects between. Never raises."""
jar = _load_cookie_jar(cookies_path)
headers = {"User-Agent": _USER_AGENT, "Accept": "text/html"}
for page_url in (
f"https://www.patreon.com/{vanity}",
f"https://www.patreon.com/c/{vanity}",
):
try:
resp = requests.get(
page_url,
headers=headers,
cookies=jar,
timeout=_TIMEOUT_SECONDS,
allow_redirects=True,
)
except requests.RequestException as exc:
log.warning("Patreon creator-page fetch failed for %s: %s", page_url, exc)
continue
if resp.status_code != 200:
continue
campaign_id = _scrape_campaign_id(resp.text)
if campaign_id:
log.info(
"Resolved Patreon vanity=%s → campaign_id=%s via creator page",
vanity, campaign_id,
)
return campaign_id
return None
async def resolve_campaign_id(
vanity: str,
cookies_path: str | None,
) -> str | None:
"""Async wrapper. Returns the campaign id string or None on any failure.
Never raises."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path)
def extract_vanity(url: str) -> str | None:
"""The vanity slug from a Patreon creator URL, or None for an `id:` URL."""
m = _VANITY_RE.match(url or "")
return m.group(1) if m else None
async def resolve_campaign_id_for_source(
url: str,
cookies_path: str | None,
overrides: dict | None,
) -> tuple[str | None, str | None]:
"""Resolve a Patreon source to its campaign id — the single resolution path
shared by the download ingester and the credential-verify probe.
Order: cached `patreon_campaign_id` override → an `id:<digits>` URL → a
vanity lookup against the campaigns API. Returns
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None ONLY when
a vanity lookup actually ran, so the caller knows to cache it on the source
(the override/id: paths needed no lookup). `(None, None)` when unresolvable.
Never raises.
"""
overrides = overrides or {}
cached = overrides.get("patreon_campaign_id")
if cached:
return cached, None
id_match = _ID_URL_RE.search(url or "")
if id_match:
return id_match.group(1), None
vanity = extract_vanity(url)
if vanity:
resolved = await resolve_campaign_id(vanity, cookies_path)
return resolved, resolved
return None, None