What you pay for, what Discord drops, and pixiv switched off #251

Merged
bvandeusen merged 37 commits from dev into main 2026-09-13 12:09:28 -04:00
3 changed files with 55 additions and 39 deletions
Showing only changes of commit 4533e036ac - Show all commits
@@ -211,6 +211,59 @@ class PostRecordOutcome:
body_chars: int
# -- membership roster seam (shared dataclass, #387 C2/C7) -----------------
@dataclass
class Membership:
"""One membership the ACCOUNT holds, as the roster needs it (#387 C2).
Lives HERE rather than in the platform module that first produced it, for
the same reason `PostRecordOutcome` does: it is the seam's contract, not
Patreon's. C7 moved it — while it sat in `patreon_client` a second platform
would have had to import its contract from the first platform's module,
which inverts the dependency and is how a "portable" seam quietly becomes
Patreon-shaped.
Deliberately not a raw upstream row: the sweep should not have to know that
a tier lives behind a JSON:API `reward` relationship, and
`platform_membership` should not gain columns because one platform shapes
things a certain way.
`status` carries the PLATFORM's own word, verbatim and unmapped
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
site's job — `membership_roster.has_paid_access` — precisely so an
unrecognised word records as evidence rather than as a decision.
`is_free_member` is SEPARATE from status and must stay that way. Patreon
expresses a free follow as this boolean rather than as a status value, so
"does the account pay for this" is `status == "active_patron" and not
is_free_member` — a question the status string alone cannot answer. NOTE:
the C0 capture contains no ACTIVE free member, so the two fields are
perfectly correlated in that sample; the separation is what the schema
says, not something the sample proves.
A platform that lacks a field supplies the empty answer, never a guess:
no tiers -> `[]`, no pledge -> `amount_cents=None` (absent stays
distinguishable from zero — "free" and "we don't know" are different
answers), no vanity -> None and identity falls back to the URL tail.
"""
campaign_id: str
display_name: str | None
url: str | None
vanity: str | None
status: str | None
is_free_member: bool
tier_names: list[str]
amount_cents: int | None
currency: str | None
# Everything the roster did not model, kept so a later question can be
# answered without another authenticated round-trip. Scoped to the
# membership's own attributes plus the creator's — never the raw page,
# which is where the card/address resources live.
details: dict
# -- base downloader (shared fetch/validate plumbing) ----------------------
class BaseNativeDownloader:
+1 -38
View File
@@ -53,6 +53,7 @@ from ..utils.paths import filehash_from_url
from ..utils.prosemirror import post_body_html
from .native_ingest_common import (
_MAX_429_RETRIES,
Membership,
NativeAuthError,
NativeDriftError,
NativeIngestError,
@@ -163,44 +164,6 @@ class MediaItem:
post_id: str
@dataclass
class Membership:
"""One membership the ACCOUNT holds, as the roster needs it (#387 C2).
Deliberately not a raw JSON:API row: the sweep (C3) should not have to know
that a tier lives behind a `reward` relationship, and `platform_membership`
should not gain columns because Patreon shapes things a certain way.
`status` carries the PLATFORM's own word, verbatim and unmapped
(`active_patron`, `former_patron`, ...). Deciding what it means is the read
site's job — `membership_roster.has_paid_access` — precisely so an
unrecognised word records as evidence rather than as a decision.
`is_free_member` is SEPARATE from status and must stay that way. The
capture shows Patreon expressing a free follow as this boolean rather than
as a status value, so "does the account pay for this" is
`status == "active_patron" and not is_free_member` — a question the status
string alone cannot answer. NOTE: the capture contains no ACTIVE free
member, so the two fields are perfectly correlated in that sample; the
separation is what the schema says, not something the sample proves.
"""
campaign_id: str
display_name: str | None
url: str | None
vanity: str | None
status: str | None
is_free_member: bool
tier_names: list[str]
amount_cents: int | None
currency: str | None
# Everything the roster did not model, kept so a later question can be
# answered without another authenticated round-trip. Scoped to the member's
# own attributes plus the campaign's — never the raw page, which is where
# the card/address resources live.
details: dict
def _filehash(url: str) -> str | None:
# Delegate to the shared extractor (utils.paths) so capture-time persistence
# and render-time inline-image matching use the EXACT same identity.
+1 -1
View File
@@ -18,8 +18,8 @@ from pathlib import Path
import pytest
from backend.app.services.native_ingest_common import Membership
from backend.app.services.patreon_client import (
Membership,
PatreonClient,
PatreonDriftError,
)