/** * 豆瓣登录脚本入口: * 1. 优先复用本地缓存的 cookies,减少重复登录; * 2. 若失效则走短信验证码流程; * 3. 登录成功后写回 cookies 供后续脚本复用。 */ import { chromium, Browser, BrowserContext, Page } from 'playwright'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import readline from 'readline'; const LOGIN_URL = 'https://accounts.douban.com/passport/login?source=main'; const HOME_URL = 'https://www.douban.com'; const COOKIES_PATH = path.join(os.homedir(), 'cookies.json'); const PHONE = process.env.DOUBAN_PHONE ?? ''; /** * 检查指定路径文件是否存在,避免捕获异常污染主流程。 */ async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; } catch { return false; } } /** * 简单的命令行问答工具,用于等待用户输入验证码或确认步骤。 */ function prompt(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer); }); }); } /** * 根据当前 URL、页面元素和关键 Cookies 判断是否处于登录态。 */ async function isLoggedIn(page: Page): Promise { const url = page.url(); if (/accounts\.douban\.com/.test(url)) { return false; } const loginButton = page.locator('text=登录豆瓣'); if ((await loginButton.count()) > 0) { return false; } const cookies = await page.context().cookies(); const hasDbcl2 = cookies.some((cookie) => cookie.name === 'dbcl2'); if (!hasDbcl2) { return false; } const navAccount = page.locator('.nav-user-account'); if ((await navAccount.count()) > 0) { try { return await navAccount.first().isVisible(); } catch { return true; } } return true; } /** * 尝试加载本地 cookies 创建上下文,若失效则回退到全新上下文。 */ async function prepareContext(browser: Browser): Promise<{ context: BrowserContext; page: Page; usedCookies: boolean; }> { if (await fileExists(COOKIES_PATH)) { const context = await browser.newContext({ storageState: COOKIES_PATH }); const page = await context.newPage(); await page.goto(HOME_URL, { waitUntil: 'networkidle' }); if (await isLoggedIn(page)) { return { context, page, usedCookies: true }; } console.warn('检测到缓存 Cookies 失效,将重新登录。'); await context.close(); } const context = await browser.newContext(); const page = await context.newPage(); await page.goto(LOGIN_URL, { waitUntil: 'networkidle' }); return { context, page, usedCookies: false }; } /** * 短信验证码登录流程: * - 输入手机号并触发验证码 * - 提示用户完成必要的前置验证 * - 等待用户输入短信验证码并提交 */ async function loginWithSms(page: Page, phone: string): Promise { const phoneInput = page.locator('input[name="phone"]'); await phoneInput.waitFor({ state: 'visible', timeout: 15000 }); await phoneInput.fill(phone); await page.click('text=获取验证码'); console.log('请在浏览器中完成页面上的验证,并等待短信验证码。'); await prompt('完成验证并收到短信后按 Enter 继续...'); const code = (await prompt('请输入短信验证码: ')).trim(); if (!code) { throw new Error('未输入短信验证码,登录流程终止。'); } await page.fill('input[name="code"]', code); await page.click('text=登录豆瓣'); try { await page.waitForLoadState('networkidle', { timeout: 60000 }); } catch { // ignore timeout, we will verify via navigation result below } await page.waitForTimeout(2000); await page.goto(HOME_URL, { waitUntil: 'networkidle' }); } /** * 程序主入口:协调上下文、执行登录并持久化 cookies。 */ async function main(): Promise { if (!PHONE) { console.error('请通过环境变量 DOUBAN_PHONE 提供登录手机号。'); process.exitCode = 1; return; } const browser = await chromium.launch({ headless: false }); try { let { context, page, usedCookies } = await prepareContext(browser); if (usedCookies) { console.info('已使用缓存 Cookies 自动登录成功。'); } else { await loginWithSms(page, PHONE); if (!(await isLoggedIn(page))) { console.error('登录失败:未能确认登录状态。'); process.exitCode = 1; return; } await context.storageState({ path: COOKIES_PATH }); console.info(`登录成功,Cookies 已保存至 ${COOKIES_PATH}`); } // 在此处可继续执行需要认证的业务逻辑 } finally { await browser.close(); } } main().catch((error) => { console.error('执行登录流程时发生错误:', error); process.exitCode = 1; });