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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CAMPAIGNS_URL = "https://www.patreon.com/api/campaigns"
|
_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
|
# 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
|
# (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
|
# `cw/Atole` matches the bare branch and yields vanity="cw" (operator-flagged
|
||||||
# 2026-06-07: every `/cw/` source failed resolution on vanity="cw").
|
# 2026-06-07: every `/cw/` source failed resolution on vanity="cw").
|
||||||
_ID_URL_RE = re.compile(r"/id:(\d+)")
|
_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_PREFIXES = ("cw/", "c/")
|
||||||
_VANITY_RE = re.compile(
|
_VANITY_RE = re.compile(
|
||||||
r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!id:)([^/?#]+)",
|
r"^https?://(?:www\.)?patreon\.com/(?:cw/|c/)?(?!(?:id:|posts/))([^/?#]+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_USER_AGENT = (
|
_USER_AGENT = (
|
||||||
@@ -178,6 +185,41 @@ def _lookup_via_page(vanity: str, cookies_path: str | None) -> str | None:
|
|||||||
return 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(
|
async def resolve_campaign_id(
|
||||||
vanity: str,
|
vanity: str,
|
||||||
cookies_path: str | None,
|
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)
|
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:
|
def extract_vanity(url: str) -> str | None:
|
||||||
"""The vanity slug from a Patreon creator URL, or None for an `id:` URL."""
|
"""The vanity slug from a Patreon creator URL, or None for an `id:` URL."""
|
||||||
m = _VANITY_RE.match(url or "")
|
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.
|
shared by the download ingester and the credential-verify probe.
|
||||||
|
|
||||||
Order: cached `patreon_campaign_id` override → an `id:<digits>` URL → a
|
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
|
vanity lookup against the campaigns API. Returns
|
||||||
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None ONLY when
|
`(campaign_id, newly_resolved_id)`: `newly_resolved_id` is non-None whenever
|
||||||
a vanity lookup actually ran, so the caller knows to cache it on the source
|
a lookup actually ran, so the caller caches it on the source (the
|
||||||
(the override/id: paths needed no lookup). `(None, None)` when unresolvable.
|
override/id: paths needed no lookup). `(None, None)` when unresolvable.
|
||||||
Never raises.
|
Never raises.
|
||||||
"""
|
"""
|
||||||
overrides = overrides or {}
|
overrides = overrides or {}
|
||||||
@@ -216,6 +269,12 @@ async def resolve_campaign_id_for_source(
|
|||||||
id_match = _ID_URL_RE.search(url or "")
|
id_match = _ID_URL_RE.search(url or "")
|
||||||
if id_match:
|
if id_match:
|
||||||
return id_match.group(1), None
|
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)
|
vanity = extract_vanity(url)
|
||||||
if vanity:
|
if vanity:
|
||||||
resolved = await resolve_campaign_id(vanity, cookies_path)
|
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"
|
assert extract_vanity("https://patreon.com/cw/Atole") == "Atole"
|
||||||
# id: URLs are NOT vanities (handled by the id path).
|
# id: URLs are NOT vanities (handled by the id path).
|
||||||
assert extract_vanity("https://www.patreon.com/id:123") is None
|
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