Files
CloudSearch/packages/backend/src/cloud/drivers/baidu.driver.ts

1190 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Baidu Netdisk Driver v4 — Cookie-based (Playwright QR login + HTTP API)
// Uses full browser Cookie string for all operations (no OAuth access_token needed).
// Share operations use internal web API (/share/verify, /share/transfer, parse HTML).
// Reference: https://github.com/hxz393/BaiduPanFilesTransfers
//
// v4 changes from v3:
// - QR login via Playwright browser → captures full Cookie string (BDUSS + BAIDUID + STOKEN + ...)
// - getShareFiles uses Cookie HTTP: getbdstoken → verify password → GET share page → regex parse
// - transferFiles uses Cookie HTTP: POST /share/transfer
// - File list/create/delete use Cookie-based /api/* endpoints with bdstoken
// - Removed OAuth Device Code flow (no more access_token)
import type { Browser, BrowserContext, Page } from 'playwright';
export interface BaiduConfig {
cookie?: string; // Full Cookie string: "BDUSS=xxx; BAIDUID=yyy; STOKEN=zzz; ..."
bdstoken?: string; // Cached bdstoken from /api/gettemplatevariable
cookieExpired?: boolean; // Flag set when cookie validation fails
nickname?: string;
}
interface ShareFileInfo {
server_filename: string;
fs_id: string;
isdir: number;
size: number;
path: string;
category: number;
}
interface ShareDetail {
files: ShareFileInfo[];
childFiles: ShareFileInfo[] | null;
}
// ═══════════════════════════════════
// Constants
// ═══════════════════════════════════
const API_HOST = "https://pan.baidu.com";
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser";
const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers
// HTTP headers matching BaiduPanFilesTransfers
const WEB_HEADERS: Record<string, string> = {
'Host': 'pan.baidu.com',
'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'Referer': 'https://pan.baidu.com',
};
function buildHeaders(cookie: string): Record<string, string> {
if (cookie) {
return { ...WEB_HEADERS, 'Cookie': cookie };
}
return { ...WEB_HEADERS };
}
// ═══════════════════════════════════
// Playwright singleton for QR login
// ═══════════════════════════════════
let _browser: Browser | null = null;
async function getBrowserSingleton(): Promise<Browser> {
const { chromium } = await import("playwright");
if (!_browser || !_browser.isConnected()) {
_browser = await chromium.launch({
executablePath: CHROMIUM_PATH,
headless: true,
args: [
"--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-software-rasterizer",
"--disable-features=Vulkan", "--use-gl=swiftshader",
],
timeout: 30000,
});
}
return _browser;
}
// ═══════════════════════════════════
// QR login session store
// ═══════════════════════════════════
const qrSessions = new Map<string, {
browser: Browser;
context: BrowserContext;
page: Page;
startTime: number;
verifying: boolean; // prevent concurrent status checks
}>();
// ═══════════════════════════════════
// Helpers
// ═══════════════════════════════════
function dailyFolderName(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
async function humanDelay(): Promise<void> {
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
}
// Extract short_url from share link: /s/1XXXXX... → strip leading '1'
function extractShortUrl(shareUrl: string): { surl: string; pwd: string } | null {
try {
const url = new URL(shareUrl);
if (!url.hostname.includes('pan.baidu.com')) return null;
const pathMatch = url.pathname.match(/^\/s\/(1[a-zA-Z0-9_-]+)/);
if (!pathMatch) return null;
const surl = pathMatch[1].slice(1);
if (surl.length < 20) return null;
const pwd = url.searchParams.get('pwd') || '';
return { surl, pwd };
} catch {
return null;
}
}
// Regex patterns for parsing share page HTML
const RE_SHAREID = /"shareid":(\d+?),"/;
const RE_SHARE_UK = /"share_uk":"(\d+?)","/;
const RE_FSID = /"fs_id":(\d+?),"/;
const RE_FILENAME = /"server_filename":"(.+?)","/;
const RE_ISDIR = /"isdir":(\d+?),"/;
const RE_SIZE = /"size":(\d+?),"/;
const RE_CATEGORY = /"category":(\d+?),"/;
// ═══════════════════════════════════
// BaiduDriver
// ═══════════════════════════════════
export class BaiduDriver {
private config: BaiduConfig;
constructor(config: BaiduConfig = {}) {
this.config = { ...config };
}
private getCookie(): string {
return this.config.cookie || '';
}
private async getBdstoken(): Promise<string | null> {
// Use cached if available
if (this.config.bdstoken) {
// Validate: bdstoken is typically ~32 chars hex
if (this.config.bdstoken.length > 10) return this.config.bdstoken;
}
const cookie = this.getCookie();
if (!cookie) return null;
try {
const url = `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`;
const res = await fetch(url, {
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
console.error('[Baidu] getBdstoken HTTP', res.status);
return null;
}
const data = await res.json() as any;
if (data.errno !== 0) {
console.error('[Baidu] getBdstoken errno:', data.errno);
// errno -6 = cookie expired / invalid
if (data.errno === -6) {
this.config.cookieExpired = true;
console.error('[Baidu] Cookie expired — user needs to re-scan QR code');
}
return null;
}
const bdstoken = data.result?.bdstoken || '';
if (bdstoken) {
this.config.bdstoken = bdstoken;
console.log('[Baidu] bdstoken obtained');
return bdstoken;
}
return null;
} catch (err: any) {
console.error('[Baidu] getBdstoken error:', err.message);
return null;
}
}
// ═══════════════════════════════════
// QR Login — Playwright browser
// ═══════════════════════════════════
static async startQrLogin(): Promise<{ qrUrl?: string; sessionId: string }> {
const sessionId = "baidu_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
let browser: Browser | null = null;
let context: BrowserContext | null = null;
let page: Page | null = null;
try {
browser = await getBrowserSingleton();
context = await browser.newContext({
viewport: { width: 1280, height: 800 },
locale: "zh-CN",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
ignoreHTTPSErrors: true,
});
// Anti-detection
await context.addInitScript(`(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); })()`);
page = await context.newPage();
// Navigate directly to passport QR login page (the actual login page with QR code)
console.log('[BaiduQR] Navigating to passport QR login...');
await page.goto('https://passport.baidu.com/v2/?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 });
await page.waitForTimeout(4000);
// Check if we landed on the right page
const currentUrl = page.url();
console.log('[BaiduQR] Current URL:', currentUrl);
// Wait for QR code image on passport page
console.log('[BaiduQR] Waiting for QR code...');
await page.waitForSelector('img[src*="passport.baidu.com/v2/api/qrcode"], img[src*="qrcode"]', { timeout: 20000 });
await page.waitForTimeout(1500);
// Extract QR code image URL
const qrImgSrc = await page.evaluate(`(() => {
const imgs = document.querySelectorAll('img');
for (const img of imgs) {
const src = img.src || '';
if (src.includes('passport.baidu.com/v2/api/qrcode') || (src.includes('qrcode') && img.width > 100)) {
return src;
}
}
return '';
})()`) as string;
if (!qrImgSrc) {
throw new Error('Could not find QR code image on page');
}
// Parse sign + logPage from QR image URL, construct wappass login URL
let qrContent = '';
try {
const imgUrlObj = new URL(qrImgSrc);
const sign = imgUrlObj.searchParams.get('sign') || '';
const logPage = imgUrlObj.searchParams.get('logPage') || '';
const t = Math.floor(Date.now() / 1000);
qrContent = `https://wappass.baidu.com/wp/?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`;
} catch {
qrContent = qrImgSrc; // fallback: raw image URL
}
// Store session
qrSessions.set(sessionId, {
browser: browser,
context,
page,
startTime: Date.now(),
verifying: false,
});
console.log('[BaiduQR] Session stored:', sessionId);
return { sessionId, qrUrl: qrContent };
} catch (err: any) {
console.error('[BaiduQR] startQrLogin error:', err.message);
// Cleanup on error
if (page) try { await page.close(); } catch {}
if (context) try { await context.close(); } catch {}
throw new Error('启动百度扫码失败: ' + err.message);
}
// Note: browser/context/page NOT closed on success — need them for status polling
}
static async getQrLoginStatus(sessionId: string): Promise<{
status: string;
cookie?: string;
nickname?: string;
bdstoken?: string;
storage_used?: string;
storage_total?: string;
}> {
const session = qrSessions.get(sessionId);
if (!session) return { status: 'expired' };
const { page, context, browser } = session;
// Prevent concurrent status checks (lock)
if (session.verifying) {
console.log('[BaiduQR] Status check already in progress, returning pending');
return { status: 'pending' };
}
session.verifying = true;
// 300s expiry
if (Date.now() - session.startTime > 300000) {
try { await context.close(); } catch {}
session.verifying = false;
qrSessions.delete(sessionId);
return { status: 'expired' };
}
try {
// Check cookies for BDUSS (login indicator)
const cookies = await context.cookies();
const hasBDUSS = cookies.some((c: any) => {
if (c.name === 'BDUSS' && c.value && c.value.length > 50) return true;
return false;
});
// Check page for login completion
const currentUrl = page.url();
const bodyText = await page.evaluate(`(() => (document.body?.innerText || ''))()`) as string;
// Login detection signals
const qrGone = !(await page.$('img[src*="qrcode"]'));
const loginSuccess = bodyText.includes('登录成功') || bodyText.includes('确认登录');
const onPanPage = currentUrl.includes('pan.baidu.com/disk');
if (hasBDUSS && (qrGone || loginSuccess || onPanPage)) {
// Login detected! Wait for redirect to pan.baidu.com
console.log('[BaiduQR] Login detected, waiting for redirect...');
// Wait up to 15s for page to settle on pan.baidu.com
for (let i = 0; i < 15; i++) {
await page.waitForTimeout(1000);
const newUrl = page.url();
if (newUrl.includes('pan.baidu.com') && !newUrl.includes('passport')) break;
}
// Navigate to disk home to ensure cookies are fully set
try {
await page.goto('https://pan.baidu.com/disk/home', { waitUntil: 'commit', timeout: 15000 });
await page.waitForTimeout(2000);
} catch {}
// Capture ALL cookies
const allCookies = await context.cookies();
const cookieStr = allCookies
.map((c: any) => `${c.name}=${c.value}`)
.join('; ');
console.log(`[BaiduQR] Login success! Got ${allCookies.length} cookies, BDUSS=${hasBDUSS}`);
// Get nickname and bdstoken
let nickname = '';
let bdstoken = '';
try {
const bdres = await fetch(
`${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`,
{ headers: buildHeaders(cookieStr), signal: AbortSignal.timeout(10000) }
);
if (bdres.ok) {
const bddata = await bdres.json() as any;
if (bddata.errno === 0) {
bdstoken = bddata.result?.bdstoken || '';
}
}
} catch {}
// Get storage info
let storage_used = '';
let storage_total = '';
if (bdstoken) {
try {
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, {
headers: buildHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
const qData = await qRes.json() as any;
if (qData.errno === 0) {
const fmt = (bytes: number) => {
if (bytes >= 1024**4) return (bytes / 1024**4).toFixed(2) + ' TB';
if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(2) + ' GB';
if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(2) + ' MB';
return (bytes / 1024).toFixed(2) + ' KB';
};
storage_used = qData.used ? fmt(qData.used) : '';
storage_total = qData.total ? fmt(qData.total) : '';
}
}
} catch {}
}
// Get nickname from Baidu REST API (baidu_name field)
if (bdstoken) {
try {
const uRes = await fetch(`${API_HOST}/rest/2.0/xpan/nas?method=uinfo`, {
headers: buildHeaders(cookieStr),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
const uData = await uRes.json() as any;
if (uData.errno === 0 && uData.baidu_name) {
nickname = uData.baidu_name;
}
}
} catch {}
}
// Cleanup
session.verifying = false;
try { await context.close(); } catch {}
qrSessions.delete(sessionId);
return {
status: 'logged_in',
cookie: cookieStr,
nickname: nickname || '百度用户',
bdstoken,
storage_used,
storage_total,
};
}
// Still pending
session.verifying = false;
return { status: 'pending' };
} catch (err: any) {
console.error('[BaiduQR] Status check error:', err.message);
session.verifying = false;
return { status: 'pending' };
}
}
static cancelQrLogin(sessionId: string) {
const session = qrSessions.get(sessionId);
if (session) {
const { context } = session;
try { context.close(); } catch {}
qrSessions.delete(sessionId);
console.log('[BaiduQR] Cancelled:', sessionId);
}
}
// ═══════════════════════════════════
// Validate — check cookie validity
// ═══════════════════════════════════
async validate(): Promise<boolean> {
const cookie = this.getCookie();
if (!cookie || !cookie.includes('BDUSS')) return false;
const bdstoken = await this.getBdstoken();
return bdstoken !== null;
}
async getUserInfo(): Promise<{ nickname: string; usedBytes: number; totalBytes: number } | null> {
const cookie = this.getCookie();
if (!cookie) return null;
try {
let nickname = this.config.nickname || '';
let usedBytes = 0;
let totalBytes = 0;
// Try to get user info from /api/userinfo
const uRes = await fetch(`${API_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (uRes.ok) {
const uData = await uRes.json() as any;
if (uData.errno === 0 && uData.records) {
nickname = uData.records[0]?.username || nickname;
}
}
// Get quota
try {
const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, {
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(10000),
});
if (qRes.ok) {
const qData = await qRes.json() as any;
if (qData.errno === 0) {
usedBytes = qData.used || 0;
totalBytes = qData.total || 0;
}
}
} catch {}
return { nickname, usedBytes, totalBytes };
} catch {
return null;
}
}
async getStorageInfo(): Promise<{ used: string; total: string }> {
const info = await this.getUserInfo();
if (!info) return { used: '0 B', total: '0 B' };
const fmt = (bytes: number) => {
if (bytes >= 1024**4) return (bytes / 1024**4).toFixed(2) + ' TB';
if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(2) + ' GB';
if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(2) + ' MB';
return (bytes / 1024).toFixed(2) + ' KB';
};
return { used: fmt(info.usedBytes), total: fmt(info.totalBytes) };
}
// ═══════════════════════════════════
// File list (Cookie-based)
// ═══════════════════════════════════
private async listRootDir(): Promise<Array<{ fid: string; file_name: string; dir: boolean; size: number }>> {
const cookie = this.getCookie();
if (!cookie) return [];
const bdstoken = await this.getBdstoken();
if (!bdstoken) return [];
try {
const url = `${API_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`;
const res = await fetch(url, {
headers: buildHeaders(cookie),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return [];
const data = await res.json() as any;
if (data.errno === 0 && data.list) {
return data.list.map((f: any) => ({
fid: String(f.fs_id),
file_name: f.server_filename,
dir: f.isdir === 1 || f.isdir === '1',
size: f.size || 0,
}));
}
console.error('[Baidu] listRootDir errno:', data.errno);
return [];
} catch (err: any) {
console.error('[Baidu] listRootDir error:', err.message);
return [];
}
}
private async createDir(path: string): Promise<boolean> {
const cookie = this.getCookie();
if (!cookie) return false;
const bdstoken = await this.getBdstoken();
if (!bdstoken) return false;
try {
const url = `${API_HOST}/api/create?a=commit&bdstoken=${bdstoken}`;
const body = new URLSearchParams({ path, isdir: '1', block_list: '[]' });
const res = await fetch(url, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) return false;
const data = await res.json() as any;
if (data.errno === 0) return true;
if (data.errno === -8) return true; // already exists
console.error('[Baidu] createDir errno:', data.errno);
return false;
} catch (err: any) {
console.error('[Baidu] createDir error:', err.message);
return false;
}
}
private async findOrCreateDir(dirName: string): Promise<string | null> {
const rootItems = await this.listRootDir();
const existing = rootItems.find(f => f.file_name === dirName && f.dir);
if (existing) return `/${dirName}`;
const ok = await this.createDir(`/${dirName}`);
if (ok) {
console.log(`[Baidu] Created dir: ${dirName}`);
return `/${dirName}`;
}
return null;
}
// ═══════════════════════════════════
// Delete files
// ═══════════════════════════════════
private async deleteFiles(fsIds: string[]): Promise<boolean> {
const cookie = this.getCookie();
if (!cookie) return false;
const bdstoken = await this.getBdstoken();
if (!bdstoken) return false;
try {
const filelist = JSON.stringify(fsIds);
const body = new URLSearchParams({ async: '2', filelist });
const res = await fetch(`${API_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) return false;
const data = await res.json() as any;
return data.errno === 0;
} catch (err: any) {
console.error('[Baidu] deleteFiles error:', err.message);
return false;
}
}
// ═══════════════════════════════════
// Validate share link
// ═══════════════════════════════════
async validateShareLink(shareUrl: string): Promise<{ valid: boolean; message: string; fileCount?: number }> {
const parsed = extractShortUrl(shareUrl);
if (!parsed) return { valid: false, message: '无法解析百度网盘链接格式' };
const { surl, pwd } = parsed;
try {
// Try to get share file list
const shareInfo = await this.getShareFiles(surl, pwd);
if (shareInfo && shareInfo.files.length > 0) {
return { valid: true, message: `有效(${shareInfo.files.length} 个文件)`, fileCount: shareInfo.files.length };
}
return { valid: false, message: '链接已过期或需要提取码' };
} catch (err: any) {
return { valid: false, message: `验证失败: ${err.message}` };
}
}
// ═══════════════════════════════════
// Get share file list — Cookie HTTP
// ═══════════════════════════════════
// Flow: getbdstoken → verify password (get randsk) → update cookie with BDCLND → GET share page → regex parse
private async getShareFiles(surl: string, pwd: string): Promise<ShareDetail | null> {
const cookie = this.getCookie();
if (!cookie) {
console.log('[Baidu] No cookie available for share file listing');
return null;
}
const bdstoken = await this.getBdstoken();
if (!bdstoken) {
console.log('[Baidu] No bdstoken available');
return null;
}
let workingCookie = cookie;
try {
// Step 1: Verify password and get randsk (BDCLND)
if (pwd) {
console.log(`[Baidu:Share] Verifying password for surl=${surl}...`);
const t = String(Date.now());
const verifyUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const verifyBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(verifyUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: verifyBody.toString(),
signal: AbortSignal.timeout(10000),
});
if (!vRes.ok) {
console.log(`[Baidu:Share] Password verify HTTP ${vRes.status}`);
return null;
}
const vData = await vRes.json() as any;
if (vData.errno !== 0) {
// Error codes: -9/-12 = wrong password, 105 = not found, -62 = blocked
const errMap: Record<number|string, string> = { '-9': '提取码错误', '-12': '提取码错误', '105': '链接不存在', '-62': '访问次数过多', '2': '链接已过期', '-33': '转存失败' };
console.log(`[Baidu:Share] Password verify failed: errno=${vData.errno}${errMap[vData.errno] || '未知错误'}`);
return null;
}
const randsk = vData.randsk || '';
if (randsk) {
// Update cookie with BDCLND=randsk (per BaiduPanFilesTransfers)
workingCookie = updateCookie(workingCookie, 'BDCLND', randsk);
console.log('[Baidu:Share] Password verified, BDCLND updated');
}
}
// Step 2: GET share page with updated cookie
const shareUrl = `https://pan.baidu.com/s/1${surl}`;
console.log(`[Baidu:Share] Fetching share page: ${shareUrl}`);
const sRes = await fetch(shareUrl, {
headers: buildHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
if (!sRes.ok) {
console.log(`[Baidu:Share] Share page HTTP ${sRes.status}`);
return null;
}
const html = await sRes.text();
// Check for error states
if (html.includes('页面不存在') || html.includes('你来晚了') || html.includes('链接已失效') || html.includes('分享已过期')) {
console.log('[Baidu:Share] Share link is dead/expired');
return null;
}
// Step 3: Parse HTML for file info using regex
const shareidMatch = html.match(RE_SHAREID);
const ukMatch = html.match(RE_SHARE_UK);
const fsIdMatches = [...html.matchAll(new RegExp(RE_FSID.source, 'g'))];
const filenameMatches = [...html.matchAll(new RegExp(RE_FILENAME.source, 'g'))];
const isdirMatches = [...html.matchAll(new RegExp(RE_ISDIR.source, 'g'))];
const sizeMatches = [...html.matchAll(new RegExp(RE_SIZE.source, 'g'))];
const categoryMatches = [...html.matchAll(new RegExp(RE_CATEGORY.source, 'g'))];
if (!shareidMatch || !ukMatch || fsIdMatches.length === 0) {
// Try alternative extraction from yunData in script tags
const yunMatch = html.match(/yunData\.setData\((\{[^]*?\})\);?/);
if (yunMatch) {
try {
const yunData = JSON.parse(yunMatch[1]);
if (yunData.filelist && yunData.filelist.length > 0) {
const files: ShareFileInfo[] = yunData.filelist.map((f: any) => ({
server_filename: f.server_filename || '',
fs_id: String(f.fs_id),
isdir: f.isdir || 0,
size: f.size || 0,
path: f.path || '',
category: f.category || 0,
}));
console.log(`[Baidu:Share] Found ${files.length} file(s) via yunData`);
return { files, childFiles: null };
}
} catch {}
}
console.log('[Baidu:Share] Could not parse file list from page');
return null;
}
const count = fsIdMatches.length;
const files: ShareFileInfo[] = [];
for (let i = 0; i < count; i++) {
files.push({
server_filename: filenameMatches[i] ? filenameMatches[i][1] : '',
fs_id: fsIdMatches[i][1],
isdir: isdirMatches[i] ? parseInt(isdirMatches[i][1]) : 0,
size: sizeMatches[i] ? parseInt(sizeMatches[i][1]) : 0,
path: '',
category: categoryMatches[i] ? parseInt(categoryMatches[i][1]) : 0,
});
}
console.log(`[Baidu:Share] Found ${files.length} file(s) via regex parse`);
return { files, childFiles: null };
} catch (err: any) {
console.error('[Baidu:Share] getShareFiles error:', err.message);
return null;
}
}
// ═══════════════════════════════════
// Transfer files — Cookie HTTP
// ═══════════════════════════════════
private async transferFiles(
surl: string, pwd: string,
fsIds: string[], destPath: string
): Promise<{ success: boolean; taskId?: string; message: string }> {
const cookie = this.getCookie();
if (!cookie) return { success: false, message: '未登录百度网盘' };
const bdstoken = await this.getBdstoken();
if (!bdstoken) return { success: false, message: '获取 bdstoken 失败Cookie 可能已过期' };
let workingCookie = cookie;
try {
// Step 1: Get share info from page (shareid + uk)
const shareUrl = `https://pan.baidu.com/s/1${surl}`;
// Verify password first if needed
if (pwd) {
const t = String(Date.now());
const vUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`;
const vBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' });
const vRes = await fetch(vUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: vBody.toString(),
signal: AbortSignal.timeout(10000),
});
if (vRes.ok) {
const vData = await vRes.json() as any;
if (vData.errno === 0 && vData.randsk) {
workingCookie = updateCookie(workingCookie, 'BDCLND', vData.randsk);
} else {
return { success: false, message: `密码验证失败 errno=${vData.errno}` };
}
}
}
// Get share page to extract shareid + uk
const sRes = await fetch(shareUrl, {
headers: buildHeaders(workingCookie),
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
if (!sRes.ok) return { success: false, message: `无法访问分享页面 HTTP ${sRes.status}` };
const html = await sRes.text();
const shareidMatch = html.match(RE_SHAREID);
const ukMatch = html.match(RE_SHARE_UK);
if (!shareidMatch || !ukMatch) {
return { success: false, message: '无法从页面提取分享信息' };
}
const shareid = shareidMatch[1];
const uk = ukMatch[1];
// Step 2: Transfer
console.log(`[Baidu:Transfer] Transferring ${fsIds.length} file(s) to ${destPath}...`);
const fsidlist = `[${fsIds.join(',')}]`;
const path = destPath === '/' ? '/' : `/${destPath.replace(/^\//, '')}`;
const tUrl = `${API_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`;
const tBody = new URLSearchParams({ fsidlist, path });
// Retry up to 3 times for transient fetch failures
let tRes: any;
let lastErr: any;
for (let attempt = 0; attempt < 3; attempt++) {
try {
tRes = await fetch(tUrl, {
method: 'POST',
headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: tBody.toString(),
signal: AbortSignal.timeout(30000),
});
break;
} catch (err: any) {
lastErr = err;
if (attempt < 2) {
console.log(`[Baidu:Transfer] Attempt ${attempt + 1} failed: ${err.message}, retrying in 2s...`);
await new Promise(r => setTimeout(r, 2000));
}
}
}
if (!tRes) return { success: false, message: `转存网络错误: ${lastErr?.message || 'fetch failed'}` };
if (!tRes.ok) return { success: false, message: `转存请求失败 HTTP ${tRes.status}` };
const tData = await tRes.json() as any;
if (tData.errno === 0) {
console.log(`[Baidu:Transfer] Success!`);
return { success: true, taskId: `transfer_${Date.now()}`, message: 'ok' };
}
// Known error codes
const errMap: Record<number, string> = {
0: '转存成功',
2: '目标目录不存在',
4: '目录中存在同名文件',
12: '转存文件数超过限制',
20: '容量不足',
'-4': '登录失效,请重新登录',
'-6': 'Cookie 无效,请重新获取',
'-62': '访问次数过多,请稍后再试',
// -33 is a known error, mapped as generic
};
const errMsg = errMap[tData.errno] || `errno=${tData.errno}`;
console.error(`[Baidu:Transfer] Failed: ${errMsg}`);
return { success: false, message: errMsg };
} catch (err: any) {
console.error('[Baidu:Transfer] Error:', err.message);
return { success: false, message: `转存异常: ${err.message}` };
}
}
// ═══════════════════════════════════
// Main saveFromShare — full pipeline
// ═══════════════════════════════════
async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<{
success: boolean;
message: string;
shareUrl?: string;
sharePwd?: string;
folderName?: string;
taskId?: string;
fileCount?: number;
folderCount?: number;
originalFolderName?: string;
}> {
const parsed = extractShortUrl(shareUrl);
if (!parsed) return { success: false, message: '无法解析百度网盘链接格式' };
const { surl, pwd } = parsed;
console.log(`[Baidu] saveFromShare: surl=${surl}, pwd=${pwd ? '***' : '(none)'}`);
// Step 1: Get share file list
const shareInfo = await this.getShareFiles(surl, pwd);
if (!shareInfo || shareInfo.files.length === 0) {
if ((this.config as any).cookieExpired) {
return {
success: false,
message: '百度登录已过期,请重新扫码登录',
cookieExpired: true,
} as any;
}
return { success: false, message: '获取分享文件列表失败,链接可能已过期或需要提取码' };
}
const { files } = shareInfo;
const originalFolderName = files[0]?.server_filename || '';
const fileCount = files.filter(f => !f.isdir).length;
const folderCount = files.filter(f => f.isdir).length;
// Step 2: Create/find daily folder, then sub-folder with original name
await humanDelay();
const saveDirName = dailyFolderName();
console.log(`[Baidu] Creating/finding dir: ${saveDirName}`);
const saveDirPath = await this.findOrCreateDir(saveDirName);
let destPath = saveDirPath || '/';
if (!saveDirPath) {
console.log(`[Baidu] WARNING: failed to create dir, saving to root`);
}
// Create sub-folder with original name under date folder
let savedFolderName = saveDirName;
if (originalFolderName && saveDirPath) {
const subDirName = originalFolderName.replace(/[/\\:*?"<>|]/g, '_').substring(0, 100);
const subDirPath = `${saveDirPath}/${subDirName}`;
const subOk = await this.createDir(subDirPath);
if (subOk) {
destPath = subDirPath;
savedFolderName = `${saveDirName}/${subDirName}`;
console.log(`[Baidu] Created sub-folder: ${subDirName}`);
} else {
console.log(`[Baidu] Failed to create sub-folder, saving to ${destPath}`);
}
}
// Step 3: Transfer files
await humanDelay();
const fsIds = files.map(f => f.fs_id);
console.log(`[Baidu] Transferring ${fsIds.length} file(s) to ${destPath}`);
const transferResult = await this.transferFiles(surl, pwd, fsIds, destPath);
if (!transferResult.success) {
return { success: false, message: `转存失败: ${transferResult.message}`, fileCount, folderCount, originalFolderName };
}
console.log(`[Baidu] Save complete: ${fsIds.length} files -> ${destPath}`);
// Step 4: Create share link from user's own drive
let ownShareUrl = '';
let ownSharePwd = '';
let shareMsg = '';
try {
// Find the saved directory to get its fs_id for sharing
const savedDir = await this.findDirByPath(destPath);
if (savedDir) {
const shareResult = await this.createShareLink(savedDir);
if (shareResult.success && shareResult.shareUrl) {
ownShareUrl = shareResult.shareUrl;
ownSharePwd = shareResult.sharePwd || '';
shareMsg = '(已创建分享链接)';
} else if (shareResult.needVerify) {
// Account needs verification to create shares
shareMsg = '(你的百度账号需要实名/绑定手机才能创建分享链接,当前为源链接)';
ownShareUrl = `https://pan.baidu.com/s/1${surl}`;
ownSharePwd = pwd || '';
} else {
shareMsg = `(分享创建失败:${shareResult.message},当前为源链接)`;
ownShareUrl = `https://pan.baidu.com/s/1${surl}`;
ownSharePwd = pwd || '';
}
} else {
ownShareUrl = `https://pan.baidu.com/s/1${surl}`;
ownSharePwd = pwd || '';
}
} catch {
ownShareUrl = `https://pan.baidu.com/s/1${surl}`;
ownSharePwd = pwd || '';
}
return {
success: true,
message: `✅ 转存成功${shareMsg}`,
shareUrl: ownShareUrl || undefined,
sharePwd: ownSharePwd || undefined,
folderName: savedFolderName,
taskId: transferResult.taskId,
fileCount,
folderCount,
originalFolderName,
};
}
// ═══════════════════════════════════
// Share creation (user's own drive)
// ═══════════════════════════════════
/** Find a directory by path, return its fs_id */
private async findDirByPath(dirPath: string): Promise<string | null> {
const cookie = this.getCookie();
if (!cookie) return null;
const bdstoken = await this.getBdstoken();
if (!bdstoken) return null;
const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')) || '/';
const dirName = dirPath.substring(dirPath.lastIndexOf('/') + 1);
try {
const res = await fetch(
`${API_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`,
{ headers: buildHeaders(cookie), signal: AbortSignal.timeout(10000) }
);
if (!res.ok) return null;
const data = await res.json() as any;
if (data.errno !== 0) return null;
const found = (data.list || []).find((f: any) => f.server_filename === dirName && f.isdir === 1);
return found ? String(found.fs_id) : null;
} catch {
return null;
}
}
/** Create a share link from a file/directory in user's own drive */
private async createShareLink(fsId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string; needVerify?: boolean }> {
const cookie = this.getCookie();
if (!cookie) return { success: false, message: '未登录' };
const bdstoken = await this.getBdstoken();
if (!bdstoken) return { success: false, message: '获取 bdstoken 失败' };
try {
// Generate a random 4-char share password (required by Baidu share/set API)
const pwd = Math.random().toString(36).substring(2, 6);
const body = new URLSearchParams({
fid_list: `[${fsId}]`,
schannel: '0',
channel_list: '[]',
period: '0',
pwd,
});
const url = `${API_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`;
const res = await fetch(url, {
method: 'POST',
headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
const data = await res.json() as any;
if (data.errno === 0) {
// Success — extract shareid and link from response
const shareid = data.shareid;
if (shareid) {
const link = data.link || `https://pan.baidu.com/s/1${shareid}`;
return { success: true, shareUrl: link, sharePwd: pwd, message: 'ok' };
}
return { success: false, message: '创建成功但未获取到分享链接' };
}
if (data.errno === 115) {
// Account genuinely restricted (should not happen with correct pwd param)
return { success: false, message: '账号异常,禁止分享', needVerify: true };
}
return { success: false, message: data.show_msg || `分享创建失败 errno=${data.errno}` };
} catch (err: any) {
return { success: false, message: err.message || '网络错误' };
}
}
// ═══════════════════════════════════
// Cleanup
// ═══════════════════════════════════
async emptyTrash(): Promise<boolean> {
// Cookie-based approach: list recycle and delete
const cookie = this.getCookie();
if (!cookie) return false;
try {
const bdstoken = await this.getBdstoken();
if (!bdstoken) return false;
// We don't have a dedicated trash API with Cookie, use /api/list with recycle parameter
// For now, skip if trash is empty
console.log('[Baidu] emptyTrash: not fully implemented for Cookie yet, skipping');
return true;
} catch (err: any) {
console.error('[Baidu] emptyTrash error:', err.message);
return false;
}
}
async cleanupOldDateFolders(days: number): Promise<{ trashed: number; errors: string[] }> {
const errors: string[] = [];
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
try {
const rootItems = await this.listRootDir();
const oldFolders = rootItems.filter(item => {
if (!item.dir) return false;
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
return item.file_name < cutoffStr;
});
if (oldFolders.length === 0) return { trashed: 0, errors: [] };
const fsIds = oldFolders.map(f => f.fid);
console.log(`[Baidu] Deleting ${fsIds.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
const ok = await this.deleteFiles(fsIds);
if (ok) return { trashed: fsIds.length, errors: [] };
return { trashed: 0, errors: [`删除 ${fsIds.length} 个文件夹失败`] };
} catch (err: any) {
return { trashed: 0, errors: [err.message] };
}
}
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<{ trashed: number; errors: string[] }> {
const errors: string[] = [];
try {
const info = await this.getUserInfo();
if (!info || info.totalBytes <= 0) return { trashed: 0, errors: [] };
const usagePercent = (info.usedBytes / info.totalBytes) * 100;
if (usagePercent < thresholdPercent) {
console.log(`[Baidu] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
return { trashed: 0, errors: [] };
}
const targetBytesToFree = Math.floor(info.totalBytes * Math.min(deletePercent, 100) / 100);
const rootItems = await this.listRootDir();
const dateFolders = rootItems
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
.sort((a, b) => a.file_name.localeCompare(b.file_name));
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
const avgSize = info.usedBytes / dateFolders.length;
const estCount = Math.max(1, Math.ceil(targetBytesToFree / (avgSize || 1)));
const foldersToTrash = dateFolders.slice(0, Math.min(estCount, dateFolders.length));
const freedMB = (foldersToTrash.length * (avgSize || 0) / 1024 / 1024).toFixed(0);
const fsIdsToTrash = foldersToTrash.map(f => f.fid);
console.log(`[Baidu] Space threshold: deleting ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB)`);
const ok = await this.deleteFiles(fsIdsToTrash);
if (ok) return { trashed: foldersToTrash.length, errors: [] };
return { trashed: 0, errors: ['空间阈值清理失败'] };
} catch (err: any) {
return { trashed: 0, errors: [err.message] };
}
}
}
// ═══════════════════════════════════
// Utility: update Cookie string
// ═══════════════════════════════════
function updateCookie(cookieStr: string, key: string, value: string): string {
const pairs = cookieStr.split(';').map(s => s.trim()).filter(s => s);
let found = false;
const updated = pairs.map(p => {
const eq = p.indexOf('=');
if (eq > 0 && p.substring(0, eq) === key) {
found = true;
return `${key}=${value}`;
}
return p;
});
if (!found) {
updated.push(`${key}=${value}`);
}
return updated.join('; ');
}