first commit

This commit is contained in:
douboer
2025-10-24 20:23:16 +08:00
commit 58dd30f0e3
12 changed files with 3296 additions and 0 deletions

180
src/login.ts Normal file
View File

@@ -0,0 +1,180 @@
/**
* 豆瓣登录脚本入口:
* 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;
});