diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py index fb29661..c8acd2c 100644 --- a/backend/app/services/patreon_resolver.py +++ b/backend/app/services/patreon_resolver.py @@ -41,6 +41,19 @@ _USER_AGENT = ( ) _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): @@ -55,6 +68,15 @@ def _load_cookie_jar(cookies_path: str | None) -> http.cookiejar.MozillaCookieJa 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, @@ -102,6 +124,50 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None: 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, diff --git a/tests/test_patreon_resolver.py b/tests/test_patreon_resolver.py index bbf40e4..6586e41 100644 --- a/tests/test_patreon_resolver.py +++ b/tests/test_patreon_resolver.py @@ -91,6 +91,41 @@ async def test_loads_cookies_file_when_present(tmp_path): assert kwargs.get("cookies") is not None +@pytest.mark.asyncio +async def test_falls_back_to_creator_page_when_api_empty(): + """When the campaigns API returns empty data (its known failure mode), the + resolver scrapes the campaign id out of the creator page (gallery-dl's + method).""" + api_resp = MagicMock() + api_resp.status_code = 200 + api_resp.json.return_value = {"data": []} + page_resp = MagicMock() + page_resp.status_code = 200 + page_resp.text = ( + '' + ) + + def _get(url, **kwargs): + return api_resp if "/api/campaigns" in url else page_resp + + with patch( + "backend.app.services.patreon_resolver.requests.get", side_effect=_get + ): + result = await resolve_campaign_id("alice", cookies_path=None) + assert result == "7654321" + + +def test_scrape_campaign_id_patterns(): + from backend.app.services.patreon_resolver import _scrape_campaign_id + + assert _scrape_campaign_id('foo /api/campaigns/424242 bar') == "424242" + assert _scrape_campaign_id('"campaign_id":"99"') == "99" + assert _scrape_campaign_id('{"id":"5","type":"campaign"}') == "5" + assert _scrape_campaign_id("no id here") is None + assert _scrape_campaign_id(None) is None + + # -- resolve_campaign_id_for_source (shared by download + verify) ----------