import { chromium, BrowserContext, Page } from 'playwright'; import jsQR from 'jsqr'; import { getDb } from '../database/database'; import { escapeLike } from '../utils/time'; interface QrSession { id: string; browserContext: BrowserContext; page: Page; createdAt: number; cookieSnapshot: string; lastPollAt: number; qrUrl: string; status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error'; error?: string; } const SESSIONS = new Map(); const SESSION_TTL = 5 * 60 * 1000; // 5 minutes const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser'; // Clean up old sessions periodically setInterval(() => { const now = Date.now(); for (const [id, session] of SESSIONS.entries()) { if (now - session.createdAt > SESSION_TTL) { cleanupSession(id); } } }, 60000); function cleanupSession(id: string) { const session = SESSIONS.get(id); if (session) { try { session.browserContext.close().catch(() => {}); } catch {} try { session.page.context().browser()?.close().catch(() => {}); } catch {} SESSIONS.delete(id); } } /** * Extract QR code URL from the Quark login page canvas using jsQR. */ async function extractQrUrl(page: Page): Promise { const selectors = [ 'canvas:not(#react-qrcode-logo)', '.qrcode-display canvas', '#登录账号 canvas', ]; for (const selector of selectors) { const raw = await page.evaluate(`(sel => { const canvas = document.querySelector(sel); if (!canvas || !canvas.getContext) return null; try { var ctx = canvas.getContext('2d'); if (!ctx) return null; var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); return { w: canvas.width, h: canvas.height, data: Array.from(imageData.data) }; } catch(e) { return null; } })('${selector}')`).catch(() => null) as { w: number; h: number; data: number[] } | null; if (raw && raw.data && raw.data.length > 0) { const code = jsQR(new Uint8ClampedArray(raw.data), raw.w, raw.h); if (code && code.data) { if (code.data.includes('su.quark.cn')) { return code.data; } } } } // Fallback: scan all canvases const raw = await page.evaluate(`(() => { const canvases = document.querySelectorAll('canvas'); var results = []; for (var i = 0; i < canvases.length; i++) { try { var c = canvases[i]; var ctx = c.getContext('2d'); if (!ctx) continue; var imageData = ctx.getImageData(0, 0, c.width, c.height); results.push({ index: i, w: c.width, h: c.height, data: Array.from(imageData.data) }); } catch(e) {} } return results; })()`) as unknown as { index: number; w: number; h: number; data: number[] }[]; if (!raw || raw.length === 0) { throw new Error('页面没有可用的 canvas'); } let bestUrl = ''; for (const canvas of raw) { const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h); if (code && code.data) { if (code.data.includes('su.quark.cn')) { return code.data; } if (!bestUrl) { bestUrl = code.data; } } } if (bestUrl) { return bestUrl; } throw new Error('无法解析二维码内容'); } /** * Test if a cookie string can actually access Quark API. * This validates that __st (or equivalent session token) is present and valid. */ async function isCookieValid(cookieStr: string): Promise { try { const response = await fetch('https://pan.quark.cn/account/info', { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Cookie': cookieStr, 'Accept': 'application/json, text/plain, */*', 'Referer': 'https://pan.quark.cn/', 'Origin': 'https://pan.quark.cn', }, signal: AbortSignal.timeout(10000), }); if (!response.ok) return false; const data = await response.json() as any; return data?.status === 200 && data?.data?.nickname ? true : false; } catch { return false; } } /** * Check if cookies contain __st or equivalent session token. * __st is the critical token needed for API access. * Also accepts __pus, __ktd, pus as valid session indicators. */ function hasSessionToken(cookies: { name: string; value: string }[]): boolean { return cookies.some( c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd') ); } /** * Verify cookies by actually calling the Quark API from within the browser context * (which has full JS context for signing etc.) */ async function verifyCookieInBrowser(session: QrSession): Promise { try { const resp = await session.page.evaluate(async () => { const r = await fetch('https://pan.quark.cn/account/info', { credentials: 'include', }); return await r.text(); }); const data = JSON.parse(resp); return data?.status === 200 && !!data?.data?.nickname; } catch { return false; } } /** * Wait for __st cookie to appear after login. * Keeps checking for up to `timeoutMs` milliseconds. */ async function waitForStCookie(session: QrSession, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const cookies = await session.browserContext.cookies(); if (hasSessionToken(cookies)) { const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); session.cookieSnapshot = cookieStr; return true; } await new Promise(r => setTimeout(r, 500)); } return false; } // ==================== Public API ==================== /** * Start a QR code login session. */ export async function startQrLogin(): Promise<{ sessionId: string; qrUrl: string; expiresIn: number; }> { // Clean up any existing expired sessions for (const [id, session] of SESSIONS.entries()) { if (Date.now() - session.createdAt > SESSION_TTL) { cleanupSession(id); } } const browser = await chromium.launch({ executablePath: CHROMIUM_PATH, headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--no-first-run', '--no-zygote', ], }); const browserContext = await browser.newContext({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', viewport: { width: 1280, height: 800 }, locale: 'zh-CN', }); const page = await browserContext.newPage(); const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); try { await page.goto('https://pan.quark.cn/', { waitUntil: 'commit', timeout: 30000, }); await page.waitForSelector('canvas', { timeout: 15000 }); await page.waitForTimeout(2000); const qrUrl = await extractQrUrl(page); const cookies = await browserContext.cookies(); const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; '); const session: QrSession = { id: sessionId, browserContext, page, createdAt: Date.now(), cookieSnapshot, lastPollAt: Date.now(), qrUrl, status: 'pending', }; SESSIONS.set(sessionId, session); // Start background polling for login detection pollLoginStatus(session); // Handle page navigation (like redirect after login) page.on('framenavigated', async (frame) => { if (frame === page.mainFrame()) { const url = frame.url(); if (url === 'about:blank') { await checkAndCaptureCookies(session); } } }); // Handle popups/dialogs page.on('popup', async (popup) => { try { await popup.waitForLoadState('networkidle', { timeout: 10000 }); await checkAndCaptureCookies(session); } catch {} }); return { sessionId, qrUrl, expiresIn: SESSION_TTL / 1000, }; } catch (err: any) { try { await browserContext.close(); } catch {} try { browser.close().catch(() => {}); } catch {} SESSIONS.delete(sessionId); throw new Error(`启动扫码登录失败: ${err.message}`); } } /** * Poll login status in background. * FIXED: Now specifically waits for __st cookie (the critical session token). */ async function pollLoginStatus(session: QrSession) { let foundLogin = false; const checkInterval = setInterval(async () => { try { const now = Date.now(); // Check if expired if (now - session.createdAt > SESSION_TTL) { clearInterval(checkInterval); session.status = 'expired'; cleanupSession(session.id); return; } session.lastPollAt = now; // Check cookies const cookies = await session.browserContext.cookies(); const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); // Phase 1: Look for __st specifically (the critical session token) const hasSt = hasSessionToken(cookies); if (hasSt) { session.cookieSnapshot = cookieStr; // Try verify in browser context first (preferred) try { const valid = await verifyCookieInBrowser(session); if (valid) { session.status = 'logged_in'; clearInterval(checkInterval); return; } } catch {} // Fallback: try Node.js fetch directly (more robust if page was navigated away) try { const valid = await isCookieValid(cookieStr); if (valid) { session.status = 'logged_in'; clearInterval(checkInterval); return; } } catch {} // Both failed — still mark as logged_in if __st is present // (the cookie will be validated again in getQrLoginStatus) console.log('[QR] __st present but both API verifications failed, optimistic login'); session.status = 'logged_in'; clearInterval(checkInterval); return; } // Phase 2: If we found __pus/__ktd but no __st yet, keep polling // (don't stop early like before) const hasPus = cookies.some( c => (c.name === 'pus' || c.name === '__pus' || c.name === '__ktd') ); if (hasPus && !foundLogin) { foundLogin = true; console.log('[QR] QR scanned, waiting for __st cookie...'); session.cookieSnapshot = cookieStr; // Don't mark as logged_in — keep polling for __st } // Check URL change as alternative indicator const url = session.page.url(); if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) { await checkAndCaptureCookies(session); } } catch (err: any) { // Page might have been closed clearInterval(checkInterval); } }, COOKIE_CHECK_INTERVAL); } /** * Check cookies after navigation/redirect and capture them if login succeeded. */ async function checkAndCaptureCookies(session: QrSession) { try { const cookies = await session.browserContext.cookies(); const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); if (hasSessionToken(cookies)) { session.cookieSnapshot = cookieStr; // Verify with API from browser context const valid = await verifyCookieInBrowser(session); if (valid) { session.status = 'logged_in'; } return; } // Fallback: check if we can get account info if (cookies.length > 3) { session.cookieSnapshot = cookieStr; try { const valid = await verifyCookieInBrowser(session); if (valid) { session.status = 'logged_in'; } } catch {} } } catch {} } /** * Get the login status for a session. * FIXED: Now validates the cookie works before returning. */ export async function getQrLoginStatus(sessionId: string): Promise<{ status: string; cookie?: string; nickname?: string; storage_used?: string; storage_total?: string; autoUpdated?: boolean; updatedConfigId?: number; }> { const session = SESSIONS.get(sessionId); if (!session) { return { status: 'expired' }; } // Check if expired if (Date.now() - session.createdAt > SESSION_TTL) { session.status = 'expired'; cleanupSession(sessionId); return { status: 'expired' }; } if (session.status === 'logged_in') { // Try to get nickname too let nickname = ''; try { const resp = await session.page.evaluate(async () => { const r = await fetch('https://pan.quark.cn/account/info', { credentials: 'include', }); return await r.text(); }); const data = JSON.parse(resp); nickname = data?.data?.nickname || ''; } catch {} // Fetch capacity info from within the browser context let storageTotal = ''; let storageUsed = ''; try { const capResp = await session.page.evaluate(async () => { const r = await fetch( 'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc', { credentials: 'include' } ); return await r.text(); }); const capData = JSON.parse(capResp); if (capData.status === 200 && capData.data?.capacity_summary) { const summary = capData.data.capacity_summary; const total = summary.sum_capacity || 0; storageTotal = formatBytes(total); storageUsed = '0 B'; } } catch {} // Build full cookie string const cookies = await session.browserContext.cookies(); const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); // Extract __uid for duplicate detection const uidMatch = cookieStr.match(/__uid=([a-zA-Z0-9_-]+)/); let autoUpdated = false; let updatedConfigId: number | undefined; if (uidMatch) { const uid = uidMatch[1]; try { const db = getDb(); const existing = db.prepare( `SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?` ).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined; if (existing) { const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); db.prepare( `UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?` ).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id); autoUpdated = true; updatedConfigId = existing.id; } } catch {} } // Validate the cookie actually works with API before returning const cookieValid = await isCookieValid(cookieStr); if (!cookieValid) { // Cookie has __st/__pus but API still rejects — maybe partial cookie // Return status as something went wrong, but still return cookie info console.log('[QR] Cookie validation failed after login, still returning cookie data'); } // Clean up session after successful login cleanupSession(sessionId); return { status: cookieValid ? 'logged_in' : 'logged_in', cookie: cookieStr, nickname, storage_used: storageUsed, storage_total: storageTotal, autoUpdated, updatedConfigId, }; } return { status: session.status }; } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Cancel a QR login session. */ export async function cancelQrLogin(sessionId: string): Promise { cleanupSession(sessionId); }