feat(patreon): resolve the creator's campaign from a single-post URL
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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user