init
Auto Bump and Publish / bump-and-publish (push) Failing after 8m24s
CI / lint-and-test (20) (push) Successful in 1m1s
CI / lint-and-test (22) (push) Successful in 1m3s

This commit is contained in:
Tom You
2026-06-11 15:16:51 +09:00
commit 083505c952
30 changed files with 8530 additions and 0 deletions
+982
View File
@@ -0,0 +1,982 @@
/**
* SKT/SK브로드밴드 자동화 - Myspeed 인터넷 SLA 속도측정
*
* Flow (실제 테스트를 통해 검증된 플로우):
* 1. http://myspeed.skbroadband.com/mesu/internet_sla.asp 접속
* 2. "품질보증(SLA) 테스트" 버튼 클릭 (class="redbtn btntolayer") → 레이어 팝업
* 3. 레이어에서 회선 선택 (radio button - el-radio 컴포넌트, value="0")
* 4. "#measureBtn" 클릭 → 테스트 시작
* 5. 5회 자동 측정 완료 대기 (각 300초 간격 → 총 ~25분)
* 6. 결과 파싱 (SLA pass/fail)
* 7. fail이면 "이의신청" 버튼 클릭
*
* 로그인 플로우:
* - 로그인 없이 접속 → osms.bworld.co.kr으로 리다이렉트
* - 로그인 후 비밀번호 변경 안내 → "다음에 하기" 클릭 (3개월 유예)
* - 로그인 완료 후 SLA 소개 페이지로 복귀
*/
import { Browser, BrowserContext, Page, chromium } from 'playwright';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { Config, DATA_DIR } from './config';
import chalk from 'chalk';
const SKT_SLA_INTRO_URL = 'http://myspeed.skbroadband.com/mesu/internet_sla.asp';
const SKT_SLA_START_URL = 'http://myspeed.skbroadband.com/mesu/sla/ipcheck.asp';
// TEST_TIMEOUT_MIN 환경변수로 타임아웃 조절 가능 (기본 40분)
const SLA_TEST_TIMEOUT_MS = (parseInt(process.env.TEST_TIMEOUT_MIN || '0') || 40) * 60 * 1000;
const POLL_INTERVAL_MS = 15 * 1000; // 15초 — 라운드 변화를 빠르게 감지
// ─── 진행 UI 헬퍼 ────────────────────────────────────────────────
const STEPS = {
login: { num: 1, total: 5, label: '로그인' },
layer: { num: 2, total: 5, label: 'SLA 테스트 준비' },
measure: { num: 3, total: 5, label: '속도 측정' },
parse: { num: 4, total: 5, label: '결과 분석' },
action: { num: 5, total: 5, label: '감면 처리' },
};
function stepHeader(step: { num: number; total: number; label: string }): void {
const bar = '●'.repeat(step.num) + '○'.repeat(step.total - step.num);
console.log(chalk.cyan(`\n ${bar} `) + chalk.bold(`[${step.num}/${step.total}] ${step.label}`));
}
function info(msg: string): void {
console.log(chalk.dim(` ${msg}`));
}
function formatElapsed(ms: number): string {
const min = Math.floor(ms / 60000);
const sec = Math.floor((ms % 60000) / 1000);
return min > 0 ? `${min}${sec}` : `${sec}`;
}
/** 측정 진행 바 (1~5회차) */
function measureProgress(round: number, total: number, elapsedMs: number): void {
const filled = round;
const empty = total - round;
const bar = chalk.green('■'.repeat(filled)) + chalk.gray('□'.repeat(empty));
const elapsed = formatElapsed(elapsedMs);
// 커서를 줄 앞으로 이동하여 같은 줄에 덮어쓰기
if (process.stdout.isTTY) {
process.stdout.write(`\r ${bar} ${round}/${total}회 완료 ${chalk.dim(elapsed)} `);
} else {
console.log(` ${bar} ${round}/${total}회 완료 ${elapsed}`);
}
}
export interface SpeedTestResult {
download_mbps: number;
upload_mbps: number;
ping_ms: number;
sla_result: 'pass' | 'fail' | 'unknown';
complaint_filed: boolean;
complaint_result: 'success' | 'failed' | 'skipped' | 'not_applicable';
raw_data: Record<string, unknown>;
error: string;
}
/**
* run() 실행 시 옵션.
* debug=true면 headless를 강제로 off, slowMo/devtools 켜고
* 에러 발생 시 브라우저를 사용자가 Enter 칠 때까지 닫지 않는다.
*/
export interface RunOptions {
dryRun?: boolean;
debug?: boolean;
}
function defaultResult(): SpeedTestResult {
return {
download_mbps: 0,
upload_mbps: 0,
ping_ms: 0,
sla_result: 'unknown',
complaint_filed: false,
complaint_result: 'skipped',
raw_data: {},
error: '',
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class SKTProvider {
private config: Config;
private browser: Browser | null = null;
private context: BrowserContext | null = null;
private page: Page | null = null;
constructor(config: Config) {
this.config = config;
}
async run(dryRunOrOptions: boolean | RunOptions = false): Promise<SpeedTestResult> {
// 하위 호환: 기존 호출 방식(boolean)은 dryRun만 지정.
// 새 호출 방식은 { dryRun, debug } 객체.
const options: RunOptions =
typeof dryRunOrOptions === 'boolean' ? { dryRun: dryRunOrOptions } : dryRunOrOptions;
const dryRun = options.dryRun === true;
const debug = options.debug === true;
const result = defaultResult();
// 디버그 모드: 브라우저 창을 열고 각 동작을 slowMo로 느리게 실행.
// 원인 추정이 어려운 상황(이슈 #3의 신규 기기 등록/다회선 주소지 선택/회선 미보유 등)에서
// 사용자가 직접 브라우저를 관찰할 수 있게 한다. config.headless 값을 덮어씀.
const headless = debug ? false : this.config.headless;
const launchOptions = {
headless,
slowMo: debug ? 250 : 0,
devtools: debug,
args: [
'--no-sandbox',
'--disable-blink-features=AutomationControlled',
'--use-fake-ui-for-media-stream',
'--disable-web-security',
],
};
// Playwright 브라우저 바이너리가 없으면 자동 설치 (npx 첫 실행 시 필요)
try {
this.browser = await chromium.launch(launchOptions);
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
if (err.message.includes("Executable doesn't exist")) {
console.log('📦 Chromium 브라우저 설치 중... (최초 1회)');
execSync('npx playwright install chromium', { stdio: 'inherit' });
this.browser = await chromium.launch(launchOptions);
} else {
throw e;
}
}
this.context = await this.createContext();
try {
this.page = await this.context.newPage();
// Step 1: 로그인
stepHeader(STEPS.login);
info('myspeed.skbroadband.com 접속 중...');
await this.page.goto(SKT_SLA_INTRO_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2000);
await this.handleLogin();
const currentUrl = this.page.url();
if (!currentUrl.includes('/mesu/internet_sla.asp') && !currentUrl.includes('/mesu/sla/ipcheck.asp')) {
info('SLA 페이지로 이동 중...');
await this.page.goto(SKT_SLA_INTRO_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2000);
}
info('로그인 완료');
// Step 2: SLA 테스트 준비
stepHeader(STEPS.layer);
info('품질보증(SLA) 테스트 레이어 열기...');
await this.openSlaLayer();
info('회선 선택 중...');
await this.selectLine();
info('준비 완료');
// Step 3: 속도 측정
stepHeader(STEPS.measure);
info('5회 측정 시작 (약 25분 소요)');
await this.startMeasurement();
await this.waitForCompletion();
// Step 4: 결과 분석
stepHeader(STEPS.parse);
info('측정 데이터 파싱 중...');
const parsed = await this.parseResults();
Object.assign(result, parsed);
// Step 5: 감면 처리
stepHeader(STEPS.action);
if (result.sla_result === 'fail' && !dryRun) {
info('SLA 미달 → 이의신청 진행...');
const ok = await this.fileComplaint();
result.complaint_filed = ok;
result.complaint_result = ok ? 'success' : 'failed';
info(ok ? '이의신청 완료' : '이의신청 실패');
} else if (result.sla_result === 'fail' && dryRun) {
info('SLA 미달 (dry-run → 이의신청 생략)');
result.complaint_result = 'skipped';
} else if (result.sla_result === 'pass') {
info('SLA 통과 → 이의신청 불필요');
result.complaint_result = 'not_applicable';
}
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
info(chalk.red(`오류: ${err.message}`));
result.error = err.message;
result.sla_result = 'unknown';
// 오류 스크린샷
try {
await this.page?.screenshot({ path: 'skt-error.png' });
info('스크린샷 저장: skt-error.png');
} catch {
// ignore
}
// 디버그 모드 + 인터랙티브 TTY에서는 브라우저를 열어둔 채 사용자 확인 대기.
// 이슈 #3 같은 환경별 엣지 케이스를 눈으로 확인하기 위함.
if (debug && process.stdin.isTTY && process.stdout.isTTY) {
await this.waitForEnter(
chalk.yellow('\n🔍 디버그 모드: 브라우저에서 현재 상태를 확인하세요.\n') +
chalk.dim(' 확인 후 Enter를 누르면 브라우저를 닫습니다... '),
);
}
} finally {
await this.context?.close();
await this.browser?.close();
this.browser = null;
this.context = null;
this.page = null;
}
return result;
}
async login(): Promise<void> {
const launchOptions = {
headless: false,
slowMo: 100,
args: ['--no-sandbox', '--disable-blink-features=AutomationControlled'],
};
try {
this.browser = await chromium.launch(launchOptions);
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
if (err.message.includes("Executable doesn't exist")) {
console.log('📦 Chromium 브라우저 설치 중... (최초 1회)');
execSync('npx playwright install chromium', { stdio: 'inherit' });
this.browser = await chromium.launch(launchOptions);
} else {
throw e;
}
}
this.context = await this.createContext();
this.page = await this.context.newPage();
console.log(chalk.cyan('\n🔐 B world/T아이디 로그인 세션 생성'));
console.log(chalk.dim(' 열린 브라우저에서 B world/T아이디 또는 간편인증 로그인을 완료하세요.'));
console.log(chalk.dim(' SLA 시작 페이지 또는 Myspeed 페이지로 돌아온 뒤 터미널에서 Enter를 누르세요.'));
try {
await this.page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await this.waitForEnter(chalk.yellow('\n로그인을 완료했으면 Enter를 누르세요... '));
await this.page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2000);
if (this.isLoginUrl(this.page.url()) || (await this.isLoginPageVisible())) {
throw new Error('로그인이 완료되지 않았습니다. 브라우저에서 인증을 마친 뒤 다시 시도하세요.');
}
fs.mkdirSync(path.dirname(this.config.auth_state_path), { recursive: true });
await this.context.storageState({ path: this.config.auth_state_path });
console.log(chalk.green(`✅ 로그인 세션 저장 완료: ${this.config.auth_state_path}`));
console.log(chalk.dim(' 이제 headless run에서 이 세션을 재사용합니다. 만료되면 auth login을 다시 실행하세요.'));
} finally {
await this.context?.close();
await this.browser?.close();
this.browser = null;
this.context = null;
this.page = null;
}
}
private async createContext(): Promise<BrowserContext> {
const storageState = fs.existsSync(this.config.auth_state_path)
? this.config.auth_state_path
: undefined;
return this.browser!.newContext({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/123.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 900 },
storageState,
});
}
/**
* 디버그 모드에서 사용자가 브라우저를 관찰할 시간을 주기 위해
* Enter 입력을 기다린다. TTY가 아니면 즉시 반환.
*/
private waitForEnter(prompt: string): Promise<void> {
return new Promise((resolve) => {
if (!process.stdin.isTTY) {
resolve();
return;
}
process.stdout.write(prompt);
const onData = () => {
process.stdin.removeListener('data', onData);
process.stdin.pause();
resolve();
};
process.stdin.resume();
process.stdin.once('data', onData);
});
}
private isLoginUrl(currentUrl: string): boolean {
return currentUrl.includes('osms.bworld.co.kr') || currentUrl.includes('t-id.co.kr');
}
private async isLoginPageVisible(): Promise<boolean> {
try {
return await this.page!.locator('#btnLogin, #loginForm, #loginForm2').first().isVisible({ timeout: 500 });
} catch {
return false;
}
}
private async handleLogin(): Promise<void> {
const page = this.page!;
const { id, password } = this.config.credentials;
if (!id || !password) {
throw new Error('SKT/B world 계정 정보가 설정되지 않았습니다. 설정 파일을 확인하세요.');
}
const url = page.url();
if (!this.isLoginUrl(url) && !(await this.isLoginPageVisible())) {
return;
}
info('B world/T아이디 로그인 페이지 감지...');
await this.fillLoginForm(id, password);
// 로그인 후 리다이렉트 대기 — osms.bworld.co.kr에서 벗어날 때까지
try {
await page.waitForURL((url) => !this.isLoginUrl(url.toString()), { timeout: 15000 });
} catch {
// 비밀번호 변경 등 중간 페이지에서 멈출 수 있음
}
await sleep(2000);
const afterUrl = page.url();
if (afterUrl.includes('unchanged-password') || afterUrl.includes('change-password')) {
info('비밀번호 변경 안내 → 다음에 하기');
try {
await page.waitForSelector('button', { timeout: 5000 });
await page.evaluate(() => {
const btns = document.querySelectorAll('button');
for (const btn of btns) {
const text = btn.textContent || '';
if (text.includes('다음에 하기') || text.includes('나중에') || text.includes('Skip')) {
btn.click();
return;
}
}
});
await sleep(3000);
} catch {
// 다음에 하기 버튼 없음, 계속 진행
}
}
}
private async openSlaLayer(): Promise<void> {
const page = this.page!;
const { id, password } = this.config.credentials;
// SK브로드밴드 Myspeed는 소개 페이지의 시작 링크를 통해 SLA 측정/로그인 단계로 이동한다.
const btnExists = await page.evaluate(() => {
return !!document.querySelector('a.bann-btn[href*="/mesu/sla/ipcheck.asp"], a[href*="/mesu/sla/ipcheck.asp"]');
});
if (btnExists) {
await page.click('a.bann-btn[href*="/mesu/sla/ipcheck.asp"], a[href*="/mesu/sla/ipcheck.asp"]');
} else {
await page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
}
await sleep(3000);
// 로그인 페이지로 리다이렉트 되었는지 확인
const currentUrl = page.url();
if (this.isLoginUrl(currentUrl) || (await this.isLoginPageVisible())) {
info('로그인 필요 → 로그인 진행');
await this.fillLoginForm(id, password);
// 로그인 후 리다이렉트 대기
try {
await page.waitForURL((url) => !this.isLoginUrl(url.toString()), { timeout: 15000 });
} catch {
// 비밀번호 변경 안내 등 중간 페이지에서 멈출 수 있음
}
await sleep(2000);
// 비밀번호 변경 안내 처리
const afterUrl = page.url();
if (afterUrl.includes('unchanged-password') || afterUrl.includes('change-password')) {
info('비밀번호 변경 안내 → 다음에 하기');
await page.evaluate(() => {
const btns = document.querySelectorAll('button');
for (const btn of btns) {
if ((btn.textContent || '').includes('다음에 하기')) {
btn.click();
return;
}
}
});
await sleep(3000);
}
// 로그인 후 SK브로드밴드 SLA 시작 페이지로 재접속
if (!page.url().includes('/mesu/sla/ipcheck.asp')) {
await page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await sleep(2000);
}
}
// 레이어가 열렸는지 확인 — Vue 컴포넌트가 #ifArea에 회선 정보를 렌더링
const layerText = await page.evaluate(() => {
return document.getElementById('ifArea')?.textContent?.trim().slice(0, 200) || '';
});
if (!layerText) {
throw new Error('로그인 후에도 SLA 레이어가 열리지 않았습니다');
}
info('SLA 레이어 열림');
}
private async fillLoginForm(id: string, password: string): Promise<void> {
const page = this.page!;
// osms.bworld.co.kr 로그인 폼: input#id (아이디), input#password (비밀번호)
// 구버전 호환을 위해 generic selector도 fallback으로 유지
const idSelectors = ['input#id', "input[name='id']", "input[type='text']"];
const pwSelectors = ['input#password', "input[name='password']", "input[type='password']"];
let idFilled = false;
for (const sel of idSelectors) {
try {
await page.waitForSelector(sel, { timeout: 5000 });
await page.fill(sel, id);
info(`계정: ${id}`);
idFilled = true;
break;
} catch {
continue;
}
}
if (!idFilled) {
const tIdLogin = page.locator('#btnLogin').first();
if (await tIdLogin.isVisible({ timeout: 500 }).catch(() => false)) {
await tIdLogin.click();
await sleep(3000);
for (const sel of idSelectors) {
try {
await page.waitForSelector(sel, { timeout: 5000 });
await page.fill(sel, id);
info(`계정: ${id}`);
idFilled = true;
break;
} catch {
continue;
}
}
}
}
if (!idFilled) {
throw new Error('저장된 로그인 세션이 없거나 만료되었습니다. 먼저 `npx -y damn-my-slow-skt@latest auth login`을 실행하세요.');
}
for (const sel of pwSelectors) {
try {
await page.waitForSelector(sel, { timeout: 3000 });
await page.fill(sel, password);
break;
} catch {
continue;
}
}
// 로그인 버튼 클릭 — Playwright의 click()으로 안정적인 클릭
try {
const loginBtn = page.locator('button[type="submit"]').filter({ hasText: '로그인' });
await loginBtn.waitFor({ state: 'visible', timeout: 3000 });
await loginBtn.click();
} catch {
// fallback: evaluate로 직접 클릭
try {
await page.evaluate(() => {
const btns = document.querySelectorAll('button, input[type="submit"]');
for (const btn of btns) {
const text = (btn as HTMLElement).textContent || (btn as HTMLInputElement).value || '';
if (text.includes('로그인')) {
(btn as HTMLElement).click();
return;
}
}
});
} catch {
// 로그인 버튼 없음
}
}
}
private async selectLine(): Promise<void> {
const page = this.page!;
const result = await page.evaluate(() => {
// Element UI 라디오 — 첫 번째 회선이 기본 선택됨
const radioLabel = document.querySelector('label.el-radio.addr') as HTMLElement | null;
if (radioLabel) {
radioLabel.click(); // Element UI는 label 클릭으로 선택 처리
// 회선 정보 텍스트 추출 (상품명 - 주소)
const labelText = radioLabel.querySelector('.el-radio__label')?.textContent?.trim() || '';
return labelText || 'selected (no label)';
}
// fallback: generic radio
const radioInput = document.querySelector('input[type="radio"]') as HTMLInputElement | null;
if (radioInput) {
radioInput.checked = true;
radioInput.dispatchEvent(new Event('change', { bubbles: true }));
const label = radioInput.closest('label');
if (label) (label as HTMLElement).click();
return radioInput.value;
}
return 'no radio found';
});
if (result === 'no radio found') {
// SLA 단계에 진입했지만 회선 라디오 버튼이 없음 = 이 계정에 SK브로드밴드 회선이 연결되어 있지 않거나 인증 후 회선 선택 화면이 달라짐.
// #measureBtn 단계까지 내려가기 전에 여기서 명확한 원인으로 끊어야
// 다른 엣지 케이스(속도측정 프로그램 미설치, 새 기기 등록 등)와 혼동되지 않음.
throw new Error(
'SK브로드밴드 회선 정보를 찾을 수 없습니다. 이 계정에 SK브로드밴드 인터넷 회선이 연결되어 있는지 확인하세요. ' +
'(회선이 없는 계정으로는 SLA 측정이 불가능합니다)',
);
}
if (result) {
info(`회선: ${result}`);
}
await sleep(500);
}
private async startMeasurement(): Promise<void> {
const page = this.page!;
// #measureBtn (a.speed_speedtest_prestart_btn) 클릭 — Vue 컴포넌트가 SLA 테스트 시작
const btn = page.locator('#measureBtn, a.speed_speedtest_prestart_btn').first();
try {
await btn.waitFor({ state: 'visible', timeout: 5000 });
await btn.click();
} catch {
// 회선 미보유 케이스는 selectLine() 단계에서 이미 걸러짐.
// 여기까지 와서 버튼이 안 뜨는 건 그 외 원인 (이슈 #3 참고).
throw new Error(
'속도 측정 시작 버튼(#measureBtn)을 찾지 못했습니다. 자주 발생하는 원인:\n' +
' • SK브로드밴드 속도측정 프로그램 미설치 또는 브라우저 에이전트 차단\n' +
' • 새 기기 등록 화면이 추가로 뜸\n' +
' • 다회선 계정에서 주소지 선택 화면이 추가로 뜸\n' +
' 디버그 모드로 원인 확인: npx -y damn-my-slow-skt@latest run --debug',
);
}
await sleep(5000);
// 측정이 시작되었는지 확인 — "회차 측정중" 또는 결과 테이블이 나타나야 함
const layerText = await page.evaluate(() => {
return (
document
.getElementById('ifArea')
?.textContent?.replace(/\s+/g, ' ')
.trim()
.slice(0, 300) || ''
);
});
if (layerText.includes('측정중') || layerText.includes('SLA 테스트')) {
info('측정 시작 확인');
} else {
info('측정 시작 대기 중...');
}
// 단말 정보 출력 — 페이지 하단의 품질측정 단말정보
const deviceInfo = await page.evaluate(() => {
const ifArea = document.getElementById('ifArea');
if (!ifArea) return null;
const text = ifArea.textContent || '';
const osMatch = text.match(/OS\s+([\s\S]*?)(?=CPU)/);
const cpuMatch = text.match(/CPU\s+([\s\S]*?)(?=RAM)/);
const ramMatch = text.match(/RAM\s+([\s\S]*?)(?=Browser)/);
const browserMatch = text.match(/Browser\s+([\s\S]*?)(?=재측정|$)/);
return {
os: osMatch ? osMatch[1].trim() : '',
cpu: cpuMatch ? cpuMatch[1].trim() : '',
ram: ramMatch ? ramMatch[1].trim() : '',
browser: browserMatch ? browserMatch[1].trim() : '',
};
});
if (deviceInfo && deviceInfo.os) {
info(chalk.cyan('품질측정 단말정보:'));
info(` OS: ${deviceInfo.os}`);
info(` CPU: ${deviceInfo.cpu}`);
info(` RAM: ${deviceInfo.ram}`);
info(` Browser: ${deviceInfo.browser}`);
}
// HTML 캡처 저장 (디버그/증거용)
await this.saveHtmlSnapshot('measurement-start');
}
private async waitForCompletion(): Promise<void> {
const page = this.page!;
const maxWaitMs = SLA_TEST_TIMEOUT_MS;
let elapsed = 0;
let lastReportedRound = 0; // 이미 출력한 라운드 추적
while (elapsed < maxWaitMs) {
await sleep(POLL_INTERVAL_MS);
elapsed += POLL_INTERVAL_MS;
// 구조화된 CSS 클래스로 회차별 결과를 직접 파싱
const status = await page.evaluate(() => {
const ifArea = document.getElementById('ifArea');
if (!ifArea) return null;
// 회차별 상세 결과
const rounds: Array<{ speed: string; slaRef: string; result: string; date: string }> = [];
for (let i = 1; i <= 5; i++) {
const speed = ifArea.querySelector(`.step-table-speed-${i}`)?.textContent?.trim() || '';
const slaRef = ifArea.querySelector(`.step-table-default-${i}`)?.textContent?.trim() || '';
const resultText = ifArea.querySelector(`.step-table-result-${i}`)?.textContent?.trim() || '';
const date = ifArea.querySelector(`.step-table-date-${i}`)?.textContent?.trim() || '';
rounds.push({ speed, slaRef, result: resultText, date });
}
const completedRounds = rounds.filter(r => r.speed).length;
// "측정중" 상태 확인
const fullText = ifArea.textContent?.replace(/\s+/g, ' ').trim() || '';
const isMeasuring = fullText.includes('측정중');
// 카운트다운 타이머
const countdown = ifArea.querySelector('.delayTimeSec')?.textContent?.trim() || '';
// 결과 요약 텍스트
const totalMatch = fullText.match(/테스트\s*횟수\s*(\d+)\s*번/);
const totalCount = totalMatch ? parseInt(totalMatch[1]) : 0;
return { rounds, completedRounds, isMeasuring, countdown, totalCount, textSnippet: fullText.slice(0, 200) };
});
if (!status) continue;
if (process.env.DEBUG_POLL) {
console.log(`\n[DEBUG POLL ${formatElapsed(elapsed)}] rounds=${status.completedRounds} measuring=${status.isMeasuring} countdown=${status.countdown} total=${status.totalCount}`);
console.log(` text: ${status.textSnippet}`);
}
// 새로 완료된 라운드가 있으면 즉시 결과 출력
if (status.completedRounds > lastReportedRound) {
for (let i = lastReportedRound; i < status.completedRounds; i++) {
const r = status.rounds[i];
const isFail = r.result.includes('미달');
const icon = isFail ? '❌' : '✅';
if (process.stdout.isTTY) console.log(''); // 진행 바 줄바꿈
info(`${icon} ${i + 1}회차: ${r.speed} (기준 ${r.slaRef}) → ${r.result} [${r.date}]`);
}
lastReportedRound = status.completedRounds;
// HTML 스냅샷 저장
await this.saveHtmlSnapshot(`round-${status.completedRounds}`);
}
const roundsDone = status.completedRounds || status.totalCount;
// 완료 조건: 5개 회차의 측정값이 모두 채워짐
// (페이지가 "측정중" 텍스트를 유지하더라도, 5개 속도값이 있으면 완료)
if (status.completedRounds >= 5) {
measureProgress(5, 5, elapsed);
if (process.stdout.isTTY) console.log('');
info('5회 측정 완료!');
await this.saveHtmlSnapshot('complete');
break;
} else if (roundsDone > 0) {
measureProgress(roundsDone, 5, elapsed);
if (status.countdown) {
if (process.stdout.isTTY) {
process.stdout.write(chalk.dim(` 다음: ${status.countdown}`));
}
}
}
}
if (elapsed >= maxWaitMs) {
if (process.stdout.isTTY) console.log('');
info(chalk.yellow(`${Math.round(maxWaitMs / 60000)}분 타임아웃 - 현재 결과로 진행`));
await this.saveHtmlSnapshot('timeout');
}
}
private async parseResults(): Promise<Partial<SpeedTestResult>> {
const page = this.page!;
const result: Partial<SpeedTestResult> = {
download_mbps: 0,
upload_mbps: 0,
ping_ms: 0,
sla_result: 'unknown',
raw_data: {},
error: '',
};
try {
// 구조화된 DOM에서 회차별 데이터를 직접 추출
const parsed = await page.evaluate(() => {
const ifArea = document.getElementById('ifArea');
if (!ifArea) return null;
// 회차별 결과 파싱 — CSS 클래스 기반
const rounds: Array<{ speed: string; slaRef: string; result: string; date: string }> = [];
for (let i = 1; i <= 5; i++) {
const speed = ifArea.querySelector(`.step-table-speed-${i}`)?.textContent?.trim() || '';
const slaRef = ifArea.querySelector(`.step-table-default-${i}`)?.textContent?.trim() || '';
const resultText = ifArea.querySelector(`.step-table-result-${i}`)?.textContent?.trim() || '';
const date = ifArea.querySelector(`.step-table-date-${i}`)?.textContent?.trim() || '';
if (speed) {
rounds.push({ speed, slaRef, result: resultText, date });
}
}
// 요약 텍스트 (display:none이어도 textContent로 접근 가능)
const fullText = ifArea.textContent?.replace(/\s+/g, ' ').trim() || '';
const satisfyMatch = fullText.match(/SLA만족\s*횟수는?\s*(\d+)\s*번/);
const failMatch = fullText.match(/미달\s*횟수는?\s*(\d+)\s*번/);
const totalMatch = fullText.match(/테스트\s*횟수\s*(\d+)\s*번/);
return {
rounds,
satisfyCount: satisfyMatch ? parseInt(satisfyMatch[1]) : 0,
failCount: failMatch ? parseInt(failMatch[1]) : 0,
totalCount: totalMatch ? parseInt(totalMatch[1]) : 0,
fullText: fullText.slice(0, 500),
};
});
if (!parsed) {
result.error = 'ifArea 엘리먼트를 찾지 못했습니다';
return result;
}
// 회차별 속도를 평균으로 계산
const speeds = parsed.rounds
.map((r) => parseFloat(r.speed))
.filter((v) => !isNaN(v));
if (speeds.length > 0) {
result.download_mbps = speeds.reduce((a, b) => a + b, 0) / speeds.length;
}
// SLA 결과 판정
const { satisfyCount, failCount, totalCount } = parsed;
if (totalCount > 0) {
info(`전체 ${totalCount}회: 만족 ${satisfyCount}회, 미달 ${failCount}`);
result.raw_data = {
total: totalCount,
satisfy: satisfyCount,
fail: failCount,
rounds: parsed.rounds,
};
// 5회 중 3회 이상 미달이면 SLA fail
if (failCount >= 3) {
result.sla_result = 'fail';
} else {
result.sla_result = 'pass';
}
}
// 개별 라운드 결과 출력
for (const round of parsed.rounds) {
const isFail = round.result.includes('미달');
const icon = isFail ? '❌' : '✅';
info(` ${icon} ${round.speed} (기준: ${round.slaRef}) → ${round.result}`);
}
// fallback: 텍스트 기반 판정
if (result.sla_result === 'unknown') {
if (parsed.fullText.includes('미달') && /[345]번/.test(parsed.fullText)) {
result.sla_result = 'fail';
} else if (parsed.fullText.includes('만족')) {
result.sla_result = 'pass';
}
}
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
info(chalk.red(`결과 파싱 실패: ${err.message}`));
result.error = err.message;
}
return result;
}
/**
* 5회 측정 완료 후 "속도측정 상세이력" 다이얼로그가 자동으로 뜸.
* 다이얼로그에서:
* 1. 측정 결과 상세 정보를 CLI에 출력 (증거)
* 2. 전화번호를 입력 (config.phone)
* 3. "확인" 버튼 클릭 → 이의신청(품질점검 신청) 완료
*/
private async fileComplaint(): Promise<boolean> {
const page = this.page!;
// 상세이력 다이얼로그가 열릴 때까지 대기
try {
await page.waitForSelector('.slaTestResultDetailPopup', { state: 'visible', timeout: 30000 });
} catch {
info('상세이력 다이얼로그가 열리지 않았습니다');
return false;
}
await sleep(2000);
await this.saveHtmlSnapshot('complaint-dialog');
// 상세이력 정보를 CLI에 출력
const detail = await page.evaluate(() => {
const popup = document.querySelector('.slaTestResultDetailPopup');
if (!popup) return null;
// 요약 테이블 (test_table type1) 파싱
const summaryRows = popup.querySelectorAll('.test_table.type1 tr');
const summary: Record<string, string> = {};
summaryRows.forEach(row => {
const th = row.querySelector('th')?.textContent?.trim() || '';
const td = row.querySelector('td')?.textContent?.trim() || '';
if (th) summary[th] = td;
});
// 회차별 속도 테이블 (test_table type3) 파싱
const speedRows = popup.querySelectorAll('.test_table.type3 tbody tr');
const rounds: Array<{ round: string; speed: string }> = [];
speedRows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 2) {
rounds.push({
round: cells[0].textContent?.trim() || '',
speed: cells[1].textContent?.trim() || '',
});
}
});
return { summary, rounds };
});
if (detail) {
console.log('');
info(chalk.cyan('━━━ SLA 테스트 결과 상세 ━━━'));
info(` 측정일자: ${detail.summary['측정일자'] || '-'}`);
info(` 상품명: ${detail.summary['상품명'] || '-'}`);
info(` SLA기준속도: ${detail.summary['SLA기준속도'] || '-'}`);
info(` 측정횟수: ${detail.summary['측정횟수'] || '-'}`);
info(` 미달횟수: ${detail.summary['미달횟수'] || '-'}`);
info(` 결과: ${detail.summary['결 과'] || '-'}`);
info('');
info(' 회차별 다운로드 속도:');
for (const r of detail.rounds) {
info(` ${r.round}회차: ${r.speed} Mbps`);
}
info(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━'));
}
// 전화번호 입력 — config.phone에서 가져옴
const phone = this.config.phone || '';
if (!phone) {
info(chalk.yellow('전화번호가 설정되지 않아 이의신청을 진행할 수 없습니다.'));
info(chalk.dim('설정 파일에 phone: "01012345678" 을 추가하세요.'));
return false;
}
// 010-XXXX-XXXX 형태로 파싱
const digits = phone.replace(/-/g, '');
const prefix = digits.slice(0, 3); // 010
const mid = digits.slice(3, 7); // 중간 4자리
const last = digits.slice(7, 11); // 끝 4자리
info(`연락처 입력: ${prefix}-${mid}-${last}`);
// 휴대폰 라디오 선택 (기본이 hp이지만 명시적으로)
await page.evaluate(() => {
const hpRadio = document.querySelector('input[type="radio"][value="hp"]') as HTMLInputElement;
if (hpRadio) {
const label = hpRadio.closest('label');
if (label) label.click();
}
});
// 중간번호, 끝번호 입력
try {
await page.fill('input[name="telnum2"]', mid);
await page.fill('input[name="telnum3"]', last);
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
info(chalk.red(`전화번호 입력 실패: ${err.message}`));
return false;
}
await sleep(1000);
// "확인" 버튼 클릭
try {
await page.click('a.sla_popup_detail_confirmCompAction_btn');
info('품질점검 신청 확인 클릭');
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
info(chalk.red(`확인 버튼 클릭 실패: ${err.message}`));
return false;
}
await sleep(3000);
await this.saveHtmlSnapshot('complaint-submitted');
return true;
}
async takeScreenshot(filePath = 'screenshot.png'): Promise<void> {
if (this.page) {
await this.page.screenshot({ path: filePath });
console.log(`스크린샷 저장: ${filePath}`);
}
}
/** #ifArea의 HTML을 파일로 저장 — 디버그/증거용 */
private async saveHtmlSnapshot(label: string): Promise<void> {
try {
const snapshotDir = path.join(DATA_DIR, 'snapshots');
fs.mkdirSync(snapshotDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filePath = path.join(snapshotDir, `${timestamp}_${label}.html`);
const html = await this.page!.evaluate(() => {
return document.getElementById('ifArea')?.innerHTML || document.body.innerHTML;
});
fs.writeFileSync(filePath, html, 'utf8');
} catch {
// 스냅샷 저장 실패는 무시 — 측정 플로우에 영향 없음
}
}
}