重构爱壹帆签名工具并优化并发复用 (#266)
This commit is contained in:
1 parent
9bc48f2360
commit
c2eeeb164b
2 files changed
+349
-78
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user