init
This commit is contained in:
+982
@@ -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 {
|
||||
// 스냅샷 저장 실패는 무시 — 측정 플로우에 영향 없음
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user