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:
40
Dockerfile
40
Dockerfile
@@ -1,15 +1,53 @@
|
|||||||
FROM oven/bun:latest
|
FROM oven/bun:latest
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install Playwright system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
|
# Install dependencies (including Playwright)
|
||||||
RUN bun install --production
|
RUN bun install --production
|
||||||
|
|
||||||
|
# Install Playwright browsers
|
||||||
|
RUN bunx playwright install chromium --with-deps || true
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Make startup script executable
|
||||||
|
RUN chmod +x startup.sh
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN adduser --disabled-password --gecos '' appuser && \
|
||||||
|
chown -R appuser:appuser /usr/src/app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["bun", "start"]
|
# Use startup script
|
||||||
|
CMD ["/bin/sh", "startup.sh"]
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -16,6 +16,7 @@
|
|||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"typescript-language-server": "^5.1.3",
|
"typescript-language-server": "^5.1.3",
|
||||||
},
|
},
|
||||||
@@ -67,6 +68,8 @@
|
|||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="],
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
|
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.15.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw=="],
|
"@types/node": ["@types/node@22.15.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw=="],
|
||||||
@@ -149,6 +152,8 @@
|
|||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
@@ -205,6 +210,10 @@
|
|||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
|
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||||
|
|||||||
@@ -5,21 +5,29 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: dsas-cca-backend
|
container_name: dsas-cca-backend
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:${PORT}"
|
- "${PORT:-3000}:${PORT:-3000}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./services/cookies.json:/usr/src/app/services/cookies.json
|
||||||
networks:
|
networks:
|
||||||
- cca_network
|
- cca_network
|
||||||
|
mem_limit: 1g
|
||||||
|
cpus: 1.0
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: "redis:8.0-alpine"
|
image: "redis:8.0-alpine"
|
||||||
container_name: dsas-cca-redis
|
container_name: dsas-cca-redis
|
||||||
command: redis-server --requirepass "dsas-cca"
|
command: redis-server --requirepass "dsas-cca"
|
||||||
volumes:
|
volumes:
|
||||||
- ./redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- cca_network
|
- cca_network
|
||||||
@@ -28,6 +36,10 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
mem_limit: 256m
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cca_network:
|
cca_network:
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// engage-api/get-activity.ts
|
// engage-api/get-activity.ts
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { readFile,writeFile,unlink } from 'fs/promises';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import {
|
||||||
|
loginWithPlaywright,
|
||||||
|
loadCachedCookies,
|
||||||
|
saveCookiesToCache,
|
||||||
|
clearCookieCache,
|
||||||
|
getCachedCookieString
|
||||||
|
} from '../services/playwright-auth';
|
||||||
|
|
||||||
// Define interfaces for our data structures
|
// Define interfaces for our data structures
|
||||||
interface ActivityResponse {
|
interface ActivityResponse {
|
||||||
@@ -22,64 +27,12 @@ class AuthenticationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Bun, we can use import.meta.dir instead of the Node.js __dirname approach
|
/**
|
||||||
const COOKIE_FILE_PATH = resolve(import.meta.dir, 'nkcs-engage.cookie.txt');
|
* Test cookie validity by calling API
|
||||||
let _inMemoryCookie: string | null = null;
|
*/
|
||||||
|
async function testCookieValidityWithApi(cookieString: string): Promise<boolean> {
|
||||||
// Cookie Cache Helper Functions
|
|
||||||
async function loadCachedCookie(): Promise < string | null > {
|
|
||||||
if (_inMemoryCookie) {
|
|
||||||
logger.debug("Using in-memory cached cookie.");
|
|
||||||
return _inMemoryCookie;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const cookieFromFile = await readFile(COOKIE_FILE_PATH, 'utf8');
|
|
||||||
if (cookieFromFile) {
|
|
||||||
_inMemoryCookie = cookieFromFile;
|
|
||||||
logger.debug("Loaded cookie from file cache.");
|
|
||||||
return _inMemoryCookie;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
logger.debug("Cookie cache file not found. No cached cookie loaded.");
|
|
||||||
} else {
|
|
||||||
logger.warn("Error loading cookie from file:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCookieToCache(cookieString: string): Promise < void > {
|
|
||||||
if (!cookieString) {
|
|
||||||
logger.warn("Attempted to save an empty or null cookie. Aborting save.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_inMemoryCookie = cookieString;
|
|
||||||
try {
|
|
||||||
await writeFile(COOKIE_FILE_PATH, cookieString, 'utf8');
|
|
||||||
logger.debug("Cookie saved to file cache.");
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.error("Error saving cookie to file:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearCookieCache(): Promise < void > {
|
|
||||||
_inMemoryCookie = null;
|
|
||||||
try {
|
|
||||||
await unlink(COOKIE_FILE_PATH);
|
|
||||||
logger.debug("Cookie cache file deleted.");
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code !== 'ENOENT') {
|
|
||||||
logger.error("Error deleting cookie file:", err.message);
|
|
||||||
} else {
|
|
||||||
logger.debug("Cookie cache file did not exist, no need to delete.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testCookieValidity(cookieString: string): Promise < boolean > {
|
|
||||||
if (!cookieString) return false;
|
if (!cookieString) return false;
|
||||||
logger.debug("Testing cookie validity...");
|
logger.debug('Testing cookie validity via API...');
|
||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
@@ -98,117 +51,63 @@ async function testCookieValidity(cookieString: string): Promise < boolean > {
|
|||||||
};
|
};
|
||||||
|
|
||||||
logger.debug(`Attempt ${attempt}/${MAX_RETRIES}`);
|
logger.debug(`Attempt ${attempt}/${MAX_RETRIES}`);
|
||||||
await axios.post(url, payload, {
|
const response = await axios.post(url, payload, {
|
||||||
headers,
|
headers,
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Cookie test successful (API responded 2xx). Cookie is valid.");
|
// Check for 4xx errors (auth failures)
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
|
logger.warn(`Cookie test returned ${response.status}, likely invalid`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Cookie test successful (API responded 2xx). Cookie is valid.');
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.warn(`Cookie validity test failed (attempt ${attempt}/${MAX_RETRIES}).`);
|
logger.warn(`Cookie validity test failed (attempt ${attempt}/${MAX_RETRIES}).`);
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
logger.warn(`Cookie test API response status: ${error.response.status}.`);
|
// 4xx = auth failure (immediate fail)
|
||||||
} else {
|
if (error.response.status >= 400 && error.response.status < 500) {
|
||||||
logger.warn(`Network/other error: ${error.message}`);
|
logger.warn(`Cookie test API response status: ${error.response.status} (auth error)`);
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt >= MAX_RETRIES) {
|
|
||||||
logger.warn("Max retries reached. Cookie is likely invalid or expired.");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// 5xx = server error (retry with delay)
|
||||||
|
logger.warn(`Cookie test API response status: ${error.response.status} (server error, retrying...)`);
|
||||||
|
} else {
|
||||||
|
// No response (000 status, network error, timeout)
|
||||||
|
logger.warn(`Network/timeout error: ${error.message} (retrying...)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Max retries reached. Cookie is likely invalid or expired.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core API Interaction Functions
|
/**
|
||||||
async function getSessionId(): Promise < string | null > {
|
* Get complete cookies using Playwright
|
||||||
const url = 'https://engage.nkcswx.cn/Login.aspx';
|
*/
|
||||||
try {
|
async function getCompleteCookies(userName: string, userPwd: string): Promise<string> {
|
||||||
const response = await axios.get(url, {
|
logger.info('Attempting to get complete cookie string using Playwright login...');
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)'
|
const cookies = await loginWithPlaywright(userName, userPwd);
|
||||||
}
|
|
||||||
});
|
if (!cookies || cookies.length === 0) {
|
||||||
const setCookieHeader = response.headers['set-cookie'];
|
throw new Error("Login failed: Could not obtain cookies.");
|
||||||
if (setCookieHeader && setCookieHeader.length > 0) {
|
|
||||||
const sessionIdCookie = setCookieHeader.find(cookie => cookie.trim().startsWith('ASP.NET_SessionId='));
|
|
||||||
if (sessionIdCookie) {
|
|
||||||
logger.debug('ASP.NET_SessionId created');
|
|
||||||
return sessionIdCookie.split(';')[0] || null; // Ensure a fallback to `null` if splitting fails
|
|
||||||
}
|
|
||||||
return null; // Explicitly return `null` if no cookie is found
|
|
||||||
}
|
|
||||||
logger.error("No ASP.NET_SessionId cookie found in Set-Cookie header.");
|
|
||||||
return null;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Error in getSessionId: ${error.response ? `${error.response.status} - ${error.response.statusText}` : error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMSAUTH(sessionId: string, userName: string, userPwd: string, templateFilePath: string): Promise < string | null > {
|
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
const url = 'https://engage.nkcswx.cn/Login.aspx';
|
return cookieString;
|
||||||
try {
|
|
||||||
let templateData = await readFile(templateFilePath, 'utf8');
|
|
||||||
const postData = templateData
|
|
||||||
.replace('{{USERNAME}}', userName)
|
|
||||||
.replace('{{PASSWORD}}', userPwd);
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Cookie': sessionId,
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)',
|
|
||||||
'Referer': 'https://engage.nkcswx.cn/Login.aspx'
|
|
||||||
};
|
|
||||||
logger.debug('Getting .ASPXFORMSAUTH');
|
|
||||||
const response = await axios.post(url, postData, {
|
|
||||||
headers,
|
|
||||||
maxRedirects: 0,
|
|
||||||
validateStatus: (status) => status >= 200 && status < 400
|
|
||||||
});
|
|
||||||
const setCookieHeader = response.headers['set-cookie'];
|
|
||||||
let formsAuthCookieValue = null;
|
|
||||||
if (setCookieHeader && setCookieHeader.length > 0) {
|
|
||||||
const aspxAuthCookies = setCookieHeader.filter(cookie => cookie.trim().startsWith('.ASPXFORMSAUTH='));
|
|
||||||
if (aspxAuthCookies.length > 0) {
|
|
||||||
for (let i = aspxAuthCookies.length - 1; i >= 0; i--) {
|
|
||||||
const cookieCandidateParts = aspxAuthCookies[i].split(';');
|
|
||||||
if (cookieCandidateParts.length > 0 && cookieCandidateParts[0] !== undefined) { // Explicit check
|
|
||||||
const firstPart = cookieCandidateParts[0].trim();
|
|
||||||
if (firstPart.length > '.ASPXFORMSAUTH='.length && firstPart.substring('.ASPXFORMSAUTH='.length).length > 0) {
|
|
||||||
formsAuthCookieValue = firstPart;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (formsAuthCookieValue) {
|
|
||||||
logger.debug('.ASPXFORMSAUTH cookie obtained.');
|
|
||||||
return formsAuthCookieValue;
|
|
||||||
} else {
|
|
||||||
logger.error("No valid .ASPXFORMSAUTH cookie found. Headers:", setCookieHeader || "none");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === 'ENOENT') logger.error(`Error: Template file '${templateFilePath}' not found.`);
|
|
||||||
else logger.error(`Error in getMSAUTH: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCompleteCookies(userName: string, userPwd: string, templateFilePath: string): Promise < string > {
|
|
||||||
logger.debug('Attempting to get complete cookie string (login process).');
|
|
||||||
const sessionId = await getSessionId();
|
|
||||||
if (!sessionId) throw new Error("Login failed: Could not obtain ASP.NET_SessionId.");
|
|
||||||
|
|
||||||
const msAuth = await getMSAUTH(sessionId, userName, userPwd, templateFilePath);
|
|
||||||
if (!msAuth) throw new Error("Login failed: Could not obtain .ASPXFORMSAUTH cookie.");
|
|
||||||
|
|
||||||
return `${sessionId}; ${msAuth}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get activity details from API
|
||||||
|
*/
|
||||||
async function getActivityDetailsRaw(
|
async function getActivityDetailsRaw(
|
||||||
activityId: string,
|
activityId: string,
|
||||||
cookies: string,
|
cookies: string,
|
||||||
@@ -270,7 +169,6 @@ async function getActivityDetailsRaw(
|
|||||||
* @param activityId - The ID of the activity to fetch.
|
* @param activityId - The ID of the activity to fetch.
|
||||||
* @param userName - URL-encoded username.
|
* @param userName - URL-encoded username.
|
||||||
* @param userPwd - URL-encoded password.
|
* @param userPwd - URL-encoded password.
|
||||||
* @param templateFileName - Name of the login template file.
|
|
||||||
* @param forceLogin - If true, bypasses cached cookie and forces a new login.
|
* @param forceLogin - If true, bypasses cached cookie and forces a new login.
|
||||||
* @returns The parsed JSON object of activity details, or null on failure.
|
* @returns The parsed JSON object of activity details, or null on failure.
|
||||||
*/
|
*/
|
||||||
@@ -278,10 +176,9 @@ export async function fetchActivityData(
|
|||||||
activityId: string,
|
activityId: string,
|
||||||
userName: string,
|
userName: string,
|
||||||
userPwd: string,
|
userPwd: string,
|
||||||
templateFileName: string = "login_template.txt",
|
|
||||||
forceLogin: boolean = false
|
forceLogin: boolean = false
|
||||||
): Promise<any | null> {
|
): Promise<any | null> {
|
||||||
let currentCookie = forceLogin ? null : await loadCachedCookie();
|
let currentCookie = forceLogin ? null : await getCachedCookieString();
|
||||||
|
|
||||||
if (forceLogin && currentCookie) {
|
if (forceLogin && currentCookie) {
|
||||||
await clearCookieCache();
|
await clearCookieCache();
|
||||||
@@ -289,21 +186,25 @@ export async function fetchActivityData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentCookie) {
|
if (currentCookie) {
|
||||||
const isValid = await testCookieValidity(currentCookie);
|
const isValid = await testCookieValidityWithApi(currentCookie);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
logger.info("Cached cookie test failed or cookie expired. Clearing cache.");
|
logger.info('Cached cookie test failed or cookie expired. Clearing cache.');
|
||||||
await clearCookieCache();
|
await clearCookieCache();
|
||||||
currentCookie = null;
|
currentCookie = null;
|
||||||
} else {
|
} else {
|
||||||
logger.info("Using valid cached cookie.");
|
logger.info('Using valid cached cookie.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentCookie) {
|
if (!currentCookie) {
|
||||||
logger.info(forceLogin ? "Forcing new login." : "No valid cached cookie found or cache bypassed. Attempting login...");
|
logger.info(forceLogin ? 'Forcing new login.' : 'No valid cached cookie found or cache bypassed. Attempting login...');
|
||||||
try {
|
try {
|
||||||
currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName));
|
currentCookie = await getCompleteCookies(userName, userPwd);
|
||||||
await saveCookieToCache(currentCookie);
|
|
||||||
|
const cookies = await loadCachedCookies();
|
||||||
|
if (cookies) {
|
||||||
|
await saveCookiesToCache(cookies);
|
||||||
|
}
|
||||||
} catch (loginError) {
|
} catch (loginError) {
|
||||||
logger.error(`Login process failed: ${(loginError as Error).message}`);
|
logger.error(`Login process failed: ${(loginError as Error).message}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -311,7 +212,7 @@ export async function fetchActivityData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!currentCookie) {
|
if (!currentCookie) {
|
||||||
logger.error("Critical: No cookie available after login attempt. Cannot fetch activity data.");
|
logger.error('Critical: No cookie available after login attempt. Cannot fetch activity data.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,11 +230,15 @@ export async function fetchActivityData(
|
|||||||
await clearCookieCache();
|
await clearCookieCache();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info("Attempting re-login due to authentication failure...");
|
logger.info('Attempting re-login due to authentication failure...');
|
||||||
currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName));
|
currentCookie = await getCompleteCookies(userName, userPwd);
|
||||||
await saveCookieToCache(currentCookie);
|
|
||||||
|
|
||||||
logger.info("Re-login successful. Retrying request for activity details once...");
|
const cookies = await loadCachedCookies();
|
||||||
|
if (cookies) {
|
||||||
|
await saveCookiesToCache(cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Re-login successful. Retrying request for activity details once...');
|
||||||
const rawActivityDetailsStringRetry = await getActivityDetailsRaw(activityId, currentCookie);
|
const rawActivityDetailsStringRetry = await getActivityDetailsRaw(activityId, currentCookie);
|
||||||
if (rawActivityDetailsStringRetry) {
|
if (rawActivityDetailsStringRetry) {
|
||||||
const parsedOuterRetry = JSON.parse(rawActivityDetailsStringRetry);
|
const parsedOuterRetry = JSON.parse(rawActivityDetailsStringRetry);
|
||||||
@@ -351,6 +256,3 @@ export async function fetchActivityData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally
|
|
||||||
//export { clearCookieCache,testCookieValidity };
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,112 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
async function extractLoginForm() {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage();
|
|
||||||
|
|
||||||
console.log('Navigating to login page...\n');
|
|
||||||
await page.goto('https://engage.nkcswx.cn/Login.aspx');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
// Extract all form fields
|
|
||||||
const formFields = await page.evaluate(() => {
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
if (!form) return { error: 'No form found' };
|
|
||||||
|
|
||||||
const fields = [];
|
|
||||||
|
|
||||||
// Get all inputs from the form
|
|
||||||
const inputs = form.querySelectorAll('input, select, textarea');
|
|
||||||
inputs.forEach((input, index) => {
|
|
||||||
fields.push({
|
|
||||||
index,
|
|
||||||
type: input.tagName,
|
|
||||||
name: input.name || '(no name)',
|
|
||||||
id: input.id || '(no id)',
|
|
||||||
inputType: input.type || 'N/A',
|
|
||||||
value: input.type === 'password' ? '[HIDDEN]' : (input.value || '(empty)'),
|
|
||||||
placeholder: input.placeholder || '(none)',
|
|
||||||
required: input.required ? 'yes' : 'no',
|
|
||||||
autocomplete: input.autocomplete || '(none)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get form attributes
|
|
||||||
const formAttrs = {
|
|
||||||
action: form.action,
|
|
||||||
method: form.method,
|
|
||||||
enctype: form.enctype,
|
|
||||||
target: form.target
|
|
||||||
};
|
|
||||||
|
|
||||||
return { formAttrs, fields };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (formFields.error) {
|
|
||||||
console.error(formFields.error);
|
|
||||||
await browser.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('FORM ATTRIBUTES');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log(`Action: ${formFields.formAttrs.action}`);
|
|
||||||
console.log(`Method: ${formFields.formAttrs.method}`);
|
|
||||||
console.log(`Enctype: ${formFields.formAttrs.enctype}`);
|
|
||||||
console.log(`Target: ${formFields.formAttrs.target || '(default)'}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('ALL FORM FIELDS');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Group fields by type
|
|
||||||
const hiddenFields = formFields.fields.filter(f => f.inputType === 'hidden');
|
|
||||||
const visibleFields = formFields.fields.filter(f => f.inputType !== 'hidden');
|
|
||||||
|
|
||||||
// Show hidden fields first (critical for ASP.NET)
|
|
||||||
if (hiddenFields.length > 0) {
|
|
||||||
console.log('📦 HIDDEN FIELDS (critical for form submission):');
|
|
||||||
console.log('-'.repeat(70));
|
|
||||||
hiddenFields.forEach(field => {
|
|
||||||
console.log(` Name: ${field.name}`);
|
|
||||||
console.log(` Value: ${field.value.substring(0, 80)}${field.value.length > 80 ? '...' : ''}`);
|
|
||||||
console.log(` Length: ${field.value.length} chars`);
|
|
||||||
console.log('');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show visible fields
|
|
||||||
if (visibleFields.length > 0) {
|
|
||||||
console.log('\n📝 VISIBLE FIELDS:');
|
|
||||||
console.log('-'.repeat(70));
|
|
||||||
visibleFields.forEach(field => {
|
|
||||||
console.log(` Name: ${field.name}`);
|
|
||||||
console.log(` Type: ${field.inputType}`);
|
|
||||||
console.log(` ID: ${field.id}`);
|
|
||||||
console.log(` Placeholder: ${field.placeholder}`);
|
|
||||||
console.log(` Required: ${field.required}`);
|
|
||||||
console.log(` Autocomplete: ${field.autocomplete}`);
|
|
||||||
console.log('');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary of field names
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
console.log('FIELD NAME SUMMARY (for authentication payload):');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
formFields.fields.forEach(field => {
|
|
||||||
const marker = field.inputType === 'hidden' ? '[HIDDEN]' : '[VISIBLE]';
|
|
||||||
console.log(` ${marker} ${field.name}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Take a screenshot for visual reference
|
|
||||||
await page.screenshot({ path: 'login-page-screenshot.png', fullPage: true });
|
|
||||||
console.log('\n📸 Screenshot saved to: login-page-screenshot.png');
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
extractLoginForm().catch(console.error);
|
|
||||||
@@ -3,9 +3,15 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run index.ts",
|
"start": "bun run index.ts",
|
||||||
"dev": "bun run --watch index.ts"
|
"dev": "bun run --watch index.ts",
|
||||||
|
"playwright:install": "bunx playwright install",
|
||||||
|
"cookie:get": "bun run test/get-cookies.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:auth": "bun test test/auth.spec.ts",
|
||||||
|
"test:ui": "bunx playwright test --ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"typescript-language-server": "^5.1.3"
|
"typescript-language-server": "^5.1.3"
|
||||||
},
|
},
|
||||||
|
|||||||
90
playwright-report/index.html
Normal file
90
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
24
playwright.config.ts
Normal file
24
playwright.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig, type PlaywrightTestConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './test',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'https://engage.nkcswx.cn',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
headless: true,
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
expect: {
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'echo "No web server needed"',
|
||||||
|
port: 0,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
32
services/cookies.json
Normal file
32
services/cookies.json
Normal 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
188
services/playwright-auth.ts
Normal 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);
|
||||||
|
}
|
||||||
25
startup.sh
Executable file
25
startup.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Starting DSAS CCA Backend..."
|
||||||
|
|
||||||
|
# Check if cookies exist and are valid
|
||||||
|
if [ -f /usr/src/app/services/cookies.json ]; then
|
||||||
|
echo "📁 Cookies file found. Checking validity..."
|
||||||
|
|
||||||
|
# Try to fetch a simple activity to test cookies
|
||||||
|
# If it fails, we'll get fresh cookies
|
||||||
|
if ! timeout 10 bun run test/test-cookies-validity.ts 2>/dev/null; then
|
||||||
|
echo "⚠️ Cookies are invalid or expired. Getting fresh cookies..."
|
||||||
|
bun run test/get-cookies.ts
|
||||||
|
else
|
||||||
|
echo "✅ Cookies are valid. Using cached cookies."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "📁 No cookies file found. Getting fresh cookies..."
|
||||||
|
bun run test/get-cookies.ts
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
echo "🎯 Starting application..."
|
||||||
|
exec bun run index.ts
|
||||||
118
test/auth.spec.ts
Normal file
118
test/auth.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import { chromium, type Cookie } from 'playwright';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const COOKIE_FILE_PATH = resolve(__dirname, '../services/cookies.json');
|
||||||
|
|
||||||
|
const testUsername = process.env.API_USERNAME || 'test@test.com';
|
||||||
|
const testPassword = process.env.API_PASSWORD || 'test123';
|
||||||
|
|
||||||
|
test('should login and extract cookies successfully', async () => {
|
||||||
|
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();
|
||||||
|
|
||||||
|
await page.goto('https://engage.nkcswx.cn/Login.aspx', {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
const usernameField = page.locator('input[name="ctl00$PageContent$loginControl$txtUN"]');
|
||||||
|
await usernameField.fill(decodeURIComponent(testUsername));
|
||||||
|
|
||||||
|
const passwordField = page.locator('input[name="ctl00$PageContent$loginControl$txtPwd"]');
|
||||||
|
await passwordField.fill(decodeURIComponent(testPassword));
|
||||||
|
|
||||||
|
const loginButton = page.locator('input[name="ctl00$PageContent$loginControl$btnLogin"]');
|
||||||
|
await loginButton.click();
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 60000 });
|
||||||
|
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
|
||||||
|
expect(cookies).toBeDefined();
|
||||||
|
expect(cookies.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const hasSessionCookie = cookies.some(c => c.name === 'ASP.NET_SessionId');
|
||||||
|
expect(hasSessionCookie).toBe(true);
|
||||||
|
|
||||||
|
fs.writeFileSync(COOKIE_FILE_PATH, JSON.stringify(cookies, null, 2));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
test('should load cookies from file if exists', () => {
|
||||||
|
if (!fs.existsSync(COOKIE_FILE_PATH)) {
|
||||||
|
throw new Error('Cookie file does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
|
||||||
|
expect(cookies.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should test cookie validity', async () => {
|
||||||
|
if (!fs.existsSync(COOKIE_FILE_PATH)) {
|
||||||
|
throw new Error('Cookie file does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
await context.addCookies(cookies);
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('https://engage.nkcswx.cn/', {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = page.url();
|
||||||
|
const isRedirectedToLogin = url.includes('/Login.aspx');
|
||||||
|
|
||||||
|
expect(isRedirectedToLogin).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test('should convert cookies to string format', () => {
|
||||||
|
if (!fs.existsSync(COOKIE_FILE_PATH)) {
|
||||||
|
throw new Error('Cookie file does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE_PATH, 'utf-8')) as Cookie[];
|
||||||
|
|
||||||
|
const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
expect(cookieString).toBeDefined();
|
||||||
|
expect(cookieString.length).toBeGreaterThan(0);
|
||||||
|
expect(cookieString).toContain('ASP.NET_SessionId=');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear cookie cache', () => {
|
||||||
|
if (fs.existsSync(COOKIE_FILE_PATH)) {
|
||||||
|
fs.unlinkSync(COOKIE_FILE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = fs.existsSync(COOKIE_FILE_PATH);
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
24
test/get-cookies.ts
Normal file
24
test/get-cookies.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { loginWithPlaywright, saveCookiesToCache } from '../services/playwright-auth';
|
||||||
|
|
||||||
|
const username = process.env.API_USERNAME;
|
||||||
|
const password = process.env.API_PASSWORD;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
console.error('❌ API_USERNAME and API_PASSWORD environment variables are required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔑 Starting cookie extraction...\n');
|
||||||
|
|
||||||
|
loginWithPlaywright(username, password)
|
||||||
|
.then(cookies => {
|
||||||
|
console.log(`\n✅ Extracted ${cookies.length} cookies`);
|
||||||
|
console.log('📁 Cookies saved to: ./services/cookies.json');
|
||||||
|
|
||||||
|
saveCookiesToCache(cookies);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('\n❌ Cookie extraction failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
35
test/test-cookies-validity.ts
Normal file
35
test/test-cookies-validity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const COOKIE_FILE = './services/cookies.json';
|
||||||
|
|
||||||
|
async function testCookies() {
|
||||||
|
try {
|
||||||
|
const fs = await import('fs');
|
||||||
|
if (!fs.existsSync(COOKIE_FILE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = JSON.parse(fs.readFileSync(COOKIE_FILE, 'utf-8'));
|
||||||
|
const cookieString = cookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
const url = 'https://engage.nkcswx.cn/Services/ActivitiesService.asmx/GetActivityDetails';
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
'Cookie': cookieString,
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA)',
|
||||||
|
};
|
||||||
|
const payload = { "activityID": "3350" };
|
||||||
|
|
||||||
|
await axios.post(url, payload, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await testCookies();
|
||||||
|
process.exit(isValid ? 0 : 1);
|
||||||
Reference in New Issue
Block a user