第一个完整功能版
This commit is contained in:
1 parent
979600d5a1
commit
1fc5c5fbb9
3 files changed
+845
-6
No files matched your search
@@ -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,会根据标题进行搜索,等待一段时间,选择剧集就行。
|
||||
<img src="https://i.mji.rip/2025/09/14/1dae193008f23e507d3cc3733a92f0a1.jpeg" style="width:400px" />
|
||||
<img src="https://i.mji.rip/2025/09/14/506fd7810928088d7450be00f67f27e6.png" style="width:400px" />
|
||||
<img src="https://i.mji.rip/2025/09/14/e206ab329c232d8bed225c6a9ff6f506.jpeg" style="width:400px" />
|
||||
<img src="https://i.mji.rip/2025/09/14/80aa5205d49a767447f61938f2dada20.jpeg" style="width:400px" />
|
||||
<img src="https://i.mji.rip/2025/09/14/9fdf945fb247994518042691f60d7849.jpeg" style="width:400px" />
|
||||
<img src="https://i.mji.rip/2025/09/14/dbacc0cf9c8a839f16b8960de1f38f11.jpeg" style="width:400px" />
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
@@ -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`。
|
||||
- 如果想更换vod站点,请添加环境变量`VOD_SERVER`,示例`https://www.caiji.cyou`。
|
||||
- 会返回所有跟title匹配的影片,所以主要使用场景是手动弹幕搜索场景。
|
||||
- 推荐vercel和claw部署,cloudflare好像不稳定,当然最稳定还是自己本地docker部署最佳。
|
||||
|
||||
### 📈项目 Star 数增长趋势
|
||||
#### Star History
|
||||
[](https://www.star-history.com/#huangxd-/danmu_api&Date)
|
||||
+822
-2
@@ -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;i<a.length;i++) out[i]=a[i]^b[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
// 字循环左移
|
||||
function rotWord(word){
|
||||
return Uint8Array.from([word[1],word[2],word[3],word[0]]);
|
||||
}
|
||||
|
||||
// 字节代换
|
||||
function subWord(word){
|
||||
return Uint8Array.from(word.map(b=>SBOX[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<Nk;i++){
|
||||
w[i] = key.slice(4*i,4*i+4);
|
||||
}
|
||||
for(let i=Nk;i<Nb*(Nr+1);i++){
|
||||
let temp = w[i-1];
|
||||
if(i%Nk===0) temp = xor(subWord(rotWord(temp)), Uint8Array.from([RCON[i/Nk],0,0,0]));
|
||||
w[i]=xor(w[i-Nk],temp);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
// AES-128 解密单块 (16 字节)
|
||||
function aesDecryptBlock(input, w) {
|
||||
const Nb=4, Nr=10;
|
||||
let state = new Uint8Array(input);
|
||||
state = addRoundKey(state, w.slice(Nr*Nb,(Nr+1)*Nb));
|
||||
for(let round=Nr-1;round>=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<cipherBytes.length;i+=blockSize){
|
||||
const block = cipherBytes.slice(i,i+blockSize);
|
||||
const decrypted = aesDecryptBlock(block,w);
|
||||
result.set(decrypted,i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ====================== PKCS#7 去填充 ======================
|
||||
function pkcs7Unpad(data){
|
||||
const pad = data[data.length-1];
|
||||
return data.slice(0,data.length-pad);
|
||||
}
|
||||
|
||||
// ====================== Base64 解码 ======================
|
||||
function base64ToBytes(b64) {
|
||||
// 先把 Base64 字符串转换成普通字符
|
||||
const binaryString = (typeof atob === 'function')
|
||||
? atob(b64) // 浏览器环境
|
||||
: BufferBase64Decode(b64); // Node / React Native 自定义
|
||||
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// 自定义 Base64 解码函数
|
||||
function BufferBase64Decode(b64) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
||||
let str = '';
|
||||
let buffer = 0, bits = 0;
|
||||
for (let i = 0; i < b64.length; i++) {
|
||||
const c = b64.charAt(i);
|
||||
if (c === '=') break;
|
||||
const val = chars.indexOf(c);
|
||||
buffer = (buffer << 6) | val;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
str += String.fromCharCode((buffer >> bits) & 0xFF);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// ====================== 主函数 ======================
|
||||
// Uint8Array UTF-8 解码成字符串,替代 TextDecoder
|
||||
function utf8BytesToString(bytes) {
|
||||
let str = "";
|
||||
let i = 0;
|
||||
while (i < bytes.length) {
|
||||
const b1 = bytes[i++];
|
||||
if (b1 < 0x80) {
|
||||
str += String.fromCharCode(b1);
|
||||
} else if (b1 >= 0xc0 && b1 < 0xe0) {
|
||||
const b2 = bytes[i++];
|
||||
str += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f));
|
||||
} else if (b1 >= 0xe0 && b1 < 0xf0) {
|
||||
const b2 = bytes[i++];
|
||||
const b3 = bytes[i++];
|
||||
str += String.fromCharCode(((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f));
|
||||
} else if (b1 >= 0xf0) {
|
||||
// surrogate pair
|
||||
const b2 = bytes[i++];
|
||||
const b3 = bytes[i++];
|
||||
const b4 = bytes[i++];
|
||||
const codepoint = ((b1 & 0x07) << 18) |
|
||||
((b2 & 0x3f) << 12) |
|
||||
((b3 & 0x3f) << 6) |
|
||||
(b4 & 0x3f);
|
||||
const cp = codepoint - 0x10000;
|
||||
str += String.fromCharCode(0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF));
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// 同时替换 TextEncoder
|
||||
function stringToUtf8Bytes(str) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i);
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xc0 | (code >> 6));
|
||||
bytes.push(0x80 | (code & 0x3f));
|
||||
} else if (code < 0xd800 || code >= 0xe000) {
|
||||
bytes.push(0xe0 | (code >> 12));
|
||||
bytes.push(0x80 | ((code >> 6) & 0x3f));
|
||||
bytes.push(0x80 | (code & 0x3f));
|
||||
} else {
|
||||
// surrogate pair
|
||||
i++;
|
||||
const code2 = str.charCodeAt(i);
|
||||
const codePoint = 0x10000 + (((code & 0x3ff) << 10) | (code2 & 0x3ff));
|
||||
bytes.push(0xf0 | (codePoint >> 18));
|
||||
bytes.push(0x80 | ((codePoint >> 12) & 0x3f));
|
||||
bytes.push(0x80 | ((codePoint >> 6) & 0x3f));
|
||||
bytes.push(0x80 | (codePoint & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
// 修改后的 aesDecryptBase64
|
||||
function aesDecryptBase64(cipherB64, keyStr) {
|
||||
try {
|
||||
const cipherBytes = base64ToBytes(cipherB64);
|
||||
const keyBytes = stringToUtf8Bytes(keyStr);
|
||||
const decryptedBytes = aesDecryptECB(cipherBytes, keyBytes);
|
||||
const unpadded = pkcs7Unpad(decryptedBytes);
|
||||
return utf8BytesToString(unpadded);
|
||||
} catch (e) {
|
||||
log("error", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function autoDecode(anything) {
|
||||
const text = typeof anything === "string" ? anything.trim() : JSON.stringify(anything ?? "");
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {}
|
||||
|
||||
const AES_KEY = "3b744389882a4067"; // 直接传字符串
|
||||
const dec = aesDecryptBase64(text, AES_KEY); // aesDecryptBase64 内会 TextEncoder.encode
|
||||
if (dec != null) {
|
||||
try {
|
||||
return JSON.parse(dec);
|
||||
} catch {
|
||||
return dec;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function str2bytes(str) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i);
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xc0 | (code >> 6));
|
||||
bytes.push(0x80 | (code & 0x3f));
|
||||
} else if (code < 0x10000) {
|
||||
bytes.push(0xe0 | (code >> 12));
|
||||
bytes.push(0x80 | ((code >> 6) & 0x3f));
|
||||
bytes.push(0x80 | (code & 0x3f));
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ===================== Base64 编码 =====================
|
||||
function bytesToBase64(bytes) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
let result = '';
|
||||
let i;
|
||||
for (i = 0; i + 2 < bytes.length; i += 3) {
|
||||
result += chars[bytes[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<b.timestamp?a:b);
|
||||
processed.push({...first, content:`${first.content} X${group.length}`});
|
||||
}
|
||||
}
|
||||
|
||||
return processed.map(c=>({
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user