From 2154d050e52add02f3f8911e56a48995e241c1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 13 Aug 2026 14:02:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20image=5Flegacy=E6=B8=B2=E6=9F=93+?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E8=A6=86=E7=9B=96+WA=20raw=5Fjson=20URL?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- yunke-server/app/scraper/media.py | 31 ++++--- .../tests/test_message_images_downloader.py | 80 +++++++++++++++++- yunkes/src/components/MessageBubble.vue | 83 +++++++++++++++---- 3 files changed, 164 insertions(+), 30 deletions(-) diff --git a/yunke-server/app/scraper/media.py b/yunke-server/app/scraper/media.py index 3c80f92..6c0c727 100644 --- a/yunke-server/app/scraper/media.py +++ b/yunke-server/app/scraper/media.py @@ -394,11 +394,13 @@ def _pick_message_image_url( ) -> str | None: """Pick the best downloadable http(s) URL for a message media row. - For ``image`` messages, prefer the thumbnail (``file_th_url``): thumbnails - are small, fast to fetch, and exactly what the chat timeline renders, so - localizing the thumbnail is sufficient for display and easy on disk + bandwidth. - Falls back to ``file_url`` then ``file_hd_url`` when no http(s) thumbnail is - available. + For ``image`` / ``image_legacy`` messages, prefer the thumbnail + (``file_th_url``): thumbnails are small, fast to fetch, and exactly what the + chat timeline renders, so localizing the thumbnail is sufficient for display + and easy on disk + bandwidth. Falls back to ``file_url`` then ``file_hd_url`` + when no http(s) thumbnail is available. ``image_legacy`` (smoke.db 26 rows + with valid ``wx.oss.yunkecn.com`` URLs) is treated exactly like ``image`` + here so the legacy WeChat type-3-via-OSS rows are localized. For every other media type (``video`` / ``file``), prefer ``file_url`` (the original), then ``file_hd_url``, then ``file_th_url``. @@ -406,7 +408,7 @@ def _pick_message_image_url( Returns ``None`` when no candidate is an http(s) URL (e.g. type-3 opaque ids or relative filenames). Only ``http://`` / ``https://`` URLs are downloadable. """ - if type_ == "image": + if type_ in ("image", "image_legacy"): order = (file_th_url, file_url, file_hd_url) else: order = (file_url, file_hd_url, file_th_url) @@ -428,12 +430,15 @@ async def download_message_images( Iterates ``message`` rows that carry an http(s) URL on any of ``file_url`` / ``file_th_url`` / ``file_hd_url``, have ``type`` in - ('image', 'video', 'file'), are on the wechat / whatsapp channel, and do - NOT yet carry a ``media_hash``. For each: pick the best available URL - (image: thumbnail-first; other: original-first — see + ('image', 'image_legacy', 'video', 'file'), are on the wechat / whatsapp + channel, and do NOT yet carry a ``media_hash``. For each: pick the best + available URL (image: thumbnail-first; other: original-first — see :func:`_pick_message_image_url`), fetch bytes (:func:`download_url`), store content-addressed (:meth:`ObjectStore.put_by_hash`, sha256 dedup), upsert a ``Media`` row, and write ``media_hash`` back onto the message. + ``image_legacy`` rows are stored as ``media_type='image'`` (normalized via + :func:`media_type_from_message`) so the legacy WeChat type-3-via-OSS rows + (smoke.db 26 rows with valid ``wx.oss.yunkecn.com`` URLs) are covered. Returns ``(attempted, localized)``. Resilient: a per-row download/store failure is logged and counted as attempted-but-not-localized; no exception @@ -462,7 +467,7 @@ async def download_message_images( ) .where(Message.media_hash.is_(None)) .where(Message.channel.in_(("wechat", "whatsapp"))) - .where(Message.type.in_(("image", "video", "file"))) + .where(Message.type.in_(("image", "image_legacy", "video", "file"))) .where( sa.or_( Message.file_url.like("http%"), @@ -500,11 +505,15 @@ async def download_message_images( continue ext = _ext_from_url(url) sha256_hex, path = await store.put_by_hash(data, ext=ext) + # Normalize ``image_legacy`` to ``image`` on the stored Media row so + # the legacy WeChat type-3-via-OSS rows land in the same bucket as + # type-2 images (matches ``media_type_from_message``). + media_type = "image" if type_ == "image_legacy" else type_ await upsert_media( session, media_hash=sha256_hex, channel=channel or "wechat", - media_type=type_, + media_type=media_type, original_url=file_url, original_th_url=file_th_url, original_hd_url=file_hd_url, diff --git a/yunke-server/tests/test_message_images_downloader.py b/yunke-server/tests/test_message_images_downloader.py index f2cb0a2..ac2d598 100644 --- a/yunke-server/tests/test_message_images_downloader.py +++ b/yunke-server/tests/test_message_images_downloader.py @@ -17,7 +17,10 @@ Mirrors ``test_recording_downloader.py``: set excludes the failing msg_id from later batches) and returns ``(attempted=1, localized=0)`` with no exception escaping. * Filter: a text message with an http URL (e.g. a documentation link) is - NOT selected (only ``type IN ('image','video','file')`` rows are eligible). + NOT selected (only ``type IN ('image','image_legacy','video','file')`` rows + are eligible). +* ``image_legacy`` coverage: legacy WeChat type-3 rows whose ``file_url`` + carries a valid OSS URL ARE selected and stored as ``media_type='image'``. """ from __future__ import annotations @@ -340,8 +343,9 @@ async def test_resilient_on_failure_and_no_infinite_loop(tmp_path): async def test_text_message_with_url_is_not_selected(tmp_path): """A text message whose text happens to be a URL is NOT selected. - The candidate filter requires ``type IN ('image','video','file')``; a - text message is skipped even if its columns somehow carry an http URL. + The candidate filter requires ``type IN ('image','image_legacy','video', + 'file')``; a text message is skipped even if its columns somehow carry an + http URL. """ store = ObjectStore(tmp_path) engine = await _make_engine() @@ -373,3 +377,73 @@ async def test_text_message_with_url_is_not_selected(tmp_path): finally: await client.aclose() await engine.dispose() + + +@respx.mock +async def test_image_legacy_is_selected_and_normalized_to_image(tmp_path): + """``type='image_legacy'`` rows ARE selected (smoke.db 26 rows). + + The candidate filter includes ``image_legacy`` so the legacy WeChat type-3 + rows whose ``file_url`` carries a valid ``wx.oss.yunkecn.com`` OSS URL get + localized. The thumbnail-first preference applies (same as ``image``), and + the stored ``Media.media_type`` is NORMALIZED to ``'image'`` so the legacy + bucket lands in the same bin as type-2 images. + """ + store = ObjectStore(tmp_path) + engine = await _make_engine() + SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + thumb_blob = b"\xff\xd8\xff\xe0legacy-thumb-jpg" + thumb_route = respx.get(OSS_THUMB).mock( + return_value=httpx.Response(200, content=thumb_blob) + ) + full_route = respx.get(OSS_FULL).mock(return_value=httpx.Response(200, content=b"full")) + + client = _primed_client() + try: + async with SessionLocal() as session: + session.add( + Message( + msg_id="m-legacy", + channel="wechat", + type="image_legacy", + type_raw=3, + file_url=OSS_FULL, + file_th_url=OSS_THUMB, + file_hd_url=OSS_HD, + ) + ) + await session.commit() + + async with SessionLocal() as session: + attempted, localized = await download_message_images( + client, store, session + ) + await session.commit() + + assert (attempted, localized) == (1, 1) + # Thumbnail-first preference applies to image_legacy exactly as for image. + assert thumb_route.called + assert not full_route.called + + expected_sha = hashlib.sha256(thumb_blob).hexdigest() + async with SessionLocal() as session: + msg = ( + await session.execute( + sa.select(Message).where(Message.msg_id == "m-legacy") + ) + ).scalar_one() + assert msg.media_hash == expected_sha + + media = ( + await session.execute( + sa.select(Media).where(Media.media_hash == expected_sha) + ) + ).scalar_one() + # Normalized: image_legacy -> image on the stored Media row. + assert media.media_type == "image" + assert media.mime_type == "image/jpeg" + assert media.original_url == OSS_FULL + finally: + await client.aclose() + await engine.dispose() diff --git a/yunkes/src/components/MessageBubble.vue b/yunkes/src/components/MessageBubble.vue index 887189f..91f0190 100644 --- a/yunkes/src/components/MessageBubble.vue +++ b/yunkes/src/components/MessageBubble.vue @@ -11,16 +11,21 @@ const props = defineProps<{ message: Message }>() * URL-driven, not type-code-driven** — many upstream WeChat messages carry a * numeric `type_raw` (3=image_legacy, 43=图片/表情, ...) that the legacy code * mapped to the `unknown` bucket even though their `file_*_url` points to a - * perfectly good OSS asset. We now render by media-URL presence + extension - * first, and only fall back to the type code as a last resort. + * perfectly good OSS asset. We render by media-URL presence + extension first, + * and only fall back to the type code as a last resort. `image_legacy` rows + * (smoke.db 26 rows with valid `wx.oss.yunkecn.com` OSS URLs) hit the same + * image branch as `image` because the URL cascade is type-agnostic. * * Cascade (per task spec): - * 1. http media URL (`file_url` > `file_hd_url` > `file_th_url`) → by ext: - * image / video / audio / generic file (📎 link). + * 1. http media URL (`file_url` > `file_hd_url` > `file_th_url`, then + * `raw_json.{ossFileName,fileUrl,mediaUrl,url}` as a WA fallback) → by ext: + * image / video / audio / generic file (📎 download); http URL with NO + * recognized media extension → 📎 link (invite / support.weixin.qq.com / + * unknown-type rows whose file_url is a share link). * 2. `duration` present (voice w/o URL) → voice chip. * 3. `transcription_text` → text bubble. * 4. `text` present → text bubble. - * 5. structured `type` placeholder (WA image w/o URL, etc.). + * 5. structured `type` placeholder (WA image w/o URL, image_legacy w/o URL). * 6. else → `[图片/表情 (type_raw)]` style tag using `wechatTypeLabel`. * * Clicking anywhere on the bubble (other than interactive children such as the @@ -45,17 +50,39 @@ const timeLabel = computed(() => formatTime(props.message.timestamp)) const HTTP_RE = /^https?:\/\//i +/** + * Raw-json keys that may carry a downloadable http URL when the structured + * `file_*_url` columns don't. WA messages store their media as a bare + * `mediaName` filename (not http), so this fallback rarely resolves WA images + * today — but upstream shapes drift, and some rows (e.g. WA media that has + * been re-hosted on the OSS bucket) do surface an http URL on these raw_json + * fields. Checking them is cheap and future-proofs the resolver. + */ +const RAW_URL_KEYS = ['ossFileName', 'fileUrl', 'mediaUrl', 'url'] as const + /** * Best http media URL on the message, honoring the precedence - * `file_url` > `file_hd_url` > `file_th_url`. Non-http values (e.g. type-3 - * opaque ids, WA `mediaName` filenames) are skipped so they fall through to - * the text/type fallbacks instead of producing broken ``. + * `file_url` > `file_hd_url` > `file_th_url`, then a `raw_json` fallback that + * scans the camelCase media fields upstream sometimes populates. Non-http + * values (e.g. type-3 opaque ids, WA `mediaName` filenames) are skipped so + * they fall through to the text/type fallbacks instead of producing broken + * ``. */ const bestMediaUrl = computed(() => { const m = props.message for (const u of [m.file_url, m.file_hd_url, m.file_th_url]) { if (u && HTTP_RE.test(u)) return u } + // WA / raw_json fallback: scan known media-bearing raw fields for any http + // URL. WA images today carry only a bare filename (mediaName) and won't + // resolve here, but rows whose media has been re-hosted upstream will. + const raw = m.raw_json + if (raw) { + for (const key of RAW_URL_KEYS) { + const v = raw[key] + if (typeof v === 'string' && HTTP_RE.test(v)) return v + } + } return undefined }) @@ -85,16 +112,21 @@ function urlExt(url: string): string | null { } /** - * Classify `bestMediaUrl` into image/video/audio/file. Returns null when there - * is no URL OR the URL has no extension (e.g. invite links like - * `support.weixin.qq.com/.../addchatroombyinvite?ticket=...`) — those aren't - * media files and should fall through to text rendering. + * Classify `bestMediaUrl` into image/video/audio/file/link. Returns: + * - `image|video|audio|file` when the URL carries a recognized media ext; + * - `link` when there IS an http URL but no recognized media extension + * (e.g. invite links `support.weixin.qq.com/.../addchatroombyinvite?...`, + * or `unknown`-type messages whose `file_url` is a doc/share link) — + * rendered as a clickable anchor so the user can follow it instead of the + * media being silently hidden; + * - `undefined` when there is no http URL at all (falls through to text / + * type-code placeholder). */ -const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | undefined>(() => { +const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | 'link' | undefined>(() => { const u = bestMediaUrl.value if (!u) return undefined const ext = urlExt(u) - if (!ext) return undefined + if (!ext) return 'link' if (IMAGE_EXTS.has(ext)) return 'image' if (VIDEO_EXTS.has(ext)) return 'video' if (AUDIO_EXTS.has(ext)) return 'audio' @@ -205,6 +237,23 @@ const typeTagLabel = computed(() => { + +
+ + + 打开链接 + +
+
@@ -228,8 +277,10 @@ const typeTagLabel = computed(() => { - + download endpoint, so degrade to a labeled placeholder). + `image_legacy` is included here so a legacy WeChat type-3 row whose + OSS URL somehow didn't resolve still shows the [图片] tag. --> + [图片]