From 02970df6af0c0f53b62f33c859c91a1f1424b2fe Mon Sep 17 00:00:00 2001 From: chungyeong Date: Thu, 19 Feb 2026 17:28:58 +0900 Subject: [PATCH] initial commit --- .dockerignore | 6 + Dockerfile | 16 + README.md | 391 ++++++++++++++++ docker-compose.yml | 62 +++ package.json | 18 + src/alertRules.js | 68 +++ src/cli.js | 207 +++++++++ src/crawlerClient.js | 427 +++++++++++++++++ src/dashboard/dashboard.css | 349 ++++++++++++++ src/dashboard/dashboard.js | 398 ++++++++++++++++ src/dashboard/index.html | 96 ++++ src/dashboardApi.js | 152 ++++++ src/dashboardAssets.js | 34 ++ src/dashboardRuntime.js | 104 +++++ src/dashboardServer.js | 225 +++++++++ src/dashboardStore.js | 562 +++++++++++++++++++++++ src/dashboardUtils.js | 40 ++ src/envLoader.js | 50 ++ src/fastifyDashboardServer.js | 162 +++++++ src/llmParameterExtractor.js | 283 ++++++++++++ src/naturalLanguageFlightParser.js | 252 ++++++++++ src/notifier.js | 184 ++++++++ src/priceWatcher.js | 443 ++++++++++++++++++ src/skyscannerSampleServer.js | 186 ++++++++ test/alertRules.test.js | 50 ++ test/crawlerClient.test.js | 201 ++++++++ test/dashboardStore.test.js | 65 +++ test/envLoader.test.js | 48 ++ test/llmParameterExtractor.test.js | 69 +++ test/naturalLanguageFlightParser.test.js | 52 +++ test/notifier.test.js | 97 ++++ test/priceWatcher.test.js | 134 ++++++ test/priceWatcherControls.test.js | 105 +++++ test/skyscannerSampleServer.test.js | 137 ++++++ 34 files changed, 5673 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/alertRules.js create mode 100644 src/cli.js create mode 100644 src/crawlerClient.js create mode 100644 src/dashboard/dashboard.css create mode 100644 src/dashboard/dashboard.js create mode 100644 src/dashboard/index.html create mode 100644 src/dashboardApi.js create mode 100644 src/dashboardAssets.js create mode 100644 src/dashboardRuntime.js create mode 100644 src/dashboardServer.js create mode 100644 src/dashboardStore.js create mode 100644 src/dashboardUtils.js create mode 100644 src/envLoader.js create mode 100644 src/fastifyDashboardServer.js create mode 100644 src/llmParameterExtractor.js create mode 100644 src/naturalLanguageFlightParser.js create mode 100644 src/notifier.js create mode 100644 src/priceWatcher.js create mode 100644 src/skyscannerSampleServer.js create mode 100644 test/alertRules.test.js create mode 100644 test/crawlerClient.test.js create mode 100644 test/dashboardStore.test.js create mode 100644 test/envLoader.test.js create mode 100644 test/llmParameterExtractor.test.js create mode 100644 test/naturalLanguageFlightParser.test.js create mode 100644 test/notifier.test.js create mode 100644 test/priceWatcher.test.js create mode 100644 test/priceWatcherControls.test.js create mode 100644 test/skyscannerSampleServer.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4fcef68 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.env +.git +.gitignore +test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..69bf5d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi + +COPY . . + +ENV NODE_ENV=production \ + DASHBOARD_HOST=0.0.0.0 \ + DASHBOARD_PORT=3000 + +EXPOSE 3000 + +CMD ["npm", "run", "dashboard:fastify"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..95414e6 --- /dev/null +++ b/README.md @@ -0,0 +1,391 @@ +# Air-Watcher 항공권 가격 추적 및 알림 프로젝트 기획서 + +## 1. 프로젝트 개요 + +- 프로젝트 명칭: 에어-워처 (Air-Watcher) +- 목표: 사용자가 원하는 항공권의 가격 변동을 실시간으로 추적하고, 가격이 변동될 경우 알림을 제공하여 최적의 구매 시점을 찾을 수 있도록 돕는다. +- 핵심 가치: 항공권 구매에 드는 시간과 비용을 절약하고, 사용자에게 경제적 이익을 제공한다. + +## 2. 주요 기능 (Features) + +### 2.1 항공권 검색 + +- 기능: 출발지, 도착지, 날짜(왕복/편도)를 기준으로 항공권을 검색할 수 있다. +- 사용자 인터페이스: 직관적인 검색 폼을 제공하며, 자동 완성 기능을 통해 도시/공항 코드 입력이 용이해야 한다. + +### 2.2 가격 추적 (Watching) + +- 기능: 검색된 항공권 또는 특정 노선(예: 서울-도쿄)을 관심 목록에 추가하여 가격 변동을 추적할 수 있다. +- 구현: 사용자가 `추적하기` 버튼을 누르면, 해당 항공편 정보가 데이터베이스에 저장된다. + +### 2.3 가격 변동 알림 + +- 기능: 관심 목록에 추가된 항공권의 가격이 변경되면 사용자에게 알림을 보낸다. +- 운영 알림 채널: + - 기본 운영: 텔레그램 봇 알림 + - 개발/로컬 fallback: 콘솔 출력 + - 확장 옵션: 웹훅 중계(슬랙 등) +- 알림 조건: 사용자가 설정한 특정 가격 이하로 떨어졌을 때, 또는 모든 가격 변동 시 알림을 선택할 수 있다. + +### 2.4 대시보드 + +- 기능: 사용자는 자신의 관심 목록, 추적 중인 항공권의 현재 가격, 과거 가격 변동 내역을 차트로 한눈에 볼 수 있다. +- 시각화: 가격 변동 추이를 쉽게 파악할 수 있도록 그래프 형태로 시각화한다. + +### 2.5 접근 제어 (Nginx 권장) + +- 앱 내부 계정/로그인은 필수 요구사항이 아니다. +- 서버 접근 제어는 Nginx 단에서 처리한다. +- 필요 시 Nginx `basic auth`를 강제해 외부 접근을 제한한다. + +## 3. 기술 스택 (Tech Stack) + +### 3.1 프론트엔드 (Frontend) + +- React.js 또는 Vue.js: 동적인 사용자 인터페이스 구축 +- TypeScript: 타입 안정성 확보 +- Tailwind CSS 또는 Styled-Components: 빠르고 일관된 스타일링 + +### 3.2 백엔드 (Backend) + +- Node.js with Express 또는 FastAPI (Python): RESTful API 서버 구축 +- TypeScript (Node.js 사용 시) + +### 3.3 데이터베이스 (Database) + +- MySQL: watch 설정, 최신 스냅샷, 알림 이벤트, 시스템 토글 상태 저장 + +### 3.4 항공권 데이터 + +- 웹페이지 크롤링 기반 수집: + - Patchright/Puppeteer: JavaScript 렌더링이 필요한 페이지 수집 + - Cheerio(또는 BeautifulSoup): HTML 파싱 및 데이터 추출 + - 사이트별 크롤러 어댑터: 각 사이트의 DOM 구조 변화에 대응 + - 멀티 소스 호환(필수): `Skyscanner` / `Naver 항공권` / `Google Flights`를 공통 인터페이스로 수집 + - 공통 스키마 정규화: 소스별 응답을 `offers[]` 단일 포맷으로 변환 + - 소스 라우팅/폴백: 1순위 소스 실패 시 다음 소스로 자동 전환 + - 프록시/재시도/백오프 전략: 차단 및 일시 오류 대응 + +### 3.5 주기적 작업 (Scheduler) + +- `node-cron` (Node.js) 또는 Celery (Python): 정해진 시간마다 크롤링 작업을 실행해 최신 가격 데이터를 수집 + +### 3.6 알림 서비스 + +- Telegram Bot API 기반 메시지 발송 +- 웹훅 연동(외부 자동화/메신저 게이트웨이 확장) + +### 3.7 배포 (Deployment) + +- Vercel (프론트엔드), AWS(EC2, RDS) 또는 Heroku (백엔드) + +## 4. 시스템 아키텍처 + +1. 클라이언트 (React/Vue): 사용자가 항공권을 검색하고 추적 요청을 보낸다. +2. API 서버 (Node.js/Express): 클라이언트 요청을 처리하고, 데이터베이스 및 크롤링 결과와 상호작용한다. +3. 데이터베이스 (MySQL): 추적할 항공권 정보와 수집된 가격 데이터를 저장한다. +4. 스케줄러 (`node-cron`): 주기적으로 가격 수집기를 실행한다. +5. 가격 수집기 (Crawler Worker): 데이터베이스에서 추적 중인 항공권 목록을 가져와 대상 웹페이지를 크롤링하고 최신 가격을 추출한다. +6. 정규화 레이어 (Normalizer): 사이트별로 다른 데이터 형식을 공통 스키마로 정리한다. +7. 알림 서비스 (Notifier): 가격 변동이 감지되면 텔레그램(기본) 또는 웹훅/콘솔로 알림을 발송한다. + +### 4.1 멀티 소스 크롤러 호환 설계 (Skyscanner/Naver/Google) + +- 결론: 가능하다. 단, 소스별 정책/약관/차단 리스크가 달라 어댑터 분리가 필요하다. +- 공통 크롤러 인터페이스 예시: + - `getQuotes(searchParams) -> { currency, offers[] }` +- 소스 어댑터: + - `skyscannerAdapter` + - `naverFlightsAdapter` + - `googleFlightsAdapter` +- 라우팅 전략: + - `primaryOnly`: 지정 소스만 사용 + - `priorityFallback`: 우선순위 목록 기반 자동 폴백 (권장) + - `parallelRace`: 병렬 조회 후 가장 먼저 성공한 결과 사용 +- 운영 권장: + - 소스별 rate limit, 차단 감지, 선택자 회귀 테스트를 분리 운영 + - 소스별 장애율/응답속도 메트릭을 수집해 우선순위를 동적으로 조정 + - 법적/정책 이슈가 있는 소스는 API/제휴 방식 우선 검토 + +### 4.2 크롤링 운영 원칙 + +- 사이트 이용약관 및 robots.txt를 확인하고 허용 범위 내에서 수집한다. +- 사이트별 요청 간격(rate limiting)을 두고 비정상적인 트래픽 패턴을 피한다. +- DOM 변경 감지를 위해 선택자 검증 로직과 실패 알림을 둔다. +- 크롤링 실패 시 재시도 정책(지수 백오프)을 적용한다. +- 수집 원본(raw HTML)과 파싱 결과를 분리 저장해 장애 분석을 용이하게 한다. + +## 5. 개발 단계 (Milestones) + +### 1단계: 핵심 백엔드 구축 (1주) + +- API 서버 기본 구조 설정 +- 사이트별 크롤러 어댑터 골격 구현 (Skyscanner/Naver/Google 3종) +- 크롤링 결과 정규화/저장 로직 구현 +- 데이터베이스 스키마 설계 + +### 2단계: 핵심 프론트엔드 구축 (1주) + +- 항공권 검색 폼 및 결과 표시 페이지 개발 + +### 3단계: 가격 추적 기능 구현 (2주) + +- 관심 목록 추가/삭제 기능 백엔드 API 개발 +- 주기적으로 가격을 가져오는 스케줄러 및 크롤러 워커 로직 구현 +- 프론트엔드에서 관심 목록 관리 UI 개발 + +### 4단계: 알림 기능 구현 (1주) + +- 텔레그램 Bot API 연동 +- 가격 변동 시 알림 발송 로직 구현 + +### 5단계: 배포 및 테스트 (1주) + +- 개발된 애플리케이션을 클라우드 환경에 배포 +- 통합 테스트 및 버그 수정 + +## 6. 자연어 입력 파서 + 가격 추적 CLI (POC) + +### 6.0 현재 반영된 요구사항 흐름 + +- `사용자 문장 -> LLM 파라미터 가공(실패 시 규칙 파서 fallback) -> 크롤러 주기 조회 -> 가격 알림` +- 구현 완료 항목: + - `watch` 명령 추가 및 옵션 파싱/실행 루프 연결 (`src/cli.js`) + - OpenAI 호출 기반 파라미터 추출기 + fallback (`src/llmParameterExtractor.js`) + - 크롤러 어댑터(엔드포인트/모의 크롤러) (`src/crawlerClient.js`) + - 가격 추적 상태관리/알림 판정 (`src/priceWatcher.js`) + - 알림기(콘솔/웹훅/텔레그램) (`src/notifier.js`) + - 테스트 추가 (`test/llmParameterExtractor.test.js`, `test/priceWatcher.test.js`, `test/notifier.test.js`) + +아래처럼 자유 문장을 입력하면 항공권 검색 조건 JSON으로 변환한다. + +```bash +npm run parse -- "11월 말부터 12월 초까지 출발하는 일정 여행 기간은 대략 12~14일, 비즈니스 2개, 프리미엄 이코노미 1개, 동일 항공편 인천 -> 마드리드 인, 바르셀로나 -> 인천 아웃 총 1회 여정 시간은 20시간 미만" +``` + +LLM 기반 파라미터 가공 + 주기적 가격 추적 실행: + +```bash +npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --interval-sec 60 --target-price 1300000 --alert-on both +``` + +한 번만 조회(테스트용): + +```bash +npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --once +``` + +Skyscanner 단독 샘플(로컬): + +```bash +npm run sample:skyscanner +``` + +다른 터미널에서: + +```bash +CRAWLER_PROVIDERS=skyscanner \ +CRAWLER_ENDPOINT_SKYSCANNER=http://127.0.0.1:8787/skyscanner \ +CRAWLER_ROUTING_STRATEGY=primaryOnly \ +npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --once +``` + +옵션: +- `--interval-sec`: 폴링 주기(초) +- `--target-price`: 목표 가격 이하 도달 시 알림 기준 +- `--alert-on both|change|threshold`: 알림 조건 (가격 변동/임계값) +- `--rule-only`: LLM 호출 없이 규칙 파서만 사용 + +환경 변수: +- `OPENAI_API_KEY`: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다. +- `OPENAI_MODEL` (선택): 기본값 `gpt-4.1-mini` +- `CRAWLER_ENDPOINT` (선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용. +- `CRAWLER_PROVIDERS` (선택): `skyscanner,naver,google` 형태 우선순위 목록. 미설정 시 단일 `CRAWLER_ENDPOINT` 또는 mock 사용. +- `CRAWLER_ENDPOINT_SKYSCANNER` (선택): Skyscanner 전용 엔드포인트 +- `CRAWLER_ENDPOINT_NAVER` (선택): Naver 전용 엔드포인트 +- `CRAWLER_ENDPOINT_GOOGLE` (선택): Google 전용 엔드포인트 +- `CRAWLER_ROUTING_STRATEGY` (선택): `priorityFallback`(기본) | `primaryOnly` | `parallelRace` +- `CRAWLER_REQUEST_TIMEOUT_MS` (선택): 크롤러 HTTP 타임아웃(ms), 기본값 `15000` +- `CRAWLER_MAX_ATTEMPTS` (선택): 크롤러 요청 최대 시도 횟수(재시도 포함), 기본값 `2` +- `CRAWLER_RETRY_BASE_DELAY_MS` (선택): 재시도 백오프 시작 지연(ms), 기본값 `300` +- `CRAWLER_RETRY_MAX_DELAY_MS` (선택): 재시도 백오프 최대 지연(ms), 기본값 `3000` +- `NOTIFY_CHANNEL` (선택): `telegram|webhook|console` +- `TELEGRAM_BOT_TOKEN` (텔레그램 사용 시 필수) +- `TELEGRAM_CHAT_ID` (텔레그램 사용 시 필수) +- `NOTIFY_WEBHOOK_URL` (웹훅 사용 시 필수) +- `TELEGRAM_API_BASE` (선택): 기본값 `https://api.telegram.org` +- `SKYSCANNER_SAMPLE_HOST` (선택): 샘플 서버 host (기본값 `127.0.0.1`) +- `SKYSCANNER_SAMPLE_PORT` (선택): 샘플 서버 port (기본값 `8787`) +- `SKYSCANNER_SAMPLE_PATH` (선택): 샘플 서버 path (기본값 `/skyscanner`) +- CLI는 현재 작업 디렉터리의 `.env` 파일을 자동 로드한다(동일 키가 이미 OS 환경변수에 있으면 OS 값을 우선). + +`.env` 예시(값은 사용자가 직접 입력): + +```bash +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-mini + +CRAWLER_ENDPOINT= +CRAWLER_PROVIDERS=skyscanner,naver,google +CRAWLER_ENDPOINT_SKYSCANNER= +CRAWLER_ENDPOINT_NAVER= +CRAWLER_ENDPOINT_GOOGLE= +CRAWLER_ROUTING_STRATEGY=priorityFallback +CRAWLER_REQUEST_TIMEOUT_MS=15000 +CRAWLER_MAX_ATTEMPTS=2 +CRAWLER_RETRY_BASE_DELAY_MS=300 +CRAWLER_RETRY_MAX_DELAY_MS=3000 + +NOTIFY_CHANNEL=telegram +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +TELEGRAM_API_BASE=https://api.telegram.org +``` + +`CRAWLER_ENDPOINT` 요청/응답 스키마(고정): + +```json +{ + "request": { + "watchId": "string", + "searchParams": { + "segments": [{"from": "ICN", "to": "MAD"}] + } + }, + "response": { + "currency": "KRW", + "offers": [ + { + "provider": "string", + "price": 1234567, + "currency": "KRW", + "metadata": {} + } + ] + } +} +``` + +테스트 실행: + +```bash +npm test +``` + +## 7. 웹 대시보드 (LLM 파싱 + 추적 관리 + 토글) + +대시보드 실행: + +```bash +npm run dashboard +``` + +Fastify 기반 전환 뼈대 실행: + +```bash +npm run dashboard:fastify +``` + +기본 접속 주소: + +- `http://127.0.0.1:3000` + +대시보드에서 가능한 작업: + +- 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인 +- 파싱된 조건으로 watch 생성 및 즉시 조회 +- watch별 `크롤링 ON/OFF`, `알림 ON/OFF` 토글 +- 전역 `전체 크롤링 ON/OFF`, `전체 알림 ON/OFF` 토글 +- 최근 가격 이벤트(목표가 도달/가격 변동) 확인 + +### 7.1 MySQL 연동 (개인 DB 사용) + +환경변수 설정: + +```bash +DASHBOARD_DB=mysql +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=your_user +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=airwatcher +``` + +또는 단일 URL 사용: + +```bash +DASHBOARD_DB=mysql +MYSQL_URL=mysql://user:password@127.0.0.1:3306/airwatcher +``` + +추가 서버 환경변수: + +- `DASHBOARD_HOST` (기본 `127.0.0.1`) +- `DASHBOARD_PORT` (기본 `3000`) +- `DASHBOARD_POLL_INTERVAL_SEC` (기본 `60`) + +참고: +- `DASHBOARD_DB=mysql`인데 MySQL 연결 정보가 없으면 서버가 에러를 반환한다. +- `DASHBOARD_DB`를 비우고 MySQL 환경변수도 없으면 메모리 저장소로 동작한다. +- `CRAWLER_ENDPOINT` 미설정 시 mock 가격으로 동작하므로 실데이터 추적 시 실제 크롤러 API를 연결해야 한다. + +### 7.2 Fastify 전환 뼈대 + +- 엔트리 파일: `src/fastifyDashboardServer.js` +- 기존 `http` 서버와 병행 운영 가능 (`dashboard` 스크립트는 유지됨) +- 동일한 대시보드 정적 파일(`src/dashboard/*`)과 주요 API를 Fastify 라우트로 제공 + +빠른 확인: + +```bash +DASHBOARD_HOST=127.0.0.1 DASHBOARD_PORT=3000 npm run dashboard:fastify +``` + +### 7.3 Docker / Compose + +Docker 이미지 빌드: + +```bash +docker build -t airwatcher . +``` + +Docker Compose 기동 (`app + mysql`): + +```bash +docker compose up --build +``` + +주요 기본값: +- 앱: `http://127.0.0.1:3000` +- DB 모드: `DASHBOARD_DB=mysql` +- MySQL 컨테이너: `127.0.0.1:3306` (기본 계정 `airwatcher/airwatcher`) + +메모리 모드로 앱만 쓰려면: + +```bash +DASHBOARD_DB=memory docker compose up --build --no-deps app +``` + +### 7.4 Nginx 리버스 프록시 + Basic Auth (선택) + +대시보드는 `127.0.0.1:3000`에서만 열고, 외부 공개는 Nginx를 통해 처리한다. + +```nginx +server { + listen 80; + server_name your-domain.example; + + # 필요할 때만 활성화 + # auth_basic "Restricted"; + # auth_basic_user_file /etc/nginx/.htpasswd; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c51c3c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "${APP_PORT:-3000}:3000" + environment: + DASHBOARD_HOST: 0.0.0.0 + DASHBOARD_PORT: 3000 + DASHBOARD_DB: ${DASHBOARD_DB:-mysql} + DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-60} + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-airwatcher} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher} + MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini} + CRAWLER_ENDPOINT: ${CRAWLER_ENDPOINT:-} + CRAWLER_PROVIDERS: ${CRAWLER_PROVIDERS:-} + CRAWLER_ENDPOINT_SKYSCANNER: ${CRAWLER_ENDPOINT_SKYSCANNER:-} + CRAWLER_ENDPOINT_NAVER: ${CRAWLER_ENDPOINT_NAVER:-} + CRAWLER_ENDPOINT_GOOGLE: ${CRAWLER_ENDPOINT_GOOGLE:-} + CRAWLER_ROUTING_STRATEGY: ${CRAWLER_ROUTING_STRATEGY:-priorityFallback} + CRAWLER_REQUEST_TIMEOUT_MS: ${CRAWLER_REQUEST_TIMEOUT_MS:-15000} + CRAWLER_MAX_ATTEMPTS: ${CRAWLER_MAX_ATTEMPTS:-2} + CRAWLER_RETRY_BASE_DELAY_MS: ${CRAWLER_RETRY_BASE_DELAY_MS:-300} + CRAWLER_RETRY_MAX_DELAY_MS: ${CRAWLER_RETRY_MAX_DELAY_MS:-3000} + NOTIFY_CHANNEL: ${NOTIFY_CHANNEL:-console} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + TELEGRAM_API_BASE: ${TELEGRAM_API_BASE:-https://api.telegram.org} + NOTIFY_WEBHOOK_URL: ${NOTIFY_WEBHOOK_URL:-} + depends_on: + mysql: + condition: service_healthy + + mysql: + image: mysql:8.4 + restart: unless-stopped + command: --default-authentication-plugin=mysql_native_password + ports: + - "${MYSQL_PORT_HOST:-3306}:3306" + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher} + MYSQL_USER: ${MYSQL_USER:-airwatcher} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} + healthcheck: + test: + - CMD-SHELL + - mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1 + interval: 5s + timeout: 3s + retries: 20 + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..965270b --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "airplane", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "parse": "node src/cli.js", + "watch": "node src/cli.js watch", + "dashboard": "node src/dashboardServer.js", + "dashboard:fastify": "node src/fastifyDashboardServer.js", + "sample:skyscanner": "node src/skyscannerSampleServer.js", + "test": "node --test" + }, + "dependencies": { + "fastify": "^5.2.2", + "mysql2": "^3.15.2" + } +} diff --git a/src/alertRules.js b/src/alertRules.js new file mode 100644 index 0000000..a4890eb --- /dev/null +++ b/src/alertRules.js @@ -0,0 +1,68 @@ +"use strict"; + +function createAlertRuleError(message) { + const error = new Error(message); + error.statusCode = 400; + return error; +} + +function normalizeAlertOn(alertOnRaw) { + const alertOn = typeof alertOnRaw === "string" ? alertOnRaw.trim().toLowerCase() : "both"; + if (["both", "change", "threshold"].includes(alertOn)) { + return alertOn; + } + throw createAlertRuleError("alertOn 값은 both|change|threshold 중 하나여야 합니다."); +} + +function parseTargetPrice(value, { allowUndefined = false } = {}) { + if (value === undefined) { + if (allowUndefined) return undefined; + return null; + } + + if (value === null || value === "") return null; + + const n = Number(value); + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) { + throw createAlertRuleError("targetPrice 값은 1 이상의 정수여야 합니다."); + } + return Math.round(n); +} + +function buildAlertRules({ targetPrice, alertOn }) { + const normalizedAlertOn = normalizeAlertOn(alertOn || "both"); + const normalizedTargetPrice = parseTargetPrice(targetPrice); + + if (normalizedAlertOn === "threshold" && normalizedTargetPrice === null) { + throw createAlertRuleError("alertOn이 threshold이면 targetPrice가 필요합니다."); + } + + return { + targetPrice: normalizedTargetPrice, + notifyOnPriceChange: normalizedAlertOn === "both" || normalizedAlertOn === "change", + notifyOnFirstResult: false, + }; +} + +function inferAlertOn(alertRules) { + const targetPrice = alertRules?.targetPrice; + const hasThreshold = + targetPrice !== null && + targetPrice !== undefined && + targetPrice !== "" && + Number.isFinite(Number(targetPrice)) && + Number(targetPrice) > 0; + const notifyOnChange = alertRules?.notifyOnPriceChange !== false; + + if (hasThreshold && notifyOnChange) return "both"; + if (hasThreshold) return "threshold"; + if (notifyOnChange) return "change"; + return "change"; +} + +module.exports = { + buildAlertRules, + inferAlertOn, + normalizeAlertOn, + parseTargetPrice, +}; diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..4e2a623 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const { buildAlertRules } = require("./alertRules"); +const { parseFlightSearchRequest } = require("./naturalLanguageFlightParser"); +const { loadDotEnv } = require("./envLoader"); +const { extractFlightSearchRequest } = require("./llmParameterExtractor"); +const { createCrawlerClient } = require("./crawlerClient"); +const { createNotifier } = require("./notifier"); +const { PriceWatcher } = require("./priceWatcher"); + +loadDotEnv(); + +function readInput(inputTokens) { + const argInput = inputTokens.join(" ").trim(); + if (argInput.length > 0) return argInput; + + if (!process.stdin.isTTY) { + const stdin = fs.readFileSync(0, "utf8").trim(); + if (stdin) return stdin; + } + + throw new Error( + "입력 문장이 없습니다. 예: npm run parse -- \"11월 말부터 12월 초까지 ...\" 또는 npm run watch -- \"11월 말부터 12월 초까지 ...\"" + ); +} + +function parseNumberValue(value, flagName) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`${flagName} 값이 숫자가 아닙니다: ${value}`); + } + return parsed; +} + +function parseWatchOptions(tokens) { + const options = { + intervalSec: 60, + targetPrice: null, + alertOn: "both", + useLlm: true, + once: false, + }; + const inputTokens = []; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (!token.startsWith("--")) { + inputTokens.push(token); + continue; + } + + if (token === "--interval-sec") { + const value = tokens[i + 1]; + if (!value) throw new Error("--interval-sec 다음에 초 단위 값을 입력하세요."); + options.intervalSec = parseNumberValue(value, "--interval-sec"); + i += 1; + continue; + } + + if (token === "--target-price") { + const value = tokens[i + 1]; + if (!value) throw new Error("--target-price 다음에 금액을 입력하세요."); + options.targetPrice = parseNumberValue(value, "--target-price"); + i += 1; + continue; + } + + if (token === "--alert-on") { + const value = tokens[i + 1]; + if (!value) throw new Error("--alert-on 다음에 both|change|threshold를 입력하세요."); + options.alertOn = value; + i += 1; + continue; + } + + if (token === "--rule-only") { + options.useLlm = false; + continue; + } + + if (token === "--use-llm") { + options.useLlm = true; + continue; + } + + if (token === "--once") { + options.once = true; + continue; + } + + throw new Error(`알 수 없는 옵션: ${token}`); + } + + if (!Number.isInteger(options.intervalSec) || options.intervalSec <= 0) { + throw new Error("--interval-sec 값은 1 이상의 정수여야 합니다."); + } + + if ( + options.targetPrice !== null && + (!Number.isInteger(options.targetPrice) || options.targetPrice <= 0) + ) { + throw new Error("--target-price 값은 1 이상의 정수여야 합니다."); + } + + if (!["both", "change", "threshold"].includes(options.alertOn)) { + throw new Error("--alert-on 값은 both|change|threshold 중 하나여야 합니다."); + } + + if (options.alertOn === "threshold" && options.targetPrice === null) { + throw new Error("--alert-on threshold 사용 시 --target-price가 필요합니다."); + } + + return { options, inputTokens }; +} + +async function runParse(inputTokens) { + const input = readInput(inputTokens); + const result = parseFlightSearchRequest(input); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +async function runWatch(tokens) { + const { options, inputTokens } = parseWatchOptions(tokens); + const input = readInput(inputTokens); + + const extracted = await extractFlightSearchRequest(input, { + preferRuleParser: !options.useLlm, + }); + + const watcher = new PriceWatcher({ + crawler: createCrawlerClient(), + notifier: createNotifier(), + pollIntervalMs: options.intervalSec * 1000, + }); + + const watchId = watcher.addWatch({ + rawInput: input, + searchParams: extracted.params, + alertRules: buildAlertRules({ + targetPrice: options.targetPrice, + alertOn: options.alertOn, + }), + }); + + if (options.once) { + const pollResult = await watcher.pollWatch(watchId); + process.stdout.write( + `${JSON.stringify( + { + watchId, + source: extracted.source, + searchParams: extracted.params, + pollResult, + }, + null, + 2 + )}\n` + ); + return; + } + + process.stdout.write( + `watchId=${watchId} source=${extracted.source} intervalSec=${options.intervalSec}\n` + ); + process.stdout.write(`${JSON.stringify(extracted.params, null, 2)}\n`); + + await watcher.start(); + process.stdout.write("가격 추적을 시작했습니다. 종료하려면 Ctrl+C를 누르세요.\n"); + + await new Promise((resolve) => { + const shutdown = () => { + watcher.stop(); + resolve(); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); +} + +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + + if (command === "watch") { + await runWatch(argv.slice(1)); + return; + } + + if (command === "parse") { + await runParse(argv.slice(1)); + return; + } + + await runParse(argv); +} + +try { + main().catch((error) => { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + }); +} catch (error) { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); +} diff --git a/src/crawlerClient.js b/src/crawlerClient.js new file mode 100644 index 0000000..f6186d1 --- /dev/null +++ b/src/crawlerClient.js @@ -0,0 +1,427 @@ +"use strict"; + +const ROUTING_STRATEGY_MAP = { + primaryonly: "primaryOnly", + priorityfallback: "priorityFallback", + parallelrace: "parallelRace", +}; + +function pickOptionOrEnv(optionValue, envKey) { + return optionValue !== undefined ? optionValue : process.env[envKey]; +} + +function normalizeProviderName(provider) { + if (typeof provider !== "string") return ""; + return provider.trim().toLowerCase(); +} + +function toProviderEnvKey(provider) { + const normalized = provider.trim().toUpperCase().replace(/[^A-Z0-9]+/g, "_"); + return `CRAWLER_ENDPOINT_${normalized}`; +} + +function parseProviderList(rawProviders) { + if (Array.isArray(rawProviders)) { + return [...new Set(rawProviders.map((item) => normalizeProviderName(String(item))).filter(Boolean))]; + } + + if (typeof rawProviders !== "string") return []; + return [ + ...new Set( + rawProviders + .split(",") + .map((item) => normalizeProviderName(item)) + .filter(Boolean) + ), + ]; +} + +function normalizeRoutingStrategy(rawStrategy) { + if (rawStrategy === undefined || rawStrategy === null || rawStrategy === "") { + return "priorityFallback"; + } + if (typeof rawStrategy !== "string") { + throw new Error("routing strategy must be a string"); + } + + const collapsed = rawStrategy.trim().toLowerCase().replace(/[\s_-]+/g, ""); + const normalized = ROUTING_STRATEGY_MAP[collapsed]; + if (!normalized) { + throw new Error( + `Unsupported routing strategy: ${rawStrategy}. Use primaryOnly|priorityFallback|parallelRace` + ); + } + return normalized; +} + +function normalizeOffer(offer, defaultCurrency, fallbackProvider) { + if (!offer || typeof offer !== "object") return null; + + const price = Number(offer.price); + if (!Number.isFinite(price) || price <= 0) return null; + + const provider = + typeof offer.provider === "string" && offer.provider.trim() + ? offer.provider.trim() + : fallbackProvider || "unknown-provider"; + const currency = + typeof offer.currency === "string" && offer.currency.trim() + ? offer.currency.trim().toUpperCase() + : defaultCurrency; + + return { + provider, + price: Math.round(price), + currency, + fetchedAt: new Date().toISOString(), + metadata: offer.metadata || null, + }; +} + +function normalizeOffers(offers, defaultCurrency = "KRW", fallbackProvider) { + if (!Array.isArray(offers)) return []; + return offers + .map((offer) => normalizeOffer(offer, defaultCurrency, fallbackProvider)) + .filter(Boolean); +} + +function parsePositiveInt(value, fallback) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function shouldRetryStatus(statusCode) { + return statusCode === 408 || statusCode === 429 || statusCode >= 500; +} + +function isTransientMessage(message) { + return /timeout|timed out|network|fetch failed|socket|econn|enotfound|eai_again/i.test(message); +} + +function isAbortError(error) { + return error?.name === "AbortError"; +} + +function withRetriableFlag(error, retriable) { + if (error && typeof error === "object") { + error.retriable = retriable; + return error; + } + + const wrapped = new Error(String(error)); + wrapped.retriable = retriable; + return wrapped; +} + +function toRequestError(error, requestTimeoutMs) { + if (isAbortError(error)) { + return withRetriableFlag( + new Error(`Crawler request timed out after ${requestTimeoutMs}ms`), + true + ); + } + + if (!(error instanceof Error)) { + return withRetriableFlag(error, false); + } + + if (typeof error.retriable === "boolean") { + return error; + } + + return withRetriableFlag(error, isTransientMessage(error.message || "")); +} + +function computeRetryDelayMs(attempt, baseDelayMs, maxDelayMs) { + const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1); + return Math.min(maxDelayMs, exponential); +} + +function stableHash(text) { + let hash = 0; + for (let i = 0; i < text.length; i += 1) { + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + } + return hash; +} + +function serializeSearchSeed(searchParams) { + const segments = Array.isArray(searchParams?.segments) ? searchParams.segments : []; + const segmentText = + segments.length > 0 + ? segments.map((segment) => `${segment.from || "?"}-${segment.to || "?"}`).join("|") + : "no-segments"; + + return JSON.stringify({ + segmentText, + departureDateWindow: searchParams?.departureDateWindow || null, + stayDurationDays: searchParams?.stayDurationDays || null, + passengers: searchParams?.passengers?.total || null, + }); +} + +function createMockCrawler() { + let tick = 0; + + return { + async getQuotes({ searchParams }) { + tick += 1; + const seed = stableHash(serializeSearchSeed(searchParams)); + const basePrice = 700000 + (seed % 450000); + const driftFactor = ((seed + tick * 17) % 80) - 40; + const best = Math.max(150000, basePrice + driftFactor * 2500); + + return [ + { + provider: "mock-ota-a", + price: Math.round(best * 1.03), + currency: "KRW", + }, + { + provider: "mock-ota-b", + price: Math.round(best), + currency: "KRW", + }, + { + provider: "mock-ota-c", + price: Math.round(best * 1.06), + currency: "KRW", + }, + ]; + }, + }; +} + +function createEndpointCrawler(options = {}) { + const endpoint = pickOptionOrEnv(options.endpoint, "CRAWLER_ENDPOINT"); + if (!endpoint) { + throw new Error("Crawler endpoint is required"); + } + const fallbackProvider = normalizeProviderName(options.provider); + + const fetchImpl = options.fetch || global.fetch; + if (typeof fetchImpl !== "function") { + throw new Error("global fetch is unavailable. Node.js 18+ is required."); + } + + const requestTimeoutMs = + parsePositiveInt( + pickOptionOrEnv(options.requestTimeoutMs, "CRAWLER_REQUEST_TIMEOUT_MS"), + 15000 + ); + const maxAttempts = parsePositiveInt( + pickOptionOrEnv(options.maxAttempts, "CRAWLER_MAX_ATTEMPTS"), + 2 + ); + const retryBaseDelayMs = parsePositiveInt( + pickOptionOrEnv(options.retryBaseDelayMs, "CRAWLER_RETRY_BASE_DELAY_MS"), + 300 + ); + const retryMaxDelayMs = parsePositiveInt( + pickOptionOrEnv(options.retryMaxDelayMs, "CRAWLER_RETRY_MAX_DELAY_MS"), + 3000 + ); + + return { + async getQuotes({ watchId, searchParams }) { + let lastError; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + }, requestTimeoutMs); + + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + watchId, + searchParams, + }), + signal: abortController.signal, + }); + + if (!response.ok) { + const body = await response.text(); + throw withRetriableFlag( + new Error(`Crawler request failed (${response.status}): ${body}`), + shouldRetryStatus(response.status) + ); + } + + const payload = await response.json(); + const offers = Array.isArray(payload) ? payload : payload?.offers; + const defaultCurrency = + typeof payload?.currency === "string" ? payload.currency.toUpperCase() : "KRW"; + const normalized = normalizeOffers(offers, defaultCurrency, fallbackProvider); + + if (normalized.length === 0) { + throw withRetriableFlag(new Error("Crawler returned no valid offers"), false); + } + + return normalized; + } catch (error) { + const requestError = toRequestError(error, requestTimeoutMs); + lastError = requestError; + const shouldRetry = + requestError.retriable === true && attempt < maxAttempts; + + if (!shouldRetry) { + throw requestError; + } + + await sleep(computeRetryDelayMs(attempt, retryBaseDelayMs, retryMaxDelayMs)); + } finally { + clearTimeout(timeout); + } + } + + throw lastError || new Error("Crawler request failed"); + }, + }; +} + +function buildProviderEndpointMap(options = {}) { + if (!options.providerEndpoints || typeof options.providerEndpoints !== "object") { + return new Map(); + } + + const map = new Map(); + for (const [provider, endpoint] of Object.entries(options.providerEndpoints)) { + const normalizedProvider = normalizeProviderName(provider); + if (!normalizedProvider) continue; + if (typeof endpoint !== "string" || endpoint.trim() === "") continue; + map.set(normalizedProvider, endpoint.trim()); + } + return map; +} + +function createMultiSourceCrawler(options = {}) { + const providers = parseProviderList(pickOptionOrEnv(options.providers, "CRAWLER_PROVIDERS")); + if (providers.length === 0) return null; + + const routingStrategy = normalizeRoutingStrategy( + pickOptionOrEnv(options.routingStrategy, "CRAWLER_ROUTING_STRATEGY") + ); + const sharedEndpointRaw = pickOptionOrEnv(options.endpoint, "CRAWLER_ENDPOINT"); + const sharedEndpoint = + typeof sharedEndpointRaw === "string" && sharedEndpointRaw.trim() + ? sharedEndpointRaw.trim() + : null; + const providerEndpointMap = buildProviderEndpointMap(options); + + const missingProviders = []; + const providerClients = providers.map((provider) => { + const providerSpecificFromEnv = process.env[toProviderEnvKey(provider)]; + const providerSpecific = + providerEndpointMap.get(provider) || + (typeof providerSpecificFromEnv === "string" && providerSpecificFromEnv.trim() + ? providerSpecificFromEnv.trim() + : null); + const endpoint = providerSpecific || sharedEndpoint; + + if (!endpoint) { + missingProviders.push(provider); + return null; + } + + return { + provider, + crawler: createEndpointCrawler({ + ...options, + endpoint, + provider, + }), + }; + }); + + if (missingProviders.length > 0) { + throw new Error( + `Missing endpoint for provider(s): ${missingProviders.join( + ", " + )}. Set CRAWLER_ENDPOINT_ or CRAWLER_ENDPOINT.` + ); + } + + const sources = providerClients.filter(Boolean); + if (sources.length === 0) { + throw new Error("No valid providers configured"); + } + + return { + async getQuotes(request) { + if (routingStrategy === "primaryOnly") { + const primary = sources[0]; + try { + return await primary.crawler.getQuotes(request); + } catch (error) { + throw new Error(`Primary provider failed (${primary.provider}): ${error.message}`); + } + } + + if (routingStrategy === "parallelRace") { + const attempts = sources.map(({ provider, crawler }) => + crawler.getQuotes(request).then((offers) => ({ provider, offers })) + ); + + try { + const winner = await Promise.any(attempts); + return winner.offers; + } catch (error) { + const reasons = Array.isArray(error?.errors) + ? error.errors.map((item) => item?.message || String(item)) + : [error?.message || String(error)]; + throw new Error(`All provider requests failed: ${reasons.join(" | ")}`); + } + } + + const errors = []; + for (const { provider, crawler } of sources) { + try { + return await crawler.getQuotes(request); + } catch (error) { + errors.push(`${provider}: ${error.message}`); + } + } + throw new Error(`All provider requests failed: ${errors.join(" | ")}`); + }, + }; +} + +function createCrawlerClient(options = {}) { + if (options.client && typeof options.client.getQuotes === "function") { + return options.client; + } + + if (typeof options.getQuotes === "function") { + return { getQuotes: options.getQuotes }; + } + + const multiSourceCrawler = createMultiSourceCrawler(options); + if (multiSourceCrawler) { + return multiSourceCrawler; + } + + const endpoint = pickOptionOrEnv(options.endpoint, "CRAWLER_ENDPOINT"); + if (endpoint) { + return createEndpointCrawler(options); + } + + return createMockCrawler(); +} + +module.exports = { + createCrawlerClient, + createEndpointCrawler, + createMultiSourceCrawler, + createMockCrawler, +}; diff --git a/src/dashboard/dashboard.css b/src/dashboard/dashboard.css new file mode 100644 index 0000000..5f9ec69 --- /dev/null +++ b/src/dashboard/dashboard.css @@ -0,0 +1,349 @@ +:root { + --bg-deep: #071422; + --bg-mid: #0d2234; + --bg-soft: #163a56; + --ink-main: #f5fbff; + --ink-sub: #b9cee0; + --accent: #ff9654; + --accent-strong: #ff7f32; + --line: rgba(255, 255, 255, 0.14); + --panel: rgba(8, 25, 40, 0.74); + --danger: #ff6f6f; + --ok: #38e0a2; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: "Space Grotesk", "Pretendard", "Noto Sans KR", sans-serif; + color: var(--ink-main); + background: radial-gradient(circle at 20% -5%, #295a81 0%, var(--bg-deep) 42%), + linear-gradient(130deg, var(--bg-mid), var(--bg-soft)); +} + +.page-gradient { + position: fixed; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 85% 20%, rgba(255, 150, 84, 0.22), transparent 36%), + radial-gradient(circle at 20% 80%, rgba(91, 173, 255, 0.2), transparent 35%); +} + +.layout { + position: relative; + z-index: 1; + max-width: 1100px; + margin: 0 auto; + padding: 28px 16px 42px; + display: grid; + gap: 14px; + grid-template-columns: 1fr; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + backdrop-filter: blur(8px); + padding: 16px; + box-shadow: 0 18px 30px rgba(3, 10, 17, 0.26); +} + +.topbar { + display: flex; + gap: 12px; + justify-content: space-between; + align-items: center; +} + +.topbar h1 { + margin: 3px 0 0; + font-size: clamp(1.4rem, 1.7vw, 1.9rem); + letter-spacing: 0.01em; +} + +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 0.76rem; + letter-spacing: 0.18em; + font-weight: 700; +} + +.config { + color: var(--ink-sub); + font-size: 0.85rem; + text-align: right; +} + +.section-title h2 { + margin: 0; + font-size: 1.05rem; +} + +.section-title p { + margin: 6px 0 0; + color: var(--ink-sub); + font-size: 0.88rem; +} + +.label { + display: block; + margin-top: 14px; + margin-bottom: 6px; + color: var(--ink-sub); + font-size: 0.88rem; +} + +textarea, +select, +input[type="number"] { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.06); + color: var(--ink-main); + border-radius: 10px; + padding: 10px 11px; + font: inherit; +} + +textarea:focus, +select:focus, +input[type="number"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255, 150, 84, 0.22); +} + +.controls-grid { + margin-top: 12px; + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.switch-field { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + padding: 8px 10px; + color: var(--ink-sub); +} + +.switch-field input { + accent-color: var(--accent); +} + +.field span { + display: block; + color: var(--ink-sub); + font-size: 0.8rem; + margin-bottom: 6px; +} + +.action-row { + margin-top: 12px; + display: flex; + gap: 8px; +} + +.btn { + border: 0; + border-radius: 10px; + padding: 10px 14px; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: transform 0.16s ease, opacity 0.16s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn.primary { + background: linear-gradient(130deg, var(--accent), var(--accent-strong)); + color: #1d1209; +} + +.btn.secondary { + background: rgba(255, 255, 255, 0.12); + color: var(--ink-main); +} + +.btn.danger { + background: rgba(255, 111, 111, 0.2); + color: #ffd8d8; +} + +.summary { + margin-top: 12px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + background: rgba(255, 255, 255, 0.03); + line-height: 1.45; +} + +.summary.empty, +.watch-list.empty, +.event-list.empty, +.json-view.empty { + color: var(--ink-sub); +} + +.summary strong { + color: var(--accent); +} + +.json-view { + margin: 10px 0 0; + white-space: pre-wrap; + max-height: 220px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + background: rgba(0, 0, 0, 0.2); + font-size: 0.82rem; +} + +.toggle-row { + margin-top: 12px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.watch-list, +.event-list { + margin-top: 10px; + display: grid; + gap: 10px; +} + +.watch-item, +.event-item { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.03); + animation: rise 0.26s ease; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(7px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.watch-item header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} + +.watch-title { + margin: 0; + font-weight: 700; + font-size: 0.96rem; +} + +.watch-sub { + margin: 6px 0 0; + color: var(--ink-sub); + font-size: 0.84rem; +} + +.price { + font-size: 1rem; + font-weight: 700; +} + +.meta-grid { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + font-size: 0.82rem; + color: var(--ink-sub); +} + +.meta-grid code { + font-family: "JetBrains Mono", "Fira Code", monospace; +} + +.item-actions { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 3px 8px; + font-size: 0.74rem; + font-weight: 600; +} + +.badge.ok { + background: rgba(56, 224, 162, 0.14); + color: #98f2cf; +} + +.badge.off { + background: rgba(255, 111, 111, 0.16); + color: #ffb4b4; +} + +.event-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.event-head strong { + color: var(--accent); +} + +.event-item p { + margin: 8px 0 0; + color: var(--ink-sub); + font-size: 0.84rem; +} + +@media (max-width: 860px) { + .controls-grid { + grid-template-columns: 1fr; + } + + .meta-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/dashboard/dashboard.js b/src/dashboard/dashboard.js new file mode 100644 index 0000000..ed4e096 --- /dev/null +++ b/src/dashboard/dashboard.js @@ -0,0 +1,398 @@ +(function () { + "use strict"; + + const elements = { + configBanner: document.getElementById("configBanner"), + queryInput: document.getElementById("queryInput"), + useLlm: document.getElementById("useLlm"), + alertOn: document.getElementById("alertOn"), + targetPrice: document.getElementById("targetPrice"), + parseBtn: document.getElementById("parseBtn"), + createWatchBtn: document.getElementById("createWatchBtn"), + parseSummary: document.getElementById("parseSummary"), + parseOutput: document.getElementById("parseOutput"), + globalCrawling: document.getElementById("globalCrawling"), + globalAlerts: document.getElementById("globalAlerts"), + watchList: document.getElementById("watchList"), + eventList: document.getElementById("eventList"), + }; + + const state = { + parsed: null, + watches: [], + events: [], + controls: { + crawlingEnabled: true, + alertsEnabled: true, + }, + }; + + function formatPrice(price, currency) { + if (!Number.isFinite(Number(price))) return "N/A"; + return `${new Intl.NumberFormat("ko-KR").format(Number(price))} ${currency || ""}`.trim(); + } + + function formatDate(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } + + async function api(path, options) { + const response = await fetch(path, { + headers: { "content-type": "application/json" }, + ...options, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || `요청 실패 (${response.status})`); + } + return payload; + } + + function readTargetPrice() { + const raw = elements.targetPrice.value.trim(); + if (!raw) return null; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error("목표 가격은 1 이상의 정수여야 합니다."); + } + return value; + } + + function renderParsed(parsedPayload) { + state.parsed = parsedPayload; + elements.parseOutput.classList.remove("empty"); + elements.parseOutput.textContent = JSON.stringify(parsedPayload, null, 2); + + const params = parsedPayload.params || {}; + const segments = Array.isArray(params.segments) ? params.segments : []; + const segmentText = segments + .map((segment) => `${segment.from || "?"} -> ${segment.to || "?"}`) + .join(" / "); + + const windowText = params.departureDateWindow + ? `${params.departureDateWindow.from} ~ ${params.departureDateWindow.to}` + : "미입력"; + + const stayText = params.stayDurationDays + ? `${params.stayDurationDays.minDays}~${params.stayDurationDays.maxDays}일` + : "미입력"; + + const paxText = Number.isFinite(Number(params.passengers && params.passengers.total)) + ? `${params.passengers.total}명` + : "미입력"; + + const maxJourney = params.constraints && params.constraints.maxJourneyHours; + const journeyText = maxJourney ? `${maxJourney.hours}시간 ${maxJourney.operator}` : "미입력"; + + const missing = Array.isArray(params.missingFields) ? params.missingFields : []; + + elements.parseSummary.classList.remove("empty"); + elements.parseSummary.innerHTML = [ + `
파서: ${parsedPayload.source}
`, + `
구간: ${segmentText || "미입력"}
`, + `
출발 윈도우: ${windowText}
`, + `
체류 기간: ${stayText}
`, + `
탑승객: ${paxText}
`, + `
최대 여정시간: ${journeyText}
`, + `
누락 필드: ${missing.length > 0 ? missing.join(", ") : "없음"}
`, + ].join(""); + } + + function watchAlertOn(watch) { + const targetPrice = watch && watch.alertRules ? watch.alertRules.targetPrice : null; + const hasThreshold = + targetPrice !== null && + targetPrice !== undefined && + targetPrice !== "" && + Number.isFinite(Number(targetPrice)) && + Number(targetPrice) > 0; + const notifyOnChange = watch && watch.alertRules ? watch.alertRules.notifyOnPriceChange !== false : true; + + if (hasThreshold && notifyOnChange) return "both"; + if (hasThreshold) return "threshold"; + if (notifyOnChange) return "change"; + return "change"; + } + + function renderWatches() { + if (!state.watches.length) { + elements.watchList.className = "watch-list empty"; + elements.watchList.textContent = "아직 등록된 watch가 없습니다."; + return; + } + + elements.watchList.className = "watch-list"; + elements.watchList.innerHTML = state.watches + .map((watch) => { + const bestPrice = watch.lastSnapshot ? watch.lastSnapshot.bestPrice : null; + const currency = watch.lastSnapshot ? watch.lastSnapshot.currency : "KRW"; + const provider = watch.lastSnapshot && watch.lastSnapshot.bestOffer + ? watch.lastSnapshot.bestOffer.provider + : "-"; + const alertOn = watchAlertOn(watch); + const targetPrice = watch.alertRules ? watch.alertRules.targetPrice : null; + + return ` +
+
+
+

${watch.rawInput || "(no input)"}

+

watchId: ${watch.id}

+
+
${formatPrice(bestPrice, currency)}
+
+ +
+
provider: ${provider}
+
마지막 갱신: ${formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)}
+
alert mode: ${alertOn}
+
target: ${targetPrice ? formatPrice(targetPrice, currency) : "-"}
+
+ +
+ + + + +
+ + ${watch.lastError ? `

오류: ${watch.lastError.message || "unknown"}

` : ""} +
+ `; + }) + .join(""); + } + + function renderEvents() { + if (!state.events.length) { + elements.eventList.className = "event-list empty"; + elements.eventList.textContent = "이벤트가 아직 없습니다."; + return; + } + + elements.eventList.className = "event-list"; + elements.eventList.innerHTML = state.events + .map((event) => { + const payload = event.payload || {}; + const sent = payload.notificationSent === true; + const badgeClass = sent ? "ok" : "off"; + const badgeLabel = sent ? "알림 발송" : "알림 억제"; + + return ` +
+
+ ${payload.eventType || event.eventType || "event"} + ${badgeLabel} +
+

+ watchId: ${event.watchId}
+ 가격: ${formatPrice(payload.currentBestPrice, payload.currency)} + ${Number.isFinite(Number(payload.previousBestPrice)) + ? ` (이전 ${formatPrice(payload.previousBestPrice, payload.currency)})` + : ""}
+ 시각: ${formatDate(event.observedAt)} +

+
+ `; + }) + .join(""); + } + + function renderControls() { + elements.globalCrawling.checked = !!state.controls.crawlingEnabled; + elements.globalAlerts.checked = !!state.controls.alertsEnabled; + } + + function setConfigBanner(config) { + const warning = config && config.dbWarning ? ` | warning: ${config.dbWarning}` : ""; + elements.configBanner.textContent = `DB: ${config.dbEngine} | poll: ${config.pollIntervalSec}s${warning}`; + } + + async function refreshAll() { + const [watchPayload, eventPayload] = await Promise.all([ + api("/api/watches"), + api("/api/events?limit=20"), + ]); + + state.watches = watchPayload.watches || []; + state.controls = watchPayload.controls || state.controls; + state.events = eventPayload.events || []; + + renderControls(); + renderWatches(); + renderEvents(); + } + + async function onParse() { + const input = elements.queryInput.value.trim(); + if (!input) { + alert("입력 문장을 작성하세요."); + return; + } + + const parsed = await api("/api/parse", { + method: "POST", + body: JSON.stringify({ + input, + useLlm: elements.useLlm.checked, + }), + }); + + renderParsed(parsed); + } + + async function onCreateWatch() { + const input = elements.queryInput.value.trim(); + if (!input) { + alert("입력 문장을 작성하세요."); + return; + } + + const payload = { + input, + useLlm: elements.useLlm.checked, + alertOn: elements.alertOn.value, + targetPrice: readTargetPrice(), + pollingEnabled: true, + alertsEnabled: true, + pollNow: true, + }; + + const created = await api("/api/watches", { + method: "POST", + body: JSON.stringify(payload), + }); + + if (created && created.watch) { + renderParsed({ source: created.parserSource || "unknown", params: created.watch.searchParams }); + } + + await refreshAll(); + } + + async function onWatchAction(event) { + const target = event.target; + if (!target || !target.closest) return; + + const container = target.closest(".watch-item"); + if (!container) return; + + const watchId = container.getAttribute("data-watch-id"); + const action = target.getAttribute("data-action"); + + if (!action || !watchId) return; + + const isToggleAction = action === "toggle-polling" || action === "toggle-alerts"; + const isButtonAction = action === "poll" || action === "delete"; + + if (isToggleAction && event.type !== "change") return; + if (isButtonAction && event.type !== "click") return; + + if (action === "toggle-polling") { + await api(`/api/watches/${encodeURIComponent(watchId)}`, { + method: "PATCH", + body: JSON.stringify({ + pollingEnabled: target.checked, + }), + }); + await refreshAll(); + return; + } + + if (action === "toggle-alerts") { + await api(`/api/watches/${encodeURIComponent(watchId)}`, { + method: "PATCH", + body: JSON.stringify({ + alertsEnabled: target.checked, + }), + }); + await refreshAll(); + return; + } + + if (action === "poll") { + await api(`/api/watches/${encodeURIComponent(watchId)}/poll`, { + method: "POST", + }); + await refreshAll(); + return; + } + + if (action === "delete") { + await api(`/api/watches/${encodeURIComponent(watchId)}`, { + method: "DELETE", + }); + await refreshAll(); + } + } + + async function onGlobalToggle() { + await api("/api/system", { + method: "PATCH", + body: JSON.stringify({ + crawlingEnabled: elements.globalCrawling.checked, + alertsEnabled: elements.globalAlerts.checked, + }), + }); + + await refreshAll(); + } + + async function bootstrap() { + try { + const config = await api("/api/config"); + setConfigBanner(config); + + await refreshAll(); + + elements.parseBtn.addEventListener("click", () => { + onParse().catch((error) => alert(error.message)); + }); + + elements.createWatchBtn.addEventListener("click", () => { + onCreateWatch().catch((error) => alert(error.message)); + }); + + elements.watchList.addEventListener("click", (event) => { + onWatchAction(event).catch((error) => alert(error.message)); + }); + + elements.watchList.addEventListener("change", (event) => { + onWatchAction(event).catch((error) => alert(error.message)); + }); + + elements.globalCrawling.addEventListener("change", () => { + onGlobalToggle().catch((error) => alert(error.message)); + }); + elements.globalAlerts.addEventListener("change", () => { + onGlobalToggle().catch((error) => alert(error.message)); + }); + + setInterval(() => { + refreshAll().catch(() => {}); + }, 5000); + } catch (error) { + alert(`초기화 실패: ${error.message}`); + } + } + + bootstrap(); +})(); diff --git a/src/dashboard/index.html b/src/dashboard/index.html new file mode 100644 index 0000000..5f38c05 --- /dev/null +++ b/src/dashboard/index.html @@ -0,0 +1,96 @@ + + + + + + Air-Watcher Dashboard + + + + +
+
+
+

AIR-WATCHER

+

Flight Watch Dashboard

+
+
초기화 중...
+
+ +
+
+

LLM 입력 파싱

+

자연어를 넣으면 조회 조건을 자동 정리합니다.

+
+ + + + +
+ + + + + +
+ +
+ + +
+ +
파싱 결과 요약이 여기에 표시됩니다.
+
{}
+
+ +
+
+

전역 제어

+

전체 추적 동작을 한 번에 켜고 끌 수 있습니다.

+
+ +
+ + +
+
+ +
+
+

추적 목록

+

각 항목별로 크롤링/알림 토글을 조정하세요.

+
+
아직 등록된 watch가 없습니다.
+
+ +
+
+

최근 이벤트

+

목표가 도달/가격 변동 이벤트를 확인합니다.

+
+
이벤트가 아직 없습니다.
+
+
+ + + + diff --git a/src/dashboardApi.js b/src/dashboardApi.js new file mode 100644 index 0000000..6c38a7c --- /dev/null +++ b/src/dashboardApi.js @@ -0,0 +1,152 @@ +"use strict"; + +const { + buildAlertRules, + inferAlertOn, + normalizeAlertOn, + parseTargetPrice, +} = require("./alertRules"); +const { createHttpError, parseBoolean } = require("./dashboardUtils"); +const { extractFlightSearchRequest } = require("./llmParameterExtractor"); + +function readInput(body) { + const input = typeof body.input === "string" ? body.input.trim() : ""; + if (!input) { + throw createHttpError(400, "input 문자열이 필요합니다."); + } + return input; +} + +function hasOwnProperty(source, key) { + return Object.prototype.hasOwnProperty.call(source, key); +} + +function createDashboardApi({ watcher, store }) { + async function parseInput(body = {}) { + const input = readInput(body); + + return extractFlightSearchRequest(input, { + preferRuleParser: parseBoolean(body.useLlm, true) === false, + }); + } + + async function createWatch(body = {}) { + const extracted = await parseInput(body); + const input = readInput(body); + + const alertRules = buildAlertRules({ + targetPrice: body.targetPrice, + alertOn: body.alertOn || "both", + }); + + const watchId = watcher.addWatch({ + rawInput: input, + searchParams: extracted.params, + alertRules, + pollingEnabled: parseBoolean(body.pollingEnabled, true), + alertsEnabled: parseBoolean(body.alertsEnabled, true), + }); + + const created = watcher.getWatch(watchId); + await store.saveWatch(created); + + if (parseBoolean(body.pollNow, false)) { + await watcher.pollWatch(watchId); + } + + return { + watch: watcher.getWatch(watchId), + parserSource: extracted.source, + }; + } + + async function updateSystem(body = {}) { + const controls = watcher.setGlobalControls({ + crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled), + alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled), + }); + await store.setGlobalControls(controls); + return { controls }; + } + + function listWatches() { + return { + controls: watcher.getGlobalControls(), + watches: watcher.listWatches(), + }; + } + + async function listEvents(rawLimit) { + const limit = Number(rawLimit); + const events = await store.listEvents(Number.isFinite(limit) ? limit : 50); + return { events }; + } + + async function pollWatch(watchId) { + const existing = watcher.getWatch(watchId); + if (!existing) { + throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); + } + + const pollResult = await watcher.pollWatch(watchId); + return { + watch: watcher.getWatch(watchId), + pollResult, + }; + } + + async function updateWatch(watchId, body = {}) { + const existing = watcher.getWatch(watchId); + if (!existing) { + throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); + } + + const patch = {}; + if (hasOwnProperty(body, "pollingEnabled")) { + patch.pollingEnabled = parseBoolean(body.pollingEnabled, existing.pollingEnabled); + } + if (hasOwnProperty(body, "alertsEnabled")) { + patch.alertsEnabled = parseBoolean(body.alertsEnabled, existing.alertsEnabled); + } + + const hasAlertModePatch = hasOwnProperty(body, "alertOn") || hasOwnProperty(body, "targetPrice"); + if (hasAlertModePatch) { + const targetPrice = hasOwnProperty(body, "targetPrice") + ? parseTargetPrice(body.targetPrice) + : existing.alertRules.targetPrice; + const alertOn = hasOwnProperty(body, "alertOn") + ? normalizeAlertOn(body.alertOn) + : inferAlertOn(existing.alertRules); + + patch.alertRules = buildAlertRules({ + targetPrice, + alertOn, + }); + } + + const updated = watcher.updateWatch(watchId, patch); + await store.saveWatch(updated); + return { watch: updated }; + } + + async function deleteWatch(watchId) { + const existed = watcher.removeWatch(watchId); + await store.deleteWatch(watchId); + return { deleted: existed }; + } + + return { + createWatch, + deleteWatch, + listEvents, + listWatches, + parseInput, + pollWatch, + updateSystem, + updateWatch, + }; +} + +module.exports = { + createDashboardApi, +}; diff --git a/src/dashboardAssets.js b/src/dashboardAssets.js new file mode 100644 index 0000000..96a1aec --- /dev/null +++ b/src/dashboardAssets.js @@ -0,0 +1,34 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { createHttpError } = require("./dashboardUtils"); + +const ASSET_MAP = { + "/": { file: "index.html", contentType: "text/html; charset=utf-8" }, + "/dashboard.css": { file: "dashboard.css", contentType: "text/css; charset=utf-8" }, + "/dashboard.js": { + file: "dashboard.js", + contentType: "application/javascript; charset=utf-8", + }, +}; + +function loadDashboardAsset(assetsDir, requestPath) { + const asset = ASSET_MAP[requestPath]; + if (!asset) return null; + + const filePath = path.join(assetsDir, asset.file); + if (!fs.existsSync(filePath)) { + throw createHttpError(500, `대시보드 파일이 없습니다: ${asset.file}`); + } + + return { + content: fs.readFileSync(filePath), + contentType: asset.contentType, + }; +} + +module.exports = { + ASSET_MAP, + loadDashboardAsset, +}; diff --git a/src/dashboardRuntime.js b/src/dashboardRuntime.js new file mode 100644 index 0000000..aec3ce5 --- /dev/null +++ b/src/dashboardRuntime.js @@ -0,0 +1,104 @@ +"use strict"; + +const { createCrawlerClient } = require("./crawlerClient"); +const { createDashboardStore } = require("./dashboardStore"); +const { loadDotEnv } = require("./envLoader"); +const { createNotifier } = require("./notifier"); +const { PriceWatcher } = require("./priceWatcher"); +const { parsePort } = require("./dashboardUtils"); + +function toRestoredWatchPayload(watch) { + return { + id: watch.id, + rawInput: watch.rawInput, + searchParams: watch.searchParams, + alertRules: watch.alertRules, + pollingEnabled: watch.pollingEnabled, + alertsEnabled: watch.alertsEnabled, + lastSnapshot: watch.lastSnapshot, + lastError: watch.lastError, + createdAt: watch.createdAt, + updatedAt: watch.updatedAt, + }; +} + +async function createDashboardRuntime(options = {}) { + loadDotEnv(); + + const logger = options.logger || console; + const pollIntervalSec = parsePort( + options.pollIntervalSec || process.env.DASHBOARD_POLL_INTERVAL_SEC, + 60 + ); + + const storeSetup = + options.store && options.store.saveWatch + ? { engine: "custom", store: options.store, warning: null } + : await createDashboardStore(options.store || {}); + + const store = storeSetup.store; + const crawler = options.crawler || createCrawlerClient(options.crawlerOptions || {}); + const notifier = options.notifier || createNotifier(options.notifierOptions || {}); + + const watcher = new PriceWatcher({ + crawler, + notifier, + pollIntervalMs: pollIntervalSec * 1000, + logger, + onWatchPolled: async ({ watch, result }) => { + try { + await store.savePollResult(watch.id, result); + + if (result && result.alert) { + await store.saveEvent({ + watchId: watch.id, + eventType: result.alert.eventType || "unknown", + observedAt: + result.alert.observedAt || + result.snapshot?.polledAt || + result.error?.at || + new Date().toISOString(), + payload: { + ...result.alert, + notificationSent: result.notificationSent === true, + }, + }); + } + } catch (error) { + logger.error(`dashboard persistence hook failed: ${error.message}`); + } + }, + }); + + const savedGlobalControls = await store.getGlobalControls(); + watcher.setGlobalControls(savedGlobalControls); + + const savedWatches = await store.listWatches(); + for (const watch of savedWatches) { + watcher.addWatch(toRestoredWatchPayload(watch)); + } + + await watcher.start(); + + const close = async () => { + watcher.stop(); + if (store && typeof store.close === "function") { + await store.close(); + } + }; + + return { + watcher, + store, + close, + info: { + dbEngine: storeSetup.engine, + dbWarning: storeSetup.warning, + pollIntervalSec, + }, + }; +} + +module.exports = { + createDashboardRuntime, +}; diff --git a/src/dashboardServer.js b/src/dashboardServer.js new file mode 100644 index 0000000..f0e66e9 --- /dev/null +++ b/src/dashboardServer.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +"use strict"; + +const http = require("node:http"); +const path = require("node:path"); +const { + buildAlertRules, + inferAlertOn, + normalizeAlertOn, + parseTargetPrice, +} = require("./alertRules"); +const { createDashboardApi } = require("./dashboardApi"); +const { loadDashboardAsset } = require("./dashboardAssets"); +const { createDashboardRuntime } = require("./dashboardRuntime"); +const { createHttpError, decodeWatchId, parsePort } = require("./dashboardUtils"); + +function parseJsonBody(req) { + return new Promise((resolve, reject) => { + let raw = ""; + req.setEncoding("utf8"); + + req.on("data", (chunk) => { + raw += chunk; + if (raw.length > 1024 * 1024) { + reject(createHttpError(413, "요청 본문이 너무 큽니다.")); + req.destroy(); + } + }); + + req.on("end", () => { + if (!raw.trim()) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(raw)); + } catch (_error) { + reject(createHttpError(400, "JSON 본문 파싱에 실패했습니다.")); + } + }); + + req.on("error", (error) => { + reject(error); + }); + }); +} + +function sendJson(res, statusCode, body) { + const payload = JSON.stringify(body); + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +function sendStaticAsset(res, asset) { + res.writeHead(200, { + "content-type": asset.contentType, + "cache-control": "no-store", + "content-length": asset.content.length, + }); + res.end(asset.content); +} + +async function createDashboardServer(options = {}) { + const runtime = await createDashboardRuntime(options); + const api = createDashboardApi({ + watcher: runtime.watcher, + store: runtime.store, + }); + const assetsDir = path.resolve(__dirname, "dashboard"); + + const server = http.createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url || "/", "http://localhost"); + const pathname = requestUrl.pathname; + const staticAsset = loadDashboardAsset(assetsDir, pathname); + if (staticAsset) { + sendStaticAsset(res, staticAsset); + return; + } + + if (req.method === "GET" && pathname === "/api/health") { + sendJson(res, 200, { + ok: true, + now: new Date().toISOString(), + dbEngine: runtime.info.dbEngine, + watchCount: runtime.watcher.listWatchIds().length, + }); + return; + } + + if (req.method === "GET" && pathname === "/api/config") { + sendJson(res, 200, runtime.info); + return; + } + + if (req.method === "GET" && pathname === "/api/system") { + sendJson(res, 200, { + controls: runtime.watcher.getGlobalControls(), + }); + return; + } + + if (req.method === "PATCH" && pathname === "/api/system") { + const body = await parseJsonBody(req); + sendJson(res, 200, await api.updateSystem(body)); + return; + } + + if (req.method === "POST" && pathname === "/api/parse") { + const body = await parseJsonBody(req); + sendJson(res, 200, await api.parseInput(body)); + return; + } + + if (req.method === "GET" && pathname === "/api/watches") { + sendJson(res, 200, api.listWatches()); + return; + } + + if (req.method === "POST" && pathname === "/api/watches") { + const body = await parseJsonBody(req); + sendJson(res, 201, await api.createWatch(body)); + return; + } + + if (req.method === "GET" && pathname === "/api/events") { + const limit = requestUrl.searchParams.get("limit"); + sendJson(res, 200, await api.listEvents(limit)); + return; + } + + const watchPollMatch = pathname.match(/^\/api\/watches\/([^/]+)\/poll$/); + if (req.method === "POST" && watchPollMatch) { + const watchId = decodeWatchId(watchPollMatch[1]); + sendJson(res, 200, await api.pollWatch(watchId)); + return; + } + + const watchMatch = pathname.match(/^\/api\/watches\/([^/]+)$/); + if (watchMatch) { + const watchId = decodeWatchId(watchMatch[1]); + + if (req.method === "PATCH") { + const body = await parseJsonBody(req); + sendJson(res, 200, await api.updateWatch(watchId, body)); + return; + } + + if (req.method === "DELETE") { + sendJson(res, 200, await api.deleteWatch(watchId)); + return; + } + } + + sendJson(res, 404, { + error: "Not found", + }); + } catch (error) { + const statusCode = Number(error.statusCode) || 500; + sendJson(res, statusCode, { + error: error.message || "Internal Server Error", + }); + } + }); + + async function close() { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + await runtime.close(); + } + + return { + server, + watcher: runtime.watcher, + store: runtime.store, + close, + info: runtime.info, + }; +} + +async function runCli() { + const host = process.env.DASHBOARD_HOST || "127.0.0.1"; + const port = parsePort(process.env.DASHBOARD_PORT, 3000); + + const app = await createDashboardServer(); + app.server.listen(port, host, () => { + process.stdout.write(`Dashboard server listening on http://${host}:${port}\n`); + if (app.info.dbWarning) { + process.stdout.write(`[WARN] ${app.info.dbWarning}\n`); + } + process.stdout.write( + `dbEngine=${app.info.dbEngine} pollIntervalSec=${app.info.pollIntervalSec}\n` + ); + }); + + const shutdown = () => { + void app.close().finally(() => { + process.exit(0); + }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +if (require.main === module) { + runCli().catch((error) => { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { + buildAlertRules, + createDashboardServer, + inferAlertOn, + normalizeAlertOn, + parseTargetPrice, +}; diff --git a/src/dashboardStore.js b/src/dashboardStore.js new file mode 100644 index 0000000..c060865 --- /dev/null +++ b/src/dashboardStore.js @@ -0,0 +1,562 @@ +"use strict"; + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function toBoolean(value, fallback = true) { + if (value === undefined || value === null) return fallback; + return value !== false && value !== 0 && value !== "0"; +} + +function toSqlDateTime(isoString) { + const date = isoString ? new Date(isoString) : new Date(); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${isoString}`); + } + + const yyyy = String(date.getUTCFullYear()); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const mi = String(date.getUTCMinutes()).padStart(2, "0"); + const ss = String(date.getUTCSeconds()).padStart(2, "0"); + const mmm = String(date.getUTCMilliseconds()).padStart(3, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}.${mmm}`; +} + +function fromSqlDateTime(value) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} + +function parseJsonColumn(value, fallback) { + if (value === null || value === undefined) { + return fallback; + } + + if (typeof value === "object") { + return value; + } + + try { + return JSON.parse(value); + } catch (_error) { + return fallback; + } +} + +class InMemoryDashboardStore { + constructor() { + this.watches = new Map(); + this.events = []; + this.globalControls = { + crawlingEnabled: true, + alertsEnabled: true, + }; + } + + async init() {} + + async close() {} + + async listWatches() { + return Array.from(this.watches.values()) + .map((watch) => cloneJson(watch)) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); + } + + async getWatch(watchId) { + const watch = this.watches.get(watchId); + return watch ? cloneJson(watch) : null; + } + + async saveWatch(watch) { + const next = cloneJson(watch); + this.watches.set(next.id, next); + return next; + } + + async deleteWatch(watchId) { + return this.watches.delete(watchId); + } + + async savePollResult(watchId, pollResult) { + const watch = this.watches.get(watchId); + if (!watch) return; + + let touched = false; + + if (pollResult && pollResult.snapshot) { + watch.lastSnapshot = cloneJson(pollResult.snapshot); + watch.lastError = null; + touched = true; + } + + if (pollResult && pollResult.error) { + watch.lastError = cloneJson(pollResult.error); + touched = true; + } + + if (touched) { + watch.updatedAt = new Date().toISOString(); + } + } + + async saveEvent(event) { + const stored = { + id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`, + watchId: event.watchId, + eventType: event.eventType, + payload: cloneJson(event.payload), + observedAt: event.observedAt, + createdAt: new Date().toISOString(), + }; + this.events.unshift(stored); + if (this.events.length > 1000) { + this.events.length = 1000; + } + return cloneJson(stored); + } + + async listEvents(limit = 50) { + const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); + return this.events.slice(0, safeLimit).map((event) => cloneJson(event)); + } + + async getGlobalControls() { + return cloneJson(this.globalControls); + } + + async setGlobalControls(patch = {}) { + if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) { + this.globalControls.crawlingEnabled = toBoolean( + patch.crawlingEnabled, + this.globalControls.crawlingEnabled + ); + } + + if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) { + this.globalControls.alertsEnabled = toBoolean( + patch.alertsEnabled, + this.globalControls.alertsEnabled + ); + } + + return cloneJson(this.globalControls); + } +} + +class MySqlDashboardStore { + constructor(pool) { + this.pool = pool; + } + + static async create(options = {}) { + let mysql; + try { + // Optional dependency: only loaded when MySQL mode is used. + mysql = require("mysql2/promise"); + } catch (_error) { + throw new Error( + "mysql2 패키지가 필요합니다. `npm install mysql2` 후 다시 실행하세요." + ); + } + + const pool = mysql.createPool({ + uri: options.uri, + host: options.host, + port: options.port, + user: options.user, + password: options.password, + database: options.database, + waitForConnections: true, + connectionLimit: Number(options.connectionLimit) || 5, + queueLimit: 0, + charset: "utf8mb4", + timezone: "Z", + }); + + const store = new MySqlDashboardStore(pool); + await store.init(); + return store; + } + + async init() { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS watches ( + id VARCHAR(64) NOT NULL, + raw_input TEXT NOT NULL, + parsed_params JSON NOT NULL, + alert_rules JSON NOT NULL, + polling_enabled TINYINT(1) NOT NULL DEFAULT 1, + alerts_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + last_snapshot JSON NULL, + last_error JSON NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS watch_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + watch_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + observed_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + INDEX idx_watch_events_watch_id (watch_id, observed_at DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS app_settings ( + setting_key VARCHAR(64) NOT NULL, + setting_value JSON NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (setting_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + } + + async close() { + await this.pool.end(); + } + + async listWatches() { + const [rows] = await this.pool.query(` + SELECT + id, + raw_input, + parsed_params, + alert_rules, + polling_enabled, + alerts_enabled, + created_at, + updated_at, + last_snapshot, + last_error + FROM watches + ORDER BY created_at DESC + `); + + return rows.map((row) => ({ + id: row.id, + rawInput: row.raw_input, + searchParams: parseJsonColumn(row.parsed_params, {}), + alertRules: parseJsonColumn(row.alert_rules, {}), + pollingEnabled: toBoolean(row.polling_enabled, true), + alertsEnabled: toBoolean(row.alerts_enabled, true), + createdAt: fromSqlDateTime(row.created_at), + updatedAt: fromSqlDateTime(row.updated_at), + lastSnapshot: parseJsonColumn(row.last_snapshot, null), + lastError: parseJsonColumn(row.last_error, null), + })); + } + + async getWatch(watchId) { + const [rows] = await this.pool.query( + ` + SELECT + id, + raw_input, + parsed_params, + alert_rules, + polling_enabled, + alerts_enabled, + created_at, + updated_at, + last_snapshot, + last_error + FROM watches + WHERE id = ? + LIMIT 1 + `, + [watchId] + ); + + if (rows.length === 0) return null; + const row = rows[0]; + return { + id: row.id, + rawInput: row.raw_input, + searchParams: parseJsonColumn(row.parsed_params, {}), + alertRules: parseJsonColumn(row.alert_rules, {}), + pollingEnabled: toBoolean(row.polling_enabled, true), + alertsEnabled: toBoolean(row.alerts_enabled, true), + createdAt: fromSqlDateTime(row.created_at), + updatedAt: fromSqlDateTime(row.updated_at), + lastSnapshot: parseJsonColumn(row.last_snapshot, null), + lastError: parseJsonColumn(row.last_error, null), + }; + } + + async saveWatch(watch) { + const createdAt = watch.createdAt || new Date().toISOString(); + const updatedAt = watch.updatedAt || createdAt; + + await this.pool.query( + ` + INSERT INTO watches ( + id, + raw_input, + parsed_params, + alert_rules, + polling_enabled, + alerts_enabled, + created_at, + updated_at, + last_snapshot, + last_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + raw_input = VALUES(raw_input), + parsed_params = VALUES(parsed_params), + alert_rules = VALUES(alert_rules), + polling_enabled = VALUES(polling_enabled), + alerts_enabled = VALUES(alerts_enabled), + updated_at = VALUES(updated_at), + last_snapshot = VALUES(last_snapshot), + last_error = VALUES(last_error) + `, + [ + watch.id, + watch.rawInput || "", + JSON.stringify(watch.searchParams || {}), + JSON.stringify(watch.alertRules || {}), + watch.pollingEnabled === false ? 0 : 1, + watch.alertsEnabled === false ? 0 : 1, + toSqlDateTime(createdAt), + toSqlDateTime(updatedAt), + watch.lastSnapshot ? JSON.stringify(watch.lastSnapshot) : null, + watch.lastError ? JSON.stringify(watch.lastError) : null, + ] + ); + + return this.getWatch(watch.id); + } + + async deleteWatch(watchId) { + const [result] = await this.pool.query(`DELETE FROM watches WHERE id = ?`, [watchId]); + return result.affectedRows > 0; + } + + async savePollResult(watchId, pollResult) { + const updates = []; + const params = []; + const nowIso = new Date().toISOString(); + + if (pollResult && pollResult.snapshot) { + updates.push("last_snapshot = ?"); + params.push(JSON.stringify(pollResult.snapshot)); + updates.push("last_error = NULL"); + } + + if (pollResult && pollResult.error) { + updates.push("last_error = ?"); + params.push(JSON.stringify(pollResult.error)); + } + + if (updates.length === 0) { + return; + } + + updates.push("updated_at = ?"); + params.push(toSqlDateTime(nowIso)); + params.push(watchId); + + await this.pool.query( + ` + UPDATE watches + SET ${updates.join(", ")} + WHERE id = ? + `, + params + ); + } + + async saveEvent(event) { + const createdAt = new Date().toISOString(); + const observedAt = event.observedAt || createdAt; + + const [result] = await this.pool.query( + ` + INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at) + VALUES (?, ?, ?, ?, ?) + `, + [ + event.watchId, + event.eventType || "unknown", + JSON.stringify(event.payload || {}), + toSqlDateTime(observedAt), + toSqlDateTime(createdAt), + ] + ); + + return { + id: String(result.insertId), + watchId: event.watchId, + eventType: event.eventType, + payload: cloneJson(event.payload || {}), + observedAt, + createdAt, + }; + } + + async listEvents(limit = 50) { + const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); + const [rows] = await this.pool.query( + ` + SELECT id, watch_id, event_type, payload, observed_at, created_at + FROM watch_events + ORDER BY created_at DESC + LIMIT ? + `, + [safeLimit] + ); + + return rows.map((row) => ({ + id: String(row.id), + watchId: row.watch_id, + eventType: row.event_type, + payload: parseJsonColumn(row.payload, {}), + observedAt: fromSqlDateTime(row.observed_at), + createdAt: fromSqlDateTime(row.created_at), + })); + } + + async getGlobalControls() { + const [rows] = await this.pool.query( + ` + SELECT setting_value + FROM app_settings + WHERE setting_key = 'global_controls' + LIMIT 1 + ` + ); + + if (rows.length === 0) { + return { + crawlingEnabled: true, + alertsEnabled: true, + }; + } + + const setting = parseJsonColumn(rows[0].setting_value, {}); + return { + crawlingEnabled: toBoolean(setting.crawlingEnabled, true), + alertsEnabled: toBoolean(setting.alertsEnabled, true), + }; + } + + async setGlobalControls(patch = {}) { + const next = { + ...(await this.getGlobalControls()), + }; + + if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) { + next.crawlingEnabled = toBoolean(patch.crawlingEnabled, next.crawlingEnabled); + } + if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) { + next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled); + } + + await this.pool.query( + ` + INSERT INTO app_settings (setting_key, setting_value, updated_at) + VALUES ('global_controls', ?, ?) + ON DUPLICATE KEY UPDATE + setting_value = VALUES(setting_value), + updated_at = VALUES(updated_at) + `, + [JSON.stringify(next), toSqlDateTime(new Date().toISOString())] + ); + + return next; + } +} + +function parsePort(rawPort, fallback) { + const n = Number(rawPort); + if (!Number.isInteger(n) || n <= 0) return fallback; + return n; +} + +async function createDashboardStore(options = {}) { + const modeRaw = options.mode || process.env.DASHBOARD_DB || ""; + const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : ""; + const isStrictMySqlMode = mode === "mysql"; + const prefersMySql = + isStrictMySqlMode || + Boolean(process.env.MYSQL_URL) || + Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE); + + if (!prefersMySql || mode === "memory") { + const store = new InMemoryDashboardStore(); + await store.init(); + return { + engine: "memory", + store, + warning: null, + }; + } + + const mysqlOptions = { + uri: options.mysqlUrl || process.env.MYSQL_URL, + host: options.mysqlHost || process.env.MYSQL_HOST, + port: parsePort(options.mysqlPort || process.env.MYSQL_PORT, 3306), + user: options.mysqlUser || process.env.MYSQL_USER, + password: options.mysqlPassword || process.env.MYSQL_PASSWORD, + database: options.mysqlDatabase || process.env.MYSQL_DATABASE, + connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT, + }; + + if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) { + if (isStrictMySqlMode) { + throw new Error( + "MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하세요." + ); + } + + const fallbackStore = new InMemoryDashboardStore(); + await fallbackStore.init(); + return { + engine: "memory", + store: fallbackStore, + warning: + "MySQL 환경변수가 없어 메모리 저장소를 사용합니다. MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하면 MySQL로 전환됩니다.", + }; + } + + try { + const store = await MySqlDashboardStore.create(mysqlOptions); + return { + engine: "mysql", + store, + warning: null, + }; + } catch (error) { + if (isStrictMySqlMode) { + throw error; + } + + const fallbackStore = new InMemoryDashboardStore(); + await fallbackStore.init(); + return { + engine: "memory", + store: fallbackStore, + warning: `MySQL 초기화 실패로 메모리 저장소를 사용합니다: ${error.message}`, + }; + } +} + +module.exports = { + InMemoryDashboardStore, + MySqlDashboardStore, + createDashboardStore, +}; diff --git a/src/dashboardUtils.js b/src/dashboardUtils.js new file mode 100644 index 0000000..2ab13bf --- /dev/null +++ b/src/dashboardUtils.js @@ -0,0 +1,40 @@ +"use strict"; + +function createHttpError(statusCode, message) { + const error = new Error(message); + error.statusCode = statusCode; + return error; +} + +function parseBoolean(value, fallback = true) { + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; + } + return fallback; +} + +function parsePort(rawValue, fallback) { + const n = Number(rawValue); + if (!Number.isInteger(n) || n <= 0 || n > 65535) return fallback; + return n; +} + +function decodeWatchId(value) { + try { + return decodeURIComponent(value); + } catch (_error) { + throw createHttpError(400, "watchId 경로가 올바르지 않습니다."); + } +} + +module.exports = { + createHttpError, + decodeWatchId, + parseBoolean, + parsePort, +}; diff --git a/src/envLoader.js b/src/envLoader.js new file mode 100644 index 0000000..afd6edb --- /dev/null +++ b/src/envLoader.js @@ -0,0 +1,50 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +function decodeEnvValue(rawValue) { + const value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + const inner = value.slice(1, -1); + if (value.startsWith('"')) { + return inner + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); + } + return inner; + } + return value; +} + +function loadDotEnv(filePath = path.resolve(process.cwd(), ".env")) { + if (!fs.existsSync(filePath)) return; + + const content = fs.readFileSync(filePath, "utf8"); + const lines = content.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) continue; + + const key = line.slice(0, separatorIndex).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (process.env[key] !== undefined) continue; + + const rawValue = line.slice(separatorIndex + 1); + process.env[key] = decodeEnvValue(rawValue); + } +} + +module.exports = { + decodeEnvValue, + loadDotEnv, +}; diff --git a/src/fastifyDashboardServer.js b/src/fastifyDashboardServer.js new file mode 100644 index 0000000..2ab0498 --- /dev/null +++ b/src/fastifyDashboardServer.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +"use strict"; + +const path = require("node:path"); +const Fastify = require("fastify"); +const { createDashboardApi } = require("./dashboardApi"); +const { loadDashboardAsset } = require("./dashboardAssets"); +const { createDashboardRuntime } = require("./dashboardRuntime"); +const { decodeWatchId, parsePort } = require("./dashboardUtils"); + +function sendStaticAsset(reply, asset) { + reply + .code(200) + .header("content-type", asset.contentType) + .header("cache-control", "no-store") + .send(asset.content); +} + +function parseRequestPathname(request) { + try { + return new URL(request.raw.url || "/", "http://localhost").pathname; + } catch (_error) { + return request.url || "/"; + } +} + +async function createFastifyDashboardServer(options = {}) { + const runtime = await createDashboardRuntime(options); + const api = createDashboardApi({ + watcher: runtime.watcher, + store: runtime.store, + }); + const assetsDir = path.resolve(__dirname, "dashboard"); + + const app = Fastify({ + logger: false, + bodyLimit: 1024 * 1024, + }); + + app.setErrorHandler((error, _request, reply) => { + const statusCode = Number(error.statusCode) || 500; + reply.code(statusCode).send({ + error: error.message || "Internal Server Error", + }); + }); + + app.addHook("onClose", async () => { + await runtime.close(); + }); + + app.get("/", async (_request, reply) => { + sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/")); + }); + + app.get("/dashboard.css", async (_request, reply) => { + sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.css")); + }); + + app.get("/dashboard.js", async (_request, reply) => { + sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.js")); + }); + + app.get("/api/health", async () => ({ + ok: true, + now: new Date().toISOString(), + dbEngine: runtime.info.dbEngine, + watchCount: runtime.watcher.listWatchIds().length, + })); + + app.get("/api/config", async () => runtime.info); + + app.get("/api/system", async () => ({ + controls: runtime.watcher.getGlobalControls(), + })); + + app.patch("/api/system", async (request) => { + return api.updateSystem(request.body || {}); + }); + + app.post("/api/parse", async (request) => { + return api.parseInput(request.body || {}); + }); + + app.get("/api/watches", async () => api.listWatches()); + + app.post("/api/watches", async (request, reply) => { + reply.code(201); + return api.createWatch(request.body || {}); + }); + + app.get("/api/events", async (request) => { + return api.listEvents(request.query?.limit); + }); + + app.post("/api/watches/:watchId/poll", async (request) => { + return api.pollWatch(decodeWatchId(request.params.watchId)); + }); + + app.patch("/api/watches/:watchId", async (request) => { + return api.updateWatch(decodeWatchId(request.params.watchId), request.body || {}); + }); + + app.delete("/api/watches/:watchId", async (request) => { + return api.deleteWatch(decodeWatchId(request.params.watchId)); + }); + + app.setNotFoundHandler(async (request, reply) => { + const staticAsset = loadDashboardAsset(assetsDir, parseRequestPathname(request)); + if (staticAsset) { + sendStaticAsset(reply, staticAsset); + return; + } + + reply.code(404).send({ error: "Not found" }); + }); + + return { + app, + watcher: runtime.watcher, + store: runtime.store, + close: async () => { + await app.close(); + }, + info: runtime.info, + }; +} + +async function runCli() { + const host = process.env.DASHBOARD_HOST || "127.0.0.1"; + const port = parsePort(process.env.DASHBOARD_PORT, 3000); + + const dashboard = await createFastifyDashboardServer(); + await dashboard.app.listen({ host, port }); + + process.stdout.write(`Fastify dashboard server listening on http://${host}:${port}\n`); + if (dashboard.info.dbWarning) { + process.stdout.write(`[WARN] ${dashboard.info.dbWarning}\n`); + } + process.stdout.write( + `dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec}\n` + ); + + const shutdown = () => { + void dashboard.close().finally(() => { + process.exit(0); + }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +if (require.main === module) { + runCli().catch((error) => { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { + createFastifyDashboardServer, +}; diff --git a/src/llmParameterExtractor.js b/src/llmParameterExtractor.js new file mode 100644 index 0000000..089d3f1 --- /dev/null +++ b/src/llmParameterExtractor.js @@ -0,0 +1,283 @@ +"use strict"; + +const { parseFlightSearchRequest } = require("./naturalLanguageFlightParser"); + +function tryParseJsonObject(text) { + if (typeof text !== "string") { + throw new Error("LLM response is not a string"); + } + + const trimmed = text.trim(); + try { + return JSON.parse(trimmed); + } catch (_error) { + // Continue to bracket-based recovery. + } + + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace < 0 || lastBrace < 0 || firstBrace >= lastBrace) { + throw new Error("LLM response did not include a valid JSON object"); + } + + const sliced = trimmed.slice(firstBrace, lastBrace + 1); + return JSON.parse(sliced); +} + +function toIntegerOrNull(value) { + if (value === null || value === undefined) return null; + const n = Number(value); + if (!Number.isFinite(n)) return null; + return Math.round(n); +} + +function sanitizeDateWindow(value) { + if (!value || typeof value !== "object") return null; + const from = typeof value.from === "string" ? value.from : null; + const to = typeof value.to === "string" ? value.to : null; + if (!from || !to) return null; + return { from, to }; +} + +function sanitizeStayDuration(value) { + if (!value || typeof value !== "object") return null; + const minDays = toIntegerOrNull(value.minDays); + const maxDays = toIntegerOrNull(value.maxDays); + if (minDays === null || maxDays === null) return null; + return { minDays: Math.min(minDays, maxDays), maxDays: Math.max(minDays, maxDays) }; +} + +function sanitizeSegments(value) { + if (!Array.isArray(value)) return null; + const segments = value + .map((segment) => { + if (!segment || typeof segment !== "object") return null; + const from = typeof segment.from === "string" ? segment.from.trim() : ""; + const to = typeof segment.to === "string" ? segment.to.trim() : ""; + if (!from || !to) return null; + return { from: from.toUpperCase(), to: to.toUpperCase() }; + }) + .filter(Boolean); + + return segments.length > 0 ? segments : null; +} + +function sanitizePassengers(value) { + if (!value || typeof value !== "object") return null; + + const byCabinSource = value.byCabin || {}; + const byCabin = { + economy: Math.max(0, toIntegerOrNull(byCabinSource.economy) || 0), + premium_economy: Math.max(0, toIntegerOrNull(byCabinSource.premium_economy) || 0), + business: Math.max(0, toIntegerOrNull(byCabinSource.business) || 0), + first: Math.max(0, toIntegerOrNull(byCabinSource.first) || 0), + }; + + const computedTotal = Object.values(byCabin).reduce((acc, n) => acc + n, 0); + const providedTotal = toIntegerOrNull(value.total); + const total = providedTotal !== null ? Math.max(providedTotal, computedTotal) : computedTotal; + if (total <= 0) return null; + return { byCabin, total }; +} + +function sanitizeMaxJourneyHours(value) { + if (!value || typeof value !== "object") return null; + const hours = toIntegerOrNull(value.hours); + const operator = value.operator === "<=" ? "<=" : value.operator === "<" ? "<" : null; + if (hours === null || operator === null) return null; + return { hours, operator }; +} + +function sanitizeConstraints(value, fallbackConstraints) { + const source = value && typeof value === "object" ? value : {}; + return { + sameFlightForAllPassengers: + typeof source.sameFlightForAllPassengers === "boolean" + ? source.sameFlightForAllPassengers + : fallbackConstraints.sameFlightForAllPassengers, + itineraryCount: + source.itineraryCount === null + ? null + : toIntegerOrNull(source.itineraryCount) ?? fallbackConstraints.itineraryCount, + maxStops: + source.maxStops === null ? null : toIntegerOrNull(source.maxStops) ?? fallbackConstraints.maxStops, + maxJourneyHours: sanitizeMaxJourneyHours(source.maxJourneyHours) || fallbackConstraints.maxJourneyHours, + }; +} + +function uniqueStrings(values) { + if (!Array.isArray(values)) return []; + const seen = new Set(); + const result = []; + for (const item of values) { + if (typeof item !== "string") continue; + if (seen.has(item)) continue; + seen.add(item); + result.push(item); + } + return result; +} + +function inferTripType(segments) { + if (!segments || segments.length < 2) return "unknown"; + const first = segments[0]; + const second = segments[1]; + if (first.from === second.to && first.to === second.from) return "round_trip"; + if (first.from === second.to && first.to !== second.from) return "open_jaw"; + return "multi_city"; +} + +function recomputeMissingFields(params) { + const missingFields = []; + if (!params.departureDateWindow) missingFields.push("departureDateWindow"); + if (!params.stayDurationDays) missingFields.push("stayDurationDays"); + if (!params.passengers) missingFields.push("passengers"); + if (!params.segments) missingFields.push("segments"); + if (!params.constraints.maxJourneyHours) missingFields.push("maxJourneyHours"); + return missingFields; +} + +function mergeWithFallback(llmObject, fallbackParams, input, now) { + const source = llmObject && typeof llmObject === "object" ? llmObject : {}; + + const departureDateWindow = + sanitizeDateWindow(source.departureDateWindow) || fallbackParams.departureDateWindow; + const stayDurationDays = sanitizeStayDuration(source.stayDurationDays) || fallbackParams.stayDurationDays; + const segments = sanitizeSegments(source.segments) || fallbackParams.segments; + const passengers = sanitizePassengers(source.passengers) || fallbackParams.passengers; + const constraints = sanitizeConstraints(source.constraints, fallbackParams.constraints); + + const warnings = uniqueStrings([ + ...fallbackParams.warnings, + ...uniqueStrings(source.warnings), + ]); + + const tripType = + typeof source.tripType === "string" && source.tripType.trim() + ? source.tripType + : inferTripType(segments); + + const parsed = { + rawInput: input, + parsedAt: new Date(now).toISOString(), + tripType, + departureDateWindow, + stayDurationDays, + segments, + passengers, + constraints, + warnings, + }; + + parsed.missingFields = recomputeMissingFields(parsed); + return parsed; +} + +function buildPrompt(input, nowDate) { + return [ + "Extract flight-search parameters from user text and return JSON only.", + "Use this JSON schema keys exactly:", + "{", + ' "departureDateWindow": {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"} | null,', + ' "stayDurationDays": {"minDays": number, "maxDays": number} | null,', + ' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | null,', + ' "passengers": {"total":number,"byCabin":{"economy":number,"premium_economy":number,"business":number,"first":number}} | null,', + ' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},', + ' "tripType": "round_trip|open_jaw|multi_city|unknown",', + ' "warnings": [string],', + ' "missingFields": [string]', + "}", + "When information is missing, set null and add key names in missingFields.", + `Today date is ${new Date(nowDate).toISOString().slice(0, 10)}.`, + "", + `User input: ${input}`, + ].join("\n"); +} + +function createOpenAIClient(options = {}) { + const apiKey = options.apiKey || process.env.OPENAI_API_KEY; + if (!apiKey) return null; + + const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"; + const model = options.model || process.env.OPENAI_MODEL || "gpt-4.1-mini"; + const fetchImpl = options.fetch || global.fetch; + if (typeof fetchImpl !== "function") { + throw new Error("global fetch is unavailable. Node.js 18+ is required."); + } + + const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`; + + return async ({ input, now }) => { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: + "You are a parser. Output valid JSON only. Do not wrap in markdown or prose.", + }, + { + role: "user", + content: buildPrompt(input, now), + }, + ], + }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(`OpenAI API request failed (${response.status}): ${message}`); + } + + const payload = await response.json(); + const content = payload?.choices?.[0]?.message?.content; + return tryParseJsonObject(content); + }; +} + +async function extractFlightSearchRequest(input, options = {}) { + if (typeof input !== "string" || input.trim() === "") { + throw new Error("input must be a non-empty string"); + } + + const now = options.now || new Date(); + const fallbackParams = parseFlightSearchRequest(input, { now }); + if (options.preferRuleParser) { + return { source: "rule_parser", params: fallbackParams }; + } + + try { + const llmClient = options.llmClient || createOpenAIClient(options); + if (!llmClient) { + return { source: "rule_parser", params: fallbackParams }; + } + + const llmRaw = await llmClient({ input, now }); + const merged = mergeWithFallback(llmRaw, fallbackParams, input, now); + return { source: "llm", params: merged }; + } catch (error) { + return { + source: "rule_parser", + params: { + ...fallbackParams, + warnings: [ + ...fallbackParams.warnings, + `LLM extraction fallback triggered: ${error.message}`, + ], + }, + }; + } +} + +module.exports = { + createOpenAIClient, + extractFlightSearchRequest, +}; diff --git a/src/naturalLanguageFlightParser.js b/src/naturalLanguageFlightParser.js new file mode 100644 index 0000000..5595dd9 --- /dev/null +++ b/src/naturalLanguageFlightParser.js @@ -0,0 +1,252 @@ +"use strict"; + +const CITY_TO_AIRPORT = { + 인천: "ICN", + 서울: "ICN", + 김포: "GMP", + 마드리드: "MAD", + 바르셀로나: "BCN", + incheon: "ICN", + seoul: "ICN", + madrid: "MAD", + barcelona: "BCN", +}; + +const CABIN_PATTERNS = [ + { regex: /비즈니스\s*(\d+)\s*(?:명|석|개)?/gi, key: "business" }, + { + regex: /프리미엄\s*이코노미\s*(\d+)\s*(?:명|석|개)?/gi, + key: "premium_economy", + }, + { regex: /이코노미\s*(\d+)\s*(?:명|석|개)?/gi, key: "economy" }, + { regex: /퍼스트\s*(\d+)\s*(?:명|석|개)?/gi, key: "first" }, +]; + +function normalize(text) { + return text + .trim() + .replace(/[,,]/g, " ") + .replace(/\s+/g, " "); +} + +function daysInMonth(year, month1Based) { + return new Date(Date.UTC(year, month1Based, 0)).getUTCDate(); +} + +function rangeByBucket(bucket, year, month1Based) { + const lastDay = daysInMonth(year, month1Based); + if (bucket === "초") return [1, Math.min(10, lastDay)]; + if (bucket === "중") return [11, Math.min(20, lastDay)]; + if (bucket === "말") return [21, lastDay]; + return [1, lastDay]; +} + +function dateToIso(year, month1Based, day) { + return `${year}-${String(month1Based).padStart(2, "0")}-${String(day).padStart( + 2, + "0" + )}`; +} + +function inferYearsForMonthRange(nowDate, startMonth, endMonth, endBucket) { + const now = new Date(nowDate); + const thisYear = now.getUTCFullYear(); + const crossesYear = endMonth < startMonth; + + let startYear = thisYear; + let endYear = crossesYear ? thisYear + 1 : thisYear; + + const [, endMaxDay] = rangeByBucket(endBucket, endYear, endMonth); + const candidateEnd = new Date(Date.UTC(endYear, endMonth - 1, endMaxDay, 23, 59, 59)); + + if (candidateEnd < now) { + startYear += 1; + endYear += 1; + } + + return { startYear, endYear }; +} + +function parseDepartureDateWindow(text, nowDate) { + const monthBucketRange = + /(\d{1,2})\s*월\s*(초|중|말)\s*부터\s*(\d{1,2})\s*월\s*(초|중|말)\s*까지/i; + const m = text.match(monthBucketRange); + if (!m) return null; + + const startMonth = Number(m[1]); + const startBucket = m[2]; + const endMonth = Number(m[3]); + const endBucket = m[4]; + + if (startMonth < 1 || startMonth > 12 || endMonth < 1 || endMonth > 12) { + return null; + } + + const { startYear, endYear } = inferYearsForMonthRange( + nowDate, + startMonth, + endMonth, + endBucket + ); + const [startMinDay] = rangeByBucket(startBucket, startYear, startMonth); + const [, endMaxDay] = rangeByBucket(endBucket, endYear, endMonth); + + return { + raw: `${startMonth}월 ${startBucket}부터 ${endMonth}월 ${endBucket}까지`, + from: dateToIso(startYear, startMonth, startMinDay), + to: dateToIso(endYear, endMonth, endMaxDay), + }; +} + +function parseStayDuration(text) { + const m = text.match(/(\d{1,2})\s*(?:~|-|–)\s*(\d{1,2})\s*일/i); + if (!m) return null; + const a = Number(m[1]); + const b = Number(m[2]); + return { + minDays: Math.min(a, b), + maxDays: Math.max(a, b), + }; +} + +function parsePassengers(text) { + const cabinCounts = { + economy: 0, + premium_economy: 0, + business: 0, + first: 0, + }; + + let remainingText = text; + for (const { regex, key } of CABIN_PATTERNS) { + const copy = new RegExp(regex.source, regex.flags); + remainingText = remainingText.replace(copy, (_full, count) => { + cabinCounts[key] += Number(count); + return " "; + }); + } + + const total = Object.values(cabinCounts).reduce((acc, n) => acc + n, 0); + return total > 0 ? { byCabin: cabinCounts, total } : null; +} + +function normalizeLocationToken(token) { + return token + .trim() + .replace(/[^\p{L}\p{N}]/gu, "") + .toLowerCase(); +} + +function toAirportCode(raw) { + const cleaned = normalizeLocationToken(raw); + if (/^[a-z]{3}$/.test(cleaned)) return cleaned.toUpperCase(); + return CITY_TO_AIRPORT[cleaned] || raw.trim(); +} + +function parseSegments(text) { + const inbound = text.match( + /([A-Za-z가-힣]{2,20})\s*(?:->|→|>)\s*([A-Za-z가-힣]{2,20})\s*인/i + ); + const outbound = text.match( + /([A-Za-z가-힣]{2,20})\s*(?:->|→|>)\s*([A-Za-z가-힣]{2,20})\s*아웃/i + ); + + if (inbound && outbound) { + return [ + { from: toAirportCode(inbound[1]), to: toAirportCode(inbound[2]) }, + { from: toAirportCode(outbound[1]), to: toAirportCode(outbound[2]) }, + ]; + } + + const genericSegments = []; + const regex = /([A-Za-z가-힣]{2,20})\s*(?:->|→|>)\s*([A-Za-z가-힣]{2,20})/gi; + let m; + while ((m = regex.exec(text)) !== null) { + genericSegments.push({ + from: toAirportCode(m[1]), + to: toAirportCode(m[2]), + }); + } + return genericSegments.length > 0 ? genericSegments : null; +} + +function inferTripType(segments) { + if (!segments || segments.length < 2) return "unknown"; + const first = segments[0]; + const second = segments[1]; + if (first.from === second.to && first.to === second.from) return "round_trip"; + if (first.from === second.to && first.to !== second.from) return "open_jaw"; + return "multi_city"; +} + +function parseMaxJourneyHours(text) { + const m = text.match(/(\d{1,2})\s*시간\s*(미만|이하)/i); + if (!m) return null; + return { + hours: Number(m[1]), + operator: m[2] === "미만" ? "<" : "<=", + }; +} + +function parseMaxStops(text) { + const m = text.match(/(\d+)\s*회\s*경유/i); + return m ? Number(m[1]) : null; +} + +function parseItineraryCount(text) { + const m = text.match(/총\s*(\d+)\s*회/i); + return m ? Number(m[1]) : null; +} + +function parseFlightSearchRequest(input, options = {}) { + if (typeof input !== "string" || input.trim() === "") { + throw new Error("input must be a non-empty string"); + } + + const now = options.now || new Date(); + const text = normalize(input); + const departureDateWindow = parseDepartureDateWindow(text, now); + const stayDuration = parseStayDuration(text); + const passengers = parsePassengers(text); + const segments = parseSegments(text); + const sameFlightForAllPassengers = /동일\s*항공편/i.test(text); + const maxJourney = parseMaxJourneyHours(text); + const maxStops = parseMaxStops(text); + const itineraryCount = parseItineraryCount(text); + + const warnings = []; + if (/총\s*\d+\s*회/i.test(text) && maxStops === null && !/경유/i.test(text)) { + warnings.push( + "'총 N회'는 현재 여정 수로 해석됩니다. 경유 제한 의도라면 'N회 경유'처럼 입력하세요." + ); + } + + const missingFields = []; + if (!departureDateWindow) missingFields.push("departureDateWindow"); + if (!stayDuration) missingFields.push("stayDurationDays"); + if (!passengers) missingFields.push("passengers"); + if (!segments) missingFields.push("segments"); + if (!maxJourney) missingFields.push("maxJourneyHours"); + + return { + rawInput: input, + parsedAt: new Date(now).toISOString(), + tripType: inferTripType(segments), + departureDateWindow, + stayDurationDays: stayDuration, + segments, + passengers, + constraints: { + sameFlightForAllPassengers, + itineraryCount, + maxStops, + maxJourneyHours: maxJourney, + }, + warnings, + missingFields, + }; +} + +module.exports = { + parseFlightSearchRequest, +}; diff --git a/src/notifier.js b/src/notifier.js new file mode 100644 index 0000000..a2f0091 --- /dev/null +++ b/src/notifier.js @@ -0,0 +1,184 @@ +"use strict"; + +function formatPrice(price, currency) { + if (!Number.isFinite(price)) return `N/A ${currency || ""}`.trim(); + const formatter = new Intl.NumberFormat("ko-KR"); + return `${formatter.format(price)} ${currency || ""}`.trim(); +} + +function formatTelegramMessage(event) { + const lines = ["Air-Watcher price alert"]; + + if (event.eventType) lines.push(`Type: ${event.eventType}`); + lines.push(`Current best: ${formatPrice(event.currentBestPrice, event.currency)}`); + + if (Number.isFinite(event.previousBestPrice)) { + lines.push(`Previous best: ${formatPrice(event.previousBestPrice, event.currency)}`); + } + + if (Number.isFinite(event.threshold)) { + lines.push(`Target threshold: ${formatPrice(event.threshold, event.currency)}`); + } + + if (event.bestOffer?.provider) { + lines.push(`Provider: ${event.bestOffer.provider}`); + } + + if (event.rawInput) { + lines.push(`Query: ${event.rawInput}`); + } + + if (event.observedAt) { + lines.push(`Observed at: ${event.observedAt}`); + } + + if (event.watchId) { + lines.push(`Watch ID: ${event.watchId}`); + } + + return lines.join("\n"); +} + +class ConsoleNotifier { + constructor(options = {}) { + this.logger = options.logger || console; + } + + async notify(event) { + const priceText = formatPrice(event.currentBestPrice, event.currency); + const prevText = + event.previousBestPrice === null + ? "N/A" + : formatPrice(event.previousBestPrice, event.currency); + this.logger.log( + `[ALERT] watchId=${event.watchId} type=${event.eventType} price=${priceText} prev=${prevText}` + ); + } +} + +class WebhookNotifier { + constructor(options = {}) { + this.webhookUrl = options.webhookUrl; + this.fetch = options.fetch || global.fetch; + if (!this.webhookUrl) { + throw new Error("webhookUrl is required"); + } + if (typeof this.fetch !== "function") { + throw new Error("global fetch is unavailable. Node.js 18+ is required."); + } + } + + async notify(event) { + const response = await this.fetch(this.webhookUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(event), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Webhook notification failed (${response.status}): ${body}`); + } + } +} + +class TelegramNotifier { + constructor(options = {}) { + this.botToken = options.botToken; + this.chatId = options.chatId; + this.apiBase = options.apiBase || "https://api.telegram.org"; + this.fetch = options.fetch || global.fetch; + + if (!this.botToken) { + throw new Error("telegram bot token is required"); + } + if (!this.chatId) { + throw new Error("telegram chat id is required"); + } + if (typeof this.fetch !== "function") { + throw new Error("global fetch is unavailable. Node.js 18+ is required."); + } + + this.endpoint = `${this.apiBase.replace(/\/$/, "")}/bot${this.botToken}/sendMessage`; + } + + async notify(event) { + const response = await this.fetch(this.endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + chat_id: this.chatId, + text: formatTelegramMessage(event), + disable_web_page_preview: true, + }), + }); + + const rawBody = await response.text(); + let payload = null; + if (rawBody) { + try { + payload = JSON.parse(rawBody); + } catch (_error) { + payload = null; + } + } + + if (!response.ok) { + const message = payload?.description || rawBody || "request failed"; + throw new Error(`Telegram notification failed (${response.status}): ${message}`); + } + + if (!payload || payload.ok !== true) { + const message = payload?.description || rawBody || "unknown response"; + throw new Error(`Telegram notification failed: ${message}`); + } + } +} + +function createNotifier(options = {}) { + if (options.notifier && typeof options.notifier.notify === "function") { + return options.notifier; + } + + const channelRaw = options.channel || process.env.NOTIFY_CHANNEL || ""; + const channel = typeof channelRaw === "string" ? channelRaw.trim().toLowerCase() : ""; + const telegramBotToken = + options.telegramBotToken || + process.env.TELEGRAM_BOT_TOKEN || + process.env.NOTIFY_TELEGRAM_BOT_TOKEN; + const telegramChatId = + options.telegramChatId || process.env.TELEGRAM_CHAT_ID || process.env.NOTIFY_TELEGRAM_CHAT_ID; + + if (channel === "telegram" || (!channel && telegramBotToken && telegramChatId)) { + return new TelegramNotifier({ + botToken: telegramBotToken, + chatId: telegramChatId, + apiBase: options.telegramApiBase || process.env.TELEGRAM_API_BASE, + fetch: options.fetch, + }); + } + + const webhookUrl = options.webhookUrl || process.env.NOTIFY_WEBHOOK_URL; + if (channel === "webhook" || (!channel && webhookUrl)) { + return new WebhookNotifier({ + webhookUrl, + fetch: options.fetch, + }); + } + + if (channel && channel !== "console") { + throw new Error(`Unsupported NOTIFY_CHANNEL: ${channel}`); + } + + return new ConsoleNotifier({ + logger: options.logger, + }); +} + +module.exports = { + ConsoleNotifier, + TelegramNotifier, + WebhookNotifier, + createNotifier, + formatTelegramMessage, +}; diff --git a/src/priceWatcher.js b/src/priceWatcher.js new file mode 100644 index 0000000..3443d17 --- /dev/null +++ b/src/priceWatcher.js @@ -0,0 +1,443 @@ +"use strict"; + +const crypto = require("node:crypto"); + +function cloneJson(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function newWatchId() { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `watch-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; +} + +function normalizeAlertRules(alertRules = {}) { + const targetPrice = + alertRules.targetPrice === null || alertRules.targetPrice === undefined + ? null + : Number(alertRules.targetPrice); + + return { + targetPrice: + targetPrice !== null && Number.isFinite(targetPrice) && targetPrice > 0 + ? Math.round(targetPrice) + : null, + notifyOnPriceChange: alertRules.notifyOnPriceChange !== false, + notifyOnFirstResult: alertRules.notifyOnFirstResult === true, + }; +} + +function normalizeToggle(value, defaultValue = true) { + if (value === undefined) return defaultValue; + return value !== false; +} + +function normalizeOffers(offers) { + if (!Array.isArray(offers)) return []; + + return offers + .map((offer) => { + if (!offer || typeof offer !== "object") return null; + + const price = Number(offer.price); + if (!Number.isFinite(price) || price <= 0) return null; + + const currency = + typeof offer.currency === "string" && offer.currency.trim() + ? offer.currency.trim().toUpperCase() + : "KRW"; + const provider = + typeof offer.provider === "string" && offer.provider.trim() + ? offer.provider.trim() + : "unknown-provider"; + + return { + ...offer, + provider, + currency, + price: Math.round(price), + }; + }) + .filter(Boolean) + .sort((a, b) => a.price - b.price); +} + +function buildAlertEvent(watch, previousSnapshot, currentSnapshot) { + const reasons = []; + const { targetPrice, notifyOnPriceChange, notifyOnFirstResult } = watch.alertRules; + + if (targetPrice !== null && currentSnapshot.bestPrice <= targetPrice) { + const crossedThreshold = !previousSnapshot || previousSnapshot.bestPrice > targetPrice; + const betterPriceBelowThreshold = + previousSnapshot && + previousSnapshot.bestPrice <= targetPrice && + currentSnapshot.bestPrice < previousSnapshot.bestPrice; + if (crossedThreshold || betterPriceBelowThreshold) { + reasons.push("target_price"); + } + } + + if ( + notifyOnPriceChange && + previousSnapshot && + previousSnapshot.bestPrice !== currentSnapshot.bestPrice + ) { + reasons.push("price_changed"); + } + + if (notifyOnFirstResult && !previousSnapshot) { + reasons.push("first_result"); + } + + if (reasons.length === 0) return null; + + const uniqueReasons = [...new Set(reasons)]; + return { + watchId: watch.id, + rawInput: watch.rawInput, + eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0], + reasons: uniqueReasons, + threshold: targetPrice, + previousBestPrice: previousSnapshot ? previousSnapshot.bestPrice : null, + currentBestPrice: currentSnapshot.bestPrice, + currency: currentSnapshot.currency, + bestOffer: currentSnapshot.bestOffer, + observedAt: currentSnapshot.polledAt, + }; +} + +function createWatchError({ message, at, phase }) { + return { + message: typeof message === "string" && message ? message : "Unknown error", + at, + phase: phase || "crawl", + }; +} + +class PriceWatcher { + constructor(options = {}) { + this.crawler = options.crawler; + this.notifier = options.notifier; + this.pollIntervalMs = + Number.isFinite(Number(options.pollIntervalMs)) && Number(options.pollIntervalMs) > 0 + ? Number(options.pollIntervalMs) + : 60000; + this.logger = options.logger || console; + this.now = options.now || (() => new Date()); + this.onWatchPolled = + typeof options.onWatchPolled === "function" ? options.onWatchPolled : null; + + if (!this.crawler || typeof this.crawler.getQuotes !== "function") { + throw new Error("crawler.getQuotes function is required"); + } + if (!this.notifier || typeof this.notifier.notify !== "function") { + throw new Error("notifier.notify function is required"); + } + + this.watches = new Map(); + this.timer = null; + this.globalControls = { + crawlingEnabled: true, + alertsEnabled: true, + }; + } + + toPublicWatch(watch) { + return { + id: watch.id, + rawInput: watch.rawInput, + searchParams: cloneJson(watch.searchParams), + alertRules: cloneJson(watch.alertRules), + pollingEnabled: watch.pollingEnabled, + alertsEnabled: watch.alertsEnabled, + createdAt: watch.createdAt, + updatedAt: watch.updatedAt, + lastSnapshot: cloneJson(watch.lastSnapshot), + lastError: cloneJson(watch.lastError), + }; + } + + getWatch(watchId) { + const watch = this.watches.get(watchId); + return watch ? this.toPublicWatch(watch) : null; + } + + listWatches() { + return Array.from(this.watches.values()) + .map((watch) => this.toPublicWatch(watch)) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); + } + + setGlobalControls(patch = {}) { + if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) { + this.globalControls.crawlingEnabled = normalizeToggle( + patch.crawlingEnabled, + this.globalControls.crawlingEnabled + ); + } + if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) { + this.globalControls.alertsEnabled = normalizeToggle( + patch.alertsEnabled, + this.globalControls.alertsEnabled + ); + } + + return { ...this.globalControls }; + } + + getGlobalControls() { + return { ...this.globalControls }; + } + + updateWatch(watchId, patch = {}) { + const watch = this.watches.get(watchId); + if (!watch) { + throw new Error(`Unknown watchId: ${watchId}`); + } + + if (Object.prototype.hasOwnProperty.call(patch, "rawInput")) { + watch.rawInput = typeof patch.rawInput === "string" ? patch.rawInput : watch.rawInput; + } + if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) { + if (!patch.searchParams || typeof patch.searchParams !== "object") { + throw new Error("searchParams must be an object"); + } + watch.searchParams = patch.searchParams; + } + if (Object.prototype.hasOwnProperty.call(patch, "alertRules")) { + watch.alertRules = normalizeAlertRules(patch.alertRules); + } + if (Object.prototype.hasOwnProperty.call(patch, "pollingEnabled")) { + watch.pollingEnabled = normalizeToggle(patch.pollingEnabled, watch.pollingEnabled); + } + if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) { + watch.alertsEnabled = normalizeToggle(patch.alertsEnabled, watch.alertsEnabled); + } + if (Object.prototype.hasOwnProperty.call(patch, "lastSnapshot")) { + watch.lastSnapshot = patch.lastSnapshot || null; + } + if (Object.prototype.hasOwnProperty.call(patch, "lastError")) { + watch.lastError = patch.lastError || null; + } + + watch.updatedAt = new Date(this.now()).toISOString(); + return this.toPublicWatch(watch); + } + + async emitPolled(watch, result) { + if (!this.onWatchPolled) return; + try { + await this.onWatchPolled({ + watch: this.toPublicWatch(watch), + result: cloneJson(result), + }); + } catch (error) { + this.logger.error(`[watch:${watch.id}] post-poll hook failed: ${error.message}`); + } + } + + addWatch({ + id, + rawInput, + searchParams, + alertRules, + pollingEnabled = true, + alertsEnabled = true, + lastSnapshot = null, + lastError = null, + createdAt, + updatedAt, + }) { + if (!searchParams || typeof searchParams !== "object") { + throw new Error("searchParams is required"); + } + + const watchId = id || newWatchId(); + if (this.watches.has(watchId)) { + throw new Error(`watchId already exists: ${watchId}`); + } + + const nowIso = new Date(this.now()).toISOString(); + this.watches.set(watchId, { + id: watchId, + rawInput: typeof rawInput === "string" ? rawInput : "", + searchParams, + alertRules: normalizeAlertRules(alertRules), + pollingEnabled: normalizeToggle(pollingEnabled, true), + alertsEnabled: normalizeToggle(alertsEnabled, true), + createdAt: typeof createdAt === "string" ? createdAt : nowIso, + updatedAt: typeof updatedAt === "string" ? updatedAt : nowIso, + lastSnapshot: lastSnapshot || null, + lastError: lastError || null, + }); + + return watchId; + } + + removeWatch(watchId) { + return this.watches.delete(watchId); + } + + listWatchIds() { + return Array.from(this.watches.keys()); + } + + async start() { + if (this.timer) return; + await this.pollAll(); + + this.timer = setInterval(() => { + void this.pollAll(); + }, this.pollIntervalMs); + } + + stop() { + if (!this.timer) return; + clearInterval(this.timer); + this.timer = null; + } + + async pollAll() { + if (!this.globalControls.crawlingEnabled) { + const skipped = []; + for (const watch of this.watches.values()) { + const result = { + watchId: watch.id, + skipped: { + reason: "global_crawling_disabled", + }, + }; + skipped.push(result); + await this.emitPolled(watch, result); + } + return skipped; + } + + const results = []; + for (const watch of this.watches.values()) { + if (!watch.pollingEnabled) { + const result = { + watchId: watch.id, + skipped: { + reason: "watch_polling_disabled", + }, + }; + results.push(result); + await this.emitPolled(watch, result); + continue; + } + // Poll sequentially to keep provider-side rate limits predictable. + results.push(await this.pollWatch(watch.id)); + } + return results; + } + + async pollWatch(watchId) { + const watch = this.watches.get(watchId); + if (!watch) { + throw new Error(`Unknown watchId: ${watchId}`); + } + + if (!this.globalControls.crawlingEnabled) { + const result = { + watchId: watch.id, + skipped: { reason: "global_crawling_disabled" }, + }; + await this.emitPolled(watch, result); + return result; + } + + if (!watch.pollingEnabled) { + const result = { + watchId: watch.id, + skipped: { reason: "watch_polling_disabled" }, + }; + await this.emitPolled(watch, result); + return result; + } + + try { + const offers = await this.crawler.getQuotes({ + watchId: watch.id, + rawInput: watch.rawInput, + searchParams: watch.searchParams, + }); + const normalizedOffers = normalizeOffers(offers); + if (normalizedOffers.length === 0) { + throw new Error("Crawler returned no valid offers"); + } + + const bestOffer = normalizedOffers[0]; + const currentSnapshot = { + polledAt: new Date(this.now()).toISOString(), + bestPrice: bestOffer.price, + currency: bestOffer.currency, + bestOffer, + offers: normalizedOffers, + }; + + const alert = buildAlertEvent(watch, watch.lastSnapshot, currentSnapshot); + watch.lastSnapshot = currentSnapshot; + watch.updatedAt = currentSnapshot.polledAt; + let notificationSent = false; + let notificationError = null; + + if (alert) { + if (this.globalControls.alertsEnabled && watch.alertsEnabled) { + try { + await this.notifier.notify(alert); + notificationSent = true; + } catch (error) { + const at = new Date(this.now()).toISOString(); + notificationError = createWatchError({ + message: `Notifier failed: ${error.message}`, + at, + phase: "notify", + }); + this.logger.error(`[watch:${watch.id}] notify failed: ${error.message}`); + } + } else { + alert.notificationSuppressed = true; + } + } + + watch.lastError = notificationError; + if (notificationError) { + watch.updatedAt = notificationError.at; + } + + const result = { + watchId: watch.id, + snapshot: currentSnapshot, + alert, + notificationSent, + }; + if (notificationError) { + result.error = notificationError; + } + await this.emitPolled(watch, result); + return result; + } catch (error) { + watch.lastError = createWatchError({ + message: error.message, + at: new Date(this.now()).toISOString(), + phase: "crawl", + }); + watch.updatedAt = watch.lastError.at; + this.logger.error(`[watch:${watch.id}] poll failed: ${error.message}`); + const result = { + watchId: watch.id, + error: watch.lastError, + }; + await this.emitPolled(watch, result); + return result; + } + } +} + +module.exports = { + PriceWatcher, +}; diff --git a/src/skyscannerSampleServer.js b/src/skyscannerSampleServer.js new file mode 100644 index 0000000..2aba64f --- /dev/null +++ b/src/skyscannerSampleServer.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +"use strict"; + +const http = require("node:http"); + +function stableHash(text) { + let hash = 0; + for (let i = 0; i < text.length; i += 1) { + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + } + return hash; +} + +function serializeSearchSeed(searchParams) { + const segments = Array.isArray(searchParams?.segments) ? searchParams.segments : []; + const segmentText = + segments.length > 0 + ? segments + .map((segment) => `${segment.from || "?"}-${segment.to || "?"}`) + .join("|") + .toUpperCase() + : "NO_SEGMENTS"; + + return JSON.stringify({ + segmentText, + departureDateWindow: searchParams?.departureDateWindow || null, + stayDurationDays: searchParams?.stayDurationDays || null, + passengers: searchParams?.passengers?.total || null, + }); +} + +function buildSampleOffers(searchParams) { + const seed = stableHash(serializeSearchSeed(searchParams)); + const base = 650000 + (seed % 500000); + + return [ + { + provider: "skyscanner", + price: Math.round(base), + currency: "KRW", + metadata: { + source: "skyscanner-sample", + rank: 1, + }, + }, + { + provider: "skyscanner", + price: Math.round(base * 1.04), + currency: "KRW", + metadata: { + source: "skyscanner-sample", + rank: 2, + }, + }, + { + provider: "skyscanner", + price: Math.round(base * 1.08), + currency: "KRW", + metadata: { + source: "skyscanner-sample", + rank: 3, + }, + }, + ]; +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let raw = ""; + req.setEncoding("utf8"); + + req.on("data", (chunk) => { + raw += chunk; + if (raw.length > 1024 * 1024) { + reject(new Error("Payload too large")); + req.destroy(); + } + }); + + req.on("end", () => { + if (!raw) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(raw)); + } catch (error) { + reject(new Error(`Invalid JSON payload: ${error.message}`)); + } + }); + + req.on("error", (error) => { + reject(error); + }); + }); +} + +function sendJson(res, statusCode, body) { + const payload = JSON.stringify(body); + res.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +function createSkyscannerSampleHandler(options = {}) { + const routePath = + typeof options.routePath === "string" && options.routePath.trim() + ? options.routePath.trim() + : "/skyscanner"; + + return async (req, res) => { + if (req.method !== "POST" || req.url !== routePath) { + sendJson(res, 404, { + error: "Not found", + expected: `POST ${routePath}`, + }); + return; + } + + try { + const body = await readJsonBody(req); + const requestPayload = + body && typeof body.request === "object" && body.request ? body.request : body; + const searchParams = + requestPayload && typeof requestPayload.searchParams === "object" + ? requestPayload.searchParams + : {}; + + sendJson(res, 200, { + currency: "KRW", + offers: buildSampleOffers(searchParams), + }); + } catch (error) { + sendJson(res, 400, { error: error.message }); + } + }; +} + +function createSkyscannerSampleServer(options = {}) { + return http.createServer(createSkyscannerSampleHandler(options)); +} + +function runCli() { + const host = process.env.SKYSCANNER_SAMPLE_HOST || "127.0.0.1"; + const portRaw = process.env.SKYSCANNER_SAMPLE_PORT || "8787"; + const routePath = process.env.SKYSCANNER_SAMPLE_PATH || "/skyscanner"; + const port = Number(portRaw); + + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid SKYSCANNER_SAMPLE_PORT: ${portRaw}`); + } + + const server = createSkyscannerSampleServer({ routePath }); + server.listen(port, host, () => { + process.stdout.write( + `Skyscanner sample server listening on http://${host}:${port}${routePath}\n` + ); + }); + + const shutdown = () => { + server.close(() => { + process.exit(0); + }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +if (require.main === module) { + try { + runCli(); + } catch (error) { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + } +} + +module.exports = { + buildSampleOffers, + createSkyscannerSampleHandler, + createSkyscannerSampleServer, +}; diff --git a/test/alertRules.test.js b/test/alertRules.test.js new file mode 100644 index 0000000..3e0bf2f --- /dev/null +++ b/test/alertRules.test.js @@ -0,0 +1,50 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + buildAlertRules, + inferAlertOn, + normalizeAlertOn, + parseTargetPrice, +} = require("../src/alertRules"); + +test("buildAlertRules builds both mode with integer target", () => { + const result = buildAlertRules({ + targetPrice: "1200000", + alertOn: "both", + }); + + assert.deepEqual(result, { + targetPrice: 1200000, + notifyOnPriceChange: true, + notifyOnFirstResult: false, + }); +}); + +test("buildAlertRules validates threshold mode target", () => { + assert.throws( + () => + buildAlertRules({ + targetPrice: null, + alertOn: "threshold", + }), + (error) => error.message.includes("targetPrice가 필요합니다.") + ); +}); + +test("normalizeAlertOn validates allowed modes", () => { + assert.equal(normalizeAlertOn(" change "), "change"); + assert.throws(() => normalizeAlertOn("invalid"), (error) => error.statusCode === 400); +}); + +test("parseTargetPrice handles undefined and allowUndefined option", () => { + assert.equal(parseTargetPrice(undefined), null); + assert.equal(parseTargetPrice(undefined, { allowUndefined: true }), undefined); +}); + +test("inferAlertOn reflects rule combinations", () => { + assert.equal(inferAlertOn({ targetPrice: 1000, notifyOnPriceChange: true }), "both"); + assert.equal(inferAlertOn({ targetPrice: 1000, notifyOnPriceChange: false }), "threshold"); + assert.equal(inferAlertOn({ targetPrice: null, notifyOnPriceChange: true }), "change"); +}); diff --git a/test/crawlerClient.test.js b/test/crawlerClient.test.js new file mode 100644 index 0000000..5f8c33b --- /dev/null +++ b/test/crawlerClient.test.js @@ -0,0 +1,201 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createCrawlerClient } = require("../src/crawlerClient"); + +function createJsonResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, + async text() { + return JSON.stringify(body); + }, + }; +} + +function createTextResponse(text, status = 500) { + return { + ok: false, + status, + async json() { + throw new Error("json not available"); + }, + async text() { + return text; + }, + }; +} + +test("single endpoint mode still works", async () => { + const crawler = createCrawlerClient({ + endpoint: "https://single-endpoint.example", + maxAttempts: 1, + fetch: async () => + createJsonResponse({ + currency: "KRW", + offers: [{ provider: "single", price: 123456 }], + }), + }); + + const offers = await crawler.getQuotes({ + watchId: "watch-1", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + }); + + assert.equal(offers.length, 1); + assert.equal(offers[0].provider, "single"); + assert.equal(offers[0].price, 123456); +}); + +test("endpoint mode retries transient failures and then succeeds", async () => { + let calls = 0; + const crawler = createCrawlerClient({ + endpoint: "https://retry-endpoint.example", + maxAttempts: 3, + retryBaseDelayMs: 1, + retryMaxDelayMs: 1, + fetch: async () => { + calls += 1; + if (calls < 3) { + return createTextResponse("upstream failed", 503); + } + return createJsonResponse({ + currency: "KRW", + offers: [{ provider: "retry", price: 777000 }], + }); + }, + }); + + const offers = await crawler.getQuotes({ + watchId: "watch-retry", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + }); + + assert.equal(calls, 3); + assert.equal(offers.length, 1); + assert.equal(offers[0].provider, "retry"); +}); + +test("priorityFallback tries next provider when primary fails", async () => { + const calls = []; + const crawler = createCrawlerClient({ + endpoint: null, + providers: ["skyscanner", "naver"], + providerEndpoints: { + skyscanner: "https://skyscanner.example", + naver: "https://naver.example", + }, + routingStrategy: "priorityFallback", + maxAttempts: 1, + fetch: async (url) => { + calls.push(url); + if (url.includes("skyscanner")) { + return createTextResponse("upstream failed", 503); + } + return createJsonResponse({ + currency: "KRW", + offers: [{ price: 980000 }], + }); + }, + }); + + const offers = await crawler.getQuotes({ + watchId: "watch-2", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + }); + + assert.equal(calls.length, 2); + assert.equal(offers.length, 1); + assert.equal(offers[0].provider, "naver"); + assert.equal(offers[0].price, 980000); +}); + +test("primaryOnly does not fallback to the next provider", async () => { + const calls = []; + const crawler = createCrawlerClient({ + endpoint: null, + providers: ["skyscanner", "naver"], + providerEndpoints: { + skyscanner: "https://skyscanner.example", + naver: "https://naver.example", + }, + routingStrategy: "primaryOnly", + maxAttempts: 1, + fetch: async (url) => { + calls.push(url); + if (url.includes("skyscanner")) { + return createTextResponse("timeout", 504); + } + return createJsonResponse({ + currency: "KRW", + offers: [{ price: 970000 }], + }); + }, + }); + + await assert.rejects(async () => crawler.getQuotes({ watchId: "watch-3", searchParams: {} }), { + message: /Primary provider failed/, + }); + assert.equal(calls.length, 1); +}); + +test("parallelRace returns first successful provider result", async () => { + const crawler = createCrawlerClient({ + endpoint: null, + providers: ["skyscanner", "naver"], + providerEndpoints: { + skyscanner: "https://skyscanner.example", + naver: "https://naver.example", + }, + routingStrategy: "parallelRace", + maxAttempts: 1, + fetch: async (url) => + new Promise((resolve) => { + if (url.includes("naver")) { + setTimeout(() => { + resolve( + createJsonResponse({ + currency: "KRW", + offers: [{ price: 930000 }], + }) + ); + }, 5); + return; + } + + setTimeout(() => { + resolve( + createJsonResponse({ + currency: "KRW", + offers: [{ price: 950000 }], + }) + ); + }, 40); + }), + }); + + const offers = await crawler.getQuotes({ + watchId: "watch-4", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + }); + + assert.equal(offers.length, 1); + assert.equal(offers[0].provider, "naver"); + assert.equal(offers[0].price, 930000); +}); + +test("throws when multi-source providers are configured without endpoints", () => { + assert.throws( + () => + createCrawlerClient({ + endpoint: null, + providers: ["unconfigured-provider"], + providerEndpoints: {}, + }), + /Missing endpoint for provider\(s\): unconfigured-provider/ + ); +}); diff --git a/test/dashboardStore.test.js b/test/dashboardStore.test.js new file mode 100644 index 0000000..c65ccc2 --- /dev/null +++ b/test/dashboardStore.test.js @@ -0,0 +1,65 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { InMemoryDashboardStore } = require("../src/dashboardStore"); + +test("in-memory store persists watch, poll result, events and controls", async () => { + const store = new InMemoryDashboardStore(); + await store.init(); + + await store.saveWatch({ + id: "watch-1", + rawInput: "인천->마드리드", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + alertRules: { targetPrice: 1300000, notifyOnPriceChange: true }, + pollingEnabled: true, + alertsEnabled: true, + createdAt: "2026-02-19T00:00:00.000Z", + updatedAt: "2026-02-19T00:00:00.000Z", + lastSnapshot: null, + lastError: null, + }); + + await store.savePollResult("watch-1", { + snapshot: { + polledAt: "2026-02-19T00:01:00.000Z", + bestPrice: 1295000, + currency: "KRW", + bestOffer: { provider: "mock" }, + offers: [{ provider: "mock", price: 1295000, currency: "KRW" }], + }, + }); + + await store.saveEvent({ + watchId: "watch-1", + eventType: "target_price", + observedAt: "2026-02-19T00:01:00.000Z", + payload: { + currentBestPrice: 1295000, + previousBestPrice: 1310000, + currency: "KRW", + }, + }); + + const watches = await store.listWatches(); + const events = await store.listEvents(5); + + assert.equal(watches.length, 1); + assert.equal(watches[0].lastSnapshot.bestPrice, 1295000); + assert.equal(events.length, 1); + assert.equal(events[0].eventType, "target_price"); + + const controlsBefore = await store.getGlobalControls(); + assert.equal(controlsBefore.crawlingEnabled, true); + assert.equal(controlsBefore.alertsEnabled, true); + + await store.setGlobalControls({ + crawlingEnabled: false, + alertsEnabled: false, + }); + + const controlsAfter = await store.getGlobalControls(); + assert.equal(controlsAfter.crawlingEnabled, false); + assert.equal(controlsAfter.alertsEnabled, false); +}); diff --git a/test/envLoader.test.js b/test/envLoader.test.js new file mode 100644 index 0000000..e1da06f --- /dev/null +++ b/test/envLoader.test.js @@ -0,0 +1,48 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { loadDotEnv } = require("../src/envLoader"); + +test("loadDotEnv parses quoted values and keeps existing env", () => { + const suffix = `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const plainKey = `AIRPLANE_TEST_PLAIN_${suffix}`; + const quotedKey = `AIRPLANE_TEST_QUOTED_${suffix}`; + const singleQuotedKey = `AIRPLANE_TEST_SINGLE_${suffix}`; + const existingKey = `AIRPLANE_TEST_EXISTING_${suffix}`; + const invalidKey = "INVALID-NAME"; + + const envPath = path.join(os.tmpdir(), `airplane_${suffix}.env`); + const envContent = [ + `# comment`, + `${plainKey}=hello`, + `${quotedKey}=\"line1\\nline2\"`, + `${singleQuotedKey}='with space'`, + `${existingKey}=from_file`, + `${invalidKey}=ignored`, + ``, + ].join("\n"); + + process.env[existingKey] = "from_process"; + fs.writeFileSync(envPath, envContent, "utf8"); + + try { + loadDotEnv(envPath); + + assert.equal(process.env[plainKey], "hello"); + assert.equal(process.env[quotedKey], "line1\nline2"); + assert.equal(process.env[singleQuotedKey], "with space"); + assert.equal(process.env[existingKey], "from_process"); + } finally { + delete process.env[plainKey]; + delete process.env[quotedKey]; + delete process.env[singleQuotedKey]; + delete process.env[existingKey]; + if (fs.existsSync(envPath)) { + fs.unlinkSync(envPath); + } + } +}); diff --git a/test/llmParameterExtractor.test.js b/test/llmParameterExtractor.test.js new file mode 100644 index 0000000..0b9e40d --- /dev/null +++ b/test/llmParameterExtractor.test.js @@ -0,0 +1,69 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { extractFlightSearchRequest } = require("../src/llmParameterExtractor"); + +test("uses LLM output when client is provided", async () => { + const llmClient = async () => ({ + departureDateWindow: { + from: "2026-11-21", + to: "2026-12-10", + }, + stayDurationDays: { + minDays: 12, + maxDays: 14, + }, + segments: [ + { from: "ICN", to: "MAD" }, + { from: "MAD", to: "ICN" }, + ], + passengers: { + total: 2, + byCabin: { + economy: 0, + premium_economy: 0, + business: 2, + first: 0, + }, + }, + constraints: { + sameFlightForAllPassengers: true, + itineraryCount: 1, + maxStops: 0, + maxJourneyHours: { + hours: 20, + operator: "<=", + }, + }, + tripType: "round_trip", + warnings: [], + missingFields: [], + }); + + const result = await extractFlightSearchRequest("임의 입력", { + now: new Date("2026-02-19T00:00:00Z"), + llmClient, + }); + + assert.equal(result.source, "llm"); + assert.equal(result.params.tripType, "round_trip"); + assert.equal(result.params.departureDateWindow.from, "2026-11-21"); + assert.equal(result.params.constraints.maxJourneyHours.hours, 20); + assert.deepEqual(result.params.missingFields, []); +}); + +test("falls back to rule parser when LLM client fails", async () => { + const result = await extractFlightSearchRequest("인천->마드리드 20시간 이하", { + llmClient: async () => { + throw new Error("intentional failure"); + }, + }); + + assert.equal(result.source, "rule_parser"); + assert.equal(result.params.constraints.maxJourneyHours.hours, 20); + assert.equal( + result.params.warnings.some((warning) => warning.includes("LLM extraction fallback triggered")), + true + ); +}); diff --git a/test/naturalLanguageFlightParser.test.js b/test/naturalLanguageFlightParser.test.js new file mode 100644 index 0000000..c025a63 --- /dev/null +++ b/test/naturalLanguageFlightParser.test.js @@ -0,0 +1,52 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { parseFlightSearchRequest } = require("../src/naturalLanguageFlightParser"); + +test("parse sample Korean request into structured params", () => { + const input = + "11월 말부터 12월 초까지 출발하는 일정 여행 기간은 대략 12~14일, 비즈니스 2개, 프리미엄 이코노미 1개, 동일 항공편 인천 -> 마드리드 인, 바르셀로나 -> 인천 아웃 총 1회 여정 시간은 20시간 미만"; + + const parsed = parseFlightSearchRequest(input, { + now: new Date("2026-02-19T00:00:00Z"), + }); + + assert.equal(parsed.departureDateWindow.from, "2026-11-21"); + assert.equal(parsed.departureDateWindow.to, "2026-12-10"); + assert.deepEqual(parsed.stayDurationDays, { minDays: 12, maxDays: 14 }); + assert.equal(parsed.passengers.total, 3); + assert.deepEqual(parsed.passengers.byCabin, { + economy: 0, + premium_economy: 1, + business: 2, + first: 0, + }); + assert.deepEqual(parsed.segments, [ + { from: "ICN", to: "MAD" }, + { from: "BCN", to: "ICN" }, + ]); + assert.equal(parsed.tripType, "open_jaw"); + assert.equal(parsed.constraints.sameFlightForAllPassengers, true); + assert.equal(parsed.constraints.itineraryCount, 1); + assert.equal(parsed.constraints.maxStops, null); + assert.deepEqual(parsed.constraints.maxJourneyHours, { hours: 20, operator: "<" }); + assert.equal(parsed.warnings.length, 1); + assert.deepEqual(parsed.missingFields, []); +}); + +test("infer next year for past month range", () => { + const parsed = parseFlightSearchRequest("11월 말부터 12월 초까지 출발", { + now: new Date("2026-12-20T00:00:00Z"), + }); + + assert.equal(parsed.departureDateWindow.from, "2027-11-21"); + assert.equal(parsed.departureDateWindow.to, "2027-12-10"); +}); + +test("parse max stops when explicit layover phrase is present", () => { + const parsed = parseFlightSearchRequest("인천->마드리드 1회 경유 20시간 이하"); + + assert.equal(parsed.constraints.maxStops, 1); + assert.deepEqual(parsed.constraints.maxJourneyHours, { hours: 20, operator: "<=" }); +}); diff --git a/test/notifier.test.js b/test/notifier.test.js new file mode 100644 index 0000000..2f12483 --- /dev/null +++ b/test/notifier.test.js @@ -0,0 +1,97 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { TelegramNotifier, createNotifier } = require("../src/notifier"); + +const sampleEvent = { + watchId: "watch-1", + rawInput: "인천->마드리드", + eventType: "target_price", + threshold: 1300000, + previousBestPrice: 1320000, + currentBestPrice: 1295000, + currency: "KRW", + bestOffer: { + provider: "mock-ota-b", + }, + observedAt: "2026-02-19T00:00:00.000Z", +}; + +test("createNotifier selects telegram channel and sends formatted message", async () => { + const requests = []; + const notifier = createNotifier({ + channel: "telegram", + telegramBotToken: "123:abc", + telegramChatId: "999", + fetch: async (url, init) => { + requests.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ ok: true }); + }, + }; + }, + }); + + await notifier.notify(sampleEvent); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, "https://api.telegram.org/bot123:abc/sendMessage"); + + const body = JSON.parse(requests[0].init.body); + assert.equal(body.chat_id, "999"); + assert.equal(body.disable_web_page_preview, true); + assert.equal(typeof body.text, "string"); + assert.match(body.text, /Current best: 1,295,000 KRW/); + assert.match(body.text, /Target threshold: 1,300,000 KRW/); +}); + +test("createNotifier supports webhook channel", async () => { + const requests = []; + const notifier = createNotifier({ + channel: "webhook", + webhookUrl: "https://example.com/hook", + fetch: async (url, init) => { + requests.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return ""; + }, + }; + }, + }); + + await notifier.notify(sampleEvent); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, "https://example.com/hook"); + assert.deepEqual(JSON.parse(requests[0].init.body), sampleEvent); +}); + +test("createNotifier rejects unsupported channel", () => { + assert.throws(() => createNotifier({ channel: "email" }), /Unsupported NOTIFY_CHANNEL/); +}); + +test("telegram notifier surfaces API errors", async () => { + const notifier = new TelegramNotifier({ + botToken: "token", + chatId: "chat", + fetch: async () => ({ + ok: false, + status: 400, + async text() { + return JSON.stringify({ description: "Bad Request: chat not found" }); + }, + }), + }); + + await assert.rejects( + async () => notifier.notify(sampleEvent), + /Telegram notification failed \(400\): Bad Request: chat not found/ + ); +}); diff --git a/test/priceWatcher.test.js b/test/priceWatcher.test.js new file mode 100644 index 0000000..7aca9ee --- /dev/null +++ b/test/priceWatcher.test.js @@ -0,0 +1,134 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { PriceWatcher } = require("../src/priceWatcher"); + +function createSequenceCrawler(prices) { + let index = 0; + return { + async getQuotes() { + const safeIndex = Math.min(index, prices.length - 1); + const price = prices[safeIndex]; + index += 1; + return [ + { + provider: "sequence-crawler", + price, + currency: "KRW", + }, + ]; + }, + }; +} + +function createSilentLogger() { + return { + log() {}, + error() {}, + }; +} + +test("emits threshold alerts when crossing and improving below threshold", async () => { + const notifications = []; + const watcher = new PriceWatcher({ + crawler: createSequenceCrawler([1000, 950, 890, 870]), + notifier: { + async notify(event) { + notifications.push(event); + }, + }, + logger: createSilentLogger(), + }); + + const watchId = watcher.addWatch({ + rawInput: "인천-마드리드 추적", + searchParams: { + segments: [{ from: "ICN", to: "MAD" }], + }, + alertRules: { + targetPrice: 900, + notifyOnPriceChange: false, + }, + }); + + await watcher.pollWatch(watchId); + await watcher.pollWatch(watchId); + await watcher.pollWatch(watchId); + await watcher.pollWatch(watchId); + + assert.equal(notifications.length, 2); + assert.equal(notifications[0].eventType, "target_price"); + assert.equal(notifications[0].currentBestPrice, 890); + assert.equal(notifications[1].currentBestPrice, 870); +}); + +test("emits price change alerts when price changes", async () => { + const notifications = []; + const watcher = new PriceWatcher({ + crawler: createSequenceCrawler([1000, 980, 980, 950]), + notifier: { + async notify(event) { + notifications.push(event); + }, + }, + logger: createSilentLogger(), + }); + + const watchId = watcher.addWatch({ + rawInput: "인천-마드리드 추적", + searchParams: { + segments: [{ from: "ICN", to: "MAD" }], + }, + alertRules: { + notifyOnPriceChange: true, + targetPrice: null, + }, + }); + + await watcher.pollWatch(watchId); + await watcher.pollWatch(watchId); + await watcher.pollWatch(watchId); + await watcher.pollWatch(watchId); + + assert.equal(notifications.length, 2); + assert.equal(notifications[0].eventType, "price_changed"); + assert.equal(notifications[0].previousBestPrice, 1000); + assert.equal(notifications[0].currentBestPrice, 980); + assert.equal(notifications[1].previousBestPrice, 980); + assert.equal(notifications[1].currentBestPrice, 950); +}); + +test("keeps crawl snapshot even when notifier fails", async () => { + const watcher = new PriceWatcher({ + crawler: createSequenceCrawler([950000]), + notifier: { + async notify() { + throw new Error("telegram timeout"); + }, + }, + logger: createSilentLogger(), + }); + + const watchId = watcher.addWatch({ + rawInput: "인천-마드리드 추적", + searchParams: { + segments: [{ from: "ICN", to: "MAD" }], + }, + alertRules: { + targetPrice: 980000, + notifyOnPriceChange: false, + }, + }); + + const result = await watcher.pollWatch(watchId); + const watch = watcher.getWatch(watchId); + + assert.equal(result.notificationSent, false); + assert.equal(result.alert.eventType, "target_price"); + assert.equal(result.snapshot.bestPrice, 950000); + assert.equal(result.error.phase, "notify"); + + assert.equal(watch.lastSnapshot.bestPrice, 950000); + assert.equal(watch.lastError.phase, "notify"); +}); diff --git a/test/priceWatcherControls.test.js b/test/priceWatcherControls.test.js new file mode 100644 index 0000000..693df93 --- /dev/null +++ b/test/priceWatcherControls.test.js @@ -0,0 +1,105 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { PriceWatcher } = require("../src/priceWatcher"); + +function createSilentLogger() { + return { + log() {}, + error() {}, + }; +} + +test("global crawling toggle skips polling", async () => { + let crawlerCalls = 0; + const watcher = new PriceWatcher({ + crawler: { + async getQuotes() { + crawlerCalls += 1; + return [{ provider: "x", price: 1000, currency: "KRW" }]; + }, + }, + notifier: { + async notify() {}, + }, + logger: createSilentLogger(), + }); + + const watchId = watcher.addWatch({ + rawInput: "테스트", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + alertRules: { targetPrice: 900, notifyOnPriceChange: true }, + }); + + watcher.setGlobalControls({ crawlingEnabled: false }); + + const result = await watcher.pollWatch(watchId); + + assert.equal(crawlerCalls, 0); + assert.equal(result.skipped.reason, "global_crawling_disabled"); +}); + +test("watch-level polling toggle skips polling", async () => { + let crawlerCalls = 0; + const watcher = new PriceWatcher({ + crawler: { + async getQuotes() { + crawlerCalls += 1; + return [{ provider: "x", price: 1000, currency: "KRW" }]; + }, + }, + notifier: { + async notify() {}, + }, + logger: createSilentLogger(), + }); + + const watchId = watcher.addWatch({ + rawInput: "테스트", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + alertRules: { targetPrice: null, notifyOnPriceChange: true }, + pollingEnabled: false, + }); + + const result = await watcher.pollWatch(watchId); + + assert.equal(crawlerCalls, 0); + assert.equal(result.skipped.reason, "watch_polling_disabled"); +}); + +test("alerts can be suppressed while still computing alert events", async () => { + const notifications = []; + let call = 0; + const watcher = new PriceWatcher({ + crawler: { + async getQuotes() { + call += 1; + if (call === 1) { + return [{ provider: "x", price: 1000, currency: "KRW" }]; + } + return [{ provider: "x", price: 900, currency: "KRW" }]; + }, + }, + notifier: { + async notify(event) { + notifications.push(event); + }, + }, + logger: createSilentLogger(), + }); + + const watchId = watcher.addWatch({ + rawInput: "테스트", + searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + alertRules: { targetPrice: 950, notifyOnPriceChange: true }, + alertsEnabled: false, + }); + + await watcher.pollWatch(watchId); + const second = await watcher.pollWatch(watchId); + + assert.equal(notifications.length, 0); + assert.equal(second.alert.eventType, "target_price"); + assert.equal(second.alert.notificationSuppressed, true); +}); diff --git a/test/skyscannerSampleServer.test.js b/test/skyscannerSampleServer.test.js new file mode 100644 index 0000000..117eb8b --- /dev/null +++ b/test/skyscannerSampleServer.test.js @@ -0,0 +1,137 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { Readable, Writable } = require("node:stream"); +const { + createSkyscannerSampleHandler, + buildSampleOffers, +} = require("../src/skyscannerSampleServer"); + +function createMockRequest({ method = "POST", url = "/skyscanner", body = "" } = {}) { + const req = new Readable({ + read() { + this.push(body); + this.push(null); + }, + }); + req.method = method; + req.url = url; + return req; +} + +function createMockResponse() { + const chunks = []; + let statusCode = 0; + let headers = {}; + let resolveDone; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + const res = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(); + }, + }); + + res.writeHead = (status, responseHeaders) => { + statusCode = status; + headers = responseHeaders || {}; + }; + + res.end = (chunk) => { + if (chunk) chunks.push(Buffer.from(chunk)); + resolveDone(); + }; + + return { + res, + done, + getStatusCode() { + return statusCode; + }, + getHeaders() { + return headers; + }, + getJsonBody() { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + }, + }; +} + +test("buildSampleOffers is deterministic for the same search params", () => { + const searchParams = { + segments: [{ from: "ICN", to: "MAD" }], + passengers: { total: 2 }, + }; + + const first = buildSampleOffers(searchParams); + const second = buildSampleOffers(searchParams); + + assert.deepEqual(first, second); + assert.equal(first[0].provider, "skyscanner"); + assert.equal(first[0].currency, "KRW"); +}); + +test("sample handler returns skyscanner offers on POST /skyscanner", async () => { + const handler = createSkyscannerSampleHandler(); + const req = createMockRequest({ + body: JSON.stringify({ + watchId: "watch-1", + searchParams: { + segments: [{ from: "ICN", to: "MAD" }], + }, + }), + }); + const response = createMockResponse(); + + await handler(req, response.res); + await response.done; + + assert.equal(response.getStatusCode(), 200); + assert.equal(response.getHeaders()["content-type"], "application/json; charset=utf-8"); + const payload = response.getJsonBody(); + assert.equal(payload.currency, "KRW"); + assert.ok(Array.isArray(payload.offers)); + assert.equal(payload.offers.length, 3); + assert.equal(payload.offers[0].provider, "skyscanner"); +}); + +test("sample handler accepts wrapped request payload shape", async () => { + const handler = createSkyscannerSampleHandler(); + const req = createMockRequest({ + body: JSON.stringify({ + request: { + watchId: "watch-2", + searchParams: { + segments: [{ from: "ICN", to: "BCN" }], + }, + }, + }), + }); + const response = createMockResponse(); + + await handler(req, response.res); + await response.done; + + assert.equal(response.getStatusCode(), 200); + const payload = response.getJsonBody(); + assert.equal(payload.offers[0].provider, "skyscanner"); +}); + +test("sample handler returns 404 for unsupported route", async () => { + const handler = createSkyscannerSampleHandler(); + const req = createMockRequest({ + url: "/not-skyscanner", + body: JSON.stringify({}), + }); + const response = createMockResponse(); + + await handler(req, response.res); + await response.done; + + assert.equal(response.getStatusCode(), 404); + assert.match(response.getJsonBody().expected, /POST \/skyscanner/); +});