From cbb8f7bda90bf58716284b1e52e66f8a00d56689 Mon Sep 17 00:00:00 2001 From: lilixu3 Date: Sun, 13 Sep 2026 13:10:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(youku):=20cna=20=E8=8E=B7=E5=8F=96=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E6=9C=AC=E5=9C=B0=E7=94=9F=E6=88=90=E5=85=9C?= =?UTF-8?q?=E5=BA=95=EF=BC=8C=E9=81=BF=E5=85=8D=20500=20(#475)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mmstat(log.mmstat.com) 被广告过滤/私人 DNS 拦截时,改用本地生成的 24 位 cna 继续取弹幕;实测优酷弹幕接口只把 cna 当客户端标识回传,不校验其来源 - 首次失败后进程内复用兜底 cna,后续请求不再访问 mmstat - getEpisodeDanmuSegments 所有失败分支统一返回空 SegmentListResponse, getEpisodeDanmu 增加空值兜底,避免 segmentList is not iterable 导致 HTTP 500 - 视频信息解析/时长校验、mtop token 缺失、弹幕属性解析增加防护 - 新增 5 个回归测试覆盖上述场景 --- danmu_api/sources/youku.js | 162 ++++++++++++++++++++++++++++--------- danmu_api/worker.test.js | 111 +++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 39 deletions(-) diff --git a/danmu_api/sources/youku.js b/danmu_api/sources/youku.js index f07b74b..081e2a7 100644 --- a/danmu_api/sources/youku.js +++ b/danmu_api/sources/youku.js @@ -11,7 +11,51 @@ import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取优酷弹幕 // ===================== + +/** + * 生成本地兜底 cna。 + * 优酷弹幕接口只把 cna 当作请求体里的客户端标识(guid)回传,不校验其来源, + * mmstat 域名被广告过滤/私人 DNS 拦截时可用本地生成值代替(详见 getEpisodeDanmuSegments)。 + */ +function generateLocalCna() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const bytes = new Uint8Array(24); + if (typeof globalThis.crypto?.getRandomValues === 'function') { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + let cna = ''; + for (const byte of bytes) { + cna += chars[byte % chars.length]; + } + return cna; +} + export default class YoukuSource extends BaseSource { + constructor() { + super(); + // mmstat 取 cna 失败后置为 true,后续请求直接复用本地 cna,避免每次都等待失败请求超时 + this._useFallbackCna = false; + this._fallbackCna = null; + } + + _getFallbackCna() { + if (!this._fallbackCna) { + this._fallbackCna = generateLocalCna(); + } + return this._fallbackCna; + } + + _emptySegmentList() { + return new SegmentListResponse({ + "type": "youku", + "segmentList": [] + }); + } + convertYoukuUrl(url) { // 使用正则表达式提取 vid 参数 const vidMatch = url.match(/vid=([^&]+)/); @@ -399,12 +443,17 @@ export default class YoukuSource extends BaseSource { // 获取分片URL列表 const segmentListResponse = await this.getEpisodeDanmuSegments(id); - const segmentList = segmentListResponse.segmentList; + const segmentList = segmentListResponse?.segmentList ?? []; + + if (segmentList.length === 0) { + log("info", "[youku] 未获取到弹幕分片,返回空结果"); + return []; + } let contents = []; // 并发限制(可通过环境变量 YOUKU_CONCURRENCY 配置,默认 8) - const concurrency = globals.youkuConcurrency; + const concurrency = Math.max(1, Number(globals.youkuConcurrency) || 8); const segments = [...segmentList]; for (let i = 0; i < segments.length; i += concurrency) { @@ -451,10 +500,7 @@ export default class YoukuSource extends BaseSource { log("info", "[youku] 获取优酷弹幕分段列表...", id); if (!id) { - return new SegmentListResponse({ - "type": "youku", - "segmentList": [] - }); + return this._emptySegmentList(); } // 处理302场景 @@ -463,6 +509,11 @@ export default class YoukuSource extends BaseSource { id = this.convertYoukuUrl(id); } + if (!id) { + log("error", "[youku] Invalid URL"); + return this._emptySegmentList(); + } + // 弹幕和视频信息 API 基础地址 const api_video_info = "https://openapi.youku.com/v2/videos/show.json"; const api_danmaku = "https://acs.youku.com/h5/mopen.youku.danmu.list/1.0/"; @@ -478,7 +529,7 @@ export default class YoukuSource extends BaseSource { log("info", "[youku]", path); } else { log("error", "[youku] Invalid URL"); - return []; + return this._emptySegmentList(); } const video_id = path[path.length - 1].split(".")[0].slice(3); @@ -497,35 +548,61 @@ export default class YoukuSource extends BaseSource { }); } catch (error) { log("error", "[youku] 请求视频信息失败:", error); - return []; + return this._emptySegmentList(); } - const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data; + let data; + try { + data = typeof res?.data === "string" ? JSON.parse(res.data) : res?.data; + } catch (error) { + log("error", "[youku] 视频信息解析失败:", error.message); + return this._emptySegmentList(); + } + if (!data) { + log("error", "[youku] 视频信息为空"); + return this._emptySegmentList(); + } const title = data.title; - const duration = data.duration; + const duration = Number(data.duration); + if (!Number.isFinite(duration) || duration <= 0) { + log("error", `[youku] 视频时长无效: ${data.duration}`); + return this._emptySegmentList(); + } log("info", `[youku] 标题: ${title}, 时长: ${duration}`); - // 获取 cna 和 tk_enc - let cna, _m_h5_tk_enc, _m_h5_tk; - try { - const cnaUrl = "https://log.mmstat.com/eg.js"; - const tkEncUrl = "https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788"; - const cnaRes = await httpGet(cnaUrl, { - headers: { - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36", - }, - allow_redirects: false - }); - log("info", `[youku] cnaRes: ${JSON.stringify(cnaRes)}`); - log("info", `[youku] cnaRes.headers: ${JSON.stringify(cnaRes.headers)}`); - const etag = cnaRes.headers["etag"] || cnaRes.headers["Etag"]; - log("info", `[youku] etag: ${etag}`); - // const match = cnaRes.headers["set-cookie"].match(/cna=([^;]+)/); - // cna = match ? match[1] : null; - cna = etag.replace(/^"|"$/g, ''); - log("info", `[youku] cna: ${cna}`); + // 获取 cna:mmstat 域名常被广告过滤/私人 DNS 拦截, + // 首次失败后改用本地生成的等价格式 cna 兜底,之后不再请求该域名 + let cna = null; + let _m_h5_tk_enc = null, _m_h5_tk = null; + if (this._useFallbackCna) { + cna = this._getFallbackCna(); + } else { + try { + const cnaUrl = "https://log.mmstat.com/eg.js"; + const cnaRes = await httpGet(cnaUrl, { + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36", + }, + allow_redirects: false + }); + const etag = cnaRes?.headers?.["etag"] || cnaRes?.headers?.["Etag"]; + if (typeof etag === "string" && etag.trim()) { + cna = etag.replace(/^"|"$/g, ''); + } + } catch (error) { + log("info", `[youku] 获取 cna 失败,切换本地生成值兜底: ${error.message}`); + } + if (!cna) { + this._useFallbackCna = true; + cna = this._getFallbackCna(); + log("info", `[youku] 使用本地生成 cna 兜底: ${cna}`); + } + } + // 获取 tk_enc / _m_h5_tk(mtop 签名 token 由服务端下发,无法本地生成) + try { + const tkEncUrl = "https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788"; let tkEncRes; while (!tkEncRes) { tkEncRes = await httpGet(tkEncUrl, { @@ -536,10 +613,7 @@ export default class YoukuSource extends BaseSource { allow_redirects: false }); } - log("info", `[youku] tkEncRes: ${JSON.stringify(tkEncRes)}`); - log("info", `[youku] tkEncRes.headers: ${JSON.stringify(tkEncRes.headers)}`); - const tkEncSetCookie = tkEncRes.headers["set-cookie"] || tkEncRes.headers["Set-Cookie"]; - log("info", `[youku] tkEncSetCookie: ${tkEncSetCookie}`); + const tkEncSetCookie = tkEncRes?.headers?.["set-cookie"] || tkEncRes?.headers?.["Set-Cookie"] || ""; // 获取 _m_h5_tk_enc const tkEncMatch = tkEncSetCookie.match(/_m_h5_tk_enc=([^;]+)/); @@ -552,8 +626,13 @@ export default class YoukuSource extends BaseSource { log("info", `[youku] _m_h5_tk_enc: ${_m_h5_tk_enc}`); log("info", `[youku] _m_h5_tk: ${_m_h5_tk}`); } catch (error) { - log("error", "[youku] 获取 cna 或 tk_enc 失败:", error); - return []; + log("error", "[youku] 获取 tk_enc 失败:", error); + return this._emptySegmentList(); + } + + if (!_m_h5_tk || !_m_h5_tk_enc) { + log("error", "[youku] 未获取到有效的 mtop token,跳过弹幕请求"); + return this._emptySegmentList(); } // 计算弹幕分段请求 @@ -695,8 +774,13 @@ export default class YoukuSource extends BaseSource { uid: 0, content: "", }; - content.timepoint = item.playat / 1000; - const prop = JSON.parse(item.propertis) + content.timepoint = Number(item.playat) / 1000 || 0; + let prop = null; + try { + prop = item.propertis ? JSON.parse(item.propertis) : null; + } catch (error) { + log("info", `[youku] 弹幕属性解析失败,已忽略颜色/位置: ${error.message}`); + } if (prop?.color) { content.color = typeof prop.color === 'string' ? parseInt(prop.color, 10) @@ -708,7 +792,7 @@ export default class YoukuSource extends BaseSource { else if (pos === 2) content.ct = 4; } content.content = item.content; - content.like = item.extFields.voteUp; + content.like = item.extFields?.voteUp ?? 0; return content; }); } diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index c9c5055..7b00edf 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -25,6 +25,7 @@ import { getSourceByKey } from './sources/registry.js'; import BilibiliSource from "./sources/bilibili.js"; import { parseHongguoPlayerUrl } from "./sources/hongguo.js"; import TencentSource from "./sources/tencent.js"; +import YoukuSource from "./sources/youku.js"; import { NodeHandler } from "./configs/handlers/node-handler.js"; import { VercelHandler } from "./configs/handlers/vercel-handler.js"; import { NetlifyHandler } from "./configs/handlers/netlify-handler.js"; @@ -4402,3 +4403,113 @@ test('movies may omit season and episode while TV uploads default both to one', assert.equal(elements.get('local-danmu-episode').value, '1'); assert.match(elements.get('local-danmu-upload-status').textContent, /第1季上传成功/); }); + +test('youku source falls back to a locally generated cna', async (t) => { + const youkuUrl = 'https://v.youku.com/v_show/id_XNjQ3ODMyNjU3Mg==.html'; + + // 生产路径由 handleRequest/server 初始化 globals(danmuLimit 等),源单测需自行初始化 + Globals.init({}); + + // httpGet/httpPost 依赖真实 Response 形状:ok/status/headers.entries()/text() + const mockResponse = (data, headers) => ({ + ok: true, + status: 200, + url: '', + headers: new Headers(headers), + text: async () => (typeof data === 'string' ? data : JSON.stringify(data)) + }); + + const mockDanmakuResponse = () => mockResponse({ + data: { + result: JSON.stringify({ + code: '0', + data: { + result: [ + { playat: 1000, content: '测试弹幕', propertis: '{"color":"16711680","pos":1}', extFields: { voteUp: 3 } } + ] + } + }) + } + }, { 'content-type': 'application/json' }); + + const buildYoukuFetch = ({ mmstatFails = false, mmstatMissingEtag = false, tokenFails = false } = {}) => { + const calls = { mmstat: 0, token: 0, danmaku: 0 }; + const fetchImpl = async (url) => { + const target = String(url); + if (target.includes('log.mmstat.com')) { + calls.mmstat++; + if (mmstatFails) throw new Error('simulated mmstat block'); + if (mmstatMissingEtag) return mockResponse('', {}); + return mockResponse('', { etag: '"maQrIwQESUACASdE0z2p2AgV"' }); + } + if (target.includes('mtop.com.youku.aplatform.weakget')) { + calls.token++; + if (tokenFails) throw new Error('simulated token block'); + return mockResponse({}, { 'set-cookie': '_m_h5_tk=token123_456;Path=/;_m_h5_tk_enc=enc123;Path=/' }); + } + if (target.includes('openapi.youku.com/v2/videos/show.json')) { + return mockResponse({ title: '测试剧集', duration: 120 }, { 'content-type': 'application/json' }); + } + if (target.includes('mopen.youku.danmu.list')) { + calls.danmaku++; + return mockDanmakuResponse(); + } + throw new Error(`unexpected fetch: ${target}`); + }; + return { fetchImpl, calls }; + }; + + await t.test('mmstat 被拦截时改用本地 cna 继续取弹幕,且后续不再请求该域名', async () => { + const source = new YoukuSource(); + const { fetchImpl, calls } = buildYoukuFetch({ mmstatFails: true }); + + const first = await withMockFetch(fetchImpl, () => source.getComments(youkuUrl, 'youku', false)); + assert.ok(first.length > 0, 'mmstat 失败后仍应取到弹幕'); + assert.equal(calls.mmstat, 1); + + const second = await withMockFetch(fetchImpl, () => source.getComments(youkuUrl, 'youku', false)); + assert.ok(second.length > 0); + assert.equal(calls.mmstat, 1, '已切换兜底 cna 时不应重复请求 mmstat'); + }); + + await t.test('mmstat 响应缺少 etag 时同样走本地 cna 兜底', async () => { + const source = new YoukuSource(); + const { fetchImpl, calls } = buildYoukuFetch({ mmstatMissingEtag: true }); + + const comments = await withMockFetch(fetchImpl, () => source.getComments(youkuUrl, 'youku', false)); + assert.ok(comments.length > 0); + assert.equal(calls.mmstat, 1); + }); + + await t.test('mtop token 获取失败时返回空结果而不是抛错', async () => { + const source = new YoukuSource(); + const { fetchImpl } = buildYoukuFetch({ mmstatFails: true, tokenFails: true }); + + const segments = await withMockFetch(fetchImpl, () => source.getComments(youkuUrl, 'youku', true)); + assert.ok(segments instanceof SegmentListResponse); + assert.deepEqual(segments.segmentList, []); + + const comments = await withMockFetch(fetchImpl, () => source.getComments(youkuUrl, 'youku', false)); + assert.deepEqual(comments, []); + }); + + await t.test('无法解析的链接返回空分片列表而不是抛错', async () => { + const segments = await new YoukuSource().getEpisodeDanmuSegments('not-a-youku-url'); + assert.ok(segments instanceof SegmentListResponse); + assert.deepEqual(segments.segmentList, []); + }); + + await t.test('mmstat 被拦截时 /api/v2/comment 返回 200 而不是 500', async () => { + const { fetchImpl } = buildYoukuFetch({ mmstatFails: true }); + const req = new MockRequest( + `${urlPrefix}/api/v2/comment?url=${encodeURIComponent(youkuUrl)}&format=json`, + { method: 'GET' } + ); + const res = await withMockFetch(fetchImpl, () => handleRequest(req)); + const body = await parseResponse(res); + + assert.equal(res.status, 200); + assert.equal(body.success, true); + assert.ok(body.count > 0); + }); +});