What you pay for, what Discord drops, and pixiv switched off #251
@@ -211,6 +211,59 @@ class PostRecordOutcome:
|
|||||||
body_chars: int
|
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) ----------------------
|
# -- base downloader (shared fetch/validate plumbing) ----------------------
|
||||||
|
|
||||||
class BaseNativeDownloader:
|
class BaseNativeDownloader:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ..utils.paths import filehash_from_url
|
|||||||
from ..utils.prosemirror import post_body_html
|
from ..utils.prosemirror import post_body_html
|
||||||
from .native_ingest_common import (
|
from .native_ingest_common import (
|
||||||
_MAX_429_RETRIES,
|
_MAX_429_RETRIES,
|
||||||
|
Membership,
|
||||||
NativeAuthError,
|
NativeAuthError,
|
||||||
NativeDriftError,
|
NativeDriftError,
|
||||||
NativeIngestError,
|
NativeIngestError,
|
||||||
@@ -163,44 +164,6 @@ class MediaItem:
|
|||||||
post_id: str
|
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:
|
def _filehash(url: str) -> str | None:
|
||||||
# Delegate to the shared extractor (utils.paths) so capture-time persistence
|
# Delegate to the shared extractor (utils.paths) so capture-time persistence
|
||||||
# and render-time inline-image matching use the EXACT same identity.
|
# and render-time inline-image matching use the EXACT same identity.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services.native_ingest_common import Membership
|
||||||
from backend.app.services.patreon_client import (
|
from backend.app.services.patreon_client import (
|
||||||
Membership,
|
|
||||||
PatreonClient,
|
PatreonClient,
|
||||||
PatreonDriftError,
|
PatreonDriftError,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user