218bfebb92
The credential Verify button still ran gallery-dl --simulate for Patreon after the cutover — testing the wrong path (and prone to the vanity "Failed to extract campaign ID" the native resolver fixes). Wire it to the native ingester, behind a DRY dispatch so callers never branch on platform. - services/download_backends.py (new): the ONE place that knows which platforms are native vs gallery-dl. `uses_native_ingester(platform)` is the shared predicate; `verify_source_credential(...)` is the uniform probe (same (ok|None, message) contract for both backends). As a platform migrates, it moves into NATIVE_INGESTER_PLATFORMS here and BOTH download routing and verify switch together. - PatreonClient.verify_auth(campaign_id): one authenticated /api/posts fetch → True (valid) / False (401/403/HTML-login) / None (drift or network — inconclusive, not a credential verdict). - patreon_ingester.verify_patreon_credential(): resolve campaign id, then verify_auth — the verify counterpart to the download path. - patreon_resolver.resolve_campaign_id_for_source(): extracted the override / id:-URL / vanity resolution into ONE helper now shared by the download ingester and verify (download_service no longer carries its own copy + regex; −`import re`). - download_service: routes on uses_native_ingester() instead of inline `== "patreon"` (3 sites); uses the shared resolver. - api/credentials: calls verify_source_credential — no platform branch. Tests: verify_auth mapping, resolve_campaign_id_for_source (override/id:/ vanity/none), the dispatch predicate, verify_patreon_credential glue, credentials endpoint proves Patreon uses the native path (gallery-dl verify asserted not-called); repointed the gallery-dl verify test to subscribestar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
4.8 KiB
Python
148 lines
4.8 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
|
|
|
|
|
|
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:
|
|
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
|
|
|
|
|
|
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
|