fix(youku): cna 获取失败时本地生成兜底,避免 500 (#475)

- mmstat(log.mmstat.com) 被广告过滤/私人 DNS 拦截时,改用本地生成的 24 位 cna
  继续取弹幕;实测优酷弹幕接口只把 cna 当客户端标识回传,不校验其来源
- 首次失败后进程内复用兜底 cna,后续请求不再访问 mmstat
- getEpisodeDanmuSegments 所有失败分支统一返回空 SegmentListResponse,
  getEpisodeDanmu 增加空值兜底,避免 segmentList is not iterable 导致 HTTP 500
- 视频信息解析/时长校验、mtop token 缺失、弹幕属性解析增加防护
- 新增 5 个回归测试覆盖上述场景
This commit is contained in:
lilixu3 authored and GitHub committed 2026-09-13 13:10:01 +08:00
1 parent 0f02cdc036
commit cbb8f7bda9
2 files changed
+234 -39

No files matched your search

+123 -39
View File
@@ -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;
});
}
+111
View File
@@ -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);
});
});