What you pay for, what Discord drops, and pixiv switched off #251
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user