diff --git a/backend/app/services/patreon_resolver.py b/backend/app/services/patreon_resolver.py index 8c8d963..591745c 100644 --- a/backend/app/services/patreon_resolver.py +++ b/backend/app/services/patreon_resolver.py @@ -26,6 +26,7 @@ import requests log = logging.getLogger(__name__) _CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns" +_POSTS_API = "https://www.patreon.com/api/posts" # A source URL of the form `.../id:` already carries the campaign id # (no lookup needed). The vanity regex deliberately EXCLUDES the id: form so the @@ -38,9 +39,15 @@ _CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns" # `cw/Atole` matches the bare branch and yields vanity="cw" (operator-flagged # 2026-06-07: every `/cw/` source failed resolution on vanity="cw"). _ID_URL_RE = re.compile(r"/id:(\d+)") +# A single-post permalink — patreon.com/posts/- (or bare +# /posts/). The trailing digits are the post id; the creator's +# campaign is resolved from the post itself (operator-flagged 2026-06-07: a +# /posts/ source resolved vanity="posts"). `posts/` is excluded from the vanity +# regex so it never masquerades as a creator slug. +_POST_URL_RE = re.compile(r"/posts/(?:[^/?#]*-)?(\d+)(?:[/?#]|$)") _VANITY_PREFIXES = ("cw/", "c/") _VANITY_RE = re.compile( - r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!id:)([^/?#]+)", + r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!(?:id:|posts/))([^/?#]+)", re.IGNORECASE, ) _USER_AGENT = ( @@ -178,6 +185,41 @@ def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None: return None +def _lookup_campaign_from_post(post_id: str, cookies_path: str | None) -> str | None: + """Resolve the owning campaign id from a single post id via the Patreon + post API (`/api/posts/?include=campaign`). A `/posts/` source URL points + at one post, but a subscription walks the whole creator — so we follow the + post to its campaign. Never raises.""" + jar = _load_cookie_jar(cookies_path) + headers = {"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"} + params = {"include": "campaign", "fields[campaign]": "name"} + url = f"{_POSTS_API}/{post_id}" + try: + resp = requests.get( + url, params=params, headers=headers, cookies=jar, + timeout=_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + log.warning("Patreon post lookup failed for post=%s: %s", post_id, exc) + return None + if resp.status_code != 200: + log.warning("Patreon post API returned HTTP %d for post=%s", resp.status_code, post_id) + return None + try: + payload = resp.json() + except ValueError: + return None + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, dict): + return None + campaign = ((data.get("relationships") or {}).get("campaign") or {}).get("data") or {} + campaign_id = campaign.get("id") + if isinstance(campaign_id, str) and campaign_id: + log.info("Resolved Patreon post=%s → campaign_id=%s", post_id, campaign_id) + return campaign_id + return None + + async def resolve_campaign_id( vanity: str, cookies_path: str | None, @@ -188,6 +230,16 @@ async def resolve_campaign_id( return await loop.run_in_executor(None, _sync_lookup, vanity, cookies_path) +async def resolve_campaign_from_post( + post_id: str, cookies_path: str | None, +) -> str | None: + """Async wrapper around _lookup_campaign_from_post. Never raises.""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, _lookup_campaign_from_post, post_id, 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 "") @@ -203,10 +255,11 @@ async def resolve_campaign_id_for_source( shared by the download ingester and the credential-verify probe. Order: cached `patreon_campaign_id` override → an `id:` URL → a + `/posts/` permalink (resolve the owning campaign from the post) → 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. + `(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None whenever + a lookup actually ran, so the caller caches it on the source (the + override/id: paths needed no lookup). `(None, None)` when unresolvable. Never raises. """ overrides = overrides or {} @@ -216,6 +269,12 @@ async def resolve_campaign_id_for_source( id_match = _ID_URL_RE.search(url or "") if id_match: return id_match.group(1), None + # A single-post URL → follow the post to its creator's campaign so the + # source subscribes to the whole feed (a subscription isn't one post). + post_match = _POST_URL_RE.search(url or "") + if post_match: + resolved = await resolve_campaign_from_post(post_match.group(1), cookies_path) + return resolved, resolved vanity = extract_vanity(url) if vanity: resolved = await resolve_campaign_id(vanity, cookies_path) diff --git a/tests/test_patreon_resolver.py b/tests/test_patreon_resolver.py index 3b2e226..4a92b1c 100644 --- a/tests/test_patreon_resolver.py +++ b/tests/test_patreon_resolver.py @@ -183,3 +183,35 @@ def test_extract_vanity_handles_all_path_prefixes(): assert extract_vanity("https://patreon.com/cw/Atole") == "Atole" # id: URLs are NOT vanities (handled by the id path). assert extract_vanity("https://www.patreon.com/id:123") is None + # /posts/ permalinks are NOT vanities (handled by the post path) — must not + # masquerade as a creator slug named "posts". + assert extract_vanity( + "https://www.patreon.com/posts/mimic-in-dungeon-158372536" + ) is None + + +@pytest.mark.asyncio +async def test_for_source_resolves_post_url_to_campaign(): + """A /posts/- permalink resolves to the OWNING creator's campaign + (a subscription walks the whole feed, not one post).""" + post_resp = MagicMock() + post_resp.status_code = 200 + post_resp.json.return_value = { + "data": { + "id": "158372536", "type": "post", + "relationships": {"campaign": {"data": {"id": "9988", "type": "campaign"}}}, + } + } + calls = [] + + def _get(url, **kwargs): + calls.append(url) + return post_resp + + with patch("backend.app.services.patreon_resolver.requests.get", side_effect=_get): + cid, resolved = await resolve_campaign_id_for_source( + "https://www.patreon.com/posts/mimic-in-dungeon-158372536", None, {}, + ) + assert cid == "9988" + assert resolved == "9988" # newly resolved → caller caches it + assert calls and "/api/posts/158372536" in calls[0]