From a8f468a4972e7cc00114b43edafea42588032201 Mon Sep 17 00:00:00 2001 From: JamesFlare1212 Date: Mon, 6 Apr 2026 16:05:38 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=20Playwright=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=87=AA=E5=8A=A8=E5=8C=96=20cookie=20?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=92=8C=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 新增 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 等错误场景处理正确 --- Dockerfile | 40 +++++- bun.lock | 9 ++ docker-compose.yaml | 18 ++- engage-api/get-activity.ts | 244 ++++++++++------------------------ engage-api/login_template.txt | 1 - extract-login-form.js | 112 ---------------- package.json | 8 +- playwright-report/index.html | 90 +++++++++++++ playwright.config.ts | 24 ++++ services/cookies.json | 32 +++++ services/playwright-auth.ts | 188 ++++++++++++++++++++++++++ startup.sh | 25 ++++ test/auth.spec.ts | 118 ++++++++++++++++ test/get-cookies.ts | 24 ++++ test/test-cookies-validity.ts | 35 +++++ 15 files changed, 679 insertions(+), 289 deletions(-) delete mode 100644 engage-api/login_template.txt delete mode 100644 extract-login-form.js create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 services/cookies.json create mode 100644 services/playwright-auth.ts create mode 100755 startup.sh create mode 100644 test/auth.spec.ts create mode 100644 test/get-cookies.ts create mode 100644 test/test-cookies-validity.ts diff --git a/Dockerfile b/Dockerfile index 127d525..03ac8ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,53 @@ FROM oven/bun:latest ENV NODE_ENV=production +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +ENV DEBIAN_FRONTEND=noninteractive 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 ./ +# Install dependencies (including Playwright) RUN bun install --production +# Install Playwright browsers +RUN bunx playwright install chromium --with-deps || true + +# Copy application code 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 -CMD ["bun", "start"] +# Use startup script +CMD ["/bin/sh", "startup.sh"] diff --git a/bun.lock b/bun.lock index 7b003ec..9dc4dce 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "uuid": "^11.1.0", }, "devDependencies": { + "@playwright/test": "^1.49.0", "@types/bun": "latest", "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=="], + "@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/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=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "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=="], @@ -205,6 +210,10 @@ "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-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], diff --git a/docker-compose.yaml b/docker-compose.yaml index d08f0f1..7108681 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,21 +5,29 @@ services: dockerfile: Dockerfile container_name: dsas-cca-backend ports: - - "${PORT}:${PORT}" + - "${PORT:-3000}:${PORT:-3000}" env_file: - .env + environment: + - NODE_ENV=production + - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright restart: unless-stopped depends_on: - - redis + redis: + condition: service_healthy + volumes: + - ./services/cookies.json:/usr/src/app/services/cookies.json networks: - cca_network + mem_limit: 1g + cpus: 1.0 redis: image: "redis:8.0-alpine" container_name: dsas-cca-redis command: redis-server --requirepass "dsas-cca" volumes: - - ./redis_data:/data + - redis_data:/data restart: unless-stopped networks: - cca_network @@ -28,6 +36,10 @@ services: interval: 10s timeout: 5s retries: 5 + mem_limit: 256m + +volumes: + redis_data: networks: cca_network: diff --git a/engage-api/get-activity.ts b/engage-api/get-activity.ts index b78bd6a..dede196 100644 --- a/engage-api/get-activity.ts +++ b/engage-api/get-activity.ts @@ -1,13 +1,18 @@ // engage-api/get-activity.ts import axios from 'axios'; -import { readFile,writeFile,unlink } from 'fs/promises'; -import { resolve } from 'path'; import { logger } from '../utils/logger'; +import { + loginWithPlaywright, + loadCachedCookies, + saveCookiesToCache, + clearCookieCache, + getCachedCookieString +} from '../services/playwright-auth'; // Define interfaces for our data structures interface ActivityResponse { d: string; - isError ? : boolean; + isError?: boolean; [key: string]: any; } @@ -15,71 +20,19 @@ interface ActivityResponse { class AuthenticationError extends Error { status: number; - constructor(message: string = "Authentication failed, cookie may be invalid.", status ? : number) { + constructor(message: string = "Authentication failed, cookie may be invalid.", status?: number) { super(message); this.name = "AuthenticationError"; this.status = status || 0; } } -// 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'); -let _inMemoryCookie: string | null = null; - -// 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 > { +/** + * Test cookie validity by calling API + */ +async function testCookieValidityWithApi(cookieString: string): Promise { if (!cookieString) return false; - logger.debug("Testing cookie validity..."); + logger.debug('Testing cookie validity via API...'); const MAX_RETRIES = 3; let attempt = 0; @@ -98,123 +51,69 @@ async function testCookieValidity(cookieString: string): Promise < boolean > { }; logger.debug(`Attempt ${attempt}/${MAX_RETRIES}`); - await axios.post(url, payload, { + const response = await axios.post(url, payload, { headers, 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; } catch (error: any) { logger.warn(`Cookie validity test failed (attempt ${attempt}/${MAX_RETRIES}).`); if (error.response) { - logger.warn(`Cookie test API response status: ${error.response.status}.`); + // 4xx = auth failure (immediate fail) + if (error.response.status >= 400 && error.response.status < 500) { + logger.warn(`Cookie test API response status: ${error.response.status} (auth error)`); + return false; + } + // 5xx = server error (retry with delay) + logger.warn(`Cookie test API response status: ${error.response.status} (server error, retrying...)`); } else { - logger.warn(`Network/other error: ${error.message}`); + // No response (000 status, network error, timeout) + logger.warn(`Network/timeout error: ${error.message} (retrying...)`); } - if (attempt >= MAX_RETRIES) { - logger.warn("Max retries reached. Cookie is likely invalid or expired."); - return false; + 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; } -// Core API Interaction Functions -async function getSessionId(): Promise < string | null > { - const url = 'https://engage.nkcswx.cn/Login.aspx'; - try { - const response = await axios.get(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)' - } - }); - const setCookieHeader = response.headers['set-cookie']; - 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; +/** + * Get complete cookies using Playwright + */ +async function getCompleteCookies(userName: string, userPwd: string): Promise { + logger.info('Attempting to get complete cookie string using Playwright login...'); + + const cookies = await loginWithPlaywright(userName, userPwd); + + if (!cookies || cookies.length === 0) { + throw new Error("Login failed: Could not obtain cookies."); } + + const cookieString = cookies.map(c => `${c.name}=${c.value}`).join('; '); + return cookieString; } -async function getMSAUTH(sessionId: string, userName: string, userPwd: string, templateFilePath: string): Promise < string | null > { - const url = 'https://engage.nkcswx.cn/Login.aspx'; - 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( activityId: string, cookies: string, maxRetries: number = 3, timeoutMilliseconds: number = 20000 -): Promise < string | null > { +): Promise { const url = 'https://engage.nkcswx.cn/Services/ActivitiesService.asmx/GetActivityDetails'; const headers = { 'Content-Type': 'application/json; charset=UTF-8', @@ -270,7 +169,6 @@ async function getActivityDetailsRaw( * @param activityId - The ID of the activity to fetch. * @param userName - URL-encoded username. * @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. * @returns The parsed JSON object of activity details, or null on failure. */ @@ -278,10 +176,9 @@ export async function fetchActivityData( activityId: string, userName: string, userPwd: string, - templateFileName: string = "login_template.txt", forceLogin: boolean = false -): Promise < any | null > { - let currentCookie = forceLogin ? null : await loadCachedCookie(); +): Promise { + let currentCookie = forceLogin ? null : await getCachedCookieString(); if (forceLogin && currentCookie) { await clearCookieCache(); @@ -289,21 +186,25 @@ export async function fetchActivityData( } if (currentCookie) { - const isValid = await testCookieValidity(currentCookie); + const isValid = await testCookieValidityWithApi(currentCookie); 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(); currentCookie = null; } else { - logger.info("Using valid cached cookie."); + logger.info('Using valid cached cookie.'); } } 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 { - currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName)); - await saveCookieToCache(currentCookie); + currentCookie = await getCompleteCookies(userName, userPwd); + + const cookies = await loadCachedCookies(); + if (cookies) { + await saveCookiesToCache(cookies); + } } catch (loginError) { logger.error(`Login process failed: ${(loginError as Error).message}`); return null; @@ -311,7 +212,7 @@ export async function fetchActivityData( } 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; } @@ -329,11 +230,15 @@ export async function fetchActivityData( await clearCookieCache(); try { - logger.info("Attempting re-login due to authentication failure..."); - currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName)); - await saveCookieToCache(currentCookie); + logger.info('Attempting re-login due to authentication failure...'); + currentCookie = await getCompleteCookies(userName, userPwd); + + const cookies = await loadCachedCookies(); + if (cookies) { + await saveCookiesToCache(cookies); + } - logger.info("Re-login successful. Retrying request for activity details once..."); + logger.info('Re-login successful. Retrying request for activity details once...'); const rawActivityDetailsStringRetry = await getActivityDetailsRaw(activityId, currentCookie); if (rawActivityDetailsStringRetry) { const parsedOuterRetry = JSON.parse(rawActivityDetailsStringRetry); @@ -351,6 +256,3 @@ export async function fetchActivityData( } } } - -// Optionally -//export { clearCookieCache,testCookieValidity }; \ No newline at end of file diff --git a/engage-api/login_template.txt b/engage-api/login_template.txt deleted file mode 100644 index cb74dc4..0000000 --- a/engage-api/login_template.txt +++ /dev/null @@ -1 +0,0 @@ -ctl00_ctl13_TSSM=%3BTelerik.Web.UI%2C+Version%3D2021.3.1111.45%2C+Culture%3Dneutral%2C+PublicKeyToken%3D121fae78165ba3d4%3Aen-GB%3Ab406acc5-0028-4c73-8915-a9da355d848a%3A1c2121e&ctl00_ScriptManager1_HiddenField=&__LASTFOCUS=&__EVENTTARGET=&__EVENTARGUMENT=&__VIEWSTATE=rPMrRuXLW4r982%2FjtXIzg5KQHHkPcmeDnLXNJi0QLKdDmyWxZthrQRM%2BZmBoVqz36ID%2FVtBYVYvBmad1sEDbGdpc96Dh33ZgcbhIaS%2FNR%2BJqh0XsLa8sVbow0Fs432yWZWvDmHt%2FaVEbDONK3P6MVM4lwNN44TBoAwXTv1cDL9Lbj0%2F1xwomdDT5wa04z0mzg7W3MNhCXZIjTJFS%2FHDyJwES2PtCdvdSdNrxpX1VH6rOfSTCWIttomG%2Fc%2FNJHW7c8%2BoMuVsro9N93g%2F132Z9pe3YUjjjUxr83sALy%2B87kc87YkRtihWGgrsF5OHWIfR0ZJwZsYlSCKq%2FyP39jprZ15j%2BO74APCcaDxDMFqguKJ7GRpEInOBOzm%2FsovIMYuEUJf3K7zQ%2BQB1I%2BPvS6MKzVYaLGA6C%2FkoFA11r2LC9uRxTZ1XnMpEyjvcySylY%2BINt1czr4YDSmXEnGRK0txEgLTYtwLFhfQE37YLU4TBeUTanH48h44kkmTb1gwzOg6HT3aOKezsW2LpvHuQnr%2BZffMtjPOLaZRRN78cDfF6gnVpF%2FpO96hYOJ3a7pnGOYnDiDhOhi5W%2Fco6EwjcJZqTLQhqSbY8biJshPQjKE8yLgQ49pL0ShN2cJfUAO8dmTnaB2%2B2Bv6n3zPpF1kaRI8Dc3pkaGycicrToEKkMBuqnaSsnIAf309Al0tbwsW9kfi3VTPw3qFQnUjawFJijhcBZB2prZ%2Fz49UzeRiXWw12YCUlZep9GLp9u8Zi16ZgW8eVcO87cL5vMPcEBLwe2CVaMZ5rh%2Fskz7nqx%2BhfPdRUmlnp5d4w6Vjv7K5tuyhCSG3tFN%2FeoweC5EOxnMkaYxZKA0haZIeNckuXLNSDPd2%2FMyXFwD99eIGQTVhDORL1cuAaDBGYh7N6fhiAfexJ%2B1C%2BH9zy7SGn1XC3H%2FQWkNkHU8wNzVPtnte%2BWmZAKIsbKS6rxa3%2BtYc8eq%2Fygd0qQ%2B76jl2Mwx%2Bmj8SpcVrFgFmvR10KuYm7cciOnJjS%2FYkKDHg0AuTiw3opzEdZ0mVi0vMm0y0zLWOYhinjOup%2FBOLtSFPC4m4xxaMeiZXfSDk9JGwF31MuVyeQ%2F%2Bz4l2UOktufk5n3%2BUepsa36K57p4SeF6r6CPq3UrTnfOX%2BteSCiigi3UEDjkjnn4YjIkPlx2Z01B%2Fmg8B8mg5xhM6eOkEqE65%2FlC8%2B2224%2Fx%2BgWbSCxM87WNT2p4KlEpqClV5EEQgyntx%2F20eLdUvf8EXNcl6u%2F6exH5lu0q2EwIutkngukVNiAzAaptESCGxG21RdS2%2B5zroo1sMPxlTQLUTo5L1%2FOhcXQRarcw5lHtO7uxpeQvLN32P%2BMrKXGUYctJLGqHz6MVzpgXjUO4vEcRGPUsJ0yaXwk45%2FOd2S%2F6jFtKM1kWk4CBG90HuDo5scHSpUxTIm4fO7LS5AtKJMGpgFTRSUgdhQ%2FuOHuImFQRycQkxdYM6p3ey5WPVWEysjsETW8y9IrT8wBDB2B2BLCXUNCLZgQm1mBln36yvWR7gTwmdbPF3SZLvQfrQA%2F2G5h2JufFm1GyvDRoVuohKJCRnEJvnIAZLNP9ZCBCyKExhqC6%2BDye%2BJjX8jXbyYqqYErMO%2BruvhOdBQeA2L1bbU%2FQojPG6osPyywnwMCtRbSSefr7JgAXN3YAJYFNKr5smD1Mugc2NIaZhPnX%2F1A64YiCbQ8R0ph6pzM5FWpp9Htk1UbaLtqGv4S5%2BoeXnUjvtbtSWV4yFCiHxIt1kqXkpwQri8gsbLR%2F8rXyTWhQyL08EjvNZlKxccIuTTgQD%2BmXEToF6fBd8rNYPKHy6fihiO4b5yNIy46veTs0Y%2BNg3g8EGG5LbyG3DWu5GBQLp9GYZMbof2DADx2%2Bgs1Ray5QunIlYpOTeEgvxjeRPf%2FrN64KZqlcNGVg96WPZ8cLXVarWF63bkdw2ODqVYT40K9lOuffbi762Ex912nXWh95dwDOU9Qb%2FkdrZb330E%2FmgxFyMMBTJPwigiwLpRJmZePGSg%2BJ8iikdgTdt1jQjAHHRpGwfTrgZwzCZSzkDo6JkZhuY2ifH%2Big%2FLWCiBjcJBx0dTewvQyeZGRsaTEndOQmPdQuzQMZbvAUGrj6Eb3XlUpR1sDqi0nKz75ubbwgxYLulmJ%2BkAIxxMasgrb2uYuB9ut%2BxmYmDH8nqYuxq52KlhpXq3BcanCHiP6W2b%2FpqlGJhrk2kJ9EgBd%2FXTHZ8noqKaJMQZSqjDYeAeRNRz67Y%2BP6ZRXn9aRc5NzpumRYye6yf3eCmX9Xxf1bpXxNsjSygpTR%2F5MjcjlibDKkxoJkIl%2FXa9mSgNnVNqYd3afWJSaVJDZ17Uu8S6xf4Wm%2FkqGAg5XFiWy8XJr91t8xVBwuyBbAmQgYx%2FeKrACOtP9Oqyn%2BgByAcL%2BItHZmo6K1SD61NGfApS1JVFrd%2FCWNM%2FuiBhU0pGhqCzEGVHhIPzaEcIn8HhfTiMMOsLPMS4N1Sjb2W%2Bljoua4CxOn8E1JkGdZybEHVn1f3i5BlRk7S46RHBcoSJJuLNGbV%2BlTnANgMkwnr25yZPt%2FK5bFXv1GwJIKfxetWycYqIVMtIpe1DKNG6Vq2pClrN665xlIaSqlBHuetUY5eOk6c7aUBNKq5NpStL6KmgUcrC9MwCogX%2F1i9VM03plkPLd%2BLwusC0L6nTZ2F5wgNx4DdFDDpiIni8a1iWvIKBZb4sViuB8GiGUIl%2BjIxqHALQxdRyc9TUu1Mf3%2B9XDQZ7iFDi2FHYtoHmNN%2BWLF3euKk6Mzfh8QdJYbwtsKTVxTEx666EBTE5HR0McWt%2B5sQZ9Ephhii931VUWxssMDTlfrq4dwUt3mH%2FxK1SQDFyx9Z9wyXfKFkUmAzgtj6s8TvlRUCG5Nb3E0Wi4TJyZQhNq6ZmERbg4XhYSYpmXCR8A9hJNj7S4fWLr4KAyq6LUbGbHzaDKMvZdEeUmVNF2GcrtfzrZreWcF%2BOJ1ydPxyGD8wq1SxTW%2FOVkV%2FZjLIakyzWWhDXkkVlMsNxHckir%2FofhWfNFSHrS6Q4fAvw3jE%2B75rVpEw3eavwWP6sRmr7SBYoBofVetoVu9sO0Rf9v7gSGig157FmXsCOYLh%2BP8vJ6WxZiZv4wEUVSGsx%2BSgyuVJ4x6CUqOsWGENnh4zAwcUfH3917U1J36MgDf3zaRvH%2Fh%2FFXzYyLRbJUKu1mBXazw3TSnhUxwrTFeMv1j31LTcgNyD3cM81YOibLAzFFgz5v%2BWb1ZiSI6s9wzD%2Blis%2BwSk2Qo064WqzxqNXcLE7%2F0IAJulXTHkQE0nka7zPtpaIoA4u%2B%2B0qvrWlTEeKb0Nbtl2gIkXlmX0AmwlzZQm1z6Erm0HjBpCHilYSPwU4RfGSs5LPJeJ9nyF7RE8JV0AHMEZDN3yVEdT27zVnnjtDd9lDlx2KJ0oJkqF0WO882QkvmWAW%2B66DfPg35%2BMBQZY2GNW6Q%2BcIfcaFt4VQM%2B8YPuyRpqDlhE2wxC%2F%2BWx6P3dKlRPRH7ZEjUbUlCNsane4pNjrSk0uY1%2FUA7b6kD6typ8zYpuDFchjxwYrhAv0EvFgLCP78NWTDI28Ak1TNa%2FZUvDFgj4mWfONhCBqYdk20OmzQx8hmiqy%2B0uZu2%2F5EQOsfW%2BGPF%2Fp6w98LqCypkLGPoTRC53vHCeyxWRuDxNwTQO2JFOE85InGReh8%2F4SsgVa6xRo29tVjMuh1qoXubLyXkplI7TMN7DMdehpQ%2FJxpF1dhDAqLjoalix0KAi0w6om4AUjuwJ5UuMoF%2BEeCPWZn9%2BJNTR7L79BCizp1FPMV7O4H1EZ0oSYKJFy4dDZudRc2hO4f0ZUqy0%2FhF5eDxVYCRnhno9h0laZMuPctCU09MEpK5XZJCAN6wdOKFf40ax1FRdOh%2FNN%2F0UEmQA3A%2BQK3RTLhEg1yQg32tHIrFX0jOWHQb7PB9ouK6uO3qPQRyxQiGNeEJbpxdy%2B8g8GVXRcVLiP%2B%2BLTbnr7UnVMueIeoVCmZuWSd1aDVC1zShPOfI9q76KuL6etoQ71TNOLgk33UK%2FljBVzVjnjNj2fVVdswD7t69hEwEOqGW6iGS%2Fr0xMeeacnbBacAXsVgGtAqBCWoUP22M1As1XSQAhCZnYpysqDbfwQ0MATpbeaECd4a9AcItdBaTBgQfGHrYC%2FKKUyNO3EbznNrPLTec%2F0XV2HC9dofXXMlzGrtPaMAJRiyfWCRrPTEH%2F8sAru7jjaqU3xByrH%2BrVIJH9McVceLdRxRZm8VRrfSzLl%2FHqRpCxci5xsHTwdnuCp5Es2%2BwJqfmrxskjbfPM7jTmlzR6HMDUWrzVao0QLVxLU36KnEIhD0JUTP1vARgqeSrU5YD4eBol3q5BR5Ncr%2BE5hrcZNyOgvgicAgdht9rxPQzZTigx4b6CU1RgLBIGzmNoRjym1mQmGeurW3Bscznl6yQz0UuZO0fIA%2BqP9nnNJhjo2y4c3EOKWG4Sy%2BUypvnoAwDhUhodRV2G0fjT8tb%2FOC8fnI%2FMtOYVqfb4fjfQFKDjh7Mimq8HoQQXru3eRWa86EXX%2FR1h3nwXKBsD1scF9ET4kQhr%2BRFqlaK7GGe2S3aPVdBFEPG7vuHPasaP7cbsXJnB4CEtNQwUuhnROyWWA7qqC1mtTz70DUyYZ0W9vxA6VY3b3qssTdURUFTnKfjx4nhA6G3Sbd6fzduRH5MR4lrRH1GodgAPTWlgUcOEc%2FJu9pINnQGNj2O0gWH%2FQPD6UBo02HLquCGLHk8tqMhh9TWpSB8IEKA1A2ACveHSto%2BHjqtQBXUGlkpFEKoE3CNAS2H%2F%2B6FAK705XNiCf%2FVDxsxiEr%2BhmTGZzjXx6QHvArqYmUPEz7eBxTlIz4o4FWWWNHinRc%2FGvfuh1IGnziq55pvwXFyk4weXQBcPv3IGjxbQD8ZXT4US4Vw6iXah6PDUQfI0sBBvLv%2Bui1XZrNr6x8RNldDL38bH0VAQpjucy32MQ4aR%2BX6QWcofQPCgA%2Fva3ybPGUK6BoMOd2O3AjDHKuha2Usv3vdyz38n2hwd%2BHR%2F13hlqbT2Nmu%2F%2FjSiK%2B96yoARkbrydN%2Fp5mYOtzna%2B%2FAfv7Tvc0aWhqKh22z4ZFL2MoUPeYqG7%2FbHEKpVQLI1AtQGkOJfxJp95bpYNY%2Fd5XFRzKTamEYoDl2x0HExLuC8QcLbthAReQ796%2F6noanuHLFvxm0OpziXcYK7HIjWRaVvsLICVmcr1IhEeriCf6d2LBqK4DlVGs9Pqj6s87Rkt3J5P46nTqnFZ1SuMTddTilQ5A6Qrid36h99lTeTgBIX0hg%2F0yUN0htLax%2B1czryqsRbb78rDuTcmy9TzbiqgmcGfBTmTdGjKYJPyl%2FAohwoe3Jz72AEqQPKuMzxOqfPAP%2FgMHOJEVA5NqBlzziABdq14HESAOn5lwmJ8wVghBGcnZqWrVJNgVo3yMU5Aw8SHz8O%2FYHq4cQ3f46HMeHLQky7z8ozxvwi2mYQotN2bJ7cbJsuPvHX1E9%2B8qUxK%2FKut%2FXJ6HmOp3jorqLO6hglHXZf37mT7CdfT5AjQyTDYpIB25%2B6yTcIi%2BvQ959Q1FI7p%2BckwKgaGsMXmwD5WBjpSWk%2FiCuqSicoyj9Z6IATDo9aNSmbxBqH4c0QTgSj4xNtf8VBY6jIrRrH8XLLFkHvqieqiDARhKDmzqiaetrg4sK%2FMUEA87lgg%2F9lxgvCq29opnxR94BYCb6MNHUSm0cVKRuw31HkTAexOVC5Iu7faBz2LXuQK4Hknfr07H6gg2kLYvSnX2Y5orAr1aYh6VEj9SxjJRTC70jpJePo%2BzVkMYlcY0BP4jiNytw0psBdJKkml5pJgUDhaVCWg6cerglOUGRqw6tutJh0z6iKlsvZW%2FOq9q3Y5nMBXsyKBRA5kU3yPk%2BOLXfBZs2BHTxFPBMjmCNmow1R3j3V3c5jQgtEzQ9VO8Sdv6756pUDJwiymVcng1X9MX2E49hjil2AhOsHPhXea14tT71pt0Hb3d6Zq99hz0UEOr8ZZsGV%2FEAp%2F7hygdNvz7raAusabllrmPYLYFcPNZDzmPf4dYEWaKbEaEIo1K4fKDUvHirhPcrlxXegFelwg4K2xZYV09AgP1lRfs0VfaWmpgQicDId80%2B88m0BD5N%2F7i49GKkqZ2cvVvYKKoIkEsxmRCucDpxKaxRSnaFSWAsgoail4Tq4qz809p9cKWAyZXeU8nWcGI5UpAo%2B%2FRVWmKakZgs8Ub27ZbCWSxImNgktIgLqqAvKNW0ysENyYFtKLf0axKOAknSUnDuKv%2FEHwYvUHRP9GpWSwiEsLzjaBvNO7RKlMbqo9OTUR6glZD%2BGEDWtv4ltadegdkqQ8x8Y5OpnUDgNsL7oT7q%2Bg79FrmqrCN9phGMuiaRqTLRsOEHPJdKtBYmTkzuJpKLL2cWZIHwPSQ%2FaUPdvpnkfaqyMiqk%2BcroKpalRI6cBdS2fNQd4MiwkW9gCz0srDXEZR2nenQFRhPXyLJRIEooLPLwPnYvqpApaLxkr%2BVP5%2Buup%2FSXN0VyhnXPShKEHmIn8NMq5EwMe0Yp%2FDHdg2iw7edfjVd234ptyJG%2BC8C9p7ftezK2C8snu5fC2cRsvjZ9huwHdyARvUm0StMPsc9rB7c5k2HZGZLSdGmAk4JF7flr6CkSk8RnYaNCW2jlJvYWs4otIypqVAXE3hdbLl1bQBiYxg1CeP6Kp1%2FJlkzDhc4ju2jWsYFbiq%2BIG3mplEsYpBisl924RzJTsa%2B8ThfsCfeZjFt9fzmwp1mkZg2KdlfwcfFz3XTH0dmh8e%2BxP3fM4YR5aXIgzUpvEmRTVwIFhakr5QIBhVoZCicmeHiJCMurrFp59mG9daNXPH72VHtlBmcI9X7iLsI9Mw2Ff5%2FCZiFD7xsxnB7ys4Lvl40QBHQqsthHudpXQh0BU%2Fw13xSE3CrJMUNISss19U%2FiHAFGZB0EnBXmmJVY4EthRzYrZG8OZX8Kkx0VgwASHWlA2uvoNrZT7k2dhmzQDgSqyCpGnYyQvSDk1Bm1iWT%2FMpJhliQIWmHSWWAVk6Yt4BcJlGP4Qs%2F1%2B2BVCA0NOTEop%2Fr3Jot3kTRANy58feUZaIByA4O%2BVOIUR5r21UecY9tGXcpjAtdi55x2DIhKX1TtXRfZUOgdyqiYUNfCEqNkLMk%2FyaSqvGgjQDNM%2FQp5g53TzT3euQUuCCzKLDv3d0NzCukOI81%2BEp8OhwZfAD2SDMO94%2BOF5QCU%2Fv5SnCoU88tedPRG%2Bd4BQb5G2YtsJmASqRNRkYWacPKreb3m4e9EkmGodVmn%2F72ZA%2B6GRGtM2szKQdTDQSGdEER7Bw%2BlNH4JyYy7yDoSPBWjAL1wBHhQrMidqORIi8GauLi2o03y2W15dR5TSsCc%2BenKlnVMnBXgAQMrDMstI0HXd7Ur5arSwxaiNWE%2FUAf0l8CDmVJf%2B84PJYmYdXkZhbkuNO2cE3e3r0SrXXBpKO0DM4hOFH4%2FJmQC52jjBpD%2Bf1P96rjT3iDu7tTK57tZnL%2BeQ2mGREPEaquZte7La8BX07WeUdi7JpF2Xjm%2Be3BWjEFAMhLPTbnsOLlKDd1kIjsqkRCU6Sdq%2BLb%2FkQ0a9i25KtkktD%2FNSsgtYehFw1dr%2Fey76FzfT%2Fw69r%2BQ%2B6F1FZ2kBAIL5SGyGfJwyOfCN6b8BsFvu60qLaOHwtHVwAoJqy6AvSKwXE6mfSfsiONGrSlEG5PXycx7BE30dYY1Ret2Nz85kVXC1zReOV%2BSclI1GJXWQEhZJjhsoel8bf%2FO2fizn4HXWgiWiaBeO0ZOUm4hfdEzKej7qxhQvMWMXgP0BWcbv5mKiwtj6nmDw0aiWjPddm%2FOpwCqd3ArbDLEx6OMfiYIx3AiOD4ijSbdv0NSqnbtVgrb7yCBkdkB2%2BQ9xT61Rj0fEGKRJIbuOBgJlbUHTevzZ9%2BMJ0Q6k1kqyzfPylh64N4F0cbdriuI5YXqntD%2FpXPu2Eau38N8yfscJLsoLPj3yB%2Btb4Vmmi90mZlx4nWZgd%2FlsAMZQ0l5ytU5eL63iZjoheg42TIoEHVugEAnDhQaIL3flu4ab4DRdYAGPZEsZ9p%2F76FqcIpZSxAKR74i6T9YCqaJKb9HJ4qCM6CJRTBhMd1GLLP%2BUjXGY3nw5EkdKS%2B7Fgv9jtk6CboBsJhdh8A7nXXFtRUBwk0bhFQxHzU2bHTqPXPYKENyNMB5%2BcP090bp657J%2FID5nUytxAMetxc6Ou6ayHLw40YtcUEGZQewocuyMYHMFUjWsZ5hy%2BsVCOPOjlIwxxiz7kBNiEOosB9PoZSPn6z0SyXzaklabPYjCRjIkjgMM9iZgd0Np%2FJ%2FqWZ90CVPHL6iFAUNsRayIr1b4jyECMub6Mv7w3BU825bnL1NPpj6ik6JHtRvOaNlh9doNoA4QMcfDrTyCFtfbvlymmTMGOQ42bG5QfPx%2FKblQ6pe9KfqNfdFFpe%2FGiGG8b2i9YeL5LHL1fXZgyBRd4nHqIPs%2FS3P0t9MN29o6fkHs1gCdr0fQSc5gydAABtrfrG%2FRigolxAZafbATS%2BKJOkRxKiT%2BkNy679soz%2BzP7Afkv4V86YLn6e6JcbVX6MMUiOY69I%2F6Q1ZlFiwfAJSoVPcVJbveiFtBYCs%2Bbv4OKyRDC8GzW5WgzJb2qEMNVAXXVfiDkAEq7oqyUpxpg57ZKePN91dqtF%2FMz1RHKBxY7WQpBDkBx0ZVjmNKwmQFnlwkswy5nS3zYSjkFuFSQHu0MzRAX78wJo9HNiDUJ3OExNa9Plqfnyh7uPBJUDutX2OuT4vqMVLI6P%2F%2BJCrHtCMqUPuTdyjN7GnVIPpc1HSCiOaUTv8Qh1uEQJaC95ICg%2B3xE4N3U5GLDVwKGIeAq0GQrDhycKDx3LaAYLgdIIVtkQ3odzcTF4ajq5MzxBoQdn%2BV8CHbMz1tO6cbSfttMvBdaWknCsxcofdkGb4dLQTtUwui1aFdz%2FPT0u%2BaFGULaqhfPUws7us1YT8mcrbN85T1xzFisdzUmvxZgCQ1mIeFVDiffR1KcbUdntG25bJiEjn9Py%2F5qBXDNYLzoRrHdfdR7b7M0Q3svn2lsos37dim32WGWIzWNKfznV%2B40g6qtIuJ%2BMCBtSGYa4jYfhnST63k5EHgixDjFmqNAJE6zs9Vb%2FOiQTZDEZzduMmCQ%2B%2BOlKDSFiTQJFFtfofsxBD810u5L1NwipOu8QNa2UlfSJ04wpwKvV1MvFPv3BJuL%2B46g7bMmdVvu9N7kCbK2zQe1dwLhacPU6HUdFZoF1BrrHQoDF6NVpS6dXxJwXduhph9Ta%2FD1pAgXuB%2BSh0uxG8nFgRVamIXKa35rQVG6uP0uBw6nXa9%2Bs6lE%2FWWM1Z7lcdHsGK74SGw%2BaiM6xgWi0Zl6s9%2BqlRMO%2FknLHUZ4t36yq7VrNy4i35XwN8OyGH9PpzKSrnSAK3vBi8q8NiSZ88LBXI9bT142oJhJdub39Z8R2ykVtYFYiGeq4%2FciqpIbNGFDeh%2FU2ZiUd7GmVTepEm4ojXKe6KEvBZeidy5x1xmwhF8OtteMliEbJVSA2CNQDYLjQY%2FHdJ7NDE0FfQoFtm2%2F%2ByYjwoKVI1y5yrTjIZN8FcWNZxcb5MH0xb0SD4dYYHcd5Gd%2BzIGUnxkAJwW%2Fo2i%2BMzn2IbiER7yZeDLibfwTc1LnBC1HAbvwnrM5OthGDDg2%2B2DU7l5l%2BinqbBx6%2BFbR4SW5jf296MmTC6tAq%2F89GeLgvDHk1qIIgLBWpJBIy9VcSgWY%2BNf5BrvgAh2Ooh1YM34%2FQy3quLrS6EizjIqL8Isdm49OumB9kci9SQJx6OJR4N6aTdZVClp1a7t4twZF9VPqcBaOxA7hplybMlQ0j34YSaTZK4ufwSjWggQvTZqvvdPlytiv2N8NB9RY6yHbnmjhKNmtr9i191Zu%2Baw8FaN5qvcACAN7anrDMtGZKaqwTOJL9mTSxV99r9rAP4wWCmHwHwxe%2FCCuuBtW6FEmFEaAJGI7zbALOF0dY7dtZEtOVLck9N%2BT9UftnH6gZ5qvc3G2D43ZL2OzmUYxfDan1cm2peBleWi4UKOdRAWOG%2BlNdaJYwTRAXBziZPhn%2Bbqysa70baU3yBdhcPfL9rP6xDatgyyf8RcLWjwFhN76pvCLZhFnUgtvmjHKd8cfV3WUF7jtUlpe5A%2FGUog%2Bd7VXfihqlgcCfPym87zABXccyKQd01AeAHANSatUWwqkte0Y5bEpkijCaV5a8mogIz0X%2BCfp3FjQOIDuC3UTrw%2ByyRJwiB2pggzVcXH4xwSCCjXZJHvKB2P1IQCFKlyltAbhk9c4YwHBTwMkTcICu%2Bt9%2B6CCFkDMlTWDDV3zFTVkVUv8RNhTuIUj4N0meu%2FaPekKHA7GdVtKn2kgi5USH58SLOkZY0GN833XtMC7jC63n851B%2BAapkJZ2lzbJiLjE2Gdh1RR4fdN%2Fq6InA7CzZN9kWayLlKNHwGWYIyDTOGWxusf%2FN3Kgc%2BjxYmTZSCPkf7iszp3QukSAhuakGNhCqrfy05gcBCS9u47f3e%2BqBdxuM6HcMOh3s3t9rUYk4T1gH5SPGESGTH9Egj%2FR6pyg%2FRsg1OLNrzHlDoPcv%2F4TnOhP22YM5CDy3ClQM2twiw9%2FDJ3k43hlDVtPEbxTOHp6qFYM39OxyUfesYHS%2FVXhdSiug6AOnGS1RFFpXohxeqZZSCSc0r%2FjxMsukx%2BeeZ%2F7PIcFinHERQHBiAlwSoU7BDq0zUOdhLGmsy2uwngbq5U8McPxOOyWLURPUs9HKNSZ5SXro1aQsxocFYgCEIOmQtVEiFmqIdo5FcVWtqfYGd2D9wIMk3NODkw8eHE6B%2BChPT1bgH1tIGZs%2B7zrk5gR9aFKK3UCshuPvbfrtXoN%2BB9LPVEnFrfESFH8VRoDPHBYrwKlt1r0t2MLt3YswR5jB1Kd4FbRIltINRCLGfW4lFHI%2FQ5iQztI%2BW6W8u%2BBU9zkNA%2FUomDFkHRV4BnLefwXLQV5pVm%2BLcQUh4SgUbaI6go7LtEDf9qnWfWGNqodxUCY8at6HXQpv9oSJDqYv%2BzWCxCPBA4XczjaW%2BtcoFXpW81ZVNdVEwL5VXlhYng2p73KHb3ZlKx5Pl%2FT99MqCwFkMplG4W0b9q0whpsPnI%2BYi5OJoeKSJdMQBXvfqmAuQjbfl2p0EbdWt8LIVvQtQDvTu55KfDlYM8zkWHs6kAz024%2Be6nCXCTZ6ur6JCqu9ieYnsw9RmIighL1S8NviJjmhdIYkdhcjEXzG0xFGXYqJtoKVik86N5KDqKTB%2B5UxLFb74dcVNWgONtqQkgJt8JGhyp3GvqVKKQ2lpzh5iwNdIiapj4xaSBdY91NQLCdx21pPqIrElnUlC9s7Pa6dRjUac6njAKohwghR9KNlOjx7YWGNJjCxA%2BxwosszvRTVGnmn0lQlXDQShtnnPLOjb7IWINjrnNWtegSqAe1MVny6ZAcuRceWaaY%2BhPhWbZjHvdgrcp2b2In8ofw7vqiH%2FgYQh1G1qGHVvSI1vDn7jptQx2KdMPe4782XyPF0yJIUIZm8saoS%2BOz2nZL64bzAU8lSTqffTjlW%2F%2FkYyw6SHDo%2BGKN1ez9jUDpKkjoeZWzzQFaoaKTTvg7KdVVBr8eAyyBYbmvi%2FdvhortJs%2FAZvYyACRXvpfgrD05yx5UpY8lU%2BbxPzEktOJjoYMxzA%2BZzIHNCERfVUTsRXGkJRBMBg6ryTpWYjNVYANibuJCtT1KipSxbiw2s%2F4CvKg5jJa5WGZaKnJhWv5iaASa7TBrL1lC4k%2FF1Y1Y8rLir5McgwjUQwF6N%2B5mzBk2wb9S%2Bbobr6S7Jg1lkMnDxx2TYolvmrr%2FtVBVYzr9xA338LCHfUVGPGJO2gnMdnv8vDSa5CajGMyFL3xv5QI5NCbfHlGsnuwBj4fpVQ6%2FyinU%2FpLSSSdvjAnrPRQPDx4EtWTsAKDJ4cLOye4gH9yIZ5UBwA2HNhn972u8JSogOpN0T3YHt9lg09b1VcnX6zFGr4YgRyOue%2BXE6F432eTpZZl45k68Fmg0KRI0x0XrmPGcw49pWvaMlhfHz45zZfaw8Pwh3p0re0nY6ie%2BOxvyNpXJA5iucY8L6s0zf3d0cBOFcazPKuFVzjw7luu9H42XlTHtU70rkqRAJhnCtD5m7hGAIYFj%2FnE6AoCX4XH3kp7zp603rYAEUF8XoF0BqKAbx1O%2BYvQlenrxZa80w10R%2BmqY9cT5G5jjSG4FiVXSTWZhj8wA8Epf9DwcalxytM9e33N2w7kv4dWTeHGbGCD2K4N%2F5CJUF803LCORGIXYPFP18Nvx5PfSY7KYF41gifQY6CDZQsuLkNvEyPQs9rlkjWN1AFXwaCChGEVTTn2a%2B0BNUmTQpWJCZOBab5Hoz1GTUL%2BV70ZdOUJYCNoKjWQDs5t%2Bk7a31fw46ay7GdEtHRjgmkfkR6XlYXeoKgPTL2AUg8k69ll0IvHvUjf138no37oOKBcHpX9s6KRn3QsbfM8LopEi04NJLqRMO20UhEvYcI89dB9KR2ekaZnaLa%2FgTsoddyqFttQ5Y4evggOxHxMSqftLGMv7JxvOTEeiBorjBO0rw%2Fk68V0fZKabELoT9xT1LaZ8BaOphWk12wq1Z5fn2sGBpkcKaIk49w6tauC6d7tQo846a0ujHkpTi2hOVkdecCG%2BUfnTsrnw6m8AS53lVQ5l79n9ReXolMlERwfVr3oVI5t8e4tO7cOAbK5yXgAgQjOQZXoCWrMNDaHqzBZVu7%2BTVkLiijF3mSX%2FS6e3QeAOv1xlLAZZMHOh5D9G%2BESPWezJhuT5XEfOSXwPDI0%2BAKhU7816yVSUqot1k2MBJmtsqO0thHWZRwRV7kMcCDkf0PKQzmMSK6%2BazKcja%2BK%2FgcU4XGhfF7fCbS%2BnWYGTeqi8G75WXeT0A9TuKc5FyoxX7PaeYyDvVUxYY4h%2BEixMTPtDxi4MDWVh%2FlX2myaTMM78vgIBq41LXpeUVKHu7gf7BOlbktooh3bLRAJG1Bj6TKddtaOEHTTKW9YIEDYeFCL46owpzRU38e4Edp6lSj3ILKGH1AhkuGDz%2BVMu9IU1g%3D%3D&__VIEWSTATEGENERATOR=C2EE9ABB&__VIEWSTATEENCRYPTED=&__EVENTVALIDATION=NCd6ALSzWw%2BUWmlS%2BFBRaL%2F9yPh1B5irRQz5OZ5e4AH7lcDPPYlKwi3vk9F3sipazzzOcqrrvSr70qsPWv2la9wNMk0zvYUPd9Yni1MvtQAihIdTmgusGuXq8F14N%2F0TuKc8WZktmkqmxU5tmSAO0cHgfRE6c3dIKTFz1OyWRa36nr2j4TIlmscdRDdogwo7wSCEBHRBVc5Szm5Rn%2BneVeZkZc5TI99F%2BUUQ5YXEcd9ruQiOsjd0YMEcoU3unsfpKuwvYH7cVWq1ioo1CsRpeSzGA6laMkUTIKqqakIQK%2FGQ3z7VtKK63wGLhhlVxXbrufH5w28tMbZqsQCFIKUIlWPuy4Zm0MpHHMo337iC4i0Bj7h7ZMkvrqRm57ij5Ujux85%2BK8KfI1mBDV%2Flr%2BiHXGFe%2BmveXXWhcbLy68%2BIOwh%2Fhk6qU8VCw1MLGceAVuWA2LVEoXL3HbD1KXniN%2BcvJA4AVHV2UZpcjN1Ma9TBhteaju6gwAS%2FWPTs5wxAc6lqtcmXLnh0YhHfbnkRVaS6MGSsnE%2BCfOMzkyt9t31pxFMmBXDl&ctl00%24hdnUnsavedDataWarningEnabled=false&ctl00%24hdnHorizontalScrollPosition=&ctl00%24hdnVerticalScrollPosition=&ctl00%24hdnStaffRegisterInFlag=in&ctl00%24hdnPageName=Login.aspx&ctl00%24PageContent%24loginControl%24hdnMaxLoginAttempts=0&ctl00%24PageContent%24loginControl%24hdnToken=&ctl00%24PageContent%24loginControl%24hdnLinkAccount=0&ctl00%24PageContent%24loginControl%24hdnIsPWALogin=false&ctl00%24PageContent%24loginControl%24hdnIsPupilPortal=0&ctl00%24PageContent%24loginControl%24languageSelect%24ddlLanguage=UK+English&ctl00_PageContent_loginControl_languageSelect_ddlLanguage_ClientState=&ctl00%24PageContent%24loginControl%24txtUN={{USERNAME}}&ctl00%24PageContent%24loginControl%24txtPwd={{PASSWORD}}&ctl00%24PageContent%24loginControl%24txtMFA=&ctl00%24PageContent%24loginControl%24btnLogin=Login&ctl00%24ddlReason=Select&ctl00_ddlReason_ClientState=&ctl00%24txtNotes= \ No newline at end of file diff --git a/extract-login-form.js b/extract-login-form.js deleted file mode 100644 index 79715d4..0000000 --- a/extract-login-form.js +++ /dev/null @@ -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); diff --git a/package.json b/package.json index cc50cf4..7502702 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,15 @@ "private": true, "scripts": { "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": { + "@playwright/test": "^1.49.0", "@types/bun": "latest", "typescript-language-server": "^5.1.3" }, diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..fd810cb --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bd2a35e --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/services/cookies.json b/services/cookies.json new file mode 100644 index 0000000..18568da --- /dev/null +++ b/services/cookies.json @@ -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" + } +] \ No newline at end of file diff --git a/services/playwright-auth.ts b/services/playwright-auth.ts new file mode 100644 index 0000000..0830ef9 --- /dev/null +++ b/services/playwright-auth.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + _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 { + const cookies = await loadCachedCookies(); + if (!cookies || cookies.length === 0) { + return null; + } + return cookiesToString(cookies); +} diff --git a/startup.sh b/startup.sh new file mode 100755 index 0000000..0ea0931 --- /dev/null +++ b/startup.sh @@ -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 diff --git a/test/auth.spec.ts b/test/auth.spec.ts new file mode 100644 index 0000000..131d2bc --- /dev/null +++ b/test/auth.spec.ts @@ -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); +}); diff --git a/test/get-cookies.ts b/test/get-cookies.ts new file mode 100644 index 0000000..3ce4882 --- /dev/null +++ b/test/get-cookies.ts @@ -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); + }); diff --git a/test/test-cookies-validity.ts b/test/test-cookies-validity.ts new file mode 100644 index 0000000..0950012 --- /dev/null +++ b/test/test-cookies-validity.ts @@ -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);