initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
test
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -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"]
|
||||
391
README.md
Normal file
391
README.md
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -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:
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
68
src/alertRules.js
Normal file
68
src/alertRules.js
Normal file
@@ -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,
|
||||
};
|
||||
207
src/cli.js
Normal file
207
src/cli.js
Normal file
@@ -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);
|
||||
}
|
||||
427
src/crawlerClient.js
Normal file
427
src/crawlerClient.js
Normal file
@@ -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_<PROVIDER> 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,
|
||||
};
|
||||
349
src/dashboard/dashboard.css
Normal file
349
src/dashboard/dashboard.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
398
src/dashboard/dashboard.js
Normal file
398
src/dashboard/dashboard.js
Normal file
@@ -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 = [
|
||||
`<div><strong>파서:</strong> ${parsedPayload.source}</div>`,
|
||||
`<div><strong>구간:</strong> ${segmentText || "미입력"}</div>`,
|
||||
`<div><strong>출발 윈도우:</strong> ${windowText}</div>`,
|
||||
`<div><strong>체류 기간:</strong> ${stayText}</div>`,
|
||||
`<div><strong>탑승객:</strong> ${paxText}</div>`,
|
||||
`<div><strong>최대 여정시간:</strong> ${journeyText}</div>`,
|
||||
`<div><strong>누락 필드:</strong> ${missing.length > 0 ? missing.join(", ") : "없음"}</div>`,
|
||||
].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 `
|
||||
<article class="watch-item" data-watch-id="${watch.id}">
|
||||
<header>
|
||||
<div>
|
||||
<h3 class="watch-title">${watch.rawInput || "(no input)"}</h3>
|
||||
<p class="watch-sub">watchId: <code>${watch.id}</code></p>
|
||||
</div>
|
||||
<div class="price">${formatPrice(bestPrice, currency)}</div>
|
||||
</header>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div>provider: <code>${provider}</code></div>
|
||||
<div>마지막 갱신: ${formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)}</div>
|
||||
<div>alert mode: <code>${alertOn}</code></div>
|
||||
<div>target: ${targetPrice ? formatPrice(targetPrice, currency) : "-"}</div>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
<label class="switch-field">
|
||||
<input data-action="toggle-polling" type="checkbox" ${watch.pollingEnabled ? "checked" : ""} />
|
||||
<span>크롤링</span>
|
||||
</label>
|
||||
<label class="switch-field">
|
||||
<input data-action="toggle-alerts" type="checkbox" ${watch.alertsEnabled ? "checked" : ""} />
|
||||
<span>알림</span>
|
||||
</label>
|
||||
<button class="btn secondary" data-action="poll">즉시 조회</button>
|
||||
<button class="btn danger" data-action="delete">삭제</button>
|
||||
</div>
|
||||
|
||||
${watch.lastError ? `<p class="watch-sub">오류: ${watch.lastError.message || "unknown"}</p>` : ""}
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.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 `
|
||||
<article class="event-item">
|
||||
<div class="event-head">
|
||||
<strong>${payload.eventType || event.eventType || "event"}</strong>
|
||||
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
||||
</div>
|
||||
<p>
|
||||
watchId: <code>${event.watchId}</code><br />
|
||||
가격: ${formatPrice(payload.currentBestPrice, payload.currency)}
|
||||
${Number.isFinite(Number(payload.previousBestPrice))
|
||||
? ` (이전 ${formatPrice(payload.previousBestPrice, payload.currency)})`
|
||||
: ""}<br />
|
||||
시각: ${formatDate(event.observedAt)}
|
||||
</p>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.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();
|
||||
})();
|
||||
96
src/dashboard/index.html
Normal file
96
src/dashboard/index.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Air-Watcher Dashboard</title>
|
||||
<link rel="stylesheet" href="/dashboard.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-gradient" aria-hidden="true"></div>
|
||||
<main class="layout">
|
||||
<header class="topbar panel">
|
||||
<div>
|
||||
<p class="eyebrow">AIR-WATCHER</p>
|
||||
<h1>Flight Watch Dashboard</h1>
|
||||
</div>
|
||||
<div class="config" id="configBanner">초기화 중...</div>
|
||||
</header>
|
||||
|
||||
<section class="panel composer">
|
||||
<div class="section-title">
|
||||
<h2>LLM 입력 파싱</h2>
|
||||
<p>자연어를 넣으면 조회 조건을 자동 정리합니다.</p>
|
||||
</div>
|
||||
|
||||
<label for="queryInput" class="label">항공권 요청 문장</label>
|
||||
<textarea id="queryInput" rows="4" placeholder="예: 11월 말~12월 초 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명"></textarea>
|
||||
|
||||
<div class="controls-grid">
|
||||
<label class="switch-field">
|
||||
<input id="useLlm" type="checkbox" checked />
|
||||
<span>LLM 파싱 사용</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>알림 기준</span>
|
||||
<select id="alertOn">
|
||||
<option value="both">가격변동 + 목표가</option>
|
||||
<option value="change">가격변동만</option>
|
||||
<option value="threshold">목표가만</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>목표 가격 (원)</span>
|
||||
<input id="targetPrice" type="number" min="1" step="1" placeholder="예: 1300000" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button id="parseBtn" class="btn secondary">파싱만 실행</button>
|
||||
<button id="createWatchBtn" class="btn primary">추적 추가</button>
|
||||
</div>
|
||||
|
||||
<div id="parseSummary" class="summary empty">파싱 결과 요약이 여기에 표시됩니다.</div>
|
||||
<pre id="parseOutput" class="json-view">{}</pre>
|
||||
</section>
|
||||
|
||||
<section class="panel system-panel">
|
||||
<div class="section-title">
|
||||
<h2>전역 제어</h2>
|
||||
<p>전체 추적 동작을 한 번에 켜고 끌 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label class="switch-field">
|
||||
<input id="globalCrawling" type="checkbox" />
|
||||
<span>전체 크롤링 ON/OFF</span>
|
||||
</label>
|
||||
<label class="switch-field">
|
||||
<input id="globalAlerts" type="checkbox" />
|
||||
<span>전체 알림 ON/OFF</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel watches-panel">
|
||||
<div class="section-title">
|
||||
<h2>추적 목록</h2>
|
||||
<p>각 항목별로 크롤링/알림 토글을 조정하세요.</p>
|
||||
</div>
|
||||
<div id="watchList" class="watch-list empty">아직 등록된 watch가 없습니다.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel events-panel">
|
||||
<div class="section-title">
|
||||
<h2>최근 이벤트</h2>
|
||||
<p>목표가 도달/가격 변동 이벤트를 확인합니다.</p>
|
||||
</div>
|
||||
<div id="eventList" class="event-list empty">이벤트가 아직 없습니다.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
152
src/dashboardApi.js
Normal file
152
src/dashboardApi.js
Normal file
@@ -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,
|
||||
};
|
||||
34
src/dashboardAssets.js
Normal file
34
src/dashboardAssets.js
Normal file
@@ -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,
|
||||
};
|
||||
104
src/dashboardRuntime.js
Normal file
104
src/dashboardRuntime.js
Normal file
@@ -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,
|
||||
};
|
||||
225
src/dashboardServer.js
Normal file
225
src/dashboardServer.js
Normal file
@@ -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,
|
||||
};
|
||||
562
src/dashboardStore.js
Normal file
562
src/dashboardStore.js
Normal file
@@ -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,
|
||||
};
|
||||
40
src/dashboardUtils.js
Normal file
40
src/dashboardUtils.js
Normal file
@@ -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,
|
||||
};
|
||||
50
src/envLoader.js
Normal file
50
src/envLoader.js
Normal file
@@ -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,
|
||||
};
|
||||
162
src/fastifyDashboardServer.js
Normal file
162
src/fastifyDashboardServer.js
Normal file
@@ -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,
|
||||
};
|
||||
283
src/llmParameterExtractor.js
Normal file
283
src/llmParameterExtractor.js
Normal file
@@ -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,
|
||||
};
|
||||
252
src/naturalLanguageFlightParser.js
Normal file
252
src/naturalLanguageFlightParser.js
Normal file
@@ -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,
|
||||
};
|
||||
184
src/notifier.js
Normal file
184
src/notifier.js
Normal file
@@ -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,
|
||||
};
|
||||
443
src/priceWatcher.js
Normal file
443
src/priceWatcher.js
Normal file
@@ -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,
|
||||
};
|
||||
186
src/skyscannerSampleServer.js
Normal file
186
src/skyscannerSampleServer.js
Normal file
@@ -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,
|
||||
};
|
||||
50
test/alertRules.test.js
Normal file
50
test/alertRules.test.js
Normal file
@@ -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");
|
||||
});
|
||||
201
test/crawlerClient.test.js
Normal file
201
test/crawlerClient.test.js
Normal file
@@ -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/
|
||||
);
|
||||
});
|
||||
65
test/dashboardStore.test.js
Normal file
65
test/dashboardStore.test.js
Normal file
@@ -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);
|
||||
});
|
||||
48
test/envLoader.test.js
Normal file
48
test/envLoader.test.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
69
test/llmParameterExtractor.test.js
Normal file
69
test/llmParameterExtractor.test.js
Normal file
@@ -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
|
||||
);
|
||||
});
|
||||
52
test/naturalLanguageFlightParser.test.js
Normal file
52
test/naturalLanguageFlightParser.test.js
Normal file
@@ -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: "<=" });
|
||||
});
|
||||
97
test/notifier.test.js
Normal file
97
test/notifier.test.js
Normal file
@@ -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/
|
||||
);
|
||||
});
|
||||
134
test/priceWatcher.test.js
Normal file
134
test/priceWatcher.test.js
Normal file
@@ -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");
|
||||
});
|
||||
105
test/priceWatcherControls.test.js
Normal file
105
test/priceWatcherControls.test.js
Normal file
@@ -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);
|
||||
});
|
||||
137
test/skyscannerSampleServer.test.js
Normal file
137
test/skyscannerSampleServer.test.js
Normal file
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user