459 lines
15 KiB
JavaScript
459 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// ============================================================================
|
|
// Spider API - Type 4 风格代理
|
|
// ============================================================================
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { URL } = require('url');
|
|
const os = require('os');
|
|
|
|
// ============================================================================
|
|
// 配置常量
|
|
// ============================================================================
|
|
const SPIDER_API_HOST = 'http://192.168.10.36:9978';
|
|
const DEFAULT_SITE_KEY = '';
|
|
const REQUEST_TIMEOUT = 30000; // 毫秒
|
|
|
|
// ============================================================================
|
|
// Spider API 客户端
|
|
// ============================================================================
|
|
class SpiderApiClient {
|
|
constructor(host, timeout = 30000) {
|
|
this.host = host.replace(/\/$/, '');
|
|
this.timeout = timeout;
|
|
}
|
|
|
|
configAction(action, extra = {}) {
|
|
return this.post('/spider/config', { action, ...extra });
|
|
}
|
|
|
|
execute(key, method, params = {}) {
|
|
return this.post('/spider', { key, method, ...params });
|
|
}
|
|
|
|
homeContent(key, filter = true) {
|
|
return this.execute(key, 'homeContent', { filter });
|
|
}
|
|
|
|
categoryContent(key, tid, page = 1, filter = true, extend = {}) {
|
|
return this.execute(key, 'categoryContent', {
|
|
tid, pg: page, filter, extend
|
|
});
|
|
}
|
|
|
|
detailContent(key, ids) {
|
|
return this.execute(key, 'detailContent', {
|
|
ids: Array.isArray(ids) ? ids : [ids]
|
|
});
|
|
}
|
|
|
|
searchContent(key, keyword, page = 1) {
|
|
return this.execute(key, 'searchContent', { wd: keyword, pg: page });
|
|
}
|
|
|
|
playerContent(key, flag, id, flags = []) {
|
|
return this.execute(key, 'playerContent', { flag, id, flags });
|
|
}
|
|
|
|
post(endpoint, data) {
|
|
return new Promise((resolve, reject) => {
|
|
const targetUrl = this.host + endpoint;
|
|
const parsedUrl = new URL(targetUrl);
|
|
const isHttps = parsedUrl.protocol === 'https:';
|
|
const client = isHttps ? https : http;
|
|
|
|
const postData = JSON.stringify(data);
|
|
const options = {
|
|
hostname: parsedUrl.hostname,
|
|
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
path: parsedUrl.pathname + parsedUrl.search,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(postData)
|
|
},
|
|
timeout: this.timeout
|
|
};
|
|
|
|
const req = client.request(options, (res) => {
|
|
let body = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => body += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch (e) {
|
|
resolve({ code: -1, msg: 'Invalid JSON response' });
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', (e) => {
|
|
resolve({ code: -1, msg: `Request failed: ${e.message}` });
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
resolve({ code: -1, msg: 'Request timeout' });
|
|
});
|
|
|
|
req.write(postData);
|
|
req.end();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Type 4 代理
|
|
// ============================================================================
|
|
class SpiderApiProxy {
|
|
constructor(client, defaultKey = '') {
|
|
this.client = client;
|
|
this.defaultKey = defaultKey;
|
|
}
|
|
|
|
async handle(req, res) {
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.statusCode = 204;
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await this.dispatch(req);
|
|
res.end(JSON.stringify(result, null, 2));
|
|
} catch (e) {
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
}
|
|
|
|
async dispatch(req) {
|
|
const input = await this.getInput(req);
|
|
|
|
// 从 PATH_INFO 或 query 参数获取 key
|
|
let key = this.getKeyFromPath(req);
|
|
if (!key) {
|
|
key = input.key || this.defaultKey;
|
|
}
|
|
|
|
// 兼容处理:play 参数作为 id 的别名
|
|
if (input.play && !input.id) {
|
|
input.id = input.play;
|
|
}
|
|
|
|
// 根据参数特征判断接口类型
|
|
let ac = '';
|
|
|
|
if (input.wd) {
|
|
// 搜索接口
|
|
ac = 'search';
|
|
} else if (input.t) {
|
|
// 有 t 参数就是分类列表
|
|
ac = 'list';
|
|
} else if (input.ids) {
|
|
// 有 ids 参数就是详情
|
|
ac = 'detail';
|
|
} else if (input.flag && input.id) {
|
|
// 有 flag 和 id 就是播放
|
|
ac = 'play';
|
|
} else if (!key || key === 'config' || input.ac === 'config') {
|
|
// 配置接口
|
|
ac = 'config';
|
|
} else {
|
|
// 默认是首页/列表
|
|
ac = 'list';
|
|
}
|
|
|
|
// 处理配置接口
|
|
if (ac === 'config') {
|
|
return await this.getProxiedConfig(req);
|
|
}
|
|
|
|
// 验证 key
|
|
if (!key) {
|
|
return { error: '缺少 key 参数' };
|
|
}
|
|
|
|
// 调用 Spider API
|
|
let apiResult = null;
|
|
const pg = parseInt(input.pg) || 1;
|
|
|
|
// 路由分发
|
|
switch (ac) {
|
|
case 'search':
|
|
apiResult = await this.client.searchContent(key, input.wd, pg);
|
|
break;
|
|
|
|
case 'detail':
|
|
const ids = Array.isArray(input.ids) ? input.ids : input.ids.split(',');
|
|
apiResult = await this.client.detailContent(key, ids);
|
|
break;
|
|
|
|
case 'play':
|
|
const flags = input.flags ? input.flags.split(',') : [];
|
|
apiResult = await this.client.playerContent(key, input.flag, input.id, flags);
|
|
// 播放接口直接返回原始数据,不需要格式化
|
|
if (apiResult && apiResult.code === 0) {
|
|
return apiResult.data || apiResult;
|
|
}
|
|
return { error: apiResult?.msg || '请求失败' };
|
|
|
|
case 'list':
|
|
default:
|
|
const t = input.t || '';
|
|
const filter = !input.f || input.f === '1';
|
|
|
|
if (!t) {
|
|
apiResult = await this.client.homeContent(key, filter);
|
|
} else {
|
|
const extend = {};
|
|
['area', 'year', 'type', 'class', 'lang'].forEach(field => {
|
|
if (input[field]) {
|
|
extend[field] = input[field];
|
|
}
|
|
});
|
|
apiResult = await this.client.categoryContent(key, t, pg, filter, extend);
|
|
}
|
|
break;
|
|
}
|
|
|
|
// 返回苹果CMS标准格式
|
|
if (apiResult && apiResult.code === 0) {
|
|
return this.formatMacCmsResponse(apiResult.data, pg);
|
|
}
|
|
|
|
// 错误情况
|
|
return { error: apiResult?.msg || '请求失败' };
|
|
}
|
|
|
|
getKeyFromPath(req) {
|
|
const pathname = req.url.split('?')[0];
|
|
|
|
// 移除开头的斜杠并获取第一段路径
|
|
const pathParts = pathname.split('/').filter(p => p);
|
|
|
|
// 如果有路径段,返回第一个(假设是 key),并进行 URL 解码
|
|
if (pathParts.length > 0) {
|
|
try {
|
|
return decodeURIComponent(pathParts[0]);
|
|
} catch (e) {
|
|
return pathParts[0];
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
formatMacCmsResponse(data, currentPage = 1) {
|
|
// 获取 list
|
|
const list = data.list || [];
|
|
const listCount = list.length;
|
|
|
|
// 从返回数据中获取或动态计算
|
|
const page = data.page ? parseInt(data.page) : currentPage;
|
|
const limit = data.limit ? parseInt(data.limit) : (listCount > 0 ? listCount : 20);
|
|
|
|
// 计算总页数:优先使用返回值,否则写死 9999
|
|
const pagecount = data.pagecount ? parseInt(data.pagecount) : 9999;
|
|
|
|
// 计算总数:优先使用返回值,否则用 pagecount * limit
|
|
const total = data.total ? parseInt(data.total) : pagecount * limit;
|
|
|
|
// 苹果CMS标准返回格式
|
|
const response = {
|
|
page,
|
|
pagecount,
|
|
limit,
|
|
total,
|
|
list
|
|
};
|
|
|
|
if (data.class) {
|
|
response.class = data.class;
|
|
}
|
|
if (data.filters) {
|
|
response.filters = data.filters;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async getProxiedConfig(req) {
|
|
const status = await this.client.configAction('status');
|
|
|
|
if (status.code !== 0 || !status.data?.sites) {
|
|
return { error: '获取配置失败' };
|
|
}
|
|
|
|
const sites = status.data.sites;
|
|
const baseUrl = this.getBaseUrl(req);
|
|
|
|
const config = {
|
|
spider: '',
|
|
wallpaper: 'https://深色壁纸.xxooo.cf/',
|
|
warningText: '资源来自网络,仅供学习使用',
|
|
sites: [],
|
|
doh: this.getDefaultDoh(),
|
|
rules: this.getDefaultRules(),
|
|
lives: []
|
|
};
|
|
|
|
sites.forEach(site => {
|
|
config.sites.push({
|
|
key: site.key,
|
|
name: site.name,
|
|
api: `${baseUrl}/${site.key}`,
|
|
type: '4',
|
|
searchable: site.searchable ?? 1,
|
|
quickSearch: site.quickSearch ?? 1,
|
|
filterable: site.filterable ?? 1
|
|
});
|
|
});
|
|
|
|
return config;
|
|
}
|
|
|
|
getBaseUrl(req) {
|
|
const protocol = req.connection.encrypted ? 'https' : 'http';
|
|
const host = req.headers.host || 'localhost';
|
|
return `${protocol}://${host}`;
|
|
}
|
|
|
|
getDefaultDoh() {
|
|
return [
|
|
{ name: 'Google', url: 'https://dns.google/dns-query', ips: ['8.8.4.4', '8.8.8.8'] },
|
|
{ name: 'Cloudflare', url: 'https://cloudflare-dns.com/dns-query', ips: ['1.1.1.1', '1.0.0.1'] }
|
|
];
|
|
}
|
|
|
|
getDefaultRules() {
|
|
return [
|
|
{ name: 'lz', hosts: ['vip.lz', 'hd.lz'], regex: ['18.5333'] },
|
|
{ name: '非凡', hosts: ['vip.ffzy', 'hd.ffzy'], regex: ['25.0666'] }
|
|
];
|
|
}
|
|
|
|
async getInput(req) {
|
|
// 解析查询参数
|
|
const queryIndex = req.url.indexOf('?');
|
|
const input = {};
|
|
|
|
if (queryIndex !== -1) {
|
|
const searchParams = new URLSearchParams(req.url.slice(queryIndex + 1));
|
|
for (const [key, value] of searchParams) {
|
|
// 对参数进行 URL 解码
|
|
try {
|
|
input[key] = decodeURIComponent(value);
|
|
} catch (e) {
|
|
input[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 处理 POST body
|
|
if (req.method === 'POST') {
|
|
const body = await this.readBody(req);
|
|
try {
|
|
const jsonBody = JSON.parse(body);
|
|
Object.assign(input, jsonBody);
|
|
} catch (e) {
|
|
// 忽略非 JSON body
|
|
}
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
readBody(req) {
|
|
return new Promise((resolve) => {
|
|
let body = '';
|
|
req.on('data', chunk => body += chunk);
|
|
req.on('end', () => resolve(body));
|
|
req.on('error', () => resolve(''));
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 主入口
|
|
// ============================================================================
|
|
function main() {
|
|
// 解析命令行参数
|
|
const args = process.argv.slice(2);
|
|
let port = 10998;
|
|
const host = '0.0.0.0';
|
|
|
|
// 支持 --port 或 -p 参数
|
|
for (let i = 0; i < args.length; i++) {
|
|
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
|
|
port = parseInt(args[i + 1]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 创建客户端和代理
|
|
const client = new SpiderApiClient(SPIDER_API_HOST, REQUEST_TIMEOUT);
|
|
const proxy = new SpiderApiProxy(client, DEFAULT_SITE_KEY);
|
|
|
|
// 创建 HTTP 服务器
|
|
const server = http.createServer((req, res) => {
|
|
proxy.handle(req, res);
|
|
});
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`Spider API Type 4 代理服务器已启动`);
|
|
console.log(`监听地址: http://${host}:${port}`);
|
|
|
|
// 获取局域网IP地址
|
|
const networkInterfaces = os.networkInterfaces();
|
|
const lanIps = [];
|
|
for (const name of Object.keys(networkInterfaces)) {
|
|
for (const net of networkInterfaces[name]) {
|
|
// 跳过内部地址和非IPv4地址
|
|
if (net.family === 'IPv4' && !net.internal) {
|
|
lanIps.push(net.address);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lanIps.length > 0) {
|
|
console.log(`局域网地址:`);
|
|
lanIps.forEach(ip => {
|
|
console.log(` http://${ip}:${port}`);
|
|
});
|
|
}
|
|
|
|
console.log(`Spider API: ${SPIDER_API_HOST}`);
|
|
console.log(`\n使用示例:`);
|
|
console.log(` 配置接口: http://localhost:${port}?ac=config`);
|
|
console.log(` 首页接口: http://localhost:${port}/站源key`);
|
|
console.log(` 分类接口: http://localhost:${port}/站源key?t=分类ID&pg=页码`);
|
|
console.log(` 详情接口: http://localhost:${port}/站源key?ids=视频ID`);
|
|
console.log(` 搜索接口: http://localhost:${port}/站源key?wd=关键词&pg=页码`);
|
|
console.log(` 播放接口: http://localhost:${port}/站源key?flag=播放源&id=播放ID`);
|
|
console.log(` 或: http://localhost:${port}/站源key?flag=播放源&play=播放ID`);
|
|
console.log(`\n提示: ac参数可省略,系统会根据其他参数自动识别接口类型`);
|
|
console.log(`筛选参数: area=地区&year=年份&type=类型&class=剧情&lang=语言`);
|
|
});
|
|
|
|
server.on('error', (e) => {
|
|
if (e.code === 'EADDRINUSE') {
|
|
console.error(`错误: 端口 ${port} 已被占用,请强制结束app或更换端口后重新运行`);
|
|
} else {
|
|
console.error('服务器错误:', e.message);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = { SpiderApiClient, SpiderApiProxy };
|