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:
1 parent
0f02cdc036
commit
cbb8f7bda9
2 files changed
+234
-39
No files matched your search
+123
-39
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user