renren.js:将App接口更换至数据更多的TV接口 (#179)
This commit is contained in:
1 parent
2d5a343036
commit
6c4d73f295
1 file changed
+305
-227
+305
-227
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成网页版 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user