feat: SubscribeStar joins the membership roster (387 D1)
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 55s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m41s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m12s
The second platform through the seam note 3970 contracted, characterized first from a live capture of the account's /subscriptions page (note 3989). The capture lives in the gitignored captures dir; the committed fixture is hand-built with invented values and was verified tag-for-tag against it - card wrappers, both table heads, and every distinct row shape - before any code depended on it. What the page is, and the three decisions it forced: The table IS the status. SubscribeStar has no per-row status word: a creator is either in the active_subscriptions card or the cancelled_subscriptions one. The card's data-identifier is stored verbatim as Membership.status and mapped in MEMBERSHIP_STATUS, keyed on the identifier rather than the table class because the cancelled table's class names the same list differently (for-unsubscribed_users). The creator's numeric data-user-id is the key, not the slug. A slug re-keys when a creator renames; the old row stops appearing; and a disappearance is exactly what reconciliation reads as a lapse. Keyed on the slug, a rename would have told a paying subscriber they had cancelled. The slug rides as vanity, where the identity join already looks for a handle. Price is kept as text, never parsed into amount_cents. A bare $ names no currency and a page price is not proven to be the charge - 3970 finding 4. Tier names live behind a per-row modal and are not fetched. Refusals, because SubscribeStar offers nothing like Patreon's meta.pagination.total and every conclusion downstream is drawn from absence. The parser raises when: the active card is missing (auth error on a login/age wall, drift otherwise); a row lacks a numeric creator id or a creator link; anything renders after a card's table; or the page carries a page= link. Both cards are paginatable (app#embed_pagination) and the captured account was too small to show what pagination looks like, so possible pagination is a roster FC cannot prove complete. A loud error on a larger account beats a quiet half-list. A missing cancelled card is not drift, and a creator in both tables is reported once, as active. Fetched from subscribestar.adult, not the .art the capture came from: FC's requests never clear the .art age wall with the 18+ cookie (1259, 1284). Whether /subscriptions on .adult authenticates exactly as .art did in the browser is untested - if not, the sweep records a visible error and C6 shows its unavailable rung. The seam leak D1 found. Note 3970 promised a second platform would be one builders line plus the client method. The sweep instead called current_user_id() on every client, which only Patreon's has, so SubscribeStar would have raised AttributeError on the first sweep. roster_user_id probes it with getattr, the same way the sweep already probes iter_memberships. Two existing tests were passing for the wrong reason and now can fail: - "a platform that has never been characterised says nothing" named SubscribeStar, and stayed green only because active_patron is not a SubscribeStar word. Now uses hentaifoundry, with a positive SubscribeStar test beside it. - the freshness test gave SubscribeStar a Patreon word, so the vocabulary excluded it and deleting the freshness gate outright would have left it green. It now uses cancelled_subscriptions, making the gate the only thing that excludes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
@@ -60,11 +60,21 @@ log = logging.getLogger(__name__)
|
||||
# Unknown words are NOT an error: an unrecognised status means the roster
|
||||
# records evidence it cannot yet interpret, which is a better state than
|
||||
# dropping the row or asserting a meaning for it.
|
||||
#
|
||||
# subscribestar: from a live capture of the account's /subscriptions page,
|
||||
# 2026-09-13 (Scribe note #3989). SubscribeStar gives NO per-row status word —
|
||||
# a membership's state is which of two tables it sits in — so the "word" stored
|
||||
# is the table card's own `data-identifier`, verbatim. Those two identifiers are
|
||||
# the whole vocabulary; there is nothing further to characterise later.
|
||||
MEMBERSHIP_STATUS: dict[str, dict[str, bool]] = {
|
||||
"patreon": {
|
||||
"active_patron": True,
|
||||
"former_patron": False,
|
||||
},
|
||||
"subscribestar": {
|
||||
"active_subscriptions": True,
|
||||
"cancelled_subscriptions": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +204,24 @@ async def _record_sync(session: AsyncSession, platform: str, **values) -> None:
|
||||
))
|
||||
|
||||
|
||||
def roster_user_id(client) -> str | None:
|
||||
"""The account id a client's roster walk needs, if that client needs one.
|
||||
|
||||
Patreon's members endpoint filters on the account's own user id, so the
|
||||
sweep has to resolve it first. SubscribeStar's /subscriptions page is simply
|
||||
the logged-in account's, with nothing to resolve. Probed with `getattr`,
|
||||
the same way the sweep probes `iter_memberships` itself (rule #169), rather
|
||||
than called unconditionally.
|
||||
|
||||
Calling `current_user_id()` unconditionally was the one place the membership
|
||||
seam was still Patreon-shaped: note #3970 promised a second platform would be
|
||||
one `builders` line plus the client method, and D1 found the sweep would
|
||||
instead have crashed on the first client without that method.
|
||||
"""
|
||||
resolve = getattr(client, "current_user_id", None)
|
||||
return resolve() if resolve is not None else None
|
||||
|
||||
|
||||
async def sync_platform(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user