96c29c370b
Render a post body faithfully by serving our stored copies of inline images instead of hotlinking the public CDN. The join key is the CDN filehash (32-hex MD5) shared between a body <img src> and the media URL we downloaded (the same identity extract_media dedups by): - utils.paths.filehash_from_url — one source of truth for the extractor; patreon_client._filehash now delegates so capture- and render-time hashing cannot drift. - ImageRecord gains source_url (provenance) + source_filehash (indexed match key); migration 0051. - the per-media sidecar carries the file's source_url; the importer persists it (NULL-only) on the ImageRecord via _apply_sidecar. - post_feed_service.get_post remaps body <img src> -> /images/<path> for every inline image whose filehash maps to a stored image of THIS artist; unmatched / pre-Phase-2 images keep hotlinking. Pre-existing on-disk images have no filehash yet, so they fall back to hotlinking until re-downloaded; localization is forward-looking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
196 lines
6.7 KiB
Python
196 lines
6.7 KiB
Python
"""Minimal gallery-dl sidecar parsing (one-time filesystem-import aid).
|
|
|
|
Per-platform quirks (post_url synthesis, key-chain overrides) live in
|
|
the platforms registry — `backend/app/services/platforms/`. This module
|
|
is platform-agnostic: it looks up `category` in the sidecar and asks
|
|
the registry for the right behavior.
|
|
"""
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from ..services.platforms import (
|
|
PLATFORMS,
|
|
description_keys_for,
|
|
external_post_id_keys_for,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SidecarData:
|
|
platform: str | None
|
|
external_post_id: str | None
|
|
post_url: str | None
|
|
post_title: str | None
|
|
description: str | None
|
|
attachment_count: int | None
|
|
post_date: datetime | None
|
|
# Per-FILE CDN/origin URL the media was downloaded from (#830 Phase 2). Only
|
|
# the per-media sidecar carries it; post-only sidecars leave it None. The
|
|
# importer persists it on the ImageRecord so the body's inline `<img src>`
|
|
# can be matched (by filehash) to the local copy at render time.
|
|
source_url: str | None
|
|
raw: dict
|
|
|
|
|
|
# gallery-dl prefixes media filenames with `NN_` for in-post ordering
|
|
# (`01_HOLLOW-ICHIGO.png`, `02_HOLOW ICHIGO.zip`) but writes the sidecar
|
|
# under the attachment's stem WITHOUT that ordering prefix
|
|
# (`HOLLOW-ICHIGO.json`). Strip the prefix when looking for sidecars.
|
|
# Confirmed against real Patreon downloads 2026-05-26 — without this
|
|
# strip, every gallery-dl post-level sidecar was invisible to FC since
|
|
# FC-3 shipped (24 deep-refresh calls produced 0 Posts in operator's DB).
|
|
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")
|
|
|
|
|
|
def find_sidecar(media: Path) -> Path | None:
|
|
# Attachment-level sidecars (image.jpg.json, image.json).
|
|
for cand in (media.with_suffix(".json"), Path(str(media) + ".json")):
|
|
if cand.is_file():
|
|
return cand
|
|
# gallery-dl post-numbered convention: strip the `NN_` prefix from
|
|
# the stem and look for that.json in the same directory.
|
|
m = _NUMBERING_PREFIX.match(media.stem)
|
|
if m:
|
|
cand = media.parent / f"{m.group(1)}.json"
|
|
if cand.is_file():
|
|
return cand
|
|
return None
|
|
|
|
|
|
def _first_str(data: dict, keys: tuple[str, ...]) -> str | None:
|
|
for k in keys:
|
|
v = data.get(k)
|
|
if isinstance(v, str) and v.strip():
|
|
return v.strip()
|
|
return None
|
|
|
|
|
|
def _first_id(data: dict, keys: tuple[str, ...]) -> str | None:
|
|
"""Like `_first_str` but accepts ints and rejects bool (Python's
|
|
bool subclasses int, so a literal `"id": true` would otherwise
|
|
yield external_post_id="True")."""
|
|
for k in keys:
|
|
v = data.get(k)
|
|
if isinstance(v, bool):
|
|
continue
|
|
if isinstance(v, (str, int)) and str(v).strip():
|
|
return str(v).strip()
|
|
return None
|
|
|
|
|
|
# Strip HTML tags + collapse whitespace + take the first non-empty line.
|
|
# Used to derive a display title from a body when the platform doesn't
|
|
# expose a separate title field (subscribestar posts always write
|
|
# `title: ""` and put the leading sentence inside `content` as HTML).
|
|
# Truncated to 120 chars with an ellipsis if longer — long enough to be
|
|
# meaningful in a feed, short enough to fit a row.
|
|
_TAG_RE = re.compile(r"<[^>]+>")
|
|
_WS_RE = re.compile(r"\s+")
|
|
|
|
|
|
def _first_line_text(body: str, limit: int = 120) -> str | None:
|
|
if not body:
|
|
return None
|
|
text = _TAG_RE.sub(" ", body)
|
|
text = text.replace("\xa0", " ")
|
|
# Split on hard line breaks first; the body-stripped HTML often
|
|
# collapses to one logical line, in which case the first sentence
|
|
# split is the next-best heuristic.
|
|
for line in text.splitlines():
|
|
line = _WS_RE.sub(" ", line).strip()
|
|
if line:
|
|
if len(line) > limit:
|
|
return line[: limit - 1].rstrip() + "…"
|
|
return line
|
|
return None
|
|
|
|
|
|
def _parse_date(v) -> datetime | None:
|
|
if isinstance(v, bool):
|
|
return None
|
|
if isinstance(v, (int, float)):
|
|
try:
|
|
return datetime.fromtimestamp(v, tz=UTC)
|
|
except (OverflowError, OSError, ValueError):
|
|
return None
|
|
if isinstance(v, str):
|
|
s = v.strip()
|
|
if not s:
|
|
return None
|
|
if s.endswith("Z"):
|
|
s = s[:-1] + "+00:00"
|
|
try:
|
|
dt = datetime.fromisoformat(s)
|
|
except ValueError:
|
|
return None
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
return None
|
|
|
|
|
|
def parse_sidecar(data: dict) -> SidecarData:
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
|
|
cat = data.get("category")
|
|
platform = cat if isinstance(cat, str) and cat.strip() else None
|
|
|
|
external_post_id = _first_id(data, external_post_id_keys_for(platform))
|
|
|
|
pc = data.get("page_count")
|
|
if isinstance(pc, bool):
|
|
attachment_count = None
|
|
elif isinstance(pc, int) and pc > 0:
|
|
attachment_count = pc
|
|
elif isinstance(data.get("images"), list):
|
|
attachment_count = len(data["images"])
|
|
else:
|
|
attachment_count = None
|
|
|
|
post_date = None
|
|
for k in ("published_at", "created_at", "date", "create_date",
|
|
"timestamp"):
|
|
if k in data:
|
|
post_date = _parse_date(data[k])
|
|
if post_date is not None:
|
|
break
|
|
|
|
description = _first_str(data, description_keys_for(platform))
|
|
|
|
# When `title` is empty (subscribestar always; sometimes elsewhere),
|
|
# synthesize from the description body's first non-empty text line.
|
|
# Patreon's explicit titles short-circuit the fallback.
|
|
post_title = _first_str(data, ("title",))
|
|
if post_title is None and description:
|
|
post_title = _first_line_text(description)
|
|
|
|
# post_url: ask the platform module to synthesize a permalink.
|
|
# When the platform registers a `derive_post_url`, it owns the
|
|
# field (the bare `url`/`post_url` value is a file CDN URL and
|
|
# must NEVER be persisted). When it doesn't register one, trust
|
|
# the sidecar's `url` (Patreon's case — real permalink).
|
|
info = PLATFORMS.get(platform) if platform else None
|
|
if info is not None and info.derive_post_url is not None:
|
|
post_url = info.derive_post_url(data)
|
|
else:
|
|
post_url = _first_str(data, ("url", "post_url"))
|
|
|
|
# Per-file source URL (#830 Phase 2): the native downloader writes it into
|
|
# each media sidecar. `url` is the post permalink for Patreon (handled
|
|
# above), so source_url has its own dedicated key and never collides.
|
|
source_url = _first_str(data, ("source_url",))
|
|
|
|
return SidecarData(
|
|
platform=platform,
|
|
external_post_id=external_post_id,
|
|
post_url=post_url,
|
|
post_title=post_title,
|
|
description=description,
|
|
attachment_count=attachment_count,
|
|
post_date=post_date,
|
|
source_url=source_url,
|
|
raw=data,
|
|
)
|