deploy: sync server CloudSearch 0.5.6
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDateTime } from '../utils/time';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { createCloudDriver, supportsSaveFromShare } from './driver-factory';
|
||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||
import { lookupIpLocation } from './ip-lookup';
|
||||
import { notifyConfigEvent } from './notification.service';
|
||||
@@ -173,22 +172,13 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
try {
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||
|
||||
switch (cloudType) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle, retrySave);
|
||||
break;
|
||||
}
|
||||
case 'baidu': {
|
||||
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
case 'aliyun':
|
||||
return { success: false, message: '阿里云盘保存功能暂未实现' };
|
||||
default:
|
||||
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||
const driver = createCloudDriver(cloudType, { cookie: config.cookie!, nickname: config.nickname });
|
||||
if (!supportsSaveFromShare(driver)) {
|
||||
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||
}
|
||||
driverResult = cloudType === 'quark'
|
||||
? await driver.saveFromShare(shareUrl, sourceTitle, retrySave)
|
||||
: await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QUARK_PAN_HOST, EP as QE } from './drivers/quark-api';
|
||||
import { createCloudDriver, supportsValidate } from './driver-factory';
|
||||
import { getDb } from '../database/database';
|
||||
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
|
||||
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
@@ -237,11 +238,13 @@ export async function testCloudConnection(id: number): Promise<{
|
||||
let storageUsed = config.storage_used || '';
|
||||
let storageTotal = config.storage_total || '';
|
||||
|
||||
if (config.cloud_type === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
|
||||
const driver = createCloudDriver(config.cloud_type, { cookie: cookie, nickname: config.nickname }) as any;
|
||||
if (supportsValidate(driver)) {
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
if (config.cloud_type === 'baidu' && typeof driver.getUserInfo === 'function') {
|
||||
const info = await driver.getUserInfo();
|
||||
if (info) {
|
||||
nickname = config.nickname || info.nickname || '百度网盘';
|
||||
@@ -249,19 +252,24 @@ export async function testCloudConnection(id: number): Promise<{
|
||||
storageUsed = fmt(info.usedBytes);
|
||||
storageTotal = fmt(info.totalBytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
|
||||
valid = await driver.validate();
|
||||
if (valid) {
|
||||
} else if (config.cloud_type === 'quark') {
|
||||
nickname = config.nickname || (await fetchQuarkNickname(cookie)) || '夸克网盘';
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
|
||||
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||
if (typeof driver.getStorageInfoQuick === 'function') {
|
||||
const storage = await driver.getStorageInfoQuick();
|
||||
storageUsed = (storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
|
||||
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||
}
|
||||
} else if (config.cloud_type === 'uc') {
|
||||
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || 'UC网盘';
|
||||
} else if (config.cloud_type === 'aliyun') {
|
||||
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || '阿里云盘';
|
||||
}
|
||||
}
|
||||
|
||||
if (valid && !nickname) {
|
||||
nickname = config.nickname || (typeof driver.getNickname === 'function' ? await driver.getNickname() : '') || config.cloud_type;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
if (!valid) {
|
||||
db.prepare(
|
||||
@@ -342,6 +350,18 @@ export async function testCloudConnectionWithCookie(cloudType: string, cookie: s
|
||||
message: nickname ? '连接成功' : '连接成功(无法获取昵称)',
|
||||
nickname: nickname || '阿里云盘',
|
||||
};
|
||||
} else if (cloudType === 'uc') {
|
||||
const driver = createCloudDriver('uc', { cookie, nickname: '' }) as any;
|
||||
const valid = supportsValidate(driver) ? await driver.validate() : false;
|
||||
if (!valid) {
|
||||
return { success: false, message: '连接失败:UC Cookie 无效或长度不足' };
|
||||
}
|
||||
const nickname = typeof driver.getNickname === 'function' ? await driver.getNickname() : 'UC网盘';
|
||||
return {
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname: nickname || 'UC网盘',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
@@ -405,14 +425,11 @@ export async function getAndValidateCredential(cloudType: string): Promise<Crede
|
||||
|
||||
try {
|
||||
let cookieValid = false;
|
||||
if (cloudType === 'baidu') {
|
||||
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||
const driver = new BaiduDriver({ cookie: cookie, nickname: config.nickname });
|
||||
cookieValid = await driver.validate();
|
||||
} else {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: cookie, nickname: config.nickname });
|
||||
const driver = createCloudDriver(cloudType, { cookie: cookie, nickname: config.nickname });
|
||||
if (supportsValidate(driver)) {
|
||||
cookieValid = await driver.validate();
|
||||
} else if (cloudType === 'aliyun') {
|
||||
cookieValid = true;
|
||||
}
|
||||
|
||||
if (!cookieValid) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
let tempDir = '';
|
||||
|
||||
async function loadCredentialService() {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudsearch-credential-'));
|
||||
process.env.DB_PATH = path.join(tempDir, 'cloudsearch.db');
|
||||
process.env.DATA_DIR = tempDir;
|
||||
process.env.COOKIE_ENCRYPTION_KEY = 'test-cookie-encryption-key-000000000000';
|
||||
const service = await import('./credential.service');
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('credential service UC integration', () => {
|
||||
beforeEach(async () => {
|
||||
await import('vitest').then(({ vi }) => vi.resetModules());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await import('vitest').then(({ vi }) => vi.resetModules());
|
||||
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
delete process.env.DB_PATH;
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.COOKIE_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('tests a saved Xunlei cloud config through the Xunlei driver instead of the unsupported fallback', async () => {
|
||||
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
|
||||
const saved = saveCloudConfig({
|
||||
cloud_type: 'xunlei',
|
||||
cookie: 'r'.repeat(32),
|
||||
nickname: 'Xunlei Account',
|
||||
is_active: 1,
|
||||
});
|
||||
|
||||
const result = await testCloudConnection(saved.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname: 'Xunlei Account',
|
||||
});
|
||||
});
|
||||
|
||||
it('tests a saved UC cloud config through the UC driver instead of the Quark fallback', async () => {
|
||||
const { saveCloudConfig, testCloudConnection } = await loadCredentialService();
|
||||
const saved = saveCloudConfig({
|
||||
cloud_type: 'uc',
|
||||
cookie: 'k=' + 'x'.repeat(80),
|
||||
nickname: 'UC Account',
|
||||
is_active: 1,
|
||||
});
|
||||
|
||||
const result = await testCloudConnection(saved.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
message: '连接成功',
|
||||
nickname: 'UC Account',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const cloudDir = path.resolve(__dirname);
|
||||
const ucDir = path.join(cloudDir, 'drivers', 'uc');
|
||||
const xunleiDir = path.join(cloudDir, 'drivers', 'xunlei');
|
||||
const legacyUcFiles = [
|
||||
path.join(cloudDir, 'drivers', 'uc.driver.ts'),
|
||||
path.join(cloudDir, 'drivers', 'uc-api.ts'),
|
||||
];
|
||||
|
||||
function readTsFiles(dir: string): Array<{ file: string; content: string }> {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const files: Array<{ file: string; content: string }> = [];
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...readTsFiles(full));
|
||||
if (entry.isFile() && full.endsWith('.ts')) files.push({ file: full, content: fs.readFileSync(full, 'utf8') });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('cloud driver module boundaries', () => {
|
||||
it('keeps UC implementation in its own driver folder', () => {
|
||||
expect(fs.existsSync(path.join(ucDir, 'driver.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(ucDir, 'api.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not keep UC implementation in the flat drivers directory', () => {
|
||||
for (const file of legacyUcFiles) {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
expect(content.trim()).toMatch(/^export \* from '\.\/uc\//);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let the UC module import or require Quark modules', () => {
|
||||
const offenders = readTsFiles(ucDir).filter(({ content }) => /from ['"].*quark|require\(['"].*quark/.test(content));
|
||||
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps Xunlei implementation in its own driver folder', () => {
|
||||
expect(fs.existsSync(path.join(xunleiDir, 'driver.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(xunleiDir, 'api.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not let the Xunlei module import or require Quark/UC modules', () => {
|
||||
const offenders = readTsFiles(xunleiDir).filter(({ content }) => /from ['"].*(quark|uc)|require\(['"].*(quark|uc)/.test(content));
|
||||
expect(offenders.map(({ file }) => path.relative(cloudDir, file))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createCloudDriver } from './driver-factory';
|
||||
import { UcDriver } from './drivers/uc/driver';
|
||||
import { XunleiDriver } from './drivers/xunlei/driver';
|
||||
|
||||
describe('createCloudDriver', () => {
|
||||
it('creates a UC driver inside the main app process', () => {
|
||||
const driver = createCloudDriver('uc', { cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' });
|
||||
|
||||
expect(driver).toBeInstanceOf(UcDriver);
|
||||
});
|
||||
|
||||
it('creates a Xunlei driver inside the main app process', () => {
|
||||
const driver = createCloudDriver('xunlei', { refreshToken: 'r'.repeat(32), nickname: 'xl-test' });
|
||||
|
||||
expect(driver).toBeInstanceOf(XunleiDriver);
|
||||
});
|
||||
|
||||
it('returns null for unsupported cloud types', () => {
|
||||
expect(createCloudDriver('unsupported-drive', { cookie: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QuarkDriver, QuarkConfig } from './drivers/quark.driver';
|
||||
import { BaiduDriver, BaiduConfig } from './drivers/baidu.driver';
|
||||
import { AliyunDriver, AliyunConfig } from './drivers/aliyun.driver';
|
||||
import { UcDriver, UcConfig } from './drivers/uc/driver';
|
||||
import { XunleiDriver, XunleiConfig } from './drivers/xunlei/driver';
|
||||
|
||||
export type CloudDriver = QuarkDriver | BaiduDriver | AliyunDriver | UcDriver | XunleiDriver;
|
||||
export type CloudDriverConfig = QuarkConfig | BaiduConfig | AliyunConfig | UcConfig | XunleiConfig;
|
||||
|
||||
export function createCloudDriver(cloudType: string, config: CloudDriverConfig): CloudDriver | null {
|
||||
switch (cloudType) {
|
||||
case 'quark':
|
||||
return new QuarkDriver(config as QuarkConfig);
|
||||
case 'baidu':
|
||||
return new BaiduDriver(config as BaiduConfig);
|
||||
case 'aliyun':
|
||||
return new AliyunDriver(config as AliyunConfig);
|
||||
case 'uc':
|
||||
return new UcDriver(config as UcConfig);
|
||||
case 'xunlei':
|
||||
return new XunleiDriver(config as XunleiConfig);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsSaveFromShare(driver: CloudDriver | null): driver is CloudDriver & { saveFromShare: (...args: any[]) => Promise<any> } {
|
||||
return !!driver && typeof (driver as any).saveFromShare === 'function';
|
||||
}
|
||||
|
||||
export function supportsValidate(driver: CloudDriver | null): driver is CloudDriver & { validate: () => Promise<boolean> } {
|
||||
return !!driver && typeof (driver as any).validate === 'function';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './uc/api';
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseUcShareUrl, UC_API_BASE, UC_SHARE_API } from './uc/api';
|
||||
import { UcDriver } from './uc/driver';
|
||||
|
||||
class MockResponse {
|
||||
ok = true;
|
||||
status = 200;
|
||||
constructor(private payload: any) {}
|
||||
async json() { return this.payload; }
|
||||
}
|
||||
|
||||
describe('UcDriver', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('parses UC share ids from drive.uc.cn links', () => {
|
||||
expect(parseUcShareUrl('https://drive.uc.cn/s/abcdef')).toBe('abcdef');
|
||||
expect(parseUcShareUrl('https://example.com/s/abcdef')).toBeNull();
|
||||
});
|
||||
|
||||
it('validates cookie presence with the same lightweight rule used by transfer reference', async () => {
|
||||
await expect(new UcDriver({ cookie: 'too-short' }).validate()).resolves.toBe(false);
|
||||
await expect(new UcDriver({ cookie: 'k=' + 'x'.repeat(80) }).validate()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('runs the UC save/share API flow and returns CloudSearch SaveResult shape', async () => {
|
||||
const calls: Array<{ url: string; method?: string; body?: any }> = [];
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: any, init: any = {}) => {
|
||||
const url = String(input);
|
||||
const body = init.body ? JSON.parse(String(init.body)) : undefined;
|
||||
calls.push({ url, method: init.method, body });
|
||||
|
||||
if (url.startsWith(`${UC_SHARE_API}/sharepage/v2/detail`)) {
|
||||
return new MockResponse({ status: 0, data: { token_info: { stoken: 'stoken-1' } } }) as any;
|
||||
}
|
||||
if (url.startsWith(`${UC_SHARE_API}/sharepage/detail`)) {
|
||||
return new MockResponse({ status: 0, data: { title: 'Demo Folder', fid: 'src-fid', share_fid_token: 'src-token' } }) as any;
|
||||
}
|
||||
if (url.startsWith(`${UC_SHARE_API}/sharepage/save`)) {
|
||||
expect(body).toMatchObject({
|
||||
fid_list: ['src-fid'],
|
||||
fid_token_list: ['src-token'],
|
||||
to_pdir_fid: '0',
|
||||
pwd_id: 'abcdef',
|
||||
stoken: 'stoken-1',
|
||||
});
|
||||
return new MockResponse({ status: 0, data: { task_id: 'save-task' } }) as any;
|
||||
}
|
||||
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('save-task')) {
|
||||
return new MockResponse({ status: 200, data: { status: 2, save_as: { save_as_top_fids: ['new-fid'] } } }) as any;
|
||||
}
|
||||
if (url === UC_SHARE_API) {
|
||||
expect(body).toMatchObject({ fid_list: ['new-fid'], title: 'Demo Folder' });
|
||||
return new MockResponse({ status: 0, data: { task_id: 'share-task' } }) as any;
|
||||
}
|
||||
if (url.startsWith(`${UC_API_BASE}/1/clouddrive/task`) && url.includes('share-task')) {
|
||||
return new MockResponse({ status: 200, data: { status: 2, share_id: 'new-share-id' } }) as any;
|
||||
}
|
||||
if (url.startsWith(`${UC_SHARE_API}/password`)) {
|
||||
expect(body).toMatchObject({ share_id: 'new-share-id' });
|
||||
return new MockResponse({ status: 0, data: { share_url: 'https://drive.uc.cn/s/newshare', passcode: 'pw12' } }) as any;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const result = await new UcDriver({ cookie: 'k=' + 'x'.repeat(80), nickname: 'uc-test' }).saveFromShare('https://drive.uc.cn/s/abcdef');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
message: '转存成功',
|
||||
shareUrl: 'https://drive.uc.cn/s/newshare',
|
||||
sharePwd: 'pw12',
|
||||
folderName: 'Demo Folder',
|
||||
fileCount: 1,
|
||||
folderCount: 0,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(7);
|
||||
expect(calls.map(c => c.url)).toContain(UC_SHARE_API);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './uc/driver';
|
||||
@@ -0,0 +1,44 @@
|
||||
// UC 网盘 API 常量与轻量工具
|
||||
|
||||
export const UC_API_BASE = 'https://pc-api.uc.cn';
|
||||
export const UC_WEB_HOST = 'https://drive.uc.cn';
|
||||
export const UC_SHARE_API = `${UC_API_BASE}/1/clouddrive/share`;
|
||||
|
||||
export const UC_EP = {
|
||||
SHARE_PAGE_TOKEN: '/sharepage/v2/detail',
|
||||
SHARE_PAGE_DETAIL: '/sharepage/detail',
|
||||
SHARE_PAGE_SAVE: '/sharepage/save',
|
||||
SHARE_PASSWORD: '/password',
|
||||
TASK: '/1/clouddrive/task',
|
||||
} as const;
|
||||
|
||||
export function getUcHeaders(cookie: string): Record<string, string> {
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Cookie': cookie,
|
||||
'Referer': `${UC_WEB_HOST}/`,
|
||||
'Origin': UC_WEB_HOST,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUcShareUrl(shareUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(shareUrl);
|
||||
if (!url.hostname.includes('drive.uc.cn')) return null;
|
||||
const match = url.pathname.match(/\/s\/([a-zA-Z0-9_-]+)/);
|
||||
return match?.[1] || null;
|
||||
} catch {
|
||||
const match = shareUrl.match(/drive\.uc\.cn\/s\/([a-zA-Z0-9_-]+)/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUcCommonParams(extra: Record<string, string | number> = {}): URLSearchParams {
|
||||
return new URLSearchParams({
|
||||
pr: 'UCBrowser',
|
||||
fr: 'pc',
|
||||
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { getUcCommonParams, getUcHeaders, parseUcShareUrl, UC_API_BASE, UC_EP, UC_SHARE_API } from './api';
|
||||
|
||||
export interface UcConfig {
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
interface UcDetail {
|
||||
title?: string;
|
||||
fid?: string;
|
||||
fid_list?: string[];
|
||||
share_fid_token?: string;
|
||||
fid_token_list?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface UcDriverResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
shareUrl?: string;
|
||||
sharePwd?: string;
|
||||
folderName?: string;
|
||||
fileCount?: number;
|
||||
folderCount?: number;
|
||||
}
|
||||
|
||||
export class UcDriver {
|
||||
private config: UcConfig;
|
||||
|
||||
constructor(config: UcConfig = {}) {
|
||||
this.config = { ...config };
|
||||
}
|
||||
|
||||
private get cookie(): string {
|
||||
return this.config.cookie || '';
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
return this.cookie.length >= 50;
|
||||
}
|
||||
|
||||
async getNickname(): Promise<string | null> {
|
||||
return this.config.nickname || 'UC网盘';
|
||||
}
|
||||
|
||||
private headers(json = false): Record<string, string> {
|
||||
return {
|
||||
...getUcHeaders(this.cookie),
|
||||
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async readJson(response: Response, context: string): Promise<any> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${context}失败: HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private apiOk(data: any): boolean {
|
||||
return data?.status === 0 || data?.status === 200 || data?.code === 0;
|
||||
}
|
||||
|
||||
private async getStoken(pwdId: string, passcode = ''): Promise<string> {
|
||||
const url = `${UC_SHARE_API}${UC_EP.SHARE_PAGE_TOKEN}?${getUcCommonParams().toString()}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(true),
|
||||
body: JSON.stringify({ passcode, pwd_id: pwdId }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '获取UC stoken');
|
||||
const stoken = data?.data?.token_info?.stoken;
|
||||
if (!stoken) throw new Error(`获取UC stoken失败: ${data?.message || 'stoken缺失'}`);
|
||||
return stoken;
|
||||
}
|
||||
|
||||
private async getDetail(pwdId: string, stoken: string): Promise<UcDetail> {
|
||||
const params = getUcCommonParams({ pwd_id: pwdId, stoken, _fetch_share: '1' });
|
||||
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_DETAIL}?${params.toString()}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '获取UC分享详情');
|
||||
if (!this.apiOk(data) || !data?.data) {
|
||||
throw new Error(`获取UC分享详情失败: ${data?.message || '详情为空'}`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
private normalizeDetailLists(detail: UcDetail): { fidList: string[]; fidTokenList: string[] } {
|
||||
let fidList = detail.fid_list || (detail.fid ? [detail.fid] : []);
|
||||
let fidTokenList = detail.fid_token_list || (detail.share_fid_token ? [detail.share_fid_token] : []);
|
||||
if (!Array.isArray(fidList)) fidList = fidList ? [fidList] : [];
|
||||
if (!Array.isArray(fidTokenList)) fidTokenList = fidTokenList ? [fidTokenList] : [];
|
||||
return { fidList, fidTokenList };
|
||||
}
|
||||
|
||||
private async initSave(pwdId: string, stoken: string, detail: UcDetail, toPdirFid = '0'): Promise<string> {
|
||||
const { fidList, fidTokenList } = this.normalizeDetailLists(detail);
|
||||
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PAGE_SAVE}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(true),
|
||||
body: JSON.stringify({
|
||||
fid_list: fidList,
|
||||
fid_token_list: fidTokenList,
|
||||
to_pdir_fid: toPdirFid,
|
||||
pwd_id: pwdId,
|
||||
stoken,
|
||||
pdir_fid: '0',
|
||||
scene: 'link',
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const data = await this.readJson(response, '发起UC转存');
|
||||
if (!this.apiOk(data) || !data?.data?.task_id) {
|
||||
throw new Error(`发起UC转存失败: ${data?.message || 'task_id缺失'}`);
|
||||
}
|
||||
return data.data.task_id;
|
||||
}
|
||||
|
||||
private async pollTask(taskId: string, mode: 'save' | 'share'): Promise<any> {
|
||||
for (let retryIndex = 0; retryIndex < 50; retryIndex++) {
|
||||
const params = getUcCommonParams({ task_id: taskId, retry_index: retryIndex });
|
||||
const response = await fetch(`${UC_API_BASE}${UC_EP.TASK}?${params.toString()}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const data = await this.readJson(response, '查询UC任务');
|
||||
const status = data?.data?.status;
|
||||
if (status === 2) return data.data;
|
||||
if (status === -1 || status === 3) throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务失败: ${data?.message || taskId}`);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`UC${mode === 'save' ? '转存' : '分享'}任务超时: ${taskId}`);
|
||||
}
|
||||
|
||||
private async initShare(fileIds: string[], title: string): Promise<string> {
|
||||
const response = await fetch(UC_SHARE_API, {
|
||||
method: 'POST',
|
||||
headers: this.headers(true),
|
||||
body: JSON.stringify({ fid_list: fileIds, title: title || '分享', expired_type: 1 }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '创建UC分享');
|
||||
if (!this.apiOk(data) || !data?.data?.task_id) {
|
||||
throw new Error(`创建UC分享失败: ${data?.message || 'task_id缺失'}`);
|
||||
}
|
||||
return data.data.task_id;
|
||||
}
|
||||
|
||||
private async setPassword(shareId: string, password = ''): Promise<{ shareUrl: string; passcode: string }> {
|
||||
const response = await fetch(`${UC_SHARE_API}${UC_EP.SHARE_PASSWORD}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(true),
|
||||
body: JSON.stringify({ share_id: shareId }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '设置UC分享密码');
|
||||
if (!this.apiOk(data)) {
|
||||
throw new Error(`设置UC分享密码失败: ${data?.message || 'API错误'}`);
|
||||
}
|
||||
return {
|
||||
shareUrl: data?.data?.share_url || `https://drive.uc.cn/s/${shareId}`,
|
||||
passcode: data?.data?.passcode || password || '',
|
||||
};
|
||||
}
|
||||
|
||||
async saveFromShare(shareUrl: string, _sourceTitle?: string): Promise<UcDriverResult> {
|
||||
if (!(await this.validate())) {
|
||||
return { success: false, message: 'UC Cookie 无效或长度不足' };
|
||||
}
|
||||
|
||||
const pwdId = parseUcShareUrl(shareUrl);
|
||||
if (!pwdId) {
|
||||
return { success: false, message: '无法解析UC分享链接' };
|
||||
}
|
||||
|
||||
try {
|
||||
const stoken = await this.getStoken(pwdId);
|
||||
const detail = await this.getDetail(pwdId, stoken);
|
||||
const saveTaskId = await this.initSave(pwdId, stoken, detail, '0');
|
||||
const saveTask = await this.pollTask(saveTaskId, 'save');
|
||||
const newFileIds: string[] = saveTask?.save_as?.save_as_top_fids || [];
|
||||
if (!newFileIds.length) throw new Error('UC转存完成但未返回文件ID');
|
||||
|
||||
const title = detail.title || _sourceTitle || '分享';
|
||||
const shareTaskId = await this.initShare(newFileIds, title);
|
||||
const shareTask = await this.pollTask(shareTaskId, 'share');
|
||||
const shareId = shareTask?.share_id || shareTask?.result?.share_id;
|
||||
if (!shareId) throw new Error('UC分享任务完成但未返回share_id');
|
||||
|
||||
const shared = await this.setPassword(shareId);
|
||||
return {
|
||||
success: true,
|
||||
message: '转存成功',
|
||||
shareUrl: shared.shareUrl,
|
||||
sharePwd: shared.passcode,
|
||||
folderName: title,
|
||||
fileCount: newFileIds.length,
|
||||
folderCount: 0,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err?.message || 'UC转存失败' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseXunleiShareUrl, getXunleiHeaders } from './xunlei/api';
|
||||
import { XunleiDriver } from './xunlei/driver';
|
||||
|
||||
describe('Xunlei cloud driver', () => {
|
||||
it('parses common Xunlei share URLs', () => {
|
||||
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz?pwd=7k9m')).toEqual({ shareId: 'VNabc123xyz', passcode: '7k9m' });
|
||||
expect(parseXunleiShareUrl('https://pan.xunlei.com/s/VNabc123xyz')).toEqual({ shareId: 'VNabc123xyz', passcode: '' });
|
||||
});
|
||||
|
||||
it('rejects non-Xunlei share URLs', () => {
|
||||
expect(parseXunleiShareUrl('https://drive.uc.cn/s/abcdef')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds bearer headers from refresh-token derived access tokens', () => {
|
||||
expect(getXunleiHeaders('access-token-1').Authorization).toBe('Bearer access-token-1');
|
||||
});
|
||||
|
||||
it('validates refresh token presence without doing network IO', async () => {
|
||||
await expect(new XunleiDriver({ refreshToken: '' }).validate()).resolves.toBe(false);
|
||||
await expect(new XunleiDriver({ refreshToken: 'r'.repeat(32), nickname: 'xl-test' }).validate()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './xunlei/driver';
|
||||
@@ -0,0 +1,50 @@
|
||||
// 迅雷网盘 API 常量与轻量工具
|
||||
|
||||
export const XUNLEI_PAN_API = 'https://api-pan.xunlei.com';
|
||||
export const XUNLEI_AUTH_API = 'https://xluser-ssl.xunlei.com';
|
||||
export const XUNLEI_WEB_HOST = 'https://pan.xunlei.com';
|
||||
|
||||
export const XUNLEI_CLIENT_ID = 'Xqp0kJBXWhwaTpB6';
|
||||
export const XUNLEI_DEVICE_ID = '925b7631473a13716b791d7f28289cad';
|
||||
|
||||
export const XUNLEI_EP = {
|
||||
SHARE_INFO: '/drive/v1/share',
|
||||
SHARE_RESTORE: '/drive/v1/share/restore',
|
||||
TASK: '/drive/v1/tasks',
|
||||
CREATE_SHARE: '/drive/v1/share',
|
||||
FILES: '/drive/v1/files',
|
||||
TOKEN: '/v1/auth/token',
|
||||
} as const;
|
||||
|
||||
export interface XunleiShareParts {
|
||||
shareId: string;
|
||||
passcode: string;
|
||||
}
|
||||
|
||||
export function parseXunleiShareUrl(shareUrl: string): XunleiShareParts | null {
|
||||
try {
|
||||
const url = new URL(shareUrl);
|
||||
if (!url.hostname.includes('pan.xunlei.com')) return null;
|
||||
const match = url.pathname.match(/\/s\/([A-Za-z0-9_-]+)/);
|
||||
if (!match) return null;
|
||||
return { shareId: match[1], passcode: url.searchParams.get('pwd') || '' };
|
||||
} catch {
|
||||
const match = shareUrl.match(/pan\.xunlei\.com\/s\/([A-Za-z0-9_-]+)/);
|
||||
if (!match) return null;
|
||||
const pwd = shareUrl.match(/[?&]pwd=([A-Za-z0-9_-]+)/)?.[1] || '';
|
||||
return { shareId: match[1], passcode: pwd };
|
||||
}
|
||||
}
|
||||
|
||||
export function getXunleiHeaders(accessToken = '', captchaToken = ''): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'x-client-id': XUNLEI_CLIENT_ID,
|
||||
'x-device-id': XUNLEI_DEVICE_ID,
|
||||
};
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
||||
if (captchaToken) headers['x-captcha-token'] = captchaToken;
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
getXunleiHeaders,
|
||||
parseXunleiShareUrl,
|
||||
XUNLEI_AUTH_API,
|
||||
XUNLEI_CLIENT_ID,
|
||||
XUNLEI_EP,
|
||||
XUNLEI_PAN_API,
|
||||
} from './api';
|
||||
|
||||
export interface XunleiConfig {
|
||||
refreshToken?: string;
|
||||
cookie?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
interface XunleiDriverResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
shareUrl?: string;
|
||||
sharePwd?: string;
|
||||
folderName?: string;
|
||||
fileCount?: number;
|
||||
folderCount?: number;
|
||||
}
|
||||
|
||||
interface XunleiShareInfo {
|
||||
pass_code_token?: string;
|
||||
files?: Array<{ id?: string; file_id?: string; name?: string; kind?: string; is_dir?: boolean }>;
|
||||
title?: string;
|
||||
share_name?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class XunleiDriver {
|
||||
private config: XunleiConfig;
|
||||
private accessToken = '';
|
||||
private accessTokenExpiresAt = 0;
|
||||
|
||||
constructor(config: XunleiConfig = {}) {
|
||||
this.config = { ...config };
|
||||
}
|
||||
|
||||
private get refreshToken(): string {
|
||||
return (this.config.refreshToken || this.config.cookie || '').trim();
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
// 测试环境先做离线格式校验,真实 API 调用在 saveFromShare 时换取 access_token。
|
||||
return this.refreshToken.length >= 20;
|
||||
}
|
||||
|
||||
async getNickname(): Promise<string | null> {
|
||||
return this.config.nickname || '迅雷网盘';
|
||||
}
|
||||
|
||||
private async readJson(response: Response, context: string): Promise<any> {
|
||||
if (!response.ok) throw new Error(`${context}失败: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private apiOk(data: any): boolean {
|
||||
return data?.errcode === 0 || data?.error_code === 0 || data?.code === 0 || (!data?.errcode && !data?.error_code && !data?.code);
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.accessTokenExpiresAt - 60000) return this.accessToken;
|
||||
if (!(await this.validate())) throw new Error('迅雷 refresh_token 无效或长度不足');
|
||||
|
||||
const response = await fetch(`${XUNLEI_AUTH_API}${XUNLEI_EP.TOKEN}`, {
|
||||
method: 'POST',
|
||||
headers: getXunleiHeaders(),
|
||||
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: XUNLEI_CLIENT_ID }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '刷新迅雷 access_token');
|
||||
if (!data?.access_token) throw new Error(`刷新迅雷 access_token失败: ${data?.message || data?.error || 'access_token缺失'}`);
|
||||
this.accessToken = data.access_token;
|
||||
this.accessTokenExpiresAt = Date.now() + Number(data.expires_in || 7200) * 1000;
|
||||
if (data.refresh_token) this.config.refreshToken = data.refresh_token;
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async headers(): Promise<Record<string, string>> {
|
||||
return getXunleiHeaders(await this.getAccessToken());
|
||||
}
|
||||
|
||||
private async getShareInfo(shareId: string, passcode = ''): Promise<XunleiShareInfo> {
|
||||
const params = new URLSearchParams({ share_id: shareId });
|
||||
if (passcode) params.set('pass_code', passcode);
|
||||
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_INFO}?${params.toString()}`, {
|
||||
headers: await this.headers(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '获取迅雷分享详情');
|
||||
if (!this.apiOk(data)) throw new Error(`获取迅雷分享详情失败: ${data?.message || data?.error || 'API错误'}`);
|
||||
const files = data.files || data.data?.files || [];
|
||||
if (!files.length) throw new Error('迅雷分享内容为空');
|
||||
return { ...data, ...(data.data || {}), files };
|
||||
}
|
||||
|
||||
private extractFileIds(files: XunleiShareInfo['files']): string[] {
|
||||
return (files || []).map(file => file.id || file.file_id || '').filter(Boolean);
|
||||
}
|
||||
|
||||
private async restoreFiles(shareId: string, passCodeToken: string, fileIds: string[]): Promise<string> {
|
||||
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.SHARE_RESTORE}`, {
|
||||
method: 'POST',
|
||||
headers: await this.headers(),
|
||||
body: JSON.stringify({ file_ids: fileIds, pass_code_token: passCodeToken, share_id: shareId, parent_id: '', specify_parent_id: true }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const data = await this.readJson(response, '发起迅雷转存');
|
||||
if (!this.apiOk(data)) throw new Error(`发起迅雷转存失败: ${data?.message || data?.error || 'API错误'}`);
|
||||
const taskId = data.restore_task_id || data.task_id || data.data?.restore_task_id || data.data?.task_id;
|
||||
if (!taskId) throw new Error('发起迅雷转存失败: task_id缺失');
|
||||
return taskId;
|
||||
}
|
||||
|
||||
private parseTraceFileIds(trace: any): Record<string, string> {
|
||||
if (!trace) return {};
|
||||
const parsed = typeof trace === 'string' ? JSON.parse(trace || '{}') : trace;
|
||||
return Object.fromEntries(Object.entries(parsed).map(([oldId, value]) => [oldId, typeof value === 'string' ? value : (value as any)?.id || (value as any)?.file_id || '']).filter(([, newId]) => !!newId));
|
||||
}
|
||||
|
||||
private async pollRestoreTask(taskId: string): Promise<string[]> {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.TASK}/${taskId}`, {
|
||||
headers: await this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const data = await this.readJson(response, '查询迅雷转存任务');
|
||||
if (data.status === 'failed' || data.status === 'error') throw new Error(`迅雷转存任务失败: ${taskId}`);
|
||||
if (data.progress === 100 || data.status === 'success' || data.status === 'complete') {
|
||||
const mapping = this.parseTraceFileIds(data.params?.trace_file_ids || data.trace_file_ids || data.data?.params?.trace_file_ids);
|
||||
const ids = Object.values(mapping);
|
||||
if (ids.length) return ids;
|
||||
if (Array.isArray(data.file_ids)) return data.file_ids;
|
||||
if (Array.isArray(data.data?.file_ids)) return data.data.file_ids;
|
||||
throw new Error('迅雷转存完成但未返回文件ID');
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`迅雷转存任务超时: ${taskId}`);
|
||||
}
|
||||
|
||||
private async createShare(fileIds: string[], title: string): Promise<{ shareUrl: string; passcode: string }> {
|
||||
const response = await fetch(`${XUNLEI_PAN_API}${XUNLEI_EP.CREATE_SHARE}`, {
|
||||
method: 'POST',
|
||||
headers: await this.headers(),
|
||||
body: JSON.stringify({ file_ids: fileIds, title: title || '分享' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const data = await this.readJson(response, '创建迅雷分享');
|
||||
if (!this.apiOk(data)) throw new Error(`创建迅雷分享失败: ${data?.message || data?.error || 'API错误'}`);
|
||||
const payload = data.data || data;
|
||||
const shareUrl = payload.share_url || payload.url;
|
||||
if (!shareUrl) throw new Error('创建迅雷分享失败: share_url缺失');
|
||||
return { shareUrl, passcode: payload.pass_code || payload.passcode || '' };
|
||||
}
|
||||
|
||||
async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<XunleiDriverResult> {
|
||||
if (!(await this.validate())) return { success: false, message: '迅雷 refresh_token 无效或长度不足' };
|
||||
const parsed = parseXunleiShareUrl(shareUrl);
|
||||
if (!parsed) return { success: false, message: '无法解析迅雷分享链接' };
|
||||
|
||||
try {
|
||||
const info = await this.getShareInfo(parsed.shareId, parsed.passcode);
|
||||
const sourceFileIds = this.extractFileIds(info.files);
|
||||
if (!sourceFileIds.length) throw new Error('无法从迅雷分享中提取文件ID');
|
||||
const restoreTaskId = await this.restoreFiles(parsed.shareId, info.pass_code_token || '', sourceFileIds);
|
||||
const savedFileIds = await this.pollRestoreTask(restoreTaskId);
|
||||
const title = info.title || info.share_name || sourceTitle || '分享';
|
||||
const shared = await this.createShare(savedFileIds, title);
|
||||
return { success: true, message: '转存成功', shareUrl: shared.shareUrl, sharePwd: shared.passcode, folderName: title, fileCount: savedFileIds.length, folderCount: 0 };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err?.message || '迅雷转存失败' };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user