feat: 使用 Playwright 实现自动化 cookie 获取和验证

主要变更:
- 新增 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 等错误场景处理正确
This commit is contained in:
JamesFlare1212
2026-04-06 16:05:38 -04:00
parent b18b8a85e0
commit a8f468a497
15 changed files with 679 additions and 289 deletions

32
services/cookies.json Normal file
View File

@@ -0,0 +1,32 @@
[
{
"name": "ASP.NET_SessionId",
"value": "fjurweoenv1wdcvhcyvhreqh",
"domain": "engage.nkcswx.cn",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "usernameCookie",
"value": "1hDdyhfXMJP9S2CpwOgJZDKOXrEEXLB%23EXOogV55NRskzh6XKDU2rym1YrGfnoklj",
"domain": "engage.nkcswx.cn",
"path": "/",
"expires": 1778095681.649044,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
},
{
"name": ".ASPXFORMSAUTH",
"value": "8E4B9D03FE08C5A2C6EB323B35110D6290CCF3408B68940F9783D1F9D37FA326A38457C956652C8A55D68218AA681485AB8C2533E4E7C85B2BF3413847009C18918281566DF940EA26761F8C0424B93AE79F519DDD0585DF19907E1F9231F5C020039960F5BFC53B7D429B1F3F83B5655F83D796",
"domain": "engage.nkcswx.cn",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
]

188
services/playwright-auth.ts Normal file
View File

@@ -0,0 +1,188 @@
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);
}