feat(fc3c): PatreonResolver — vanity → campaign_id via requests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""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 requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
|
||||
_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)
|
||||
@@ -0,0 +1,88 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.patreon_resolver import resolve_campaign_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_on_happy_path():
|
||||
fake_response = MagicMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json.return_value = {"data": [{"id": "12345678", "type": "campaign"}]}
|
||||
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
|
||||
result = await resolve_campaign_id("alice", cookies_path=None)
|
||||
assert result == "12345678"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_401():
|
||||
fake_response = MagicMock()
|
||||
fake_response.status_code = 401
|
||||
fake_response.json.return_value = {}
|
||||
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
|
||||
result = await resolve_campaign_id("alice", cookies_path=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_network_error():
|
||||
import requests
|
||||
with patch(
|
||||
"backend.app.services.patreon_resolver.requests.get",
|
||||
side_effect=requests.ConnectionError("nope"),
|
||||
):
|
||||
result = await resolve_campaign_id("alice", cookies_path=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_empty_data():
|
||||
fake_response = MagicMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json.return_value = {"data": []}
|
||||
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
|
||||
result = await resolve_campaign_id("alice", cookies_path=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_malformed_json():
|
||||
fake_response = MagicMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json.side_effect = ValueError("not json")
|
||||
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
|
||||
result = await resolve_campaign_id("alice", cookies_path=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_cookies_file_does_not_raise(tmp_path):
|
||||
fake_response = MagicMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json.return_value = {"data": [{"id": "1", "type": "campaign"}]}
|
||||
with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response):
|
||||
result = await resolve_campaign_id(
|
||||
"alice", cookies_path=str(tmp_path / "does-not-exist.txt")
|
||||
)
|
||||
assert result == "1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loads_cookies_file_when_present(tmp_path):
|
||||
cookies = tmp_path / "cookies.txt"
|
||||
cookies.write_text(
|
||||
"# Netscape HTTP Cookie File\n"
|
||||
".patreon.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc\n"
|
||||
)
|
||||
fake_response = MagicMock()
|
||||
fake_response.status_code = 200
|
||||
fake_response.json.return_value = {"data": [{"id": "9", "type": "campaign"}]}
|
||||
with patch(
|
||||
"backend.app.services.patreon_resolver.requests.get",
|
||||
return_value=fake_response,
|
||||
) as mock_get:
|
||||
result = await resolve_campaign_id("alice", cookies_path=str(cookies))
|
||||
assert result == "9"
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs.get("cookies") is not None
|
||||
Reference in New Issue
Block a user