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,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 (
|
||||
Artist,
|
||||
ExternalLink,
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
Post,
|
||||
@@ -437,6 +438,27 @@ async def test_get_post_returns_sanitized_html_body(db):
|
||||
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
|
||||
async def test_get_post_unknown_returns_none(db):
|
||||
item = await PostFeedService(db).get_post(99999)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, select
|
||||
|
||||
from backend.app.models import (
|
||||
Artist,
|
||||
ExternalLink,
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
ImportSettings,
|
||||
@@ -226,3 +227,47 @@ def test_upsert_post_record_idempotent_no_double(importer, import_layout):
|
||||
assert importer.session.execute(
|
||||
select(func.count()).select_from(Post)
|
||||
).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