feat(posts): extract + record external file-host links (Phase 3)
Capture off-platform links (mega/gdrive/mediafire/dropbox/pixeldrain) embedded in post bodies so they're never silently dropped, and surface them in the post view. The download worker (Phase 4) walks these rows. - link_extract.py: pure extractor — <a href> + bare URLs, unwraps Patreon redirect shims, PRESERVES the full url incl. #fragment (mega's key), dedups. Reusable by every platform (runs off Post.description). - external_link model + migration 0049: post_id/artist_id/host/url/label/status /attempts/last_error/attachment_id/timing; CHECK whitelists (full enum incl. worker statuses up front) + (post_id,url) unique. - importer._sync_external_links: insert-missing on both import paths (_apply_sidecar + upsert_post_record) so a re-import never resets a link's status; runs for all platforms. - post_feed_service.get_post: returns external_links (detail-only). - PostCard: renders the links (host chip + label + status) once expanded. - tests: extractor (5 hosts, fragment, shim unwrap, dedup), importer (record + no-dup on reimport), serializer. Refs FC #830. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
"""external_link table — off-platform file-host links found in post bodies
|
||||||
|
|
||||||
|
Creators host the real files on mega.nz / Google Drive / MediaFire / Dropbox /
|
||||||
|
Pixeldrain and link them in the post text. This table records each such link
|
||||||
|
(so nothing is silently dropped), and doubles as the dedup + dead-letter ledger
|
||||||
|
the download worker (a later slice) walks. `url` keeps the FULL link including
|
||||||
|
the `#fragment` — mega.nz's decryption key lives there; truncating it makes the
|
||||||
|
file undownloadable.
|
||||||
|
|
||||||
|
CHECK whitelists for host + status include the full enum up front (incl. the
|
||||||
|
download-worker statuses) so the worker slice needs no constraint migration.
|
||||||
|
|
||||||
|
Revision ID: 0049
|
||||||
|
Revises: 0048
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0049"
|
||||||
|
down_revision: Union[str, None] = "0048"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"external_link",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"post_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("post.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"artist_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("artist.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("host", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("label", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status", sa.String(length=16), nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"attachment_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("post_attachment.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("duration_seconds", sa.Float(), nullable=True),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"host IN ('mega','gdrive','mediafire','dropbox','pixeldrain')",
|
||||||
|
name="ck_external_link_host",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"status IN ('pending','downloading','downloaded','failed',"
|
||||||
|
"'skipped','dead')",
|
||||||
|
name="ck_external_link_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_external_link_post_id", "external_link", ["post_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_external_link_artist_id", "external_link", ["artist_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_external_link_status", "external_link", ["status"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_external_link_post_url", "external_link", ["post_id", "url"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_external_link_post_url", table_name="external_link")
|
||||||
|
op.drop_index("ix_external_link_status", table_name="external_link")
|
||||||
|
op.drop_index("ix_external_link_artist_id", table_name="external_link")
|
||||||
|
op.drop_index("ix_external_link_post_id", table_name="external_link")
|
||||||
|
op.drop_table("external_link")
|
||||||
@@ -7,6 +7,7 @@ from .backup_run import BackupRun
|
|||||||
from .base import Base
|
from .base import Base
|
||||||
from .credential import Credential
|
from .credential import Credential
|
||||||
from .download_event import DownloadEvent
|
from .download_event import DownloadEvent
|
||||||
|
from .external_link import ExternalLink
|
||||||
from .image_prediction import ImagePrediction
|
from .image_prediction import ImagePrediction
|
||||||
from .image_provenance import ImageProvenance
|
from .image_provenance import ImageProvenance
|
||||||
from .image_record import ImageRecord
|
from .image_record import ImageRecord
|
||||||
@@ -52,6 +53,7 @@ __all__ = [
|
|||||||
"TagKind",
|
"TagKind",
|
||||||
"image_tag",
|
"image_tag",
|
||||||
"DownloadEvent",
|
"DownloadEvent",
|
||||||
|
"ExternalLink",
|
||||||
"ImportBatch",
|
"ImportBatch",
|
||||||
"ImportTask",
|
"ImportTask",
|
||||||
"ImportSettings",
|
"ImportSettings",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""ExternalLink — an off-platform file-host link found in a post body.
|
||||||
|
|
||||||
|
Creators host the actual files (films, packs) on mega.nz / Google Drive /
|
||||||
|
MediaFire / Dropbox / Pixeldrain and drop the link in the post text. This row
|
||||||
|
is the record that the link existed (so nothing is silently dropped), the
|
||||||
|
dedup + dead-letter ledger for fetching it, and the driver the download worker
|
||||||
|
walks. `url` keeps the FULL link including the `#fragment` (mega's decryption
|
||||||
|
key) — truncating it makes the file undownloadable.
|
||||||
|
|
||||||
|
status lifecycle: pending → downloading → downloaded | failed | dead
|
||||||
|
(too many attempts) | skipped (host disabled). `attachment_id` links the
|
||||||
|
captured file once a download lands (SET NULL so deleting the attachment
|
||||||
|
doesn't delete the link record).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
# Kept in sync with link_extract.SUPPORTED_HOSTS and the CHECK in migration 0049.
|
||||||
|
HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
|
||||||
|
STATUSES = ("pending", "downloading", "downloaded", "failed", "skipped", "dead")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalLink(Base):
|
||||||
|
__tablename__ = "external_link"
|
||||||
|
__table_args__ = (
|
||||||
|
# One row per (post, url). The full url (incl. #fragment) is the identity
|
||||||
|
# — the same file linked twice in a post collapses to one row.
|
||||||
|
Index("uq_external_link_post_url", "post_id", "url", unique=True),
|
||||||
|
Index("ix_external_link_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
post_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
artist_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
host: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
label: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, server_default="pending"
|
||||||
|
)
|
||||||
|
attempts: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default=text("0")
|
||||||
|
)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
attachment_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("post_attachment.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
@@ -23,6 +23,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ExternalLink,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
ImportSettings,
|
ImportSettings,
|
||||||
@@ -39,6 +40,7 @@ from ..utils.paths import (
|
|||||||
)
|
)
|
||||||
from ..utils.phash import compute_phash, find_similar
|
from ..utils.phash import compute_phash, find_similar
|
||||||
from ..utils.sidecar import find_sidecar, parse_sidecar
|
from ..utils.sidecar import find_sidecar, parse_sidecar
|
||||||
|
from .link_extract import extract_external_links
|
||||||
from ..utils.slug import slugify
|
from ..utils.slug import slugify
|
||||||
from .archive_extractor import extract_archive, is_archive
|
from .archive_extractor import extract_archive, is_archive
|
||||||
from .attachment_store import AttachmentStore
|
from .attachment_store import AttachmentStore
|
||||||
@@ -305,6 +307,32 @@ class Importer:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _sync_external_links(self, post: Post) -> None:
|
||||||
|
"""Record off-platform file-host links (mega/gdrive/mediafire/dropbox/
|
||||||
|
pixeldrain) found in the post body, so they're never silently dropped
|
||||||
|
and the download worker can fetch them later. Shared by every platform's
|
||||||
|
import (runs off Post.description). INSERT-MISSING only — an existing row
|
||||||
|
keeps its status/attempts (a re-import must not reset a link already
|
||||||
|
downloaded or dead-lettered). Identity is (post_id, url); the full url
|
||||||
|
incl. #fragment is preserved by the extractor."""
|
||||||
|
links = extract_external_links(post.description)
|
||||||
|
if not links:
|
||||||
|
return
|
||||||
|
self.session.flush() # ensure post.id is assigned before we reference it
|
||||||
|
existing = set(self.session.execute(
|
||||||
|
select(ExternalLink.url).where(ExternalLink.post_id == post.id)
|
||||||
|
).scalars().all())
|
||||||
|
for link in links:
|
||||||
|
if link.url in existing:
|
||||||
|
continue
|
||||||
|
self.session.add(ExternalLink(
|
||||||
|
post_id=post.id,
|
||||||
|
artist_id=post.artist_id,
|
||||||
|
host=link.host,
|
||||||
|
url=link.url,
|
||||||
|
label=link.label,
|
||||||
|
))
|
||||||
|
|
||||||
def import_one(self, source: Path) -> ImportResult:
|
def import_one(self, source: Path) -> ImportResult:
|
||||||
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
"""Dispatch by kind. Media → normal pipeline. Archive → extract
|
||||||
media members (one Post via the archive-adjacent sidecar) and
|
media members (one Post via the archive-adjacent sidecar) and
|
||||||
@@ -767,6 +795,7 @@ class Importer:
|
|||||||
if sd.attachment_count is not None:
|
if sd.attachment_count is not None:
|
||||||
post.attachment_count = sd.attachment_count
|
post.attachment_count = sd.attachment_count
|
||||||
post.raw_metadata = sd.raw
|
post.raw_metadata = sd.raw
|
||||||
|
self._sync_external_links(post)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1042,6 +1071,7 @@ class Importer:
|
|||||||
if sd.attachment_count is not None:
|
if sd.attachment_count is not None:
|
||||||
post.attachment_count = sd.attachment_count
|
post.attachment_count = sd.attachment_count
|
||||||
post.raw_metadata = sd.raw
|
post.raw_metadata = sd.raw
|
||||||
|
self._sync_external_links(post)
|
||||||
|
|
||||||
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
# Race-safe (image_record_id, post_id) upsert — mirrors the
|
||||||
# _find_or_create_source/post savepoint pattern. The plain
|
# _find_or_create_source/post savepoint pattern. The plain
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Extract off-platform file-host links from a post body.
|
||||||
|
|
||||||
|
Pure (no I/O) so it's unit-testable and reusable by EVERY in-house downloader's
|
||||||
|
import path — every platform stores its body in `Post.description`, so running
|
||||||
|
this there covers them all. Finds links to the supported external file hosts in
|
||||||
|
a post's HTML body (both `<a href="...">` anchors and bare URLs in text),
|
||||||
|
unwraps a Patreon outbound-redirect wrapper, and preserves the FULL url
|
||||||
|
including the `#fragment` (mega.nz puts the decryption key there) and the query
|
||||||
|
string — without those a mega download is impossible.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from html import unescape
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
# Supported file-host enum values (kept in sync with the external_link CHECK).
|
||||||
|
SUPPORTED_HOSTS = ("mega", "gdrive", "mediafire", "dropbox", "pixeldrain")
|
||||||
|
|
||||||
|
# Bare-domain suffix → canonical host label. Matched against the URL netloc
|
||||||
|
# (exact or as a dotted suffix, so www./dl. subdomains resolve too).
|
||||||
|
_HOST_MAP = {
|
||||||
|
"mega.nz": "mega",
|
||||||
|
"mega.co.nz": "mega",
|
||||||
|
"drive.google.com": "gdrive",
|
||||||
|
"mediafire.com": "mediafire",
|
||||||
|
"dropbox.com": "dropbox",
|
||||||
|
"pixeldrain.com": "pixeldrain",
|
||||||
|
}
|
||||||
|
|
||||||
|
# `<a href="...">label</a>` — DOTALL so a label spanning tags/newlines is caught.
|
||||||
|
_HREF_RE = re.compile(
|
||||||
|
r"""<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*>(.*?)</a>""",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
# Bare http(s) URL in text. Stops at whitespace, quotes, angle brackets, and
|
||||||
|
# closing brackets — but KEEPS `#`, `&`, `?`, `=` so fragments/queries survive.
|
||||||
|
_URL_RE = re.compile(r"""https?://[^\s"'<>)\]}]+""", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExtractedLink:
|
||||||
|
host: str # one of SUPPORTED_HOSTS
|
||||||
|
url: str # full url incl. query + #fragment
|
||||||
|
label: str | None # visible anchor text, when present
|
||||||
|
|
||||||
|
|
||||||
|
def _netloc(url: str) -> str:
|
||||||
|
# Lowercase host without credentials or port.
|
||||||
|
return urlsplit(url).netloc.lower().split("@")[-1].split(":")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def host_for(url: str) -> str | None:
|
||||||
|
"""Canonical host label for a url, or None if it's not a supported host."""
|
||||||
|
netloc = _netloc(url)
|
||||||
|
for suffix, host in _HOST_MAP.items():
|
||||||
|
if netloc == suffix or netloc.endswith("." + suffix):
|
||||||
|
return host
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap(url: str) -> str:
|
||||||
|
"""Unwrap a Patreon outbound-redirect wrapper to its inner target, so a
|
||||||
|
wrapped mega/gdrive link resolves to the real host. Patreon has used both
|
||||||
|
`www.patreon.com/...?url=<encoded>` and the `l.patreon.com` shim; check the
|
||||||
|
common target-param names. Non-Patreon urls pass through untouched."""
|
||||||
|
netloc = _netloc(url)
|
||||||
|
if not (netloc == "patreon.com" or netloc.endswith(".patreon.com")):
|
||||||
|
return url
|
||||||
|
qs = parse_qs(urlsplit(url).query)
|
||||||
|
for key in ("url", "u", "ext_url", "redirect", "target"):
|
||||||
|
vals = qs.get(key)
|
||||||
|
if vals and vals[0]:
|
||||||
|
return unescape(vals[0]).strip()
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def extract_external_links(html: str | None) -> list[ExtractedLink]:
|
||||||
|
"""All supported-host links in `html`, de-duplicated by url (first wins, so
|
||||||
|
an anchor's label is kept over a later bare sighting of the same url)."""
|
||||||
|
if not html:
|
||||||
|
return []
|
||||||
|
found: dict[str, ExtractedLink] = {}
|
||||||
|
|
||||||
|
# 1) Anchors first — they carry a human label ("Mega - Streamable").
|
||||||
|
for raw_href, inner in _HREF_RE.findall(html):
|
||||||
|
url = _unwrap(unescape(raw_href).strip())
|
||||||
|
host = host_for(url)
|
||||||
|
if host is None:
|
||||||
|
continue
|
||||||
|
label = unescape(_TAG_RE.sub("", inner)).strip() or None
|
||||||
|
found.setdefault(url, ExtractedLink(host=host, url=url, label=label))
|
||||||
|
|
||||||
|
# 2) Bare URLs pasted in text (no label). Trailing prose punctuation is
|
||||||
|
# trimmed; the href values already captured above de-dup away here.
|
||||||
|
for raw in _URL_RE.findall(html):
|
||||||
|
url = _unwrap(unescape(raw).strip().rstrip(".,;"))
|
||||||
|
host = host_for(url)
|
||||||
|
if host is None:
|
||||||
|
continue
|
||||||
|
found.setdefault(url, ExtractedLink(host=host, url=url, label=None))
|
||||||
|
|
||||||
|
return list(found.values())
|
||||||
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ExternalLink,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
Post,
|
Post,
|
||||||
@@ -196,8 +197,26 @@ class PostFeedService:
|
|||||||
# detail-only (the feed list stays lightweight plain text). None when the
|
# detail-only (the feed list stays lightweight plain text). None when the
|
||||||
# post has no body.
|
# post has no body.
|
||||||
item["description_html"] = sanitize_post_html(post.description)
|
item["description_html"] = sanitize_post_html(post.description)
|
||||||
|
item["external_links"] = await self._external_links_for(post.id)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
async def _external_links_for(self, post_id: int) -> list[dict]:
|
||||||
|
"""Off-platform file-host links recorded for a post (detail-only). Each
|
||||||
|
carries its host, full url, label, and download status so the post view
|
||||||
|
can surface them (and, later, a retry/download affordance)."""
|
||||||
|
rows = (await self.session.execute(
|
||||||
|
select(ExternalLink)
|
||||||
|
.where(ExternalLink.post_id == post_id)
|
||||||
|
.order_by(ExternalLink.id.asc())
|
||||||
|
)).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": e.id, "host": e.host, "url": e.url,
|
||||||
|
"label": e.label, "status": e.status,
|
||||||
|
}
|
||||||
|
for e in rows
|
||||||
|
]
|
||||||
|
|
||||||
# --- composition helpers ---------------------------------------------
|
# --- composition helpers ---------------------------------------------
|
||||||
|
|
||||||
async def _thumbnails_for(
|
async def _thumbnails_for(
|
||||||
|
|||||||
@@ -87,6 +87,25 @@
|
|||||||
@click="toggleDesc"
|
@click="toggleDesc"
|
||||||
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
|
||||||
|
|
||||||
|
<!-- Off-platform file-host links (mega/gdrive/…) found in the body.
|
||||||
|
Shown once expanded; clickable now, auto-downloaded by the worker
|
||||||
|
slice. -->
|
||||||
|
<div v-if="descExpanded && externalLinks.length" class="fc-post-card__links">
|
||||||
|
<div class="fc-post-card__links-label">External files</div>
|
||||||
|
<a
|
||||||
|
v-for="lnk in externalLinks" :key="lnk.id"
|
||||||
|
:href="lnk.url" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="fc-post-card__link"
|
||||||
|
>
|
||||||
|
<span class="fc-post-card__link-host">{{ lnk.host }}</span>
|
||||||
|
<span class="fc-post-card__link-text">{{ lnk.label || lnk.url }}</span>
|
||||||
|
<span
|
||||||
|
v-if="lnk.status && lnk.status !== 'pending'"
|
||||||
|
class="fc-post-card__link-status" :data-status="lnk.status"
|
||||||
|
>{{ lnk.status }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="attachments.length" class="fc-post-card__atts">
|
<div v-if="attachments.length" class="fc-post-card__atts">
|
||||||
<a
|
<a
|
||||||
v-for="att in attachments" :key="att.id"
|
v-for="att in attachments" :key="att.id"
|
||||||
@@ -201,6 +220,8 @@ const fullDescription = computed(() => detail.value?.description_full || null)
|
|||||||
// Backend-sanitized HTML body (detail-only). Drives the faithful render once
|
// Backend-sanitized HTML body (detail-only). Drives the faithful render once
|
||||||
// expanded; falls back to plain text when detail isn't loaded.
|
// expanded; falls back to plain text when detail isn't loaded.
|
||||||
const descHtml = computed(() => detail.value?.description_html || null)
|
const descHtml = computed(() => detail.value?.description_html || null)
|
||||||
|
// Off-platform file-host links (detail-only), surfaced once expanded.
|
||||||
|
const externalLinks = computed(() => detail.value?.external_links || [])
|
||||||
const descText = computed(() =>
|
const descText = computed(() =>
|
||||||
descExpanded.value
|
descExpanded.value
|
||||||
? (fullDescription.value || props.post.description_plain)
|
? (fullDescription.value || props.post.description_plain)
|
||||||
@@ -469,4 +490,46 @@ function formatBytes (n) {
|
|||||||
}
|
}
|
||||||
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
|
||||||
|
.fc-post-card__links {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.fc-post-card__links-label {
|
||||||
|
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
.fc-post-card__link {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.fc-post-card__link:hover {
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
border-color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-post-card__link-host {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 700; text-transform: uppercase; font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: rgba(var(--v-theme-accent), 0.16);
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-post-card__link-text {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fc-post-card__link-status {
|
||||||
|
flex: 0 0 auto; margin-left: auto;
|
||||||
|
font-size: 0.7rem; text-transform: uppercase;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
.fc-post-card__link-status[data-status="downloaded"] {
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Unit tests for link_extract — pure, no DB/network."""
|
||||||
|
|
||||||
|
from backend.app.services.link_extract import extract_external_links, host_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_classification_covers_all_and_rejects_others():
|
||||||
|
assert host_for("https://mega.nz/file/x#k") == "mega"
|
||||||
|
assert host_for("https://www.dropbox.com/s/x?dl=0") == "dropbox"
|
||||||
|
assert host_for("https://drive.google.com/file/d/ID/view") == "gdrive"
|
||||||
|
assert host_for("https://pixeldrain.com/u/abc") == "pixeldrain"
|
||||||
|
assert host_for("https://www.mediafire.com/file/x") == "mediafire"
|
||||||
|
assert host_for("https://example.com/x") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_preserves_fragment_and_label():
|
||||||
|
html = ('<p>Watch: '
|
||||||
|
'<a href="https://mega.nz/file/Bux0FRrZ#b2Pn9841">Mega - Streamable</a></p>')
|
||||||
|
links = extract_external_links(html)
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0].host == "mega"
|
||||||
|
# The #fragment (mega's decryption key) MUST survive.
|
||||||
|
assert links[0].url == "https://mega.nz/file/Bux0FRrZ#b2Pn9841"
|
||||||
|
assert links[0].label == "Mega - Streamable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_all_five_hosts():
|
||||||
|
html = (
|
||||||
|
'<a href="https://mega.nz/file/a#k">m</a>'
|
||||||
|
'<a href="https://drive.google.com/file/d/ID/view?usp=sharing">g</a>'
|
||||||
|
'<a href="https://www.mediafire.com/file/x/y">mf</a>'
|
||||||
|
'<a href="https://www.dropbox.com/s/x/y?dl=0">db</a>'
|
||||||
|
'<a href="https://pixeldrain.com/u/ems4UomN">px</a>'
|
||||||
|
)
|
||||||
|
hosts = {lnk.host for lnk in extract_external_links(html)}
|
||||||
|
assert hosts == {"mega", "gdrive", "mediafire", "dropbox", "pixeldrain"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_url_in_text():
|
||||||
|
html = "Full film: https://pixeldrain.com/u/ems4UomN and more prose."
|
||||||
|
links = extract_external_links(html)
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0].host == "pixeldrain"
|
||||||
|
assert links[0].url == "https://pixeldrain.com/u/ems4UomN" # trailing '.' trimmed
|
||||||
|
assert links[0].label is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unwraps_patreon_redirect():
|
||||||
|
html = ('<a href="https://www.patreon.com/redirect?'
|
||||||
|
'url=https%3A%2F%2Fmega.nz%2Ffile%2Fz%23key">x</a>')
|
||||||
|
links = extract_external_links(html)
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0].host == "mega"
|
||||||
|
assert links[0].url == "https://mega.nz/file/z#key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_unsupported_and_dedups_anchor_label_wins():
|
||||||
|
html = (
|
||||||
|
'<a href="https://example.com/x">no</a>'
|
||||||
|
'<a href="https://mega.nz/file/dup#k">a</a>'
|
||||||
|
' bare https://mega.nz/file/dup#k'
|
||||||
|
)
|
||||||
|
links = extract_external_links(html)
|
||||||
|
assert len(links) == 1 # example.com dropped; mega dup collapsed
|
||||||
|
assert links[0].label == "a" # anchor (with label) wins over bare
|
||||||
|
|
||||||
|
|
||||||
|
def test_amp_escaped_query_is_unescaped():
|
||||||
|
html = '<a href="https://drive.google.com/uc?id=ID&export=download">g</a>'
|
||||||
|
links = extract_external_links(html)
|
||||||
|
assert links[0].url == "https://drive.google.com/uc?id=ID&export=download"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_inputs():
|
||||||
|
assert extract_external_links(None) == []
|
||||||
|
assert extract_external_links("") == []
|
||||||
|
assert extract_external_links("<p>no links here</p>") == []
|
||||||
@@ -11,6 +11,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from backend.app.models import (
|
from backend.app.models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ExternalLink,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
Post,
|
Post,
|
||||||
@@ -437,6 +438,27 @@ async def test_get_post_returns_sanitized_html_body(db):
|
|||||||
assert "<script>" not in item["description_html"]
|
assert "<script>" not in item["description_html"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_post_returns_external_links(db):
|
||||||
|
artist = await _seed_artist(db, "alice-extlinks")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-extlinks")
|
||||||
|
p = await _seed_post(
|
||||||
|
db, src.id, external_id="EXTLINKS", post_date=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(ExternalLink(
|
||||||
|
post_id=p.id, artist_id=artist.id, host="mega",
|
||||||
|
url="https://mega.nz/file/x#k", label="Mega",
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
item = await PostFeedService(db).get_post(p.id)
|
||||||
|
assert len(item["external_links"]) == 1
|
||||||
|
link = item["external_links"][0]
|
||||||
|
assert link["host"] == "mega"
|
||||||
|
assert link["url"] == "https://mega.nz/file/x#k"
|
||||||
|
assert link["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_post_unknown_returns_none(db):
|
async def test_get_post_unknown_returns_none(db):
|
||||||
item = await PostFeedService(db).get_post(99999)
|
item = await PostFeedService(db).get_post(99999)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, select
|
|||||||
|
|
||||||
from backend.app.models import (
|
from backend.app.models import (
|
||||||
Artist,
|
Artist,
|
||||||
|
ExternalLink,
|
||||||
ImageProvenance,
|
ImageProvenance,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
ImportSettings,
|
ImportSettings,
|
||||||
@@ -226,3 +227,47 @@ def test_upsert_post_record_idempotent_no_double(importer, import_layout):
|
|||||||
assert importer.session.execute(
|
assert importer.session.execute(
|
||||||
select(func.count()).select_from(Post)
|
select(func.count()).select_from(Post)
|
||||||
).scalar_one() == 1
|
).scalar_one() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_links_recorded_from_body(importer, import_layout):
|
||||||
|
"""A mega/gdrive/etc. link in the body is recorded as an external_link row
|
||||||
|
(status pending) so it's never silently dropped."""
|
||||||
|
import_root, _ = import_layout
|
||||||
|
artist = Artist(name="Linker", slug="linker")
|
||||||
|
importer.session.add(artist)
|
||||||
|
importer.session.flush()
|
||||||
|
sc = import_root / "Linker" / "_post.json"
|
||||||
|
sc.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sc.write_text(json.dumps({
|
||||||
|
"category": "patreon", "id": 999, "title": "Film",
|
||||||
|
"url": "https://patreon.com/posts/999",
|
||||||
|
"content": '<p>Get it: <a href="https://mega.nz/file/AbC#key123">Mega</a></p>',
|
||||||
|
}))
|
||||||
|
assert importer.upsert_post_record(sc, artist=artist) is True
|
||||||
|
importer.session.expire_all() # re-read so the server_default status loads
|
||||||
|
links = importer.session.execute(select(ExternalLink)).scalars().all()
|
||||||
|
assert len(links) == 1
|
||||||
|
assert links[0].host == "mega"
|
||||||
|
assert links[0].url == "https://mega.nz/file/AbC#key123"
|
||||||
|
assert links[0].label == "Mega"
|
||||||
|
assert links[0].status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_links_not_duplicated_on_reimport(importer, import_layout):
|
||||||
|
"""Re-importing the same body keeps ONE external_link row (insert-missing)."""
|
||||||
|
import_root, _ = import_layout
|
||||||
|
artist = Artist(name="Linker2", slug="linker2")
|
||||||
|
importer.session.add(artist)
|
||||||
|
importer.session.flush()
|
||||||
|
sc = import_root / "Linker2" / "_post.json"
|
||||||
|
sc.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sc.write_text(json.dumps({
|
||||||
|
"category": "patreon", "id": 1000, "title": "Film",
|
||||||
|
"url": "https://patreon.com/posts/1000",
|
||||||
|
"content": '<a href="https://pixeldrain.com/u/xyz">px</a>',
|
||||||
|
}))
|
||||||
|
assert importer.upsert_post_record(sc, artist=artist) is True
|
||||||
|
assert importer.upsert_post_record(sc, artist=artist) is True
|
||||||
|
assert importer.session.execute(
|
||||||
|
select(func.count()).select_from(ExternalLink)
|
||||||
|
).scalar_one() == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user