重构爱壹帆签名工具并优化并发复用 (#266)

This commit is contained in:
Wo254992 authored and GitHub committed 2026-03-31 22:25:07 +08:00
1 parent 9bc48f2360
commit c2eeeb164b
2 files changed
+349 -78

No files matched your search

+61 -78
View File
@@ -1,13 +1,14 @@
import BaseSource from './base.js';
import { log } from "../utils/log-util.js";
import { httpGet, updateQueryString } from "../utils/http-util.js";
import { convertToAsciiSum, md5 } from "../utils/codec-util.js";
import { convertToAsciiSum } from "../utils/codec-util.js";
import { hexToInt } from "../utils/danmu-util.js";
import { generateValidStartDate } from "../utils/time-util.js";
import { addAnime, removeEarliestAnime } from "../utils/cache-util.js";
import { titleMatches } from "../utils/common-util.js";
import { simplized } from "../utils/zh-util.js";
import { globals } from '../configs/globals.js';
import { AiyifanSigningProvider } from '../utils/aiyifan-util.js';
// =====================
// 获取爱壹帆弹幕
@@ -15,10 +16,6 @@ import { globals } from '../configs/globals.js';
export default class AiyifanSource extends BaseSource {
constructor() {
super();
// 签名常量
this.PUBLIC_KEY = "CJStD3SqE3GrCouoCpbVIb1VCJOmBZ4sBZ8mE2uoDJHVDpKrP69cEMKtCZ0qD31bP68qDJ9bCJOvDZ4oDM4sOJ1VCJTcCpOuCpHYCpOmDZLcOJTaD3GrDZ5ZP68qOJOpDc6";
this.SALT = "StD3JStD3SqE3GrCouoC";
this.USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
@@ -31,17 +28,20 @@ export default class AiyifanSource extends BaseSource {
this.VIDEO_API = "https://m10.yfsp.tv/v3/video/play";
this.DANMU_API = "https://m10.yfsp.tv/api/video/getBarrage";
this.DOMAIN_API = "https://www.yfsp.tv/play";
this.CONFIG_PAGE_API = "https://www.yfsp.tv/";
this.signingProvider = new AiyifanSigningProvider({
userAgent: this.USER_AGENT,
configPageUrl: this.CONFIG_PAGE_API
});
this.inflightDanmuRequests = new Map();
}
/**
* 计算接口签名 vv
*/
computeVv(params) {
const sortedParams = Object.keys(params)
.map(k => `${k}=${params[k]}`)
.join('&');
const raw = this.PUBLIC_KEY + "&" + sortedParams.toLowerCase() + "&" + this.SALT;
return md5(raw);
extractEpisodeRequestKey(id) {
try {
return new URL(id).searchParams.get("id") ?? id;
} catch {
return id;
}
}
/**
@@ -132,14 +132,6 @@ export default class AiyifanSource extends BaseSource {
cid: "0,1,4,152",
};
const vv = this.computeVv(baseParams);
const params = {
...baseParams,
vv: vv,
pub: this.PUBLIC_KEY
};
const headers = {
"User-Agent": this.USER_AGENT,
"Accept": "application/json"
@@ -148,10 +140,7 @@ export default class AiyifanSource extends BaseSource {
log("info", `[播放列表] 请求 vid: ${vid}`);
try {
const urlWithParams = updateQueryString(this.PLAYLIST_API, params);
const response = await httpGet(globals.makeProxyUrl(urlWithParams), { headers });
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
const { data } = await this.signingProvider.signedGetJson(this.PLAYLIST_API, baseParams, headers, "播放列表");
const episodes = [];
const infoList = data.data?.info || [];
@@ -187,14 +176,6 @@ export default class AiyifanSource extends BaseSource {
isMasterSupport: 1
};
const vv = this.computeVv(baseParams);
const params = {
...baseParams,
vv: vv,
pub: this.PUBLIC_KEY
};
const headers = {
"User-Agent": this.USER_AGENT,
"Accept": "application/json"
@@ -202,13 +183,10 @@ export default class AiyifanSource extends BaseSource {
const epInfo = epId ? `(ID:${epId})` : "";
log("info", `[视频信息] 请求 key: ${epKey} ${epInfo}`);
log("info", `[视频信息] vv签名: ${vv.substring(0, 16)}...`);
try {
const urlWithParams = updateQueryString(this.VIDEO_API, params);
const response = await httpGet(globals.makeProxyUrl(urlWithParams), { headers });
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
const { data, vv } = await this.signingProvider.signedGetJson(this.VIDEO_API, baseParams, headers, "视频信息");
log("info", `[视频信息] vv签名: ${vv.substring(0, 16)}...`);
return data.data || {};
} catch (error) {
log("error", `[视频信息失败] 错误: ${error.message}`);
@@ -245,26 +223,15 @@ export default class AiyifanSource extends BaseSource {
uniqueKey: uniqueKey,
};
const vv = this.computeVv(baseParams);
const params = {
...baseParams,
vv: vv,
pub: this.PUBLIC_KEY
};
const headers = {
"User-Agent": this.USER_AGENT,
};
log("info", `[弹幕] 请求 uniqueKey: ${uniqueKey}`);
log("info", `[弹幕] vv签名: ${vv.substring(0, 16)}...`);
try {
const urlWithParams = updateQueryString(this.DANMU_API, params);
const response = await httpGet(globals.makeProxyUrl(urlWithParams), { headers });
const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data;
const { data, vv } = await this.signingProvider.signedGetJson(this.DANMU_API, baseParams, headers, "弹幕");
log("info", `[弹幕] vv签名: ${vv.substring(0, 16)}...`);
const danmuList = data.data?.info || [];
log("info", `[弹幕] 获取到 ${danmuList.length} 条弹幕`);
@@ -419,35 +386,51 @@ export default class AiyifanSource extends BaseSource {
async getEpisodeDanmu(id) {
log("info", `[Aiyifan] 获取弹幕: ${id}`);
// 从 URL 中提取 id 参数
const videoId = new URL(id).searchParams.get("id") ?? id;
// 获取视频信息
const videoInfo = await this.getVideoInfo(videoId);
if (!videoInfo) {
log("error", "获取视频信息失败");
return [];
const requestKey = this.extractEpisodeRequestKey(id);
const inflightRequest = this.inflightDanmuRequests.get(requestKey);
if (inflightRequest) {
log("info", `[Aiyifan] 复用进行中的弹幕请求: ${requestKey}`);
return await inflightRequest;
}
// 提取uniqueKey
const uniqueKey = this.extractUniqueKey(videoInfo);
if (!uniqueKey) {
log("error", "未获取到uniqueKey");
return [];
const requestPromise = (async () => {
// 从 URL 中提取 id 参数
const videoId = requestKey;
// 获取视频信息
const videoInfo = await this.getVideoInfo(videoId);
if (!videoInfo) {
log("error", "获取视频信息失败");
return [];
}
// 提取uniqueKey
const uniqueKey = this.extractUniqueKey(videoInfo);
if (!uniqueKey) {
log("error", "未获取到uniqueKey");
return [];
}
// 获取弹幕
const danmuList = await this.fetchBarrage(uniqueKey);
if (danmuList.length === 0) {
log("info", "未获取到弹幕");
return [];
}
// 按时间排序
danmuList.sort((a, b) => (a.second || 0) - (b.second || 0));
log("info", `[Aiyifan] 获取到 ${danmuList.length} 条弹幕`);
return danmuList;
})();
this.inflightDanmuRequests.set(requestKey, requestPromise);
try {
return await requestPromise;
} finally {
this.inflightDanmuRequests.delete(requestKey);
}
// 获取弹幕
const danmuList = await this.fetchBarrage(uniqueKey);
if (danmuList.length === 0) {
log("info", "未获取到弹幕");
return [];
}
// 按时间排序
danmuList.sort((a, b) => (a.second || 0) - (b.second || 0));
log("info", `[Aiyifan] 获取到 ${danmuList.length} 条弹幕`);
return danmuList;
}
/**
+288
View File
@@ -0,0 +1,288 @@
import { globals } from '../configs/globals.js';
import { log } from "./log-util.js";
import { md5 } from "./codec-util.js";
import { httpGet, updateQueryString } from "./http-util.js";
const DEFAULT_CONFIG_PAGE_URL = "https://www.yfsp.tv/";
const DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/124.0.0 Safari/537.36"
);
export const AIYIFAN_SIGNING_CONFIG_TTL_MS = 60 * 1000;
function extractAssignedObjectLiteral(html, variableName) {
const assignmentPattern = new RegExp(`\\b(?:var|let|const)\\s+${variableName}\\s*=\\s*`);
const match = assignmentPattern.exec(html);
if (!match) {
return null;
}
const objectStart = html.indexOf("{", match.index + match[0].length);
if (objectStart === -1) {
return null;
}
let depth = 0;
let inString = false;
let quote = "";
let escaped = false;
for (let i = objectStart; i < html.length; i++) {
const char = html[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
inString = false;
quote = "";
}
continue;
}
if (char === '"' || char === "'") {
inString = true;
quote = char;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return html.slice(objectStart, i + 1);
}
}
}
return null;
}
function parseFallbackPConfig(html) {
const match = html.match(/"pConfig"\s*:\s*\{\s*"publicKey"\s*:\s*"([^"]+)"\s*,\s*"privateKey"\s*:\s*\[(.*?)\]\s*\}/s);
if (!match) {
return null;
}
let privateKeys = [];
try {
privateKeys = JSON.parse(`[${match[2]}]`);
} catch {
return null;
}
if (!match[1] || !privateKeys.length) {
return null;
}
return {
publicKey: match[1],
privateKey: privateKeys[0]
};
}
export function extractPConfigFromInjectJson(injectJson) {
const config = injectJson?.config?.[0]?.pConfig;
const publicKey = config?.publicKey;
const privateKey = Array.isArray(config?.privateKey) ? config.privateKey[0] : config?.privateKey;
if (!publicKey || !privateKey) {
return null;
}
return { publicKey, privateKey };
}
export function extractPConfigFromHtml(html) {
const objectLiteral = extractAssignedObjectLiteral(html, "injectJson");
if (objectLiteral) {
try {
const injectJson = JSON.parse(objectLiteral);
const signingConfig = extractPConfigFromInjectJson(injectJson);
if (signingConfig) {
return signingConfig;
}
} catch (error) {
log("warn", `[Aiyifan] 解析 injectJson 失败,回退到 pConfig 提取: ${error.message}`);
}
}
return parseFallbackPConfig(html);
}
function normalizeQueryValue(value) {
if (value === undefined || value === null) {
return null;
}
return String(value);
}
function isSigningParam(key) {
return key === "vv" || key === "pub";
}
function splitQueryString(queryString) {
if (!queryString) {
return [];
}
return queryString
.split("&")
.filter(Boolean)
.map(pair => {
const equalsIndex = pair.indexOf("=");
const rawKey = equalsIndex === -1 ? pair : pair.slice(0, equalsIndex);
const rawValue = equalsIndex === -1 ? "" : pair.slice(equalsIndex + 1);
const key = decodeURIComponent(rawKey.replace(/\+/g, "%20"));
const value = decodeURIComponent(rawValue.replace(/\+/g, "%20"));
return [key, value];
});
}
function getQueryEntries(input) {
if (!input) {
return [];
}
if (typeof input === "string") {
const trimmed = input.trim();
if (!trimmed) {
return [];
}
let queryString = trimmed;
const queryIndex = trimmed.indexOf("?");
if (queryIndex !== -1) {
const hashIndex = trimmed.indexOf("#", queryIndex);
queryString = trimmed.slice(queryIndex + 1, hashIndex === -1 ? undefined : hashIndex);
} else if (trimmed.startsWith("?")) {
queryString = trimmed.slice(1);
}
return splitQueryString(queryString);
}
if (input instanceof URLSearchParams) {
return Array.from(input.entries());
}
return Object.entries(input)
.map(([key, value]) => [key, normalizeQueryValue(value)])
.filter(([, value]) => value !== null);
}
export function buildCanonicalQuery(input) {
return getQueryEntries(input)
.filter(([key]) => !isSigningParam(key))
.map(([key, value]) => `${key}=${value}`)
.join("&");
}
export function computeAiyifanVv(input, signingConfig) {
const query = buildCanonicalQuery(input);
const raw = `${signingConfig.publicKey}&${query.toLowerCase()}&${signingConfig.privateKey}`;
return md5(raw);
}
function normalizeJsonPayload(data) {
if (typeof data === "string") {
return JSON.parse(data);
}
return data;
}
function isSignedRequestSuccessful(payload) {
return payload?.ret === 200 && payload?.data?.code === 0;
}
function getFailureMessage(payload, status) {
return payload?.data?.msg || payload?.msg || `HTTP ${status}`;
}
export class AiyifanSigningProvider {
constructor(options = {}) {
this.request = options.request || httpGet;
this.proxyUrlBuilder = options.proxyUrlBuilder || (url => globals.makeProxyUrl(url));
this.userAgent = options.userAgent || DEFAULT_USER_AGENT;
this.configPageUrl = options.configPageUrl || DEFAULT_CONFIG_PAGE_URL;
this.ttlMs = options.ttlMs || AIYIFAN_SIGNING_CONFIG_TTL_MS;
this.now = options.now || (() => Date.now());
this.signingConfig = null;
this.signingConfigFetchedAt = 0;
}
async getSigningConfig(forceRefresh = false) {
const now = this.now();
const cacheValid = this.signingConfig && (now - this.signingConfigFetchedAt) < this.ttlMs;
if (!forceRefresh && cacheValid) {
return this.signingConfig;
}
const response = await this.request(this.proxyUrlBuilder(this.configPageUrl), {
headers: {
"User-Agent": this.userAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}
});
const html = typeof response.data === "string" ? response.data : String(response.data ?? "");
const signingConfig = extractPConfigFromHtml(html);
if (!signingConfig) {
throw new Error("未能从桌面站页面解析到 pConfig");
}
this.signingConfig = signingConfig;
this.signingConfigFetchedAt = now;
log("info", `[Aiyifan] 已更新桌面站签名配置: ${signingConfig.publicKey.slice(0, 12)}...`);
return signingConfig;
}
buildSignedParams(baseParams, signingConfig) {
return {
...baseParams,
vv: computeAiyifanVv(baseParams, signingConfig),
pub: signingConfig.publicKey
};
}
async signedGetJson(api, baseParams, headers = {}, logPrefix = "Aiyifan", forceRefresh = false) {
const signingConfig = await this.getSigningConfig(forceRefresh);
const signedParams = this.buildSignedParams(baseParams, signingConfig);
const requestUrl = updateQueryString(api, signedParams);
const response = await this.request(this.proxyUrlBuilder(requestUrl), { headers });
let payload;
try {
payload = normalizeJsonPayload(response.data);
} catch (error) {
if (!forceRefresh) {
log("warn", `[${logPrefix}] 响应无法解析为 JSON,刷新签名配置后重试: ${error.message}`);
return this.signedGetJson(api, baseParams, headers, logPrefix, true);
}
throw error;
}
if (response.status !== 200 || !isSignedRequestSuccessful(payload)) {
if (!forceRefresh) {
log("warn", `[${logPrefix}] 当前签名请求失败,刷新 pConfig 后重试: ${getFailureMessage(payload, response.status)}`);
return this.signedGetJson(api, baseParams, headers, logPrefix, true);
}
throw new Error(getFailureMessage(payload, response.status));
}
return {
data: payload,
vv: signedParams.vv,
signingConfig
};
}
}