// 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 = { '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 { if (cookie) { return { ...WEB_HEADERS, 'Cookie': cookie }; } return { ...WEB_HEADERS }; } // ═══════════════════════════════════ // Playwright singleton for QR login // ═══════════════════════════════════ let _browser: Browser | null = null; async function getBrowserSingleton(): Promise { 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(); // ═══════════════════════════════════ // 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 { 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 { // 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 { 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> { 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 { 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 { 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 { 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 { 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 = { '-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 = { 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 { 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 { // 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('; '); }