Files
douban-login/src/login.ts
2025-10-24 20:23:16 +08:00

181 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 豆瓣登录脚本入口:
* 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<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* 简单的命令行问答工具,用于等待用户输入验证码或确认步骤。
*/
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string>((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
/**
* 根据当前 URL、页面元素和关键 Cookies 判断是否处于登录态。
*/
async function isLoggedIn(page: Page): Promise<boolean> {
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<void> {
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<void> {
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;
});