init
This commit is contained in:
@@ -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 입력
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Vendored
+10
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
```
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
require('../dist/index.js');
|
||||
@@ -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"
|
||||
@@ -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/"],
|
||||
},
|
||||
];
|
||||
Generated
+3641
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+271
@@ -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 로그인 세션 저장 경로
|
||||
`;
|
||||
}
|
||||
@@ -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:00–08: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
@@ -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'));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}%`)}`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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('');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
globals: true,
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user