181 lines
4.9 KiB
TypeScript
181 lines
4.9 KiB
TypeScript
/**
|
||
* 豆瓣登录脚本入口:
|
||
* 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;
|
||
});
|