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. -->
+
[图片]