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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
'<html><script>window.patreon={"bootstrap":{"campaign":'
|
||||
'{"data":{"id":"7654321","type":"campaign"}}}}</script></html>'
|
||||
)
|
||||
|
||||
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) ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user