1190 lines
45 KiB
TypeScript
1190 lines
45 KiB
TypeScript
// 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('; ');
|
||
}
|