上传文件至「lib/pan」
This commit is contained in:
@@ -0,0 +1,559 @@
|
|||||||
|
/**
|
||||||
|
* 百度网盘处理类
|
||||||
|
* 提供分享链接解析、文件列表获取、播放链接生成功能
|
||||||
|
*
|
||||||
|
* @module BaiduHandler
|
||||||
|
* @author 优雅
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Crypto as CryptoJS } from 'assets://js/lib/cat.js';
|
||||||
|
|
||||||
|
// 全局调试开关
|
||||||
|
let DEBUG = globalThis.baidu_debug || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志输出
|
||||||
|
*/
|
||||||
|
function log(tag, message) {
|
||||||
|
if (!DEBUG) return;
|
||||||
|
console.log(`【百度-${tag}】 ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaiduHandler {
|
||||||
|
/**
|
||||||
|
* 初始化百度网盘处理器
|
||||||
|
* 配置正则表达式、请求头、API地址等基础参数
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
// 百度分享链接正则表达式 - 匹配标准分享链接和提取密码
|
||||||
|
this.regex = /https:\/\/pan\.baidu\.com\/s\/([^?&#]+)(?:\?.*?pwd=([^&]+))?/;
|
||||||
|
|
||||||
|
// 基础请求头配置
|
||||||
|
this.headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
|
'Referer': 'https://pan.baidu.com/',
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "zh,en-GB;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 百度网盘API基础地址
|
||||||
|
this.api = 'https://pan.baidu.com';
|
||||||
|
|
||||||
|
// 应用ID - 用于API请求标识
|
||||||
|
this.app_id = 250528;
|
||||||
|
|
||||||
|
// 视图模式 - 1表示列表视图
|
||||||
|
this.view_mode = 1;
|
||||||
|
|
||||||
|
// 渠道标识
|
||||||
|
this.channel = 'chunlei';
|
||||||
|
|
||||||
|
// 清晰度类型定义
|
||||||
|
this.type = ["M3U8_AUTO_4K", "M3U8_AUTO_2K", "M3U8_AUTO_1080", "M3U8_AUTO_720", "M3U8_AUTO_480"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取百度Cookie
|
||||||
|
* 从全局变量 baidu_cookie 中读取
|
||||||
|
* @returns {string} 百度Cookie字符串
|
||||||
|
*/
|
||||||
|
get cookie() {
|
||||||
|
return globalThis.baidu_cookie || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置百度Cookie
|
||||||
|
* 存储到全局变量 baidu_cookie 中
|
||||||
|
* @param {string} value - Cookie字符串
|
||||||
|
*/
|
||||||
|
set cookie(value) {
|
||||||
|
globalThis.baidu_cookie = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延时函数
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一请求函数
|
||||||
|
* @param {string} url - 请求地址
|
||||||
|
* @param {object} options - 请求配置
|
||||||
|
* @param {number} retries - 重试次数
|
||||||
|
* @param {string} requestType - 请求类型标识
|
||||||
|
*/
|
||||||
|
async request(url, options, retries = 2, requestType = '未知') {
|
||||||
|
log('请求', `【${requestType}】 ${options.method || 'GET'} ${url.substring(0, 100)}${url.length > 100 ? '...' : ''}`);
|
||||||
|
if (DEBUG && options.data) {
|
||||||
|
const dataStr = JSON.stringify(options.data);
|
||||||
|
log('请求数据', `【${requestType}】 ${dataStr.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= retries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await req(url, options);
|
||||||
|
log('响应', `【${requestType}】 ${response.code || response.status || 'unknown'}`);
|
||||||
|
|
||||||
|
if (response.content) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.content);
|
||||||
|
} catch {
|
||||||
|
return { content: response.content, status: response.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: response.status };
|
||||||
|
} catch (error) {
|
||||||
|
log('错误', `【${requestType}】 失败 (${i + 1}/${retries + 1}): ${error?.message || error}`);
|
||||||
|
if (i === retries) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: -1,
|
||||||
|
message: '请求失败: ' + (error?.message || '未知错误')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
* 将字节数转换为人类可读的格式 (B, KB, MB, GB, TB)
|
||||||
|
* @param {number} bytes - 文件大小(字节)
|
||||||
|
* @returns {string} 格式化后的文件大小
|
||||||
|
*/
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
let size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return size.toFixed(2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象转查询字符串
|
||||||
|
* 将对象转换为URL查询参数格式
|
||||||
|
* @param {Object} obj - 要转换的对象
|
||||||
|
* @returns {string} URL查询字符串
|
||||||
|
*/
|
||||||
|
objectToQuery(obj) {
|
||||||
|
return Object.entries(obj)
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析分享链接
|
||||||
|
* 从分享URL中提取surl和密码
|
||||||
|
* @param {string} url - 百度分享链接
|
||||||
|
* @returns {Object|null} 包含surl和pwd的对象,解析失败返回null
|
||||||
|
*/
|
||||||
|
getShareData(url) {
|
||||||
|
const matches = this.regex.exec(url);
|
||||||
|
if (!matches || !matches[1]) return null;
|
||||||
|
return { surl: matches[1], pwd: matches[2] || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取随机密钥(randsk)并返回独立Cookie
|
||||||
|
* 验证分享密码并获取访问凭证
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl和pwd
|
||||||
|
* @returns {Object|null} 包含randsk和cookie的对象,失败返回null
|
||||||
|
*/
|
||||||
|
async getRandsk(shareData) {
|
||||||
|
// 注意:verify接口需要去掉开头的1
|
||||||
|
const shorturl = shareData.surl.replace(/^1+/, '');
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const baseCookie = this.cookie;
|
||||||
|
const verUrl = `${this.api}/share/verify?t=${timestamp}&surl=${shorturl}`;
|
||||||
|
|
||||||
|
// 准备两种数据格式
|
||||||
|
const formData = { pwd: shareData.pwd || '' };
|
||||||
|
const postData = `pwd=${encodeURIComponent(shareData.pwd || '')}`;
|
||||||
|
const dataFormats = [
|
||||||
|
{ data: formData, type: 'object' },
|
||||||
|
{ data: postData, type: 'string' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const format of dataFormats) {
|
||||||
|
try {
|
||||||
|
const result = await this.request(verUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...this.headers,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Cookie': baseCookie
|
||||||
|
},
|
||||||
|
data: format.data
|
||||||
|
}, 3, '验证分享密码');
|
||||||
|
|
||||||
|
if (!result.error && result.errno === 0 && result.randsk) {
|
||||||
|
const randsk = result.randsk;
|
||||||
|
const BDCLND = `BDCLND=${randsk}`;
|
||||||
|
|
||||||
|
// 构建独立Cookie,不修改全局
|
||||||
|
let independentCookie;
|
||||||
|
if (!baseCookie.includes('BDCLND')) {
|
||||||
|
independentCookie = baseCookie + (baseCookie ? '; ' : '') + BDCLND;
|
||||||
|
} else {
|
||||||
|
independentCookie = baseCookie.replace(/BDCLND=[^;]+/, BDCLND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { randsk, cookie: independentCookie };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('Randsk', `${format.type}格式失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Randsk', `所有数据格式都失败`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享文件列表
|
||||||
|
* 获取分享链接根目录的文件和目录列表
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl和pwd
|
||||||
|
* @returns {Object|null} 包含list、uk、shareid、title、cookie的对象,失败返回null
|
||||||
|
*/
|
||||||
|
async getShareList(shareData) {
|
||||||
|
const shorturl = shareData.surl.replace(/^1+/, '');
|
||||||
|
const randskResult = await this.getRandsk(shareData);
|
||||||
|
if (!randskResult) return null;
|
||||||
|
|
||||||
|
const headers = { ...this.headers, 'Cookie': randskResult.cookie };
|
||||||
|
|
||||||
|
const listUrl = `${this.api}/share/list?` + this.objectToQuery({
|
||||||
|
web: 5,
|
||||||
|
app_id: this.app_id,
|
||||||
|
desc: 1,
|
||||||
|
showempty: 0,
|
||||||
|
page: 1,
|
||||||
|
num: 100,
|
||||||
|
order: 'time',
|
||||||
|
shorturl: shorturl,
|
||||||
|
root: 1,
|
||||||
|
view_mode: this.view_mode,
|
||||||
|
channel: this.channel,
|
||||||
|
clienttype: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResult = await this.request(listUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: headers
|
||||||
|
}, 3, '获取分享列表');
|
||||||
|
|
||||||
|
if (listResult.error || listResult.errno !== 0) {
|
||||||
|
log('分享列表', `获取失败: errno=${listResult.errno}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: listResult.list,
|
||||||
|
uk: listResult.uk,
|
||||||
|
shareid: listResult.share_id,
|
||||||
|
title: listResult.title,
|
||||||
|
cookie: randskResult.cookie
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定路径下的文件列表
|
||||||
|
* 递归获取子目录中的文件
|
||||||
|
* @param {string} path - 目录路径
|
||||||
|
* @param {Object} shareInfo - 分享信息,包含uk和shareid
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl和pwd
|
||||||
|
* @returns {Array} 文件列表
|
||||||
|
*/
|
||||||
|
async getSharepath(path, shareInfo, shareData) {
|
||||||
|
const randskResult = await this.getRandsk(shareData);
|
||||||
|
if (!randskResult) return [];
|
||||||
|
|
||||||
|
const headers = { ...this.headers, 'Cookie': randskResult.cookie };
|
||||||
|
|
||||||
|
const dirUrl = `${this.api}/share/list?` + this.objectToQuery({
|
||||||
|
is_from_web: true,
|
||||||
|
uk: shareInfo.uk,
|
||||||
|
shareid: shareInfo.shareid,
|
||||||
|
order: 'name',
|
||||||
|
desc: 0,
|
||||||
|
showempty: 0,
|
||||||
|
view_mode: this.view_mode,
|
||||||
|
page: 1,
|
||||||
|
num: 100,
|
||||||
|
dir: path,
|
||||||
|
channel: this.channel,
|
||||||
|
app_id: this.app_id
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirResult = await this.request(dirUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: headers
|
||||||
|
}, 3, `获取目录列表-${path}`);
|
||||||
|
|
||||||
|
if (dirResult.error || dirResult.errno !== 0) return [];
|
||||||
|
return dirResult.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件项中提取视频信息
|
||||||
|
* 提取文件名、路径、缩略图、大小等信息
|
||||||
|
* @param {Object} item - 文件项
|
||||||
|
* @param {Object} shareInfo - 分享信息
|
||||||
|
* @param {Object} shareData - 分享数据
|
||||||
|
* @returns {Object} 视频信息对象
|
||||||
|
*/
|
||||||
|
extractVideoInfo(item, shareInfo, shareData) {
|
||||||
|
const fileName = item.server_filename || item.path.split('/').pop();
|
||||||
|
|
||||||
|
// 提取缩略图
|
||||||
|
let thumbnail = '';
|
||||||
|
if (item.thumbs) {
|
||||||
|
thumbnail = item.thumbs.url || item.thumbs.icon || '';
|
||||||
|
} else if (item.icon) {
|
||||||
|
thumbnail = item.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: fileName,
|
||||||
|
path: item.path.replaceAll('#', '\0'),
|
||||||
|
uk: shareInfo.uk,
|
||||||
|
shareid: shareInfo.shareid,
|
||||||
|
fsid: item.fs_id || item.fsid,
|
||||||
|
surl: shareData.surl,
|
||||||
|
size: item.size,
|
||||||
|
thumbnail: thumbnail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享中的视频文件列表 - 主入口方法
|
||||||
|
* 递归获取分享中的所有视频文件
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl和pwd
|
||||||
|
* @returns {Array} 视频文件列表
|
||||||
|
*/
|
||||||
|
async getFilesByShareUrl(shareData) {
|
||||||
|
log('文件列表', `开始获取`);
|
||||||
|
if (!shareData || !shareData.surl) return [];
|
||||||
|
|
||||||
|
const listResult = await this.getShareList(shareData);
|
||||||
|
if (!listResult || !listResult.list) return [];
|
||||||
|
|
||||||
|
const shareInfo = {
|
||||||
|
uk: listResult.uk,
|
||||||
|
shareid: listResult.shareid
|
||||||
|
};
|
||||||
|
|
||||||
|
let dirs = [];
|
||||||
|
let videos = [];
|
||||||
|
|
||||||
|
// 处理根目录文件
|
||||||
|
listResult.list.map(item => {
|
||||||
|
if (item.category === '6' || item.category === 6) {
|
||||||
|
dirs.push(item.path);
|
||||||
|
}
|
||||||
|
if (item.category === '1' || item.category === 1) {
|
||||||
|
videos.push(this.extractVideoInfo(item, shareInfo, shareData));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理子目录
|
||||||
|
if (dirs.length > 0) {
|
||||||
|
const results = await Promise.all(dirs.map(async (path) => {
|
||||||
|
const dirItems = await this.getSharepath(path, shareInfo, shareData);
|
||||||
|
if (dirItems.length === 0) return [];
|
||||||
|
|
||||||
|
let subDirs = [];
|
||||||
|
let subVideos = [];
|
||||||
|
|
||||||
|
dirItems.map(item => {
|
||||||
|
if (item.category === '6' || item.category === 6) {
|
||||||
|
subDirs.push(item.path);
|
||||||
|
}
|
||||||
|
if (item.category === '1' || item.category === 1) {
|
||||||
|
subVideos.push(this.extractVideoInfo(item, shareInfo, shareData));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理更深层目录
|
||||||
|
if (subDirs.length > 0) {
|
||||||
|
const deeperResults = await Promise.all(subDirs.map(subPath =>
|
||||||
|
this.getSharepath(subPath, shareInfo, shareData)
|
||||||
|
));
|
||||||
|
|
||||||
|
deeperResults.forEach(deeperItems => {
|
||||||
|
deeperItems.forEach(item => {
|
||||||
|
if (item.category === '1' || item.category === 1) {
|
||||||
|
subVideos.push(this.extractVideoInfo(item, shareInfo, shareData));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subVideos;
|
||||||
|
}));
|
||||||
|
|
||||||
|
results.flat().forEach(video => videos.push(video));
|
||||||
|
}
|
||||||
|
|
||||||
|
log('文件列表', `找到 ${videos.length} 个视频`);
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户UID
|
||||||
|
* 通过百度MBD接口获取用户唯一标识
|
||||||
|
* @returns {string} 用户UID
|
||||||
|
*/
|
||||||
|
async getUid() {
|
||||||
|
const headers = { ...this.headers, 'Cookie': this.cookie };
|
||||||
|
|
||||||
|
const result = await this.request('https://mbd.baidu.com/userx/v1/info/get?appname=baiduboxapp&fields=%20%20%20%20%20%20%20%20%5B%22bg_image%22,%22member%22,%22uid%22,%22avatar%22,%20%22avatar_member%22%5D&client&clientfrom&lang=zh-cn&tpl&ttt', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: headers
|
||||||
|
}, 3, '获取用户UID');
|
||||||
|
|
||||||
|
return result.data?.fields?.uid || result.data?.uid || result.uid || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA1哈希计算
|
||||||
|
* 使用CryptoJS进行SHA1加密
|
||||||
|
* @param {string} message - 待加密字符串
|
||||||
|
* @returns {string} SHA1哈希值(十六进制)
|
||||||
|
*/
|
||||||
|
sha1(message) {
|
||||||
|
return CryptoJS.SHA1(message).toString(CryptoJS.enc.Hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签名
|
||||||
|
* 用于Web版播放链接的签名验证
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl(完整surl,带开头的1)
|
||||||
|
* @returns {Promise<string|null>} 签名字符串,失败返回null
|
||||||
|
*/
|
||||||
|
async getSign(shareData) {
|
||||||
|
// 注意:tplconfig接口需要完整的surl(带开头的1)
|
||||||
|
const url = `${this.api}/share/tplconfig?surl=${shareData.surl}&fields=Espace_info,card_info,sign,timestamp&view_mode=${this.view_mode}&channel=${this.channel}&web=1&app_id=${this.app_id}`;
|
||||||
|
const headers = { ...this.headers, 'Cookie': this.cookie };
|
||||||
|
const data = await this.request(url, { headers }, 3, '获取签名');
|
||||||
|
|
||||||
|
if (data.error || data.errno !== 0 || !data.data) {
|
||||||
|
log('签名', `获取失败`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件的直链地址(App版)
|
||||||
|
* 生成百度网盘App接口的播放直链
|
||||||
|
* @param {string} path - 文件路径
|
||||||
|
* @param {string} uk - 用户uk
|
||||||
|
* @param {string} shareid - 分享ID
|
||||||
|
* @param {string} fsid - 文件fsid
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl和pwd
|
||||||
|
* @returns {string|null} 播放直链,失败返回null
|
||||||
|
*/
|
||||||
|
async getAppShareUrl(path, uk, shareid, fsid, shareData) {
|
||||||
|
path = path.replaceAll('\0', '#');
|
||||||
|
|
||||||
|
const randskResult = await this.getRandsk(shareData);
|
||||||
|
if (!randskResult) return null;
|
||||||
|
|
||||||
|
const uid = await this.getUid();
|
||||||
|
if (!uid) return null;
|
||||||
|
|
||||||
|
// 构建App接口请求头
|
||||||
|
const headers = {
|
||||||
|
...this.headers,
|
||||||
|
'Cookie': randskResult.cookie,
|
||||||
|
"User-Agent": 'netdisk;P2SP;2.2.91.136;android-android;'
|
||||||
|
};
|
||||||
|
|
||||||
|
const devuid = "73CED981D0F186D12BC18CAE1684FFD5|VSRCQTF6W";
|
||||||
|
const time = String(Date.now());
|
||||||
|
|
||||||
|
const bdussMatch = randskResult.cookie.match(/BDUSS=(.+?);/);
|
||||||
|
if (!bdussMatch) return null;
|
||||||
|
|
||||||
|
const BDUSS = bdussMatch[1];
|
||||||
|
|
||||||
|
// 计算签名
|
||||||
|
const rand = this.sha1(
|
||||||
|
this.sha1(BDUSS) +
|
||||||
|
uid +
|
||||||
|
"ebrcUYiuxaZv2XGu7KIYKxUrqfnOfpDF" +
|
||||||
|
time +
|
||||||
|
devuid +
|
||||||
|
"11.30.2ae5821440fab5e1a61a025f014bd8972"
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = this.api + "/share/list?" + this.objectToQuery({
|
||||||
|
shareid,
|
||||||
|
uk,
|
||||||
|
fid: fsid,
|
||||||
|
sekey: randskResult.randsk,
|
||||||
|
origin: 'dlna',
|
||||||
|
devuid,
|
||||||
|
clienttype: 1,
|
||||||
|
channel: 'android_12_zhao_bd-netdisk_1024266h',
|
||||||
|
version: '11.30.2',
|
||||||
|
time,
|
||||||
|
rand
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.request(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers
|
||||||
|
}, 3, '获取App直链');
|
||||||
|
|
||||||
|
if (result.error || result.errno !== 0 || !result.list?.length) return null;
|
||||||
|
return result.list[0].dlink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Web版播放链接
|
||||||
|
* 返回不同清晰度的播放链接数组
|
||||||
|
* 使用流程: 先通过getFilesByShareUrl获取视频信息,然后用其中的uk、shareid、fsid和shareData调用此方法
|
||||||
|
*
|
||||||
|
* @param {string} path - 文件路径(支持\0转义的路径)
|
||||||
|
* @param {string} uk - 用户UK
|
||||||
|
* @param {string} shareid - 分享ID
|
||||||
|
* @param {string} fsid - 文件ID
|
||||||
|
* @param {Object} shareData - 分享数据,包含surl(完整surl,带开头的1)
|
||||||
|
* @returns {Promise<Array>} 播放链接数组,每个元素包含{name, url},name为清晰度名称
|
||||||
|
*/
|
||||||
|
async getWebPlayUrls(path, uk, shareid, fsid, shareData) {
|
||||||
|
// 还原被替换的#字符
|
||||||
|
path = path.replace(/\0/g, '#');
|
||||||
|
// 获取签名
|
||||||
|
const sign = await this.getSign(shareData);
|
||||||
|
if (!sign) return [];
|
||||||
|
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const urls = [];
|
||||||
|
|
||||||
|
// 生成不同清晰度的播放链接
|
||||||
|
this.type.forEach(type => {
|
||||||
|
const urlInfo = {
|
||||||
|
name: type.replace('M3U8_AUTO_', ''),
|
||||||
|
url: `${this.api}/share/streaming?channel=${this.channel}&uk=${uk}&fid=${fsid}&sign=${sign}×tamp=${timestamp}&shareid=${shareid}&type=${type}&vip=0&jsToken&isplayer=1&check_blue=1&adToken`
|
||||||
|
};
|
||||||
|
urls.push(urlInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Baidu = new BaiduHandler();
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
/**
|
||||||
|
* 夸克网盘处理工具
|
||||||
|
*
|
||||||
|
* 提供夸克网盘分享链接解析、文件下载、流媒体播放等功能。
|
||||||
|
*
|
||||||
|
* @module QuarkPanHandler
|
||||||
|
* @author 优雅
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 全局调试开关
|
||||||
|
let DEBUG = globalThis.quark_debug || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志输出
|
||||||
|
*/
|
||||||
|
function log(tag, message) {
|
||||||
|
if (!DEBUG) return;
|
||||||
|
console.log(`【夸克-${tag}】 ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误码定义
|
||||||
|
const ERROR_CODES = {
|
||||||
|
// 空间相关
|
||||||
|
32003: { message: "网盘空间不足,请清理空间或开通会员" },
|
||||||
|
32008: { message: "存储配额已用完" },
|
||||||
|
|
||||||
|
// 认证相关
|
||||||
|
31001: { message: "未登录或Cookie已失效,请重新获取Cookie" },
|
||||||
|
31002: { message: "登录态已过期,请重新登录" },
|
||||||
|
32004: { message: "Cookie已失效,请重新获取" },
|
||||||
|
32011: { message: "登录态已过期" },
|
||||||
|
32012: { message: "认证失败,请检查Cookie" },
|
||||||
|
32014: { message: "账号异常,请重新登录" },
|
||||||
|
|
||||||
|
// 限流相关
|
||||||
|
32001: { message: "请求过于频繁,请稍后再试" },
|
||||||
|
32002: { message: "操作频率过高" },
|
||||||
|
|
||||||
|
// 文件相关
|
||||||
|
32005: { message: "文件不存在" },
|
||||||
|
32006: { message: "文件已被删除" },
|
||||||
|
32007: { message: "文件格式不支持" },
|
||||||
|
|
||||||
|
// 分享相关
|
||||||
|
32009: { message: "分享链接已失效" },
|
||||||
|
32010: { message: "提取码错误" },
|
||||||
|
32013: { message: "分享文件已被删除" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 夸克网盘处理类
|
||||||
|
*/
|
||||||
|
class QuarkHandler {
|
||||||
|
constructor() {
|
||||||
|
// 夸克分享链接正则表达式 - 匹配标准分享链接和提取密码
|
||||||
|
this.regex = /https:\/\/pan\.quark\.cn\/s\/([^?&#]+)(?:\?.*?pwd=([^&]+))?/;
|
||||||
|
// 请求参数
|
||||||
|
this.pr = 'pr=ucpro&fr=pc';
|
||||||
|
// 基础请求头
|
||||||
|
this.baseHeader = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
'Referer': 'https://pan.quark.cn',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
// API基础URL
|
||||||
|
this.apiUrl = 'https://drive.quark.cn/1/clouddrive/';
|
||||||
|
// 分享令牌缓存
|
||||||
|
this.shareTokenCache = {};
|
||||||
|
// 保存目录名称
|
||||||
|
this.saveDirName = 'drpy';
|
||||||
|
// 保存目录ID
|
||||||
|
this.saveDirId = null;
|
||||||
|
// 字幕文件扩展名
|
||||||
|
this.subtitleExts = ['.srt', '.ass', '.scc', '.stl', '.ttml'];
|
||||||
|
// 视频文件扩展名
|
||||||
|
this.videoExts = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg', '.ts', '.m3u8'];
|
||||||
|
// 视频扩展名开关(1=启用判断,0=禁用)
|
||||||
|
this.videoExtsEnabled = 0;
|
||||||
|
|
||||||
|
// Cookie存储
|
||||||
|
this._cookie = '';
|
||||||
|
|
||||||
|
// 上次刷新时间
|
||||||
|
this.lastRefreshTime = 0;
|
||||||
|
this.refreshInterval = 1 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 刷新锁
|
||||||
|
this._isRefreshing = false;
|
||||||
|
this._refreshPromise = null;
|
||||||
|
|
||||||
|
// 播放地址临时缓存(20分钟)
|
||||||
|
this.urlCache = {};
|
||||||
|
this.cacheTTL = 20 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Cookie
|
||||||
|
*/
|
||||||
|
get cookie() {
|
||||||
|
if (this._cookie) return this._cookie;
|
||||||
|
if (globalThis.quark_cookie) this._cookie = globalThis.quark_cookie;
|
||||||
|
return this._cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置Cookie
|
||||||
|
*/
|
||||||
|
set cookie(value) {
|
||||||
|
this._cookie = value || '';
|
||||||
|
if (value) globalThis.quark_cookie = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求头
|
||||||
|
*/
|
||||||
|
getHeaders() {
|
||||||
|
return { ...this.baseHeader, Cookie: this.cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延时函数
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的播放地址
|
||||||
|
*/
|
||||||
|
getCachedUrl(shareId, fileId) {
|
||||||
|
const cacheKey = `${shareId}_${fileId}`;
|
||||||
|
const cached = this.urlCache[cacheKey];
|
||||||
|
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
|
||||||
|
return cached.urls;
|
||||||
|
}
|
||||||
|
if (cached) delete this.urlCache[cacheKey];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置缓存的播放地址
|
||||||
|
*/
|
||||||
|
setCachedUrl(shareId, fileId, urls) {
|
||||||
|
const cacheKey = `${shareId}_${fileId}`;
|
||||||
|
this.urlCache[cacheKey] = { urls: urls, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一请求函数
|
||||||
|
* @param {string} url - 请求地址
|
||||||
|
* @param {object} options - 请求配置
|
||||||
|
* @param {number} retries - 重试次数
|
||||||
|
* @param {string} requestType - 请求类型标识
|
||||||
|
*/
|
||||||
|
async request(url, options, retries = 2, requestType = '未知') {
|
||||||
|
log('请求', `【${requestType}】 ${options.method || 'GET'} ${url.substring(0, 100)}${url.length > 100 ? '...' : ''}`);
|
||||||
|
if (DEBUG && options.data) {
|
||||||
|
const dataStr = JSON.stringify(options.data);
|
||||||
|
log('请求数据', `【${requestType}】 ${dataStr.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= retries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await req(url, options);
|
||||||
|
log('响应', `【${requestType}】 ${response.code || response.status || 'unknown'}`);
|
||||||
|
|
||||||
|
if (response.content) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.content);
|
||||||
|
} catch {
|
||||||
|
return { content: response.content, status: response.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: response.status };
|
||||||
|
} catch (error) {
|
||||||
|
log('错误', `【${requestType}】 失败 (${i + 1}/${retries + 1}): ${error?.message || error}`);
|
||||||
|
if (i === retries) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 31001,
|
||||||
|
message: ERROR_CODES[31001]?.message || '未登录或Cookie已失效,请重新获取Cookie'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保Cookie有效
|
||||||
|
*/
|
||||||
|
async ensureValidCookie() {
|
||||||
|
const CACHE_TIME = 1 * 24 * 60 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastRefresh = now - this.lastRefreshTime;
|
||||||
|
|
||||||
|
const needRefresh = () => {
|
||||||
|
if (this.lastRefreshTime === 0) return true;
|
||||||
|
if (timeSinceLastRefresh >= CACHE_TIME) return true;
|
||||||
|
if (!this.cookie || !this.cookie.includes('__puus=')) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!needRefresh()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Cookie', `开始刷新...`);
|
||||||
|
const originalCookie = this.cookie;
|
||||||
|
const success = await this.refreshQuarkCookie();
|
||||||
|
|
||||||
|
if (success && this.cookie && this.cookie.includes('__puus=')) {
|
||||||
|
this.lastRefreshTime = now;
|
||||||
|
log('Cookie', `刷新成功`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cookie = originalCookie;
|
||||||
|
if (originalCookie && originalCookie.includes('__puus=')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Cookie', `无效`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新夸克Cookie
|
||||||
|
*/
|
||||||
|
async refreshQuarkCookie() {
|
||||||
|
if (this._isRefreshing) return await this._refreshPromise;
|
||||||
|
|
||||||
|
this._isRefreshing = true;
|
||||||
|
this._refreshPromise = (async () => {
|
||||||
|
if (!this.cookie) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${this.apiUrl}file/sort?pr=ucpro&fr=pc&uc_param_str=&pdir_fid=0&_page=1&_size=50&_fetch_total=1&_fetch_sub_dirs=0&_sort=file_type:asc,updated_at:desc`;
|
||||||
|
const resp = await req(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": this.baseHeader['User-Agent'],
|
||||||
|
Origin: 'https://pan.quark.cn',
|
||||||
|
Referer: 'https://pan.quark.cn/',
|
||||||
|
Cookie: this.cookie
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCookie = resp.headers?.['set-cookie'] || resp.headers?.['Set-Cookie'];
|
||||||
|
if (setCookie) {
|
||||||
|
this.cookie = this.mergeCookies(this.cookie, setCookie);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._refreshPromise;
|
||||||
|
} finally {
|
||||||
|
this._isRefreshing = false;
|
||||||
|
this._refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并Cookie
|
||||||
|
*/
|
||||||
|
mergeCookies(oldCookie, setCookie) {
|
||||||
|
const oldCookies = {};
|
||||||
|
if (oldCookie) {
|
||||||
|
oldCookie.split(';').forEach(part => {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex > 0) {
|
||||||
|
oldCookies[trimmed.substring(0, eqIndex)] = trimmed.substring(eqIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieArray = Array.isArray(setCookie) ? setCookie : [setCookie];
|
||||||
|
for (const item of cookieArray) {
|
||||||
|
const cookiePart = item.split(';')[0].trim();
|
||||||
|
const eqIndex = cookiePart.indexOf('=');
|
||||||
|
if (eqIndex > 0) {
|
||||||
|
oldCookies[cookiePart.substring(0, eqIndex)] = cookiePart.substring(eqIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(oldCookies).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析分享链接
|
||||||
|
* 从分享URL中提取shareId和密码
|
||||||
|
* @param {string} url - 夸克分享链接
|
||||||
|
* @returns {Object|null} 包含shareId、folderId、sharePwd的对象,解析失败返回null
|
||||||
|
*/
|
||||||
|
getShareData(url) {
|
||||||
|
const matches = this.regex.exec(url);
|
||||||
|
if (!matches || !matches[1]) return null;
|
||||||
|
|
||||||
|
let shareId = matches[1];
|
||||||
|
if (shareId.indexOf("?") > 0) shareId = shareId.split('?')[0];
|
||||||
|
const passCode = matches[2] || '';
|
||||||
|
|
||||||
|
return { shareId: shareId, folderId: '0', sharePwd: passCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化夸克
|
||||||
|
*/
|
||||||
|
async initQuark(db, cfg) {
|
||||||
|
if (cfg?.quark_cookie) this.cookie = cfg.quark_cookie;
|
||||||
|
if (cfg?.quark_videoExtsEnabled !== undefined) this.videoExtsEnabled = cfg.quark_videoExtsEnabled;
|
||||||
|
if (this.lastRefreshTime === 0) this.lastRefreshTime = Date.now();
|
||||||
|
await this.ensureValidCookie();
|
||||||
|
log('初始化', `完成, videoExtsEnabled=${this.videoExtsEnabled}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最长公共子序列
|
||||||
|
*/
|
||||||
|
lcs(str1, str2) {
|
||||||
|
if (!str1 || !str2) return { length: 0, sequence: '', offset: 0 };
|
||||||
|
let sequence = '';
|
||||||
|
const str1Length = str1.length, str2Length = str2.length;
|
||||||
|
const num = Array(str1Length).fill().map(() => Array(str2Length).fill(0));
|
||||||
|
let maxlen = 0, lastSubsBegin = 0, thisSubsBegin = null;
|
||||||
|
for (let i = 0; i < str1Length; i++) {
|
||||||
|
for (let j = 0; j < str2Length; j++) {
|
||||||
|
if (str1[i] === str2[j]) {
|
||||||
|
num[i][j] = (i === 0 || j === 0) ? 1 : 1 + num[i - 1][j - 1];
|
||||||
|
if (num[i][j] > maxlen) {
|
||||||
|
maxlen = num[i][j];
|
||||||
|
thisSubsBegin = i - num[i][j] + 1;
|
||||||
|
if (lastSubsBegin === thisSubsBegin) {
|
||||||
|
sequence += str1[i];
|
||||||
|
} else {
|
||||||
|
lastSubsBegin = thisSubsBegin;
|
||||||
|
sequence = str1.substr(lastSubsBegin, i + 1 - lastSubsBegin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { length: maxlen, sequence: sequence, offset: thisSubsBegin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找最佳匹配
|
||||||
|
*/
|
||||||
|
findBestLCS(mainItem, targetItems) {
|
||||||
|
const results = [];
|
||||||
|
let bestMatchIndex = 0;
|
||||||
|
for (let i = 0; i < targetItems.length; i++) {
|
||||||
|
const currentLCS = this.lcs(mainItem.name || mainItem.file_name, targetItems[i].name || targetItems[i].file_name);
|
||||||
|
results.push({ target: targetItems[i], lcs: currentLCS });
|
||||||
|
if (currentLCS.length > results[bestMatchIndex].lcs.length) bestMatchIndex = i;
|
||||||
|
}
|
||||||
|
return { allLCS: results, bestMatch: results[bestMatchIndex], bestMatchIndex: bestMatchIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (!bytes || bytes == 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0, size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return size.toFixed(2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理保存目录
|
||||||
|
*/
|
||||||
|
async clearSaveDir() {
|
||||||
|
if (!this.saveDirId) return { success: true };
|
||||||
|
|
||||||
|
const url = `${this.apiUrl}file/sort?pdir_fid=${this.saveDirId}&_page=1&_size=200&_sort=file_type:asc,updated_at:desc&${this.pr}`;
|
||||||
|
const listData = await this.request(url, { method: 'GET', headers: this.getHeaders() }, 3, '清理目录-获取列表');
|
||||||
|
|
||||||
|
if (listData.code && listData.code !== 200 && listData.code !== 0) {
|
||||||
|
log('清理目录', `失败: code=${listData.code}`);
|
||||||
|
return { error: true, code: listData.code, message: ERROR_CODES[listData.code]?.message || '获取目录文件列表失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listData.data?.list?.length > 0) {
|
||||||
|
const deleteUrl = `${this.apiUrl}file/delete?${this.pr}`;
|
||||||
|
const deleteResult = await this.request(deleteUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { action_type: 2, filelist: listData.data.list.map(v => v.fid), exclude_fids: [] },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '清理目录-删除文件');
|
||||||
|
|
||||||
|
if (deleteResult.code && deleteResult.code !== 200 && deleteResult.code !== 0) {
|
||||||
|
log('清理目录', `删除失败: code=${deleteResult.code}`);
|
||||||
|
return { error: true, code: deleteResult.code, message: ERROR_CODES[deleteResult.code]?.message || '清理目录失败' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建保存目录
|
||||||
|
*/
|
||||||
|
async createSaveDir(clean) {
|
||||||
|
if (this.saveDirId) {
|
||||||
|
if (clean) {
|
||||||
|
const clearResult = await this.clearSaveDir();
|
||||||
|
if (clearResult && clearResult.error) return clearResult;
|
||||||
|
}
|
||||||
|
return { success: true, dirId: this.saveDirId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.apiUrl}file/sort?pdir_fid=0&_page=1&_size=200&_sort=file_type:asc,updated_at:desc&${this.pr}`;
|
||||||
|
const listData = await this.request(url, { method: 'GET', headers: this.getHeaders() }, 3, '创建目录-获取根目录');
|
||||||
|
|
||||||
|
if (listData.code && listData.code !== 200 && listData.code !== 0) {
|
||||||
|
const errorMsg = ERROR_CODES[listData.code]?.message || listData.message || '获取目录列表失败';
|
||||||
|
log('目录', `获取失败: code=${listData.code}`);
|
||||||
|
return { error: true, code: listData.code, message: errorMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listData.message && listData.code !== 200 && listData.code !== 0) {
|
||||||
|
return { error: true, code: listData.code || -1, message: listData.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listData.data?.list) {
|
||||||
|
for (const item of listData.data.list) {
|
||||||
|
if (item.file_name === this.saveDirName && item.dir === true) {
|
||||||
|
this.saveDirId = item.fid;
|
||||||
|
if (clean) {
|
||||||
|
const clearResult = await this.clearSaveDir();
|
||||||
|
if (clearResult && clearResult.error) return clearResult;
|
||||||
|
}
|
||||||
|
return { success: true, dirId: this.saveDirId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.saveDirId) {
|
||||||
|
const createUrl = `${this.apiUrl}file?${this.pr}`;
|
||||||
|
const create = await this.request(createUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { pdir_fid: '0', file_name: this.saveDirName, dir_path: '', dir_init_lock: false },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '创建目录-新建目录');
|
||||||
|
|
||||||
|
if (create.code && create.code !== 200 && create.code !== 0) {
|
||||||
|
const errorMsg = ERROR_CODES[create.code]?.message || create.message || '创建目录失败';
|
||||||
|
log('目录', `创建失败: code=${create.code}`);
|
||||||
|
return { error: true, code: create.code, message: errorMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (create.data?.fid) {
|
||||||
|
this.saveDirId = create.data.fid;
|
||||||
|
log('目录', `创建成功,ID: ${this.saveDirId}`);
|
||||||
|
return { success: true, dirId: this.saveDirId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: true, code: -1, message: '创建保存目录失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享令牌
|
||||||
|
*/
|
||||||
|
async getShareToken(shareData) {
|
||||||
|
if (!this.shareTokenCache[shareData.shareId]) {
|
||||||
|
const url = `${this.apiUrl}share/sharepage/token?${this.pr}`;
|
||||||
|
const result = await this.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { pwd_id: shareData.shareId, passcode: shareData.sharePwd || '' },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '获取分享令牌');
|
||||||
|
if (result.data?.stoken) {
|
||||||
|
this.shareTokenCache[shareData.shareId] = result.data;
|
||||||
|
} else {
|
||||||
|
log('分享令牌', `获取失败: ${shareData.shareId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过分享链接获取文件列表
|
||||||
|
*/
|
||||||
|
async getFilesByShareUrl(shareInfo) {
|
||||||
|
log('文件列表', `开始获取`);
|
||||||
|
await this.ensureValidCookie();
|
||||||
|
|
||||||
|
const shareData = typeof shareInfo === 'string' ? this.getShareData(shareInfo) : shareInfo;
|
||||||
|
if (!shareData) return [];
|
||||||
|
|
||||||
|
await this.getShareToken(shareData);
|
||||||
|
if (!this.shareTokenCache[shareData.shareId]) return [];
|
||||||
|
|
||||||
|
const videos = [];
|
||||||
|
const subtitles = [];
|
||||||
|
|
||||||
|
const listFile = async (shareId, folderId, page = 1) => {
|
||||||
|
const prePage = 200;
|
||||||
|
const url = `${this.apiUrl}share/sharepage/detail?pwd_id=${shareId}&stoken=${encodeURIComponent(this.shareTokenCache[shareId].stoken)}&pdir_fid=${folderId}&force=0&_page=${page}&_size=${prePage}&_sort=file_type:asc,file_name:asc&${this.pr}`;
|
||||||
|
const listData = await this.request(url, { method: 'GET', headers: this.getHeaders() }, 3, `获取文件列表-${folderId}-p${page}`);
|
||||||
|
|
||||||
|
if (!listData.data?.list) return;
|
||||||
|
|
||||||
|
const items = listData.data.list;
|
||||||
|
const subDir = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const fileName = item.file_name || '';
|
||||||
|
if (item.dir === true) {
|
||||||
|
subDir.push(item);
|
||||||
|
} else if (item.file === true && item.size >= 1024 * 1024 * 5) {
|
||||||
|
// 判断是否为视频文件
|
||||||
|
let isVideo = false;
|
||||||
|
|
||||||
|
// 如果启用视频扩展名判断
|
||||||
|
if (this.videoExtsEnabled === 1) {
|
||||||
|
const isVideoExt = this.videoExts.some(ext => fileName.toLowerCase().endsWith(ext));
|
||||||
|
isVideo = item.obj_category === 'video' || isVideoExt;
|
||||||
|
} else {
|
||||||
|
// 禁用扩展名判断,只依赖夸克分类
|
||||||
|
isVideo = item.obj_category === 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
item.stoken = this.shareTokenCache[shareData.shareId].stoken;
|
||||||
|
item.formatted_size = this.formatFileSize(item.size);
|
||||||
|
item.thumbnail = item.thumbnail || item.big_thumbnail || '';
|
||||||
|
item.file_type = 'video';
|
||||||
|
videos.push(item);
|
||||||
|
}
|
||||||
|
} else if (item.type === 'file' && this.subtitleExts.some(x => fileName.endsWith(x))) {
|
||||||
|
subtitles.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page < Math.ceil(listData.metadata?._total / prePage)) {
|
||||||
|
await listFile(shareId, folderId, page + 1);
|
||||||
|
}
|
||||||
|
for (const dir of subDir) {
|
||||||
|
await listFile(shareId, dir.fid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await listFile(shareData.shareId, shareData.folderId);
|
||||||
|
|
||||||
|
if (subtitles.length > 0) {
|
||||||
|
videos.forEach(item => {
|
||||||
|
const matchSubtitle = this.findBestLCS(item, subtitles);
|
||||||
|
if (matchSubtitle.bestMatch) item.subtitle = matchSubtitle.bestMatch.target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log('文件列表', `找到 ${videos.length} 个视频`);
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存文件到个人网盘
|
||||||
|
*/
|
||||||
|
async saveDirect(shareId, stoken, fileId, fileToken) {
|
||||||
|
const dirResult = await this.createSaveDir(false);
|
||||||
|
if (dirResult.error) {
|
||||||
|
return { error: true, code: dirResult.code, message: dirResult.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stoken) {
|
||||||
|
await this.getShareToken({ shareId });
|
||||||
|
if (!this.shareTokenCache[shareId]) {
|
||||||
|
return { error: true, code: -2, message: '获取分享令牌失败' };
|
||||||
|
}
|
||||||
|
stoken = this.shareTokenCache[shareId].stoken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveUrl = `${this.apiUrl}share/sharepage/save?${this.pr}`;
|
||||||
|
const saveResult = await this.request(saveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
fid_list: [fileId],
|
||||||
|
fid_token_list: [fileToken],
|
||||||
|
to_pdir_fid: this.saveDirId,
|
||||||
|
pwd_id: shareId,
|
||||||
|
stoken: stoken,
|
||||||
|
pdir_fid: '0',
|
||||||
|
scene: 'link'
|
||||||
|
},
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '保存文件');
|
||||||
|
|
||||||
|
// 检查错误
|
||||||
|
if (saveResult.code && saveResult.code !== 200 && saveResult.code !== 0) {
|
||||||
|
log('保存', `失败: code=${saveResult.code}`);
|
||||||
|
return { error: true, code: saveResult.code, message: ERROR_CODES[saveResult.code]?.message || '保存文件失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.data?.task_resp?.code) {
|
||||||
|
return { error: true, code: saveResult.data.task_resp.code, message: saveResult.data.task_resp.message || '保存文件失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.data?.task_id) {
|
||||||
|
if (saveResult.data.task_resp?.data?.save_as?.save_as_top_fids?.length > 0) {
|
||||||
|
return saveResult.data.task_resp.data.save_as.save_as_top_fids[0];
|
||||||
|
}
|
||||||
|
for (let retry = 0; retry < 10; retry++) {
|
||||||
|
await this.delay(1000);
|
||||||
|
const taskUrl = `${this.apiUrl}task?task_id=${saveResult.data.task_id}&retry_index=${retry}&${this.pr}`;
|
||||||
|
const taskResult = await this.request(taskUrl, { method: 'GET', headers: this.getHeaders() }, 3, `查询保存任务-${retry+1}`);
|
||||||
|
if (taskResult.code && taskResult.code !== 200 && taskResult.code !== 0) {
|
||||||
|
return { error: true, code: taskResult.code, message: ERROR_CODES[taskResult.code]?.message || '查询任务状态失败' };
|
||||||
|
}
|
||||||
|
if (taskResult.data?.save_as?.save_as_top_fids?.length > 0) {
|
||||||
|
return taskResult.data.save_as.save_as_top_fids[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: true, code: -3, message: '保存文件超时,请稍后重试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直播转码播放地址
|
||||||
|
*/
|
||||||
|
async getLiveTranscoding(shareId, stoken, fileId, fileToken) {
|
||||||
|
// 先检查缓存
|
||||||
|
const cacheKey = `${shareId}_${fileId}_transcoding`;
|
||||||
|
const cachedResult = this.urlCache[cacheKey];
|
||||||
|
if (cachedResult && (Date.now() - cachedResult.timestamp) < this.cacheTTL) {
|
||||||
|
return cachedResult.urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 转存文件
|
||||||
|
const saveFileId = await this.saveDirect(shareId, stoken, fileId, fileToken);
|
||||||
|
if (!saveFileId || saveFileId.error) {
|
||||||
|
log('转码', `转存失败: ${saveFileId?.message || '未知错误'}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求转码画质
|
||||||
|
const url = `${this.apiUrl}file/v2/play?${this.pr}`;
|
||||||
|
const result = await this.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
fid: saveFileId,
|
||||||
|
resolutions: 'normal,low,high,super,2k,4k',
|
||||||
|
supports: 'fmp4'
|
||||||
|
},
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '获取转码地址');
|
||||||
|
|
||||||
|
const videoList = result.data?.video_list || null;
|
||||||
|
if (videoList && videoList.length > 0) {
|
||||||
|
// 缓存播放地址
|
||||||
|
this.urlCache[cacheKey] = { urls: videoList, timestamp: Date.now() };
|
||||||
|
|
||||||
|
// 异步删除临时文件
|
||||||
|
this.delay(3000).then(() => {
|
||||||
|
this.deleteFile(saveFileId).catch(e => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return videoList;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('转码', `异常: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
*/
|
||||||
|
async deleteFile(fileId) {
|
||||||
|
if (!fileId) return;
|
||||||
|
const deleteUrl = `${this.apiUrl}file/delete?${this.pr}`;
|
||||||
|
await this.request(deleteUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { action_type: 2, filelist: [fileId], exclude_fids: [] },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '删除临时文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载令牌
|
||||||
|
*/
|
||||||
|
async getToken() {
|
||||||
|
let t = Math.floor(Date.now() / 1e3);
|
||||||
|
let data = {
|
||||||
|
"conversation_id": "300000" + t,
|
||||||
|
"conversation_type": 3,
|
||||||
|
"msg_id": t + "000"
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.23.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'origin': 'https://pan.quark.cn',
|
||||||
|
'referer': 'https://pan.quark.cn/'
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await req('https://drive-social-api.quark.cn/1/clouddrive/chat/conv/file/acquire_dl_token?pr=ucpro&fr=pc&sys=darwin&ve=3.19', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Cookie': this.cookie },
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
if (response.code == 200 && response.content) {
|
||||||
|
const result = JSON.parse(response.content);
|
||||||
|
return result.data?.token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取无限画质链接
|
||||||
|
*/
|
||||||
|
async getUrl(shareId, stoken, fileId, fileToken) {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
"fids": [fileId],
|
||||||
|
"fids_token": [fileToken],
|
||||||
|
"pwd_id": shareId,
|
||||||
|
"stoken": stoken,
|
||||||
|
"speedup_session": "",
|
||||||
|
"token": token
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.20.0 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await req('https://drive-pc.quark.cn/1/clouddrive/file/download?pr=ucpro&fr=pc', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Cookie': this.cookie },
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code == 200 && response.content) {
|
||||||
|
const result = JSON.parse(response.content);
|
||||||
|
if (result.data && Array.isArray(result.data)) {
|
||||||
|
return result.data.map(item => ({
|
||||||
|
name: item.video_max_resolution || '原画',
|
||||||
|
url: item.download_url
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载链接
|
||||||
|
*/
|
||||||
|
async getDownload(shareId, stoken, fileId, fileToken, clean = false) {
|
||||||
|
// 先检查缓存
|
||||||
|
const cachedResult = this.getCachedUrl(shareId, fileId);
|
||||||
|
if (cachedResult) return cachedResult;
|
||||||
|
|
||||||
|
await this.ensureValidCookie();
|
||||||
|
|
||||||
|
const dirResult = await this.createSaveDir(clean);
|
||||||
|
if (dirResult.error) {
|
||||||
|
return { error: true, code: dirResult.code, message: dirResult.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFileId = await this.saveDirect(shareId, stoken, fileId, fileToken);
|
||||||
|
|
||||||
|
// 检查保存是否失败,直接返回 saveDirect 的错误信息
|
||||||
|
if (saveFileId && saveFileId.error) {
|
||||||
|
return saveFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveFileId) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: -1,
|
||||||
|
message: '文件转存失败,请检查分享链接是否有效'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.apiUrl}file/download?${this.pr}`;
|
||||||
|
const result = await this.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { fids: [saveFileId] },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '获取下载链接');
|
||||||
|
|
||||||
|
// 检查错误码
|
||||||
|
if (result.code && result.code !== 200 && result.code !== 0) {
|
||||||
|
log('下载', `失败: code=${result.code}`);
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: result.code,
|
||||||
|
message: ERROR_CODES[result.code]?.message || '获取下载链接失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data?.task_resp?.code) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: result.data.task_resp.code,
|
||||||
|
message: result.data.task_resp.message || '获取下载链接失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadResult = result.data?.[0] || null;
|
||||||
|
|
||||||
|
if (downloadResult) {
|
||||||
|
this.setCachedUrl(shareId, fileId, downloadResult);
|
||||||
|
return downloadResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: -2,
|
||||||
|
message: '获取下载链接失败,请稍后重试'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试URL支持性
|
||||||
|
*/
|
||||||
|
async testSupport(url, headers) {
|
||||||
|
try {
|
||||||
|
const resp = await req(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { ...this.baseHeader, ...headers, 'Range': 'bytes=0-0' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.code === 206 || resp.code === 200) {
|
||||||
|
const isAccept = resp.headers?.['accept-ranges'] === 'bytes';
|
||||||
|
const contentRange = resp.headers?.['content-range'];
|
||||||
|
const contentLength = parseInt(resp.headers?.['content-length'] || '0');
|
||||||
|
const isSupport = isAccept || !!contentRange || contentLength === 1 || resp.status === 200;
|
||||||
|
return [isSupport, resp.headers || {}];
|
||||||
|
}
|
||||||
|
return [false, null];
|
||||||
|
} catch {
|
||||||
|
return [false, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Quark = new QuarkHandler();
|
||||||
+962
@@ -0,0 +1,962 @@
|
|||||||
|
/**
|
||||||
|
* UC网盘处理工具
|
||||||
|
*
|
||||||
|
* 提供UC网盘分享链接解析、文件下载等功能。
|
||||||
|
*
|
||||||
|
* @module UCPanHandler
|
||||||
|
* @author 优雅
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Crypto as CryptoJS } from 'assets://js/lib/cat.js';
|
||||||
|
|
||||||
|
// 全局调试开关
|
||||||
|
let DEBUG = globalThis.uc_debug || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志输出
|
||||||
|
*/
|
||||||
|
function log(tag, message) {
|
||||||
|
if (!DEBUG) return;
|
||||||
|
console.log(`【UC-${tag}】 ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误码定义
|
||||||
|
const ERROR_CODES = {
|
||||||
|
// 空间相关
|
||||||
|
32003: { message: "网盘空间不足,请清理空间或开通会员" },
|
||||||
|
32008: { message: "存储配额已用完" },
|
||||||
|
|
||||||
|
// 认证相关
|
||||||
|
31001: { message: "未登录或Cookie已失效,请重新获取Cookie" },
|
||||||
|
31002: { message: "登录态已过期,请重新登录" },
|
||||||
|
32004: { message: "Cookie已失效,请重新获取" },
|
||||||
|
32011: { message: "登录态已过期" },
|
||||||
|
32012: { message: "认证失败,请检查Cookie" },
|
||||||
|
32014: { message: "账号异常,请重新登录" },
|
||||||
|
|
||||||
|
// 限流相关
|
||||||
|
32001: { message: "请求过于频繁,请稍后再试" },
|
||||||
|
32002: { message: "操作频率过高" },
|
||||||
|
|
||||||
|
// 文件相关
|
||||||
|
32005: { message: "文件不存在" },
|
||||||
|
32006: { message: "文件已被删除" },
|
||||||
|
32007: { message: "文件格式不支持" },
|
||||||
|
|
||||||
|
// 分享相关
|
||||||
|
32009: { message: "分享链接已失效" },
|
||||||
|
32010: { message: "提取码错误" },
|
||||||
|
32013: { message: "分享文件已被删除" }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UC网盘处理类
|
||||||
|
*/
|
||||||
|
class UCHandler {
|
||||||
|
constructor() {
|
||||||
|
// UC分享链接正则表达式 - 匹配标准分享链接和提取密码
|
||||||
|
this.regex = /https:\/\/drive\.uc\.cn\/s\/([^?&#]+)(?:\?.*?pwd=([^&]+))?/;
|
||||||
|
// 请求参数
|
||||||
|
this.pr = 'pr=UCBrowser&fr=pc';
|
||||||
|
// 基础请求头
|
||||||
|
this.baseHeader = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) uc-cloud-drive/1.8.5 Chrome/100.0.4896.160 Electron/18.3.5.16-b62cf9c50d Safari/537.36 Channel/ucpan_other_ch',
|
||||||
|
'Referer': 'https://drive.uc.cn/',
|
||||||
|
'Origin': 'https://drive.uc.cn',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
// API基础URL
|
||||||
|
this.apiUrl = 'https://pc-api.uc.cn/1/clouddrive';
|
||||||
|
// 分享令牌缓存
|
||||||
|
this.shareTokenCache = {};
|
||||||
|
// 保存目录名称
|
||||||
|
this.saveDirName = 'drpy';
|
||||||
|
// 保存目录ID
|
||||||
|
this.saveDirId = null;
|
||||||
|
// 保存文件ID缓存
|
||||||
|
this.saveFileIdCaches = {};
|
||||||
|
// 字幕文件扩展名
|
||||||
|
this.subtitleExts = ['.srt', '.ass', '.scc', '.stl', '.ttml'];
|
||||||
|
// 视频文件扩展名
|
||||||
|
this.videoExts = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.mpg', '.mpeg', '.ts', '.m3u8'];
|
||||||
|
// 视频扩展名开关(1=启用判断,0=禁用)
|
||||||
|
this.videoExtsEnabled = 0;
|
||||||
|
|
||||||
|
// Cookie和Token存储
|
||||||
|
this._cookie = "";
|
||||||
|
this._token = "";
|
||||||
|
|
||||||
|
// Token和Cookie有效期记录
|
||||||
|
this.tokenExpireTime = 0;
|
||||||
|
this.cookieExpireTime = 0;
|
||||||
|
|
||||||
|
// 播放地址临时缓存(20分钟)
|
||||||
|
this.urlCache = {};
|
||||||
|
this.cacheTTL = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
// 上次刷新时间
|
||||||
|
this.lastRefreshTime = 0;
|
||||||
|
this.refreshInterval = 4 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// UC TV配置
|
||||||
|
this.ucConfig = {
|
||||||
|
api: "https://open-api-drive.uc.cn",
|
||||||
|
clientID: "5acf882d27b74502b7040b0c65519aa7",
|
||||||
|
signKey: "l3srvtd7p42l0d0x1u8d7yc8ye9kki4d",
|
||||||
|
appVer: "1.6.8",
|
||||||
|
channel: "UCTVOFFICIALWEB",
|
||||||
|
deviceID: "07b48aaba8a739356ab8107b5e230ad4"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新锁
|
||||||
|
this._isRefreshing = false;
|
||||||
|
this._refreshPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Cookie
|
||||||
|
*/
|
||||||
|
get cookie() {
|
||||||
|
if (this._cookie && this.cookieExpireTime > Date.now()) {
|
||||||
|
return this._cookie;
|
||||||
|
}
|
||||||
|
if (globalThis.uc_cookie) {
|
||||||
|
this._cookie = globalThis.uc_cookie;
|
||||||
|
this.cookieExpireTime = Date.now() + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
return this._cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置Cookie
|
||||||
|
*/
|
||||||
|
set cookie(value) {
|
||||||
|
this._cookie = value || '';
|
||||||
|
if (value) {
|
||||||
|
this.cookieExpireTime = Date.now() + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
globalThis.uc_cookie = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Token
|
||||||
|
*/
|
||||||
|
get token() {
|
||||||
|
if (this._token && this.tokenExpireTime > Date.now()) {
|
||||||
|
return this._token;
|
||||||
|
}
|
||||||
|
if (globalThis.uc_token) {
|
||||||
|
this._token = globalThis.uc_token;
|
||||||
|
try {
|
||||||
|
const parts = this._token.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const payload = JSON.parse(CryptoJS.enc.Base64.parse(parts[1]).toString(CryptoJS.enc.Utf8));
|
||||||
|
this.tokenExpireTime = payload.exp * 1000;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return this._token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置Token
|
||||||
|
*/
|
||||||
|
set token(value) {
|
||||||
|
this._token = value || '';
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
const parts = value.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const payload = JSON.parse(CryptoJS.enc.Base64.parse(parts[1]).toString(CryptoJS.enc.Utf8));
|
||||||
|
this.tokenExpireTime = payload.exp * 1000;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
globalThis.uc_token = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求头
|
||||||
|
*/
|
||||||
|
getHeaders() {
|
||||||
|
return { ...this.baseHeader, Cookie: this.cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延时函数
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的播放地址
|
||||||
|
*/
|
||||||
|
getCachedUrl(shareId, fileId) {
|
||||||
|
const cacheKey = `${shareId}_${fileId}`;
|
||||||
|
const cached = this.urlCache[cacheKey];
|
||||||
|
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
|
||||||
|
return cached.urls;
|
||||||
|
}
|
||||||
|
if (cached) delete this.urlCache[cacheKey];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置缓存的播放地址
|
||||||
|
*/
|
||||||
|
setCachedUrl(shareId, fileId, urls) {
|
||||||
|
const cacheKey = `${shareId}_${fileId}`;
|
||||||
|
this.urlCache[cacheKey] = { urls: urls, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一请求函数
|
||||||
|
* @param {string} url - 请求地址
|
||||||
|
* @param {object} options - 请求配置
|
||||||
|
* @param {number} retries - 重试次数
|
||||||
|
* @param {string} requestType - 请求类型标识
|
||||||
|
*/
|
||||||
|
async request(url, options, retries = 2, requestType = '未知') {
|
||||||
|
log('请求', `【${requestType}】 ${options.method || 'GET'} ${url.substring(0, 100)}${url.length > 100 ? '...' : ''}`);
|
||||||
|
if (DEBUG && options.data) {
|
||||||
|
const dataStr = JSON.stringify(options.data);
|
||||||
|
log('请求数据', `【${requestType}】 ${dataStr.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i <= retries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await req(url, options);
|
||||||
|
log('响应', `【${requestType}】 ${response.code || response.status || 'unknown'}`);
|
||||||
|
|
||||||
|
if (response.content) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(response.content);
|
||||||
|
} catch {
|
||||||
|
return { content: response.content, status: response.status };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: response.status };
|
||||||
|
} catch (error) {
|
||||||
|
log('错误', `【${requestType}】 失败 (${i + 1}/${retries + 1}): ${error?.message || error}`);
|
||||||
|
if (i === retries) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 31001,
|
||||||
|
message: ERROR_CODES[31001]?.message || '未登录或Cookie已失效,请重新获取Cookie'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化UC
|
||||||
|
*/
|
||||||
|
async initUC(db, cfg) {
|
||||||
|
if (cfg?.uc_cookie) this.cookie = cfg.uc_cookie;
|
||||||
|
if (cfg?.uc_token) this.token = cfg.uc_token;
|
||||||
|
if (cfg?.uc_videoExtsEnabled !== undefined) this.videoExtsEnabled = cfg.uc_videoExtsEnabled;
|
||||||
|
if (this.lastRefreshTime === 0) this.lastRefreshTime = Date.now();
|
||||||
|
await this.ensureTokenAndCookie();
|
||||||
|
log('初始化', `完成, videoExtsEnabled=${this.videoExtsEnabled}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析分享链接
|
||||||
|
* 从分享URL中提取shareId和密码
|
||||||
|
* @param {string} url - UC分享链接
|
||||||
|
* @returns {Object|null} 包含shareId、folderId、passCode的对象,解析失败返回null
|
||||||
|
*/
|
||||||
|
getShareData(url) {
|
||||||
|
const matches = this.regex.exec(url);
|
||||||
|
if (!matches || !matches[1]) return null;
|
||||||
|
|
||||||
|
let shareId = matches[1];
|
||||||
|
if (shareId.indexOf("?") > 0) shareId = shareId.split('?')[0];
|
||||||
|
// public=1 表示无密码,否则使用正则捕获的密码
|
||||||
|
let passCode = matches[2] || '';
|
||||||
|
// 如果URL中有public=1参数,则密码为空
|
||||||
|
if (url.includes('public=1')) {
|
||||||
|
passCode = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shareId: shareId, folderId: '0', passCode: passCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享令牌
|
||||||
|
*/
|
||||||
|
async getShareToken(shareData) {
|
||||||
|
if (!this.shareTokenCache[shareData.shareId]) {
|
||||||
|
const url = `${this.apiUrl}/share/sharepage/token?${this.pr}`;
|
||||||
|
const result = await this.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { pwd_id: shareData.shareId, passcode: shareData.passCode || '' },
|
||||||
|
headers: this.baseHeader
|
||||||
|
}, 3, '获取分享令牌');
|
||||||
|
if (result.data?.stoken) {
|
||||||
|
this.shareTokenCache[shareData.shareId] = result.data;
|
||||||
|
} else {
|
||||||
|
log('分享令牌', `获取失败: ${shareData.shareId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过分享链接获取文件列表
|
||||||
|
*/
|
||||||
|
async getFilesByShareUrl(shareInfo) {
|
||||||
|
log('文件列表', `开始获取`);
|
||||||
|
const shareData = typeof shareInfo === 'string' ? this.getShareData(shareInfo) : shareInfo;
|
||||||
|
if (!shareData) return [];
|
||||||
|
|
||||||
|
await this.getShareToken(shareData);
|
||||||
|
if (!this.shareTokenCache[shareData.shareId]) return [];
|
||||||
|
|
||||||
|
const videos = [];
|
||||||
|
const subtitles = [];
|
||||||
|
|
||||||
|
const listFile = async (shareId, folderId, page = 1) => {
|
||||||
|
const prePage = 200;
|
||||||
|
const url = `${this.apiUrl}/share/sharepage/detail?pwd_id=${shareId}&stoken=${encodeURIComponent(this.shareTokenCache[shareId].stoken)}&pdir_fid=${folderId}&force=0&_page=${page}&_size=${prePage}&_sort=file_type:asc,file_name:asc&${this.pr}`;
|
||||||
|
const listData = await this.request(url, { method: 'GET', headers: this.baseHeader }, 3, `获取文件列表-${folderId}-p${page}`);
|
||||||
|
|
||||||
|
if (!listData.data?.list) return;
|
||||||
|
|
||||||
|
const items = listData.data.list;
|
||||||
|
const subDir = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const fileName = item.file_name || '';
|
||||||
|
if (item.dir === true) {
|
||||||
|
subDir.push(item);
|
||||||
|
} else if (item.file === true && item.size >= 1024 * 1024 * 5) {
|
||||||
|
// 判断是否为视频文件
|
||||||
|
let isVideo = false;
|
||||||
|
|
||||||
|
// 如果启用视频扩展名判断
|
||||||
|
if (this.videoExtsEnabled === 1) {
|
||||||
|
const isVideoExt = this.videoExts.some(ext => fileName.toLowerCase().endsWith(ext));
|
||||||
|
isVideo = item.obj_category === 'video' || isVideoExt;
|
||||||
|
} else {
|
||||||
|
// 禁用扩展名判断,只依赖UC分类
|
||||||
|
isVideo = item.obj_category === 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
item.stoken = this.shareTokenCache[shareData.shareId].stoken;
|
||||||
|
item.formatted_size = this.formatFileSize(item.size);
|
||||||
|
if (!item.share_fid_token && item.fid_token) item.share_fid_token = item.fid_token;
|
||||||
|
if (!item.share_fid_token) item.share_fid_token = item.fid;
|
||||||
|
videos.push(item);
|
||||||
|
}
|
||||||
|
} else if (item.type === 'file' && this.subtitleExts.some(x => fileName.endsWith(x))) {
|
||||||
|
subtitles.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page < Math.ceil(listData.metadata?._total / prePage)) {
|
||||||
|
await listFile(shareId, folderId, page + 1);
|
||||||
|
}
|
||||||
|
for (const dir of subDir) {
|
||||||
|
await listFile(shareId, dir.fid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await listFile(shareData.shareId, shareData.folderId);
|
||||||
|
|
||||||
|
if (subtitles.length > 0) {
|
||||||
|
videos.forEach(item => {
|
||||||
|
const matchSubtitle = this.findBestLCS(item, subtitles);
|
||||||
|
if (matchSubtitle.bestMatch) item.subtitle = matchSubtitle.bestMatch.target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log('文件列表', `找到 ${videos.length} 个视频`);
|
||||||
|
return videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最长公共子序列
|
||||||
|
*/
|
||||||
|
lcs(str1, str2) {
|
||||||
|
if (!str1 || !str2) return { length: 0, sequence: '', offset: 0 };
|
||||||
|
let sequence = '';
|
||||||
|
const str1Length = str1.length, str2Length = str2.length;
|
||||||
|
const num = Array(str1Length).fill().map(() => Array(str2Length).fill(0));
|
||||||
|
let maxlen = 0, lastSubsBegin = 0, thisSubsBegin = null;
|
||||||
|
for (let i = 0; i < str1Length; i++) {
|
||||||
|
for (let j = 0; j < str2Length; j++) {
|
||||||
|
if (str1[i] === str2[j]) {
|
||||||
|
num[i][j] = (i === 0 || j === 0) ? 1 : 1 + num[i - 1][j - 1];
|
||||||
|
if (num[i][j] > maxlen) {
|
||||||
|
maxlen = num[i][j];
|
||||||
|
thisSubsBegin = i - num[i][j] + 1;
|
||||||
|
if (lastSubsBegin === thisSubsBegin) {
|
||||||
|
sequence += str1[i];
|
||||||
|
} else {
|
||||||
|
lastSubsBegin = thisSubsBegin;
|
||||||
|
sequence = str1.substr(lastSubsBegin, i + 1 - lastSubsBegin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { length: maxlen, sequence: sequence, offset: thisSubsBegin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找最佳匹配
|
||||||
|
*/
|
||||||
|
findBestLCS(mainItem, targetItems) {
|
||||||
|
const results = [];
|
||||||
|
let bestMatchIndex = 0;
|
||||||
|
for (let i = 0; i < targetItems.length; i++) {
|
||||||
|
const currentLCS = this.lcs(mainItem.name || mainItem.file_name, targetItems[i].name || targetItems[i].file_name);
|
||||||
|
results.push({ target: targetItems[i], lcs: currentLCS });
|
||||||
|
if (currentLCS.length > results[bestMatchIndex].lcs.length) bestMatchIndex = i;
|
||||||
|
}
|
||||||
|
return { allLCS: results, bestMatch: results[bestMatchIndex], bestMatchIndex: bestMatchIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (!bytes || bytes == 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0, size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return size.toFixed(2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理保存目录
|
||||||
|
*/
|
||||||
|
async clearSaveDir() {
|
||||||
|
if (!this.saveDirId) return { success: true };
|
||||||
|
|
||||||
|
const url = `${this.apiUrl}/file/sort?pdir_fid=${this.saveDirId}&_page=1&_size=200&_sort=file_type:asc,updated_at:desc&${this.pr}`;
|
||||||
|
const listData = await this.request(url, { method: 'GET', headers: this.getHeaders() }, 3, '清理目录-获取列表');
|
||||||
|
|
||||||
|
if (listData.code && listData.code !== 200 && listData.code !== 0) {
|
||||||
|
return { error: true, code: listData.code, message: ERROR_CODES[listData.code]?.message || '获取目录文件列表失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listData.data?.list?.length > 0) {
|
||||||
|
const deleteUrl = `${this.apiUrl}/file/delete?${this.pr}`;
|
||||||
|
const deleteResult = await this.request(deleteUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { action_type: 2, filelist: listData.data.list.map(v => v.fid), exclude_fids: [] },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '清理目录-删除文件');
|
||||||
|
|
||||||
|
if (deleteResult.code && deleteResult.code !== 200 && deleteResult.code !== 0) {
|
||||||
|
return { error: true, code: deleteResult.code, message: ERROR_CODES[deleteResult.code]?.message || '清理目录失败' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建保存目录
|
||||||
|
*/
|
||||||
|
async createSaveDir(clean) {
|
||||||
|
if (this.saveDirId) {
|
||||||
|
if (clean) {
|
||||||
|
const clearResult = await this.clearSaveDir();
|
||||||
|
if (clearResult && clearResult.error) return clearResult;
|
||||||
|
}
|
||||||
|
return { success: true, dirId: this.saveDirId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.apiUrl}/file/sort?pdir_fid=0&_page=1&_size=200&_sort=file_type:asc,updated_at:desc&${this.pr}`;
|
||||||
|
const listData = await this.request(url, { method: 'GET', headers: this.getHeaders() }, 3, '创建目录-获取根目录');
|
||||||
|
|
||||||
|
if (!listData.code && !listData.data?.list) {
|
||||||
|
return { error: true, code: 31001, message: ERROR_CODES[31001]?.message || '未登录或Cookie已失效,请重新获取Cookie' };
|
||||||
|
}
|
||||||
|
// 检查是否有错误码
|
||||||
|
if (listData.code && listData.code !== 200 && listData.code !== 0) {
|
||||||
|
const errorMsg = ERROR_CODES[listData.code]?.message || listData.message || '获取目录列表失败';
|
||||||
|
log('目录', `获取失败: code=${listData.code}`);
|
||||||
|
return { error: true, code: listData.code, message: errorMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有错误信息
|
||||||
|
if (listData.message && listData.code !== 200 && listData.code !== 0) {
|
||||||
|
return { error: true, code: listData.code || -1, message: listData.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listData.data?.list) {
|
||||||
|
for (const item of listData.data.list) {
|
||||||
|
if (item.file_name === this.saveDirName) {
|
||||||
|
this.saveDirId = item.fid;
|
||||||
|
if (clean) {
|
||||||
|
const clearResult = await this.clearSaveDir();
|
||||||
|
if (clearResult && clearResult.error) return clearResult;
|
||||||
|
}
|
||||||
|
return { success: true, dirId: this.saveDirId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.saveDirId) {
|
||||||
|
const createUrl = `${this.apiUrl}/file?${this.pr}`;
|
||||||
|
const create = await this.request(createUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { pdir_fid: '0', file_name: this.saveDirName, dir_path: '', dir_init_lock: false },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '创建目录-新建目录');
|
||||||
|
|
||||||
|
if (create.code && create.code !== 200 && create.code !== 0) {
|
||||||
|
const errorMsg = ERROR_CODES[create.code]?.message || create.message || '创建目录失败';
|
||||||
|
log('目录', `创建失败: code=${create.code}`);
|
||||||
|
return { error: true, code: create.code, message: errorMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (create.data?.fid) {
|
||||||
|
this.saveDirId = create.data.fid;
|
||||||
|
log('目录', `创建成功,ID: ${this.saveDirId}`);
|
||||||
|
return { success: true, dirId: this.saveDirId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: true, code: -1, message: '创建保存目录失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存文件到个人网盘
|
||||||
|
*/
|
||||||
|
async save(shareId, stoken, fileId, fileToken, clean) {
|
||||||
|
const dirResult = await this.createSaveDir(clean);
|
||||||
|
if (dirResult.error) {
|
||||||
|
return { error: true, code: dirResult.code, message: dirResult.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clean) Object.keys(this.saveFileIdCaches).forEach(key => delete this.saveFileIdCaches[key]);
|
||||||
|
|
||||||
|
if (!stoken) {
|
||||||
|
await this.getShareToken({ shareId });
|
||||||
|
if (!this.shareTokenCache[shareId]) {
|
||||||
|
return { error: true, code: -2, message: '获取分享令牌失败' };
|
||||||
|
}
|
||||||
|
stoken = this.shareTokenCache[shareId].stoken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveUrl = `${this.apiUrl}/share/sharepage/save?${this.pr}`;
|
||||||
|
const saveResult = await this.request(saveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
fid_list: [fileId],
|
||||||
|
fid_token_list: [fileToken],
|
||||||
|
to_pdir_fid: this.saveDirId,
|
||||||
|
pwd_id: shareId,
|
||||||
|
stoken: stoken,
|
||||||
|
pdir_fid: '0',
|
||||||
|
scene: 'link'
|
||||||
|
},
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '保存文件');
|
||||||
|
|
||||||
|
// 检查错误
|
||||||
|
if (saveResult.code && saveResult.code !== 200 && saveResult.code !== 0) {
|
||||||
|
log('保存', `失败: code=${saveResult.code}`);
|
||||||
|
return { error: true, code: saveResult.code, message: ERROR_CODES[saveResult.code]?.message || '保存文件失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.data?.task_resp?.code) {
|
||||||
|
return { error: true, code: saveResult.data.task_resp.code, message: saveResult.data.task_resp.message || '保存文件失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveResult.data?.task_id) {
|
||||||
|
if (saveResult.data.task_resp?.data?.save_as?.save_as_top_fids?.length > 0) {
|
||||||
|
return saveResult.data.task_resp.data.save_as.save_as_top_fids[0];
|
||||||
|
}
|
||||||
|
for (let retry = 0; retry < 10; retry++) {
|
||||||
|
await this.delay(1000);
|
||||||
|
const taskUrl = `${this.apiUrl}/task?task_id=${saveResult.data.task_id}&retry_index=${retry}&${this.pr}`;
|
||||||
|
const taskResult = await this.request(taskUrl, { method: 'GET', headers: this.getHeaders() }, 3, `查询保存任务-${retry+1}`);
|
||||||
|
if (taskResult.code && taskResult.code !== 200 && taskResult.code !== 0) {
|
||||||
|
return { error: true, code: taskResult.code, message: ERROR_CODES[taskResult.code]?.message || '查询任务状态失败' };
|
||||||
|
}
|
||||||
|
if (taskResult.data?.save_as?.save_as_top_fids?.length > 0) {
|
||||||
|
return taskResult.data.save_as.save_as_top_fids[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: true, code: -3, message: '保存文件超时,请稍后重试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新Token
|
||||||
|
*/
|
||||||
|
async refreshToken() {
|
||||||
|
if (this._isRefreshing) return await this._refreshPromise;
|
||||||
|
|
||||||
|
this._isRefreshing = true;
|
||||||
|
this._refreshPromise = (async () => {
|
||||||
|
const currentToken = this.token;
|
||||||
|
if (!currentToken) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString() + '000';
|
||||||
|
const deviceID = this.ucConfig.deviceID;
|
||||||
|
const reqId = this.generateReqId(deviceID, timestamp);
|
||||||
|
const data = {
|
||||||
|
req_id: reqId, app_ver: this.ucConfig.appVer, device_id: deviceID,
|
||||||
|
device_brand: "OPPO", platform: "tv", device_name: "PCRT00",
|
||||||
|
device_model: "PCRT00", build_device: "aosp", build_product: "PCRT00",
|
||||||
|
device_gpu: "Adreno%20(TM)%20640", activity_rect: "%7B%7D",
|
||||||
|
channel: this.ucConfig.channel, refresh_token: currentToken
|
||||||
|
};
|
||||||
|
const resp = await req('http://api.extscreen.com/ucdrive/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 7.1.2; zh-cn; PCRT00 Build/N2G47O) AppleWebKit/533.1 (KHTML, like Gecko) Mobile Safari/533.1',
|
||||||
|
'Connection': 'Keep-Alive', 'Content-Type': 'application/json',
|
||||||
|
'Cookie': 'sl-session=VIaxTAKF8mdJBhU2uda0zA=='
|
||||||
|
},
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
if (resp.code == 200 && resp.content) {
|
||||||
|
const result = JSON.parse(resp.content);
|
||||||
|
if (result.data?.access_token) {
|
||||||
|
this.token = result.data.access_token;
|
||||||
|
log('Token', `刷新成功`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._refreshPromise;
|
||||||
|
} finally {
|
||||||
|
this._isRefreshing = false;
|
||||||
|
this._refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新Cookie
|
||||||
|
*/
|
||||||
|
async refreshCookie() {
|
||||||
|
if (this._isRefreshing) return await this._refreshPromise;
|
||||||
|
|
||||||
|
this._isRefreshing = true;
|
||||||
|
this._refreshPromise = (async () => {
|
||||||
|
const currentCookie = this.cookie;
|
||||||
|
if (!currentCookie) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.request('https://pc-api.uc.cn/1/clouddrive/config?pr=UCBrowser&fr=pc', {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": this.baseHeader['User-Agent'],
|
||||||
|
Origin: 'https://drive.uc.cn',
|
||||||
|
Referer: 'https://drive.uc.cn/',
|
||||||
|
Cookie: currentCookie
|
||||||
|
}
|
||||||
|
}, 3, '刷新Cookie');
|
||||||
|
const setCookie = resp.headers?.['set-cookie'] || resp.headers?.['Set-Cookie'];
|
||||||
|
if (!setCookie) return false;
|
||||||
|
|
||||||
|
const cookieObject = {};
|
||||||
|
const cookieParts = Array.isArray(setCookie) ? setCookie : [setCookie];
|
||||||
|
for (const part of cookieParts) {
|
||||||
|
const match = part.match(/([^=;]+)=([^;]+)/);
|
||||||
|
if (match) cookieObject[match[1].trim()] = match[2].trim();
|
||||||
|
}
|
||||||
|
if (cookieObject.__puus) {
|
||||||
|
const oldCookies = {};
|
||||||
|
currentCookie.split(';').forEach(part => {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex > 0) oldCookies[trimmed.substring(0, eqIndex)] = trimmed.substring(eqIndex + 1);
|
||||||
|
});
|
||||||
|
const newCookie = Object.entries({
|
||||||
|
__pus: oldCookies.__pus, __puus: cookieObject.__puus,
|
||||||
|
}).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||||
|
this.cookie = newCookie;
|
||||||
|
log('Cookie', `刷新成功`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._refreshPromise;
|
||||||
|
} finally {
|
||||||
|
this._isRefreshing = false;
|
||||||
|
this._refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Token是否有效
|
||||||
|
*/
|
||||||
|
async validToken() {
|
||||||
|
if (!this.token) return false;
|
||||||
|
if (this.tokenExpireTime > Date.now()) return true;
|
||||||
|
log('Token', `已过期`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Cookie是否有效
|
||||||
|
*/
|
||||||
|
async validCookie() {
|
||||||
|
const currentCookie = this.cookie;
|
||||||
|
if (!currentCookie) return false;
|
||||||
|
try {
|
||||||
|
const resp = await this.request('https://pc-api.uc.cn/1/clouddrive/config?pr=UCBrowser&fr=pc', {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": this.baseHeader['User-Agent'],
|
||||||
|
Origin: 'https://drive.uc.cn',
|
||||||
|
Referer: 'https://drive.uc.cn/',
|
||||||
|
Cookie: currentCookie
|
||||||
|
}
|
||||||
|
}, 1, '验证Cookie');
|
||||||
|
if (resp.code == 200 || resp.status == 200) return true;
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保Token和Cookie有效
|
||||||
|
*/
|
||||||
|
async ensureTokenAndCookie() {
|
||||||
|
const needRefresh = () => {
|
||||||
|
if (this.lastRefreshTime === 0) return true;
|
||||||
|
if (Date.now() - this.lastRefreshTime >= this.refreshInterval) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!needRefresh()) {
|
||||||
|
const tokenValid = await this.validToken();
|
||||||
|
const cookieValid = await this.validCookie();
|
||||||
|
if (tokenValid && cookieValid) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await this.refreshToken();
|
||||||
|
const cookieResult = await this.refreshCookie();
|
||||||
|
|
||||||
|
if (tokenResult || cookieResult) {
|
||||||
|
this.lastRefreshTime = Date.now();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成设备ID
|
||||||
|
*/
|
||||||
|
generateDeviceID(timestamp) {
|
||||||
|
return CryptoJS.MD5(timestamp).toString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成请求ID
|
||||||
|
*/
|
||||||
|
generateReqId(deviceID, timestamp) {
|
||||||
|
return CryptoJS.MD5(deviceID + timestamp).toString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载链接
|
||||||
|
*/
|
||||||
|
async getDownload(shareId, stoken, fileId, fileToken, clean = false) {
|
||||||
|
// 先检查缓存
|
||||||
|
const cachedResult = this.getCachedUrl(shareId, fileId);
|
||||||
|
if (cachedResult) return cachedResult;
|
||||||
|
|
||||||
|
await this.ensureTokenAndCookie();
|
||||||
|
|
||||||
|
if (!this.saveFileIdCaches[fileId]) {
|
||||||
|
const saveResult = await this.save(shareId, stoken, fileId, fileToken, clean);
|
||||||
|
if (saveResult && saveResult.error) return saveResult;
|
||||||
|
if (!saveResult) return { error: true, code: -1, message: '文件转存失败' };
|
||||||
|
this.saveFileIdCaches[fileId] = saveResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
// 尝试获取无限画质链接
|
||||||
|
if (this.token) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString() + '000';
|
||||||
|
const deviceID = this.ucConfig.deviceID;
|
||||||
|
const reqId = this.generateReqId(deviceID, timestamp);
|
||||||
|
const x_pan_token = CryptoJS.SHA256('GET&/file&' + timestamp + '&' + this.ucConfig.signKey).toString();
|
||||||
|
const params = {
|
||||||
|
req_id: reqId, access_token: this.token, app_ver: this.ucConfig.appVer,
|
||||||
|
device_id: deviceID, device_brand: 'Xiaomi', platform: 'tv',
|
||||||
|
device_name: 'M2004J7AC', device_model: 'M2004J7AC',
|
||||||
|
build_device: 'M2004J7AC', build_product: 'M2004J7AC',
|
||||||
|
device_gpu: 'Adreno (TM) 550', activity_rect: '{}',
|
||||||
|
channel: this.ucConfig.channel, method: 'streaming',
|
||||||
|
group_by: 'source', fid: this.saveFileIdCaches[fileId],
|
||||||
|
resolution: 'low,normal,high,super,2k,4k', support: 'dolby_vision'
|
||||||
|
};
|
||||||
|
const urlParams = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
||||||
|
const url = `https://open-api-drive.uc.cn/file?${urlParams}`;
|
||||||
|
|
||||||
|
const response = await req(url, {
|
||||||
|
method: 'GET', headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 9; zh-cn; RMX1931 Build/PQ3A.190605.05081124) AppleWebKit/533.1 (KHTML, like Gecko) Mobile Safari/533.1',
|
||||||
|
'Connection': 'Keep-Alive', 'x-pan-tm': timestamp,
|
||||||
|
'x-pan-token': x_pan_token, 'content-type': 'text/plain;charset=UTF-8',
|
||||||
|
'x-pan-client-id': this.ucConfig.clientID, 'Cookie': this.cookie
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code == 200 && response.content) {
|
||||||
|
const jsonData = JSON.parse(response.content);
|
||||||
|
if (jsonData.data?.video_info) {
|
||||||
|
result = jsonData.data.video_info.map(item => ({ name: item.resolution, url: item.url }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级获取普通下载链接
|
||||||
|
if (!result) {
|
||||||
|
const url = `${this.apiUrl}/file/download?${this.pr}`;
|
||||||
|
const downResult = await this.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { fids: [this.saveFileIdCaches[fileId]] },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '获取下载链接');
|
||||||
|
|
||||||
|
if (downResult.code && downResult.code !== 200 && downResult.code !== 0) {
|
||||||
|
return { error: true, code: downResult.code, message: ERROR_CODES[downResult.code]?.message || '获取下载链接失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadData = downResult.data?.[0];
|
||||||
|
if (downloadData) {
|
||||||
|
result = [{ name: "原画", url: downloadData.download_url }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.setCachedUrl(shareId, fileId, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: true, code: -2, message: '获取下载链接失败,请稍后重试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直播转码播放地址
|
||||||
|
*/
|
||||||
|
async getLiveTranscoding(shareId, stoken, fileId, fileToken) {
|
||||||
|
// 先检查缓存
|
||||||
|
const cacheKey = `${shareId}_${fileId}_transcoding`;
|
||||||
|
const cachedResult = this.urlCache[cacheKey];
|
||||||
|
if (cachedResult && (Date.now() - cachedResult.timestamp) < this.cacheTTL) {
|
||||||
|
return cachedResult.urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 转存文件
|
||||||
|
const saveFileId = await this.save(shareId, stoken, fileId, fileToken, true);
|
||||||
|
if (!saveFileId || saveFileId.error) {
|
||||||
|
log('转码', `转存失败: ${saveFileId?.message || '未知错误'}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求转码画质
|
||||||
|
const url = `${this.apiUrl}/file/v2/play?${this.pr}`;
|
||||||
|
const result = await this.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
fid: saveFileId,
|
||||||
|
resolutions: 'normal,low,high,super,2k,4k',
|
||||||
|
supports: 'fmp4'
|
||||||
|
},
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '获取转码地址');
|
||||||
|
|
||||||
|
const videoList = result.data?.video_list || null;
|
||||||
|
if (videoList && videoList.length > 0) {
|
||||||
|
// 缓存播放地址
|
||||||
|
this.urlCache[cacheKey] = { urls: videoList, timestamp: Date.now() };
|
||||||
|
return videoList;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('转码', `异常: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
*/
|
||||||
|
async deleteFile(fileId) {
|
||||||
|
if (!fileId) return;
|
||||||
|
const deleteUrl = `${this.apiUrl}/file/delete?${this.pr}`;
|
||||||
|
await this.request(deleteUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { action_type: 2, filelist: [fileId], exclude_fids: [] },
|
||||||
|
headers: this.getHeaders()
|
||||||
|
}, 3, '删除临时文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取懒加载结果
|
||||||
|
*/
|
||||||
|
async getLazyResult(downCache, mediaProxyUrl) {
|
||||||
|
const urls = [];
|
||||||
|
if (Array.isArray(downCache)) {
|
||||||
|
downCache.forEach(it => {
|
||||||
|
if (it && it.url) urls.push(it.name, it.url + "#isVideo=true##fastPlayMode##threads=10#");
|
||||||
|
});
|
||||||
|
} else if (downCache?.download_url) {
|
||||||
|
urls.push("原画", downCache.download_url + "#isVideo=true##fastPlayMode##threads=10#");
|
||||||
|
} else if (downCache?.error) {
|
||||||
|
return { parse: 0, url: [], error: downCache.message };
|
||||||
|
}
|
||||||
|
return { parse: 0, url: urls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试URL支持性
|
||||||
|
*/
|
||||||
|
async testSupport(url, headers) {
|
||||||
|
try {
|
||||||
|
const resp = await req(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { ...this.baseHeader, ...headers, 'Range': 'bytes=0-0' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.code === 206 || resp.code === 200) {
|
||||||
|
const isAccept = resp.headers?.['accept-ranges'] === 'bytes';
|
||||||
|
const contentRange = resp.headers?.['content-range'];
|
||||||
|
const contentLength = parseInt(resp.headers?.['content-length'] || '0');
|
||||||
|
const isSupport = isAccept || !!contentRange || contentLength === 1 || resp.status === 200;
|
||||||
|
return [isSupport, resp.headers || {}];
|
||||||
|
}
|
||||||
|
return [false, null];
|
||||||
|
} catch {
|
||||||
|
return [false, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UC = new UCHandler();
|
||||||
Reference in New Issue
Block a user