feat(subscribestar): port gallery-dl doc + audio attachment extraction
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m24s

Some SubscribeStar posts deliver content only through document/audio attachments,
which live OUTSIDE data-gallery. Port gallery-dl's _media_from_post for them:
- docs: scope uploads-docs..post-edit_form, split on doc_preview blocks, take the
  href URL + doc_preview-title + data-upload-id (kind=attachment).
- audio: scope uploads-audios..post-edit_form, split on audio_preview-data
  blocks, take the src URL + audio_preview-title + data-upload-id (kind=audio).

The existing downloader handles them unchanged (plain streaming GET; the file
validator only inspects image/video extensions via is_validatable, so PDFs/zips/
audio pass straight through, no quarantine).

Test covers doc + audio extraction (the cheunart sample has none, so this pins
gallery-dl's documented markup shape).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:18:10 -04:00
parent 8771364cee
commit 976f581aa2
2 changed files with 79 additions and 4 deletions
+52 -4
View File
@@ -97,6 +97,12 @@ _POST_DATE_RE = re.compile(r'<div class="post-date">([^<]+)</div>')
_CONTENT_OPEN = '<div class="post-content" data-role="post_content-text">'
_CONTENT_CLOSE = '</div><div class="post-uploads for-youtube"'
_GALLERY_RE = re.compile(r'data-gallery="([^"]*)"')
# Document + audio attachments are NOT in data-gallery — gallery-dl's
# _media_from_post scrapes them from their own `uploads-docs` / `uploads-audios`
# sections, splitting on each preview block. Some posts deliver content ONLY
# through these (PDFs/zips, audio), so we must walk them too.
_DOC_SPLIT_RE = re.compile(r'class="doc_preview[" ]')
_AUDIO_SPLIT_RE = re.compile(r'class="audio_preview-data[" ]')
_NEXT_PAGE_RE = re.compile(
r'data-role="infinite_scroll-next_page"\s+href="([^"]+)"'
)
@@ -191,6 +197,29 @@ def _extract_content(chunk: str) -> str:
return content.strip()
def _attachment_item(
frag: str, base: str, post_id: str, *, kind: str, title_marker: str, url_attr: str
) -> "MediaItem | None":
"""One doc/audio attachment from its preview block — gallery-dl's
`_media_from_post` attachment/audio fields. `url_attr` is the URL-bearing
attribute (`href="` for docs, `src="` for audio); `title_marker` precedes the
display name. Returns None when the block carries no URL."""
rel = unescape(_extr(frag, url_attr, '"'))
if not rel:
return None
url = urljoin(base + "/", rel)
upload_id = _extr(frag, 'data-upload-id="', '"')
name = unescape(_extr(frag, title_marker, "<")).strip()
return MediaItem(
url=url,
filename=name or basename_from_url(url),
kind=kind,
filehash=filehash_from_url(url),
post_id=post_id,
media_id=str(upload_id or ""),
)
def _split_creator_url(campaign_id: str) -> tuple[str, str]:
"""`campaign_id` is the creator URL → (base, slug).
@@ -372,10 +401,11 @@ class SubscribeStarClient:
return unescape(m.group(1)) if m else None
def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
"""Resolve downloadable media from the post's `data-gallery` JSON
manifest. Each item carries a stable `id`, `original_filename`, `type`,
and a full-res `url` (`/post_uploads?payload=...`, relative — joined to the
post's base). `included_index` is unused (HTML carries media inline)."""
"""Resolve downloadable media (gallery-dl's `_media_from_post`): the
per-post `data-gallery` JSON manifest (images/videos), PLUS document
attachments (`uploads-docs`) and audio (`uploads-audios`) — some posts
deliver content only through the latter two. `/previews` (locked teaser)
gallery items are skipped. `included_index` is unused (media is inline)."""
chunk = post.get("_html") or ""
base = post.get("_base") or "https://www.subscribestar.com"
post_id = str(post.get("id") or "")
@@ -413,6 +443,24 @@ class SubscribeStarClient:
media_id=media_id,
)
)
# Document attachments (uploads-docs → doc_preview blocks): href URL.
docs = _extr(chunk, 'class="uploads-docs"', 'class="post-edit_form"')
for frag in _DOC_SPLIT_RE.split(docs)[1:]:
item = _attachment_item(
frag, base, post_id, kind="attachment",
title_marker='doc_preview-title">', url_attr='href="',
)
if item is not None:
items.append(item)
# Audio attachments (uploads-audios → audio_preview-data blocks): src URL.
audios = _extr(chunk, 'class="uploads-audios"', 'class="post-edit_form"')
for frag in _AUDIO_SPLIT_RE.split(audios)[1:]:
item = _attachment_item(
frag, base, post_id, kind="audio",
title_marker='audio_preview-title">', url_attr='src="',
)
if item is not None:
items.append(item)
return items
@staticmethod
+27
View File
@@ -227,6 +227,33 @@ def test_extract_media_skips_locked_previews():
assert [m.media_id for m in items] == ["1"]
def test_extract_media_includes_doc_and_audio_attachments():
# gallery-dl _media_from_post: document (uploads-docs/doc_preview, href) and
# audio (uploads-audios/audio_preview-data, src) attachments are separate from
# data-gallery — some posts deliver content ONLY through these.
client = SubscribeStarClient(None)
chunk = (
'<div class="post " data-id="888">'
'<div class="uploads-docs">'
'<div class="doc_preview" data-upload-id="501">'
'<a href="/post_uploads/d/chapter.pdf">'
'<div class="doc_preview-title">chapter.pdf</div></a></div></div>'
'<div class="uploads-audios">'
'<div class="audio_preview-data" data-upload-id="502">'
'<audio src="/post_uploads/a/track.mp3"></audio>'
'<div class="audio_preview-title">track.mp3</div></div></div>'
'<div class="post-edit_form"></div></div>'
)
post = {"id": "888", "_html": chunk, "_base": "https://subscribestar.adult"}
by_kind = {m.kind: m for m in client.extract_media(post, {})}
assert by_kind["attachment"].url == "https://subscribestar.adult/post_uploads/d/chapter.pdf"
assert by_kind["attachment"].filename == "chapter.pdf"
assert by_kind["attachment"].media_id == "501"
assert by_kind["audio"].url == "https://subscribestar.adult/post_uploads/a/track.mp3"
assert by_kind["audio"].filename == "track.mp3"
assert by_kind["audio"].media_id == "502"
def test_text_post_has_no_media_but_keeps_body():
client = SubscribeStarClient(None)
[post] = client._parse_posts(_feed_page(_post_html("111", body="<p>text only</p>")))