diff --git a/backend/app/services/membership_roster.py b/backend/app/services/membership_roster.py
index 2c52888..8cf4945 100644
--- a/backend/app/services/membership_roster.py
+++ b/backend/app/services/membership_roster.py
@@ -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,
*,
diff --git a/backend/app/services/subscribestar_client.py b/backend/app/services/subscribestar_client.py
index ffdedba..0a1751b 100644
--- a/backend/app/services/subscribestar_client.py
+++ b/backend/app/services/subscribestar_client.py
@@ -43,6 +43,7 @@ import requests
from ..utils.paths import filehash_from_url
from .native_ingest_common import (
_MAX_429_RETRIES,
+ Membership,
NativeAuthError,
NativeDriftError,
NativeIngestError,
@@ -297,6 +298,196 @@ def _extract_creator_name(html: str) -> str | None:
return name or None
+# -- membership roster (#387 D1) ------------------------------------------
+#
+# Characterized from a live operator capture of the account's /subscriptions
+# page, 2026-09-13 — Scribe note #3989. Read that note before changing any of
+# this; each constant below is a finding from it, not a guess.
+
+# The account page is fetched from `.adult`. The `.art` age wall never clears
+# with the 18+ cookie for FC's requests (see _normalize_ss_host, issues #1259 /
+# #1284). The capture itself came from `.art` only because a human had clicked
+# through the gate in the browser.
+_ROSTER_BASE = "https://subscribestar.adult"
+_ROSTER_URL = f"{_ROSTER_BASE}/subscriptions"
+
+# Two tables, and WHICH table a creator sits in is the only status the page
+# gives — there is no per-row status word. Keyed on each card's
+# `data-identifier`, the one vocabulary that names a state: the table class
+# inside the cancelled card says `for-unsubscribed_users`, a different word for
+# the same list (note #3989, CORRECTION 1). The identifier is stored verbatim as
+# Membership.status and mapped in membership_roster.MEMBERSHIP_STATUS.
+_ROSTER_ACTIVE = "active_subscriptions"
+_ROSTER_CANCELLED = "cancelled_subscriptions"
+
+_ROSTER_ROW_OPEN = '
'
+# Active rows nest a second `
` INSIDE the row's own
+#
— a narrow-screen duplicate of the actions cell. Its
s are not
+# columns, so every row is cut here before its cells are read.
+_ROSTER_NESTED_ROW = '
]*>([^<]*)")
+_ROSTER_HEAD_RE = re.compile(r'
]*>(.*?)
', re.DOTALL)
+_ROSTER_CELL_RE = re.compile(r'
]*>(.*?)
', re.DOTALL)
+_ROSTER_PAGE_LINK_RE = re.compile(r'href="[^"]*[?&]page=\d')
+_TAG_RE = re.compile(r"<[^>]+>")
+
+# Columns that hold identity or controls rather than facts about the
+# subscription, so they stay out of `details`. Matched on the header's own text,
+# lowercased — the page's words, not ours.
+_ROSTER_SKIP_COLUMNS = frozenset({"profile", "updates", "actions"})
+
+
+def _cell_text(fragment: str) -> str:
+ """Visible text of a cell: tags dropped, entities decoded, whitespace folded.
+
+ Decoding matters here specifically: an active row with no Discord link
+ renders its cell as the entity `—`, not as an empty cell.
+ """
+ return " ".join(unescape(_TAG_RE.sub(" ", fragment)).split())
+
+
+def _roster_table(html: str, identifier: str) -> tuple[str, str] | None:
+ """One roster card: (its table markup, whatever trails `` inside it).
+
+ None when the card is absent. The trailing part is returned rather than
+ discarded because it is the pagination check: in the characterized page a
+ card closes the moment its table does.
+ """
+ start = html.find(f'data-identifier="{identifier}"')
+ if start < 0:
+ return None
+ end = html.find("", start)
+ if end < 0:
+ raise SubscribeStarDriftError(
+ f"SubscribeStar roster card {identifier!r} has no table"
+ )
+ close = html.find("", end)
+ trailing = html[end + len(""): close if close >= 0 else len(html)]
+ return html[start:end], trailing
+
+
+def _roster_rows(table: str, identifier: str, base: str) -> list[Membership]:
+ labels = [_cell_text(h).lower() for h in _ROSTER_HEAD_RE.findall(table)]
+ body = table[table.find("
"):] if "" in table else ""
+ starts = [m.start() for m in re.finditer(re.escape(_ROSTER_ROW_OPEN), body)]
+ rows = []
+ for n, start in enumerate(starts):
+ row = body[start: starts[n + 1] if n + 1 < len(starts) else len(body)]
+ row = row.split(_ROSTER_NESTED_ROW, 1)[0]
+
+ href = _ROSTER_HREF_RE.search(row)
+ if href is None:
+ raise SubscribeStarDriftError(
+ f"SubscribeStar roster row in {identifier!r} has no creator link"
+ )
+ # The creator's numeric id, NOT the slug, is the key (note #3989,
+ # CORRECTION 2). A slug re-keys when a creator renames; the old row then
+ # stops appearing, and a disappearance is exactly what reconciliation
+ # reads as a lapse. The id survives a rename.
+ user_id = _ROSTER_USER_ID_RE.search(row)
+ if user_id is None or not user_id.group(1).isdigit():
+ raise SubscribeStarDriftError(
+ f"SubscribeStar roster row in {identifier!r} has no numeric "
+ f"data-user-id — a membership that cannot be attributed to a "
+ f"creator is not usable"
+ )
+ name = _ROSTER_NAME_RE.search(row)
+ slug = unescape(href.group(1))
+ cells = _ROSTER_CELL_RE.findall(row)
+ rows.append(Membership(
+ campaign_id=user_id.group(1),
+ display_name=(_cell_text(name.group(1)) if name else "") or None,
+ url=f"{base}/{slug}",
+ vanity=slug,
+ status=identifier,
+ # No free-follow concept on this page (#3970 §2: False when a
+ # platform has none).
+ is_free_member=False,
+ # Tier names live behind a per-row modal, not inline. Fetching every
+ # modal would be N authenticated requests for a field nothing reads.
+ tier_names=[],
+ # Deliberately NOT parsed from the price cell: a bare `$` names no
+ # currency, and a page price is not proven to be the charge (#3970
+ # finding 4). None keeps "unknown" distinct from zero. The raw text
+ # is kept in `details`.
+ amount_cents=None,
+ currency=None,
+ details={
+ # Paired with the header text by POSITION: two columns share the
+ # `for-date` class, and the updates column's
does not carry
+ # its
's class at all.
+ "columns": {
+ label: _cell_text(cell)
+ for label, cell in zip(labels, cells)
+ if label not in _ROSTER_SKIP_COLUMNS
+ },
+ },
+ ))
+ return rows
+
+
+def parse_subscriptions_page(html: str, *, base: str = _ROSTER_BASE) -> list[Membership]:
+ """Every membership on the account's /subscriptions page.
+
+ Refuses rather than guessing, because every conclusion downstream is drawn
+ from ABSENCE — a roster that comes back short reads as "you cancelled
+ those". So this raises when:
+
+ * the active card is missing — as SubscribeStarAuthError if the page is a
+ login or age wall (the fix is credentials), otherwise as drift;
+ * a row has no creator link or no numeric creator id;
+ * anything renders after a card's table, or the page carries a `page=` link.
+ Both cards are paginatable (`data-view="app#embed_pagination"`), and the
+ characterized account was too small to show what pagination looks like —
+ so possible pagination is treated as a roster FC cannot prove complete.
+
+ A missing cancelled card is NOT drift: an account that has never cancelled
+ plausibly has no such table. A creator present in both tables is reported
+ once, as active — a current subscription is the fact that matters.
+ """
+ active = _roster_table(html, _ROSTER_ACTIVE)
+ if active is None:
+ if any(marker in html for marker in _LOGIN_MARKERS):
+ raise SubscribeStarAuthError(
+ "SubscribeStar served a login/age wall instead of the "
+ "subscriptions page (cookies expired or age cookie missing)"
+ )
+ raise SubscribeStarDriftError(
+ f"SubscribeStar subscriptions page has no {_ROSTER_ACTIVE!r} card "
+ f"— {_describe_page(html)}"
+ )
+ roster_region = html[html.find(f'data-identifier="{_ROSTER_ACTIVE}"'):]
+ if _ROSTER_PAGE_LINK_RE.search(roster_region):
+ raise SubscribeStarDriftError(
+ "SubscribeStar subscriptions page carries a page= link — the roster "
+ "may be paginated, and FC cannot prove it is complete (note #3989)"
+ )
+
+ memberships: list[Membership] = []
+ seen: set[str] = set()
+ for identifier, found in (
+ (_ROSTER_ACTIVE, active),
+ (_ROSTER_CANCELLED, _roster_table(html, _ROSTER_CANCELLED)),
+ ):
+ if found is None:
+ continue
+ table, trailing = found
+ if trailing.strip():
+ raise SubscribeStarDriftError(
+ f"SubscribeStar roster card {identifier!r} renders content after "
+ f"its table — possibly pagination, so the roster cannot be "
+ f"proven complete (note #3989)"
+ )
+ for membership in _roster_rows(table, identifier, base):
+ if membership.campaign_id in seen:
+ continue
+ seen.add(membership.campaign_id)
+ memberships.append(membership)
+ return memberships
+
+
class SubscribeStarClient:
"""Synchronous SubscribeStar HTML-scrape read client. Construct with a path
to a Netscape cookies.txt (the same file CredentialService.get_cookies_path
@@ -645,6 +836,23 @@ class SubscribeStarClient:
return None
return _extract_creator_name(html)
+ # -- membership roster (#387 D1) ----------------------------------------
+
+ def iter_memberships(self, user_id: str | None = None) -> Iterator[Membership]:
+ """Yield every subscription the account holds (note #3989).
+
+ `user_id` exists for the seam's signature (note #3970) and is ignored:
+ the page is the logged-in account's own, so there is nothing to resolve.
+ The sweep only resolves an id for a client that exposes
+ `current_user_id`, which this one does not.
+
+ One request, and the whole page is parsed before anything is yielded, so
+ a drift error can never leave a caller holding part of a roster.
+ """
+ self._session.headers["Referer"] = f"{_ROSTER_BASE}/"
+ resp = self._get(_ROSTER_URL)
+ yield from parse_subscriptions_page(resp.text or "", base=_ROSTER_BASE)
+
# -- verify ------------------------------------------------------------
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]:
diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py
index 2af679c..6e2b47e 100644
--- a/backend/app/tasks/maintenance.py
+++ b/backend/app/tasks/maintenance.py
@@ -1247,17 +1247,20 @@ def sync_memberships() -> str:
from ..services.artist_membership_service import rescan as membership_rescan
from ..services.credential_crypto import CredentialCrypto
from ..services.credential_service import CredentialService
- from ..services.membership_roster import sync_platform
+ from ..services.membership_roster import roster_user_id, sync_platform
from ..services.patreon_client import PatreonClient
+ from ..services.subscribestar_client import SubscribeStarClient
from ._async_session import async_session_factory
key_path = IMAGES_ROOT / "secrets" / "credential_key.b64"
# platform -> how to build a client from a cookies path. A platform is in
# the sweep only if it is here AND its client exposes `iter_memberships`
- # AND a credential exists — three independent gates, each silent, so
- # adding SubscribeStar (D1) is one line here and nothing else.
- builders = {"patreon": PatreonClient}
+ # AND a credential exists — three independent gates, each silent, so a
+ # platform is added with one line here and nothing else. SubscribeStar (D1)
+ # was the second; the only other change it needed was `roster_user_id`
+ # replacing an unconditional Patreon-only call below.
+ builders = {"patreon": PatreonClient, "subscribestar": SubscribeStarClient}
async def _run() -> dict:
async_factory, engine = async_session_factory()
@@ -1287,8 +1290,7 @@ def sync_memberships() -> str:
# slow roster does not block the event loop, and bound the
# whole walk rather than only its individual requests.
def _walk():
- user_id = _client.current_user_id()
- return list(_client.iter_memberships(user_id))
+ return list(_client.iter_memberships(roster_user_id(_client)))
return await asyncio.wait_for(
asyncio.to_thread(_walk),
diff --git a/tests/fixtures/subscribestar_subscriptions_page1.html b/tests/fixtures/subscribestar_subscriptions_page1.html
new file mode 100644
index 0000000..138080c
--- /dev/null
+++ b/tests/fixtures/subscribestar_subscriptions_page1.html
@@ -0,0 +1,3 @@
+My Subscriptions | SubscribeStar.adult
diff --git a/tests/test_gated_reason.py b/tests/test_gated_reason.py
index fb179d5..8242a1b 100644
--- a/tests/test_gated_reason.py
+++ b/tests/test_gated_reason.py
@@ -74,8 +74,22 @@ def test_an_unrecognised_status_says_nothing(status):
def test_a_platform_that_has_never_been_characterised_says_nothing():
- """SubscribeStar and FANBOX (D1) inherit silence, not a Patreon guess."""
- assert gated_reason("subscribestar", "active_patron") is None
+ """An uncharacterised platform inherits silence, not a Patreon guess.
+
+ This named SubscribeStar until D1 characterised it (note #3989). Left as it
+ was, it would have kept passing only because `active_patron` is not a
+ SubscribeStar word, testing nothing its name claims.
+ """
+ assert gated_reason("hentaifoundry", "active_patron") is None
+
+
+def test_subscribestar_explains_the_gate_in_its_own_words():
+ """SubscribeStar's status is which table the creator sits in, and those
+ identifiers reach the same three reasons as Patreon's words."""
+ assert gated_reason("subscribestar", "active_subscriptions") == GATED_TIER
+ assert gated_reason("subscribestar", "cancelled_subscriptions") == GATED_LAPSED
+ # Patreon's vocabulary does not leak across platforms.
+ assert gated_reason("subscribestar", "former_patron") is None
# --- the join, against the database ----------------------------------------
@@ -178,8 +192,11 @@ async def test_one_platforms_fresh_sweep_does_not_vouch_for_anothers(db):
url="https://subscribestar.adult/maewix",
)
await _membership(db, status="former_patron")
+ # SubscribeStar's REAL word, so that the freshness gate is the only thing
+ # excluding this membership. With a Patreon word (as this test first had),
+ # the vocabulary excluded it and removing the gate would have left it green.
await _membership(db, platform="subscribestar", campaign="s1",
- status="former_patron",
+ status="cancelled_subscriptions",
url="https://subscribestar.adult/maewix",
details={"campaign": {"vanity": "maewix"}})
await _synced(db) # patreon only
diff --git a/tests/test_membership_roster.py b/tests/test_membership_roster.py
index 6d5b2fa..1e99855 100644
--- a/tests/test_membership_roster.py
+++ b/tests/test_membership_roster.py
@@ -170,8 +170,12 @@ def test_the_status_map_contains_only_characterised_values():
contain. Adding it because it "obviously" belongs is precisely the guess
this test exists to stop.
"""
+ # subscribestar added with its own capture (note #3989, 2026-09-13). Its
+ # "words" are the two table identifiers, because the page carries no
+ # per-row status at all.
assert MEMBERSHIP_STATUS == {
"patreon": {"active_patron": True, "former_patron": False},
+ "subscribestar": {"active_subscriptions": True, "cancelled_subscriptions": False},
}
diff --git a/tests/test_subscribestar_memberships.py b/tests/test_subscribestar_memberships.py
new file mode 100644
index 0000000..5c68704
--- /dev/null
+++ b/tests/test_subscribestar_memberships.py
@@ -0,0 +1,261 @@
+"""SubscribeStarClient.iter_memberships — parsing only, no network (#387 D1).
+
+The fixture mirrors a REAL capture of the operator's /subscriptions page
+(Scribe note #3989) with every value invented. Its structure was checked
+against the capture tag for tag before this file was written: the card
+wrappers, both table heads, and every distinct row shape — including the two
+forms the Discord cell takes, and the narrow-screen `
`
+that active rows nest INSIDE their own row.
+
+Most of what follows pins refusals. SubscribeStar gives no completeness signal
+the way Patreon's `meta.pagination.total` does, and every conclusion drawn from
+this roster is drawn from absence, so the parser's job is to raise whenever it
+cannot vouch for the whole list.
+
+`_get` is stubbed rather than mocked at the socket: these tests are about what
+the client does with a page, and the HTTP path is covered by
+test_subscribestar_client.py.
+"""
+
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app.services.membership_roster import has_paid_access, roster_user_id
+from backend.app.services.native_ingest_common import Membership
+from backend.app.services.subscribestar_client import (
+ SubscribeStarAuthError,
+ SubscribeStarClient,
+ SubscribeStarDriftError,
+ parse_subscriptions_page,
+)
+
+_FIXTURE = Path(__file__).parent / "fixtures" / "subscribestar_subscriptions_page1.html"
+
+
+@pytest.fixture
+def page():
+ return _FIXTURE.read_text()
+
+
+@pytest.fixture
+def client():
+ return SubscribeStarClient(cookies_path=None)
+
+
+def _serve(client, html):
+ calls = []
+
+ def fake(url, *, headers=None):
+ calls.append(url)
+ return SimpleNamespace(text=html)
+
+ client._get = fake
+ return calls
+
+
+def _by_id(memberships):
+ return {m.campaign_id: m for m in memberships}
+
+
+# --- the request -----------------------------------------------------------
+
+
+def test_the_roster_is_fetched_from_adult_not_art(client, page):
+ """The capture came from `.art`, but FC's requests never clear the `.art`
+ age wall with the 18+ cookie (#1259, #1284). Copying the browser's host is
+ the obvious move and would turn every sweep into an auth error."""
+ calls = _serve(client, page)
+ list(client.iter_memberships())
+ assert calls == ["https://subscribestar.adult/subscriptions"]
+
+
+def test_one_request_for_the_whole_roster(client, page):
+ calls = _serve(client, page)
+ list(client.iter_memberships(user_id="ignored"))
+ assert len(calls) == 1
+
+
+# --- what a membership contains --------------------------------------------
+
+
+def test_both_tables_are_read(page):
+ rows = parse_subscriptions_page(page)
+ assert all(isinstance(m, Membership) for m in rows)
+ assert len(rows) == 4
+ statuses = sorted(m.status for m in rows)
+ assert statuses == [
+ "active_subscriptions", "active_subscriptions",
+ "cancelled_subscriptions", "cancelled_subscriptions",
+ ]
+
+
+def test_the_creator_id_is_the_key_not_the_slug(page):
+ """A slug re-keys when a creator renames; the old row then stops appearing,
+ and reconciliation reads a disappearance as a lapse. The numeric id does not
+ change, so a rename can never surface as "you cancelled this creator"
+ (note #3989, CORRECTION 2)."""
+ rows = _by_id(parse_subscriptions_page(page))
+ assert set(rows) == {"1001", "2002002", "3003003", "4004"}
+ alpha = rows["1001"]
+ assert alpha.vanity == "creator-alpha"
+ # The handle is carried as a URL so `match_kind`'s URL-tail fallback reaches
+ # it — which is what a SubscribeStar Source.url ends in.
+ assert alpha.url == "https://subscribestar.adult/creator-alpha"
+ assert alpha.display_name == "Creator Alpha"
+
+
+def test_the_table_is_the_status(page):
+ """No per-row status word exists. The card's `data-identifier` is stored
+ verbatim — and is exactly what MEMBERSHIP_STATUS maps, so a renamed
+ identifier fails here rather than silently reading as unknown."""
+ rows = _by_id(parse_subscriptions_page(page))
+ assert rows["1001"].status == "active_subscriptions"
+ assert rows["3003003"].status == "cancelled_subscriptions"
+ assert has_paid_access("subscribestar", rows["1001"].status) is True
+ assert has_paid_access("subscribestar", rows["3003003"].status) is False
+
+
+def test_price_is_kept_as_text_and_never_parsed_into_money(page):
+ """A bare `$` names no currency and a page price is not proven to be the
+ charge. None keeps "unknown" distinct from zero (#3970 finding 4)."""
+ rows = _by_id(parse_subscriptions_page(page))
+ alpha = rows["1001"]
+ assert alpha.amount_cents is None
+ assert alpha.currency is None
+ assert alpha.details["columns"]["price"] == "$5"
+ assert alpha.tier_names == []
+ assert alpha.is_free_member is False
+
+
+def test_columns_are_paired_by_position_not_by_class(page):
+ """Two active columns share the `for-date` class, and the updates column's
+
does not carry its
's class — pairing by class would merge or drop
+ them. Controls and identity stay out of details."""
+ cols = _by_id(parse_subscriptions_page(page))["1001"].details["columns"]
+ assert cols == {
+ "subscribed": "Jan 2026",
+ "renewed": "Mar 2026",
+ "paused": "-",
+ "price": "$5",
+ "discord": "Joined",
+ }
+ cancelled = _by_id(parse_subscriptions_page(page))["3003003"].details["columns"]
+ assert cancelled == {"unsubscribed": "Dec 2025", "price": "$3"}
+
+
+def test_both_discord_cell_forms_read_as_text(page):
+ """The real page renders a linked Discord as a button with an icon, and an
+ unlinked one as the ENTITY `—`. Undecoded, the second would store the
+ literal string "—"."""
+ rows = _by_id(parse_subscriptions_page(page))
+ assert rows["1001"].details["columns"]["discord"] == "Joined"
+ assert rows["2002002"].details["columns"]["discord"] == "—"
+
+
+def test_the_nested_actions_row_is_not_read_as_columns(page):
+ """Active rows nest a narrow-screen `