From 1fc5c5fbb97698e67ac325dbf6a262f16ddd37d6 Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Sun, 14 Sep 2025 20:03:24 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=B8=80=E4=B8=AA=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +- danmu_api/worker.js | 824 ++++++++++++++++++++++++++++++++++++++- danmu_api/worker.test.js | 4 +- 3 files changed, 845 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9401851..9f0c795 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,20 @@ 4. 保存并部署。 ### 手动部署 -创建一个worker,并将danmu_api/worker.js里的代码直接拷贝到你创建的worker.js里,并点击部署。 +创建一个worker,将`danmu_api/worker.js`里的代码直接拷贝到你创建的`worker.js`里,然后点击部署。 + +## API食用指南 +支持 forward/senplayer 等支持弹幕API的播放器。 +以`senplayer`为例: +1. 获取到部署之后的API地址,如`http://192.168.1.7:9321/87654321`,其中`87654321`是默认token,前提是没有传TOKEN环境变量 +2. 将API地址填入自定义弹幕API,在`设置 - 弹幕设置 - 自定义弹幕API` +3. 播放界面点击`弹幕按钮 - 搜索弹幕`,选择你的弹幕API,会根据标题进行搜索,等待一段时间,选择剧集就行。 + + + + + + ## 项目结构 ``` @@ -138,4 +151,10 @@ danmu_api/ - 运行 Docker 容器时,需通过 `-e TOKEN=your_token_here` 传递 `TOKEN` 环境变量。 - cloudflare貌似被哔风控了。 - 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。 -- 如果想更换vod站点,请添加环境变量`VOD_SERVER`,示例`https://www.caiji.cyou`。 \ No newline at end of file +- 如果想更换vod站点,请添加环境变量`VOD_SERVER`,示例`https://www.caiji.cyou`。 +- 会返回所有跟title匹配的影片,所以主要使用场景是手动弹幕搜索场景。 +- 推荐vercel和claw部署,cloudflare好像不稳定,当然最稳定还是自己本地docker部署最佳。 + +### 📈项目 Star 数增长趋势 +#### Star History +[![Star History Chart](https://api.star-history.com/svg?repos=huangxd-/danmu_api&type=Date)](https://www.star-history.com/#huangxd-/danmu_api&Date) \ No newline at end of file diff --git a/danmu_api/worker.js b/danmu_api/worker.js index aebb69e..20dcb3d 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -1,6 +1,6 @@ // 全局状态(Cloudflare 和 Vercel 都可能重用实例) // ⚠️ 不是持久化存储,每次冷启动会丢失 -const VERSION = "0.0.3"; +const VERSION = "1.0.0"; let animes = []; let episodeIds = []; let episodeNum = 10001; // 全局变量,用于自增 ID @@ -375,7 +375,7 @@ async function getVodAnimes(title) { if (response && response.data && response.data.list && response.data.list.length > 0) { log("log", `请求 ${vodServer} 成功`); const data = response.data; - console.log("vod response: ↓↓↓"); + log("log", "vod response: ↓↓↓"); printFirst200Chars(data); return data.list; } else { @@ -1459,6 +1459,781 @@ async function fetchOtherServer(inputUrl) { } } +// ===================== +// 人人视频 配置 & 工具 +// ===================== +// --------------------- +// 通用工具 +// --------------------- +function sortedQueryString(params = {}) { + const normalized = {}; + for (const [k, v] of Object.entries(params)) { + if (typeof v === "boolean") normalized[k] = v ? "true" : "false"; + else if (v == null) normalized[k] = ""; + else normalized[k] = String(v); + } + + // 获取对象的所有键并排序 + const keys = []; + for (const key in normalized) { + if (Object.prototype.hasOwnProperty.call(normalized, key)) { + keys.push(key); + } + } + keys.sort(); + + // 构建键值对数组 + const pairs = []; + for (const key of keys) { + // 对键和值进行 URL 编码 + const encodedKey = encodeURIComponent(key); + const encodedValue = encodeURIComponent(normalized[key]); + pairs.push(`${encodedKey}=${encodedValue}`); + } + + // 用 & 连接所有键值对 + return pairs.join('&'); +} + +function updateQueryString(url, params) { + // 解析 URL + let baseUrl = url; + let queryString = ''; + const hashIndex = url.indexOf('#'); + let hash = ''; + if (hashIndex !== -1) { + baseUrl = url.substring(0, hashIndex); + hash = url.substring(hashIndex); + } + const queryIndex = baseUrl.indexOf('?'); + if (queryIndex !== -1) { + queryString = baseUrl.substring(queryIndex + 1); + baseUrl = baseUrl.substring(0, queryIndex); + } + + // 解析现有查询字符串为对象 + const queryParams = {}; + if (queryString) { + const pairs = queryString.split('&'); + for (const pair of pairs) { + if (pair) { + const [key, value = ''] = pair.split('=').map(decodeURIComponent); + queryParams[key] = value; + } + } + } + + // 更新参数 + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + queryParams[key] = params[key]; + } + } + + // 构建新的查询字符串 + const newQuery = []; + for (const key in queryParams) { + if (Object.prototype.hasOwnProperty.call(queryParams, key)) { + newQuery.push( + `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}` + ); + } + } + + // 拼接最终 URL + return baseUrl + (newQuery.length ? '?' + newQuery.join('&') : '') + hash; +} + +function getPathname(url) { + // 查找路径的起始位置(跳过协议和主机部分) + let pathnameStart = url.indexOf('//') + 2; + if (pathnameStart === 1) pathnameStart = 0; // 如果没有协议部分 + const pathStart = url.indexOf('/', pathnameStart); + if (pathStart === -1) return '/'; // 如果没有路径,返回默认根路径 + const queryStart = url.indexOf('?', pathStart); + const hashStart = url.indexOf('#', pathStart); + // 确定路径的结束位置(查询字符串或片段之前) + let pathEnd = queryStart !== -1 ? queryStart : (hashStart !== -1 ? hashStart : url.length); + const pathname = url.substring(pathStart, pathEnd); + return pathname || '/'; +} + +function 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); +} + +function 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 = 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, + }; +} + +// ====================== AES-128-ECB 完整实现 ====================== + +// S盒 +const SBOX = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 +]; + +// 轮常量 +const RCON = [ + 0x00,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36 +]; + +// 字节异或 +function xor(a,b) { + const out = new Uint8Array(a.length); + for(let i=0;iSBOX[b])); +} + +// 扩展密钥 16 字节 -> 176 字节 +function keyExpansion(key) { + const Nk = 4, Nb=4, Nr=10; + const w = new Array(Nb*(Nr+1)); + for(let i=0;i=1;round--){ + state = invShiftRows(state); + state = invSubBytes(state); + state = addRoundKey(state, w.slice(round*Nb,(round+1)*Nb)); + state = invMixColumns(state); + } + state = invShiftRows(state); + state = invSubBytes(state); + state = addRoundKey(state, w.slice(0,Nb)); + return state; +} + +// AES 辅助函数 +function addRoundKey(state, w){ + const out = new Uint8Array(16); + for(let c=0;c<4;c++) + for(let r=0;r<4;r++) + out[r+4*c]=state[r+4*c]^w[c][r]; + return out; +} + +function invSubBytes(state){ + const INV_SBOX = new Array(256); + for(let i=0;i<256;i++) INV_SBOX[SBOX[i]]=i; + return Uint8Array.from(state.map(b=>INV_SBOX[b])); +} + +function invShiftRows(state){ + const out = new Uint8Array(16); + for(let r=0;r<4;r++) + for(let c=0;c<4;c++) + out[r+4*c]=state[r+4*((c-r+4)%4)]; + return out; +} + +function invMixColumns(state){ + function mul(a,b){ + let p=0; + for(let i=0;i<8;i++){ + if(b&1) p^=a; + let hi=(a&0x80); + a=(a<<1)&0xFF; + if(hi) a^=0x1b; + b>>=1; + } + return p; + } + const out = new Uint8Array(16); + for(let c=0;c<4;c++){ + const col = state.slice(4*c,4*c+4); + out[4*c+0]=mul(col[0],0x0e)^mul(col[1],0x0b)^mul(col[2],0x0d)^mul(col[3],0x09); + out[4*c+1]=mul(col[0],0x09)^mul(col[1],0x0e)^mul(col[2],0x0b)^mul(col[3],0x0d); + out[4*c+2]=mul(col[0],0x0d)^mul(col[1],0x09)^mul(col[2],0x0e)^mul(col[3],0x0b); + out[4*c+3]=mul(col[0],0x0b)^mul(col[1],0x0d)^mul(col[2],0x09)^mul(col[3],0x0e); + } + return out; +} + +// ====================== ECB 模式解密 ====================== +function aesDecryptECB(cipherBytes, keyBytes){ + const w = keyExpansion(keyBytes); + const blockSize = 16; + const result = new Uint8Array(cipherBytes.length); + for(let i=0;i> 2]; + result += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + result += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + result += chars[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + result += chars[bytes[i] >> 2]; + if (i + 1 < bytes.length) { + result += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + result += chars[(bytes[i + 1] & 15) << 2]; + result += '='; + } else { + result += chars[(bytes[i] & 3) << 4]; + result += '=='; + } + } + return result; +} + +// ===================== SHA256 算法 ===================== +// 纯 JS SHA256,返回字节数组 +function sha256(ascii) { + function rightRotate(n, x) { return (x >>> n) | (x << (32 - n)); } + + let maxWord = Math.pow(2, 32); + let words = [], asciiBitLength = ascii.length * 8; + + for (let i = 0; i < ascii.length; i++) { + words[i >> 2] |= ascii.charCodeAt(i) << ((3 - i) % 4 * 8); + } + + words[ascii.length >> 2] |= 0x80 << ((3 - ascii.length % 4) * 8); + words[((ascii.length + 8) >> 6) * 16 + 15] = asciiBitLength; + + let w = new Array(64), hash = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + ]; + + const k = [ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 + ]; + + for (let j = 0; j < words.length; j += 16) { + let a = hash[0], b = hash[1], c = hash[2], d = hash[3], + e = hash[4], f = hash[5], g = hash[6], h = hash[7]; + + for (let i = 0; i < 64; i++) { + if (i < 16) w[i] = words[j + i] | 0; + else { + const s0 = rightRotate(7, w[i-15]) ^ rightRotate(18, w[i-15]) ^ (w[i-15]>>>3); + const s1 = rightRotate(17, w[i-2]) ^ rightRotate(19, w[i-2]) ^ (w[i-2]>>>10); + w[i] = (w[i-16] + s0 + w[i-7] + s1) | 0; + } + const S1 = rightRotate(6, e) ^ rightRotate(11, e) ^ rightRotate(25, e); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + S1 + ch + k[i] + w[i]) | 0; + const S0 = rightRotate(2, a) ^ rightRotate(13, a) ^ rightRotate(22, a); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) | 0; + + h = g; g = f; f = e; e = (d + temp1) | 0; + d = c; c = b; b = a; a = (temp1 + temp2) | 0; + } + + hash[0] = (hash[0] + a) | 0; + hash[1] = (hash[1] + b) | 0; + hash[2] = (hash[2] + c) | 0; + hash[3] = (hash[3] + d) | 0; + hash[4] = (hash[4] + e) | 0; + hash[5] = (hash[5] + f) | 0; + hash[6] = (hash[6] + g) | 0; + hash[7] = (hash[7] + h) | 0; + } + + // 转为字节数组 + const bytes = []; + for (let h of hash) { + bytes.push((h >> 24) & 0xFF); + bytes.push((h >> 16) & 0xFF); + bytes.push((h >> 8) & 0xFF); + bytes.push(h & 0xFF); + } + return bytes; +} + +// ===================== HMAC-SHA256 ===================== +function createHmacSha256(key, message) { + const blockSize = 64; // 512 bit + let keyBytes = str2bytes(key); + if (keyBytes.length > blockSize) keyBytes = sha256(key); + if (keyBytes.length < blockSize) keyBytes = keyBytes.concat(Array(blockSize - keyBytes.length).fill(0)); + + const oKeyPad = keyBytes.map(b => b ^ 0x5c); + const iKeyPad = keyBytes.map(b => b ^ 0x36); + + const innerHash = sha256(String.fromCharCode(...iKeyPad) + message); + const hmacBytes = sha256(String.fromCharCode(...oKeyPad) + String.fromCharCode(...innerHash)); + + return bytesToBase64(hmacBytes); +} + +async function renrenHttpGet(url, { params = {}, headers = {} } = {}) { + const u = updateQueryString(url, params) + const resp = await httpGet(u, { + headers: headers, + }); + return resp; +} + +function generateDeviceId() { + return (Math.random().toString(36).slice(2)).toUpperCase(); +} + +async function renrenRequest(method, url, params = {}) { + const deviceId = generateDeviceId(); + const headers = buildSignedHeaders({ method, url, params, deviceId }); + const resp = await httpGet(url + "?" + sortedQueryString(params), { + headers: headers, + }); + return resp; +} + +// --------------------- +// 人人视频搜索 +// --------------------- +async function renrenSearch(keyword, episodeInfo = null) { + const parsedKeyword = { title: keyword, season: null }; // 简化 parse_search_keyword + const searchTitle = parsedKeyword.title; + const searchSeason = parsedKeyword.season; + + const lock = { value: false }; + const lastRequestTime = { value: 0 }; + let allResults = await performNetworkSearch(searchTitle, episodeInfo, { lockRef: lock, lastRequestTimeRef: lastRequestTime, minInterval: 400 }); + + if (searchSeason == null) return allResults; + + // 按 season 过滤 + return allResults.filter(r => r.season === searchSeason); +} + +async function performNetworkSearch( + keyword, + episodeInfo = null, + { + lockRef = null, + lastRequestTimeRef = { value: 0 }, // 调用方传引用 + minInterval = 500 // 默认节流间隔(毫秒) + } = {} +) { + const url = `https://api.rrmj.plus/m-station/search/drama`; + const params = { keywords: keyword, size: 20, order: "match", search_after: "", isExecuteVipActivity: true }; + + // 🔒 锁逻辑(可选) + if (lockRef) { + while (lockRef.value) await new Promise(r => setTimeout(r, 50)); + lockRef.value = true; + } + + // ⏱️ 节流逻辑(依赖 lastRequestTimeRef) + const now = Date.now(); + const dt = now - lastRequestTimeRef.value; + if (dt < minInterval) await new Promise(r => setTimeout(r, minInterval - dt)); + + const resp = await renrenRequest("GET", url, params); + lastRequestTimeRef.value = Date.now(); // 更新引用 + + if (lockRef) lockRef.value = false; + + if (!resp.data) return []; + + const decoded = autoDecode(resp.data); + const list = decoded?.data?.searchDramaList || []; + return list.map((item, idx) => ({ + provider: "renren", + mediaId: String(item.id), + title: String(item.title || "").replace(/<[^>]+>/g, "").replace(/:/g, ":"), + type: "tv_series", + season: null, + year: item.year, + imageUrl: item.cover, + episodeCount: item.episodeTotal, + currentEpisodeIndex: episodeInfo?.episode ?? null, + })); +} + +// --------------------- +// 人人视频URL信息提取 +// --------------------- +async function getInfoFromUrl(url) { + const m = String(url).match(/\/v\/(\d+)/); + if (!m) return null; + const dramaId = m[1]; + const detail = await fetchDramaDetail(dramaId); + if (!detail) return null; + const titleClean = String(detail.dramaInfo.title).replace(/<[^>]+>/g,"").replace(/:/g,":"); + const searchResults = await search(titleClean); + const bestMatch = searchResults.find(r=>r.mediaId===dramaId); + if (bestMatch && !bestMatch.episodeCount) bestMatch.episodeCount = (detail.episodeList?.length || 0); + if (bestMatch) return bestMatch; + return { + provider: "renren", + mediaId: dramaId, + title: titleClean, + type: "tv_series", + season: null, + episodeCount: (detail.episodeList?.length || null) + }; +} + +function getIdFromUrl(url) { + const m = String(url).match(/\/v\/\d+\/(\d+)/); + return m ? m[1] : null; +} + +function formatEpisodeIdForComments(providerEpisodeId) { + return String(providerEpisodeId); +} + +async function fetchDramaDetail(dramaId) { + const url = `https://api.rrmj.plus/m-station/drama/page`; + const params = { hsdrOpen:0,isAgeLimit:0,dramaId:String(dramaId),hevcOpen:1 }; + const resp = await renrenRequest("GET", url, params); + if (!resp.data) return null; + const decoded = autoDecode(resp.data); + return decoded?.data || null; +} + +async function _episodeCountFromSid(dramaId) { + const detail = await fetchDramaDetail(dramaId); + if (!detail || !detail.episodeList) return null; + return detail.episodeList.filter(ep => String(ep.sid).trim()).length; +} + +async function getEpisodes(mediaId, targetEpisodeIndex=null, dbMediaType=null) { + const detail = await fetchDramaDetail(mediaId); + if (!detail || !detail.episodeList) return []; + + let episodes = []; + detail.episodeList.forEach((ep, idx)=>{ + const sid = String(ep.sid || "").trim(); + if(!sid) return; + const title = String(ep.title || `第${idx+1}`.padStart(2,"0")+"集"); + episodes.push({ sid, order: idx+1, title }); + }); + + if(targetEpisodeIndex) episodes = episodes.filter(e=>e.order===targetEpisodeIndex); + + return episodes.map(e=>({ + provider: "renren", + episodeId: e.sid, + title: e.title, + episodeIndex: e.order, + url: null + })); +} + +// --------------------- +// 人人视频弹幕 +// --------------------- +async function fetchEpisodeDanmu(sid) { + 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/${sid}`; + const headers = { + "Accept": "application/json", + "User-Agent": ClientProfile.user_agent, + "Origin": ClientProfile.origin, + "Referer": ClientProfile.referer, + }; + const resp = await renrenHttpGet(url, { headers }); + if (!resp.data) return null; + const data = autoDecode(resp.data); + if (Array.isArray(data)) return data; + if (data?.data && Array.isArray(data.data)) return data.data; + return null; +} + +function 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 }; +} + +function formatComments(items) { + const unique = {}; + for(const it of items){ + const text = String(it.d||""); + const meta = parseRRSPPFields(it.p); + if(!unique[meta.contentId]) unique[meta.contentId] = { content: text, ...meta }; + } + + const grouped = {}; + for(const c of Object.values(unique)){ + if(!grouped[c.content]) grouped[c.content] = []; + grouped[c.content].push(c); + } + + const processed = []; + for(const [content, group] of Object.entries(grouped)){ + if(group.length===1) processed.push(group[0]); + else{ + const first = group.reduce((a,b)=>a.timestamp({ + cid: Number(c.contentId), + p: `${c.timestamp.toFixed(2)},${c.mode},${c.color},[renren]`, + m: c.content, + t: c.timestamp + })); +} + +async function getRenRenComments(episodeId, progressCallback=null){ + if(progressCallback) await progressCallback(5,"开始获取弹幕人人弹幕"); + log("log", "开始获取弹幕人人弹幕"); + const raw = await fetchEpisodeDanmu(episodeId); + if(progressCallback) await progressCallback(85,`原始弹幕 ${raw.length} 条,正在规范化`); + log("log", `原始弹幕 ${raw.length} 条,正在规范化`); + const formatted = formatComments(raw); + if(progressCallback) await progressCallback(100,`弹幕处理完成,共 ${formatted.length} 条`); + log("log", `弹幕处理完成,共 ${formatted.length} 条`); + // 输出前五条弹幕 + log("log", "Top 5 danmus:", JSON.stringify(formatted.slice(0, 5), null, 2)); + return formatted; +} + // ===================== // 路由请求相关 // ===================== @@ -1619,6 +2394,45 @@ async function searchAnime(url) { } } + // 查询renren + const results = await renrenSearch(queryTitle); + log("log", "人人视频搜索结果:", results); + + let animesRenren = results.filter(s => s.title.includes(queryTitle)); + for (const anime of animesRenren) { + const eps = await getEpisodes(anime.mediaId); + printFirst200Chars(eps); + + let links = []; + for (const ep of eps) { + links.push({"name": ep.episodeIndex, "url": ep.episodeId, "title": `【${ep.provider}】${anime.title}(${anime.year}) ${ep.title}`}); + } + + let transformedAnime = { + animeId: Number(anime.mediaId), // Mapping animeId to id + bangumiId: String(anime.mediaId), // Mapping bangumiId to id + animeTitle: `${anime.title}(${anime.year})【${anime.type}】from renren`, // Mapping animeTitle to titleTxt + type: anime.type, // Mapping type to cat_name + typeDescription: anime.type, // Mapping typeDescription to cat_name + imageUrl: anime.imageUrl, // Mapping imageUrl to cover + startDate: `${anime.year}-01-01T00:00:00`, // Start date to the year field in ISO format + episodeCount: links.length, // Mapping episodeCount to length of seriesPlaylinks + rating: 0, // Default rating as 0 + isFavorited: true, // Assuming all anime are favorited by default + }; + + curAnimes.push(transformedAnime); + // Check if the anime already exists in the animes array + const exists = animes.some(existingAnime => existingAnime.animeId === transformedAnime.animeId); + if (!exists) { + const transformedAnimeCopy = { ...transformedAnime, links: links }; + addAnime(transformedAnimeCopy); + } + if (animes.length > MAX_ANIMES) { + removeEarliestAnime(); + } + } + return jsonResponse({ errorCode: 0, success: true, @@ -1715,6 +2529,12 @@ async function getComment(path) { danmus = await fetchYouku(url); } + // 请求人人弹幕 + const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; + if (!urlPattern.test(url)) { + danmus = await getRenRenComments(url); + } + // 如果弹幕为空,则请求第三方弹幕服务器作为兜底 if (danmus.length === 0) { danmus = await fetchOtherServer(url); diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index cc585fd..04199dc 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -77,8 +77,8 @@ test('worker.js API endpoints', async (t) => { // const keyword = "国王排名"; // youku // const keyword = "黑白局"; - - const keyword = "地球超新鲜"; + // renren + const keyword = "瑞克和莫蒂"; const searchUrl = new URL(`${urlPrefix}/${token}/api/v2/search/anime?keyword=${keyword}`); const searchRes = await searchAnime(searchUrl);