feat(subscribestar): port gallery-dl doc + audio attachment extraction
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:
@@ -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
|
||||
|
||||
@@ -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>")))
|
||||
|
||||
Reference in New Issue
Block a user