主要变更: - 新增 Playwright 登录认证服务 (services/playwright-auth.ts) - 重构 get-activity.ts 使用 Playwright 替代 Axios 登录 - 实现自动 cookie 过期检测和重试机制 - 优化 Docker 配置支持 Playwright 浏览器运行 - 添加启动脚本自动验证和刷新 cookies - 完善错误处理:区分 4xx(认证失败) 和 5xx(服务器错误) 技术细节: - 删除旧版 login_template.txt 和 nkcs-engage.cookie.txt - 添加 startup.sh 启动时自动验证 cookies - 改进 cookie 验证逻辑,添加指数退避重试 - Dockerfile 安装 Playwright 系统依赖 - docker-compose.yaml 添加 volumes 和 health checks 测试: - 添加 auth.spec.ts 自动化测试 - 添加 get-cookies.ts 和 test-cookies-validity.ts 工具脚本 - 验证 401/500/000 等错误场景处理正确
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
import { chromium, type BrowserContext, type Cookie } from 'playwright';
|
||
import { logger } from '../utils/logger';
|
||
import * as fs from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
const LOGIN_URL = 'https://engage.nkcswx.cn/Login.aspx';
|
||
const COOKIE_FILE_PATH = resolve(import.meta.dir, 'cookies.json');
|
||
|
||
let _inMemoryCookies: Cookie[] | null = null;
|
||
|
||
/**
|
||
* Login using Playwright and extract cookies
|
||
*/
|
||
export async function loginWithPlaywright(username: string, password: string): Promise<Cookie[]> {
|
||
logger.info('Starting Playwright login process...');
|
||
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||
});
|
||
|
||
try {
|
||
const context = 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',
|
||
});
|
||
|
||
const page = await context.newPage();
|
||
|
||
logger.info(`Navigating to login page: ${LOGIN_URL}`);
|
||
await page.goto(LOGIN_URL, {
|
||
waitUntil: 'networkidle',
|
||
timeout: 30000
|
||
});
|
||
|
||
logger.info('Login page loaded. Filling form...');
|
||
|
||
const usernameField = page.locator('input[name="ctl00$PageContent$loginControl$txtUN"]');
|
||
await usernameField.fill(decodeURIComponent(username));
|
||
|
||
const passwordField = page.locator('input[name="ctl00$PageContent$loginControl$txtPwd"]');
|
||
await passwordField.fill(decodeURIComponent(password));
|
||
|
||
const rememberMe = page.locator('input[name="ctl00$PageContent$loginControl$cbRememberMe"]');
|
||
await rememberMe.check().catch(() => {
|
||
logger.debug('Could not check remember me checkbox (optional)');
|
||
});
|
||
|
||
const loginButton = page.locator('input[name="ctl00$PageContent$loginControl$btnLogin"]');
|
||
logger.info('Clicking login button...');
|
||
await loginButton.click();
|
||
|
||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||
|
||
const isLoggedIn = await checkLoginSuccess(page);
|
||
|
||
if (!isLoggedIn) {
|
||
const errorMessage = await page.locator('.error, .errorMessage, [class*="error"]').first().textContent();
|
||
throw new Error(`Login failed. Possible error: ${errorMessage || 'Unknown error'}`);
|
||
}
|
||
|
||
logger.info('Login successful! Extracting cookies...');
|
||
|
||
const cookies = await context.cookies();
|
||
logger.info(`Extracted ${cookies.length} cookies`);
|
||
|
||
await saveCookiesToCache(cookies);
|
||
logImportantCookies(cookies);
|
||
|
||
await browser.close();
|
||
return cookies;
|
||
} catch (error) {
|
||
logger.error('Error during Playwright login:', error);
|
||
await browser.close();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if login was successful
|
||
*/
|
||
async function checkLoginSuccess(page: any): Promise<boolean> {
|
||
await page.waitForTimeout(1000);
|
||
|
||
const currentUrl = page.url();
|
||
const notOnLoginPage = !currentUrl.includes('Login.aspx');
|
||
|
||
const hasLogoutLink = await page.locator('text=Logout, text=退出,text=Sign Out').count() > 0;
|
||
const hasWelcomeText = await page.locator('text=Welcome, text=欢迎').count() > 0;
|
||
|
||
return notOnLoginPage || hasLogoutLink || hasWelcomeText;
|
||
}
|
||
|
||
/**
|
||
* Log important cookies for debugging
|
||
*/
|
||
function logImportantCookies(cookies: Cookie[]): void {
|
||
const importantCookieNames = [
|
||
'ASP.NET_SessionId',
|
||
'.ASPXFORMSAUTH',
|
||
];
|
||
|
||
logger.debug('Important cookies:');
|
||
cookies.forEach(cookie => {
|
||
if (importantCookieNames.some(name => cookie.name.includes(name))) {
|
||
logger.debug(` ${cookie.name}: ${cookie.value.substring(0, 50)}${cookie.value.length > 50 ? '...' : ''}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Load cookies from cache file
|
||
*/
|
||
export async function loadCachedCookies(): Promise<Cookie[] | null> {
|
||
if (_inMemoryCookies) {
|
||
logger.debug('Using in-memory cached cookies.');
|
||
return _inMemoryCookies;
|
||
}
|
||
|
||
if (!fs.existsSync(COOKIE_FILE_PATH)) {
|
||
logger.debug('Cookie cache file not found. No cached cookies loaded.');
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
|
||
_inMemoryCookies = cookies;
|
||
logger.debug(`Loaded ${cookies.length} cookies from file cache.`);
|
||
return cookies;
|
||
} catch (error: any) {
|
||
logger.warn('Error loading cookies from file:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save cookies to cache file
|
||
*/
|
||
export async function saveCookiesToCache(cookies: Cookie[]): Promise<void> {
|
||
if (!cookies || cookies.length === 0) {
|
||
logger.warn('Attempted to save empty or null cookies. Aborting save.');
|
||
return;
|
||
}
|
||
|
||
_inMemoryCookies = cookies;
|
||
|
||
try {
|
||
await fs.promises.writeFile(COOKIE_FILE_PATH, JSON.stringify(cookies, null, 2), 'utf-8');
|
||
logger.debug('Cookies saved to file cache.');
|
||
} catch (error: any) {
|
||
logger.error('Error saving cookies to file:', error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear cookie cache
|
||
*/
|
||
export async function clearCookieCache(): Promise<void> {
|
||
_inMemoryCookies = null;
|
||
|
||
try {
|
||
await fs.promises.unlink(COOKIE_FILE_PATH);
|
||
logger.debug('Cookie cache file deleted.');
|
||
} catch (error: any) {
|
||
if (error.code !== 'ENOENT') {
|
||
logger.error('Error deleting cookie file:', error.message);
|
||
} else {
|
||
logger.debug('Cookie cache file did not exist, no need to delete.');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert cookies array to cookie string for axios
|
||
*/
|
||
export function cookiesToString(cookies: Cookie[]): string {
|
||
return cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||
}
|
||
|
||
/**
|
||
* Get cookie string from cache
|
||
*/
|
||
export async function getCachedCookieString(): Promise<string | null> {
|
||
const cookies = await loadCachedCookies();
|
||
if (!cookies || cookies.length === 0) {
|
||
return null;
|
||
}
|
||
return cookiesToString(cookies);
|
||
}
|