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
+18
View File
@@ -0,0 +1,18 @@
# damn-my-slow-skt — AI Agent용 환경 설정
# 이 프로젝트는 .env를 사용하지 않습니다.
# 설정은 ~/.damn-my-slow-isp/config-skt.yaml (YAML)로 관리됩니다.
# 아래는 AI Agent가 개발/테스트할 때 참고할 정보입니다.
#
# 실제 동작에 필요한 credential 및 약관 동의:
# SKT_ID=your-skt-account@example.com
# SKT_PASSWORD=your-password
# terms.accepted=true, 현재 version, 유효한 accepted_at 필요
#
# 선택적 알림 설정:
# DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
# TELEGRAM_BOT_TOKEN=123456:ABC...
# TELEGRAM_CHAT_ID=123456789
#
# 위 값들은 config-skt.yaml에 설정합니다:
# cp config.yaml.example ~/.damn-my-slow-isp/config-skt.yaml
# # 그 후 공식 이용약관 URL 확인 및 credential 입력
+107
View File
@@ -0,0 +1,107 @@
name: Auto Bump and Publish
on:
push:
branches:
- main
workflow_dispatch:
inputs:
bump:
description: Version bump type
required: true
default: patch
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
id-token: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
bump-and-publish:
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- name: Determine bump type
id: bump_type
run: echo "value=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bump || 'patch' }}" >> "$GITHUB_OUTPUT"
- name: Wait 5 minutes for more commits
if: github.event_name != 'workflow_dispatch'
run: sleep 300
- name: Checkout latest branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Sync version with npm registry
id: sync
run: |
# npm 레지스트리에 이미 publish된 최신 버전을 확인하여
# 로컬 package.json과 불일치하면 동기화한다.
# (이전 CI에서 publish 성공 후 commit이 실패한 경우 발생)
LOCAL=$(node -p "require('./package.json').version")
REMOTE=$(npm view damn-my-slow-skt version 2>/dev/null || echo "0.0.0")
echo "local=$LOCAL remote=$REMOTE"
if npx semver "$REMOTE" -r ">$LOCAL" > /dev/null 2>&1; then
echo "⚠️ npm registry ($REMOTE) > local ($LOCAL). Syncing..."
npm version "$REMOTE" --no-git-tag-version --allow-same-version
fi
- name: Bump version
id: bump
run: |
npm version ${{ steps.bump_type.outputs.value }} --no-git-tag-version
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build
run: npm run build
- name: Commit and push version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json 2>/dev/null || true
if git diff --cached --quiet; then
echo "No changes to commit."
else
git commit -m "chore: auto bump ${{ steps.bump_type.outputs.value }} to v${{ steps.bump.outputs.version }}"
git push origin HEAD:${{ github.ref_name }}
fi
- name: Publish to npm (Trusted Publishers OIDC)
run: npm publish --provenance --access public
- name: Tag and release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "v${{ steps.bump.outputs.version }}"
git push origin "v${{ steps.bump.outputs.version }}"
gh release create "v${{ steps.bump.outputs.version }}" --generate-notes
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- name: Type check
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Test
run: npm test
+51
View File
@@ -0,0 +1,51 @@
# Config (contains credentials)
config.yaml
config.yml
config-skt.yaml
# Environment
.env
.env.*
!.env.ai-ready
!.env.example
# Node.js
node_modules/
# Build output
dist/
# Database
*.db
*.sqlite
*.sqlite3
*.json.db
# Puppeteer cache
.cache/
# Logs
*.log
# macOS
.DS_Store
# IDE
.idea/
*.swp
# Screenshots
skt-error.png
screenshot.png
speed-test-result.png
# Update cache
.update-cache.json
# Sisyphus (AI agent planner)
.sisyphus/
!.sisyphus/.gitignore
!.sisyphus/plans/
!.sisyphus/evidence/
.claude/autoresearch-results.tsv
+10
View File
@@ -0,0 +1,10 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"],
"testing.automaticallyOpenPeekView": "never"
}
+92
View File
@@ -0,0 +1,92 @@
# AGENTS.md — damn-my-slow-skt
> AI Agent rules and codebase documentation. Update this file when completing tasks.
## Project Overview
- **Name**: damn-my-slow-skt v0.5.x
- **Purpose**: SKT/SK Broadband internet SLA speed measurement automation + fee reduction CLI tool
- **What it does**: Automates the flow of entering SK Broadband Myspeed, running SLA speed tests, storing results, and preparing fee-reduction handling when speed is below the SLA threshold.
- **Stack**: TypeScript (ES2020, CommonJS), Node.js 20+, Playwright, SQLite (node:sqlite), Commander CLI
- **Package manager**: npm (use `npm install`, never yarn/pnpm)
- **License**: MIT
- **npm package**: `damn-my-slow-skt`
- **Language of product**: Korean (UI strings, README, commit messages)
- **Language of code**: English (variable names, comments explain why)
## Development Setup
```bash
npm install
npx playwright install chromium
npm run build
npm run typecheck
npm run lint
npm test
```
- No external DB, Redis, or Docker required
- Config file: `~/.damn-my-slow-isp/config-skt.yaml` (YAML, not .env)
- Config requires explicit SKT/SK Broadband official terms acceptance under `terms`
- `run` command requires real SKT/B world credentials or SK Broadband authentication; all other dev tasks work without credentials
## Codebase Structure
```
src/
├── index.ts
├── cli.ts
├── config.ts
├── db.ts
├── skt.ts # SKT/SK Broadband Myspeed Playwright automation
├── migration.ts
├── notify.ts
├── report.ts
├── scheduler.ts
└── updater.ts
tests/
├── config.test.ts
├── db.test.ts
```
### Key Files Explained
- **skt.ts**: Core browser automation. Drives SK Broadband Myspeed SLA flow. Do not change live-site selectors without browser verification against myspeed.skbroadband.com.
- **cli.ts**: Commander commands. `run` validates required config fields before execution.
- **config.ts**: YAML config load/save, interfaces, defaults, required field validation.
- **scheduler.ts**: Generates launchd/systemd/cron triggers from `max_attempts` and `retry_interval_minutes`.
- **db.ts**: Dual storage backend: `node:sqlite` first, JSON fallback on Node 20.
## Coding Conventions
### TypeScript
- **Module**: CommonJS compatible dependencies
- **Target**: ES2020, `strict: true`
- **Error handling**: Always `catch (e: unknown)`, then narrow to `Error`
- **No new `any`**: Use `unknown` and type guards
- **String formatting**: Template literals
- **Async**: async/await
### Console Output
- Use `chalk` for colors
- Use emoji as status indicators (✅ ❌ 📊 ⏱ 📡 🐌)
- Check `process.stdout.isTTY` for interactive vs cron mode
### Config System
- YAML-based, not .env
- Deep-merged with defaults on load
- Config version tracked in `_config_version` (current: 4)
- `terms` stores current SKT/SK Broadband terms acceptance metadata and is required for `run`
- Migrations are interactive
## Restrictions
- **Never commit credentials** — local config files are gitignored
- **Never break CJS compatibility**
- **Never modify SK Broadband automation selectors** without testing against the live site
- **Never use `process.exit()` in library code** — only CLI entry points
- **Node 20 minimum** — do not use Node 22+ APIs without fallback
## Mandatory Practices
- Update AGENTS.md when completing tasks that change structure, conventions, or dependencies
- Run: `npm run typecheck && npm run lint && npm run build && npm test`
- Use Korean conventional commits
- Keep screenshot-on-error pattern in `skt.ts`
- Test with `--dry-run` when possible to avoid filing real complaints
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+119
View File
@@ -0,0 +1,119 @@
# AI Agentic Coding Setup — damn-my-slow-skt
> **이 문서는 AI 에이전트(Codex, Claude Code, OpenCode 등)가 이 프로젝트를 개발/테스트할 때 필요한 환경 설정을 안내합니다.**
> 사람 개발자가 AI 코딩 환경을 구성할 때도 참고하세요.
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ damn-my-slow-skt │
│ │
│ CLI (Commander) │
│ ├── init → 설정 wizard + 스케줄 등록 │
│ ├── run → Playwright → myspeed.skbroadband.com 측정 │
│ ├── history → SQLite/JSON DB 조회 │
│ ├── report → 월간 통계 │
│ └── schedule → launchd/systemd/cron 등록 │
│ │
│ Storage: SQLite (Node 22+) / JSON fallback (20+) │
│ Config: ~/.damn-my-slow-isp/config-skt.yaml │
│ Terms: SKT/SK Broadband agreement required │
│ No external DB/Redis/Docker required! │
└─────────────────────────────────────────────────────┘
```
---
## Quick Setup (Local Development)
```bash
# 1. Install dependencies
npm install
# 2. Install Playwright browsers (headless Chromium)
npx playwright install chromium
# 3. Build
npm run build
# 4. Create test config (optional — only needed for actual SKT/SK브로드밴드 measurement)
cp config.yaml.example ~/.damn-my-slow-isp/config-skt.yaml
# Edit with your SKT/B world credentials and keep the terms block accepted only after reviewing the official URLs
# 5. Run type check + lint + tests
npm run typecheck
npm run lint
npm test
```
---
## Codex Cloud Setup (Ubuntu 24.04)
> **Codex Cloud는 Docker를 사용할 수 없습니다.**
> 이 프로젝트는 외부 서비스(MySQL, Redis 등)가 불필요하므로 바로 사용 가능합니다.
### Setup Script (네트워크 접근 가능 시)
```bash
#!/bin/bash
# Codex Cloud: 초기 설정 (network enabled)
npm install
npx playwright install-deps chromium # 시스템 의존성 (Ubuntu)
npx playwright install chromium # Chromium 브라우저 바이너리
npm run build
```
### Maintain Script (브랜치 전환 후)
```bash
#!/bin/bash
# Codex Cloud: 브랜치 체크아웃 후 유지보수
npm install
npm run build
```
---
## Required Secrets
| Secret | Required | Purpose |
|--------|----------|---------|
| SKT/B world ID/Password | **Yes** (for `run` only) | SKT/B world 계정 — `config-skt.yaml`에 설정 |
| SKT/SK Broadband Terms Acceptance | **Yes** (for `run` only) | `terms` block in `config-skt.yaml`; generated by `init` or v4 migration |
| Discord Webhook | No | 결과 알림 |
| Telegram Token | No | 결과 알림 |
> **개발/테스트 시에는 credential 없이도** `build`, `typecheck`, `lint`, `test` 모두 실행 가능합니다.
> `run` 명령만 실제 SKT/B world 계정이 필요합니다. 단, 실행 전 현재 SKT/SK브로드밴드 공식 이용약관 동의(`terms.accepted`, `version`, `accepted_at`)도 필요합니다.
---
## Available Commands
| Command | Description | Needs Credential |
|---------|-------------|-----------------|
| `npm run build` | TypeScript → JavaScript 컴파일 | No |
| `npm run typecheck` | `tsc --noEmit` 타입 체크 | No |
| `npm run lint` | ESLint 정적 분석 | No |
| `npm test` | Vitest 단위 테스트 | No |
| `npm run dev` | ts-node 개발 모드 | No |
---
## Tech Stack Summary
| Component | Technology | Notes |
|-----------|-----------|-------|
| Language | TypeScript (ES2020, CommonJS) | `strict: true` |
| Runtime | Node.js 20+ | Node 22+ 권장 (native SQLite) |
| CLI | Commander + Inquirer + Chalk v4 | CJS 호환 버전 |
| Browser | Playwright (Chromium) | SKT/SK브로드밴드 SLA 측정 자동화 |
| Storage | node:sqlite / JSON fallback | 외부 DB 불필요 |
| HTTP | Axios | 알림, npm 업데이트 체크 |
| Config | YAML (js-yaml) | `~/.damn-my-slow-isp/config-skt.yaml`; v4 includes SKT terms acceptance |
| Lint | ESLint + typescript-eslint | `eslint.config.mjs` |
| Test | Vitest | `tests/` directory |
+133
View File
@@ -0,0 +1,133 @@
# 🐌 damn-my-slow-skt
**SKT/SK브로드밴드 인터넷 SLA 속도 미달을 자동으로 측정하고 감면 신청을 돕는 CLI 도구.**
## 이게 뭔데
SK브로드밴드 유선 인터넷은 공식 Myspeed 사이트에서 인터넷 SLA 속도측정을 제공한다. 이 도구는 그 흐름을 Playwright로 실행하고, 결과를 로컬 DB에 남기며, SLA 미달 시 감면 처리까지 이어가도록 설계된 자동화 CLI다.
> 현재 SK브로드밴드 Myspeed는 B world/T아이디/간편인증 기반 로그인 화면을 사용한다. 이전 provider의 셀렉터는 제거했고, SK브로드밴드 공식 진입점과 용어로 전환했다. 실제 감면 제출 셀렉터는 live 계정으로 검증해야 한다.
## 시작하기
### 1. Node.js 설치
Node.js 20 이상이 필요합니다.
```bash
node -v
npm -v
npx -v
```
### 2. 초기 설정
```bash
npx -y damn-my-slow-skt@latest init
```
설정 파일은 `~/.damn-my-slow-isp/config-skt.yaml`에 저장됩니다. 비밀번호는 로컬 YAML 파일에만 저장됩니다.
초기 설정 중 SKT/SK브로드밴드 공식 이용약관 URL이 표시되며, 명시적으로 동의해야 설정 파일이 저장됩니다. 동의하지 않으면 `init`은 설정 파일을 만들지 않고 종료합니다.
### 3. 로그인 세션 저장
SK브로드밴드 Myspeed는 B world/T아이디/간편인증 로그인이 필요합니다. 최초 1회는 브라우저를 열어 직접 인증하고, 세션을 로컬 파일로 저장합니다.
```bash
npx -y damn-my-slow-skt@latest auth login
```
저장 위치는 설정 파일의 `auth_state_path`이며 기본값은 `~/.damn-my-slow-isp/auth-skt.json`입니다. 세션이 만료되면 같은 명령을 다시 실행하세요.
```bash
npx -y damn-my-slow-skt@latest auth status
npx -y damn-my-slow-skt@latest auth clear
```
### 4. 실행
```bash
npx -y damn-my-slow-skt@latest run
```
- `--dry-run`: 측정만 하고 감면 신청은 생략
- `--force`: 오늘 이미 완료했어도 강제로 다시 실행
- `--debug`: 브라우저 창을 띄워 진행 과정을 직접 확인
## SKT/SK브로드밴드 SLA 기준
공식 확인 출처:
- 인터넷 품질 측정: http://myspeed.skbroadband.com/
- 인터넷 SLA 속도측정: http://myspeed.skbroadband.com/mesu/internet_sla.asp
- SK브로드밴드 이용약관: https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000
- 전기통신 서비스 이용약관 PDF: https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf
Myspeed SLA 안내 페이지에서 확인한 기준:
- **판정**: 30분간 5회 이상 측정하여 측정치의 60% 이상이 최저속도(다운로드 속도)에 미달하면 당일 이용요금 감면
- **대상**: PC 1대를 이용한 측정. 무선랜, 공유환경, 상품 제공속도보다 낮은 LAN카드 이용 및 설정 변경 등은 제외될 수 있음
- **측정 사이트**: SK브로드밴드 Myspeed 인터넷 SLA 속도측정 메뉴
- **후속 조치**: 최저속도 미달 시 품질 점검 및 더 나은 서비스 제공을 위해 TM 및 방문 점검을 실시할 수 있음
## 설정 바꾸기
`~/.damn-my-slow-isp/config-skt.yaml` 파일을 직접 편집합니다.
```yaml
terms:
provider: "skt"
accepted: true
accepted_at: "2026-03-30T00:00:00.000Z"
version: "2026-03-30"
urls:
- "https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000"
- "https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf"
schedule:
max_attempts: 10
retry_interval_minutes: 120
notification:
discord_webhook: ""
telegram_bot_token: ""
auth_state_path: "~/.damn-my-slow-isp/auth-skt.json"
```
`run` 명령은 `terms.accepted: true`, 현재 약관 `version`, 그리고 유효한 `accepted_at`이 없으면 실행하지 않습니다. 기존 설정은 대화형 `run` 또는 `init --force`에서 약관 동의를 기록하도록 마이그레이션됩니다.
설정 변경 후 스케줄 재등록:
```bash
npx -y damn-my-slow-skt@latest schedule install
```
## 요구사항
- Node.js 20+
- SKT/B world 계정 또는 SK브로드밴드 회선 인증 수단
- SKT/SK브로드밴드 공식 이용약관 동의
- 최초 1회 `auth login`으로 저장한 로그인 세션
- 유선(LAN) 연결 권장
- Playwright Chromium
## 개발
```bash
npm install
npx playwright install chromium
npm run typecheck
npm run lint
npm run build
npm test
```
| Component | Technology |
|-----------|------------|
| Language | TypeScript (ES2020, CommonJS, strict) |
| CLI | Commander + Inquirer + Chalk v4 |
| Browser | Playwright Chromium |
| Storage | node:sqlite / JSON fallback |
| Config | YAML — `~/.damn-my-slow-isp/config-skt.yaml` |
+108
View File
@@ -0,0 +1,108 @@
# damn-my-slow-skt
SKT/SK브로드밴드 인터넷 SLA 속도 미달 시 요금 감면 자동화를 목표로 하는 CLI 도구.
## 개요
SK브로드밴드는 Myspeed에서 인터넷 품질 측정과 인터넷 SLA 속도측정을 제공한다. 이 도구는 공식 측정 흐름을 자동 실행하고 결과를 저장하며, 미달 시 감면 처리까지 이어가도록 설계한다.
## 핵심 기능
1. **SKT/B world 로그인 진입** (Playwright 기반 브라우저 자동화)
2. **SK브로드밴드 공식 SLA 속도 측정 실행** (myspeed.skbroadband.com)
3. **측정 결과 기록** (SQLite / JSON fallback)
4. **속도 미달 시 감면 신청 처리**
5. **SKT/SK브로드밴드 공식 이용약관 동의 기록**
6. **결과 리포트** (Discord/Telegram 알림 옵션)
7. **다회 측정** - 하루 최대 N회, 감면 성공 시 자동 스킵
8. **업데이트 마이그레이션** - 버전 업 시 설정 변경 안내
## SK브로드밴드 SLA 측정 플로우
1. http://myspeed.skbroadband.com/mesu/internet_sla.asp 접속
2. "SLA 속도측정 시작하기" 클릭
3. B world/T아이디 또는 간편인증 로그인
4. 회선/측정 환경 확인
5. 5회 자동 측정 완료 대기
6. 결과 파싱 → SLA pass/fail 판단
7. fail 시 품질 점검/감면 처리 단계 진행
## SLA 기준
- 30분간 5회 이상 측정
- 측정치의 60% 이상이 최저속도(다운로드 속도)에 미달하면 당일 이용요금 감면
- 유선(LAN) 연결 권장, 무선랜/공유환경/부적합 LAN카드 등은 감면 제외 가능
## 기술 스택
- **언어**: TypeScript (Node.js 20+)
- **브라우저 자동화**: Playwright (headless Chromium)
- **스케줄링**: macOS launchd / Linux systemd timer / crontab
- **설정**: YAML (`~/.damn-my-slow-isp/config-skt.yaml`)
- **데이터 저장**: SQLite (Node 22+ built-in) / JSON fallback
- **알림**: Discord webhook / Telegram bot (선택)
- **배포**: npm registry (`npx damn-my-slow-skt`)
## 설정 파일 (`~/.damn-my-slow-isp/config-skt.yaml`)
```yaml
_config_version: 4
credentials:
id: "사용자ID"
password: "비밀번호"
terms:
provider: "skt"
accepted: true
accepted_at: "2026-03-30T00:00:00.000Z"
version: "2026-03-30"
urls:
- "https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000"
- "https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf"
phone: "01012345678"
plan:
speed_mbps: 1000
schedule:
time: "04:00"
timezone: "Asia/Seoul"
max_attempts: 10
retry_interval_minutes: 120
stop_on_complaint_success: true
notification:
discord_webhook: ""
telegram_bot_token: ""
telegram_chat_id: ""
headless: true
db_path: "~/.damn-my-slow-isp/history-skt.db"
```
`init`은 위 약관 URL을 표시하고 명시적 동의를 받은 뒤 설정을 저장한다. v4 마이그레이션도 같은 공식 URL을 표시하고 동의를 기록한다. `run`은 현재 SKT 약관 버전 동의가 없으면 필수 설정 누락으로 종료한다.
## CLI 인터페이스
```bash
damn-my-slow-skt init
damn-my-slow-skt run
damn-my-slow-skt run --dry-run
damn-my-slow-skt run --force
damn-my-slow-skt config show
damn-my-slow-skt history
damn-my-slow-skt report
damn-my-slow-skt schedule install
damn-my-slow-skt schedule remove
```
## 프로젝트 구조
```
src/
├── index.ts
├── cli.ts
├── config.ts
├── db.ts
├── skt.ts
├── migration.ts
├── notify.ts
├── report.ts
├── scheduler.ts
└── updater.ts
```
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env node
'use strict';
require('../dist/index.js');
+38
View File
@@ -0,0 +1,38 @@
# damn-my-slow-skt 설정 파일 예시
# config-skt.yaml로 복사 후 사용: cp config.yaml.example ~/.damn-my-slow-isp/config-skt.yaml
_config_version: 4
credentials:
id: "skt아이디@example.com"
password: "비밀번호"
terms:
provider: "skt"
accepted: true
accepted_at: "2026-03-30T00:00:00.000Z"
version: "2026-03-30"
urls:
- "https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000"
- "https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf"
phone: "01012345678"
plan:
speed_mbps: 1000 # 계약 속도 (Mbps)
schedule:
time: "04:00"
timezone: "Asia/Seoul"
max_attempts: 10
retry_interval_minutes: 120
stop_on_complaint_success: true
notification:
discord_webhook: ""
telegram_bot_token: ""
telegram_chat_id: ""
headless: true
db_path: "~/.damn-my-slow-isp/history-skt.db"
auth_state_path: "~/.damn-my-slow-isp/auth-skt.json"
+33
View File
@@ -0,0 +1,33 @@
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
export default [
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: {
// Error-level: catch real bugs
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-floating-promises": "error",
"no-console": "off", // CLI tool — console is the UI
// Warn-level: style preferences
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
// Off: too noisy for this codebase
"@typescript-eslint/no-require-imports": "off",
},
},
{
ignores: ["dist/", "node_modules/", "tests/"],
},
];
+3641
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "damn-my-slow-skt",
"version": "0.5.26",
"description": "SKT/SK브로드밴드 인터넷 SLA 속도 미달 시 요금 감면을 자동화하는 CLI 도구",
"keywords": [
"skt",
"skbroadband",
"sla",
"internet",
"speed",
"automation",
"cli"
],
"homepage": "https://github.com/kargnas/damn-my-slow-skt",
"bugs": {
"url": "https://github.com/kargnas/damn-my-slow-skt/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/kargnas/damn-my-slow-skt.git"
},
"license": "MIT",
"bin": {
"damn-my-slow-skt": "bin/damn-my-slow-skt"
},
"main": "dist/index.js",
"files": [
"bin",
"dist",
"README.md"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc",
"dev": "ts-node src/index.ts",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build"
},
"dependencies": {
"axios": "^1.6.0",
"chalk": "^4.1.2",
"cli-table3": "^0.6.3",
"commander": "^12.0.0",
"inquirer": "^8.2.6",
"js-yaml": "^4.1.0",
"playwright": "^1.52.0"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
"@types/inquirer": "^8.2.10",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@typescript-eslint/parser": "^8.58.1",
"eslint": "^10.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^4.1.3"
}
}
+1075
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
/**
* 설정 파일 로드/저장 - SKT/SK브로드밴드 전용
* 기본 경로: ~/.damn-my-slow-isp/config-skt.yaml
*/
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import os from 'os';
/** 모든 ISP 공용 데이터 디렉토리 (~/.damn-my-slow-isp/) */
export const DATA_DIR = path.join(os.homedir(), '.damn-my-slow-isp');
export interface Credentials {
id: string;
password: string;
}
export interface Plan {
speed_mbps: number;
}
export interface Schedule {
time: string;
timezone: string;
/** 하루 최대 측정 횟수 (감면 성공 시 중단) */
max_attempts: number;
/** 재시도 간격 (분). 첫 측정 후 이 간격으로 반복 */
retry_interval_minutes: number;
/** 감면 신청 성공 시 나머지 시도 중단 */
stop_on_complaint_success: boolean;
}
export interface Notification {
discord_webhook: string;
telegram_bot_token: string;
telegram_chat_id: string;
}
export interface TermsAgreement {
provider: 'skt';
accepted: boolean;
accepted_at: string;
version: string;
urls: string[];
}
export interface Config {
_config_version: number;
credentials: Credentials;
terms: TermsAgreement;
/** 이의신청 시 연락처 */
phone: string;
plan: Plan;
schedule: Schedule;
notification: Notification;
headless: boolean;
db_path: string;
auth_state_path: string;
}
export const DEFAULT_CONFIG_PATH = path.join(DATA_DIR, 'config-skt.yaml');
export const DEFAULT_AUTH_STATE_PATH = path.join(DATA_DIR, 'auth-skt.json');
export const SKT_TERMS_VERSION = '2026-03-30';
export const SKT_TERMS_URLS = [
'https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000',
'https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf',
];
function isValidIsoTimestamp(input: string): boolean {
if (!input.trim()) return false;
const date = new Date(input);
return !Number.isNaN(date.getTime()) && date.toISOString() === input;
}
function expandHome(input: string): string {
if (input === '~') return os.homedir();
if (input.startsWith('~/')) return path.join(os.homedir(), input.slice(2));
return input;
}
export function getDefaultConfig(): Config {
return {
_config_version: 4,
credentials: { id: '', password: '' },
terms: {
provider: 'skt',
accepted: false,
accepted_at: '',
version: SKT_TERMS_VERSION,
urls: [...SKT_TERMS_URLS],
},
phone: '',
plan: { speed_mbps: 1000 },
schedule: {
time: '04:00',
timezone: 'Asia/Seoul',
max_attempts: 10,
retry_interval_minutes: 120,
stop_on_complaint_success: true,
},
notification: {
discord_webhook: '',
telegram_bot_token: '',
telegram_chat_id: '',
},
headless: true,
db_path: path.join(DATA_DIR, 'history-skt.db'),
auth_state_path: DEFAULT_AUTH_STATE_PATH,
};
}
export function loadConfig(configPath?: string): Config {
const cfgPath = configPath || DEFAULT_CONFIG_PATH;
if (!fs.existsSync(cfgPath)) {
throw new Error(
`설정 파일이 없습니다: ${cfgPath}\n'npx -y damn-my-slow-skt@latest init' 명령으로 설정 파일을 생성하세요.`
);
}
const raw = yaml.load(fs.readFileSync(cfgPath, 'utf8')) as Record<string, unknown> || {};
const defaults = getDefaultConfig();
const creds = (raw.credentials || {}) as Record<string, string>;
const terms = (raw.terms || {}) as Record<string, unknown>;
const plan = (raw.plan || {}) as Record<string, unknown>;
const sched = (raw.schedule || {}) as Partial<Schedule>;
const notif = (raw.notification || {}) as Record<string, string>;
return {
_config_version: Number(raw._config_version) || 1,
credentials: {
id: creds.id || '',
password: creds.password || '',
},
terms: {
provider: 'skt',
accepted: terms.accepted === true,
accepted_at: typeof terms.accepted_at === 'string' ? terms.accepted_at : '',
version: typeof terms.version === 'string' ? terms.version : defaults.terms.version,
urls: Array.isArray(terms.urls) && terms.urls.every((url) => typeof url === 'string')
? [...terms.urls]
: [...defaults.terms.urls],
},
phone: String(raw.phone || ''),
plan: {
speed_mbps: Number(plan.speed_mbps || 1000),
},
schedule: {
time: sched.time || '04:00',
timezone: sched.timezone || 'Asia/Seoul',
max_attempts: Number(sched.max_attempts) || 10,
retry_interval_minutes: Number(sched.retry_interval_minutes) || 120,
stop_on_complaint_success:
sched.stop_on_complaint_success !== undefined
? Boolean(sched.stop_on_complaint_success)
: true,
},
notification: {
discord_webhook: notif.discord_webhook || '',
telegram_bot_token: notif.telegram_bot_token || '',
telegram_chat_id: notif.telegram_chat_id || '',
},
headless: raw.headless !== undefined ? Boolean(raw.headless) : true,
db_path: expandHome(String(raw.db_path || defaults.db_path)),
auth_state_path: expandHome(String(raw.auth_state_path || defaults.auth_state_path)),
};
}
export function saveConfig(config: Config, configPath?: string): void {
const cfgPath = configPath || DEFAULT_CONFIG_PATH;
const data = {
_config_version: config._config_version,
credentials: {
id: config.credentials.id,
password: config.credentials.password,
},
terms: {
provider: config.terms.provider,
accepted: config.terms.accepted,
accepted_at: config.terms.accepted_at,
version: config.terms.version,
urls: config.terms.urls,
},
phone: config.phone,
plan: {
speed_mbps: config.plan.speed_mbps,
},
schedule: {
time: config.schedule.time,
timezone: config.schedule.timezone,
max_attempts: config.schedule.max_attempts,
retry_interval_minutes: config.schedule.retry_interval_minutes,
stop_on_complaint_success: config.schedule.stop_on_complaint_success,
},
notification: {
discord_webhook: config.notification.discord_webhook,
telegram_bot_token: config.notification.telegram_bot_token,
telegram_chat_id: config.notification.telegram_chat_id,
},
headless: config.headless,
db_path: config.db_path,
auth_state_path: config.auth_state_path,
};
fs.writeFileSync(cfgPath, yaml.dump(data), 'utf8');
}
/**
* run 명령 실행에 필요한 필수 설정이 모두 채워져 있는지 검증.
* 누락된 항목이 있으면 필드명 배열을 반환, 모두 있으면 빈 배열.
*/
export function validateRequiredFields(config: Config): string[] {
const missing: string[] = [];
if (!config.credentials.id) missing.push('credentials.id (SKT/B world 아이디)');
if (!config.credentials.password) missing.push('credentials.password (SKT/B world 비밀번호)');
if (
config.terms.provider !== 'skt' ||
config.terms.accepted !== true ||
!isValidIsoTimestamp(config.terms.accepted_at) ||
config.terms.version !== SKT_TERMS_VERSION
) {
missing.push('terms (SKT/SK브로드밴드 공식 이용약관 동의)');
}
if (!config.phone) missing.push('phone (연락처)');
return missing;
}
export function getExampleConfigContent(): string {
return `# damn-my-slow-skt 설정 파일
# 주의: 이 파일은 .gitignore에 포함되어 있습니다 (비밀번호 보호)
_config_version: 4
credentials:
id: "skt아이디@example.com"
password: "비밀번호"
terms:
provider: "skt"
accepted: true
accepted_at: "2026-03-30T00:00:00.000Z"
version: "${SKT_TERMS_VERSION}"
urls:
- "${SKT_TERMS_URLS[0]}"
- "${SKT_TERMS_URLS[1]}"
phone: "01012345678"
plan:
speed_mbps: 1000 # 계약 속도 (Mbps) - 기가라이트: 1000, 기가 인터넷: 2000
schedule:
time: "04:00" # 첫 측정 시작 시간
timezone: "Asia/Seoul"
max_attempts: 10 # 하루 최대 측정 횟수 (감면 성공 시 중단)
retry_interval_minutes: 120 # 재시도 간격 (분) - 기본 2시간
stop_on_complaint_success: true # 감면 성공 시 나머지 시도 중단
notification:
discord_webhook: "" # Discord 웹훅 URL (선택)
telegram_bot_token: "" # Telegram 봇 토큰 (선택)
telegram_chat_id: "" # Telegram 채팅 ID (선택)
headless: true # false로 설정하면 브라우저 창 표시 (디버그용)
db_path: "~/.damn-my-slow-isp/history-skt.db" # 측정 이력 저장 경로
auth_state_path: "~/.damn-my-slow-isp/auth-skt.json" # B world 로그인 세션 저장 경로
`;
}
+257
View File
@@ -0,0 +1,257 @@
/**
* SQLite 측정 이력 저장/조회
* Uses Node.js built-in sqlite (node:sqlite) available since Node 22+
* Falls back to JSON file storage for older Node versions
*/
import fs from 'fs';
import path from 'path';
export interface SpeedRecord {
id?: number;
isp: string;
measured_at: string;
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: string;
error: string;
}
export interface Stats {
total: number;
sla_pass: number;
sla_fail: number;
complaints_filed: number;
avg_download_mbps: number;
avg_upload_mbps: number;
avg_ping_ms: number;
}
interface SqliteDb {
exec(sql: string): void;
prepare(sql: string): SqliteStatement;
close(): void;
}
interface SqliteStatement {
run(...params: unknown[]): { lastInsertRowid: number };
all(...params: unknown[]): Record<string, unknown>[];
}
function openSqlite(dbPath: string): SqliteDb | null {
try {
// Node 22.5+ built-in sqlite
const { DatabaseSync } = require('node:sqlite') as {
DatabaseSync: new (path: string) => SqliteDb;
};
return new DatabaseSync(dbPath);
} catch {
return null;
}
}
// ─── JSON fallback store ───────────────────────────────────────────────────
interface JsonStore {
records: SpeedRecord[];
nextId: number;
}
function readJsonStore(storePath: string): JsonStore {
try {
const raw = fs.readFileSync(storePath, 'utf8');
return JSON.parse(raw) as JsonStore;
} catch {
return { records: [], nextId: 1 };
}
}
function writeJsonStore(storePath: string, store: JsonStore): void {
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf8');
}
// ─── SpeedDatabase ────────────────────────────────────────────────────────
export class SpeedDatabase {
private dbPath: string;
private db: SqliteDb | null;
private jsonPath: string;
private usingJson: boolean;
constructor(dbPath: string) {
// Expand ~ in path
if (dbPath.startsWith('~/') || dbPath === '~') {
const home = process.env.HOME || process.env.USERPROFILE || '';
dbPath = path.join(home, dbPath.slice(2));
}
this.dbPath = dbPath;
this.jsonPath = dbPath.replace(/\.db$/, '.json');
// Ensure directory exists
const dir = path.dirname(dbPath);
if (dir && dir !== '.' && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = openSqlite(dbPath);
this.usingJson = this.db === null;
if (!this.usingJson) {
this.initDb();
console.log(`DB: SQLite (${dbPath})`);
} else {
console.log(`DB: JSON fallback (${this.jsonPath})`);
}
}
private initDb(): void {
this.db!.exec(`
CREATE TABLE IF NOT EXISTS speed_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
isp TEXT NOT NULL,
measured_at TEXT NOT NULL,
download_mbps REAL DEFAULT 0,
upload_mbps REAL DEFAULT 0,
ping_ms REAL DEFAULT 0,
sla_result TEXT DEFAULT 'unknown',
complaint_filed INTEGER DEFAULT 0,
complaint_result TEXT DEFAULT 'skipped',
raw_data TEXT DEFAULT '{}',
error TEXT DEFAULT ''
)
`);
}
save(record: Omit<SpeedRecord, 'id'>): number {
if (!this.usingJson && this.db) {
const stmt = this.db.prepare(`
INSERT INTO speed_records
(isp, measured_at, download_mbps, upload_mbps, ping_ms,
sla_result, complaint_filed, complaint_result, raw_data, error)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
record.isp,
record.measured_at,
record.download_mbps,
record.upload_mbps,
record.ping_ms,
record.sla_result,
record.complaint_filed ? 1 : 0,
record.complaint_result,
record.raw_data,
record.error
);
return info.lastInsertRowid;
} else {
// JSON fallback
const store = readJsonStore(this.jsonPath);
const id = store.nextId++;
store.records.push({ ...record, id });
writeJsonStore(this.jsonPath, store);
return id;
}
}
getHistory(limit = 50, month?: string): SpeedRecord[] {
if (!this.usingJson && this.db) {
let query = 'SELECT * FROM speed_records';
const params: unknown[] = [];
if (month) {
query += ' WHERE measured_at LIKE ?';
params.push(`${month}%`);
}
query += ' ORDER BY measured_at DESC LIMIT ?';
params.push(limit);
const rows = this.db.prepare(query).all(...params);
return rows.map((row) => ({
id: row.id as number,
isp: row.isp as string,
measured_at: row.measured_at as string,
download_mbps: (row.download_mbps as number) || 0,
upload_mbps: (row.upload_mbps as number) || 0,
ping_ms: (row.ping_ms as number) || 0,
sla_result: (row.sla_result as 'pass' | 'fail' | 'unknown') || 'unknown',
complaint_filed: Boolean(row.complaint_filed),
complaint_result:
(row.complaint_result as SpeedRecord['complaint_result']) || 'skipped',
raw_data: (row.raw_data as string) || '{}',
error: (row.error as string) || '',
}));
} else {
// JSON fallback
const store = readJsonStore(this.jsonPath);
let records = [...store.records];
if (month) {
records = records.filter((r) => r.measured_at.startsWith(month));
}
records.sort((a, b) => b.measured_at.localeCompare(a.measured_at));
return records.slice(0, limit);
}
}
/** 오늘(KST 기준) 측정 기록 조회 */
getTodayRecords(timezone = 'Asia/Seoul'): SpeedRecord[] {
// measured_at은 UTC ISO 문자열(`...Z`)로 저장되므로, startsWith로 비교하면
// KST 00:0008:59(UTC 전날) 시간대의 기록이 누락된다.
// 각 레코드의 UTC 시각을 타임존 로컬 날짜로 변환해서 비교한다.
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: timezone }); // YYYY-MM-DD
return this.getHistory(9999).filter((r) => {
const recordDate = new Date(r.measured_at);
if (isNaN(recordDate.getTime())) return false;
const localDate = recordDate.toLocaleDateString('sv-SE', { timeZone: timezone });
return localDate === todayStr;
});
}
/** 오늘 감면 신청 성공 여부 */
hasTodayComplaintSuccess(timezone = 'Asia/Seoul'): boolean {
return this.getTodayRecords(timezone).some((r) => r.complaint_result === 'success');
}
getStats(month?: string): Stats {
const records = this.getHistory(9999, month);
if (records.length === 0) {
return {
total: 0,
sla_pass: 0,
sla_fail: 0,
complaints_filed: 0,
avg_download_mbps: 0,
avg_upload_mbps: 0,
avg_ping_ms: 0,
};
}
return {
total: records.length,
sla_pass: records.filter((r) => r.sla_result === 'pass').length,
sla_fail: records.filter((r) => r.sla_result === 'fail').length,
complaints_filed: records.filter((r) => r.complaint_filed).length,
avg_download_mbps:
records.reduce((s, r) => s + r.download_mbps, 0) / records.length,
avg_upload_mbps:
records.reduce((s, r) => s + r.upload_mbps, 0) / records.length,
avg_ping_ms: records.reduce((s, r) => s + r.ping_ms, 0) / records.length,
};
}
close(): void {
this.db?.close();
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Docker 자동 감지 및 래핑
*
* Synology NAS 등 GTK 라이브러리가 없는 Linux에서
* Chromium 실행 실패 시 Playwright 공식 Docker 이미지로 자동 전환.
*/
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import chalk from 'chalk';
import { DATA_DIR } from './config';
// ─── 감지 함수 ─────────────────────────────────────────────────────
/** Docker 컨테이너 내부에서 실행 중인지 확인 */
export function isInsideDocker(): boolean {
// 우리가 re-exec 시 설정하는 환경변수 (가장 확실)
if (process.env.DAMN_DOCKER === '1') return true;
// Docker 컨테이너는 /.dockerenv 파일이 존재
if (fs.existsSync('/.dockerenv')) return true;
// cgroup 기반 감지 (일부 Linux 환경)
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8');
if (cgroup.includes('docker') || cgroup.includes('containerd')) return true;
} catch {
// /proc가 없는 환경 — Docker 아님
}
return false;
}
/** Docker CLI가 설치되어 있고 Docker 데몬이 실행 중인지 확인 */
export function isDockerAvailable(): boolean {
try {
execSync('docker info', { stdio: 'pipe', timeout: 15_000 });
return true;
} catch {
return false;
}
}
/**
* Chromium 실행 실패가 shared library 누락 때문인지 판별.
* Synology 등에서 libatk-1.0.so.0, libgdk-3.so.0 등이 없을 때 발생.
*/
export function isSharedLibraryError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
return (
msg.includes('shared libraries') ||
msg.includes('cannot open shared object file') ||
msg.includes('libatk') ||
msg.includes('libgdk') ||
msg.includes('libgtk') ||
msg.includes('libgobject') ||
msg.includes('libglib') ||
msg.includes('libpango') ||
msg.includes('libnss') ||
msg.includes('libnspr')
);
}
// ─── Docker 재실행 ─────────────────────────────────────────────────
/**
* 현재 Playwright 버전에 맞는 Docker 이미지 태그 반환.
* Playwright 공식 Docker 이미지: mcr.microsoft.com/playwright:v{version}-noble
*/
function getDockerImageTag(): string {
try {
const pkgPath = require.resolve('playwright/package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return `v${pkg.version}-noble`;
} catch {
// require.resolve 실패 시 (npx 환경 등) — 안전한 기본값
return 'v1.52.0-noble';
}
}
/**
* 현재 CLI 명령을 Docker 컨테이너 안에서 재실행.
*
* - DATA_DIR (~/.damn-my-slow-isp)을 컨테이너에 마운트하여 설정/DB 공유
* - process.argv에서 서브커맨드와 옵션을 추출하여 그대로 전달
* - DAMN_DOCKER=1 환경변수로 재귀 방지
*
* 이 함수는 반환하지 않음 (process.exit 호출)
*/
export function reExecInDocker(): never {
const imageTag = getDockerImageTag();
const dockerImage = `mcr.microsoft.com/playwright:${imageTag}`;
const containerDataDir = '/root/.damn-my-slow-isp';
// process.argv에서 서브커맨드 + 옵션 추출 (node, script 경로 제외)
// 예: ['node', 'damn-my-slow-skt', 'run', '--dry-run'] → ['run', '--dry-run']
const cliArgs = process.argv.slice(2);
// DATA_DIR 경로를 컨테이너 경로로 재매핑
// (사용자가 -c 옵션으로 DATA_DIR 내 경로를 지정한 경우)
const remappedArgs = cliArgs.map(arg => {
if (arg.includes(DATA_DIR)) {
return arg.replace(DATA_DIR, containerDataDir);
}
return arg;
});
console.log(chalk.cyan('\n🐳 Linux에서 Chromium 실행 불가 — Docker로 자동 전환'));
console.log(chalk.dim(` 이미지: ${dockerImage}`));
console.log(chalk.dim(` 마운트: ${DATA_DIR}${containerDataDir}`));
console.log('');
const dockerArgs = [
'run', '--rm',
'-v', `${DATA_DIR}:${containerDataDir}:rw`,
'-e', 'DAMN_DOCKER=1',
// 호스트에 TTY가 있으면 -it 추가 (interactive 프롬프트 + 색상 출력)
...(process.stdout.isTTY ? ['-it'] : []),
dockerImage,
'npx', '-y', 'damn-my-slow-skt@latest',
...remappedArgs,
];
const result = spawnSync('docker', dockerArgs, { stdio: 'inherit' });
process.exit(result.status ?? 1);
}
/**
* Docker 미설치 시 안내 메시지 출력.
* Synology 사용자를 위한 구체적인 설치 가이드 포함.
*/
export function printDockerInstallGuide(errorMsg: string): void {
console.error(chalk.red('\n❌ Chromium 실행에 필요한 시스템 라이브러리가 없습니다.'));
console.error(chalk.dim(` ${errorMsg}`));
console.error('');
console.error(chalk.yellow('💡 Docker를 설치하면 자동으로 해결됩니다:'));
console.error(chalk.dim(' Synology: 패키지센터 → Container Manager 설치'));
console.error(chalk.dim(' 일반 Linux: https://docs.docker.com/engine/install/'));
console.error('');
console.error(chalk.dim(' Docker 설치 후 다시 실행하면 자동으로 Docker를 사용합니다.'));
console.error('');
console.error(chalk.dim(' 수동 실행:'));
console.error(chalk.dim(` docker run --rm -v ${DATA_DIR}:/root/.damn-my-slow-isp \\`));
console.error(chalk.dim(' mcr.microsoft.com/playwright:v1.52.0-noble \\'));
console.error(chalk.dim(' npx -y damn-my-slow-skt@latest run'));
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
/**
* damn-my-slow-skt - CLI 엔트리포인트
*/
import { buildCli } from './cli';
const program = buildCli();
program.parseAsync(process.argv).catch((err: unknown) => {
const error = err instanceof Error ? err : new Error(String(err));
console.error(error.message);
process.exit(1);
});
+194
View File
@@ -0,0 +1,194 @@
/**
* 업데이트 후 마이그레이션 체크 시스템
*
* 설정 파일의 _config_version을 기준으로 필요한 마이그레이션을 감지하고
* 사용자에게 적용 여부를 묻는다.
*/
import chalk from 'chalk';
import inquirer from 'inquirer';
import { Config, saveConfig, SKT_TERMS_URLS, SKT_TERMS_VERSION } from './config';
import { installSchedule, removeSchedule, getPlatform } from './scheduler';
/** 현재 최신 config version */
export const CURRENT_CONFIG_VERSION = 4;
interface Migration {
/** 이 마이그레이션이 적용되는 target version */
version: number;
/** 사용자에게 보여줄 제목 */
title: string;
/** 상세 설명 */
description: string;
/** 마이그레이션 적용 함수. true 반환 시 config가 변경됨 */
apply: (config: Config) => Config;
/** 스케줄 재등록이 필요한지 여부 */
requiresScheduleReinstall: boolean;
}
/**
* 버전별 마이그레이션 목록.
* version 오름차순으로 정의. 사용자의 현재 _config_version보다 높은 것만 실행.
*/
const MIGRATIONS: Migration[] = [
{
version: 2,
title: '설정 파일 경로 변경',
description: '기본 경로가 ./config.yaml → ~/.damn-my-slow-isp/config-skt.yaml로 변경되었습니다.',
apply: (config) => {
// 경로 변경은 이미 코드에서 처리됨. config_version만 업데이트.
return { ...config, _config_version: 2 };
},
requiresScheduleReinstall: false,
},
{
version: 3,
title: '다회 측정 지원 (하루 최대 10회)',
description: [
'기존: 하루 1회 측정',
'변경: 하루 최대 10회, 2시간 간격으로 측정 (감면 성공 시 중단)',
'',
'속도가 정상으로 나올 수 있는 시간대를 피해 여러 번 측정합니다.',
'스케줄 재등록이 필요합니다.',
].join('\n'),
apply: (config) => {
return {
...config,
schedule: {
...config.schedule,
max_attempts: 10,
retry_interval_minutes: 120,
stop_on_complaint_success: true,
},
_config_version: 3,
};
},
requiresScheduleReinstall: true,
},
{
version: 4,
title: 'SKT/SK브로드밴드 공식 이용약관 동의 기록',
description: [
'SKT/SK브로드밴드 SLA 측정과 감면 신청을 실행하려면 공식 이용약관 동의 기록이 필요합니다.',
'아래 공식 약관을 확인한 뒤 적용하면 현재 버전 동의 시각이 설정 파일에 저장됩니다.',
'',
`SK브로드밴드 이용약관: ${SKT_TERMS_URLS[0]}`,
`전기통신 서비스 이용약관 PDF: ${SKT_TERMS_URLS[1]}`,
].join('\n'),
apply: (config) => {
return {
...config,
terms: {
provider: 'skt',
accepted: true,
accepted_at: new Date().toISOString(),
version: SKT_TERMS_VERSION,
urls: [...SKT_TERMS_URLS],
},
_config_version: 4,
};
},
requiresScheduleReinstall: false,
},
];
/**
* 대기 중인 마이그레이션이 있는지 확인하고 interactive하게 적용.
* non-interactive (cron/launchd) 환경에서는 스킵하고 안내만 출력.
*/
export async function checkAndRunMigrations(
config: Config,
configPath: string,
options: { interactive?: boolean } = {}
): Promise<Config> {
const currentVersion = config._config_version || 1;
if (currentVersion >= CURRENT_CONFIG_VERSION) {
return config; // 최신 상태
}
const pending = MIGRATIONS.filter((m) => m.version > currentVersion);
if (pending.length === 0) return config;
console.log('');
console.log(chalk.yellow('📋 업데이트 후 변경 사항이 있습니다:'));
console.log('');
for (const migration of pending) {
console.log(chalk.bold(` [v${migration.version}] ${migration.title}`));
for (const line of migration.description.split('\n')) {
console.log(chalk.dim(` ${line}`));
}
console.log('');
}
// non-interactive (cron/launchd 등)에서는 적용하지 않고 안내만
const isInteractive = options.interactive !== undefined
? options.interactive
: process.stdout.isTTY === true;
if (!isInteractive) {
console.log(chalk.yellow(' → 대화형 환경에서 "npx -y damn-my-slow-skt@latest run" 을 실행하여 마이그레이션을 적용하세요.'));
console.log('');
return config;
}
let updatedConfig = { ...config };
let needScheduleReinstall = false;
for (const migration of pending) {
const { apply: doApply } = await inquirer.prompt([
{
type: 'confirm',
name: 'apply',
message: `[v${migration.version}] ${migration.title} - 적용하시겠습니까?`,
default: true,
},
]);
if (doApply) {
updatedConfig = migration.apply(updatedConfig);
if (migration.requiresScheduleReinstall) {
needScheduleReinstall = true;
}
console.log(chalk.green(` ✅ v${migration.version} 적용 완료`));
} else {
// 거절해도 version은 올려서 다시 묻지 않도록
updatedConfig._config_version = migration.version;
console.log(chalk.dim(` ⏭️ v${migration.version} 건너뜀`));
}
}
// config 저장
saveConfig(updatedConfig, configPath);
console.log(chalk.dim(`\n 설정 파일 저장됨: ${configPath}`));
// 스케줄 재등록
if (needScheduleReinstall) {
const platform = getPlatform();
if (platform !== 'windows' && platform !== 'unknown') {
const { reinstall } = await inquirer.prompt([
{
type: 'confirm',
name: 'reinstall',
message: '스케줄을 새 설정으로 재등록하시겠습니까?',
default: true,
},
]);
if (reinstall) {
try {
removeSchedule();
installSchedule(updatedConfig, configPath);
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
console.error(chalk.red(`스케줄 재등록 실패: ${err.message}`));
console.error(chalk.dim(`수동으로 재등록하세요: npx -y damn-my-slow-skt@latest schedule install --config ${configPath}`));
}
}
}
}
console.log('');
return updatedConfig;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Discord / Telegram 알림
*/
import axios from 'axios';
import { Config } from './config';
import { SpeedRecord } from './db';
export function formatRecord(record: SpeedRecord): string {
// 자동화 오류로 측정 자체가 실패한 경우 — 속도값이 0이어도 의미 없음
if (record.error) {
return (
`**인터넷 속도 측정 실패** (${record.measured_at.slice(0, 16)})\n` +
`🚨 오류: ${record.error}`
);
}
const slaEmoji =
record.sla_result === 'pass' ? '✅' : record.sla_result === 'fail' ? '❌' : '⚠️';
let complaintInfo = '';
if (record.complaint_filed) {
complaintInfo = `\n🔔 이의신청: ${record.complaint_result === 'success' ? '완료' : '실패'}`;
}
return (
`**인터넷 속도 측정 결과** (${record.measured_at.slice(0, 16)})\n` +
`${slaEmoji} SLA: ${record.sla_result.toUpperCase()}\n` +
`⬇️ 다운로드: ${record.download_mbps.toFixed(1)} Mbps` +
complaintInfo
);
}
export async function notifyDiscord(webhookUrl: string, record: SpeedRecord): Promise<boolean> {
if (!webhookUrl) return false;
const message = formatRecord(record);
const color = record.error ? 0xff8800 : record.sla_result === 'pass' ? 0x00ff00 : 0xff0000;
const payload = {
embeds: [
{
title: '🐌 damn-my-slow-skt',
description: message,
color,
},
],
};
try {
await axios.post(webhookUrl, payload, { timeout: 10000 });
console.log('Discord 알림 전송 완료');
return true;
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
console.error(`Discord 알림 실패: ${err.message}`);
return false;
}
}
export async function notifyTelegram(
botToken: string,
chatId: string,
record: SpeedRecord
): Promise<boolean> {
if (!botToken || !chatId) return false;
const message = formatRecord(record).replace(/\*\*/g, '*');
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
try {
await axios.post(
url,
{ chat_id: chatId, text: message, parse_mode: 'Markdown' },
{ timeout: 10000 }
);
console.log('Telegram 알림 전송 완료');
return true;
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
console.error(`Telegram 알림 실패: ${err.message}`);
return false;
}
}
export async function sendNotifications(config: Config, record: SpeedRecord): Promise<void> {
const { notification: notif } = config;
if (notif.discord_webhook) {
await notifyDiscord(notif.discord_webhook, record);
}
if (notif.telegram_bot_token && notif.telegram_chat_id) {
await notifyTelegram(notif.telegram_bot_token, notif.telegram_chat_id, record);
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* 측정 이력 리포트 생성
*/
import Table from 'cli-table3';
import chalk from 'chalk';
import { SpeedRecord, Stats } from './db';
export function printHistory(records: SpeedRecord[]): void {
if (records.length === 0) {
console.log(chalk.yellow('측정 이력이 없습니다.'));
return;
}
const table = new Table({
head: [
chalk.cyan('일시'),
chalk.cyan('ISP'),
chalk.cyan('다운로드'),
chalk.cyan('업로드'),
chalk.cyan('Ping'),
chalk.cyan('SLA'),
chalk.cyan('이의신청'),
],
colWidths: [20, 6, 14, 14, 10, 10, 10],
style: { 'padding-left': 1, 'padding-right': 1 },
});
for (const r of records) {
const slaIcon =
r.sla_result === 'pass' ? chalk.green('✅') : r.sla_result === 'fail' ? chalk.red('❌') : '⚠️';
const complaintIcon =
r.complaint_result === 'success'
? chalk.green('✅')
: r.complaint_result === 'failed'
? chalk.red('❌')
: '-';
table.push([
r.measured_at.slice(0, 16),
r.isp.toUpperCase(),
`${r.download_mbps.toFixed(1)} Mbps`,
`${r.upload_mbps.toFixed(1)} Mbps`,
`${r.ping_ms.toFixed(0)} ms`,
slaIcon,
complaintIcon,
]);
}
console.log('\n📊 인터넷 속도 측정 이력');
console.log(table.toString());
}
export function printStats(stats: Stats): void {
if (stats.total === 0) {
console.log(chalk.yellow('측정 데이터가 없습니다.'));
return;
}
console.log('\n' + chalk.bold('📈 요약 리포트'));
console.log(` 전체 측정: ${stats.total}`);
console.log(` SLA 통과: ${chalk.green(`${stats.sla_pass}`)}`);
console.log(` SLA 미달: ${chalk.red(`${stats.sla_fail}`)}`);
console.log(` 이의신청: ${stats.complaints_filed}`);
console.log(` 평균 다운로드: ${stats.avg_download_mbps.toFixed(1)} Mbps`);
console.log(` 평균 업로드: ${stats.avg_upload_mbps.toFixed(1)} Mbps`);
if (stats.sla_fail > 0) {
const failRate = ((stats.sla_fail / stats.total) * 100).toFixed(1);
console.log(`\n ${chalk.bold.red(`⚠️ SLA 미달률: ${failRate}%`)}`);
}
}
+556
View File
@@ -0,0 +1,556 @@
/**
* 자동 스케줄 설치/제거 (macOS launchd / Linux systemd/cron)
*/
import os from 'os';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { Config, DATA_DIR, DEFAULT_CONFIG_PATH } from './config';
/** POSIX shell 안전 문자열 이스케이프. 공백·특수문자가 포함된 경로를 cron/systemd에 안전하게 넘긴다. */
function shellQuote(s: string): string {
if (/^[a-zA-Z0-9_./:@=,-]+$/.test(s)) return s;
return "'" + s.replace(/'/g, "'\\''") + "'";
}
const LAUNCHD_PLIST_PATH = path.join(
os.homedir(),
'Library',
'LaunchAgents',
'com.damn-my-slow-skt.plist'
);
const CRON_COMMENT = '# damn-my-slow-skt';
const SYSTEMD_SERVICE_PATH = path.join(
os.homedir(),
'.config',
'systemd',
'user',
'damn-my-slow-skt.service'
);
const SYSTEMD_TIMER_PATH = path.join(
os.homedir(),
'.config',
'systemd',
'user',
'damn-my-slow-skt.timer'
);
export function getPlatform(): 'macos' | 'linux' | 'windows' | 'unknown' {
const platform = os.platform();
if (platform === 'darwin') return 'macos';
if (platform === 'linux') return 'linux';
if (platform === 'win32') return 'windows';
return 'unknown';
}
/**
* npx 임시 캐시 경로인지 판별.
* npx는 _npx/ 디렉토리 아래에 임시 설치하므로, launchd/cron에 이 경로를 기록하면
* 세션 종료 후 파일이 사라져 실행 실패한다.
*/
function isNpxTempPath(p: string): boolean {
return p.includes('/_npx/') || p.includes('\\_npx\\');
}
interface CliExec {
/** 실행 바이너리 (npx 모드면 npx 절대경로, 아니면 CLI 절대경로) */
program: string;
/** program 뒤에 붙는 인자 (npx 모드면 ['--yes', 'damn-my-slow-skt']) */
prefixArgs: string[];
/** npx 모드 여부 */
isNpx: boolean;
}
function getCliExec(): CliExec {
// 1) 글로벌 설치 경로 확인
try {
const globalPath = execSync('which damn-my-slow-skt 2>/dev/null', { encoding: 'utf8' }).trim();
if (globalPath && !isNpxTempPath(globalPath)) {
return { program: globalPath, prefixArgs: [], isNpx: false };
}
} catch {
// ignore
}
// 2) process.argv[1]이 안정적 경로(글로벌 or 로컬 node_modules)인 경우
const scriptPath = process.argv[1];
if (scriptPath && scriptPath.includes('damn-my-slow-skt') && !isNpxTempPath(scriptPath)) {
return { program: scriptPath, prefixArgs: [], isNpx: false };
}
// 3) npx 모드 - npx 바이너리 절대경로를 찾아서 사용
let npxPath = 'npx';
try {
npxPath = execSync('which npx 2>/dev/null', { encoding: 'utf8' }).trim() || 'npx';
} catch {
// fallback
}
return { program: npxPath, prefixArgs: ['--yes', 'damn-my-slow-skt'], isNpx: true };
}
/** 사용자 안내용 실행 명령어 문자열 */
export function getRunCommand(): string {
const exec = getCliExec();
if (exec.isNpx) {
return 'npx damn-my-slow-skt';
}
return 'damn-my-slow-skt';
}
// ─────────────────────────────────────────────
// 스케줄 시간 계산
// ─────────────────────────────────────────────
interface ScheduleTime { hour: number; minute: number; }
/**
* 시작 시간 + 간격 + 최대 횟수로 트리거 시간 목록 생성.
* 예: 04:00, max=10, interval=120 → 04:00, 06:00, 08:00, ..., 22:00
*/
function buildScheduleTimes(config: Config): ScheduleTime[] {
const [startH, startM] = config.schedule.time.split(':').map(Number);
const maxAttempts = config.schedule.max_attempts || 10;
const intervalMin = config.schedule.retry_interval_minutes || 120;
const times: ScheduleTime[] = [];
for (let i = 0; i < maxAttempts; i++) {
const totalMinutes = (startH * 60 + startM) + (i * intervalMin);
const hour = Math.floor(totalMinutes / 60) % 24;
const minute = totalMinutes % 60;
// 다음 날로 넘어가면 중단 (24시간 내만)
if (i > 0 && totalMinutes >= 24 * 60) break;
times.push({ hour, minute });
}
return times;
}
/** 스케줄 시간을 보기 좋게 출력 */
function formatScheduleTimes(times: ScheduleTime[]): string {
return times.map((t) => `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`).join(', ');
}
// ─────────────────────────────────────────────
// macOS - launchd plist
// ─────────────────────────────────────────────
function buildLaunchdPlist(config: Config, configPath: string): string {
const times = buildScheduleTimes(config);
const exec = getCliExec();
const logDir = DATA_DIR;
const logPath = path.join(logDir, 'run.log');
const errPath = path.join(logDir, 'run.error.log');
fs.mkdirSync(logDir, { recursive: true });
const args = [exec.program, ...exec.prefixArgs, 'run', '--config', configPath];
const argsXml = args.map((a) => ` <string>${a}</string>`).join('\n');
// launchd는 StartCalendarInterval을 array로 받으면 여러 시간에 트리거
const calendarEntries = times.map((t) => ` <dict>
<key>Hour</key>
<integer>${t.hour}</integer>
<key>Minute</key>
<integer>${t.minute}</integer>
</dict>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.damn-my-slow-skt</string>
<key>ProgramArguments</key>
<array>
${argsXml}
</array>
<key>StartCalendarInterval</key>
<array>
${calendarEntries}
</array>
<key>StandardOutPath</key>
<string>${logPath}</string>
<key>StandardErrorPath</key>
<string>${errPath}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
`;
}
export function installMacos(config: Config, configPath: string): void {
const plistDir = path.dirname(LAUNCHD_PLIST_PATH);
fs.mkdirSync(plistDir, { recursive: true });
// 기존 언로드
try {
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
} catch {
// ignore
}
const plist = buildLaunchdPlist(config, configPath);
fs.writeFileSync(LAUNCHD_PLIST_PATH, plist, 'utf8');
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`);
const times = buildScheduleTimes(config);
console.log(`✅ macOS launchd 스케줄 등록 완료: ${LAUNCHD_PLIST_PATH}`);
console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`);
console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`);
console.log(`\n 제거하려면: npx -y damn-my-slow-skt@latest schedule remove`);
}
export function removeMacos(): void {
if (!fs.existsSync(LAUNCHD_PLIST_PATH)) {
console.log('등록된 launchd 스케줄이 없습니다.');
return;
}
try {
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
} catch {
// ignore
}
fs.unlinkSync(LAUNCHD_PLIST_PATH);
console.log('✅ macOS launchd 스케줄 제거 완료');
}
// ─────────────────────────────────────────────
// Linux - systemd timer 또는 cron
// ─────────────────────────────────────────────
function hasSystemd(): boolean {
try {
execSync('systemctl --user status 2>/dev/null', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/** crontab 바이너리가 PATH에 존재하는지 확인 */
function hasCrontab(): boolean {
try {
execSync('which crontab 2>/dev/null', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* Synology NAS 감지 — /etc/synoinfo.conf 존재 여부로 판별.
* Synology DSM은 user-level crontab이 없고 /etc/crontab을 직접 편집해야 한다.
*/
function isSynology(): boolean {
return fs.existsSync('/etc/synoinfo.conf');
}
export function installLinux(config: Config, configPath: string): void {
if (hasSystemd()) {
installSystemd(config, configPath);
} else if (isSynology()) {
installSynologyCron(config, configPath);
} else if (hasCrontab()) {
installCron(config, configPath);
} else {
throw new Error(
'crontab 명령어를 찾을 수 없습니다.\n' +
'수동으로 cron을 설정하세요:\n' +
` npx --yes damn-my-slow-skt run --config ${configPath}`
);
}
}
function installSystemd(config: Config, configPath: string): void {
const times = buildScheduleTimes(config);
const exec = getCliExec();
const serviceDir = path.dirname(SYSTEMD_SERVICE_PATH);
fs.mkdirSync(serviceDir, { recursive: true });
const execCmd = [shellQuote(exec.program), ...exec.prefixArgs.map(shellQuote), 'run', '--config', shellQuote(configPath)].join(' ');
const serviceContent = `[Unit]
Description=damn-my-slow-skt SKT/SK Broadband SLA Speed Test
[Service]
Type=oneshot
ExecStart=${execCmd}
StandardOutput=journal
StandardError=journal
`;
// systemd는 여러 OnCalendar 라인을 지원
const onCalendarLines = times
.map((t) => `OnCalendar=*-*-* ${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}:00`)
.join('\n');
const timerContent = `[Unit]
Description=damn-my-slow-skt daily timer (${times.length}회/일)
[Timer]
${onCalendarLines}
Persistent=true
[Install]
WantedBy=timers.target
`;
fs.writeFileSync(SYSTEMD_SERVICE_PATH, serviceContent, 'utf8');
fs.writeFileSync(SYSTEMD_TIMER_PATH, timerContent, 'utf8');
execSync('systemctl --user daemon-reload');
execSync('systemctl --user enable damn-my-slow-skt.timer');
execSync('systemctl --user start damn-my-slow-skt.timer');
console.log(`✅ systemd 타이머 등록 완료`);
console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`);
console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`);
console.log(` 확인: systemctl --user status damn-my-slow-skt.timer`);
console.log(`\n 제거하려면: npx -y damn-my-slow-skt@latest schedule remove`);
}
/**
* Synology NAS 전용 cron 설치.
* DSM은 user-level crontab이 없으므로 /etc/crontab을 직접 편집하고
* synoservicectl --restart crond로 crond를 재시작한다.
* /etc/crontab 형식: minute hour mday month wday user command
*/
function installSynologyCron(config: Config, configPath: string): void {
const SYSTEM_CRONTAB = '/etc/crontab';
const times = buildScheduleTimes(config);
const exec = getCliExec();
const logPath = path.join(DATA_DIR, 'cron.log');
const user = os.userInfo().username;
const execCmd = [shellQuote(exec.program), ...exec.prefixArgs.map(shellQuote), 'run', '--config', shellQuote(configPath)].join(' ');
// cron은 최소 PATH로 실행되므로 node/npx가 있는 디렉토리를 PATH에 명시해야 한다
const nodeBinDir = path.dirname(process.execPath);
const pathPrefix = `PATH=${shellQuote(nodeBinDir)}:/usr/local/bin:/usr/bin:/bin`;
// Synology /etc/crontab은 user 필드가 포함된 형식
const cronLines = times.map((t) =>
`${t.minute}\t${t.hour}\t*\t*\t*\t${user}\t${pathPrefix} ${execCmd} >> ${shellQuote(logPath)} 2>&1 ${CRON_COMMENT}`
);
let existing = '';
try {
existing = fs.readFileSync(SYSTEM_CRONTAB, 'utf8');
} catch {
throw new Error(`${SYSTEM_CRONTAB}을 읽을 수 없습니다. sudo 권한이 필요할 수 있습니다.`);
}
// 기존 damn-my-slow-skt 라인 제거 후 새 라인 추가
const lines = existing
.split('\n')
.filter((l) => !l.includes(CRON_COMMENT));
// 마지막 빈 줄 유지하면서 추가
while (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
lines.push(...cronLines, '');
const newCrontab = lines.join('\n');
try {
fs.writeFileSync(SYSTEM_CRONTAB, newCrontab, 'utf8');
} catch {
// sudo 실행 시 $HOME이 /root로 바뀌므로 --config로 원래 경로를 명시해야 한다
throw new Error(
`/etc/crontab 쓰기 실패. sudo 권한으로 다시 시도하세요:\n` +
` sudo npx --yes damn-my-slow-skt schedule install --config ${configPath}`
);
}
// crond 재시작으로 변경사항 반영
try {
execSync('synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' });
} catch {
// synoservicectl이 없으면 /usr/syno/bin/ 경로로 재시도
try {
execSync('/usr/syno/bin/synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' });
} catch {
console.log('⚠️ crond 재시작 실패 — NAS를 재부팅하면 반영됩니다.');
}
}
console.log(`✅ Synology /etc/crontab 등록 완료`);
console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`);
console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`);
console.log(`\n 제거하려면: sudo npx --yes damn-my-slow-skt schedule remove`);
}
function installCron(config: Config, configPath: string): void {
const times = buildScheduleTimes(config);
const exec = getCliExec();
const logPath = path.join(DATA_DIR, 'cron.log');
const execCmd = [shellQuote(exec.program), ...exec.prefixArgs.map(shellQuote), 'run', '--config', shellQuote(configPath)].join(' ');
// cron은 최소 PATH로 실행되므로 node/npx가 있는 디렉토리를 PATH에 명시
const nodeBinDir = path.dirname(process.execPath);
const pathPrefix = `PATH=${shellQuote(nodeBinDir)}:/usr/local/bin:/usr/bin:/bin`;
// 각 트리거 시간마다 cron 라인 생성
const cronLines = times.map((t) =>
`${t.minute} ${t.hour} * * * ${pathPrefix} ${execCmd} >> ${shellQuote(logPath)} 2>&1 ${CRON_COMMENT}`
);
let existing = '';
try {
existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
} catch {
// no crontab
}
// 기존 damn-my-slow-skt 라인 제거 후 새 라인 추가
const lines = existing
.split('\n')
.filter((l) => !l.includes(CRON_COMMENT));
lines.push(...cronLines);
const newCrontab = lines.join('\n') + '\n';
// stdin 방식 먼저 시도 (표준 Linux)
const proc = require('child_process').spawnSync('crontab', ['-'], {
input: newCrontab,
encoding: 'utf8',
});
if (proc.status !== 0 || proc.error) {
// BusyBox(Synology NAS 등)는 crontab - (stdin) 미지원 → 임시 파일 방식으로 재시도
const tmpFile = path.join(os.tmpdir(), `damn-my-slow-skt-cron-${Date.now()}.tmp`);
try {
fs.writeFileSync(tmpFile, newCrontab, 'utf8');
const proc2 = require('child_process').spawnSync('crontab', [tmpFile], {
encoding: 'utf8',
});
if (proc2.status !== 0 || proc2.error) {
const errMsg =
(proc2.stderr as string | undefined) ||
proc2.error?.message ||
(proc.stderr as string | undefined) ||
proc.error?.message ||
'unknown error';
throw new Error(`crontab 설치 실패: ${errMsg}`);
}
} finally {
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
}
}
console.log(`✅ crontab 등록 완료`);
console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`);
console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`);
console.log(`\n 제거하려면: npx -y damn-my-slow-skt@latest schedule remove`);
}
export function removeLinux(): void {
if (fs.existsSync(SYSTEMD_SERVICE_PATH) || fs.existsSync(SYSTEMD_TIMER_PATH)) {
try {
execSync('systemctl --user stop damn-my-slow-skt.timer 2>/dev/null', { stdio: 'ignore' });
execSync('systemctl --user disable damn-my-slow-skt.timer 2>/dev/null', { stdio: 'ignore' });
} catch {
// ignore
}
if (fs.existsSync(SYSTEMD_SERVICE_PATH)) fs.unlinkSync(SYSTEMD_SERVICE_PATH);
if (fs.existsSync(SYSTEMD_TIMER_PATH)) fs.unlinkSync(SYSTEMD_TIMER_PATH);
try {
execSync('systemctl --user daemon-reload 2>/dev/null', { stdio: 'ignore' });
} catch {
// ignore
}
console.log('✅ systemd 타이머 제거 완료');
return;
}
// Synology NAS: /etc/crontab에서 제거
if (isSynology()) {
try {
const SYSTEM_CRONTAB = '/etc/crontab';
const existing = fs.readFileSync(SYSTEM_CRONTAB, 'utf8');
const lines = existing.split('\n').filter((l) => !l.includes(CRON_COMMENT));
fs.writeFileSync(SYSTEM_CRONTAB, lines.join('\n') + '\n', 'utf8');
try {
execSync('synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' });
} catch {
try {
execSync('/usr/syno/bin/synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' });
} catch { /* ignore */ }
}
console.log('✅ Synology /etc/crontab 스케줄 제거 완료');
} catch {
throw new Error('/etc/crontab 수정 실패. sudo 권한으로 다시 시도하세요:\n sudo npx --yes damn-my-slow-skt schedule remove');
}
return;
}
// 일반 Linux: crontab 명령어로 제거
try {
const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
if (!existing.includes(CRON_COMMENT)) {
console.log('등록된 crontab 스케줄이 없습니다.');
return;
}
const lines = existing.split('\n').filter((l) => !l.includes(CRON_COMMENT));
const newCrontab = lines.join('\n') + '\n';
const proc = require('child_process').spawnSync('crontab', ['-'], { input: newCrontab, encoding: 'utf8' });
if (proc.status !== 0 || proc.error) {
throw new Error(`crontab 제거 실패: ${(proc.stderr as string | undefined) || proc.error?.message || 'unknown error'}`);
}
console.log('✅ crontab 스케줄 제거 완료');
} catch (e: unknown) {
if (e instanceof Error && e.message.includes('crontab 제거 실패')) throw e;
console.log('등록된 crontab 스케줄이 없습니다.');
}
}
export function installSchedule(config: Config, configPath: string = DEFAULT_CONFIG_PATH): void {
const platform = getPlatform();
if (platform === 'macos') {
installMacos(config, configPath);
} else if (platform === 'linux') {
installLinux(config, configPath);
} else if (platform === 'windows') {
console.log('');
const times = buildScheduleTimes(config);
console.log('Windows에서는 작업 스케줄러(Task Scheduler)를 사용하세요:');
console.log('1. Win + R → taskschd.msc 입력');
console.log('2. 기본 작업 만들기 클릭');
console.log(`3. 프로그램: npx --yes damn-my-slow-skt run --config ${configPath}`);
console.log(`4. 트리거: 매일 ${formatScheduleTimes(times)} (${times.length}개 등록)`);
console.log(' (run 내부에서 오늘 완료 여부를 체크하므로 모두 등록해도 안전합니다)');
} else {
throw new Error(`지원하지 않는 플랫폼: ${platform}`);
}
}
export function removeSchedule(): void {
const platform = getPlatform();
if (platform === 'macos') {
removeMacos();
} else if (platform === 'linux') {
removeLinux();
} else {
console.log('이 플랫폼에서는 자동 제거가 지원되지 않습니다.');
}
}
+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 {
// 스냅샷 저장 실패는 무시 — 측정 플로우에 영향 없음
}
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 자동 업데이트 체크 - npm registry에서 최신 버전 확인
* 24시간에 1번만 체크 (캐시)
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import axios from 'axios';
import chalk from 'chalk';
const CACHE_FILE = path.join(os.homedir(), '.damn-my-slow-isp', 'update-cache.json');
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24시간
const PACKAGE_NAME = 'damn-my-slow-skt';
interface UpdateCache {
lastCheck: number;
latestVersion: string;
}
function readCache(): UpdateCache | null {
try {
if (!fs.existsSync(CACHE_FILE)) return null;
const raw = fs.readFileSync(CACHE_FILE, 'utf8');
return JSON.parse(raw) as UpdateCache;
} catch {
return null;
}
}
function writeCache(data: UpdateCache): void {
try {
const dir = path.dirname(CACHE_FILE);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf8');
} catch {
// ignore cache write errors
}
}
async function fetchLatestVersion(): Promise<string | null> {
try {
const resp = await axios.get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
timeout: 5000,
});
return resp.data?.version || null;
} catch {
return null;
}
}
function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na !== nb) return na - nb;
}
return 0;
}
export async function checkForUpdates(
currentVersion: string,
options: { noUpdateCheck?: boolean; interactive?: boolean } = {}
): Promise<void> {
if (options.noUpdateCheck) return;
const cache = readCache();
const now = Date.now();
// 24시간 이내 체크했으면 스킵
if (cache && now - cache.lastCheck < CHECK_INTERVAL_MS) {
const latestVersion = cache.latestVersion;
if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) {
printUpdateNotice(currentVersion, latestVersion);
}
return;
}
const latestVersion = await fetchLatestVersion();
if (!latestVersion) return;
writeCache({ lastCheck: now, latestVersion });
if (compareVersions(latestVersion, currentVersion) > 0) {
printUpdateNotice(currentVersion, latestVersion);
}
}
function printUpdateNotice(current: string, latest: string): void {
console.log('');
console.log(
chalk.yellow('🔄 새 버전이 있습니다:') +
chalk.dim(` v${current}`) +
chalk.yellow(' → ') +
chalk.green(`v${latest}`)
);
console.log(chalk.dim(' 업데이트하려면:'));
console.log(chalk.cyan(` npm install -g ${PACKAGE_NAME}@latest`));
console.log('');
}
+187
View File
@@ -0,0 +1,187 @@
/**
* config.ts 단위 테스트
* - getDefaultConfig()가 올바른 기본값을 반환하는지 확인
* - 설정 로드/저장은 파일 I/O가 포함되므로 통합 테스트에 가까움
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import inquirer from 'inquirer';
import { checkAndRunMigrations, CURRENT_CONFIG_VERSION } from '../src/migration';
import {
getDefaultConfig,
loadConfig,
saveConfig,
SKT_TERMS_URLS,
SKT_TERMS_VERSION,
validateRequiredFields,
} from '../src/config';
afterEach(() => {
vi.restoreAllMocks();
});
describe('getDefaultConfig', () => {
it('should return valid default config with required fields', () => {
const config = getDefaultConfig();
expect(config._config_version).toBe(4);
expect(config.credentials).toBeDefined();
expect(config.terms).toEqual({
provider: 'skt',
accepted: false,
accepted_at: '',
version: SKT_TERMS_VERSION,
urls: SKT_TERMS_URLS,
});
expect(config.plan.speed_mbps).toBe(1000);
expect(config.schedule.timezone).toBe('Asia/Seoul');
expect(config.headless).toBe(true);
});
it('should have schedule with multi-attempt defaults', () => {
const config = getDefaultConfig();
expect(config.schedule.max_attempts).toBeGreaterThan(1);
expect(config.schedule.retry_interval_minutes).toBeGreaterThan(0);
expect(config.schedule.stop_on_complaint_success).toBe(true);
});
});
describe('validateRequiredFields', () => {
it('should require SKT terms acceptance', () => {
const config = getDefaultConfig();
config.credentials.id = 'user@example.com';
config.credentials.password = 'password';
config.phone = '01012345678';
expect(validateRequiredFields(config)).toContain(
'terms (SKT/SK브로드밴드 공식 이용약관 동의)',
);
});
it('should reject invalid SKT terms acceptance timestamps', () => {
const config = getDefaultConfig();
config.credentials.id = 'user@example.com';
config.credentials.password = 'password';
config.phone = '01012345678';
config.terms = {
provider: 'skt',
accepted: true,
accepted_at: 'not-a-date',
version: SKT_TERMS_VERSION,
urls: SKT_TERMS_URLS,
};
expect(validateRequiredFields(config)).toContain(
'terms (SKT/SK브로드밴드 공식 이용약관 동의)',
);
});
it('should reject normalized or underspecified timestamp strings', () => {
const config = getDefaultConfig();
config.credentials.id = 'user@example.com';
config.credentials.password = 'password';
config.phone = '01012345678';
config.terms = {
provider: 'skt',
accepted: true,
accepted_at: '1',
version: SKT_TERMS_VERSION,
urls: SKT_TERMS_URLS,
};
expect(validateRequiredFields(config)).toContain(
'terms (SKT/SK브로드밴드 공식 이용약관 동의)',
);
config.terms.accepted_at = '2026-02-31T00:00:00.000Z';
expect(validateRequiredFields(config)).toContain(
'terms (SKT/SK브로드밴드 공식 이용약관 동의)',
);
});
it('should accept config with current SKT terms metadata', () => {
const config = getDefaultConfig();
config.credentials.id = 'user@example.com';
config.credentials.password = 'password';
config.phone = '01012345678';
config.terms = {
provider: 'skt',
accepted: true,
accepted_at: '2026-03-30T00:00:00.000Z',
version: SKT_TERMS_VERSION,
urls: SKT_TERMS_URLS,
};
expect(validateRequiredFields(config)).toEqual([]);
});
});
describe('loadConfig and saveConfig terms handling', () => {
it('should default missing terms in old configs safely', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skt-config-'));
const configPath = path.join(dir, 'config.yaml');
fs.writeFileSync(configPath, [
'_config_version: 3',
'credentials:',
' id: "user@example.com"',
' password: "password"',
'phone: "01012345678"',
].join('\n'), 'utf8');
const config = loadConfig(configPath);
expect(config.terms.accepted).toBe(false);
expect(config.terms.version).toBe(SKT_TERMS_VERSION);
expect(config.terms.urls).toEqual(SKT_TERMS_URLS);
});
it('should preserve accepted current terms when saving and loading', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skt-config-'));
const configPath = path.join(dir, 'config.yaml');
const config = getDefaultConfig();
config.terms = {
provider: 'skt',
accepted: true,
accepted_at: '2026-03-30T00:00:00.000Z',
version: SKT_TERMS_VERSION,
urls: SKT_TERMS_URLS,
};
saveConfig(config, configPath);
const loaded = loadConfig(configPath);
expect(loaded.terms).toEqual(config.terms);
});
});
describe('terms migration', () => {
it('should record SKT terms acceptance when migrating v3 configs to v4', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skt-config-'));
const configPath = path.join(dir, 'config.yaml');
const config = getDefaultConfig();
config._config_version = 3;
config.credentials.id = 'user@example.com';
config.credentials.password = 'password';
config.phone = '01012345678';
vi.spyOn(inquirer, 'prompt').mockResolvedValue({ apply: true });
const migrated = await checkAndRunMigrations(config, configPath, { interactive: true });
expect(migrated._config_version).toBe(CURRENT_CONFIG_VERSION);
expect(migrated.terms).toMatchObject({
provider: 'skt',
accepted: true,
version: SKT_TERMS_VERSION,
urls: SKT_TERMS_URLS,
});
expect(new Date(migrated.terms.accepted_at).toISOString()).toBe(migrated.terms.accepted_at);
expect(loadConfig(configPath).terms.accepted).toBe(true);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* db.ts 단위 테스트
* - getTodayRecords(): UTC로 저장된 measured_at과 타임존 로컬 날짜 비교가
* 올바르게 동작해야 함 (이슈 #5)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { SpeedDatabase, SpeedRecord } from '../src/db';
function makeRecord(overrides: Partial<SpeedRecord>): Omit<SpeedRecord, 'id'> {
return {
isp: 'skt',
measured_at: new Date().toISOString(),
download_mbps: 100,
upload_mbps: 100,
ping_ms: 10,
sla_result: 'fail',
complaint_filed: true,
complaint_result: 'success',
raw_data: '{}',
error: '',
...overrides,
};
}
describe('SpeedDatabase.getTodayRecords (timezone-aware)', () => {
let tmpDir: string;
let db: SpeedDatabase;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dmsk-db-'));
db = new SpeedDatabase(path.join(tmpDir, 'test.db'));
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('treats UTC record from KST early morning as today (issue #5)', () => {
// KST 2026-04-18 04:00 = UTC 2026-04-17 19:00
const utcEarlyMorning = '2026-04-17T19:00:00.000Z';
db.save(makeRecord({ measured_at: utcEarlyMorning }));
// 같은 KST 날짜(2026-04-18)의 06:00 = UTC 2026-04-17 21:00 시점 기준으로 조회
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T21:00:00.000Z'));
try {
const records = db.getTodayRecords('Asia/Seoul');
expect(records).toHaveLength(1);
expect(records[0].measured_at).toBe(utcEarlyMorning);
} finally {
vi.useRealTimers();
}
});
it('hasTodayComplaintSuccess returns true for KST early-morning success', () => {
db.save(
makeRecord({
measured_at: '2026-04-17T19:00:00.000Z', // KST 2026-04-18 04:00
complaint_result: 'success',
})
);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T21:00:00.000Z')); // KST 06:00
try {
expect(db.hasTodayComplaintSuccess('Asia/Seoul')).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('excludes records from previous KST day', () => {
// KST 2026-04-17 23:00 = UTC 2026-04-17 14:00 (어제)
db.save(makeRecord({ measured_at: '2026-04-17T14:00:00.000Z' }));
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-17T21:00:00.000Z')); // KST 2026-04-18 06:00
try {
const records = db.getTodayRecords('Asia/Seoul');
expect(records).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
globals: true,
testTimeout: 10_000,
},
});