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
+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]