diff --git a/danmu_api/sources/aiyifan.js b/danmu_api/sources/aiyifan.js index 9c72d55..b76ded6 100644 --- a/danmu_api/sources/aiyifan.js +++ b/danmu_api/sources/aiyifan.js @@ -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; } /** diff --git a/danmu_api/utils/aiyifan-util.js b/danmu_api/utils/aiyifan-util.js new file mode 100644 index 0000000..68e46a2 --- /dev/null +++ b/danmu_api/utils/aiyifan-util.js @@ -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 + }; + } +}