feat(patreon): resolve the creator's campaign from a single-post URL
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 35s
CI / integration (push) Successful in 3m2s

A source URL like https://www.patreon.com/posts/mimic-in-dungeon-158372536 is a
single-post permalink, not a creator page — the resolver grabbed "posts" as the
vanity and failed (operator-flagged 2026-06-07). Add a resolution path: extract
the trailing post id and follow it to the owning campaign via the Patreon post
API (/api/posts/<id>?include=campaign), so pasting any post URL subscribes to
that creator's whole feed. `posts/` is excluded from the vanity regex so it
can't masquerade as a creator slug.

Resolution order is now: cached override → id: URL → /posts/<id> → vanity
(campaigns API + creator-page scrape). Tests cover the post→campaign resolve
and that /posts/ URLs aren't treated as vanities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 21:32:15 -04:00
parent a3c9499e93
commit 416d8d71cd
2 changed files with 95 additions and 4 deletions
+63 -4
View File
@@ -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:<digits>` 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/<slug>-<post_id> (or bare
# /posts/<post_id>). 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/<id>?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:<digits>` URL → a
`/posts/<id>` 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)
+32
View File
@@ -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/<slug>-<id> 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]