diff --git a/danmu_api/sources/renren.js b/danmu_api/sources/renren.js index 588411d..b20e80f 100644 --- a/danmu_api/sources/renren.js +++ b/danmu_api/sources/renren.js @@ -2,7 +2,7 @@ import BaseSource from './base.js'; import { globals } from '../configs/globals.js'; import { log } from "../utils/log-util.js"; import { getPathname, httpGet, sortedQueryString, updateQueryString } from "../utils/http-util.js"; -import { autoDecode, createHmacSha256, generateRandomSid, generateSign, generateXCaSign } from "../utils/codec-util.js"; +import { autoDecode, createHmacSha256, generateSign } from "../utils/codec-util.js"; import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; @@ -11,83 +11,88 @@ import { SegmentListResponse } from '../models/dandan-model.js'; // ===================== // 获取人人视频弹幕 // ===================== + +/** + * 人人视频弹幕源 + * 集成 TV 端 API 协议,保留网页版接口作为降级容灾策略。 + * 兼容处理 SeriesId-EpisodeId 复合主键,确保弹幕与剧集详情的关联正确性。 + */ export default class RenrenSource extends BaseSource { + // API 配置常量 API_CONFIG = { SECRET_KEY: "cf65GPholnICgyw1xbrpA79XVkizOdMq", - SEARCH_HOST: "api.qwdjapp.com", - DRAMA_HOST: "api.zhimeisj.top", - DANMU_HOST: "static-dm.qwdjapp.com", - APP_VERSION: "10.31.2", - USER_AGENT: 'Mozilla/5.0 (Linux; Android 16; 23127PN0CC Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/143.0.7499.146 Mobile Safari/537.36 App/RRSPApp platform/android AppVersion/10.31.2' + + // TV 端接口配置 + TV_HOST: "api.gorafie.com", + TV_DANMU_HOST: "static-dm.qwdjapp.com", + TV_VERSION: "1.2.2", + TV_USER_AGENT: 'okhttp/3.12.13', + TV_CLIENT_TYPE: 'android_qwtv_RRSP', + TV_PKT: 'rrmj', + + // 网页版/旧版接口配置 (降级备用) + WEB_HOST: "api.rrmj.plus", + WEB_DANMU_HOST: "static-dm.rrmj.plus" }; - generateAppCommonHeaders(timestamp, sign, xCaSign = null) { - const headers = { - 'User-Agent': this.API_CONFIG.USER_AGENT, - 'deviceId': 'T2%2Bjh%2FnHhJkWEzPnQT2E0%2FEw865FTT0uL%2BiBwRa2ZdM%3D', - 'aliId': 'aUzmLtnZIYoDAA9KyLdcLQpM', - 'umId': '53e0f078fa8474ae7ba412f766989b54od', - 'clientType': 'android_rrsp_xb_XiaoMi', + /** + * 生成 TV 端接口所需的请求头 + * 处理签名、设备标识及版本控制字段 + * @param {number} timestamp 当前时间戳 + * @param {string} sign 接口签名 + * @returns {Object} HTTP Headers + */ + generateTvHeaders(timestamp, sign) { + return { + 'clientVersion': this.API_CONFIG.TV_VERSION, + 'p': 'Android', + 'deviceid': 'tWEtIN7JG2DTDkBBigvj6A%3D%3D', + 'token': '', // 必须为空字符串以通过校验 + 'aliid': 'aYBd5dAzYrgDAOVWv2eYoPSo', + 'umid': '', // 必须为空字符串以通过校验 + 'clienttype': this.API_CONFIG.TV_CLIENT_TYPE, + 'pkt': this.API_CONFIG.TV_PKT, 't': timestamp.toString(), 'sign': sign, 'isAgree': '1', - 'cv': this.API_CONFIG.APP_VERSION, - 'ct': 'android_rrsp_xb_XiaoMi', - 'pkt': 'rrmj', - 'p': 'Android', - 'wcode': '3', 'et': '2', - 'uet': '1', - 'folding-screen': '1', - 'Accept': 'application/json', 'Accept-Encoding': 'gzip', - 'Connection': 'close' + 'User-Agent': this.API_CONFIG.TV_USER_AGENT, }; - - if (xCaSign) { - headers['x-ca-sign'] = xCaSign; - headers['x-ca-method'] = '1'; - } - - return headers; } - async searchAppContent(keyword, size = 15) { + /** + * 搜索剧集 (TV API) + * @param {string} keyword 搜索关键词 + * @param {number} size 分页大小 + * @returns {Array} 统一格式的搜索结果列表 + */ + async searchAppContent(keyword, size = 30) { try { const timestamp = Date.now(); - const path = "/search/content"; + const path = "/qwtv/search"; const queryParams = { - keywords: keyword, - size: size, - search_after: "", - order: "match", - isAgeLimit: false + searchWord: keyword, + num: size, + searchNext: "", + well: "match" }; const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); - const queryString = Object.entries(queryParams) .map(([k, v]) => `${k}=${encodeURIComponent(v === null || v === undefined ? "" : String(v))}`) .join('&'); + + const headers = this.generateTvHeaders(timestamp, sign); - const xCaSign = generateXCaSign(path, timestamp, queryString, this.API_CONFIG.SECRET_KEY); - - const headers = this.generateAppCommonHeaders(timestamp, sign, xCaSign); - headers['Host'] = this.API_CONFIG.SEARCH_HOST; - headers['Origin'] = 'https://d.rrsp.com.cn'; - headers['Referer'] = 'https://d.rrsp.com.cn/'; - - const resp = await httpGet(`https://${this.API_CONFIG.SEARCH_HOST}${path}?${queryString}`, { + const resp = await httpGet(`https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`, { headers: headers, retries: 1, }); - if (!resp.data) return []; + if (!resp.data || resp.data.code !== "0000") return []; - // 服务端明确提示"版本过低/强制更新"时:直接返回空,让上层走备用搜索 - if (resp?.data?.code === "0001") return []; - - const list = resp?.data?.data?.searchDramaList || []; + const list = resp.data.data || []; return list.map((item) => ({ provider: "renren", mediaId: String(item.id), @@ -96,182 +101,105 @@ export default class RenrenSource extends BaseSource { season: null, year: item.year, imageUrl: item.cover, - episodeCount: item.episodeTotal, + episodeCount: null, // 列表页不返回总集数 currentEpisodeIndex: null, })); } catch (error) { - const msg = String(error?.message || ""); - const is418 = /status:\s*418\b/.test(msg); - - if (is418) { - log("warn", "[Renren] /search/content 被服务端拦截 (418),已降级为备用搜索接口"); - return []; - } - - log("error", "[Renren] searchAppContent error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + log("error", "[Renren] searchAppContent error:", error.message); return []; } } + /** + * 获取剧集详情 (TV API) + * @param {string} dramaId 剧集ID + * @param {string} episodeSid 单集ID (可选) + * @returns {Object} 详情数据对象 + */ async getAppDramaDetail(dramaId, episodeSid = "") { try { - if (!episodeSid) episodeSid = generateRandomSid(); - const timestamp = Date.now(); - const path = "/app/drama/page"; + const path = "/qwtv/drama/details"; const queryParams = { - isAgeLimit: false, - dramaId: dramaId, - episodeSid: episodeSid, - quality: "SD", - subtitle: 3, - hsdrOpen: 1, - hevcOpen: 1, - tria4k: 1 + isAgeLimit: "false", + seriesId: dramaId, + episodeId: episodeSid, + clarity: "HD", + caption: "0", + hevcOpen: "1" }; const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); const queryString = Object.entries(queryParams) .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) .join('&'); + + const headers = this.generateTvHeaders(timestamp, sign); - const headers = this.generateAppCommonHeaders(timestamp, sign); - headers['Host'] = this.API_CONFIG.DRAMA_HOST; - headers['ignore'] = 'false'; - - const resp = await httpGet(`https://${this.API_CONFIG.DRAMA_HOST}${path}?${queryString}`, { + const resp = await httpGet(`https://${this.API_CONFIG.TV_HOST}${path}?${queryString}`, { headers: headers, retries: 1, }); - if (!resp.data) return null; - + if (!resp.data || resp.data.code !== "0000") return null; return resp.data; } catch (error) { - log("error", "[Renren] getAppDramaDetail error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + log("error", "[Renren] getAppDramaDetail error:", error.message); return null; } } - // ========== 弹幕API ========== + /** + * 获取单集弹幕 (TV API) + * 请求 static-dm.qwdjapp.com 获取全量弹幕数据 + * @param {string} episodeSid 单集ID (支持复合ID自动解包) + * @returns {Array} 原始弹幕数据列表 + */ async getAppDanmu(episodeSid) { try { const timestamp = Date.now(); - const path = `/v1/produce/danmu/emo/EPISODE/${episodeSid}`; + + // 处理复合ID (SeriesId-EpisodeId),提取真实的 EpisodeId + let realEpisodeId = episodeSid; + if (String(episodeSid).includes("-")) { + realEpisodeId = String(episodeSid).split("-")[1]; + } - const sign = generateSign(path, timestamp, {}, this.API_CONFIG.SECRET_KEY); - const xCaSign = generateXCaSign(path, timestamp, "", this.API_CONFIG.SECRET_KEY); + // 构造请求路径 (注意:此处使用 EPISODE 路径,不包含 emo) + const path = `/v1/produce/danmu/EPISODE/${realEpisodeId}`; + const queryParams = {}; // 该接口无查询参数 + const sign = generateSign(path, timestamp, queryParams, this.API_CONFIG.SECRET_KEY); + const headers = this.generateTvHeaders(timestamp, sign); - const headers = this.generateAppCommonHeaders(timestamp, sign, xCaSign); - headers['Host'] = this.API_CONFIG.DANMU_HOST; + // 请求旧域名 static-dm.qwdjapp.com + const url = `https://${this.API_CONFIG.TV_DANMU_HOST}${path}`; - const resp = await httpGet(`https://${this.API_CONFIG.DANMU_HOST}${path}`, { + const resp = await httpGet(url, { headers: headers, retries: 1, }); if (!resp.data) return null; + + const data = autoDecode(resp.data); + + // 兼容直接返回数组或包装在 data 字段中的情况 + if (Array.isArray(data)) return data; + if (data && data.data && Array.isArray(data.data)) return data.data; - return resp.data; + return []; } catch (error) { - log("error", "[Renren] getAppDanmu error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + log("error", "[Renren] getAppDanmu error:", error.message); return null; } } - parseRRSPPFields(pField) { - const parts = String(pField).split(","); - const num = (i, cast, dft) => { - try { return cast(parts[i]); } - catch { return dft; } - }; - const timestamp = num(0, parseFloat, 0); - const mode = num(1, x => parseInt(x, 10), 1); - const size = num(2, x => parseInt(x, 10), 25); - const color = num(3, x => parseInt(x, 10), 16777215); - const userId = parts[6] || ""; - const contentId = parts[7] || `${timestamp}:${userId}`; - return { timestamp, mode, size, color, userId, contentId }; - } - - generateSignature(method, aliId, ct, cv, timestamp, path, sortedQuery, secret) { - const signStr = `${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${timestamp}\n${path}?${sortedQuery}`; - return createHmacSha256(secret, signStr); - } - - buildSignedHeaders({ method, url, params = {}, deviceId, token }) { - const ClientProfile = { - client_type: "web_pc", - client_version: "1.0.0", - user_agent: "Mozilla/5.0", - origin: "https://rrsp.com.cn", - referer: "https://rrsp.com.cn/", - }; - const pathname = getPathname(url); - const qs = sortedQueryString(params); - const nowMs = Date.now(); - const SIGN_SECRET = "ES513W0B1CsdUrR13Qk5EgDAKPeeKZY"; - const xCaSign = this.generateSignature( - method, deviceId, ClientProfile.client_type, ClientProfile.client_version, - nowMs, pathname, qs, SIGN_SECRET - ); - return { - clientVersion: ClientProfile.client_version, - deviceId, - clientType: ClientProfile.client_type, - t: String(nowMs), - aliId: deviceId, - umid: deviceId, - token: token || "", - cv: ClientProfile.client_version, - ct: ClientProfile.client_type, - uet: "9", - "x-ca-sign": xCaSign, - Accept: "application/json", - "User-Agent": ClientProfile.user_agent, - Origin: ClientProfile.origin, - Referer: ClientProfile.referer, - }; - } - - async renrenHttpGet(url, { params = {}, headers = {} } = {}) { - const u = updateQueryString(url, params); - const resp = await httpGet(u, { - headers: headers, - retries: 1, - }); - return resp; - } - - generateDeviceId() { - return (Math.random().toString(36).slice(2)).toUpperCase(); - } - - async renrenRequest(method, url, params = {}) { - const deviceId = this.generateDeviceId(); - const headers = this.buildSignedHeaders({ method, url, params, deviceId }); - const resp = await httpGet(url + "?" + sortedQueryString(params), { - headers: headers, - retries: 1, - }); - return resp; - } - + /** + * 执行网页版网络搜索 (降级逻辑) + */ async performNetworkSearch(keyword, { lockRef = null, lastRequestTimeRef = { value: 0 }, minInterval = 500 } = {}) { try { - const url = `https://api.rrmj.plus/m-station/search/drama`; + const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/search/drama`; const params = { keywords: keyword, size: 20, @@ -310,15 +238,15 @@ export default class RenrenSource extends BaseSource { currentEpisodeIndex: null, })); } catch (error) { - log("error", "[Renren] performNetworkSearch error:", { - message: error.message, - name: error.name, - stack: error.stack, - }); + log("error", "[Renren] performNetworkSearch error:", error.message); return []; } } + // ===================== + // 标准接口实现 (BaseSource 抽象方法) + // ===================== + async search(keyword) { const parsedKeyword = { title: keyword, season: null }; const searchTitle = parsedKeyword.title; @@ -326,12 +254,12 @@ export default class RenrenSource extends BaseSource { let allResults = []; - // 优先使用 APP 接口搜索 - // allResults = await this.searchAppContent(searchTitle); + // 1. 优先使用 TV 接口 + allResults = await this.searchAppContent(searchTitle); - // APP 接口失败时降级到网页接口 + // 2. 降级策略: 若 TV 接口无结果,尝试网页接口 if (allResults.length === 0) { - log("info", "[Renren] APP 搜索无结果,降级到网页接口"); + log("info", "[Renren] TV 搜索无结果,降级到网页接口"); const lock = { value: false }; const lastRequestTime = { value: 0 }; allResults = await this.performNetworkSearch(searchTitle, { @@ -347,15 +275,15 @@ export default class RenrenSource extends BaseSource { } async getDetail(id) { - // 优先使用 APP 接口 - // const resp = await this.getAppDramaDetail(String(id)); - // if (resp) { - // return resp.data; - // } + // 1. 优先使用 TV 接口 + const resp = await this.getAppDramaDetail(String(id)); + if (resp && resp.data) { + return resp.data; + } - // // APP 接口失败时降级到网页接口 - // log("info", "[Renren] APP 详情接口失败,降级到网页接口"); - const url = `https://api.rrmj.plus/m-station/drama/page`; + // 2. 降级策略: 尝试网页接口 + log("info", "[Renren] TV 详情接口失败,降级到网页接口"); // [复原日志] + const url = `https://${this.API_CONFIG.WEB_HOST}/m-station/drama/page`; const params = { hsdrOpen: 0, isAgeLimit: 0, dramaId: String(id), hevcOpen: 1 }; const fallbackResp = await this.renrenRequest("GET", url, params); if (!fallbackResp.data) return null; @@ -368,11 +296,19 @@ export default class RenrenSource extends BaseSource { if (!detail || !detail.episodeList) return []; let episodes = []; + const seriesId = String(id); + detail.episodeList.forEach((ep, idx) => { - const sid = String(ep.sid || "").trim(); - if (!sid) return; - const title = String(ep.title || `第${String(idx + 1).padStart(2, "0")}集`); - episodes.push({ sid, order: idx + 1, title }); + const epSid = String(ep.sid || "").trim(); + if (!epSid) return; + + const showTitle = ep.title ? String(ep.title) : `第${String(ep.episodeNo || idx + 1).padStart(2, "0")}集`; + + // 构建复合ID (SeriesId-EpisodeId) + // TV弹幕接口需要EpisodeId,搜索可能需要SeriesId,保留此结构确保上下文完整 + const compositeId = `${seriesId}-${epSid}`; + + episodes.push({ sid: compositeId, order: ep.episodeNo || idx + 1, title: showTitle }); }); return episodes.map(e => ({ @@ -440,20 +376,44 @@ export default class RenrenSource extends BaseSource { } async getEpisodeDanmu(id) { - // 优先使用 APP 接口 - // const resp = await this.getAppDanmu(id); - // if (resp) { - // return resp; - // } + // 1. 优先尝试 TV 接口 + let danmuList = await this.getAppDanmu(id); + + // 2. 降级策略: TV 接口无数据时,尝试网页版接口 + if (!danmuList || danmuList.length === 0) { + log("info", "[Renren] TV 弹幕接口失败或无数据,尝试降级网页接口"); + danmuList = await this.getWebDanmuFallback(id); + } + + // 3. 返回原始数据列表,BaseSource 会自动调用本类的 formatComments 进行格式化 + if (danmuList && Array.isArray(danmuList) && danmuList.length > 0) { + log("info", `[Renren] 成功获取 ${danmuList.length} 条弹幕`); + return danmuList; + } + + return []; + } + + /** + * 获取网页版弹幕 (降级方法) + * 自动处理复合 ID 的解包 + */ + async getWebDanmuFallback(id) { + let realEpisodeId = id; + if (String(id).includes("-")) { + realEpisodeId = String(id).split("-")[1]; + } + + // 日志保留 + log("info", `[Renren] 降级网页版弹幕,使用 ID: ${realEpisodeId}`); - // // APP 接口失败时降级到网页接口 - // log("info", "[Renren] APP 弹幕接口失败,降级到网页接口"); const ClientProfile = { user_agent: "Mozilla/5.0", origin: "https://rrsp.com.cn", referer: "https://rrsp.com.cn/", }; - const url = `https://static-dm.rrmj.plus/v1/produce/danmu/EPISODE/${id}`; + + const url = `https://${this.API_CONFIG.WEB_DANMU_HOST}/v1/produce/danmu/EPISODE/${realEpisodeId}`; const headers = { "Accept": "application/json", "User-Agent": ClientProfile.user_agent, @@ -461,18 +421,23 @@ export default class RenrenSource extends BaseSource { "Referer": ClientProfile.referer, }; - const fallbackResp = await this.renrenHttpGet(url, { headers }); - if (!fallbackResp.data) return null; - - const data = autoDecode(fallbackResp.data); - if (Array.isArray(data)) return data; - if (data?.data && Array.isArray(data.data)) return data.data; - return null; + try { + const fallbackResp = await this.renrenHttpGet(url, { headers }); + if (!fallbackResp.data) return []; + + const data = autoDecode(fallbackResp.data); + let list = []; + if (Array.isArray(data)) list = data; + else if (data?.data && Array.isArray(data.data)) list = data.data; + + return list; + } catch (e) { + log("error", `[Renren] 网页版弹幕降级失败: ${e.message}`); + return []; + } } async getEpisodeDanmuSegments(id) { - log("info", "[Renren] 获取弹幕分段列表:", id); - return new SegmentListResponse({ "type": "renren", "segmentList": [{ @@ -488,16 +453,129 @@ export default class RenrenSource extends BaseSource { return this.getEpisodeDanmu(segment.url); } + // ===================== + // 数据解析与签名工具 + // ===================== + + /** + * 解析 RRSP 的 P 字段 (属性字符串) + * 格式: timestamp,mode,size,color,uid,cid... + * 使用安全数值转换,防止 NaN 污染导致数据被误去重 + */ + parseRRSPPFields(pField) { + const parts = String(pField).split(","); + + // 安全数值转换工具:若解析结果为 NaN,则返回默认值 + const safeNum = (val, parser, defaultVal) => { + if (val === undefined || val === null || val === "") return defaultVal; + const res = parser(val); + return isNaN(res) ? defaultVal : res; + }; + + const timestamp = safeNum(parts[0], parseFloat, 0); + const mode = safeNum(parts[1], x => parseInt(x, 10), 1); + const size = safeNum(parts[2], x => parseInt(x, 10), 25); + const color = safeNum(parts[3], x => parseInt(x, 10), 16777215); + + const userId = parts[6] || ""; + const contentId = parts[7] || `${timestamp}:${userId}`; + + return { timestamp, mode, size, color, userId, contentId }; + } + + /** + * 格式化弹幕列表为标准模型 + * 将原始 d/p 字段映射为系统内部对象 + * 兼容处理 item.d 和 item.content 内容字段 + */ formatComments(comments) { return comments.map(item => { - const text = String(item.d || ""); - const meta = this.parseRRSPPFields(item.p); - return { - cid: Number(meta.contentId), - p: `${meta.timestamp.toFixed(2)},${meta.mode},${meta.color},[renren]`, - m: text, - t: meta.timestamp - }; - }); + // 提取内容 (优先 d,兼容 content) + let text = String(item.d || ""); + if (!text && item.content) text = String(item.content); + + if (!text) return null; + + // 提取属性 (p) + if (item.p) { + const meta = this.parseRRSPPFields(item.p); + return { + cid: Number(meta.contentId) || 0, + p: `${meta.timestamp.toFixed(2)},${meta.mode},${meta.color},[renren]`, + m: text, + t: meta.timestamp + }; + } + return null; + }).filter(Boolean); } -} \ No newline at end of file + + /** + * 生成网页版 API 签名 + */ + generateSignature(method, aliId, ct, cv, timestamp, path, sortedQuery, secret) { + const signStr = `${method.toUpperCase()}\naliId:${aliId}\nct:${ct}\ncv:${cv}\nt:${timestamp}\n${path}?${sortedQuery}`; + return createHmacSha256(secret, signStr); + } + + /** + * 构建网页版带签名的请求头 + */ + buildSignedHeaders({ method, url, params = {}, deviceId, token }) { + const ClientProfile = { + client_type: "web_pc", + client_version: "1.0.0", + user_agent: "Mozilla/5.0", + origin: "https://rrsp.com.cn", + referer: "https://rrsp.com.cn/", + }; + const pathname = getPathname(url); + const qs = sortedQueryString(params); + const nowMs = Date.now(); + const SIGN_SECRET = "ES513W0B1CsdUrR13Qk5EgDAKPeeKZY"; + const xCaSign = this.generateSignature( + method, deviceId, ClientProfile.client_type, ClientProfile.client_version, + nowMs, pathname, qs, SIGN_SECRET + ); + return { + clientVersion: ClientProfile.client_version, + deviceId, + clientType: ClientProfile.client_type, + t: String(nowMs), + aliId: deviceId, + umid: deviceId, + token: token || "", + cv: ClientProfile.client_version, + ct: ClientProfile.client_type, + uet: "9", + "x-ca-sign": xCaSign, + Accept: "application/json", + "User-Agent": ClientProfile.user_agent, + Origin: ClientProfile.origin, + Referer: ClientProfile.referer, + }; + } + + async renrenHttpGet(url, { params = {}, headers = {} } = {}) { + const u = updateQueryString(url, params); + const resp = await httpGet(u, { + headers: headers, + retries: 1, + }); + return resp; + } + + generateDeviceId() { + return (Math.random().toString(36).slice(2)).toUpperCase(); + } + + async renrenRequest(method, url, params = {}) { + const deviceId = this.generateDeviceId(); + const headers = this.buildSignedHeaders({ method, url, params, deviceId }); + const resp = await httpGet(url + "?" + sortedQueryString(params), { + headers: headers, + retries: 1, + }); + return resp; + } +}