fix(media): 補圖片副檔名與表情 URL 改寫
Docs/ChangeHistory.mdDocs/MEDIA_CACHE.mdDocs/TEST_FILES_REFERENCE.mdEmojiMap.jsMediaRoutes.cjsOtherTool/live_engine/core/emoji_parser.pyOtherTool/live_engine/tests/test_emoji_parser.pyScriptLib/media/cache.cjsScriptLib/monitor/store.cjsTest/emoji_store.test.cjsTest/media_cache.test.cjsTikTok.jsassets/app.css.build.jsontodo.md
commit d6ce99fa8c2a66e3a3ae97f4ba22a5a881f3196eAuthor: Nuclear0709 <agp05432@gmail.com>Date: Thu Nov 12 08:43:22 2026 +0800 fix(media): 補圖片副檔名與表情 URL 改寫 - MediaCache URL 改為 /media/<hash>.<ext>,舊路徑仍相容 - 副檔名依 Content-Type/來源 URL,未知時用 .png - 無副檔名的表情映射 URL 轉進 MediaCache - Python overlay 接受 /media/<hash> 舊路徑 - 補 media、emoji resolver 與 parser 測試 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,83 @@ # ChangeHistory +## 2026.11.11 01:15 MediaCache URL 補圖片副檔名++**類型**: 修正 · **檔案**: `ScriptLib/media/cache.cjs`、`MediaRoutes.cjs`、`ScriptLib/monitor/store.cjs`、`OtherTool/live_engine/core/emoji_parser.py`、`Test/media_cache.test.cjs`、`OtherTool/live_engine/tests/test_emoji_parser.py`、`Docs/MEDIA_CACHE.md`、`Docs/RUNTIME.md`++### 問題++MediaCache 對外只有 `/media/<sha256>`,沒有 `.png`/`.jpg` 等副檔名。以 URL 副檔名分類圖片的客戶端與 Python overlay 會把它當成一般文字;若是未知的無副檔名 emoji URL,也無法辨識。++### 修改++- MediaCache 對外 URL 改成 `/media/<sha256>.<ext>`:優先使用已快取的 `Content-Type`,其次來源 URL 副檔名,未知時用 `.png`。+- 路由同時接受舊的無副檔名形式;副檔名只用於客戶端分類,實際格式由 `Content-Type` 決定。+- inline data URI materialize 後也帶正確副檔名。+- Python overlay 的圖片 URL 規則額外接受 `/media/<64 hex>`(含可選副檔名),涵蓋舊資料。+- 表情映射中沒有副檔名的 HTTP URL 透過 MediaCache 改寫成 `/media/<hash>.<ext>`;已有副檔名者維持遠端 URL。+- 補 MediaCache、history materialize、emoji resolver 與 overlay parser 測試。++### 限制++- 來源與實際 `Content-Type` 不一致時,副檔名可能只是提示;瀏覽器與 ImageIO 仍以內容為準。+- 任意網域的無副檔名 emoji URL 仍不會被自動當成圖片;需要伺服器改寫成 `/media/<hash>.<ext>`,或客戶端改用明確的 `imageURLs` 清單。++### 驗證++- `node --test Test/media_cache.test.cjs Test/monitor_history.test.cjs`+- `python -m pytest OtherTool/live_engine/tests/test_emoji_parser.py -q`+- `npm run lint`++---++## 2026.11.11 00:55 診斷 Socket 沿用 SOCKET_API++**類型**: 修正 · **檔案**: `ScriptLib/diagnostics/target.cjs`、`DiagnosticsRoutes.cjs`、`Server.js`、`assets/diagnostics.js`、`diagnostics.html`、`.env.example`、`Docs/DIAGNOSTICS.md`、`Docs/RUNTIME.md`、`Test/diagnostics.test.cjs`++### 問題++診斷訂閱原本額外要求 `DIAGNOSTICS_SOCKET_HOST`/`PORT`,與聊天輸出的 `SOCKET_API` 重複指定同一個 ReplyKit SocketServer。++### 修改++- 新增 `resolveDiagnosticsTarget()`:優先解析 `SOCKET_API`,其次 `SOCKET_APIS` 第一個可用端點。+- 移除 `DIAGNOSTICS_SOCKET_HOST`/`PORT`;`DIAGNOSTICS_SOCKET_ENABLED` 只控制啟動時自動訂閱。+- `/diagnostics` 顯示解析出的目標與來源;未設定時提示補 `SOCKET_API`。+- 套用訂閱時重新解析目前 `SOCKET_API`,設定變更後重新套用即可重連。+- 補 target 解析與 route 測試。++### 驗證++- `node --test Test/diagnostics.test.cjs`+- `node Test/diagnostics_ui.cjs`+- `npm run lint`++---++## 2026.11.11 00:40 診斷 Socket 目標顯示++**類型**: 修正 · **檔案**: `assets/diagnostics.js`、`DiagnosticsRoutes.cjs`、`Docs/DIAGNOSTICS.md`、`Test/diagnostics_ui.cjs`++### 問題++已設定 `DIAGNOSTICS_SOCKET_HOST` 時,未連線前 `socketClient.snapshot().host` 仍是空字串,`/diagnostics` 因此顯示「未設定 · 錯誤」,看起來像設定失效。++### 修改++- 狀態列優先用 `defaults.host` 顯示 `.env` 目標,其次才用目前連線 host。+- 真正未設定時顯示可行動提示:補 `DIAGNOSTICS_SOCKET_HOST`/`PORT` 並重啟主服務。+- GET 回傳 `defaults.configured`。+- POST 目標無效時回傳同樣明確的設定提示。+- UI 測試 fake 補 `defaults`。++### 驗證++- `node --test Test/diagnostics.test.cjs`+- `node Test/diagnostics_ui.cjs`+- `npm run lint`++---+ ## 2026.11.11 00:23 Raw Socket 圖片選單 **類型**: 功能 · **檔案**: `monitor.html`、`assets/monitor.js`、`lang/*.json`、`Test/monitor_audience_ui.cjs`diff --git a/Docs/MEDIA_CACHE.md b/Docs/MEDIA_CACHE.md--- a/Docs/MEDIA_CACHE.md+++ b/Docs/MEDIA_CACHE.md@@ -13,7 +13,7 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL: ```text 外部圖片 URL → MediaCache.localUrl(url)- → http://127.0.0.1:3332/media/<sha256>+ → http://127.0.0.1:3332/media/<sha256>.<ext> → 第一次請求時下載並寫入本機 ``` @@ -22,11 +22,11 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL: ## 檔案 - `ScriptLib/media/cache.cjs`:快取、下載、SSRF 檢查與供應。-- `MediaRoutes.cjs`:公開的 `GET /media/<sha256>` 路由。+- `MediaRoutes.cjs`:公開的 `GET /media/<sha256>[.<ext>]` 路由;副檔名只供客戶端分類,實際格式仍看 `Content-Type`。 - `cache/media/<sha256>.bin`:圖片位元組。 - `data/media.sqlite`:來源 URL、Content-Type、大小、ETag、存取時間與失敗次數。 -`/media/` 只接受 hash,**不提供 `?url=` 任意抓取**。資料庫與圖片目錄已排除 Git。+`/media/` 只接受 hash(可帶 png/jpg/gif/webp/avif/bmp 副檔名),**不提供 `?url=` 任意抓取**。資料庫與圖片目錄已排除 Git。對外 URL 會依內容類型或來源副檔名產生 `.png`/`.jpg` 等後綴,讓依副檔名分類的客戶端也能辨識。 ## 整合位置 @@ -44,13 +44,15 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL: - MediaCache 一般只處理 HTTP(S);data URI 在 live 路徑原樣直傳,不抓取。只有 monitor 歷史會把 data URI materialize 成 content-addressed 本機檔。 - `ScriptLib/media/inline.cjs` 驗證格式、encoded 大小、數量與總量;不切片、不 fallback HTTP。 - 通知會移除 body 中的 data URI,且外部 Bark/ntfy 不帶 data URI icon。-- Live 顯示保留 inline;monitor 歷史會 materialize 成 `/media/<content-hash>`,SQLite 只存本地 URL,不存 base64。+- Live 顯示保留 inline;monitor 歷史會 materialize 成 `/media/<content-hash>.<ext>`,SQLite 只存本地 URL,不存 base64。 - inline 與歷史引用的 HTTP 圖片會 pin,一般 LRU 不會刪掉;歷史場次/訊息被修剪後才釋放引用。+- 表情映射中沒有圖片副檔名的 HTTP URL 會先轉進 MediaCache,輸出 `/media/<hash>.<ext>`;已有 `.png`/`.jpg` 等副檔名者維持遠端 URL。+- 舊的無副檔名 `/media/<hash>` 仍可讀取;Python overlay 也接受這種路徑,避免舊資料變回文字。 - Python overlay 已在 texture worker 本機解碼 png/jpeg/webp/gif data URI;日誌只記錄類型與長度。 ## 檢視與手動測試 -- `/monitor` 的「MediaCache 資源」面板可查看快取統計、搜尋、預覽、複製本地 `/media/<hash>` 連結與開啟圖片。+- `/monitor` 的「MediaCache 資源」面板可查看快取統計、搜尋、預覽、複製本地 `/media/<hash>.<ext>` 連結與開啟圖片。 - `GET /api/media`(需登入)支援 `page`、`pageSize`、`q`、`type`、`status`;只回傳 hash、host、型別、大小、時間與狀態,不暴露完整來源 URL。 - `GET /api/media/stats`(需登入)回傳總數、bytes、cached/failed/missing。 - `/monitor` 另有 Raw Socket 測試入口:`POST /api/socket/test` 經子程序直接送 `socketHub`,可選擇是否套用 MediaCache/emoji 正式轉換;不經 `/chat`、filter、dedup、traffic 或 AutoClip。diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.md--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -62,7 +62,7 @@ node Test/xxx.mjs | `Test/monitor_routes.test.cjs` | 監控歷史 API 的登入、場次、訊息與缺少場次 | | `Test/navigation_guard.test.mjs` | Signer TikTok 導航最小間隔與 WAF 冷卻 | | `Test/video_tk_ui.cjs` | videoTK 劇院樣式還原、可拖動按鈕與拖曳不誤觸 |-| `Test/media_cache.test.cjs` | 圖片快取、inline pin、LRU/大小上限、SVG/私有位址拒絕、路由與 SocketHub 改寫 |+| `Test/media_cache.test.cjs` | 圖片快取、`.ext` URL、inline pin、LRU/大小上限、SVG/私有位址拒絕、路由與 SocketHub 改寫 | | `Test/inline_image.test.cjs` | data URI 圖片格式、大小/數量限制與通知剝離 | | `Test/socket_test_routes.test.cjs` | Raw Socket 測試入口的登入、子程序檢查與 payload 轉送 | | `Test/diagnostics.test.cjs` | 診斷 store、`/post`、diagnostic、token、Socket 外部事件訂閱與 API |diff --git a/EmojiMap.js b/EmojiMap.js--- a/EmojiMap.js+++ b/EmojiMap.js@@ -6,6 +6,16 @@ const EMOJI_MAP_FILE = path.resolve('./emoji_map.json'); let emojiMap = {}; let activeStore = emojiStoreModule.store; let stopWatching;+let emojiMediaResolver = null;++/**+ * 設定表情圖片的媒體改寫器(例如轉成 MediaCache 的 /media/<hash>.<ext>)。+ * 設為 null 可還原原始 URL;解析失敗時退回原 URL。+ * @param {((url: string) => string)|null} resolver+ */+export function setEmojiMediaResolver(resolver) {+ emojiMediaResolver = typeof resolver === 'function' ? resolver : null;+} /** * 切換使用中的映射檔、載入內容並啟動每秒監看;先停止前一次監看。@@ -36,7 +46,11 @@ export function replaceEmojis(text) { const codes=Object.keys(emojiMap).sort((a,b)=>b.length-a.length); if(!codes.length)return text; const escaped=codes.map(code=>code.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'));- return text.replace(new RegExp(escaped.join('|'),'g'),code=>emojiMap[code]);+ return text.replace(new RegExp(escaped.join('|'),'g'),code=>{+ const url=emojiMap[code];+ if (!emojiMediaResolver || typeof url !== 'string') return url;+ try { return emojiMediaResolver(url) || url; } catch { return url; }+ }); } /**@@ -87,6 +101,7 @@ export async function saveEmojiMap(filePath = EMOJI_MAP_FILE) { export default { loadEmojiMap, replaceEmojis,+ setEmojiMediaResolver, getEmojiMap, addEmoji, removeEmoji,diff --git a/MediaRoutes.cjs b/MediaRoutes.cjs--- a/MediaRoutes.cjs+++ b/MediaRoutes.cjs@@ -1,7 +1,8 @@ const { defaultMediaCache } = require('./ScriptLib/media/cache.cjs'); /**- * 公開的圖片快取路由:只接受 /media/<sha256>,不提供任意 URL 抓取。+ * 公開的圖片快取路由:只接受 /media/<sha256>[.<ext>],不提供任意 URL 抓取。+ * 副檔名只是給客戶端分類用,實際內容仍由 Content-Type 決定。 * @param {import('./ScriptLib/media/cache.cjs').MediaCache} [cache] * @returns {Function} 與其他路由相同的同步請求處理器。 */@@ -9,7 +10,8 @@ function createMediaRoutes(cache = defaultMediaCache) { return function serveMedia(req, res) { const url = new URL(req.url, 'http://localhost'); if (!url.pathname.startsWith('/media/')) return false;- const hash = url.pathname.slice('/media/'.length);+ const hash = url.pathname.slice('/media/'.length)+ .replace(/\.(?:png|jpe?g|gif|webp|avif|bmp)$/i, ''); cache.serve(req, res, hash).catch(error => { if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });diff --git a/OtherTool/live_engine/core/emoji_parser.py b/OtherTool/live_engine/core/emoji_parser.py--- a/OtherTool/live_engine/core/emoji_parser.py+++ b/OtherTool/live_engine/core/emoji_parser.py@@ -7,6 +7,8 @@ INLINE_IMAGE_RE = re.compile( IMAGE_URL_RE = re.compile( r'(?:https?://[^\s]+?\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?'+ # MediaCache 舊版或自訂路徑可能沒有副檔名;/media/<sha256> 仍是明確圖片。+ r'|https?://[^\s]+/media/[a-f0-9]{64}(?:\.[a-z0-9]+)?(?:\?[^\s]*)?' r'|data:image/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2})', re.IGNORECASE )diff --git a/OtherTool/live_engine/tests/test_emoji_parser.py b/OtherTool/live_engine/tests/test_emoji_parser.py--- a/OtherTool/live_engine/tests/test_emoji_parser.py+++ b/OtherTool/live_engine/tests/test_emoji_parser.py@@ -83,6 +83,15 @@ class TestParseMessage: assert [s["type"] for s in segs] == ["text", "image", "text"] assert segs[1]["url"] == url + def test_media_cache_urls(self):+ for url in [+ "http://192.168.0.102:3332/media/" + "a" * 64,+ "http://192.168.0.102:3332/media/" + "b" * 64 + ".png",+ ]:+ segs = parse_message(f"look {url} end")+ assert [s["type"] for s in segs] == ["text", "image", "text"]+ assert segs[1]["url"] == url+ class TestStripImageUrls: def test_plain_text(self):diff --git a/ScriptLib/media/cache.cjs b/ScriptLib/media/cache.cjs--- a/ScriptLib/media/cache.cjs+++ b/ScriptLib/media/cache.cjs@@ -17,9 +17,29 @@ const CACHE_CONTROL = 'public, max-age=31536000, immutable'; const ALLOWED_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/bmp' ]);+const EXTENSION_BY_TYPE = new Map([+ ['image/png', '.png'], ['image/jpeg', '.jpg'], ['image/gif', '.gif'],+ ['image/webp', '.webp'], ['image/avif', '.avif'], ['image/bmp', '.bmp']+]);+const KNOWN_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp|avif|bmp)$/i; const hashUrl = value => crypto.createHash('sha256').update(value).digest('hex'); +/** 給客戶端的 URL 一律帶可辨識的圖片副檔名,讓以副檔名分類的客戶端也能處理。 */+function imageExtension(contentType, sourceUrl = '') {+ const fromType = EXTENSION_BY_TYPE.get(String(contentType || '').toLowerCase());+ if (fromType) return fromType;+ try {+ const match = KNOWN_EXTENSION_RE.exec(new URL(sourceUrl).pathname);+ if (match) return match[0].toLowerCase().replace(/\.jpeg$/i, '.jpg');+ } catch { /* ignore */ }+ return '.png';+}++function mediaHash(value) {+ return String(value || '').replace(KNOWN_EXTENSION_RE, '');+}+ /** * Media URL 給其他機器使用,預設挑目前區網私有 IPv4,而不是 localhost。 * MEDIA_BASE_URL 可明確覆寫(例如反向代理或固定 hostname)。@@ -136,7 +156,8 @@ class MediaCache { } catch { return value; }- return `${this.baseUrl}/media/${hash}`;+ const row = this.db.prepare('SELECT content_type FROM media WHERE hash=?').get(hash);+ return `${this.baseUrl}/media/${hash}${imageExtension(row?.content_type, url)}`; } /** 背景預抓 HTTP 圖片;失敗不影響原訊息。 */@@ -166,7 +187,7 @@ class MediaCache { decoded.hash, 'inline:' + decoded.hash, decoded.mime, decoded.body.length, decoded.hash, this.now(), this.now() );- return `${this.baseUrl}/media/${decoded.hash}`;+ return `${this.baseUrl}/media/${decoded.hash}${imageExtension(decoded.mime)}`; } /** 讓 monitor 歷史引用的 HTTP 快取不被 LRU 清掉。 */@@ -285,6 +306,7 @@ class MediaCache { res.end('Method Not Allowed'); return; }+ hash = mediaHash(hash); if (!/^[a-f0-9]{64}$/.test(String(hash || ''))) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found');@@ -335,7 +357,7 @@ class MediaCache { try { host = new URL(row.url).hostname; } catch { /* ignore */ } return { hash: row.hash,- localUrl: `${this.baseUrl}/media/${row.hash}`,+ localUrl: `${this.baseUrl}/media/${row.hash}${imageExtension(row.content_type)}`, host, contentType: row.content_type || '', byteSize: Number(row.byte_size) || 0,@@ -386,4 +408,7 @@ class MediaCache { const defaultMediaCache = new MediaCache(); -module.exports = { MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress, detectMediaBaseUrl };+module.exports = {+ MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress,+ detectMediaBaseUrl, imageExtension, mediaHash+};diff --git a/ScriptLib/monitor/store.cjs b/ScriptLib/monitor/store.cjs--- a/ScriptLib/monitor/store.cjs+++ b/ScriptLib/monitor/store.cjs@@ -94,7 +94,7 @@ class MonitorHistoryStore { if (!this.mediaCache || typeof value !== 'string' || !inlineMedia.isInlineImage(value)) return value; try { const local = this.mediaCache.storeInlineDataUri(value);- const hash = /\/media\/([a-f0-9]{64})$/.exec(local || '')?.[1];+ const hash = /\/media\/([a-f0-9]{64})(?:\.[a-z0-9]+)?$/.exec(local || '')?.[1]; if (hash) { hashes.add(hash); return local;diff --git a/Test/emoji_store.test.cjs b/Test/emoji_store.test.cjs--- a/Test/emoji_store.test.cjs+++ b/Test/emoji_store.test.cjs@@ -123,6 +123,10 @@ test('方括號按字面匹配,插入網址中的代碼與替換符號不會 await emojiMap.loadEmojiMap(file); assert.equal(emojiMap.replaceEmojis('[x]'), 'https://example.com/$&/long.png');+ emojiMap.setEmojiMediaResolver(url => url.endsWith('.png')+ ? 'http://media.test/media/' + 'a'.repeat(64) + '.png' : url);+ t.after(() => emojiMap.setEmojiMediaResolver(null));+ assert.match(emojiMap.replaceEmojis('[x]'), /^http:\/\/media\.test\/media\/[a-f0-9]{64}\.png$/); }); test('管理 API 檢查登入、持久化修改,並回報版本衝突', async t => {diff --git a/Test/media_cache.test.cjs b/Test/media_cache.test.cjs--- a/Test/media_cache.test.cjs+++ b/Test/media_cache.test.cjs@@ -5,7 +5,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const http = require('node:http');-const { MediaCache, hashUrl, normalizeImageUrl, detectMediaBaseUrl } = require('../ScriptLib/media/cache.cjs');+const { MediaCache, hashUrl, normalizeImageUrl, detectMediaBaseUrl, imageExtension, mediaHash } = require('../ScriptLib/media/cache.cjs'); const { createMediaRoutes, createMediaAdminRoutes } = require('../MediaRoutes.cjs'); const { SocketHub } = require('../ScriptLib/socket/hub.mjs'); @@ -23,6 +23,15 @@ function fakeServe(cache, hash, method = 'GET', headers = {}) { }); } +test('Media URL 依內容類型或來源補上圖片副檔名', () => {+ assert.equal(imageExtension('image/jpeg'), '.jpg');+ assert.equal(imageExtension('image/webp'), '.webp');+ assert.equal(imageExtension('', 'https://cdn.example/a.WEBP?x=1'), '.webp');+ assert.equal(imageExtension('', 'https://cdn.example/a.jpeg'), '.jpg');+ assert.equal(imageExtension('', 'https://cdn.example/no-extension'), '.png');+ assert.equal(mediaHash('a'.repeat(64) + '.jpg'), 'a'.repeat(64));+});+ test('Media base 預設使用私有 LAN IPv4,且允許環境變數覆寫', () => { const interfaces = { lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],@@ -65,7 +74,7 @@ test('MediaCache 把外部 URL 換成本地 hash,下載後可再從快取供 const source = `http://127.0.0.1:${server.address().port}/img.png`; const local = cache.localUrl(source); const hash = hashUrl(source);- assert.equal(local, `http://127.0.0.1:3332/media/${hash}`);+ assert.equal(local, `http://127.0.0.1:3332/media/${hash}.png`); assert.equal(cache.localUrl(source), local); const first = await fakeServe(cache, hash);@@ -83,6 +92,7 @@ test('MediaCache 把外部 URL 換成本地 hash,下載後可再從快取供 const second = await fakeServe(cache, hash); assert.equal(second.status, 200); assert.equal(second.headers.ETag, '"' + hash + '"');+ assert.equal((await fakeServe(cache, hash + '.png')).status, 200); const notModified = await fakeServe(cache, hash, 'GET', { 'if-none-match': second.headers.ETag }); assert.equal(notModified.status, 304); @@ -132,7 +142,7 @@ test('MediaRoutes 只接管 /media/<hash> 並呼叫快取', async () => { serve(req, res, hash) { calls.push(hash); res.writeHead(200); res.end(); return Promise.resolve(); } }); const response = await new Promise(resolve => {- const req = { url: '/media/' + 'a'.repeat(64), method: 'GET', headers: {} };+ const req = { url: '/media/' + 'a'.repeat(64) + '.png', method: 'GET', headers: {} }; const res = { writeHead() {}, end() { resolve(true); } };@@ -154,8 +164,8 @@ test('inline 圖片 pinned,LRU 不刪;釋放引用後才清檔', t => { fs.rmSync(dir, { recursive: true, force: true }); }); const local = cache.storeInlineDataUri('data:image/png;base64,iVBORw0KGgo=');- assert.match(local, /\/media\/[a-f0-9]{64}$/);- const hash = local.split('/').pop();+ assert.match(local, /\/media\/[a-f0-9]{64}\.png$/);+ const hash = local.split('/').pop().replace(/\.[a-z0-9]+$/i, ''); assert.equal(fs.existsSync(cache.filePath(hash)), true); cache.prune(); assert.equal(fs.existsSync(cache.filePath(hash)), true);diff --git a/TikTok.js b/TikTok.js--- a/TikTok.js+++ b/TikTok.js@@ -43,7 +43,7 @@ import { TikTokLiveConnection } from 'tiktok-live-connector'; import { setupCustomSignServer, waitForSigner, setStreamerName } from './SignServer/index.js'; import Translate from "./TranslateTest.js" import { recordMessageStat, getTopMessages, getAllMessageStatsSorted, processFilter, clearStats, takeThrottleSummaries } from "./MessageFilter.js"-import { replaceEmojis, loadEmojiMap, getEmojiMap } from "./EmojiMap.js"+import { replaceEmojis, setEmojiMediaResolver, loadEmojiMap, getEmojiMap } from "./EmojiMap.js" import { startKickChat } from './ScriptLib/kick/chat.mjs'; import mediaCacheModule from './ScriptLib/media/cache.cjs'; import console from 'console';@@ -887,6 +887,15 @@ function cachedMediaUrl(value) { } } +// 只有沒有圖片副檔名的表情 URL 才轉進 MediaCache;已有副檔名者維持原遠端 URL。+// 轉換後固定是 /media/<hash>.<ext>,以副檔名分類的客戶端也能辨識。+const EMOJI_IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|avif|bmp)(?:[?#]|$)/i;+setEmojiMediaResolver(url => {+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return url;+ if (EMOJI_IMAGE_EXT_RE.test(url)) return url;+ return cachedMediaUrl(url);+});+ function sendAdOverylayMessage(user, text, iconURL, useTTS) { // 只改 overlay 使用的圖片;通知仍保留原始 URL,手機才連得到外部站。 const localText = String(text || '').replace(AD_IMAGE_URL_RE, url => cachedMediaUrl(url));diff --git a/assets/app.css.build.json b/assets/app.css.build.json--- a/assets/app.css.build.json+++ b/assets/app.css.build.json@@ -1,5 +1,5 @@ { "schema": 2,- "inputHash": "d5695138279146aa1fca53e5fda83ba8441f95737fedabfa82f6ffba142846f4",+ "inputHash": "b904a07281dcfbda88874f967a15ccd683e3d022a1fe24006a5a701f3f4473f2", "outputHash": "5e8e8ac9b7a490acf78fc06ee9e9c0e63e69a0a32e695f93025dce6a24930dce" }diff --git a/todo.md b/todo.md--- a/todo.md+++ b/todo.md@@ -22,7 +22,7 @@ - [ ] 獨立 `/dedup`(或 `/diagnostics/dedup`)診斷頁:最近 test results、其他送入訊息的去重狀態查詢與剩餘冷卻;由 monitor/runtime 提供入口。 - [x] monitor 歷史 SQLite 持久化、最近場次 API 與即時/歷史切換。 - [x] Signer TikTok 導航節流與 WAF 403 冷卻,避免 fetch page 重建風暴。-- [x] 圖片媒體快取 Phase 1:本地 `/media/<hash>`、SocketHub 頭像/禮物圖與 AdOverlay 圖片改寫。+- [x] 圖片媒體快取 Phase 1:本地 `/media/<hash>.<ext>`、SocketHub 頭像/禮物圖與 AdOverlay 圖片改寫。 - [x] Python overlay 支援 img/giftImg/AdOverlay icon 與訊息內嵌 data URI。 - [x] monitor MediaCache 檢視/本地連結複製與 Raw Socket 測試入口。 - [x] 應用診斷 `/diagnostics`、LogServer 相容 `/post` 與可選 token。