chore: 현재 작업 중간 커밋
This commit is contained in:
15
Dockerfile
15
Dockerfile
@@ -1,4 +1,13 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-slim
|
||||||
|
|
||||||
|
# Chrome 실행에 필요한 시스템 라이브러리 + Google Chrome Stable 설치
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
wget gnupg ca-certificates fonts-noto-cjk \
|
||||||
|
&& wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
|
||||||
|
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
|
||||||
|
&& apt-get purge -y wget gnupg \
|
||||||
|
&& apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,7 +18,9 @@ COPY . .
|
|||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
DASHBOARD_HOST=0.0.0.0 \
|
DASHBOARD_HOST=0.0.0.0 \
|
||||||
DASHBOARD_PORT=3000
|
DASHBOARD_PORT=3000 \
|
||||||
|
# Chrome이 컨테이너 내에서 sandbox 없이 실행될 수 있도록
|
||||||
|
CHROME_NO_SANDBOX=true
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -166,7 +166,7 @@ npm run parse -- "11월 말부터 12월 초까지 출발하는 일정 여행 기
|
|||||||
LLM 기반 파라미터 가공 + 주기적 가격 추적 실행:
|
LLM 기반 파라미터 가공 + 주기적 가격 추적 실행:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --interval-sec 60 --target-price 1300000 --alert-on both
|
npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --interval-sec 3600 --target-price 1300000 --alert-on both
|
||||||
```
|
```
|
||||||
|
|
||||||
한 번만 조회(테스트용):
|
한 번만 조회(테스트용):
|
||||||
@@ -191,7 +191,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드,
|
|||||||
```
|
```
|
||||||
|
|
||||||
옵션:
|
옵션:
|
||||||
- `--interval-sec`: 폴링 주기(초)
|
- `--interval-sec`: 폴링 주기(초, 기본 `3600`, `3600` 미만 입력 시 즉시 에러)
|
||||||
- `--target-price`: 목표 가격 이하 도달 시 알림 기준
|
- `--target-price`: 목표 가격 이하 도달 시 알림 기준
|
||||||
- `--alert-on both|change|threshold`: 알림 조건 (가격 변동/임계값)
|
- `--alert-on both|change|threshold`: 알림 조건 (가격 변동/임계값)
|
||||||
- `--rule-only`: LLM 호출 없이 규칙 파서만 사용
|
- `--rule-only`: LLM 호출 없이 규칙 파서만 사용
|
||||||
@@ -199,6 +199,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드,
|
|||||||
환경 변수:
|
환경 변수:
|
||||||
- `OPENAI_API_KEY`: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다.
|
- `OPENAI_API_KEY`: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다.
|
||||||
- `OPENAI_MODEL` (선택): 기본값 `gpt-4.1-mini`
|
- `OPENAI_MODEL` (선택): 기본값 `gpt-4.1-mini`
|
||||||
|
- `LLM_REQUEST_TIMEOUT_MS` (선택): LLM HTTP 타임아웃(ms), 기본값 `20000`
|
||||||
- `CRAWLER_ENDPOINT` (선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용.
|
- `CRAWLER_ENDPOINT` (선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용.
|
||||||
- `CRAWLER_PROVIDERS` (선택): `skyscanner,naver,google` 형태 우선순위 목록. 미설정 시 단일 `CRAWLER_ENDPOINT` 또는 mock 사용.
|
- `CRAWLER_PROVIDERS` (선택): `skyscanner,naver,google` 형태 우선순위 목록. 미설정 시 단일 `CRAWLER_ENDPOINT` 또는 mock 사용.
|
||||||
- `CRAWLER_ENDPOINT_SKYSCANNER` (선택): Skyscanner 전용 엔드포인트
|
- `CRAWLER_ENDPOINT_SKYSCANNER` (선택): Skyscanner 전용 엔드포인트
|
||||||
@@ -224,6 +225,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드,
|
|||||||
```bash
|
```bash
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_MODEL=gpt-4.1-mini
|
OPENAI_MODEL=gpt-4.1-mini
|
||||||
|
LLM_REQUEST_TIMEOUT_MS=20000
|
||||||
|
|
||||||
CRAWLER_ENDPOINT=
|
CRAWLER_ENDPOINT=
|
||||||
CRAWLER_PROVIDERS=skyscanner,naver,google
|
CRAWLER_PROVIDERS=skyscanner,naver,google
|
||||||
@@ -293,12 +295,12 @@ npm run dashboard:fastify
|
|||||||
대시보드에서 가능한 작업:
|
대시보드에서 가능한 작업:
|
||||||
|
|
||||||
- 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인
|
- 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인
|
||||||
- 파싱된 조건으로 watch 생성 및 즉시 조회
|
- 파싱된 조건으로 watch 생성
|
||||||
- watch별 `크롤링 ON/OFF`, `알림 ON/OFF` 토글
|
- watch별 `크롤링 ON/OFF`, `알림 ON/OFF` 토글
|
||||||
- 전역 `전체 크롤링 ON/OFF`, `전체 알림 ON/OFF` 토글
|
- 전역 `전체 크롤링 ON/OFF`, `전체 알림 ON/OFF` 토글
|
||||||
- 최근 가격 이벤트(목표가 도달/가격 변동) 확인
|
- 최근 가격 이벤트(목표가 도달/가격 변동) 확인
|
||||||
|
|
||||||
### 7.1 MySQL 연동 (개인 DB 사용)
|
### 7.1 MySQL 연동 (외부 DB 사용)
|
||||||
|
|
||||||
환경변수 설정:
|
환경변수 설정:
|
||||||
|
|
||||||
@@ -308,26 +310,87 @@ MYSQL_HOST=127.0.0.1
|
|||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
MYSQL_USER=your_user
|
MYSQL_USER=your_user
|
||||||
MYSQL_PASSWORD=your_password
|
MYSQL_PASSWORD=your_password
|
||||||
MYSQL_DATABASE=airwatcher
|
MYSQL_DATABASE=your_database
|
||||||
```
|
```
|
||||||
|
|
||||||
또는 단일 URL 사용:
|
또는 단일 URL 사용:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DASHBOARD_DB=mysql
|
DASHBOARD_DB=mysql
|
||||||
MYSQL_URL=mysql://user:password@127.0.0.1:3306/airwatcher
|
MYSQL_URL=mysql://user:password@db.example.com:3306/your_database
|
||||||
```
|
```
|
||||||
|
|
||||||
추가 서버 환경변수:
|
추가 서버 환경변수:
|
||||||
|
|
||||||
- `DASHBOARD_HOST` (기본 `127.0.0.1`)
|
- `DASHBOARD_HOST` (기본 `127.0.0.1`)
|
||||||
- `DASHBOARD_PORT` (기본 `3000`)
|
- `DASHBOARD_PORT` (기본 `3000`)
|
||||||
- `DASHBOARD_POLL_INTERVAL_SEC` (기본 `60`)
|
- `DASHBOARD_POLL_INTERVAL_SEC` (기본 `3600`, `3600` 미만/비정수 입력 시 서버 시작 에러)
|
||||||
|
- `DASHBOARD_USERS` (선택, `username:password,username2:password2` 형식. 설정 시 로그인 페이지 + 계정별 세션 인증 활성화)
|
||||||
|
- `DASHBOARD_ADMIN_USERS` (선택, 전역 제어 권한 계정 목록. 미설정 시 `DASHBOARD_USERS` 첫 번째 계정을 admin으로 간주)
|
||||||
|
- `DASHBOARD_SESSION_TTL_SEC` (선택, 기본 `604800`초 = 7일)
|
||||||
|
- `DASHBOARD_REQUIRE_AUTH` (기본: `NODE_ENV=production`이면 `true`, 아니면 `false`)
|
||||||
|
- `DASHBOARD_API_TOKEN` (`DASHBOARD_REQUIRE_AUTH=true`일 때 필수, API `Authorization: Bearer <token>` 또는 `X-API-Key`로 전달)
|
||||||
|
- `DASHBOARD_ALLOW_MEMORY_FALLBACK` (기본 `false`, `true`일 때만 MySQL 초기화 실패/설정 누락 시 메모리 저장소 fallback 허용)
|
||||||
|
- `DASHBOARD_DB_SCHEMA` (선택, `auto|playground`, 기본 `playground`)
|
||||||
|
- `DASHBOARD_PROJECT_KEY` (선택, 기본 `air-watcher`, playground 스키마에서 프로젝트 네임스페이스 키)
|
||||||
|
- `LLM_REQUEST_TIMEOUT_MS` (기본 `20000`, LLM 파싱 요청 타임아웃)
|
||||||
|
|
||||||
참고:
|
참고:
|
||||||
- `DASHBOARD_DB=mysql`인데 MySQL 연결 정보가 없으면 서버가 에러를 반환한다.
|
- 애플리케이션은 외부 MySQL에 연결해 row CRUD만 수행한다.
|
||||||
|
- 애플리케이션은 DB/테이블 생성이나 마이그레이션을 수행하지 않는다.
|
||||||
|
- `DASHBOARD_DB=mysql`인데 MySQL 연결 정보가 없으면 서버가 시작 실패한다. (`DASHBOARD_ALLOW_MEMORY_FALLBACK=true`면 메모리 fallback 가능)
|
||||||
|
- MySQL 초기화 실패도 동일하게 기본은 시작 실패이며, fallback은 명시적으로 허용해야 한다.
|
||||||
- `DASHBOARD_DB`를 비우고 MySQL 환경변수도 없으면 메모리 저장소로 동작한다.
|
- `DASHBOARD_DB`를 비우고 MySQL 환경변수도 없으면 메모리 저장소로 동작한다.
|
||||||
- `CRAWLER_ENDPOINT` 미설정 시 mock 가격으로 동작하므로 실데이터 추적 시 실제 크롤러 API를 연결해야 한다.
|
- `CRAWLER_ENDPOINT` 미설정 시 mock 가격으로 동작하므로 실데이터 추적 시 실제 크롤러 API를 연결해야 한다.
|
||||||
|
- `DASHBOARD_DB_SCHEMA`는 `playground`(또는 `auto`)만 지원한다.
|
||||||
|
|
||||||
|
### 7.1.1 계정 분리 + 텔레그램 설정 페이지
|
||||||
|
|
||||||
|
- 로그인 페이지: `/login`
|
||||||
|
- 텔레그램 설정/가이드 페이지: `/setup`
|
||||||
|
- 계정별로 watch/event가 분리되어, 일반 사용자는 본인 watch만 조회/수정 가능
|
||||||
|
- 텔레그램은 계정별 `chatId`/`botToken`(선택, 서버 기본 토큰 fallback) 저장 가능
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DASHBOARD_USERS=alice:alice_pw,bob:bob_pw
|
||||||
|
DASHBOARD_ADMIN_USERS=alice
|
||||||
|
```
|
||||||
|
|
||||||
|
참고:
|
||||||
|
- `DASHBOARD_USERS`를 설정하면 브라우저 로그인 기반 인증이 우선 사용된다.
|
||||||
|
- 토큰 인증(`DASHBOARD_API_TOKEN`)은 기존 자동화/API 호출 용도로 병행 가능하다.
|
||||||
|
|
||||||
|
### 7.1.2 단일 Playground DB에 여러 소형 프로젝트 수용
|
||||||
|
|
||||||
|
소형 프로젝트를 여러 개 운영한다면 DB를 프로젝트별로 쪼개기보다, 하나의 DB(예: `playground`)에서 `project_key` 네임스페이스로 분리하는 방식이 실용적이다.
|
||||||
|
|
||||||
|
- 스키마 파일: `playground_schema.sql`
|
||||||
|
- 핵심 테이블:
|
||||||
|
- `projects`: 프로젝트 목록/메타
|
||||||
|
- `project_documents`: 프로젝트별 문서(JSON) 저장
|
||||||
|
- `project_events`: 프로젝트별 이벤트 로그
|
||||||
|
- `project_settings`: 프로젝트별 설정 key-value
|
||||||
|
|
||||||
|
적용 예시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p playground < playground_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Air-Watcher를 playground 스키마로 실행하려면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DASHBOARD_DB=mysql
|
||||||
|
DASHBOARD_DB_SCHEMA=playground
|
||||||
|
DASHBOARD_PROJECT_KEY=air-watcher
|
||||||
|
```
|
||||||
|
|
||||||
|
Air-Watcher 매핑 권장:
|
||||||
|
- watch 레코드: `project_documents` (`doc_type='watch'`, `doc_key=<watchId>`)
|
||||||
|
- 이벤트: `project_events` (`stream='watch_events'`)
|
||||||
|
- 전역/유저 설정: `project_settings` (`global_controls`, `user_profiles` 등)
|
||||||
|
|
||||||
### 7.2 Fastify 전환 뼈대
|
### 7.2 Fastify 전환 뼈대
|
||||||
|
|
||||||
@@ -341,7 +404,7 @@ MYSQL_URL=mysql://user:password@127.0.0.1:3306/airwatcher
|
|||||||
DASHBOARD_HOST=127.0.0.1 DASHBOARD_PORT=3000 npm run dashboard:fastify
|
DASHBOARD_HOST=127.0.0.1 DASHBOARD_PORT=3000 npm run dashboard:fastify
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.3 Docker / Compose
|
### 7.3 Docker / Compose (앱 단독)
|
||||||
|
|
||||||
Docker 이미지 빌드:
|
Docker 이미지 빌드:
|
||||||
|
|
||||||
@@ -349,16 +412,20 @@ Docker 이미지 빌드:
|
|||||||
docker build -t airwatcher .
|
docker build -t airwatcher .
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker Compose 기동 (`app + mysql`):
|
Docker Compose 기동 (`app` only):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build app
|
||||||
```
|
```
|
||||||
|
|
||||||
주요 기본값:
|
주요 기본값:
|
||||||
- 앱: `http://127.0.0.1:3000`
|
- 앱: `http://127.0.0.1:3000`
|
||||||
- DB 모드: `DASHBOARD_DB=mysql`
|
- DB 모드: `DASHBOARD_DB=mysql`
|
||||||
- MySQL 컨테이너: `127.0.0.1:3306` (기본 계정 `airwatcher/airwatcher`)
|
- MySQL은 외부 서버를 사용하며 Compose에서 DB 컨테이너를 띄우지 않는다.
|
||||||
|
- 인증: `DASHBOARD_REQUIRE_AUTH=true` (토큰 미설정 시 앱 시작 실패)
|
||||||
|
|
||||||
|
참고:
|
||||||
|
- `.env` 또는 쉘 환경변수로 `MYSQL_URL` 또는 `MYSQL_HOST`/`MYSQL_USER`/`MYSQL_DATABASE`를 전달해야 한다.
|
||||||
|
|
||||||
메모리 모드로 앱만 쓰려면:
|
메모리 모드로 앱만 쓰려면:
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,24 @@ services:
|
|||||||
DASHBOARD_HOST: 0.0.0.0
|
DASHBOARD_HOST: 0.0.0.0
|
||||||
DASHBOARD_PORT: 3000
|
DASHBOARD_PORT: 3000
|
||||||
DASHBOARD_DB: ${DASHBOARD_DB:-mysql}
|
DASHBOARD_DB: ${DASHBOARD_DB:-mysql}
|
||||||
DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-60}
|
DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-3600}
|
||||||
MYSQL_HOST: mysql
|
DASHBOARD_REQUIRE_AUTH: ${DASHBOARD_REQUIRE_AUTH:-true}
|
||||||
MYSQL_PORT: 3306
|
DASHBOARD_API_TOKEN: ${DASHBOARD_API_TOKEN:-}
|
||||||
MYSQL_USER: ${MYSQL_USER:-airwatcher}
|
DASHBOARD_USERS: ${DASHBOARD_USERS:-}
|
||||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher}
|
DASHBOARD_ADMIN_USERS: ${DASHBOARD_ADMIN_USERS:-}
|
||||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher}
|
DASHBOARD_SESSION_TTL_SEC: ${DASHBOARD_SESSION_TTL_SEC:-604800}
|
||||||
|
DASHBOARD_ALLOW_MEMORY_FALLBACK: ${DASHBOARD_ALLOW_MEMORY_FALLBACK:-false}
|
||||||
|
DASHBOARD_DB_SCHEMA: ${DASHBOARD_DB_SCHEMA:-playground}
|
||||||
|
DASHBOARD_PROJECT_KEY: ${DASHBOARD_PROJECT_KEY:-air-watcher}
|
||||||
|
MYSQL_URL: ${MYSQL_URL:-}
|
||||||
|
MYSQL_HOST: ${MYSQL_HOST:-}
|
||||||
|
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini}
|
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini}
|
||||||
|
LLM_REQUEST_TIMEOUT_MS: ${LLM_REQUEST_TIMEOUT_MS:-20000}
|
||||||
CRAWLER_ENDPOINT: ${CRAWLER_ENDPOINT:-}
|
CRAWLER_ENDPOINT: ${CRAWLER_ENDPOINT:-}
|
||||||
CRAWLER_PROVIDERS: ${CRAWLER_PROVIDERS:-}
|
CRAWLER_PROVIDERS: ${CRAWLER_PROVIDERS:-}
|
||||||
CRAWLER_ENDPOINT_SKYSCANNER: ${CRAWLER_ENDPOINT_SKYSCANNER:-}
|
CRAWLER_ENDPOINT_SKYSCANNER: ${CRAWLER_ENDPOINT_SKYSCANNER:-}
|
||||||
@@ -33,30 +43,3 @@ services:
|
|||||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
TELEGRAM_API_BASE: ${TELEGRAM_API_BASE:-https://api.telegram.org}
|
TELEGRAM_API_BASE: ${TELEGRAM_API_BASE:-https://api.telegram.org}
|
||||||
NOTIFY_WEBHOOK_URL: ${NOTIFY_WEBHOOK_URL:-}
|
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:
|
|
||||||
|
|||||||
1107
package-lock.json
generated
Normal file
1107
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,21 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "concurrently \"npm run crawler\" \"npm run dashboard\"",
|
||||||
"parse": "node src/cli.js",
|
"parse": "node src/cli.js",
|
||||||
"watch": "node src/cli.js watch",
|
"watch": "node src/cli.js watch",
|
||||||
"dashboard": "node src/dashboardServer.js",
|
"dashboard": "node src/dashboardServer.js",
|
||||||
"dashboard:fastify": "node src/fastifyDashboardServer.js",
|
"dashboard:fastify": "node src/fastifyDashboardServer.js",
|
||||||
"sample:skyscanner": "node src/skyscannerSampleServer.js",
|
"sample:skyscanner": "node src/skyscannerSampleServer.js",
|
||||||
|
"crawler": "node src/crawlerServer.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.2.2",
|
"fastify": "^5.2.2",
|
||||||
"mysql2": "^3.15.2"
|
"mysql2": "^3.15.2",
|
||||||
|
"patchright": "^1.57.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
playground_schema.sql
Normal file
93
playground_schema.sql
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
-- Multi-project playground schema (MySQL)
|
||||||
|
-- Purpose: keep many small projects in one database using project_key namespace.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
project_key VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
meta_json LONGTEXT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (project_key),
|
||||||
|
KEY idx_projects_updated_at (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_documents (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
project_key VARCHAR(100) NOT NULL,
|
||||||
|
doc_type VARCHAR(100) NOT NULL,
|
||||||
|
doc_key VARCHAR(191) NOT NULL,
|
||||||
|
data_json LONGTEXT NOT NULL,
|
||||||
|
meta_json LONGTEXT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_project_documents_namespace (project_key, doc_type, doc_key),
|
||||||
|
KEY idx_project_documents_project_type_updated (project_key, doc_type, updated_at),
|
||||||
|
KEY idx_project_documents_project_updated (project_key, updated_at),
|
||||||
|
CONSTRAINT fk_project_documents_project_key
|
||||||
|
FOREIGN KEY (project_key) REFERENCES projects(project_key)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
project_key VARCHAR(100) NOT NULL,
|
||||||
|
stream VARCHAR(100) NOT NULL,
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
payload_json LONGTEXT NOT NULL,
|
||||||
|
observed_at DATETIME(3) NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_project_events_project_stream_created (project_key, stream, created_at),
|
||||||
|
KEY idx_project_events_project_created (project_key, created_at),
|
||||||
|
CONSTRAINT fk_project_events_project_key
|
||||||
|
FOREIGN KEY (project_key) REFERENCES projects(project_key)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_settings (
|
||||||
|
project_key VARCHAR(100) NOT NULL,
|
||||||
|
setting_key VARCHAR(191) NOT NULL,
|
||||||
|
setting_value LONGTEXT NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (project_key, setting_key),
|
||||||
|
KEY idx_project_settings_updated_at (updated_at),
|
||||||
|
CONSTRAINT fk_project_settings_project_key
|
||||||
|
FOREIGN KEY (project_key) REFERENCES projects(project_key)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Seed one project namespace for this repository (optional)
|
||||||
|
INSERT INTO projects (
|
||||||
|
project_key,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
meta_json,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'air-watcher',
|
||||||
|
'Air Watcher',
|
||||||
|
'Flight price watcher dashboard and alerts',
|
||||||
|
JSON_OBJECT('owner', 'team', 'status', 'active'),
|
||||||
|
UTC_TIMESTAMP(3),
|
||||||
|
UTC_TIMESTAMP(3)
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
meta_json = VALUES(meta_json),
|
||||||
|
updated_at = VALUES(updated_at);
|
||||||
|
|
||||||
|
-- Mapping guide for Air-Watcher:
|
||||||
|
-- 1) watch row -> project_documents (doc_type='watch', doc_key=watch_id)
|
||||||
|
-- 2) app settings -> project_settings (setting_key='global_controls' etc.)
|
||||||
|
-- 3) user profiles -> project_settings (setting_key='user_profiles')
|
||||||
|
-- 4) watch alert events -> project_events (stream='watch_events')
|
||||||
84
src/apiAuth.js
Normal file
84
src/apiAuth.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const { parseBoolean } = require("./dashboardUtils");
|
||||||
|
|
||||||
|
function normalizeToken(value) {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeaderValue(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const first = value.find((item) => typeof item === "string");
|
||||||
|
return typeof first === "string" ? first : "";
|
||||||
|
}
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBearerToken(authorizationHeader) {
|
||||||
|
const normalized = readHeaderValue(authorizationHeader).trim();
|
||||||
|
if (!normalized) return "";
|
||||||
|
|
||||||
|
const matched = normalized.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!matched) return "";
|
||||||
|
|
||||||
|
return normalizeToken(matched[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function secureEqual(left, right) {
|
||||||
|
const leftBuffer = Buffer.from(left);
|
||||||
|
const rightBuffer = Buffer.from(right);
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||||
|
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiAuth(options = {}) {
|
||||||
|
const nodeEnvRaw = options.nodeEnv !== undefined ? options.nodeEnv : process.env.NODE_ENV;
|
||||||
|
const nodeEnv = typeof nodeEnvRaw === "string" ? nodeEnvRaw.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
const requireAuthRaw =
|
||||||
|
options.requireAuth !== undefined ? options.requireAuth : process.env.DASHBOARD_REQUIRE_AUTH;
|
||||||
|
const enabled = parseBoolean(requireAuthRaw, nodeEnv === "production");
|
||||||
|
|
||||||
|
const tokenRaw =
|
||||||
|
options.apiToken !== undefined ? options.apiToken : process.env.DASHBOARD_API_TOKEN;
|
||||||
|
const token = normalizeToken(typeof tokenRaw === "string" ? tokenRaw : "");
|
||||||
|
|
||||||
|
if (enabled && !token) {
|
||||||
|
throw new Error("DASHBOARD_API_TOKEN must be set when dashboard auth is enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorizedRequest(headers, authConfig) {
|
||||||
|
if (!authConfig || authConfig.enabled !== true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedToken = normalizeToken(authConfig.token);
|
||||||
|
if (!expectedToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerToken = parseBearerToken(headers?.authorization);
|
||||||
|
if (bearerToken && secureEqual(bearerToken, expectedToken)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = normalizeToken(readHeaderValue(headers?.["x-api-key"]));
|
||||||
|
if (apiKey && secureEqual(apiKey, expectedToken)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isAuthorizedRequest,
|
||||||
|
resolveApiAuth,
|
||||||
|
};
|
||||||
20
src/cli.js
20
src/cli.js
@@ -8,6 +8,7 @@ const { loadDotEnv } = require("./envLoader");
|
|||||||
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
|
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
|
||||||
const { createCrawlerClient } = require("./crawlerClient");
|
const { createCrawlerClient } = require("./crawlerClient");
|
||||||
const { createNotifier } = require("./notifier");
|
const { createNotifier } = require("./notifier");
|
||||||
|
const { MIN_CRAWL_INTERVAL_SEC } = require("./pollingConfig");
|
||||||
const { PriceWatcher } = require("./priceWatcher");
|
const { PriceWatcher } = require("./priceWatcher");
|
||||||
|
|
||||||
loadDotEnv();
|
loadDotEnv();
|
||||||
@@ -36,7 +37,7 @@ function parseNumberValue(value, flagName) {
|
|||||||
|
|
||||||
function parseWatchOptions(tokens) {
|
function parseWatchOptions(tokens) {
|
||||||
const options = {
|
const options = {
|
||||||
intervalSec: 60,
|
intervalSec: MIN_CRAWL_INTERVAL_SEC,
|
||||||
targetPrice: null,
|
targetPrice: null,
|
||||||
alertOn: "both",
|
alertOn: "both",
|
||||||
useLlm: true,
|
useLlm: true,
|
||||||
@@ -93,8 +94,10 @@ function parseWatchOptions(tokens) {
|
|||||||
throw new Error(`알 수 없는 옵션: ${token}`);
|
throw new Error(`알 수 없는 옵션: ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(options.intervalSec) || options.intervalSec <= 0) {
|
if (!Number.isInteger(options.intervalSec) || options.intervalSec < MIN_CRAWL_INTERVAL_SEC) {
|
||||||
throw new Error("--interval-sec 값은 1 이상의 정수여야 합니다.");
|
throw new Error(
|
||||||
|
`--interval-sec 값은 ${MIN_CRAWL_INTERVAL_SEC} 이상의 정수여야 합니다.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -129,6 +132,17 @@ async function runWatch(tokens) {
|
|||||||
preferRuleParser: !options.useLlm,
|
preferRuleParser: !options.useLlm,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const missing = extracted.params.missingFields || [];
|
||||||
|
if (missing.includes("segments")) {
|
||||||
|
throw new Error("출발지 또는 도착지 정보가 파악되지 않았습니다. 문장을 다시 작성해주세요.");
|
||||||
|
}
|
||||||
|
if (missing.includes("departureDateWindow")) {
|
||||||
|
throw new Error("출발 날짜 정보가 파악되지 않았습니다. (예: '11월 25일에 출발') 문장을 구체적으로 적어주세요.");
|
||||||
|
}
|
||||||
|
if (extracted.params.tripType === "round_trip" && missing.includes("stayDurationDays")) {
|
||||||
|
throw new Error("왕복 여정입니다만, 체류 기간(며칠 동안 여행하는지)이 파악되지 않았습니다. (예: '12일 체류')");
|
||||||
|
}
|
||||||
|
|
||||||
const watcher = new PriceWatcher({
|
const watcher = new PriceWatcher({
|
||||||
crawler: createCrawlerClient(),
|
crawler: createCrawlerClient(),
|
||||||
notifier: createNotifier(),
|
notifier: createNotifier(),
|
||||||
|
|||||||
@@ -359,6 +359,20 @@ function createMultiSourceCrawler(options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
async getQuotes(request) {
|
async getQuotes(request) {
|
||||||
|
const explicitProvider = normalizeProviderName(request.searchParams?.provider);
|
||||||
|
if (explicitProvider) {
|
||||||
|
const target = sources.find((s) => s.provider === explicitProvider);
|
||||||
|
if (target) {
|
||||||
|
try {
|
||||||
|
return await target.crawler.getQuotes(request);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Explicit provider failed (${target.provider}): ${error.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Requested provider not available: ${explicitProvider}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (routingStrategy === "primaryOnly") {
|
if (routingStrategy === "primaryOnly") {
|
||||||
const primary = sources[0];
|
const primary = sources[0];
|
||||||
try {
|
try {
|
||||||
|
|||||||
64
src/crawlerServer.js
Normal file
64
src/crawlerServer.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const Fastify = require("fastify");
|
||||||
|
// const { scrapeSkyscanner } = require("./crawlers/skyscanner");
|
||||||
|
// const { scrapeNaver } = require("./crawlers/naver");
|
||||||
|
const { scrapeGoogle } = require("./crawlers/google");
|
||||||
|
|
||||||
|
async function startCrawlerServer() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = process.env.CRAWLER_SERVER_PORT || 8787;
|
||||||
|
const host = process.env.CRAWLER_SERVER_HOST || "127.0.0.1";
|
||||||
|
|
||||||
|
// app.post("/skyscanner", async (request, reply) => {
|
||||||
|
// const { searchParams } = request.body || {};
|
||||||
|
// try {
|
||||||
|
// const offers = await scrapeSkyscanner(searchParams);
|
||||||
|
// return { currency: "KRW", offers };
|
||||||
|
// } catch (err) {
|
||||||
|
// app.log.error(err);
|
||||||
|
// reply.code(500).send({ error: err.message });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// app.post("/naver", async (request, reply) => {
|
||||||
|
// const { searchParams } = request.body || {};
|
||||||
|
// try {
|
||||||
|
// const offers = await scrapeNaver(searchParams);
|
||||||
|
// return { currency: "KRW", offers };
|
||||||
|
// } catch (err) {
|
||||||
|
// app.log.error(err);
|
||||||
|
// reply.code(500).send({ error: err.message });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
app.post("/google", async (request, reply) => {
|
||||||
|
const { searchParams } = request.body || {};
|
||||||
|
try {
|
||||||
|
const offers = await scrapeGoogle(searchParams);
|
||||||
|
return { currency: "KRW", offers };
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ host, port });
|
||||||
|
console.log(`[Crawler Server] listening on http://${host}:${port}`);
|
||||||
|
console.log(`Available endpoints: /google`); // skyscanner, naver 일시 제외
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
startCrawlerServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startCrawlerServer };
|
||||||
426
src/crawlers/baseCrawler.js
Normal file
426
src/crawlers/baseCrawler.js
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { chromium } = require("patchright");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
|
||||||
|
const PROFILE_DIR = path.join(os.homedir(), ".air-watcher", "chrome-profile");
|
||||||
|
const DEBUG_DIR = path.join(__dirname, "..", "..", "debug");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Browser singleton — one Chrome instance shared across all requests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let _browserContext = null;
|
||||||
|
let _browserLaunchPromise = null;
|
||||||
|
|
||||||
|
async function getBrowserContext() {
|
||||||
|
if (_browserContext) return _browserContext;
|
||||||
|
if (_browserLaunchPromise) return _browserLaunchPromise;
|
||||||
|
|
||||||
|
_browserLaunchPromise = (async () => {
|
||||||
|
// Default to headed mode (less detectable); set CRAWLER_HEADLESS=true to override
|
||||||
|
const isHeadless = process.env.CRAWLER_HEADLESS === "true";
|
||||||
|
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const launchOpts = {
|
||||||
|
headless: isHeadless,
|
||||||
|
viewport: null, // natural window size — fixed viewport is a bot fingerprint
|
||||||
|
locale: "ko-KR",
|
||||||
|
timezoneId: "Asia/Seoul",
|
||||||
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--lang=ko-KR,ko",
|
||||||
|
],
|
||||||
|
// Patchright handles automation flag removal internally
|
||||||
|
};
|
||||||
|
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
||||||
|
channel: "chrome",
|
||||||
|
...launchOpts,
|
||||||
|
});
|
||||||
|
console.log("[Crawler] Browser started: real Chrome (persistent context)");
|
||||||
|
} catch (_) {
|
||||||
|
console.log("[Crawler] Chrome not found, falling back to Chromium");
|
||||||
|
context = await chromium.launchPersistentContext(PROFILE_DIR, launchOpts);
|
||||||
|
console.log("[Crawler] Browser started: Chromium (persistent context)");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.on("close", () => {
|
||||||
|
_browserContext = null;
|
||||||
|
_browserLaunchPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
_browserContext = context;
|
||||||
|
_browserLaunchPromise = null;
|
||||||
|
return context;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return _browserLaunchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeBrowser() {
|
||||||
|
if (_browserContext) {
|
||||||
|
try { await _browserContext.close(); } catch (_) {}
|
||||||
|
_browserContext = null;
|
||||||
|
_browserLaunchPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.on(sig, async () => {
|
||||||
|
await closeBrowser();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function randomDelay(min = 500, max = 2000) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function humanScroll(page) {
|
||||||
|
const scrollAmount = Math.floor(Math.random() * 400) + 200;
|
||||||
|
await page.mouse.wheel(0, scrollAmount);
|
||||||
|
await page.waitForTimeout(randomDelay(300, 800));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses common cookie consent banners.
|
||||||
|
*/
|
||||||
|
async function dismissCookieConsent(page) {
|
||||||
|
const cssSelectors = [
|
||||||
|
'button[aria-label*="동의"]',
|
||||||
|
'button[aria-label*="Accept"]',
|
||||||
|
'form[action*="consent"] button',
|
||||||
|
'[data-consent="accept"]',
|
||||||
|
'#acceptCookieButton',
|
||||||
|
'button[data-testid="accept-cookie-policy"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sel of cssSelectors) {
|
||||||
|
try {
|
||||||
|
const btn = await page.$(sel);
|
||||||
|
if (btn) {
|
||||||
|
await btn.click();
|
||||||
|
console.log(`[Crawler] Dismissed cookie consent: ${sel}`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textLabels = ["동의", "수락", "Accept all", "Accept", "모두 동의"];
|
||||||
|
for (const label of textLabels) {
|
||||||
|
try {
|
||||||
|
const loc = page.locator("button", { hasText: label });
|
||||||
|
if (await loc.count() > 0) {
|
||||||
|
await loc.first().click();
|
||||||
|
console.log(`[Crawler] Dismissed cookie consent: button with "${label}"`);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Debug: save HTML + screenshot + all frame HTML
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function saveDebugDump(page, provider) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const prefix = path.join(DEBUG_DIR, `${provider}-${timestamp}`);
|
||||||
|
|
||||||
|
// Screenshot
|
||||||
|
await page.screenshot({ path: `${prefix}.png`, fullPage: true });
|
||||||
|
console.log(`[Debug] Screenshot: ${prefix}.png`);
|
||||||
|
|
||||||
|
// Main page HTML
|
||||||
|
const mainHtml = await page.content();
|
||||||
|
fs.writeFileSync(`${prefix}-main.html`, mainHtml, "utf8");
|
||||||
|
console.log(`[Debug] Main HTML: ${prefix}-main.html`);
|
||||||
|
|
||||||
|
// All frame URLs + HTML
|
||||||
|
const frames = page.frames();
|
||||||
|
for (let i = 0; i < frames.length; i++) {
|
||||||
|
const frame = frames[i];
|
||||||
|
if (frame === page.mainFrame()) continue;
|
||||||
|
try {
|
||||||
|
const frameHtml = await frame.content();
|
||||||
|
const framePath = `${prefix}-frame${i}.html`;
|
||||||
|
fs.writeFileSync(framePath, frameHtml, "utf8");
|
||||||
|
console.log(`[Debug] Frame ${i} (${frame.url()}): ${framePath}`);
|
||||||
|
} catch (_) {
|
||||||
|
console.log(`[Debug] Frame ${i} (${frame.url()}): could not read`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Debug] Failed to save debug dump:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bot challenge: PerimeterX PRESS & HOLD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHALLENGE_INDICATORS = [
|
||||||
|
"PRESS & HOLD", "press & hold", "Press & Hold",
|
||||||
|
"person or a robot", "사람인지 로봇인지",
|
||||||
|
"길게 누르기", "누르고 유지",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects PerimeterX / Skyscanner bot challenge page.
|
||||||
|
*/
|
||||||
|
async function isChallengePage(page) {
|
||||||
|
// Fast check: PerimeterX captcha element exists?
|
||||||
|
const hasPxCaptcha = await page.$("#px-captcha");
|
||||||
|
if (hasPxCaptcha) return true;
|
||||||
|
|
||||||
|
// Fallback: check visible text
|
||||||
|
const bodyText = await page.evaluate(() => document.body.innerText || "");
|
||||||
|
return CHALLENGE_INDICATORS.some((t) => bodyText.includes(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solves PerimeterX "PRESS & HOLD" challenge.
|
||||||
|
*
|
||||||
|
* PerimeterX structure (from HTML dump):
|
||||||
|
* <div id="px-captcha" style="display: block; min-width: 310px;">
|
||||||
|
* <iframe style="display: none;" ...></iframe>
|
||||||
|
* <!-- captcha.js async-injects the PRESS & HOLD button here -->
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* The captcha.js script loads async and renders the button inside #px-captcha.
|
||||||
|
* We must wait for it to render before interacting.
|
||||||
|
*/
|
||||||
|
async function handlePressAndHold(page, attempt = 1) {
|
||||||
|
if (attempt > 3) {
|
||||||
|
console.log("[Crawler] PRESS & HOLD: max retries reached");
|
||||||
|
await saveDebugDump(page, "challenge-failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isChallengePage(page))) return false;
|
||||||
|
|
||||||
|
console.log(`[Crawler] Detected PerimeterX challenge (attempt ${attempt})...`);
|
||||||
|
|
||||||
|
// Wait for captcha.js to render the button inside #px-captcha
|
||||||
|
// The script loads async, so the button may not exist yet
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const el = document.getElementById("px-captcha");
|
||||||
|
if (!el) return false;
|
||||||
|
// Wait until the captcha div has actual rendered content (height > 50px)
|
||||||
|
return el.offsetHeight > 50;
|
||||||
|
},
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
console.log("[Crawler] PerimeterX captcha rendered");
|
||||||
|
} catch (_) {
|
||||||
|
console.log("[Crawler] Timeout waiting for captcha render, trying anyway...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small extra delay for any final rendering
|
||||||
|
await page.waitForTimeout(randomDelay(500, 1500));
|
||||||
|
|
||||||
|
// Strategy 1: Use #px-captcha element directly (most reliable)
|
||||||
|
let btnPos = null;
|
||||||
|
const pxCaptcha = await page.$("#px-captcha");
|
||||||
|
if (pxCaptcha) {
|
||||||
|
const box = await pxCaptcha.boundingBox();
|
||||||
|
if (box && box.width > 30 && box.height > 30) {
|
||||||
|
btnPos = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
console.log(`[Crawler] Using #px-captcha center: (${Math.round(btnPos.x)}, ${Math.round(btnPos.y)}), size: ${Math.round(box.width)}x${Math.round(box.height)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Search DOM for any element containing challenge text
|
||||||
|
if (!btnPos) {
|
||||||
|
btnPos = await page.evaluate(() => {
|
||||||
|
const variants = ["PRESS & HOLD", "길게 누르기", "누르고 유지"];
|
||||||
|
const all = document.querySelectorAll("*");
|
||||||
|
for (const el of all) {
|
||||||
|
const text = (el.innerText || el.textContent || "").trim();
|
||||||
|
const match = variants.some((v) => text.toUpperCase().includes(v.toUpperCase()));
|
||||||
|
if (!match) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width > 50 && rect.width < 500 && rect.height > 25 && rect.height < 200) {
|
||||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (btnPos) {
|
||||||
|
console.log(`[Crawler] Found button by text at (${Math.round(btnPos.x)}, ${Math.round(btnPos.y)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btnPos) {
|
||||||
|
console.log("[Crawler] Could not locate captcha button, saving debug dump...");
|
||||||
|
await saveDebugDump(page, "challenge");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-like mouse movement to button
|
||||||
|
const jitterX = (Math.random() - 0.5) * 6;
|
||||||
|
const jitterY = (Math.random() - 0.5) * 6;
|
||||||
|
await page.mouse.move(btnPos.x + jitterX, btnPos.y + jitterY, {
|
||||||
|
steps: 15 + Math.floor(Math.random() * 10),
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(randomDelay(300, 700));
|
||||||
|
|
||||||
|
// Press and hold for 5-8 seconds (PerimeterX needs longer holds)
|
||||||
|
await page.mouse.down();
|
||||||
|
console.log("[Crawler] Holding button...");
|
||||||
|
const holdTime = randomDelay(5000, 8000);
|
||||||
|
await page.waitForTimeout(holdTime);
|
||||||
|
await page.mouse.up();
|
||||||
|
console.log(`[Crawler] Released after ${holdTime}ms`);
|
||||||
|
|
||||||
|
// Wait for page reaction (navigation or DOM change)
|
||||||
|
try {
|
||||||
|
await page.waitForNavigation({ timeout: 15000, waitUntil: "domcontentloaded" });
|
||||||
|
} catch (_) {}
|
||||||
|
await page.waitForTimeout(randomDelay(2000, 4000));
|
||||||
|
|
||||||
|
// Check if challenge is gone
|
||||||
|
if (!(await isChallengePage(page))) {
|
||||||
|
console.log("[Crawler] Bot challenge solved!");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Crawler] Challenge still present, retrying...");
|
||||||
|
return handlePressAndHold(page, attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function navigateWithHumanBehavior(page, url, opts = {}) {
|
||||||
|
const { waitUntil = "domcontentloaded", timeout = 60000 } = opts;
|
||||||
|
await page.goto(url, { waitUntil, timeout });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 30000 });
|
||||||
|
} catch (_) {
|
||||||
|
console.log("[Crawler] networkidle timeout, continuing...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle bot challenges
|
||||||
|
const challengeSolved = await handlePressAndHold(page);
|
||||||
|
if (challengeSolved) {
|
||||||
|
try {
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 30000 });
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dismissCookieConsent(page);
|
||||||
|
await page.waitForTimeout(randomDelay(1500, 3000));
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await humanScroll(page);
|
||||||
|
await page.waitForTimeout(randomDelay(500, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Price extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractPricesFromText(text, minPrice) {
|
||||||
|
const prices = [];
|
||||||
|
const patterns = [/[\d,]+\s*원/g, /₩\s*[\d,]+/g, /KRW\s*[\d,]+/g];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const num = parseInt(match[0].replace(/[^0-9]/g, ""), 10);
|
||||||
|
if (!isNaN(num) && num >= minPrice) prices.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractPricesFromPage(page, opts = {}) {
|
||||||
|
const { minPrice = 10000 } = opts;
|
||||||
|
|
||||||
|
// Extract from main frame
|
||||||
|
const mainPrices = await page.evaluate((minP) => {
|
||||||
|
const text = document.body.innerText || "";
|
||||||
|
const prices = [];
|
||||||
|
const patterns = [/[\d,]+\s*원/g, /₩\s*[\d,]+/g, /KRW\s*[\d,]+/g];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const num = parseInt(match[0].replace(/[^0-9]/g, ""), 10);
|
||||||
|
if (!isNaN(num) && num >= minP) prices.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prices;
|
||||||
|
}, minPrice);
|
||||||
|
|
||||||
|
// Also extract from child frames (Google Flights uses iframes)
|
||||||
|
const framePrices = [];
|
||||||
|
for (const frame of page.frames()) {
|
||||||
|
if (frame === page.mainFrame()) continue;
|
||||||
|
try {
|
||||||
|
const fp = await frame.evaluate((minP) => {
|
||||||
|
const text = document.body?.innerText || "";
|
||||||
|
const prices = [];
|
||||||
|
const patterns = [/[\d,]+\s*원/g, /₩\s*[\d,]+/g, /KRW\s*[\d,]+/g];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const num = parseInt(match[0].replace(/[^0-9]/g, ""), 10);
|
||||||
|
if (!isNaN(num) && num >= minP) prices.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prices;
|
||||||
|
}, minPrice);
|
||||||
|
framePrices.push(...fp);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = [...mainPrices, ...framePrices];
|
||||||
|
return [...new Set(all)].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// withBrowser — shared browser, new tab per request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function withBrowser(task) {
|
||||||
|
const context = await getBrowserContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
try {
|
||||||
|
return await task(page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Crawler Error]", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
withBrowser,
|
||||||
|
closeBrowser,
|
||||||
|
randomDelay,
|
||||||
|
humanScroll,
|
||||||
|
navigateWithHumanBehavior,
|
||||||
|
extractPricesFromPage,
|
||||||
|
dismissCookieConsent,
|
||||||
|
saveDebugDump,
|
||||||
|
};
|
||||||
792
src/crawlers/google.js
Normal file
792
src/crawlers/google.js
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { withBrowser, navigateWithHumanBehavior, extractPricesFromPage, saveDebugDump, randomDelay } = require("./baseCrawler");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Known airline names for Korean-language Google Flights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KNOWN_AIRLINES = [
|
||||||
|
"대한항공", "아시아나항공", "제주항공", "진에어", "티웨이항공",
|
||||||
|
"에어부산", "에어서울", "에어프레미아", "이스타항공",
|
||||||
|
"일본항공", "전일본공수", "중국국제항공", "중국남방항공", "중국동방항공",
|
||||||
|
"캐세이퍼시픽항공", "싱가포르항공", "타이항공", "베트남항공",
|
||||||
|
"에미레이트항공", "카타르항공", "터키항공", "에티하드항공",
|
||||||
|
"루프트한자", "에어프랑스", "KLM", "브리티시 에어웨이즈", "이베리아항공",
|
||||||
|
"핀에어", "스칸디나비아항공", "SAS항공", "LOT 폴란드항공",
|
||||||
|
"스위스국제항공", "오스트리아항공", "TAP 포르투갈",
|
||||||
|
"유나이티드항공", "델타항공", "아메리칸항공", "에어캐나다",
|
||||||
|
"콴타스", "뉴질랜드항공", "에어뉴질랜드",
|
||||||
|
"피치항공", "스쿠트", "에어아시아", "세부퍼시픽",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structured flight card extraction from Google Flights DOM
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts structured flight data from Google Flights result cards.
|
||||||
|
*
|
||||||
|
* Tries DOM selectors first, falls back to innerText regex parsing.
|
||||||
|
* Returns an array of structured flight objects or null if extraction fails.
|
||||||
|
*/
|
||||||
|
async function extractFlightCardsFromPage(page, { minPrice = 10000 } = {}) {
|
||||||
|
const results = await page.evaluate(({ knownAirlines, minP }) => {
|
||||||
|
// --- Helper: parse price from text ---
|
||||||
|
function parsePrice(text) {
|
||||||
|
const patterns = [/₩\s*([\d,]+)/, /([\d,]+)\s*원/];
|
||||||
|
for (const p of patterns) {
|
||||||
|
const m = text.match(p);
|
||||||
|
if (m) {
|
||||||
|
const num = parseInt(m[1].replace(/,/g, ""), 10);
|
||||||
|
if (!isNaN(num) && num >= minP) return num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: parse duration string to minutes ---
|
||||||
|
function parseDurationMinutes(text) {
|
||||||
|
const m = text.match(/(\d+)\s*시간\s*(?:(\d+)\s*분)?/);
|
||||||
|
if (!m) return null;
|
||||||
|
return parseInt(m[1], 10) * 60 + (m[2] ? parseInt(m[2], 10) : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: find all airlines in text ---
|
||||||
|
function findAirlines(text) {
|
||||||
|
const found = [];
|
||||||
|
for (const name of knownAirlines) {
|
||||||
|
// Find all occurrences with position
|
||||||
|
let idx = text.indexOf(name);
|
||||||
|
while (idx !== -1) {
|
||||||
|
found.push({ name, pos: idx });
|
||||||
|
idx = text.indexOf(name, idx + name.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: generic pattern
|
||||||
|
if (found.length === 0) {
|
||||||
|
const re = /[\uAC00-\uD7A3A-Za-z]+(?:항공|에어(?:라인)?)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
found.push({ name: m[0], pos: m.index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deduplicate by position, sort by position
|
||||||
|
found.sort((a, b) => a.pos - b.pos);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: extract fields from card innerText (multi-leg) ---
|
||||||
|
function parseCardText(text) {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
// Price
|
||||||
|
result.price = parsePrice(text);
|
||||||
|
|
||||||
|
// Find all time pairs: 오전/오후 HH:MM – 오전/오후 HH:MM+N
|
||||||
|
const timePattern = /(오[전후])\s*(\d{1,2}:\d{2})\s*[–\-~]\s*(오[전후])\s*(\d{1,2}:\d{2})(\+\d+)?/g;
|
||||||
|
const timePairs = [];
|
||||||
|
let tm;
|
||||||
|
while ((tm = timePattern.exec(text)) !== null) {
|
||||||
|
timePairs.push({
|
||||||
|
departureTime: `${tm[1]} ${tm[2]}`,
|
||||||
|
arrivalTime: `${tm[3]} ${tm[4]}${tm[5] || ""}`,
|
||||||
|
pos: tm.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all durations
|
||||||
|
const durationPattern = /(\d+시간(?:\s*\d+분)?)/g;
|
||||||
|
const durations = [];
|
||||||
|
let dm;
|
||||||
|
while ((dm = durationPattern.exec(text)) !== null) {
|
||||||
|
durations.push({
|
||||||
|
duration: dm[1],
|
||||||
|
durationMinutes: parseDurationMinutes(dm[1]),
|
||||||
|
pos: dm.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all stops markers
|
||||||
|
const stopsMarkers = [];
|
||||||
|
const directPattern = /직항/g;
|
||||||
|
let sm;
|
||||||
|
while ((sm = directPattern.exec(text)) !== null) {
|
||||||
|
stopsMarkers.push({ stops: 0, stopsText: "직항", pos: sm.index });
|
||||||
|
}
|
||||||
|
const stopsPattern = /경유\s*(\d+)\s*회/g;
|
||||||
|
while ((sm = stopsPattern.exec(text)) !== null) {
|
||||||
|
stopsMarkers.push({ stops: parseInt(sm[1], 10), stopsText: sm[0], pos: sm.index });
|
||||||
|
}
|
||||||
|
stopsMarkers.sort((a, b) => a.pos - b.pos);
|
||||||
|
|
||||||
|
// Find all routes: XXX–YYY
|
||||||
|
const routePattern = /\b([A-Z]{3})\s*[–\-]\s*([A-Z]{3})\b/g;
|
||||||
|
const routes = [];
|
||||||
|
let rm;
|
||||||
|
while ((rm = routePattern.exec(text)) !== null) {
|
||||||
|
routes.push({ route: `${rm[1]}–${rm[2]}`, pos: rm.index });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all layovers: "N시간 M분 XXX"
|
||||||
|
const layoverPattern = /(\d+시간(?:\s*\d+분)?)\s+([A-Z]{3})/g;
|
||||||
|
const allLayovers = [];
|
||||||
|
let lm;
|
||||||
|
while ((lm = layoverPattern.exec(text)) !== null) {
|
||||||
|
allLayovers.push({
|
||||||
|
duration: lm[1],
|
||||||
|
durationMinutes: parseDurationMinutes(lm[1]),
|
||||||
|
airportCode: lm[2],
|
||||||
|
pos: lm.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find airlines with positions
|
||||||
|
const airlines = findAirlines(text);
|
||||||
|
|
||||||
|
// Determine number of legs from time pairs (most reliable signal)
|
||||||
|
const legCount = Math.max(timePairs.length, routes.length, 1);
|
||||||
|
|
||||||
|
if (legCount <= 1) {
|
||||||
|
// Single leg — flat structure (backward compatible)
|
||||||
|
if (airlines.length > 0) result.airline = airlines[0].name;
|
||||||
|
if (timePairs.length > 0) {
|
||||||
|
result.departureTime = timePairs[0].departureTime;
|
||||||
|
result.arrivalTime = timePairs[0].arrivalTime;
|
||||||
|
}
|
||||||
|
if (durations.length > 0) {
|
||||||
|
result.duration = durations[0].duration;
|
||||||
|
result.durationMinutes = durations[0].durationMinutes;
|
||||||
|
}
|
||||||
|
if (stopsMarkers.length > 0) {
|
||||||
|
result.stops = stopsMarkers[0].stops;
|
||||||
|
result.stopsText = stopsMarkers[0].stopsText;
|
||||||
|
}
|
||||||
|
if (routes.length > 0) result.route = routes[0].route;
|
||||||
|
|
||||||
|
// Layovers: filter out main duration
|
||||||
|
const legLayovers = allLayovers.filter(l => !result.duration || l.duration !== result.duration);
|
||||||
|
if (legLayovers.length > 0) result.layovers = legLayovers;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-leg — build legs array
|
||||||
|
// Assign each extracted item to the nearest leg by position
|
||||||
|
function assignToLeg(items, legAnchors) {
|
||||||
|
const assigned = legAnchors.map(() => []);
|
||||||
|
for (const item of items) {
|
||||||
|
let bestLeg = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (let i = 0; i < legAnchors.length; i++) {
|
||||||
|
const dist = Math.abs(item.pos - legAnchors[i]);
|
||||||
|
if (dist < bestDist) { bestDist = dist; bestLeg = i; }
|
||||||
|
}
|
||||||
|
assigned[bestLeg].push(item);
|
||||||
|
}
|
||||||
|
return assigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use timePair positions as leg anchors; fall back to route positions
|
||||||
|
const legAnchors = timePairs.length >= legCount
|
||||||
|
? timePairs.map(t => t.pos)
|
||||||
|
: routes.map(r => r.pos);
|
||||||
|
|
||||||
|
const airlinesByLeg = assignToLeg(airlines, legAnchors);
|
||||||
|
const durationsByLeg = assignToLeg(durations, legAnchors);
|
||||||
|
const stopsByLeg = assignToLeg(stopsMarkers, legAnchors);
|
||||||
|
const routesByLeg = assignToLeg(routes, legAnchors);
|
||||||
|
const layoversByLeg = assignToLeg(allLayovers, legAnchors);
|
||||||
|
|
||||||
|
const legs = [];
|
||||||
|
for (let i = 0; i < legCount; i++) {
|
||||||
|
const leg = {};
|
||||||
|
if (airlinesByLeg[i] && airlinesByLeg[i].length > 0) leg.airline = airlinesByLeg[i][0].name;
|
||||||
|
if (timePairs[i]) {
|
||||||
|
leg.departureTime = timePairs[i].departureTime;
|
||||||
|
leg.arrivalTime = timePairs[i].arrivalTime;
|
||||||
|
}
|
||||||
|
if (durationsByLeg[i] && durationsByLeg[i].length > 0) {
|
||||||
|
leg.duration = durationsByLeg[i][0].duration;
|
||||||
|
leg.durationMinutes = durationsByLeg[i][0].durationMinutes;
|
||||||
|
}
|
||||||
|
if (stopsByLeg[i] && stopsByLeg[i].length > 0) {
|
||||||
|
leg.stops = stopsByLeg[i][0].stops;
|
||||||
|
leg.stopsText = stopsByLeg[i][0].stopsText;
|
||||||
|
}
|
||||||
|
if (routesByLeg[i] && routesByLeg[i].length > 0) leg.route = routesByLeg[i][0].route;
|
||||||
|
|
||||||
|
// Layovers for this leg (filter out main duration)
|
||||||
|
const legDuration = leg.duration;
|
||||||
|
const legLayovers = (layoversByLeg[i] || []).filter(l => !legDuration || l.duration !== legDuration);
|
||||||
|
if (legLayovers.length > 0) {
|
||||||
|
leg.layovers = legLayovers.map(l => ({ duration: l.duration, durationMinutes: l.durationMinutes, airportCode: l.airportCode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
legs.push(leg);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.legs = legs;
|
||||||
|
|
||||||
|
// Also set top-level fields from first leg for backward compatibility
|
||||||
|
const first = legs[0];
|
||||||
|
if (first) {
|
||||||
|
if (first.airline) result.airline = first.airline;
|
||||||
|
if (first.departureTime) result.departureTime = first.departureTime;
|
||||||
|
if (first.arrivalTime) result.arrivalTime = first.arrivalTime;
|
||||||
|
if (first.duration) result.duration = first.duration;
|
||||||
|
if (first.durationMinutes) result.durationMinutes = first.durationMinutes;
|
||||||
|
if (first.stops !== undefined) result.stops = first.stops;
|
||||||
|
if (first.stopsText) result.stopsText = first.stopsText;
|
||||||
|
if (first.route) result.route = first.route;
|
||||||
|
if (first.layovers) result.layovers = first.layovers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Strategy 1: Find flight card elements by known selectors ---
|
||||||
|
const cardSelectors = [
|
||||||
|
'li.pIav2d',
|
||||||
|
'li[class*="pIav2d"]',
|
||||||
|
'[role="listitem"][class*="pIav2d"]',
|
||||||
|
'ul[class*="Rk10dc"] > li',
|
||||||
|
'div[class*="yR1fYc"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
let cards = [];
|
||||||
|
for (const sel of cardSelectors) {
|
||||||
|
const els = document.querySelectorAll(sel);
|
||||||
|
if (els.length > 0) {
|
||||||
|
cards = Array.from(els);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: broader selectors
|
||||||
|
if (cards.length === 0) {
|
||||||
|
const listItems = document.querySelectorAll('[role="listitem"]');
|
||||||
|
// Filter to items that contain a price
|
||||||
|
cards = Array.from(listItems).filter(el => {
|
||||||
|
const t = el.innerText || "";
|
||||||
|
return /₩\s*[\d,]+|[\d,]+\s*원/.test(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0) return null;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const text = cards[i].innerText || "";
|
||||||
|
const parsed = parseCardText(text);
|
||||||
|
if (!parsed.price) continue;
|
||||||
|
parsed.rank = i + 1;
|
||||||
|
results.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.length > 0 ? results : null;
|
||||||
|
}, { knownAirlines: KNOWN_AIRLINES, minP: minPrice });
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal protobuf encoder (wire format only, no .proto schema needed)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function writeVarint(value) {
|
||||||
|
const bytes = [];
|
||||||
|
// Handle negative values: protobuf encodes them as 10-byte uint64
|
||||||
|
if (value < 0) {
|
||||||
|
const big = BigInt(value) & 0xFFFFFFFFFFFFFFFFn;
|
||||||
|
let v = big;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
bytes.push(Number(v & 0x7Fn) | 0x80);
|
||||||
|
v >>= 7n;
|
||||||
|
}
|
||||||
|
bytes.push(Number(v & 0x7Fn));
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
let v = value >>> 0;
|
||||||
|
while (v > 0x7f) {
|
||||||
|
bytes.push((v & 0x7f) | 0x80);
|
||||||
|
v >>>= 7;
|
||||||
|
}
|
||||||
|
bytes.push(v);
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeField(fieldNumber, wireType) {
|
||||||
|
return writeVarint((fieldNumber << 3) | wireType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function varintField(fieldNumber, value) {
|
||||||
|
return Buffer.concat([writeField(fieldNumber, 0), writeVarint(value)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(fieldNumber, str) {
|
||||||
|
const buf = Buffer.from(str, "utf8");
|
||||||
|
return Buffer.concat([writeField(fieldNumber, 2), writeVarint(buf.length), buf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageField(fieldNumber, messageBuf) {
|
||||||
|
return Buffer.concat([writeField(fieldNumber, 2), writeVarint(messageBuf.length), messageBuf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Google Flights protobuf TFS builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protobuf schema (reverse-engineered from Google Flights URLs):
|
||||||
|
*
|
||||||
|
* TFS {
|
||||||
|
* field 1 (varint): 28 (constant)
|
||||||
|
* field 2 (varint): number of segments
|
||||||
|
* field 3 (message, repeated): Segment {
|
||||||
|
* field 2 (string): date "YYYY-MM-DD"
|
||||||
|
* field 5 (varint): maxStops (0=direct, 1=1stop, omit=unlimited)
|
||||||
|
* field 12 (varint): maxDurationMinutes
|
||||||
|
* field 13 (message): origin { field 1: 1, field 2: IATA }
|
||||||
|
* field 14 (message): dest { field 1: 1, field 2: IATA }
|
||||||
|
* }
|
||||||
|
* field 8 (varint): adults
|
||||||
|
* field 9 (varint): cabinClass (1=economy,2=premEco,3=business,4=first)
|
||||||
|
* field 14 (varint): 1 (constant)
|
||||||
|
* field 16 (message): { field 1: -1 } (no-limit sentinel)
|
||||||
|
* field 19 (varint): tripType (1=round_trip, 2=one_way, 3=multi_city)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CABIN_CLASS_MAP = {
|
||||||
|
economy: 1,
|
||||||
|
premium_economy: 2,
|
||||||
|
business: 3,
|
||||||
|
first: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIP_TYPE_MAP = {
|
||||||
|
round_trip: 1,
|
||||||
|
one_way: 2,
|
||||||
|
multi_city: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildAirportMsg(iata) {
|
||||||
|
return Buffer.concat([varintField(1, 1), stringField(2, iata.toUpperCase())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegmentMsg(date, from, to, opts = {}) {
|
||||||
|
const parts = [stringField(2, date)];
|
||||||
|
if (opts.maxStops !== undefined && opts.maxStops !== null) {
|
||||||
|
parts.push(varintField(5, opts.maxStops));
|
||||||
|
}
|
||||||
|
if (opts.maxDurationMinutes) {
|
||||||
|
parts.push(varintField(12, opts.maxDurationMinutes));
|
||||||
|
}
|
||||||
|
parts.push(messageField(13, buildAirportMsg(from)));
|
||||||
|
parts.push(messageField(14, buildAirportMsg(to)));
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTfs(flightSegments, adults, cabinClassNum, tripType) {
|
||||||
|
const parts = [];
|
||||||
|
parts.push(varintField(1, 28));
|
||||||
|
parts.push(varintField(2, flightSegments.length));
|
||||||
|
for (const seg of flightSegments) {
|
||||||
|
parts.push(messageField(3, seg));
|
||||||
|
}
|
||||||
|
parts.push(varintField(8, adults));
|
||||||
|
parts.push(varintField(9, cabinClassNum));
|
||||||
|
parts.push(varintField(14, 1));
|
||||||
|
// field 16: { field 1: -1 } — no-limit sentinel
|
||||||
|
parts.push(messageField(16, varintField(1, -1)));
|
||||||
|
// field 19: trip type (1=round_trip, 2=one_way, 3=multi_city)
|
||||||
|
parts.push(varintField(19, TRIP_TYPE_MAP[tripType] || 1));
|
||||||
|
return Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrlSafeBase64(buf) {
|
||||||
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TFU is constant (default settings)
|
||||||
|
const TFU = "EgYIABAAGAA";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getDStr(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const yy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${yy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Google Flights URL using protobuf-encoded tfs parameter.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - tripType: one_way / round_trip / multi_city
|
||||||
|
* - segments[].from / to
|
||||||
|
* - departureDateWindow.from
|
||||||
|
* - stayDurationDays.minDays (default 7 for round_trip)
|
||||||
|
* - passengers.total, passengers.byCabin
|
||||||
|
* - constraints.maxStops (0=direct, 1=1stop, 2=2stops, null=unlimited)
|
||||||
|
* - constraints.maxJourneyHours.hours → converted to minutes
|
||||||
|
*/
|
||||||
|
function buildGoogleUrl(searchParams) {
|
||||||
|
const segments = searchParams.segments || [];
|
||||||
|
if (segments.length === 0) return "https://www.google.com/travel/flights";
|
||||||
|
|
||||||
|
// open_jaw is treated as multi_city for URL building
|
||||||
|
const tripType = searchParams.tripType === "open_jaw" ? "multi_city" : searchParams.tripType;
|
||||||
|
const adults = searchParams.passengers?.total || 1;
|
||||||
|
const byCabin = searchParams.passengers?.byCabin || {};
|
||||||
|
|
||||||
|
let cabinKey = "economy";
|
||||||
|
if (byCabin.first > 0) cabinKey = "first";
|
||||||
|
else if (byCabin.business > 0) cabinKey = "business";
|
||||||
|
else if (byCabin.premium_economy > 0) cabinKey = "premium_economy";
|
||||||
|
const cabinClassNum = CABIN_CLASS_MAP[cabinKey];
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const maxStops = searchParams.constraints?.maxStops ?? null;
|
||||||
|
const maxDurationMinutes = searchParams.constraints?.maxJourneyHours?.hours
|
||||||
|
? searchParams.constraints.maxJourneyHours.hours * 60
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const segOpts = {
|
||||||
|
maxStops: maxStops !== null && maxStops !== undefined ? maxStops : undefined,
|
||||||
|
maxDurationMinutes: maxDurationMinutes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const flightSegments = [];
|
||||||
|
|
||||||
|
if (tripType === "round_trip") {
|
||||||
|
const from = segments[0].from;
|
||||||
|
const to = segments[0].to;
|
||||||
|
const departDate = searchParams.departureDateWindow?.from || tomorrow;
|
||||||
|
const d1 = getDStr(departDate);
|
||||||
|
|
||||||
|
const stayDays = searchParams.stayDurationDays?.minDays || 7;
|
||||||
|
const returnDate = new Date(departDate);
|
||||||
|
returnDate.setDate(returnDate.getDate() + stayDays);
|
||||||
|
const d2 = getDStr(returnDate);
|
||||||
|
|
||||||
|
flightSegments.push(buildSegmentMsg(d1, from, to, segOpts));
|
||||||
|
flightSegments.push(buildSegmentMsg(d2, to, from, segOpts));
|
||||||
|
} else if (tripType === "multi_city") {
|
||||||
|
const departDate = new Date(searchParams.departureDateWindow?.from || tomorrow);
|
||||||
|
const stayDays = searchParams.stayDurationDays?.minDays || 7;
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const seg = segments[i];
|
||||||
|
const segDate = i === 0
|
||||||
|
? departDate
|
||||||
|
: new Date(departDate.getTime() + stayDays * 24 * 60 * 60 * 1000);
|
||||||
|
flightSegments.push(buildSegmentMsg(getDStr(segDate), seg.from, seg.to, segOpts));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// one_way
|
||||||
|
const from = segments[0].from;
|
||||||
|
const to = segments[0].to;
|
||||||
|
const d1 = searchParams.departureDateWindow?.from
|
||||||
|
? getDStr(searchParams.departureDateWindow.from)
|
||||||
|
: getDStr(tomorrow);
|
||||||
|
flightSegments.push(buildSegmentMsg(d1, from, to, segOpts));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tfsBuf = buildTfs(flightSegments, adults, cabinClassNum, tripType);
|
||||||
|
const tfsEncoded = toUrlSafeBase64(tfsBuf);
|
||||||
|
|
||||||
|
return `https://www.google.com/travel/flights/search?tfs=${tfsEncoded}&tfu=${TFU}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Google Flights error page detection & retry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ERROR_INDICATORS = ["오류가 발생했습니다", "오류가 발생", "죄송합니다", "Something went wrong", "sorry"];
|
||||||
|
|
||||||
|
async function isGoogleErrorPage(page) {
|
||||||
|
return page.evaluate((indicators) => {
|
||||||
|
const text = document.body?.innerText || "";
|
||||||
|
return indicators.some((t) => text.includes(t));
|
||||||
|
}, ERROR_INDICATORS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickRefreshButton(page) {
|
||||||
|
// Try Korean "새로고침" button first, then English
|
||||||
|
const labels = ["새로고침", "Refresh", "Try again", "다시 시도"];
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
const loc = page.locator("button", { hasText: label });
|
||||||
|
if (await loc.count() > 0) {
|
||||||
|
await loc.first().click();
|
||||||
|
console.log(`[Google Flights] Clicked "${label}" button`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
// Fallback: look for any visible button-like element with those texts
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
const loc = page.locator(`a, [role="button"]`, { hasText: label });
|
||||||
|
if (await loc.count() > 0) {
|
||||||
|
await loc.first().click();
|
||||||
|
console.log(`[Google Flights] Clicked "${label}" link/role-button`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFlightResults(page, { timeout = 30000 } = {}) {
|
||||||
|
// Race all selectors concurrently — first one to appear wins
|
||||||
|
const selectors = [
|
||||||
|
'[class*="pIav2d"]', // flight result list
|
||||||
|
'[class*="YMlIz"]', // price element
|
||||||
|
'li[class*="result"]',
|
||||||
|
'[role="listitem"]',
|
||||||
|
'[class*="price"], [class*="Price"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
...selectors.map(sel =>
|
||||||
|
page.waitForSelector(sel, { timeout }).then(() => sel)
|
||||||
|
),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)),
|
||||||
|
]);
|
||||||
|
console.log(`[Google Flights] Found elements matching: ${result}`);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Click a specific flight card by 0-based index
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function clickFlightCard(page, cardIndex) {
|
||||||
|
const selectors = [
|
||||||
|
'li.pIav2d',
|
||||||
|
'li[class*="pIav2d"]',
|
||||||
|
'[role="listitem"][class*="pIav2d"]',
|
||||||
|
'ul[class*="Rk10dc"] > li',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sel of selectors) {
|
||||||
|
try {
|
||||||
|
const loc = page.locator(sel);
|
||||||
|
const count = await loc.count();
|
||||||
|
if (count > cardIndex) {
|
||||||
|
await loc.nth(cardIndex).click();
|
||||||
|
console.log(`[Google Flights] Clicked card #${cardIndex + 1} via Playwright locator (${sel})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: role=listitem with price text
|
||||||
|
try {
|
||||||
|
const loc = page.locator('[role="listitem"]').filter({ hasText: /₩/ });
|
||||||
|
const count = await loc.count();
|
||||||
|
if (count > cardIndex) {
|
||||||
|
await loc.nth(cardIndex).click();
|
||||||
|
console.log(`[Google Flights] Clicked card #${cardIndex + 1} via fallback locator`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
console.log(`[Google Flights] Failed to click card #${cardIndex + 1}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegFromCard(card) {
|
||||||
|
const leg = {};
|
||||||
|
if (card.airline) leg.airline = card.airline;
|
||||||
|
if (card.departureTime) leg.departureTime = card.departureTime;
|
||||||
|
if (card.arrivalTime) leg.arrivalTime = card.arrivalTime;
|
||||||
|
if (card.duration) leg.duration = card.duration;
|
||||||
|
if (card.durationMinutes) leg.durationMinutes = card.durationMinutes;
|
||||||
|
if (card.route) leg.route = card.route;
|
||||||
|
if (card.stops !== undefined) leg.stops = card.stops;
|
||||||
|
if (card.stopsText) leg.stopsText = card.stopsText;
|
||||||
|
if (card.layovers) leg.layovers = card.layovers;
|
||||||
|
return leg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
async function navigateAndWaitForResults(page, url) {
|
||||||
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||||
|
|
||||||
|
// Wait for flight result selectors instead of networkidle
|
||||||
|
// (Google Flights SPA never reaches networkidle due to background requests)
|
||||||
|
await waitForFlightResults(page);
|
||||||
|
|
||||||
|
// Give the page extra time for JS rendering
|
||||||
|
await page.waitForTimeout(randomDelay(2000, 4000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeGoogle(searchParams) {
|
||||||
|
const url = buildGoogleUrl(searchParams);
|
||||||
|
console.log(`[Google Flights] Navigating to ${url}`);
|
||||||
|
|
||||||
|
return withBrowser(async (page) => {
|
||||||
|
// Initial navigation — skip networkidle (Google Flights never settles)
|
||||||
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||||
|
await page.waitForTimeout(randomDelay(1500, 3000));
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
// Check for Google Flights error page
|
||||||
|
if (await isGoogleErrorPage(page)) {
|
||||||
|
console.log(`[Google Flights] Error page detected (attempt ${attempt}/${MAX_RETRIES})`);
|
||||||
|
|
||||||
|
if (attempt >= MAX_RETRIES) {
|
||||||
|
console.log("[Google Flights] Max retries reached, giving up.");
|
||||||
|
await saveDebugDump(page, "google-error-final");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === 1) {
|
||||||
|
// Strategy 1: Click "새로고침" refresh button
|
||||||
|
const clicked = await clickRefreshButton(page);
|
||||||
|
if (clicked) {
|
||||||
|
await waitForFlightResults(page);
|
||||||
|
await page.waitForTimeout(randomDelay(2000, 3000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// If no refresh button, fall through to strategy 2
|
||||||
|
console.log("[Google Flights] No refresh button, trying page reload...");
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 60000 });
|
||||||
|
await waitForFlightResults(page);
|
||||||
|
await page.waitForTimeout(randomDelay(2000, 3000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Navigate via Google Flights homepage first (warm-up)
|
||||||
|
console.log("[Google Flights] Trying homepage warm-up approach...");
|
||||||
|
await page.goto("https://www.google.com/travel/flights", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(randomDelay(2000, 4000));
|
||||||
|
// Now navigate to the actual search URL
|
||||||
|
await navigateAndWaitForResults(page, url);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No error page — try to find flight results
|
||||||
|
const found = await waitForFlightResults(page);
|
||||||
|
if (found) break;
|
||||||
|
|
||||||
|
// No results and no error — wait a bit more
|
||||||
|
console.log("[Google Flights] No result selectors found, waiting extra time...");
|
||||||
|
await page.waitForTimeout(8000);
|
||||||
|
|
||||||
|
// Check again if error page appeared during wait
|
||||||
|
if (await isGoogleErrorPage(page)) continue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra scroll to load more results
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await page.mouse.wheel(0, 600);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try structured flight card extraction first (outbound / first segment)
|
||||||
|
const outboundCards = await extractFlightCardsFromPage(page, { minPrice: 10000 });
|
||||||
|
|
||||||
|
if (outboundCards && outboundCards.length > 0) {
|
||||||
|
console.log(`[Google Flights] Extracted ${outboundCards.length} outbound flight cards`);
|
||||||
|
const sortedOutbound = [...outboundCards].sort((a, b) => a.price - b.price);
|
||||||
|
|
||||||
|
// For round_trip / multi_city: click cheapest outbound to reveal return flights
|
||||||
|
let returnLeg = null;
|
||||||
|
const tripType = searchParams.tripType || "round_trip";
|
||||||
|
|
||||||
|
if (tripType !== "one_way") {
|
||||||
|
const cheapest = sortedOutbound[0];
|
||||||
|
console.log(`[Google Flights] Clicking outbound #${cheapest.rank} (₩${cheapest.price.toLocaleString()}) to see return flights...`);
|
||||||
|
const clicked = await clickFlightCard(page, cheapest.rank - 1);
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
// Wait for page transition and return flight cards to load
|
||||||
|
await page.waitForTimeout(randomDelay(2000, 3000));
|
||||||
|
await waitForFlightResults(page, { timeout: 20000 });
|
||||||
|
await page.waitForTimeout(randomDelay(1000, 2000));
|
||||||
|
|
||||||
|
// Scroll to load more return results
|
||||||
|
await page.mouse.wheel(0, 400);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const returnCards = await extractFlightCardsFromPage(page, { minPrice: 10000 });
|
||||||
|
if (returnCards && returnCards.length > 0) {
|
||||||
|
const sortedReturn = [...returnCards].sort((a, b) => a.price - b.price);
|
||||||
|
returnLeg = buildLegFromCard(sortedReturn[0]);
|
||||||
|
console.log(`[Google Flights] Extracted ${returnCards.length} return flight cards (best: ₩${sortedReturn[0].price.toLocaleString()} ${returnLeg.airline || ""} ${returnLeg.route || ""})`);
|
||||||
|
} else {
|
||||||
|
console.log("[Google Flights] No return flight cards found after selecting outbound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedOutbound.map((card) => {
|
||||||
|
const outboundLeg = buildLegFromCard(card);
|
||||||
|
const legs = [outboundLeg];
|
||||||
|
if (returnLeg) legs.push(returnLeg);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "google",
|
||||||
|
price: card.price,
|
||||||
|
currency: "KRW",
|
||||||
|
metadata: {
|
||||||
|
url,
|
||||||
|
rank: card.rank,
|
||||||
|
legs: legs.length > 1 ? legs : undefined,
|
||||||
|
// Top-level fields from first leg for backward compatibility
|
||||||
|
airline: card.airline || null,
|
||||||
|
departureTime: card.departureTime || null,
|
||||||
|
arrivalTime: card.arrivalTime || null,
|
||||||
|
duration: card.duration || null,
|
||||||
|
durationMinutes: card.durationMinutes || null,
|
||||||
|
route: card.route || null,
|
||||||
|
stops: card.stops !== undefined ? card.stops : null,
|
||||||
|
stopsText: card.stopsText || null,
|
||||||
|
layovers: card.layovers || null,
|
||||||
|
flightNumber: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract prices only (existing method)
|
||||||
|
console.log("[Google Flights] Structured extraction failed, falling back to price-only extraction");
|
||||||
|
const prices = await extractPricesFromPage(page, { minPrice: 10000 });
|
||||||
|
|
||||||
|
if (prices.length === 0) {
|
||||||
|
console.log("[Google Flights] No prices found on page.");
|
||||||
|
await saveDebugDump(page, "google");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Google Flights] Found ${prices.length} prices: ${prices.slice(0, 5).join(", ")}...`);
|
||||||
|
return prices.map((price, i) => ({
|
||||||
|
provider: "google",
|
||||||
|
price,
|
||||||
|
currency: "KRW",
|
||||||
|
metadata: { url, rank: i + 1 },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scrapeGoogle, buildGoogleUrl };
|
||||||
117
src/crawlers/naver.js
Normal file
117
src/crawlers/naver.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { withBrowser, navigateWithHumanBehavior, extractPricesFromPage } = require("./baseCrawler");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date as YYYYMMDD for Naver Flights URLs.
|
||||||
|
*/
|
||||||
|
function getDStr(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const yy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${yy}${mm}${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps internal cabin class to Naver fareType code.
|
||||||
|
* economy → Y, premium_economy → P, business → C, first → F
|
||||||
|
*/
|
||||||
|
function getFareType(byCabin) {
|
||||||
|
if (byCabin.first > 0) return "F";
|
||||||
|
if (byCabin.business > 0) return "C";
|
||||||
|
if (byCabin.premium_economy > 0) return "P";
|
||||||
|
return "Y";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Naver Flights URL from structured search params.
|
||||||
|
*
|
||||||
|
* Round-trip format:
|
||||||
|
* /flights/international/ICN-NRT-20260315/NRT-ICN-20260322?adult=1&fareType=Y
|
||||||
|
*
|
||||||
|
* Multi-city format:
|
||||||
|
* /flights/multi?itinerary=ICN:NRT:20260315,NRT:ICN:20260322&adult=1&fareType=Y
|
||||||
|
*
|
||||||
|
* One-way format:
|
||||||
|
* /flights/international/ICN-NRT-20260315?adult=1&fareType=Y
|
||||||
|
*/
|
||||||
|
function buildNaverUrl(searchParams) {
|
||||||
|
const segments = searchParams.segments || [];
|
||||||
|
if (segments.length === 0) return "https://flight.naver.com";
|
||||||
|
|
||||||
|
const tripType = searchParams.tripType;
|
||||||
|
const adults = searchParams.passengers?.total || 1;
|
||||||
|
const byCabin = searchParams.passengers?.byCabin || {};
|
||||||
|
const fareType = getFareType(byCabin);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (tripType === "round_trip") {
|
||||||
|
const from = segments[0].from.toUpperCase();
|
||||||
|
const to = segments[0].to.toUpperCase();
|
||||||
|
const departDate = searchParams.departureDateWindow?.from || tomorrow;
|
||||||
|
const d1 = getDStr(departDate);
|
||||||
|
|
||||||
|
// Return date: use stayDurationDays if available, default to 7 days
|
||||||
|
const stayDays = searchParams.stayDurationDays?.minDays || 7;
|
||||||
|
const returnDate = new Date(departDate);
|
||||||
|
returnDate.setDate(returnDate.getDate() + stayDays);
|
||||||
|
const d2 = getDStr(returnDate);
|
||||||
|
|
||||||
|
// Naver round-trip: outbound segment / return segment (origin/dest reversed)
|
||||||
|
return `https://flight.naver.com/flights/international/${from}-${to}-${d1}/${to}-${from}-${d2}?adult=${adults}&fareType=${fareType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tripType === "multi_city") {
|
||||||
|
// Naver multi-city: /flights/multi?itinerary=ICN:NRT:20260315,NRT:ICN:20260322
|
||||||
|
let currentBaseDate = new Date(searchParams.departureDateWindow?.from || tomorrow);
|
||||||
|
const itineraryParts = [];
|
||||||
|
for (const seg of segments) {
|
||||||
|
itineraryParts.push(`${seg.from.toUpperCase()}:${seg.to.toUpperCase()}:${getDStr(currentBaseDate)}`);
|
||||||
|
currentBaseDate.setDate(currentBaseDate.getDate() + 3);
|
||||||
|
}
|
||||||
|
return `https://flight.naver.com/flights/multi?itinerary=${itineraryParts.join(",")}&adult=${adults}&fareType=${fareType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// one_way
|
||||||
|
const from = segments[0].from.toUpperCase();
|
||||||
|
const to = segments[0].to.toUpperCase();
|
||||||
|
const d1 = searchParams.departureDateWindow?.from ? getDStr(searchParams.departureDateWindow.from) : getDStr(tomorrow);
|
||||||
|
return `https://flight.naver.com/flights/international/${from}-${to}-${d1}?adult=${adults}&fareType=${fareType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeNaver(searchParams) {
|
||||||
|
const url = buildNaverUrl(searchParams);
|
||||||
|
console.log(`[Naver] Navigating to ${url}`);
|
||||||
|
|
||||||
|
return withBrowser(async (page) => {
|
||||||
|
await navigateWithHumanBehavior(page, url);
|
||||||
|
|
||||||
|
// Wait for price elements with generic selectors
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('[class*="price"], [class*="Price"], [class*="fare"]', { timeout: 15000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Naver] Timeout waiting for price elements.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract prices using generic Korean won patterns
|
||||||
|
const prices = await extractPricesFromPage(page);
|
||||||
|
|
||||||
|
if (prices.length === 0) {
|
||||||
|
console.log("[Naver] No prices found on page.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return prices.map((price, i) => ({
|
||||||
|
provider: "naver",
|
||||||
|
price,
|
||||||
|
currency: "KRW",
|
||||||
|
metadata: { url, rank: i + 1 },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scrapeNaver, buildNaverUrl };
|
||||||
185
src/crawlers/skyscanner.js
Normal file
185
src/crawlers/skyscanner.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { withBrowser, navigateWithHumanBehavior, extractPricesFromPage, saveDebugDump } = require("./baseCrawler");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date as YYYY-MM-DD for Skyscanner URLs.
|
||||||
|
*/
|
||||||
|
function getDateStr(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const yy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${yy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps internal cabin class names to Skyscanner's cabinclass param.
|
||||||
|
*/
|
||||||
|
function getCabinClass(byCabin) {
|
||||||
|
if (byCabin.first > 0) return "first";
|
||||||
|
if (byCabin.business > 0) return "business";
|
||||||
|
if (byCabin.premium_economy > 0) return "premiumeconomy";
|
||||||
|
return "economy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps maxStops to Skyscanner's stops parameter.
|
||||||
|
*
|
||||||
|
* Skyscanner uses negation-based filtering:
|
||||||
|
* stops=!oneStop,!twoPlusStops → direct only (maxStops=0)
|
||||||
|
* stops=!twoPlusStops → direct + 1 stop (maxStops=1)
|
||||||
|
* (omit) → all flights (no limit)
|
||||||
|
*/
|
||||||
|
function getStopsParam(maxStops) {
|
||||||
|
if (maxStops === 0) return "!oneStop,!twoPlusStops";
|
||||||
|
if (maxStops === 1) return "!twoPlusStops";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Skyscanner URL from structured search params.
|
||||||
|
*
|
||||||
|
* Uses /transport/d/ path format with YYYY-MM-DD dates:
|
||||||
|
* One-way: /transport/d/icn/2026-03-15/nrt/
|
||||||
|
* Round-trip: /transport/d/icn/2026-03-15/nrt/nrt/2026-03-22/icn/
|
||||||
|
* Multi-city: /transport/d/icn/2026-11-26/mad/bcn/2026-12-15/icn/
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - constraints.maxStops (0=direct, 1=1stop max, null=all)
|
||||||
|
* - constraints.maxJourneyHours.hours → duration (total minutes across all legs)
|
||||||
|
*/
|
||||||
|
function buildSkyscannerUrl(searchParams) {
|
||||||
|
const segments = searchParams.segments || [];
|
||||||
|
if (segments.length === 0) return "https://www.skyscanner.co.kr";
|
||||||
|
|
||||||
|
// open_jaw is treated as multi_city for URL building
|
||||||
|
const tripType = searchParams.tripType === "open_jaw" ? "multi_city" : searchParams.tripType;
|
||||||
|
const adults = searchParams.passengers?.total || 1;
|
||||||
|
const byCabin = searchParams.passengers?.byCabin || {};
|
||||||
|
const cabinClass = getCabinClass(byCabin);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
// Build path segments: /origin/date/dest repeated for each leg
|
||||||
|
const pathParts = [];
|
||||||
|
|
||||||
|
if (tripType === "round_trip") {
|
||||||
|
const from = segments[0].from.toLowerCase();
|
||||||
|
const to = segments[0].to.toLowerCase();
|
||||||
|
const departDate = searchParams.departureDateWindow?.from || tomorrow;
|
||||||
|
const d1 = getDateStr(departDate);
|
||||||
|
|
||||||
|
const stayDays = searchParams.stayDurationDays?.minDays || 7;
|
||||||
|
const returnDate = new Date(departDate);
|
||||||
|
returnDate.setDate(returnDate.getDate() + stayDays);
|
||||||
|
const d2 = getDateStr(returnDate);
|
||||||
|
|
||||||
|
// Outbound: from/date/to, Return: to/date/from
|
||||||
|
pathParts.push(from, d1, to, to, d2, from);
|
||||||
|
} else if (tripType === "multi_city") {
|
||||||
|
const departDate = new Date(searchParams.departureDateWindow?.from || tomorrow);
|
||||||
|
const stayDays = searchParams.stayDurationDays?.minDays || 7;
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const seg = segments[i];
|
||||||
|
const segDate = i === 0
|
||||||
|
? departDate
|
||||||
|
: new Date(departDate.getTime() + stayDays * 24 * 60 * 60 * 1000);
|
||||||
|
pathParts.push(seg.from.toLowerCase(), getDateStr(segDate), seg.to.toLowerCase());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// one_way
|
||||||
|
const from = segments[0].from.toLowerCase();
|
||||||
|
const to = segments[0].to.toLowerCase();
|
||||||
|
const d1 = searchParams.departureDateWindow?.from
|
||||||
|
? getDateStr(searchParams.departureDateWindow.from)
|
||||||
|
: getDateStr(tomorrow);
|
||||||
|
pathParts.push(from, d1, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `/transport/d/${pathParts.join("/")}/`;
|
||||||
|
|
||||||
|
// Query params
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("adultsv2", String(adults));
|
||||||
|
params.set("cabinclass", cabinClass);
|
||||||
|
params.set("childrenv2", "");
|
||||||
|
|
||||||
|
if (searchParams.constraints?.maxJourneyHours?.hours) {
|
||||||
|
params.set("duration", String(searchParams.constraints.maxJourneyHours.hours * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxStops = searchParams.constraints?.maxStops;
|
||||||
|
if (maxStops !== undefined && maxStops !== null) {
|
||||||
|
const stopsVal = getStopsParam(maxStops);
|
||||||
|
if (stopsVal) {
|
||||||
|
params.set("stops", stopsVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://www.skyscanner.co.kr${path}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeSkyscanner(searchParams) {
|
||||||
|
const url = buildSkyscannerUrl(searchParams);
|
||||||
|
console.log(`[Skyscanner] Navigating to ${url}`);
|
||||||
|
|
||||||
|
return withBrowser(async (page) => {
|
||||||
|
await navigateWithHumanBehavior(page, url);
|
||||||
|
|
||||||
|
// Skyscanner-specific selectors for flight result cards and prices
|
||||||
|
const selectors = [
|
||||||
|
'[class*="FlightsResults"]',
|
||||||
|
'[class*="ResultsSummary"]',
|
||||||
|
'[class*="price"], [class*="Price"]',
|
||||||
|
'[class*="fqs-price"]',
|
||||||
|
'[class*="BpkText"]',
|
||||||
|
'a[href*="booking"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wait for any of the flight result selectors (longer timeout for SPA)
|
||||||
|
let found = false;
|
||||||
|
for (const sel of selectors) {
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(sel, { timeout: 10000 });
|
||||||
|
console.log(`[Skyscanner] Found elements matching: ${sel}`);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
} catch (_) {
|
||||||
|
// try next selector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
console.log("[Skyscanner] No result selectors found, waiting extra time...");
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra scroll to load more results
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await page.mouse.wheel(0, 600);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract prices using generic Korean won patterns
|
||||||
|
const prices = await extractPricesFromPage(page);
|
||||||
|
|
||||||
|
if (prices.length === 0) {
|
||||||
|
console.log("[Skyscanner] No prices found on page.");
|
||||||
|
await saveDebugDump(page, "skyscanner");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Skyscanner] Found ${prices.length} prices: ${prices.slice(0, 5).join(", ")}...`);
|
||||||
|
return prices.map((price, i) => ({
|
||||||
|
provider: "skyscanner",
|
||||||
|
price,
|
||||||
|
currency: "KRW",
|
||||||
|
metadata: { url, rank: i + 1 },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scrapeSkyscanner, buildSkyscannerUrl };
|
||||||
@@ -64,6 +64,28 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar h1 {
|
.topbar h1 {
|
||||||
margin: 3px 0 0;
|
margin: 3px 0 0;
|
||||||
font-size: clamp(1.4rem, 1.7vw, 1.9rem);
|
font-size: clamp(1.4rem, 1.7vw, 1.9rem);
|
||||||
@@ -127,7 +149,7 @@ input[type="number"]:focus {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-field {
|
.switch-field {
|
||||||
@@ -169,6 +191,11 @@ input[type="number"]:focus {
|
|||||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn.small {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@@ -208,6 +235,10 @@ input[type="number"]:focus {
|
|||||||
color: var(--ink-sub);
|
color: var(--ink-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.summary strong {
|
.summary strong {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -295,6 +326,60 @@ input[type="number"]:focus {
|
|||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-offers {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-offer-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-offer-cabin {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-details {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-airline {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-times {
|
||||||
|
color: var(--ink-main);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-stops {
|
||||||
|
color: var(--ink-sub);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flight-leg-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -321,6 +406,11 @@ input[type="number"]:focus {
|
|||||||
color: #ffb4b4;
|
color: #ffb4b4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
background: rgba(255, 183, 77, 0.2);
|
||||||
|
color: #ffd08a;
|
||||||
|
}
|
||||||
|
|
||||||
.event-head {
|
.event-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -339,6 +429,20 @@ input[type="number"]:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.controls-grid {
|
.controls-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
configBanner: document.getElementById("configBanner"),
|
configBanner: document.getElementById("configBanner"),
|
||||||
|
systemPanel: document.getElementById("systemPanel"),
|
||||||
|
setupLink: document.getElementById("setupLink"),
|
||||||
|
logoutBtn: document.getElementById("logoutBtn"),
|
||||||
queryInput: document.getElementById("queryInput"),
|
queryInput: document.getElementById("queryInput"),
|
||||||
useLlm: document.getElementById("useLlm"),
|
useLlm: document.getElementById("useLlm"),
|
||||||
|
provider: document.getElementById("provider"),
|
||||||
|
sameFlight: document.getElementById("sameFlight"),
|
||||||
alertOn: document.getElementById("alertOn"),
|
alertOn: document.getElementById("alertOn"),
|
||||||
targetPrice: document.getElementById("targetPrice"),
|
targetPrice: document.getElementById("targetPrice"),
|
||||||
parseBtn: document.getElementById("parseBtn"),
|
parseBtn: document.getElementById("parseBtn"),
|
||||||
@@ -21,17 +26,118 @@
|
|||||||
parsed: null,
|
parsed: null,
|
||||||
watches: [],
|
watches: [],
|
||||||
events: [],
|
events: [],
|
||||||
|
user: null,
|
||||||
|
apiToken: "",
|
||||||
|
auth: {
|
||||||
|
accountAuthEnabled: false,
|
||||||
|
tokenAuthEnabled: false,
|
||||||
|
},
|
||||||
controls: {
|
controls: {
|
||||||
crawlingEnabled: true,
|
crawlingEnabled: true,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function readStoredApiToken() {
|
||||||
|
try {
|
||||||
|
const token = sessionStorage.getItem("dashboard_api_token");
|
||||||
|
return typeof token === "string" ? token.trim() : "";
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredApiToken(token) {
|
||||||
|
try {
|
||||||
|
if (!token) {
|
||||||
|
sessionStorage.removeItem("dashboard_api_token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionStorage.setItem("dashboard_api_token", token);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
function formatPrice(price, currency) {
|
function formatPrice(price, currency) {
|
||||||
if (!Number.isFinite(Number(price))) return "N/A";
|
if (!Number.isFinite(Number(price))) return "N/A";
|
||||||
return `${new Intl.NumberFormat("ko-KR").format(Number(price))} ${currency || ""}`.trim();
|
return `${new Intl.NumberFormat("ko-KR").format(Number(price))} ${currency || ""}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLeg(leg, label) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// airline + route
|
||||||
|
const airlineParts = [];
|
||||||
|
if (label) airlineParts.push(`<strong>${escapeHtml(label)}</strong>`);
|
||||||
|
if (leg.airline) airlineParts.push(escapeHtml(leg.airline));
|
||||||
|
if (leg.route) airlineParts.push(escapeHtml(leg.route));
|
||||||
|
if (airlineParts.length > 0) {
|
||||||
|
lines.push(`<div class="flight-airline">${airlineParts.join(" ")}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// times + duration
|
||||||
|
const timeParts = [];
|
||||||
|
if (leg.departureTime && leg.arrivalTime) {
|
||||||
|
timeParts.push(`${escapeHtml(leg.departureTime)} → ${escapeHtml(leg.arrivalTime)}`);
|
||||||
|
}
|
||||||
|
if (leg.duration) {
|
||||||
|
timeParts.push(escapeHtml(leg.duration));
|
||||||
|
}
|
||||||
|
if (timeParts.length > 0) {
|
||||||
|
lines.push(`<div class="flight-times">${timeParts.join(" · ")}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stops + layover info
|
||||||
|
if (leg.stopsText || leg.stops === 0) {
|
||||||
|
let stopsStr = escapeHtml(leg.stopsText || "직항");
|
||||||
|
if (Array.isArray(leg.layovers) && leg.layovers.length > 0) {
|
||||||
|
const layoverStr = leg.layovers
|
||||||
|
.map(l => `${escapeHtml(l.duration || "")} ${escapeHtml(l.airportCode || "")}`.trim())
|
||||||
|
.join(", ");
|
||||||
|
stopsStr += ` (${layoverStr})`;
|
||||||
|
}
|
||||||
|
lines.push(`<div class="flight-stops">${stopsStr}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlightDetails(metadata) {
|
||||||
|
if (!metadata) return "";
|
||||||
|
|
||||||
|
// Multi-leg: render each leg separately
|
||||||
|
if (Array.isArray(metadata.legs) && metadata.legs.length > 0) {
|
||||||
|
const legLabels = ["가는편", "오는편", "구간3", "구간4"];
|
||||||
|
const legsHtml = metadata.legs
|
||||||
|
.map((leg, i) => renderLeg(leg, metadata.legs.length > 1 ? legLabels[i] || `구간${i + 1}` : null))
|
||||||
|
.join('<hr class="flight-leg-divider">');
|
||||||
|
return `<div class="flight-details">${legsHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single leg: check if structured data is available
|
||||||
|
const hasStructured = metadata.airline || metadata.departureTime || metadata.duration || (metadata.stops !== undefined && metadata.stops !== null);
|
||||||
|
|
||||||
|
if (!hasStructured) {
|
||||||
|
if (metadata.flightNumber) {
|
||||||
|
return `<div class="flight-details"><span class="flight-airline">[${escapeHtml(metadata.flightNumber)}] ${escapeHtml(metadata.departureTime || "")}</span></div>`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = renderLeg(metadata, null);
|
||||||
|
return html ? `<div class="flight-details">${html}</div>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -47,12 +153,41 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options) {
|
async function api(path, options, retryOnUnauthorized = true) {
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options && options.headers ? options.headers : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.apiToken) {
|
||||||
|
headers.authorization = `Bearer ${state.apiToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
headers: { "content-type": "application/json" },
|
headers,
|
||||||
|
credentials: "same-origin",
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
if (state.auth.accountAuthEnabled) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryOnUnauthorized && state.auth.tokenAuthEnabled) {
|
||||||
|
const prompted = prompt("API token을 입력하세요.");
|
||||||
|
const token = typeof prompted === "string" ? prompted.trim() : "";
|
||||||
|
if (token) {
|
||||||
|
state.apiToken = token;
|
||||||
|
writeStoredApiToken(token);
|
||||||
|
return api(path, options, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error || `요청 실패 (${response.status})`);
|
throw new Error(payload.error || `요청 실패 (${response.status})`);
|
||||||
@@ -100,13 +235,15 @@
|
|||||||
|
|
||||||
elements.parseSummary.classList.remove("empty");
|
elements.parseSummary.classList.remove("empty");
|
||||||
elements.parseSummary.innerHTML = [
|
elements.parseSummary.innerHTML = [
|
||||||
`<div><strong>파서:</strong> ${parsedPayload.source}</div>`,
|
`<div><strong>파서:</strong> ${escapeHtml(parsedPayload.source || "unknown")}</div>`,
|
||||||
`<div><strong>구간:</strong> ${segmentText || "미입력"}</div>`,
|
`<div><strong>구간:</strong> ${escapeHtml(segmentText || "미입력")}</div>`,
|
||||||
`<div><strong>출발 윈도우:</strong> ${windowText}</div>`,
|
`<div><strong>출발 윈도우:</strong> ${escapeHtml(windowText)}</div>`,
|
||||||
`<div><strong>체류 기간:</strong> ${stayText}</div>`,
|
`<div><strong>체류 기간:</strong> ${escapeHtml(stayText)}</div>`,
|
||||||
`<div><strong>탑승객:</strong> ${paxText}</div>`,
|
`<div><strong>탑승객:</strong> ${escapeHtml(paxText)}</div>`,
|
||||||
`<div><strong>최대 여정시간:</strong> ${journeyText}</div>`,
|
`<div><strong>최대 여정시간:</strong> ${escapeHtml(journeyText)}</div>`,
|
||||||
`<div><strong>누락 필드:</strong> ${missing.length > 0 ? missing.join(", ") : "없음"}</div>`,
|
`<div><strong>누락 필드:</strong> ${escapeHtml(
|
||||||
|
missing.length > 0 ? missing.join(", ") : "없음"
|
||||||
|
)}</div>`,
|
||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,23 +281,64 @@
|
|||||||
const alertOn = watchAlertOn(watch);
|
const alertOn = watchAlertOn(watch);
|
||||||
const targetPrice = watch.alertRules ? watch.alertRules.targetPrice : null;
|
const targetPrice = watch.alertRules ? watch.alertRules.targetPrice : null;
|
||||||
|
|
||||||
|
const bestOfferData = watch.lastSnapshot && watch.lastSnapshot.bestOffer ? watch.lastSnapshot.bestOffer : null;
|
||||||
|
const metadata = bestOfferData && bestOfferData.metadata ? bestOfferData.metadata : {};
|
||||||
|
const flightDetailsHtml = renderFlightDetails(metadata);
|
||||||
|
const urlStr = metadata.url ? `<a href="${escapeHtml(metadata.url)}" target="_blank" style="color:var(--accent); text-decoration:none; margin-left: 8px;">🔗 예매하기</a>` : "";
|
||||||
|
|
||||||
|
const subOffers = watch.lastSnapshot && watch.lastSnapshot.bestOffer && Array.isArray(watch.lastSnapshot.bestOffer.subOffers)
|
||||||
|
? watch.lastSnapshot.bestOffer.subOffers
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sameFlightMode = watch.searchParams && watch.searchParams.constraints && watch.searchParams.constraints.sameFlightForAllPassengers !== undefined
|
||||||
|
? watch.searchParams.constraints.sameFlightForAllPassengers
|
||||||
|
: true;
|
||||||
|
|
||||||
|
let subOffersHtml = "";
|
||||||
|
if (subOffers.length > 0) {
|
||||||
|
subOffersHtml = `
|
||||||
|
<div class="sub-offers">
|
||||||
|
${subOffers.map(sub => {
|
||||||
|
const subMeta = sub.metadata || {};
|
||||||
|
const subUrl = subMeta.url || sub.url;
|
||||||
|
// 동일 항공편 모드면 상단에 이미 표시되므로 sub-offer에 중복 표시 안 함
|
||||||
|
const subFlightDetails = sameFlightMode ? "" : renderFlightDetails(subMeta);
|
||||||
return `
|
return `
|
||||||
<article class="watch-item" data-watch-id="${watch.id}">
|
<div class="sub-offer-item">
|
||||||
|
<span class="sub-offer-cabin">${escapeHtml(sub.cabin)} (${sub.paxCount}명)</span>
|
||||||
|
<span>${escapeHtml(formatPrice(sub.price, currency))} ${subUrl ? `<a href="${escapeHtml(subUrl)}" target="_blank" style="color:var(--accent); text-decoration:none;">🔗</a>` : ''}</span>
|
||||||
|
</div>
|
||||||
|
${subFlightDetails}`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="watch-item" data-watch-id="${escapeHtml(watch.id)}">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="watch-title">${watch.rawInput || "(no input)"}</h3>
|
<h3 class="watch-title">${escapeHtml(watch.rawInput || "(no input)")}</h3>
|
||||||
<p class="watch-sub">watchId: <code>${watch.id}</code></p>
|
<p class="watch-sub">
|
||||||
|
watchId: <code>${escapeHtml(watch.id)}</code>
|
||||||
|
<span class="badge ${sameFlightMode ? 'ok' : 'warn'}" style="margin-left:8px;">${sameFlightMode ? '동일 항공편' : '개별 최저가'}</span>
|
||||||
|
</p>
|
||||||
|
${flightDetailsHtml ? `${flightDetailsHtml}${urlStr ? `<p class="watch-sub" style="margin-top: 4px;">${urlStr}</p>` : ""}` : (urlStr ? `<p class="watch-sub" style="margin-top: 4px;">${urlStr}</p>` : '')}
|
||||||
</div>
|
</div>
|
||||||
<div class="price">${formatPrice(bestPrice, currency)}</div>
|
<div class="price">${escapeHtml(formatPrice(bestPrice, currency))}</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="meta-grid">
|
<div class="meta-grid">
|
||||||
<div>provider: <code>${provider}</code></div>
|
<div>provider: <code>${escapeHtml(provider)}</code></div>
|
||||||
<div>마지막 갱신: ${formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)}</div>
|
<div>마지막 갱신: ${escapeHtml(
|
||||||
<div>alert mode: <code>${alertOn}</code></div>
|
formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)
|
||||||
<div>target: ${targetPrice ? formatPrice(targetPrice, currency) : "-"}</div>
|
)}</div>
|
||||||
|
<div>alert mode: <code>${escapeHtml(alertOn)}</code></div>
|
||||||
|
<div>target: ${escapeHtml(targetPrice ? formatPrice(targetPrice, currency) : "-")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${subOffersHtml}
|
||||||
|
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<label class="switch-field">
|
<label class="switch-field">
|
||||||
<input data-action="toggle-polling" type="checkbox" ${watch.pollingEnabled ? "checked" : ""} />
|
<input data-action="toggle-polling" type="checkbox" ${watch.pollingEnabled ? "checked" : ""} />
|
||||||
@@ -170,11 +348,14 @@
|
|||||||
<input data-action="toggle-alerts" type="checkbox" ${watch.alertsEnabled ? "checked" : ""} />
|
<input data-action="toggle-alerts" type="checkbox" ${watch.alertsEnabled ? "checked" : ""} />
|
||||||
<span>알림</span>
|
<span>알림</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn secondary" data-action="poll">즉시 조회</button>
|
|
||||||
<button class="btn danger" data-action="delete">삭제</button>
|
<button class="btn danger" data-action="delete">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${watch.lastError ? `<p class="watch-sub">오류: ${watch.lastError.message || "unknown"}</p>` : ""}
|
${
|
||||||
|
watch.lastError
|
||||||
|
? `<p class="watch-sub">오류: ${escapeHtml(watch.lastError.message || "unknown")}</p>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
@@ -192,23 +373,45 @@
|
|||||||
elements.eventList.innerHTML = state.events
|
elements.eventList.innerHTML = state.events
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const payload = event.payload || {};
|
const payload = event.payload || {};
|
||||||
const sent = payload.notificationSent === true;
|
const notificationState =
|
||||||
const badgeClass = sent ? "ok" : "off";
|
typeof payload.notificationState === "string"
|
||||||
const badgeLabel = sent ? "알림 발송" : "알림 억제";
|
? payload.notificationState
|
||||||
|
: payload.notificationSent === true
|
||||||
|
? "sent"
|
||||||
|
: payload.notificationSuppressed === true
|
||||||
|
? "suppressed"
|
||||||
|
: payload.notificationError
|
||||||
|
? "failed"
|
||||||
|
: "suppressed";
|
||||||
|
|
||||||
|
let badgeClass = "off";
|
||||||
|
let badgeLabel = "알림 억제";
|
||||||
|
if (notificationState === "sent") {
|
||||||
|
badgeClass = "ok";
|
||||||
|
badgeLabel = "알림 발송";
|
||||||
|
} else if (notificationState === "failed") {
|
||||||
|
badgeClass = "warn";
|
||||||
|
badgeLabel = "알림 실패";
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="event-item">
|
<article class="event-item">
|
||||||
<div class="event-head">
|
<div class="event-head">
|
||||||
<strong>${payload.eventType || event.eventType || "event"}</strong>
|
<strong>${escapeHtml(payload.eventType || event.eventType || "event")}</strong>
|
||||||
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
watchId: <code>${event.watchId}</code><br />
|
watchId: <code>${escapeHtml(event.watchId)}</code><br />
|
||||||
가격: ${formatPrice(payload.currentBestPrice, payload.currency)}
|
가격: ${escapeHtml(formatPrice(payload.currentBestPrice, payload.currency))}
|
||||||
${Number.isFinite(Number(payload.previousBestPrice))
|
${Number.isFinite(Number(payload.previousBestPrice))
|
||||||
? ` (이전 ${formatPrice(payload.previousBestPrice, payload.currency)})`
|
? ` (이전 ${escapeHtml(formatPrice(payload.previousBestPrice, payload.currency))})`
|
||||||
: ""}<br />
|
: ""}<br />
|
||||||
시각: ${formatDate(event.observedAt)}
|
시각: ${escapeHtml(formatDate(event.observedAt))}
|
||||||
|
${
|
||||||
|
notificationState === "failed" && payload.notificationError?.message
|
||||||
|
? `<br />오류: ${escapeHtml(payload.notificationError.message)}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
@@ -223,7 +426,51 @@
|
|||||||
|
|
||||||
function setConfigBanner(config) {
|
function setConfigBanner(config) {
|
||||||
const warning = config && config.dbWarning ? ` | warning: ${config.dbWarning}` : "";
|
const warning = config && config.dbWarning ? ` | warning: ${config.dbWarning}` : "";
|
||||||
elements.configBanner.textContent = `DB: ${config.dbEngine} | poll: ${config.pollIntervalSec}s${warning}`;
|
const auth = config && config.authEnabled ? "on" : "off";
|
||||||
|
const username = state.user && state.user.username ? state.user.username : "-";
|
||||||
|
elements.configBanner.textContent =
|
||||||
|
`DB: ${config.dbEngine} | poll: ${config.pollIntervalSec}s | auth: ${auth} | user: ${username}${warning}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionUi() {
|
||||||
|
const isAdmin = !!(state.user && state.user.isAdmin);
|
||||||
|
if (elements.systemPanel) {
|
||||||
|
elements.systemPanel.classList.toggle("hidden", !isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.setupLink) {
|
||||||
|
elements.setupLink.href = "/setup";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
const session = await api("/api/session");
|
||||||
|
state.auth = {
|
||||||
|
accountAuthEnabled: Boolean(session && session.auth && session.auth.accountAuthEnabled),
|
||||||
|
tokenAuthEnabled: Boolean(session && session.auth && session.auth.tokenAuthEnabled),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (session && session.authenticated === true && session.user) {
|
||||||
|
state.user = session.user;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.auth.accountAuthEnabled) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
state.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
try {
|
||||||
|
await api("/api/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -269,11 +516,12 @@
|
|||||||
const payload = {
|
const payload = {
|
||||||
input,
|
input,
|
||||||
useLlm: elements.useLlm.checked,
|
useLlm: elements.useLlm.checked,
|
||||||
|
provider: elements.provider.value || undefined,
|
||||||
|
sameFlight: elements.sameFlight.value === "true",
|
||||||
alertOn: elements.alertOn.value,
|
alertOn: elements.alertOn.value,
|
||||||
targetPrice: readTargetPrice(),
|
targetPrice: readTargetPrice(),
|
||||||
pollingEnabled: true,
|
pollingEnabled: true,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
pollNow: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const created = await api("/api/watches", {
|
const created = await api("/api/watches", {
|
||||||
@@ -301,7 +549,7 @@
|
|||||||
if (!action || !watchId) return;
|
if (!action || !watchId) return;
|
||||||
|
|
||||||
const isToggleAction = action === "toggle-polling" || action === "toggle-alerts";
|
const isToggleAction = action === "toggle-polling" || action === "toggle-alerts";
|
||||||
const isButtonAction = action === "poll" || action === "delete";
|
const isButtonAction = action === "delete";
|
||||||
|
|
||||||
if (isToggleAction && event.type !== "change") return;
|
if (isToggleAction && event.type !== "change") return;
|
||||||
if (isButtonAction && event.type !== "click") return;
|
if (isButtonAction && event.type !== "click") return;
|
||||||
@@ -328,14 +576,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "poll") {
|
|
||||||
await api(`/api/watches/${encodeURIComponent(watchId)}/poll`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
await refreshAll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "delete") {
|
if (action === "delete") {
|
||||||
await api(`/api/watches/${encodeURIComponent(watchId)}`, {
|
await api(`/api/watches/${encodeURIComponent(watchId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -358,8 +598,14 @@
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
try {
|
try {
|
||||||
|
state.apiToken = readStoredApiToken();
|
||||||
|
await ensureSession();
|
||||||
const config = await api("/api/config");
|
const config = await api("/api/config");
|
||||||
|
if (config && config.currentUser) {
|
||||||
|
state.user = config.currentUser;
|
||||||
|
}
|
||||||
setConfigBanner(config);
|
setConfigBanner(config);
|
||||||
|
renderSessionUi();
|
||||||
|
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
|
||||||
@@ -386,6 +632,12 @@
|
|||||||
onGlobalToggle().catch((error) => alert(error.message));
|
onGlobalToggle().catch((error) => alert(error.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (elements.logoutBtn) {
|
||||||
|
elements.logoutBtn.addEventListener("click", () => {
|
||||||
|
onLogout().catch((error) => alert(error.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
refreshAll().catch(() => {});
|
refreshAll().catch(() => {});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|||||||
@@ -14,7 +14,13 @@
|
|||||||
<p class="eyebrow">AIR-WATCHER</p>
|
<p class="eyebrow">AIR-WATCHER</p>
|
||||||
<h1>Flight Watch Dashboard</h1>
|
<h1>Flight Watch Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
<div class="config" id="configBanner">초기화 중...</div>
|
<div class="config" id="configBanner">초기화 중...</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<a class="topbar-link" href="/setup" id="setupLink">텔레그램 설정</a>
|
||||||
|
<button class="btn secondary small" id="logoutBtn" type="button">로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel composer">
|
<section class="panel composer">
|
||||||
@@ -32,6 +38,24 @@
|
|||||||
<span>LLM 파싱 사용</span>
|
<span>LLM 파싱 사용</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>크롤러 소스</span>
|
||||||
|
<select id="provider">
|
||||||
|
<option value="">자동 (기본)</option>
|
||||||
|
<!-- <option value="skyscanner">스카이스캐너</option> -->
|
||||||
|
<!-- <option value="naver">네이버 항공권</option> -->
|
||||||
|
<option value="google">구글 플라이트</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>좌석 조합 (다중 클래스)</span>
|
||||||
|
<select id="sameFlight">
|
||||||
|
<option value="true">동일 항공편 유지 (기본)</option>
|
||||||
|
<option value="false">가장 싼 개별 항공편 합산</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>알림 기준</span>
|
<span>알림 기준</span>
|
||||||
<select id="alertOn">
|
<select id="alertOn">
|
||||||
@@ -56,7 +80,7 @@
|
|||||||
<pre id="parseOutput" class="json-view">{}</pre>
|
<pre id="parseOutput" class="json-view">{}</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel system-panel">
|
<section class="panel system-panel" id="systemPanel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>전역 제어</h2>
|
<h2>전역 제어</h2>
|
||||||
<p>전체 추적 동작을 한 번에 켜고 끌 수 있습니다.</p>
|
<p>전체 추적 동작을 한 번에 켜고 끌 수 있습니다.</p>
|
||||||
|
|||||||
116
src/dashboard/login.css
Normal file
116
src/dashboard/login.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
:root {
|
||||||
|
--bg-deep: #071422;
|
||||||
|
--bg-soft: #163a56;
|
||||||
|
--ink-main: #f5fbff;
|
||||||
|
--ink-sub: #b9cee0;
|
||||||
|
--accent: #ff9654;
|
||||||
|
--accent-strong: #ff7f32;
|
||||||
|
--line: rgba(255, 255, 255, 0.16);
|
||||||
|
--panel: rgba(8, 25, 40, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
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 15% -10%, #2a5f8c 0%, var(--bg-deep) 42%),
|
||||||
|
linear-gradient(120deg, #0a1f32, var(--bg-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: 22px 20px;
|
||||||
|
box-shadow: 0 22px 36px rgba(2, 10, 18, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--ink-sub);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--ink-sub);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--ink-main);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 150, 84, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1d1209;
|
||||||
|
background: linear-gradient(130deg, var(--accent), var(--accent-strong));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
color: #ffbcbc;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
41
src/dashboard/login.html
Normal file
41
src/dashboard/login.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Air-Watcher Login</title>
|
||||||
|
<link rel="stylesheet" href="/login.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="auth-layout">
|
||||||
|
<section class="auth-card">
|
||||||
|
<p class="eyebrow">AIR-WATCHER</p>
|
||||||
|
<h1>로그인</h1>
|
||||||
|
<p class="sub">계정별 watch 목록과 텔레그램 알림을 분리해서 사용합니다.</p>
|
||||||
|
|
||||||
|
<form id="loginForm" class="auth-form">
|
||||||
|
<label class="field">
|
||||||
|
<span>아이디</span>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>비밀번호</span>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="loginBtn" class="btn" type="submit">로그인</button>
|
||||||
|
<p id="message" class="message"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
src/dashboard/login.js
Normal file
86
src/dashboard/login.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const form = document.getElementById("loginForm");
|
||||||
|
const usernameInput = document.getElementById("username");
|
||||||
|
const passwordInput = document.getElementById("password");
|
||||||
|
const loginBtn = document.getElementById("loginBtn");
|
||||||
|
const message = document.getElementById("message");
|
||||||
|
|
||||||
|
function setMessage(text) {
|
||||||
|
message.textContent = text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || `요청 실패 (${response.status})`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redirectIfAlreadyLoggedIn() {
|
||||||
|
try {
|
||||||
|
const session = await api("/api/session");
|
||||||
|
if (session && session.authenticated === true) {
|
||||||
|
window.location.href = "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountAuthEnabled = Boolean(
|
||||||
|
session && session.auth && session.auth.accountAuthEnabled
|
||||||
|
);
|
||||||
|
if (!accountAuthEnabled) {
|
||||||
|
setMessage("계정 로그인 모드가 비활성화되어 있습니다. 대시보드로 이동하세요.");
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore session check errors on login page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setMessage("");
|
||||||
|
|
||||||
|
const username = usernameInput.value.trim();
|
||||||
|
const password = passwordInput.value.trim();
|
||||||
|
if (!username || !password) {
|
||||||
|
setMessage("아이디/비밀번호를 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message || "로그인에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
onSubmit(event).catch((error) => {
|
||||||
|
setMessage(error.message || "로그인에 실패했습니다.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
redirectIfAlreadyLoggedIn().catch(() => {});
|
||||||
|
})();
|
||||||
194
src/dashboard/setup.css
Normal file
194
src/dashboard/setup.css
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
: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.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 26px 16px 42px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 18px 30px rgba(3, 10, 17, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--ink-sub);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--ink-sub);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.row span {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 150, 84, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
color: #1d1209;
|
||||||
|
background: linear-gradient(130deg, var(--accent), var(--accent-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
color: var(--ink-main);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
color: #ffd4b8;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-sub);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide code {
|
||||||
|
color: var(--ink-main);
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/dashboard/setup.html
Normal file
78
src/dashboard/setup.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Air-Watcher Telegram Setup</title>
|
||||||
|
<link rel="stylesheet" href="/setup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="layout">
|
||||||
|
<header class="panel topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">AIR-WATCHER</p>
|
||||||
|
<h1>텔레그램 설정</h1>
|
||||||
|
<p id="userLabel" class="sub">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="link-btn" href="/">대시보드</a>
|
||||||
|
<button id="logoutBtn" class="btn secondary" type="button">로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>내 알림 설정</h2>
|
||||||
|
<form id="settingsForm" class="form">
|
||||||
|
<label class="field row">
|
||||||
|
<input id="telegramEnabled" type="checkbox" />
|
||||||
|
<span>텔레그램 알림 사용</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>수신 Chat ID</span>
|
||||||
|
<input id="telegramChatId" type="text" placeholder="예: 123456789" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Bot Token (선택: 비우면 서버 기본 토큰 사용)</span>
|
||||||
|
<input id="telegramBotToken" type="password" placeholder="123456:ABC..." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field row">
|
||||||
|
<input id="clearBotToken" type="checkbox" />
|
||||||
|
<span>저장된 내 Bot Token 삭제</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Telegram API Base (선택)</span>
|
||||||
|
<input id="telegramApiBase" type="text" placeholder="https://api.telegram.org" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="saveBtn" class="btn primary" type="submit">설정 저장</button>
|
||||||
|
<button id="testBtn" class="btn secondary" type="button">테스트 메시지 보내기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="statusMessage" class="status"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>텔레그램 연동 안내</h2>
|
||||||
|
<ol class="guide">
|
||||||
|
<li>텔레그램에서 <code>@BotFather</code>에게 <code>/newbot</code> 명령으로 봇을 만듭니다.</li>
|
||||||
|
<li>발급된 Bot Token을 위 설정에 저장합니다. (또는 운영자가 서버 전역 토큰을 설정)</li>
|
||||||
|
<li>내가 만든 봇과 1:1 대화를 시작하고 아무 메시지나 1개 보냅니다.</li>
|
||||||
|
<li>
|
||||||
|
브라우저에서
|
||||||
|
<code>https://api.telegram.org/bot<TOKEN>/getUpdates</code>
|
||||||
|
를 열어 <code>chat.id</code> 값을 확인합니다.
|
||||||
|
</li>
|
||||||
|
<li>확인한 <code>chat.id</code>를 위 <code>수신 Chat ID</code>에 저장하고 테스트 전송을 눌러 확인합니다.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/setup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
151
src/dashboard/setup.js
Normal file
151
src/dashboard/setup.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
userLabel: document.getElementById("userLabel"),
|
||||||
|
logoutBtn: document.getElementById("logoutBtn"),
|
||||||
|
settingsForm: document.getElementById("settingsForm"),
|
||||||
|
telegramEnabled: document.getElementById("telegramEnabled"),
|
||||||
|
telegramChatId: document.getElementById("telegramChatId"),
|
||||||
|
telegramBotToken: document.getElementById("telegramBotToken"),
|
||||||
|
clearBotToken: document.getElementById("clearBotToken"),
|
||||||
|
telegramApiBase: document.getElementById("telegramApiBase"),
|
||||||
|
saveBtn: document.getElementById("saveBtn"),
|
||||||
|
testBtn: document.getElementById("testBtn"),
|
||||||
|
statusMessage: document.getElementById("statusMessage"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
user: null,
|
||||||
|
settings: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
elements.statusMessage.textContent = text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || `요청 실패 (${response.status})`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const user = state.user;
|
||||||
|
const settings = state.settings;
|
||||||
|
if (!user || !settings) return;
|
||||||
|
|
||||||
|
elements.userLabel.textContent = `${user.username} 계정`;
|
||||||
|
elements.telegramEnabled.checked = settings.telegramEnabled === true;
|
||||||
|
elements.telegramChatId.value = settings.telegramChatId || "";
|
||||||
|
elements.telegramApiBase.value = settings.telegramApiBase || "";
|
||||||
|
elements.telegramBotToken.value = "";
|
||||||
|
elements.clearBotToken.checked = false;
|
||||||
|
|
||||||
|
if (settings.hasTelegramBotToken) {
|
||||||
|
elements.telegramBotToken.placeholder = `저장됨 (${settings.telegramBotTokenMasked || "***"})`;
|
||||||
|
} else {
|
||||||
|
elements.telegramBotToken.placeholder = "123456:ABC...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionAndSettings() {
|
||||||
|
const session = await api("/api/session");
|
||||||
|
if (!session || session.authenticated !== true || !session.user) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.user = session.user;
|
||||||
|
|
||||||
|
const me = await api("/api/me");
|
||||||
|
state.settings = me.settings;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus("");
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
telegramEnabled: elements.telegramEnabled.checked,
|
||||||
|
telegramChatId: elements.telegramChatId.value.trim() || null,
|
||||||
|
telegramApiBase: elements.telegramApiBase.value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenValue = elements.telegramBotToken.value.trim();
|
||||||
|
if (elements.clearBotToken.checked) {
|
||||||
|
payload.telegramBotToken = null;
|
||||||
|
} else if (tokenValue) {
|
||||||
|
payload.telegramBotToken = tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const updated = await api("/api/me/telegram", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
state.settings = updated.settings;
|
||||||
|
render();
|
||||||
|
setStatus("설정을 저장했습니다.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || "설정 저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
elements.saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTestSend() {
|
||||||
|
setStatus("");
|
||||||
|
elements.testBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await api("/api/me/telegram/test", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
setStatus(result.message || "테스트 전송 완료");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message || "테스트 전송에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
elements.testBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
try {
|
||||||
|
await api("/api/logout", { method: "POST" });
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.settingsForm.addEventListener("submit", (event) => {
|
||||||
|
onSave(event).catch((error) => setStatus(error.message || "설정 저장에 실패했습니다."));
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.testBtn.addEventListener("click", () => {
|
||||||
|
onTestSend().catch((error) => setStatus(error.message || "테스트 전송에 실패했습니다."));
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.logoutBtn.addEventListener("click", () => {
|
||||||
|
onLogout().catch((error) => setStatus(error.message || "로그아웃 실패"));
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSessionAndSettings().catch((error) => {
|
||||||
|
setStatus(error.message || "초기화 실패");
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
normalizeAlertOn,
|
normalizeAlertOn,
|
||||||
parseTargetPrice,
|
parseTargetPrice,
|
||||||
} = require("./alertRules");
|
} = require("./alertRules");
|
||||||
|
const { TelegramNotifier } = require("./notifier");
|
||||||
const { createHttpError, parseBoolean } = require("./dashboardUtils");
|
const { createHttpError, parseBoolean } = require("./dashboardUtils");
|
||||||
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
|
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
|
||||||
|
|
||||||
@@ -21,8 +22,58 @@ function hasOwnProperty(source, key) {
|
|||||||
return Object.prototype.hasOwnProperty.call(source, key);
|
return Object.prototype.hasOwnProperty.call(source, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireUser(context = {}) {
|
||||||
|
const user = context.user;
|
||||||
|
if (!user || typeof user.username !== "string" || !user.username.trim()) {
|
||||||
|
throw createHttpError(401, "Unauthorized");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: user.username.trim(),
|
||||||
|
isAdmin: user.isAdmin === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessWatch(user, watch) {
|
||||||
|
if (!watch) return false;
|
||||||
|
if (user.isAdmin) return true;
|
||||||
|
return watch.ownerId === user.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterWatchesForUser(user, watches) {
|
||||||
|
if (user.isAdmin) return watches;
|
||||||
|
return watches.filter((watch) => watch.ownerId === user.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOptionalString(value) {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value) {
|
||||||
|
const secret = toOptionalString(value);
|
||||||
|
if (!secret) return "";
|
||||||
|
if (secret.length <= 6) return `${secret.slice(0, 1)}***`;
|
||||||
|
return `${secret.slice(0, 3)}***${secret.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUserProfile(store, username) {
|
||||||
|
if (!store || typeof store.getUserProfile !== "function") {
|
||||||
|
throw createHttpError(500, "store.getUserProfile is not available");
|
||||||
|
}
|
||||||
|
return store.getUserProfile(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserProfile(store, username, patch) {
|
||||||
|
if (!store || typeof store.upsertUserProfile !== "function") {
|
||||||
|
throw createHttpError(500, "store.upsertUserProfile is not available");
|
||||||
|
}
|
||||||
|
return store.upsertUserProfile(username, patch);
|
||||||
|
}
|
||||||
|
|
||||||
function createDashboardApi({ watcher, store }) {
|
function createDashboardApi({ watcher, store }) {
|
||||||
async function parseInput(body = {}) {
|
async function parseInput(body = {}, context = {}) {
|
||||||
|
requireUser(context);
|
||||||
const input = readInput(body);
|
const input = readInput(body);
|
||||||
|
|
||||||
return extractFlightSearchRequest(input, {
|
return extractFlightSearchRequest(input, {
|
||||||
@@ -30,18 +81,99 @@ function createDashboardApi({ watcher, store }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWatch(body = {}) {
|
async function createWatch(body = {}, context = {}) {
|
||||||
const extracted = await parseInput(body);
|
const user = requireUser(context);
|
||||||
|
const extracted = await parseInput(body, context);
|
||||||
const input = readInput(body);
|
const input = readInput(body);
|
||||||
|
|
||||||
|
// 필수 데이터 누락 시 조기 차단 (IP 차단 방지)
|
||||||
|
const missing = extracted.params.missingFields || [];
|
||||||
|
if (missing.includes("segments")) {
|
||||||
|
throw createHttpError(400, "출발지 또는 도착지 정보가 파악되지 않았습니다. 문장을 다시 작성해주세요.");
|
||||||
|
}
|
||||||
|
if (missing.includes("departureDateWindow")) {
|
||||||
|
throw createHttpError(400, "출발 날짜 정보가 파악되지 않았습니다. (예: '11월 25일에 출발') 문장을 구체적으로 적어주세요.");
|
||||||
|
}
|
||||||
|
if (extracted.params.tripType === "round_trip" && missing.includes("stayDurationDays")) {
|
||||||
|
throw createHttpError(400, "왕복 여정입니다만, 체류 기간(며칠 동안 여행하는지)이 파악되지 않았습니다. (예: '12일 체류')");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.sameFlight !== undefined && extracted.params && extracted.params.constraints) {
|
||||||
|
extracted.params.constraints.sameFlightForAllPassengers = parseBoolean(body.sameFlight, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.provider && typeof body.provider === "string") {
|
||||||
|
extracted.params.provider = body.provider.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const alertRules = buildAlertRules({
|
const alertRules = buildAlertRules({
|
||||||
targetPrice: body.targetPrice,
|
targetPrice: body.targetPrice,
|
||||||
alertOn: body.alertOn || "both",
|
alertOn: body.alertOn || "both",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const paramVariations = [];
|
||||||
|
const baseParams = extracted.params;
|
||||||
|
const window = baseParams.departureDateWindow;
|
||||||
|
const stay = baseParams.stayDurationDays;
|
||||||
|
|
||||||
|
let startDates = [];
|
||||||
|
if (window && window.from && window.to) {
|
||||||
|
const fromDate = new Date(window.from);
|
||||||
|
const toDate = new Date(window.to);
|
||||||
|
if (fromDate <= toDate) {
|
||||||
|
const current = new Date(fromDate);
|
||||||
|
while (current <= toDate) {
|
||||||
|
startDates.push(current.toISOString().split("T")[0]);
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDates.length === 0 && window && window.from) {
|
||||||
|
startDates.push(window.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stayDays = [];
|
||||||
|
if (stay && stay.minDays && stay.maxDays) {
|
||||||
|
for (let i = stay.minDays; i <= stay.maxDays; i += 1) {
|
||||||
|
stayDays.push(i);
|
||||||
|
}
|
||||||
|
} else if (stay && stay.minDays) {
|
||||||
|
stayDays.push(stay.minDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDates.length === 0) {
|
||||||
|
paramVariations.push(baseParams);
|
||||||
|
} else if (stayDays.length === 0) {
|
||||||
|
for (const d of startDates) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(baseParams));
|
||||||
|
copy.departureDateWindow = { from: d, to: d };
|
||||||
|
paramVariations.push(copy);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const d of startDates) {
|
||||||
|
for (const sd of stayDays) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(baseParams));
|
||||||
|
copy.departureDateWindow = { from: d, to: d };
|
||||||
|
copy.stayDurationDays = { minDays: sd, maxDays: sd };
|
||||||
|
paramVariations.push(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstWatchId = null;
|
||||||
|
|
||||||
|
for (const params of paramVariations) {
|
||||||
|
let suffix = "";
|
||||||
|
if (params.departureDateWindow?.from) suffix += params.departureDateWindow.from;
|
||||||
|
if (params.stayDurationDays?.minDays) suffix += ` (체류 ${params.stayDurationDays.minDays}일)`;
|
||||||
|
|
||||||
|
const watchInput = paramVariations.length > 1 && suffix ? `[${suffix}] ${input}` : input;
|
||||||
|
|
||||||
const watchId = watcher.addWatch({
|
const watchId = watcher.addWatch({
|
||||||
rawInput: input,
|
ownerId: user.username,
|
||||||
searchParams: extracted.params,
|
rawInput: watchInput,
|
||||||
|
searchParams: params,
|
||||||
alertRules,
|
alertRules,
|
||||||
pollingEnabled: parseBoolean(body.pollingEnabled, true),
|
pollingEnabled: parseBoolean(body.pollingEnabled, true),
|
||||||
alertsEnabled: parseBoolean(body.alertsEnabled, true),
|
alertsEnabled: parseBoolean(body.alertsEnabled, true),
|
||||||
@@ -50,17 +182,28 @@ function createDashboardApi({ watcher, store }) {
|
|||||||
const created = watcher.getWatch(watchId);
|
const created = watcher.getWatch(watchId);
|
||||||
await store.saveWatch(created);
|
await store.saveWatch(created);
|
||||||
|
|
||||||
if (parseBoolean(body.pollNow, false)) {
|
if (!firstWatchId) firstWatchId = watchId;
|
||||||
await watcher.pollWatch(watchId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 신규 추가 후 즉시 순차 크롤링을 백그라운드에서 트리거합니다.
|
||||||
|
watcher.pollAll().catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[DashboardAPI] Immediate poll failed:", error);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
watch: watcher.getWatch(watchId),
|
watch: watcher.getWatch(firstWatchId),
|
||||||
parserSource: extracted.source,
|
parserSource: extracted.source,
|
||||||
|
createdCount: paramVariations.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSystem(body = {}) {
|
async function updateSystem(body = {}, context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
throw createHttpError(403, "관리자 계정만 시스템 전역 설정을 변경할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
const controls = watcher.setGlobalControls({
|
const controls = watcher.setGlobalControls({
|
||||||
crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled),
|
crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled),
|
||||||
alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled),
|
alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled),
|
||||||
@@ -69,35 +212,30 @@ function createDashboardApi({ watcher, store }) {
|
|||||||
return { controls };
|
return { controls };
|
||||||
}
|
}
|
||||||
|
|
||||||
function listWatches() {
|
function listWatches(context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
|
const allWatches = watcher.listWatches();
|
||||||
return {
|
return {
|
||||||
controls: watcher.getGlobalControls(),
|
controls: watcher.getGlobalControls(),
|
||||||
watches: watcher.listWatches(),
|
watches: filterWatchesForUser(user, allWatches),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listEvents(rawLimit) {
|
async function listEvents(rawLimit, context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
const limit = Number(rawLimit);
|
const limit = Number(rawLimit);
|
||||||
const events = await store.listEvents(Number.isFinite(limit) ? limit : 50);
|
const safeLimit = Number.isFinite(limit) ? limit : 50;
|
||||||
|
const events = await store.listEvents(
|
||||||
|
safeLimit,
|
||||||
|
user.isAdmin ? {} : { ownerId: user.username }
|
||||||
|
);
|
||||||
return { events };
|
return { events };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollWatch(watchId) {
|
async function updateWatch(watchId, body = {}, context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
const existing = watcher.getWatch(watchId);
|
const existing = watcher.getWatch(watchId);
|
||||||
if (!existing) {
|
if (!existing || !canAccessWatch(user, 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}`);
|
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,19 +267,124 @@ function createDashboardApi({ watcher, store }) {
|
|||||||
return { watch: updated };
|
return { watch: updated };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteWatch(watchId) {
|
async function deleteWatch(watchId, context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
|
const existing = watcher.getWatch(watchId);
|
||||||
|
if (!existing || !canAccessWatch(user, existing)) {
|
||||||
|
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
|
||||||
|
}
|
||||||
|
|
||||||
const existed = watcher.removeWatch(watchId);
|
const existed = watcher.removeWatch(watchId);
|
||||||
await store.deleteWatch(watchId);
|
await store.deleteWatch(watchId);
|
||||||
return { deleted: existed };
|
return { deleted: existed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMe(context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
|
const profile = await ensureUserProfile(store, user.username);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
settings: {
|
||||||
|
telegramEnabled: profile.telegramEnabled === true,
|
||||||
|
telegramChatId: profile.telegramChatId || "",
|
||||||
|
telegramApiBase: profile.telegramApiBase || process.env.TELEGRAM_API_BASE || "",
|
||||||
|
hasTelegramBotToken: Boolean(profile.telegramBotToken),
|
||||||
|
telegramBotTokenMasked: maskSecret(profile.telegramBotToken),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMyTelegram(body = {}, context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
|
const previous = await ensureUserProfile(store, user.username);
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
if (hasOwnProperty(body, "telegramEnabled")) {
|
||||||
|
patch.telegramEnabled = parseBoolean(body.telegramEnabled, previous.telegramEnabled);
|
||||||
|
}
|
||||||
|
if (hasOwnProperty(body, "telegramChatId")) {
|
||||||
|
patch.telegramChatId = toOptionalString(body.telegramChatId);
|
||||||
|
}
|
||||||
|
if (hasOwnProperty(body, "telegramBotToken")) {
|
||||||
|
patch.telegramBotToken = toOptionalString(body.telegramBotToken);
|
||||||
|
}
|
||||||
|
if (hasOwnProperty(body, "telegramApiBase")) {
|
||||||
|
patch.telegramApiBase = toOptionalString(body.telegramApiBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...previous,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (merged.telegramEnabled === true && !merged.telegramChatId) {
|
||||||
|
throw createHttpError(400, "telegramEnabled=true이면 telegramChatId가 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateUserProfile(store, user.username, patch);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
settings: {
|
||||||
|
telegramEnabled: updated.telegramEnabled === true,
|
||||||
|
telegramChatId: updated.telegramChatId || "",
|
||||||
|
telegramApiBase: updated.telegramApiBase || process.env.TELEGRAM_API_BASE || "",
|
||||||
|
hasTelegramBotToken: Boolean(updated.telegramBotToken),
|
||||||
|
telegramBotTokenMasked: maskSecret(updated.telegramBotToken),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMyTelegramTest(context = {}) {
|
||||||
|
const user = requireUser(context);
|
||||||
|
const profile = await ensureUserProfile(store, user.username);
|
||||||
|
|
||||||
|
const botToken =
|
||||||
|
profile.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || process.env.NOTIFY_TELEGRAM_BOT_TOKEN;
|
||||||
|
const chatId = profile.telegramChatId;
|
||||||
|
if (!botToken || !chatId) {
|
||||||
|
throw createHttpError(
|
||||||
|
400,
|
||||||
|
"테스트 전송에 필요한 값이 부족합니다. telegramBotToken(또는 전역 TELEGRAM_BOT_TOKEN)과 telegramChatId를 설정하세요."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifier = new TelegramNotifier({
|
||||||
|
botToken,
|
||||||
|
chatId,
|
||||||
|
apiBase: profile.telegramApiBase || process.env.TELEGRAM_API_BASE,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifier.notify({
|
||||||
|
watchId: "telegram-setup-test",
|
||||||
|
eventType: "test",
|
||||||
|
currentBestPrice: 0,
|
||||||
|
previousBestPrice: null,
|
||||||
|
threshold: null,
|
||||||
|
currency: "KRW",
|
||||||
|
rawInput: `[${user.username}] 텔레그램 설정 테스트`,
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
bestOffer: {
|
||||||
|
provider: "setup",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "텔레그램 테스트 메시지를 전송했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createWatch,
|
createWatch,
|
||||||
deleteWatch,
|
deleteWatch,
|
||||||
|
getMe,
|
||||||
listEvents,
|
listEvents,
|
||||||
listWatches,
|
listWatches,
|
||||||
parseInput,
|
parseInput,
|
||||||
pollWatch,
|
sendMyTelegramTest,
|
||||||
|
updateMyTelegram,
|
||||||
updateSystem,
|
updateSystem,
|
||||||
updateWatch,
|
updateWatch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,23 @@ const { createHttpError } = require("./dashboardUtils");
|
|||||||
|
|
||||||
const ASSET_MAP = {
|
const ASSET_MAP = {
|
||||||
"/": { file: "index.html", contentType: "text/html; charset=utf-8" },
|
"/": { file: "index.html", contentType: "text/html; charset=utf-8" },
|
||||||
|
"/login": { file: "login.html", contentType: "text/html; charset=utf-8" },
|
||||||
|
"/setup": { file: "setup.html", contentType: "text/html; charset=utf-8" },
|
||||||
"/dashboard.css": { file: "dashboard.css", contentType: "text/css; charset=utf-8" },
|
"/dashboard.css": { file: "dashboard.css", contentType: "text/css; charset=utf-8" },
|
||||||
|
"/login.css": { file: "login.css", contentType: "text/css; charset=utf-8" },
|
||||||
|
"/setup.css": { file: "setup.css", contentType: "text/css; charset=utf-8" },
|
||||||
"/dashboard.js": {
|
"/dashboard.js": {
|
||||||
file: "dashboard.js",
|
file: "dashboard.js",
|
||||||
contentType: "application/javascript; charset=utf-8",
|
contentType: "application/javascript; charset=utf-8",
|
||||||
},
|
},
|
||||||
|
"/login.js": {
|
||||||
|
file: "login.js",
|
||||||
|
contentType: "application/javascript; charset=utf-8",
|
||||||
|
},
|
||||||
|
"/setup.js": {
|
||||||
|
file: "setup.js",
|
||||||
|
contentType: "application/javascript; charset=utf-8",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadDashboardAsset(assetsDir, requestPath) {
|
function loadDashboardAsset(assetsDir, requestPath) {
|
||||||
|
|||||||
269
src/dashboardAuth.js
Normal file
269
src/dashboardAuth.js
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = "airwatcher_sid";
|
||||||
|
const DEFAULT_SESSION_TTL_SEC = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
function normalizeUsername(value) {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassword(value) {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function secureEqual(left, right) {
|
||||||
|
const leftBuffer = Buffer.from(String(left));
|
||||||
|
const rightBuffer = Buffer.from(String(right));
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||||
|
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieHeader(cookieHeader) {
|
||||||
|
if (typeof cookieHeader !== "string" || cookieHeader.trim() === "") return {};
|
||||||
|
|
||||||
|
return cookieHeader.split(";").reduce((acc, pair) => {
|
||||||
|
const index = pair.indexOf("=");
|
||||||
|
if (index <= 0) return acc;
|
||||||
|
|
||||||
|
const key = pair.slice(0, index).trim();
|
||||||
|
const value = pair.slice(index + 1).trim();
|
||||||
|
if (!key) return acc;
|
||||||
|
try {
|
||||||
|
acc[key] = decodeURIComponent(value);
|
||||||
|
} catch (_error) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBasicAuthorization(headerValue) {
|
||||||
|
if (typeof headerValue !== "string") return null;
|
||||||
|
const matched = headerValue.trim().match(/^Basic\s+(.+)$/i);
|
||||||
|
if (!matched) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(matched[1], "base64").toString("utf8");
|
||||||
|
const separator = decoded.indexOf(":");
|
||||||
|
if (separator < 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: decoded.slice(0, separator),
|
||||||
|
password: decoded.slice(separator + 1),
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value, fallback) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUsers(rawUsers) {
|
||||||
|
if (typeof rawUsers !== "string" || rawUsers.trim() === "") {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = new Map();
|
||||||
|
const entries = rawUsers
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const separator = entry.indexOf(":");
|
||||||
|
if (separator <= 0) continue;
|
||||||
|
|
||||||
|
const username = normalizeUsername(entry.slice(0, separator));
|
||||||
|
const password = normalizePassword(entry.slice(separator + 1));
|
||||||
|
if (!username || !password) continue;
|
||||||
|
users.set(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdminUsers(rawAdminUsers, knownUsers) {
|
||||||
|
const admins = new Set();
|
||||||
|
if (typeof rawAdminUsers === "string" && rawAdminUsers.trim() !== "") {
|
||||||
|
for (const part of rawAdminUsers.split(/[\n,]/)) {
|
||||||
|
const username = normalizeUsername(part);
|
||||||
|
if (username) admins.add(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admins.size === 0 && knownUsers.size > 0) {
|
||||||
|
const firstUser = knownUsers.keys().next().value;
|
||||||
|
if (firstUser) admins.add(firstUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return admins;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDashboardAuth(options = {}) {
|
||||||
|
const users = parseUsers(
|
||||||
|
options.users !== undefined
|
||||||
|
? options.users
|
||||||
|
: process.env.DASHBOARD_USERS || process.env.DASHBOARD_BASIC_AUTH_USERS || ""
|
||||||
|
);
|
||||||
|
const admins = parseAdminUsers(
|
||||||
|
options.adminUsers !== undefined ? options.adminUsers : process.env.DASHBOARD_ADMIN_USERS,
|
||||||
|
users
|
||||||
|
);
|
||||||
|
const sessionTtlSec = parsePositiveInt(
|
||||||
|
options.sessionTtlSec !== undefined
|
||||||
|
? options.sessionTtlSec
|
||||||
|
: process.env.DASHBOARD_SESSION_TTL_SEC,
|
||||||
|
DEFAULT_SESSION_TTL_SEC
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return users.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(username) {
|
||||||
|
return admins.has(normalizeUsername(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUser(username, authType) {
|
||||||
|
const normalized = normalizeUsername(username);
|
||||||
|
return {
|
||||||
|
username: normalized,
|
||||||
|
isAdmin: isAdmin(normalized),
|
||||||
|
authType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredSessions() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, session] of sessions.entries()) {
|
||||||
|
if (!session || session.expiresAt <= now) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCredentials(username, password) {
|
||||||
|
const normalizedUsername = normalizeUsername(username);
|
||||||
|
const normalizedPassword = normalizePassword(password);
|
||||||
|
if (!normalizedUsername || !normalizedPassword) return null;
|
||||||
|
|
||||||
|
const expectedPassword = users.get(normalizedUsername);
|
||||||
|
if (!expectedPassword) return null;
|
||||||
|
|
||||||
|
return secureEqual(expectedPassword, normalizedPassword) ? normalizedUsername : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueSession(username) {
|
||||||
|
const sessionId = crypto.randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = Date.now() + sessionTtlSec * 1000;
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
username,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionIdFromHeaders(headers = {}) {
|
||||||
|
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie[0] : headers.cookie;
|
||||||
|
const cookies = parseCookieHeader(cookieHeader);
|
||||||
|
const sessionId = cookies[SESSION_COOKIE_NAME];
|
||||||
|
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionCookie(sessionId) {
|
||||||
|
return `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${sessionTtlSec}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionClearCookie() {
|
||||||
|
return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(username, password) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
throw new Error("Dashboard account login is not configured. Set DASHBOARD_USERS.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifiedUsername = verifyCredentials(username, password);
|
||||||
|
if (!verifiedUsername) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = issueSession(verifiedUsername);
|
||||||
|
return {
|
||||||
|
user: toUser(verifiedUsername, "session"),
|
||||||
|
sessionId,
|
||||||
|
setCookie: buildSessionCookie(sessionId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserFromSession(headers = {}) {
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
const sessionId = readSessionIdFromHeaders(headers);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
if (session.expiresAt <= Date.now()) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toUser(session.username, "session");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserFromBasicAuth(headers = {}) {
|
||||||
|
const authHeader = Array.isArray(headers.authorization)
|
||||||
|
? headers.authorization[0]
|
||||||
|
: headers.authorization;
|
||||||
|
const parsed = parseBasicAuthorization(authHeader);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const verifiedUsername = verifyCredentials(parsed.username, parsed.password);
|
||||||
|
if (!verifiedUsername) return null;
|
||||||
|
return toUser(verifiedUsername, "basic");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserFromRequest(headers = {}) {
|
||||||
|
if (!isEnabled()) return null;
|
||||||
|
|
||||||
|
const fromSession = getUserFromSession(headers);
|
||||||
|
if (fromSession) return fromSession;
|
||||||
|
|
||||||
|
return getUserFromBasicAuth(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(headers = {}) {
|
||||||
|
const sessionId = readSessionIdFromHeaders(headers);
|
||||||
|
if (sessionId) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
setCookie: buildSessionClearCookie(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: isEnabled(),
|
||||||
|
sessionCookieName: SESSION_COOKIE_NAME,
|
||||||
|
listUsers: () => Array.from(users.keys()),
|
||||||
|
getUserFromRequest,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
toUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
createDashboardAuth,
|
||||||
|
};
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
const { createCrawlerClient } = require("./crawlerClient");
|
const { createCrawlerClient } = require("./crawlerClient");
|
||||||
const { createDashboardStore } = require("./dashboardStore");
|
const { createDashboardStore } = require("./dashboardStore");
|
||||||
const { loadDotEnv } = require("./envLoader");
|
const { loadDotEnv } = require("./envLoader");
|
||||||
const { createNotifier } = require("./notifier");
|
const { createNotifier, TelegramNotifier } = require("./notifier");
|
||||||
|
const { MIN_CRAWL_INTERVAL_SEC, normalizeCrawlIntervalSec } = require("./pollingConfig");
|
||||||
const { PriceWatcher } = require("./priceWatcher");
|
const { PriceWatcher } = require("./priceWatcher");
|
||||||
const { parsePort } = require("./dashboardUtils");
|
|
||||||
|
|
||||||
function toRestoredWatchPayload(watch) {
|
function toRestoredWatchPayload(watch) {
|
||||||
return {
|
return {
|
||||||
id: watch.id,
|
id: watch.id,
|
||||||
|
ownerId: watch.ownerId || null,
|
||||||
rawInput: watch.rawInput,
|
rawInput: watch.rawInput,
|
||||||
searchParams: watch.searchParams,
|
searchParams: watch.searchParams,
|
||||||
alertRules: watch.alertRules,
|
alertRules: watch.alertRules,
|
||||||
@@ -22,13 +23,79 @@ function toRestoredWatchPayload(watch) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDashboardNotifier({ store, fallbackNotifier, logger }) {
|
||||||
|
const notifierCache = new Map();
|
||||||
|
const fallbackIsGlobalTelegram = fallbackNotifier instanceof TelegramNotifier;
|
||||||
|
const globalTelegramBotToken =
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN || process.env.NOTIFY_TELEGRAM_BOT_TOKEN || null;
|
||||||
|
const globalTelegramApiBase = process.env.TELEGRAM_API_BASE || undefined;
|
||||||
|
|
||||||
|
async function notifyViaTelegram(event, profile) {
|
||||||
|
const botToken = profile.telegramBotToken || globalTelegramBotToken;
|
||||||
|
const chatId = profile.telegramChatId;
|
||||||
|
if (!botToken || !chatId) {
|
||||||
|
throw new Error("telegramBotToken and telegramChatId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = profile.telegramApiBase || globalTelegramApiBase;
|
||||||
|
const cacheKey = `${botToken}|${chatId}|${apiBase || ""}`;
|
||||||
|
let notifier = notifierCache.get(cacheKey);
|
||||||
|
if (!notifier) {
|
||||||
|
notifier = new TelegramNotifier({
|
||||||
|
botToken,
|
||||||
|
chatId,
|
||||||
|
apiBase,
|
||||||
|
});
|
||||||
|
notifierCache.set(cacheKey, notifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifier.notify(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async notify(event, context = {}) {
|
||||||
|
const ownerId =
|
||||||
|
context &&
|
||||||
|
context.watch &&
|
||||||
|
typeof context.watch.ownerId === "string" &&
|
||||||
|
context.watch.ownerId.trim()
|
||||||
|
? context.watch.ownerId.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (ownerId && store && typeof store.getUserProfile === "function") {
|
||||||
|
try {
|
||||||
|
const profile = await store.getUserProfile(ownerId);
|
||||||
|
if (profile && profile.telegramEnabled === true) {
|
||||||
|
await notifyViaTelegram(event, profile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackIsGlobalTelegram) {
|
||||||
|
// Owner-bound watch should not leak to a shared/global telegram target.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`per-user notifier failed for ${ownerId}: ${error.message}`);
|
||||||
|
if (fallbackIsGlobalTelegram) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fallbackNotifier.notify(event, context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createDashboardRuntime(options = {}) {
|
async function createDashboardRuntime(options = {}) {
|
||||||
loadDotEnv();
|
loadDotEnv();
|
||||||
|
|
||||||
const logger = options.logger || console;
|
const logger = options.logger || console;
|
||||||
const pollIntervalSec = parsePort(
|
const pollIntervalSec = normalizeCrawlIntervalSec(
|
||||||
options.pollIntervalSec || process.env.DASHBOARD_POLL_INTERVAL_SEC,
|
options.pollIntervalSec !== undefined
|
||||||
60
|
? options.pollIntervalSec
|
||||||
|
: process.env.DASHBOARD_POLL_INTERVAL_SEC,
|
||||||
|
MIN_CRAWL_INTERVAL_SEC
|
||||||
);
|
);
|
||||||
|
|
||||||
const storeSetup =
|
const storeSetup =
|
||||||
@@ -38,7 +105,12 @@ async function createDashboardRuntime(options = {}) {
|
|||||||
|
|
||||||
const store = storeSetup.store;
|
const store = storeSetup.store;
|
||||||
const crawler = options.crawler || createCrawlerClient(options.crawlerOptions || {});
|
const crawler = options.crawler || createCrawlerClient(options.crawlerOptions || {});
|
||||||
const notifier = options.notifier || createNotifier(options.notifierOptions || {});
|
const baseNotifier = options.notifier || createNotifier(options.notifierOptions || {});
|
||||||
|
const notifier = createDashboardNotifier({
|
||||||
|
store,
|
||||||
|
fallbackNotifier: baseNotifier,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
const watcher = new PriceWatcher({
|
const watcher = new PriceWatcher({
|
||||||
crawler,
|
crawler,
|
||||||
@@ -50,8 +122,18 @@ async function createDashboardRuntime(options = {}) {
|
|||||||
await store.savePollResult(watch.id, result);
|
await store.savePollResult(watch.id, result);
|
||||||
|
|
||||||
if (result && result.alert) {
|
if (result && result.alert) {
|
||||||
|
const notificationState =
|
||||||
|
result.notificationSent === true
|
||||||
|
? "sent"
|
||||||
|
: result.error && result.error.phase === "notify"
|
||||||
|
? "failed"
|
||||||
|
: result.alert.notificationSuppressed === true
|
||||||
|
? "suppressed"
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
await store.saveEvent({
|
await store.saveEvent({
|
||||||
watchId: watch.id,
|
watchId: watch.id,
|
||||||
|
ownerId: watch.ownerId || null,
|
||||||
eventType: result.alert.eventType || "unknown",
|
eventType: result.alert.eventType || "unknown",
|
||||||
observedAt:
|
observedAt:
|
||||||
result.alert.observedAt ||
|
result.alert.observedAt ||
|
||||||
@@ -61,6 +143,8 @@ async function createDashboardRuntime(options = {}) {
|
|||||||
payload: {
|
payload: {
|
||||||
...result.alert,
|
...result.alert,
|
||||||
notificationSent: result.notificationSent === true,
|
notificationSent: result.notificationSent === true,
|
||||||
|
notificationState,
|
||||||
|
notificationError: notificationState === "failed" ? result.error : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,13 @@
|
|||||||
|
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const {
|
const { isAuthorizedRequest, resolveApiAuth } = require("./apiAuth");
|
||||||
buildAlertRules,
|
const { buildAlertRules, inferAlertOn, normalizeAlertOn, parseTargetPrice } = require("./alertRules");
|
||||||
inferAlertOn,
|
|
||||||
normalizeAlertOn,
|
|
||||||
parseTargetPrice,
|
|
||||||
} = require("./alertRules");
|
|
||||||
const { createDashboardApi } = require("./dashboardApi");
|
const { createDashboardApi } = require("./dashboardApi");
|
||||||
|
const { createDashboardAuth } = require("./dashboardAuth");
|
||||||
const { loadDashboardAsset } = require("./dashboardAssets");
|
const { loadDashboardAsset } = require("./dashboardAssets");
|
||||||
const { createDashboardRuntime } = require("./dashboardRuntime");
|
const { createDashboardRuntime } = require("./dashboardRuntime");
|
||||||
const { createHttpError, decodeWatchId, parsePort } = require("./dashboardUtils");
|
const { createHttpError, decodeWatchId, parsePort, toPublicErrorResponse } = require("./dashboardUtils");
|
||||||
|
|
||||||
function parseJsonBody(req) {
|
function parseJsonBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -46,16 +43,25 @@ function parseJsonBody(req) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendJson(res, statusCode, body) {
|
function sendJson(res, statusCode, body, extraHeaders = {}) {
|
||||||
const payload = JSON.stringify(body);
|
const payload = JSON.stringify(body);
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"cache-control": "no-store",
|
"cache-control": "no-store",
|
||||||
"content-length": Buffer.byteLength(payload),
|
"content-length": Buffer.byteLength(payload),
|
||||||
|
...extraHeaders,
|
||||||
});
|
});
|
||||||
res.end(payload);
|
res.end(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendRedirect(res, location) {
|
||||||
|
res.writeHead(302, {
|
||||||
|
location,
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
function sendStaticAsset(res, asset) {
|
function sendStaticAsset(res, asset) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"content-type": asset.contentType,
|
"content-type": asset.contentType,
|
||||||
@@ -65,24 +71,167 @@ function sendStaticAsset(res, asset) {
|
|||||||
res.end(asset.content);
|
res.end(asset.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPublicUser(user) {
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
username: user.username,
|
||||||
|
isAdmin: user.isAdmin === true,
|
||||||
|
authType: user.authType || "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createDashboardServer(options = {}) {
|
async function createDashboardServer(options = {}) {
|
||||||
|
const logger = options.logger || console;
|
||||||
|
const dashboardAuth = createDashboardAuth(options.accountAuth || {});
|
||||||
|
let tokenAuth;
|
||||||
|
try {
|
||||||
|
tokenAuth = resolveApiAuth(options.auth || {});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
dashboardAuth.enabled &&
|
||||||
|
typeof error?.message === "string" &&
|
||||||
|
error.message.includes("DASHBOARD_API_TOKEN")
|
||||||
|
) {
|
||||||
|
tokenAuth = {
|
||||||
|
enabled: false,
|
||||||
|
token: "",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const runtime = await createDashboardRuntime(options);
|
const runtime = await createDashboardRuntime(options);
|
||||||
const api = createDashboardApi({
|
const api = createDashboardApi({
|
||||||
watcher: runtime.watcher,
|
watcher: runtime.watcher,
|
||||||
store: runtime.store,
|
store: runtime.store,
|
||||||
});
|
});
|
||||||
|
const identityRequired = dashboardAuth.enabled || tokenAuth.enabled;
|
||||||
|
const serverInfo = {
|
||||||
|
...runtime.info,
|
||||||
|
authEnabled: identityRequired,
|
||||||
|
accountAuthEnabled: dashboardAuth.enabled,
|
||||||
|
tokenAuthEnabled: tokenAuth.enabled,
|
||||||
|
};
|
||||||
const assetsDir = path.resolve(__dirname, "dashboard");
|
const assetsDir = path.resolve(__dirname, "dashboard");
|
||||||
|
|
||||||
|
function resolveRequestUser(headers) {
|
||||||
|
if (dashboardAuth.enabled) {
|
||||||
|
const accountUser = dashboardAuth.getUserFromRequest(headers);
|
||||||
|
if (accountUser) return accountUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenAuth.enabled && isAuthorizedRequest(headers, tokenAuth)) {
|
||||||
|
return {
|
||||||
|
username: "admin",
|
||||||
|
isAdmin: true,
|
||||||
|
authType: "token",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identityRequired) {
|
||||||
|
return {
|
||||||
|
username: "guest",
|
||||||
|
isAdmin: true,
|
||||||
|
authType: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendUnauthorized(res) {
|
||||||
|
const authenticateHeader = dashboardAuth.enabled
|
||||||
|
? 'Basic realm="airwatcher-dashboard"'
|
||||||
|
: 'Bearer realm="airwatcher-dashboard"';
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
401,
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ "www-authenticate": authenticateHeader }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const requestUrl = new URL(req.url || "/", "http://localhost");
|
const requestUrl = new URL(req.url || "/", "http://localhost");
|
||||||
const pathname = requestUrl.pathname;
|
const pathname = requestUrl.pathname;
|
||||||
|
const currentUser = resolveRequestUser(req.headers);
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/login") {
|
||||||
|
if (!dashboardAuth.enabled) {
|
||||||
|
throw createHttpError(400, "계정 로그인이 비활성화되어 있습니다. DASHBOARD_USERS를 설정하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
const loginResult = dashboardAuth.login(body.username, body.password);
|
||||||
|
if (!loginResult) {
|
||||||
|
sendJson(res, 401, { error: "아이디 또는 비밀번호가 올바르지 않습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
user: toPublicUser(loginResult.user),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"set-cookie": loginResult.setCookie,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/logout") {
|
||||||
|
const logoutResult = dashboardAuth.logout(req.headers);
|
||||||
|
sendJson(res, 200, { ok: true }, { "set-cookie": logoutResult.setCookie });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/session") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
authenticated: Boolean(currentUser),
|
||||||
|
user: toPublicUser(currentUser),
|
||||||
|
auth: {
|
||||||
|
accountAuthEnabled: dashboardAuth.enabled,
|
||||||
|
tokenAuthEnabled: tokenAuth.enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardAuth.enabled && pathname === "/") {
|
||||||
|
if (!currentUser) {
|
||||||
|
const loginAsset = loadDashboardAsset(assetsDir, "/login");
|
||||||
|
sendStaticAsset(res, loginAsset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardAuth.enabled && pathname === "/login" && currentUser) {
|
||||||
|
sendRedirect(res, "/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardAuth.enabled && pathname === "/setup" && !currentUser) {
|
||||||
|
sendRedirect(res, "/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const staticAsset = loadDashboardAsset(assetsDir, pathname);
|
const staticAsset = loadDashboardAsset(assetsDir, pathname);
|
||||||
if (staticAsset) {
|
if (staticAsset) {
|
||||||
sendStaticAsset(res, staticAsset);
|
sendStaticAsset(res, staticAsset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
if (!currentUser) {
|
||||||
|
sendUnauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && pathname === "/api/health") {
|
if (req.method === "GET" && pathname === "/api/health") {
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -94,7 +243,26 @@ async function createDashboardServer(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && pathname === "/api/config") {
|
if (req.method === "GET" && pathname === "/api/config") {
|
||||||
sendJson(res, 200, runtime.info);
|
sendJson(res, 200, {
|
||||||
|
...serverInfo,
|
||||||
|
currentUser: toPublicUser(currentUser),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/me") {
|
||||||
|
sendJson(res, 200, await api.getMe({ user: currentUser }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PATCH" && pathname === "/api/me/telegram") {
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
sendJson(res, 200, await api.updateMyTelegram(body, { user: currentUser }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/me/telegram/test") {
|
||||||
|
sendJson(res, 200, await api.sendMyTelegramTest({ user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,37 +275,30 @@ async function createDashboardServer(options = {}) {
|
|||||||
|
|
||||||
if (req.method === "PATCH" && pathname === "/api/system") {
|
if (req.method === "PATCH" && pathname === "/api/system") {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
sendJson(res, 200, await api.updateSystem(body));
|
sendJson(res, 200, await api.updateSystem(body, { user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/api/parse") {
|
if (req.method === "POST" && pathname === "/api/parse") {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
sendJson(res, 200, await api.parseInput(body));
|
sendJson(res, 200, await api.parseInput(body, { user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && pathname === "/api/watches") {
|
if (req.method === "GET" && pathname === "/api/watches") {
|
||||||
sendJson(res, 200, api.listWatches());
|
sendJson(res, 200, api.listWatches({ user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/api/watches") {
|
if (req.method === "POST" && pathname === "/api/watches") {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
sendJson(res, 201, await api.createWatch(body));
|
sendJson(res, 201, await api.createWatch(body, { user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && pathname === "/api/events") {
|
if (req.method === "GET" && pathname === "/api/events") {
|
||||||
const limit = requestUrl.searchParams.get("limit");
|
const limit = requestUrl.searchParams.get("limit");
|
||||||
sendJson(res, 200, await api.listEvents(limit));
|
sendJson(res, 200, await api.listEvents(limit, { user: currentUser }));
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,12 +308,12 @@ async function createDashboardServer(options = {}) {
|
|||||||
|
|
||||||
if (req.method === "PATCH") {
|
if (req.method === "PATCH") {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
sendJson(res, 200, await api.updateWatch(watchId, body));
|
sendJson(res, 200, await api.updateWatch(watchId, body, { user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "DELETE") {
|
if (req.method === "DELETE") {
|
||||||
sendJson(res, 200, await api.deleteWatch(watchId));
|
sendJson(res, 200, await api.deleteWatch(watchId, { user: currentUser }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,10 +322,8 @@ async function createDashboardServer(options = {}) {
|
|||||||
error: "Not found",
|
error: "Not found",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const statusCode = Number(error.statusCode) || 500;
|
const failure = toPublicErrorResponse(error, { logger });
|
||||||
sendJson(res, statusCode, {
|
sendJson(res, failure.statusCode, failure.body);
|
||||||
error: error.message || "Internal Server Error",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +339,7 @@ async function createDashboardServer(options = {}) {
|
|||||||
watcher: runtime.watcher,
|
watcher: runtime.watcher,
|
||||||
store: runtime.store,
|
store: runtime.store,
|
||||||
close,
|
close,
|
||||||
info: runtime.info,
|
info: serverInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +354,7 @@ async function runCli() {
|
|||||||
process.stdout.write(`[WARN] ${app.info.dbWarning}\n`);
|
process.stdout.write(`[WARN] ${app.info.dbWarning}\n`);
|
||||||
}
|
}
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`dbEngine=${app.info.dbEngine} pollIntervalSec=${app.info.pollIntervalSec}\n`
|
`dbEngine=${app.info.dbEngine} pollIntervalSec=${app.info.pollIntervalSec} authEnabled=${app.info.authEnabled}\n`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ function toBoolean(value, fallback = true) {
|
|||||||
return value !== false && value !== 0 && value !== "0";
|
return value !== false && value !== 0 && value !== "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBooleanFlag(value, fallback = false) {
|
||||||
|
if (value === undefined || value === null) 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 toSqlDateTime(isoString) {
|
function toSqlDateTime(isoString) {
|
||||||
const date = isoString ? new Date(isoString) : new Date();
|
const date = isoString ? new Date(isoString) : new Date();
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
@@ -48,10 +60,105 @@ function parseJsonColumn(value, fallback) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTERNAL_OWNER_KEY = "__ownerId";
|
||||||
|
const INTERNAL_EVENT_OWNER_KEY = "__ownerId";
|
||||||
|
const USER_PROFILES_SETTING_KEY = "user_profiles";
|
||||||
|
const PLAYGROUND_REQUIRED_TABLES = [
|
||||||
|
"projects",
|
||||||
|
"project_documents",
|
||||||
|
"project_events",
|
||||||
|
"project_settings",
|
||||||
|
];
|
||||||
|
const DEFAULT_PROJECT_KEY = "air-watcher";
|
||||||
|
|
||||||
|
function toOptionalString(value) {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDbSchemaMode(value) {
|
||||||
|
const normalized = toOptionalString(value);
|
||||||
|
if (!normalized) return "auto";
|
||||||
|
if (normalized === "playground" || normalized === "auto") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserProfile(username, source = {}) {
|
||||||
|
const normalizedUsername = toOptionalString(username);
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
throw new Error("username is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: normalizedUsername,
|
||||||
|
telegramEnabled: source.telegramEnabled === true,
|
||||||
|
telegramChatId: toOptionalString(source.telegramChatId),
|
||||||
|
telegramBotToken: toOptionalString(source.telegramBotToken),
|
||||||
|
telegramApiBase: toOptionalString(source.telegramApiBase),
|
||||||
|
updatedAt: toOptionalString(source.updatedAt) || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectInternalOwner(searchParams, ownerId) {
|
||||||
|
const params = cloneJson(searchParams || {});
|
||||||
|
if (ownerId) {
|
||||||
|
params[INTERNAL_OWNER_KEY] = ownerId;
|
||||||
|
} else {
|
||||||
|
delete params[INTERNAL_OWNER_KEY];
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInternalOwner(searchParams) {
|
||||||
|
const params = parseJsonColumn(searchParams, {});
|
||||||
|
const ownerId =
|
||||||
|
params && typeof params[INTERNAL_OWNER_KEY] === "string"
|
||||||
|
? params[INTERNAL_OWNER_KEY].trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (params && typeof params === "object") {
|
||||||
|
delete params[INTERNAL_OWNER_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerId: ownerId || null,
|
||||||
|
searchParams: params || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectEventOwnerPayload(payload, ownerId) {
|
||||||
|
const nextPayload = cloneJson(payload || {});
|
||||||
|
if (ownerId) {
|
||||||
|
nextPayload[INTERNAL_EVENT_OWNER_KEY] = ownerId;
|
||||||
|
}
|
||||||
|
return nextPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEventOwnerPayload(payload) {
|
||||||
|
const parsed = parseJsonColumn(payload, {});
|
||||||
|
const ownerId =
|
||||||
|
parsed && typeof parsed[INTERNAL_EVENT_OWNER_KEY] === "string"
|
||||||
|
? parsed[INTERNAL_EVENT_OWNER_KEY].trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
delete parsed[INTERNAL_EVENT_OWNER_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerId: ownerId || null,
|
||||||
|
payload: parsed || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class InMemoryDashboardStore {
|
class InMemoryDashboardStore {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.watches = new Map();
|
this.watches = new Map();
|
||||||
this.events = [];
|
this.events = [];
|
||||||
|
this.userProfiles = new Map();
|
||||||
this.globalControls = {
|
this.globalControls = {
|
||||||
crawlingEnabled: true,
|
crawlingEnabled: true,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
@@ -75,6 +182,7 @@ class InMemoryDashboardStore {
|
|||||||
|
|
||||||
async saveWatch(watch) {
|
async saveWatch(watch) {
|
||||||
const next = cloneJson(watch);
|
const next = cloneJson(watch);
|
||||||
|
next.ownerId = toOptionalString(next.ownerId);
|
||||||
this.watches.set(next.id, next);
|
this.watches.set(next.id, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
@@ -109,6 +217,7 @@ class InMemoryDashboardStore {
|
|||||||
const stored = {
|
const stored = {
|
||||||
id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`,
|
id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`,
|
||||||
watchId: event.watchId,
|
watchId: event.watchId,
|
||||||
|
ownerId: toOptionalString(event.ownerId),
|
||||||
eventType: event.eventType,
|
eventType: event.eventType,
|
||||||
payload: cloneJson(event.payload),
|
payload: cloneJson(event.payload),
|
||||||
observedAt: event.observedAt,
|
observedAt: event.observedAt,
|
||||||
@@ -121,9 +230,13 @@ class InMemoryDashboardStore {
|
|||||||
return cloneJson(stored);
|
return cloneJson(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listEvents(limit = 50) {
|
async listEvents(limit = 50, options = {}) {
|
||||||
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
||||||
return this.events.slice(0, safeLimit).map((event) => cloneJson(event));
|
const ownerId = toOptionalString(options.ownerId);
|
||||||
|
const filtered = ownerId
|
||||||
|
? this.events.filter((event) => event.ownerId === ownerId)
|
||||||
|
: this.events;
|
||||||
|
return filtered.slice(0, safeLimit).map((event) => cloneJson(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGlobalControls() {
|
async getGlobalControls() {
|
||||||
@@ -147,11 +260,45 @@ class InMemoryDashboardStore {
|
|||||||
|
|
||||||
return cloneJson(this.globalControls);
|
return cloneJson(this.globalControls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserProfile(username) {
|
||||||
|
const normalizedUsername = toOptionalString(username);
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
throw new Error("username is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.userProfiles.get(normalizedUsername);
|
||||||
|
if (!existing) {
|
||||||
|
return normalizeUserProfile(normalizedUsername, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneJson(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertUserProfile(username, patch = {}) {
|
||||||
|
const normalizedUsername = toOptionalString(username);
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
throw new Error("username is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.userProfiles.get(normalizedUsername) || normalizeUserProfile(normalizedUsername, {});
|
||||||
|
const next = normalizeUserProfile(normalizedUsername, {
|
||||||
|
...previous,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.userProfiles.set(normalizedUsername, next);
|
||||||
|
return cloneJson(next);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MySqlDashboardStore {
|
class MySqlDashboardStore {
|
||||||
constructor(pool) {
|
constructor(pool, options = {}) {
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
|
this.projectKey = toOptionalString(options.projectKey) || DEFAULT_PROJECT_KEY;
|
||||||
|
this.schemaPreference = normalizeDbSchemaMode(options.schemaMode);
|
||||||
|
this.schemaMode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(options = {}) {
|
static async create(options = {}) {
|
||||||
@@ -179,220 +326,263 @@ class MySqlDashboardStore {
|
|||||||
timezone: "Z",
|
timezone: "Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = new MySqlDashboardStore(pool);
|
const store = new MySqlDashboardStore(pool, {
|
||||||
|
projectKey: options.projectKey,
|
||||||
|
schemaMode: options.schemaMode,
|
||||||
|
});
|
||||||
await store.init();
|
await store.init();
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.pool.query(`
|
if (this.schemaPreference !== "auto" && this.schemaPreference !== "playground") {
|
||||||
CREATE TABLE IF NOT EXISTS watches (
|
throw new Error("DASHBOARD_DB_SCHEMA는 playground 또는 auto만 지원합니다.");
|
||||||
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(`
|
const inspectTables = PLAYGROUND_REQUIRED_TABLES;
|
||||||
CREATE TABLE IF NOT EXISTS watch_events (
|
const [rows] = await this.pool.query(
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`
|
||||||
watch_id VARCHAR(64) NOT NULL,
|
SELECT TABLE_NAME
|
||||||
event_type VARCHAR(64) NOT NULL,
|
FROM information_schema.TABLES
|
||||||
payload JSON NOT NULL,
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
observed_at DATETIME(3) NOT NULL,
|
AND TABLE_NAME IN (${inspectTables.map(() => "?").join(", ")})
|
||||||
created_at DATETIME(3) NOT NULL,
|
`,
|
||||||
PRIMARY KEY (id),
|
inspectTables
|
||||||
INDEX idx_watch_events_watch_id (watch_id, observed_at DESC)
|
);
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await this.pool.query(`
|
const existing = new Set(
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
rows.map((row) => String(row.TABLE_NAME || row.table_name || ""))
|
||||||
setting_key VARCHAR(64) NOT NULL,
|
);
|
||||||
setting_value JSON NOT NULL,
|
const missingPlayground = PLAYGROUND_REQUIRED_TABLES.filter((tableName) => !existing.has(tableName));
|
||||||
updated_at DATETIME(3) NOT NULL,
|
if (missingPlayground.length > 0) {
|
||||||
PRIMARY KEY (setting_key)
|
throw new Error(
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
`MySQL playground 스키마가 준비되지 않았습니다. 누락 테이블: ${missingPlayground.join(", ")}. playground_schema.sql을 먼저 적용하세요.`
|
||||||
`);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.schemaMode = "playground";
|
||||||
|
await this.ensureProjectEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureProjectEntry() {
|
||||||
|
const now = toSqlDateTime(new Date().toISOString());
|
||||||
|
await this.pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO projects (
|
||||||
|
project_key,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
meta_json,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description),
|
||||||
|
meta_json = VALUES(meta_json),
|
||||||
|
updated_at = VALUES(updated_at)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
this.projectKey,
|
||||||
|
this.projectKey,
|
||||||
|
"Managed by Air-Watcher dashboard",
|
||||||
|
JSON.stringify({ source: "air-watcher" }),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
async listWatches() {
|
async getSettingJson(settingKey, fallbackValue) {
|
||||||
const [rows] = await this.pool.query(`
|
const [rows] = await this.pool.query(
|
||||||
SELECT
|
`
|
||||||
id,
|
SELECT setting_value
|
||||||
raw_input,
|
FROM project_settings
|
||||||
parsed_params,
|
WHERE project_key = ?
|
||||||
alert_rules,
|
AND setting_key = ?
|
||||||
polling_enabled,
|
LIMIT 1
|
||||||
alerts_enabled,
|
`,
|
||||||
created_at,
|
[this.projectKey, settingKey]
|
||||||
updated_at,
|
);
|
||||||
last_snapshot,
|
if (rows.length === 0) {
|
||||||
last_error
|
return cloneJson(fallbackValue);
|
||||||
FROM watches
|
}
|
||||||
ORDER BY created_at DESC
|
return parseJsonColumn(rows[0].setting_value, cloneJson(fallbackValue));
|
||||||
`);
|
}
|
||||||
|
|
||||||
return rows.map((row) => ({
|
async upsertSettingJson(settingKey, value) {
|
||||||
id: row.id,
|
const now = toSqlDateTime(new Date().toISOString());
|
||||||
rawInput: row.raw_input,
|
await this.pool.query(
|
||||||
searchParams: parseJsonColumn(row.parsed_params, {}),
|
`
|
||||||
alertRules: parseJsonColumn(row.alert_rules, {}),
|
INSERT INTO project_settings (project_key, setting_key, setting_value, updated_at)
|
||||||
pollingEnabled: toBoolean(row.polling_enabled, true),
|
VALUES (?, ?, ?, ?)
|
||||||
alertsEnabled: toBoolean(row.alerts_enabled, true),
|
ON DUPLICATE KEY UPDATE
|
||||||
createdAt: fromSqlDateTime(row.created_at),
|
setting_value = VALUES(setting_value),
|
||||||
updatedAt: fromSqlDateTime(row.updated_at),
|
updated_at = VALUES(updated_at)
|
||||||
lastSnapshot: parseJsonColumn(row.last_snapshot, null),
|
`,
|
||||||
lastError: parseJsonColumn(row.last_error, null),
|
[this.projectKey, settingKey, JSON.stringify(value), now]
|
||||||
}));
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fromWatchDocumentRow(row) {
|
||||||
|
const source = parseJsonColumn(row.data_json, {});
|
||||||
|
const watch = source && typeof source === "object" ? source : {};
|
||||||
|
return {
|
||||||
|
id: toOptionalString(watch.id) || String(row.doc_key),
|
||||||
|
ownerId: toOptionalString(watch.ownerId),
|
||||||
|
rawInput: typeof watch.rawInput === "string" ? watch.rawInput : "",
|
||||||
|
searchParams: cloneJson(watch.searchParams || {}),
|
||||||
|
alertRules: cloneJson(watch.alertRules || {}),
|
||||||
|
pollingEnabled: toBoolean(watch.pollingEnabled, true),
|
||||||
|
alertsEnabled: toBoolean(watch.alertsEnabled, true),
|
||||||
|
createdAt: toOptionalString(watch.createdAt) || fromSqlDateTime(row.created_at),
|
||||||
|
updatedAt: toOptionalString(watch.updatedAt) || fromSqlDateTime(row.updated_at),
|
||||||
|
lastSnapshot: watch.lastSnapshot ? cloneJson(watch.lastSnapshot) : null,
|
||||||
|
lastError: watch.lastError ? cloneJson(watch.lastError) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toWatchDocument(watch) {
|
||||||
|
const createdAt = toOptionalString(watch.createdAt) || new Date().toISOString();
|
||||||
|
const updatedAt = toOptionalString(watch.updatedAt) || createdAt;
|
||||||
|
return {
|
||||||
|
id: watch.id,
|
||||||
|
ownerId: toOptionalString(watch.ownerId),
|
||||||
|
rawInput: watch.rawInput || "",
|
||||||
|
searchParams: cloneJson(watch.searchParams || {}),
|
||||||
|
alertRules: cloneJson(watch.alertRules || {}),
|
||||||
|
pollingEnabled: watch.pollingEnabled !== false,
|
||||||
|
alertsEnabled: watch.alertsEnabled !== false,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
lastSnapshot: watch.lastSnapshot ? cloneJson(watch.lastSnapshot) : null,
|
||||||
|
lastError: watch.lastError ? cloneJson(watch.lastError) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWatches() {
|
||||||
|
const [rows] = await this.pool.query(
|
||||||
|
`
|
||||||
|
SELECT doc_key, data_json, created_at, updated_at
|
||||||
|
FROM project_documents
|
||||||
|
WHERE project_key = ?
|
||||||
|
AND doc_type = 'watch'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`,
|
||||||
|
[this.projectKey]
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.fromWatchDocumentRow(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWatch(watchId) {
|
async getWatch(watchId) {
|
||||||
const [rows] = await this.pool.query(
|
const [rows] = await this.pool.query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT doc_key, data_json, created_at, updated_at
|
||||||
id,
|
FROM project_documents
|
||||||
raw_input,
|
WHERE project_key = ?
|
||||||
parsed_params,
|
AND doc_type = 'watch'
|
||||||
alert_rules,
|
AND doc_key = ?
|
||||||
polling_enabled,
|
|
||||||
alerts_enabled,
|
|
||||||
created_at,
|
|
||||||
updated_at,
|
|
||||||
last_snapshot,
|
|
||||||
last_error
|
|
||||||
FROM watches
|
|
||||||
WHERE id = ?
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[watchId]
|
[this.projectKey, watchId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
const row = rows[0];
|
return this.fromWatchDocumentRow(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) {
|
async saveWatch(watch) {
|
||||||
const createdAt = watch.createdAt || new Date().toISOString();
|
const persisted = this.toWatchDocument(watch);
|
||||||
const updatedAt = watch.updatedAt || createdAt;
|
|
||||||
|
|
||||||
await this.pool.query(
|
await this.pool.query(
|
||||||
`
|
`
|
||||||
INSERT INTO watches (
|
INSERT INTO project_documents (
|
||||||
id,
|
project_key,
|
||||||
raw_input,
|
doc_type,
|
||||||
parsed_params,
|
doc_key,
|
||||||
alert_rules,
|
data_json,
|
||||||
polling_enabled,
|
meta_json,
|
||||||
alerts_enabled,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at
|
||||||
last_snapshot,
|
)
|
||||||
last_error
|
VALUES (?, 'watch', ?, ?, NULL, ?, ?)
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
raw_input = VALUES(raw_input),
|
data_json = VALUES(data_json),
|
||||||
parsed_params = VALUES(parsed_params),
|
updated_at = VALUES(updated_at)
|
||||||
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)
|
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
|
this.projectKey,
|
||||||
watch.id,
|
watch.id,
|
||||||
watch.rawInput || "",
|
JSON.stringify(persisted),
|
||||||
JSON.stringify(watch.searchParams || {}),
|
toSqlDateTime(persisted.createdAt),
|
||||||
JSON.stringify(watch.alertRules || {}),
|
toSqlDateTime(persisted.updatedAt),
|
||||||
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);
|
return this.getWatch(watch.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteWatch(watchId) {
|
async deleteWatch(watchId) {
|
||||||
const [result] = await this.pool.query(`DELETE FROM watches WHERE id = ?`, [watchId]);
|
const [result] = await this.pool.query(
|
||||||
|
`
|
||||||
|
DELETE FROM project_documents
|
||||||
|
WHERE project_key = ?
|
||||||
|
AND doc_type = 'watch'
|
||||||
|
AND doc_key = ?
|
||||||
|
`,
|
||||||
|
[this.projectKey, watchId]
|
||||||
|
);
|
||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePollResult(watchId, pollResult) {
|
async savePollResult(watchId, pollResult) {
|
||||||
const updates = [];
|
const watch = await this.getWatch(watchId);
|
||||||
const params = [];
|
if (!watch) return;
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
|
|
||||||
|
let touched = false;
|
||||||
if (pollResult && pollResult.snapshot) {
|
if (pollResult && pollResult.snapshot) {
|
||||||
updates.push("last_snapshot = ?");
|
watch.lastSnapshot = cloneJson(pollResult.snapshot);
|
||||||
params.push(JSON.stringify(pollResult.snapshot));
|
watch.lastError = null;
|
||||||
updates.push("last_error = NULL");
|
touched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pollResult && pollResult.error) {
|
if (pollResult && pollResult.error) {
|
||||||
updates.push("last_error = ?");
|
watch.lastError = cloneJson(pollResult.error);
|
||||||
params.push(JSON.stringify(pollResult.error));
|
touched = true;
|
||||||
}
|
}
|
||||||
|
if (!touched) return;
|
||||||
|
|
||||||
if (updates.length === 0) {
|
watch.updatedAt = new Date().toISOString();
|
||||||
return;
|
await this.saveWatch(watch);
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async saveEvent(event) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const observedAt = event.observedAt || createdAt;
|
const observedAt = event.observedAt || createdAt;
|
||||||
|
const ownerId = toOptionalString(event.ownerId);
|
||||||
|
const payload = injectEventOwnerPayload(event.payload, ownerId);
|
||||||
|
|
||||||
const [result] = await this.pool.query(
|
const [result] = await this.pool.query(
|
||||||
`
|
`
|
||||||
INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at)
|
INSERT INTO project_events (
|
||||||
VALUES (?, ?, ?, ?, ?)
|
project_key,
|
||||||
|
stream,
|
||||||
|
event_type,
|
||||||
|
payload_json,
|
||||||
|
observed_at,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
event.watchId,
|
this.projectKey,
|
||||||
|
"watch_events",
|
||||||
event.eventType || "unknown",
|
event.eventType || "unknown",
|
||||||
JSON.stringify(event.payload || {}),
|
JSON.stringify({
|
||||||
|
watchId: event.watchId,
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
toSqlDateTime(observedAt),
|
toSqlDateTime(observedAt),
|
||||||
toSqlDateTime(createdAt),
|
toSqlDateTime(createdAt),
|
||||||
]
|
]
|
||||||
@@ -401,6 +591,7 @@ class MySqlDashboardStore {
|
|||||||
return {
|
return {
|
||||||
id: String(result.insertId),
|
id: String(result.insertId),
|
||||||
watchId: event.watchId,
|
watchId: event.watchId,
|
||||||
|
ownerId,
|
||||||
eventType: event.eventType,
|
eventType: event.eventType,
|
||||||
payload: cloneJson(event.payload || {}),
|
payload: cloneJson(event.payload || {}),
|
||||||
observedAt,
|
observedAt,
|
||||||
@@ -408,46 +599,51 @@ class MySqlDashboardStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async listEvents(limit = 50) {
|
async listEvents(limit = 50, options = {}) {
|
||||||
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
||||||
|
const ownerIdFilter = toOptionalString(options.ownerId);
|
||||||
|
const fetchLimit = ownerIdFilter ? Math.min(1000, Math.max(safeLimit * 5, 200)) : safeLimit;
|
||||||
|
|
||||||
const [rows] = await this.pool.query(
|
const [rows] = await this.pool.query(
|
||||||
`
|
`
|
||||||
SELECT id, watch_id, event_type, payload, observed_at, created_at
|
SELECT id, event_type, payload_json, observed_at, created_at
|
||||||
FROM watch_events
|
FROM project_events
|
||||||
|
WHERE project_key = ?
|
||||||
|
AND stream = 'watch_events'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`,
|
`,
|
||||||
[safeLimit]
|
[this.projectKey, fetchLimit]
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((row) => ({
|
const mapped = rows
|
||||||
|
.map((row) => {
|
||||||
|
const envelope = parseJsonColumn(row.payload_json, {});
|
||||||
|
const rawPayload =
|
||||||
|
envelope && Object.prototype.hasOwnProperty.call(envelope, "payload")
|
||||||
|
? envelope.payload
|
||||||
|
: envelope;
|
||||||
|
const extracted = extractEventOwnerPayload(rawPayload);
|
||||||
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
watchId: row.watch_id,
|
watchId: toOptionalString(envelope.watchId),
|
||||||
|
ownerId: extracted.ownerId || toOptionalString(envelope.ownerId),
|
||||||
eventType: row.event_type,
|
eventType: row.event_type,
|
||||||
payload: parseJsonColumn(row.payload, {}),
|
payload: extracted.payload,
|
||||||
observedAt: fromSqlDateTime(row.observed_at),
|
observedAt: fromSqlDateTime(row.observed_at),
|
||||||
createdAt: fromSqlDateTime(row.created_at),
|
createdAt: fromSqlDateTime(row.created_at),
|
||||||
}));
|
};
|
||||||
|
})
|
||||||
|
.filter((event) => {
|
||||||
|
if (!ownerIdFilter) return true;
|
||||||
|
return event.ownerId === ownerIdFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapped.slice(0, safeLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGlobalControls() {
|
async getGlobalControls() {
|
||||||
const [rows] = await this.pool.query(
|
const setting = await this.getSettingJson("global_controls", {});
|
||||||
`
|
|
||||||
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 {
|
return {
|
||||||
crawlingEnabled: toBoolean(setting.crawlingEnabled, true),
|
crawlingEnabled: toBoolean(setting.crawlingEnabled, true),
|
||||||
alertsEnabled: toBoolean(setting.alertsEnabled, true),
|
alertsEnabled: toBoolean(setting.alertsEnabled, true),
|
||||||
@@ -466,19 +662,42 @@ class MySqlDashboardStore {
|
|||||||
next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled);
|
next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pool.query(
|
await this.upsertSettingJson("global_controls", next);
|
||||||
`
|
|
||||||
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;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserProfile(username) {
|
||||||
|
const normalizedUsername = toOptionalString(username);
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
throw new Error("username is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await this.getSettingJson(USER_PROFILES_SETTING_KEY, {});
|
||||||
|
const profileSource =
|
||||||
|
profiles && typeof profiles === "object" ? profiles[normalizedUsername] || {} : {};
|
||||||
|
return normalizeUserProfile(normalizedUsername, profileSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertUserProfile(username, patch = {}) {
|
||||||
|
const normalizedUsername = toOptionalString(username);
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
throw new Error("username is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await this.getSettingJson(USER_PROFILES_SETTING_KEY, {});
|
||||||
|
const safeProfiles = profiles && typeof profiles === "object" ? { ...profiles } : {};
|
||||||
|
const previous = normalizeUserProfile(normalizedUsername, safeProfiles[normalizedUsername] || {});
|
||||||
|
|
||||||
|
const next = normalizeUserProfile(normalizedUsername, {
|
||||||
|
...previous,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
safeProfiles[normalizedUsername] = next;
|
||||||
|
await this.upsertSettingJson(USER_PROFILES_SETTING_KEY, safeProfiles);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePort(rawPort, fallback) {
|
function parsePort(rawPort, fallback) {
|
||||||
@@ -491,6 +710,12 @@ async function createDashboardStore(options = {}) {
|
|||||||
const modeRaw = options.mode || process.env.DASHBOARD_DB || "";
|
const modeRaw = options.mode || process.env.DASHBOARD_DB || "";
|
||||||
const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : "";
|
const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : "";
|
||||||
const isStrictMySqlMode = mode === "mysql";
|
const isStrictMySqlMode = mode === "mysql";
|
||||||
|
const allowMemoryFallback = parseBooleanFlag(
|
||||||
|
options.allowMemoryFallback !== undefined
|
||||||
|
? options.allowMemoryFallback
|
||||||
|
: process.env.DASHBOARD_ALLOW_MEMORY_FALLBACK,
|
||||||
|
false
|
||||||
|
);
|
||||||
const prefersMySql =
|
const prefersMySql =
|
||||||
isStrictMySqlMode ||
|
isStrictMySqlMode ||
|
||||||
Boolean(process.env.MYSQL_URL) ||
|
Boolean(process.env.MYSQL_URL) ||
|
||||||
@@ -514,12 +739,14 @@ async function createDashboardStore(options = {}) {
|
|||||||
password: options.mysqlPassword || process.env.MYSQL_PASSWORD,
|
password: options.mysqlPassword || process.env.MYSQL_PASSWORD,
|
||||||
database: options.mysqlDatabase || process.env.MYSQL_DATABASE,
|
database: options.mysqlDatabase || process.env.MYSQL_DATABASE,
|
||||||
connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT,
|
connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT,
|
||||||
|
projectKey: options.projectKey || process.env.DASHBOARD_PROJECT_KEY || DEFAULT_PROJECT_KEY,
|
||||||
|
schemaMode: options.schemaMode || process.env.DASHBOARD_DB_SCHEMA || "playground",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) {
|
if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) {
|
||||||
if (isStrictMySqlMode) {
|
if (!allowMemoryFallback) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하세요."
|
"MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하거나 DASHBOARD_ALLOW_MEMORY_FALLBACK=true를 지정하세요."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,8 +768,8 @@ async function createDashboardStore(options = {}) {
|
|||||||
warning: null,
|
warning: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isStrictMySqlMode) {
|
if (!allowMemoryFallback) {
|
||||||
throw error;
|
throw new Error(`MySQL 초기화 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackStore = new InMemoryDashboardStore();
|
const fallbackStore = new InMemoryDashboardStore();
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ function createHttpError(statusCode, message) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStatusCode(statusCode, fallback = 500) {
|
||||||
|
const normalized = Number(statusCode);
|
||||||
|
if (!Number.isInteger(normalized) || normalized < 400 || normalized > 599) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function parseBoolean(value, fallback = true) {
|
function parseBoolean(value, fallback = true) {
|
||||||
if (value === undefined) return fallback;
|
if (value === undefined) return fallback;
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
@@ -32,9 +40,33 @@ function decodeWatchId(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPublicErrorResponse(error, options = {}) {
|
||||||
|
const logger = options.logger || null;
|
||||||
|
const statusCode = normalizeStatusCode(error?.statusCode, 500);
|
||||||
|
const shouldMask = statusCode >= 500;
|
||||||
|
const message =
|
||||||
|
shouldMask
|
||||||
|
? "Internal Server Error"
|
||||||
|
: typeof error?.message === "string" && error.message
|
||||||
|
? error.message
|
||||||
|
: "Bad Request";
|
||||||
|
|
||||||
|
if (shouldMask && logger && typeof logger.error === "function") {
|
||||||
|
logger.error(error?.stack || error?.message || String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
body: {
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createHttpError,
|
createHttpError,
|
||||||
decodeWatchId,
|
decodeWatchId,
|
||||||
parseBoolean,
|
parseBoolean,
|
||||||
parsePort,
|
parsePort,
|
||||||
|
toPublicErrorResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const Fastify = require("fastify");
|
const Fastify = require("fastify");
|
||||||
|
const { isAuthorizedRequest, resolveApiAuth } = require("./apiAuth");
|
||||||
const { createDashboardApi } = require("./dashboardApi");
|
const { createDashboardApi } = require("./dashboardApi");
|
||||||
|
const { createDashboardAuth } = require("./dashboardAuth");
|
||||||
const { loadDashboardAsset } = require("./dashboardAssets");
|
const { loadDashboardAsset } = require("./dashboardAssets");
|
||||||
const { createDashboardRuntime } = require("./dashboardRuntime");
|
const { createDashboardRuntime } = require("./dashboardRuntime");
|
||||||
const { decodeWatchId, parsePort } = require("./dashboardUtils");
|
const { createHttpError, decodeWatchId, parsePort, toPublicErrorResponse } = require("./dashboardUtils");
|
||||||
|
|
||||||
function sendStaticAsset(reply, asset) {
|
function sendStaticAsset(reply, asset) {
|
||||||
reply
|
reply
|
||||||
@@ -24,31 +26,124 @@ function parseRequestPathname(request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPublicUser(user) {
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
username: user.username,
|
||||||
|
isAdmin: user.isAdmin === true,
|
||||||
|
authType: user.authType || "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createFastifyDashboardServer(options = {}) {
|
async function createFastifyDashboardServer(options = {}) {
|
||||||
|
const logger = options.logger || console;
|
||||||
|
const dashboardAuth = createDashboardAuth(options.accountAuth || {});
|
||||||
|
let tokenAuth;
|
||||||
|
try {
|
||||||
|
tokenAuth = resolveApiAuth(options.auth || {});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
dashboardAuth.enabled &&
|
||||||
|
typeof error?.message === "string" &&
|
||||||
|
error.message.includes("DASHBOARD_API_TOKEN")
|
||||||
|
) {
|
||||||
|
tokenAuth = {
|
||||||
|
enabled: false,
|
||||||
|
token: "",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const runtime = await createDashboardRuntime(options);
|
const runtime = await createDashboardRuntime(options);
|
||||||
const api = createDashboardApi({
|
const api = createDashboardApi({
|
||||||
watcher: runtime.watcher,
|
watcher: runtime.watcher,
|
||||||
store: runtime.store,
|
store: runtime.store,
|
||||||
});
|
});
|
||||||
|
const identityRequired = dashboardAuth.enabled || tokenAuth.enabled;
|
||||||
|
const serverInfo = {
|
||||||
|
...runtime.info,
|
||||||
|
authEnabled: identityRequired,
|
||||||
|
accountAuthEnabled: dashboardAuth.enabled,
|
||||||
|
tokenAuthEnabled: tokenAuth.enabled,
|
||||||
|
};
|
||||||
const assetsDir = path.resolve(__dirname, "dashboard");
|
const assetsDir = path.resolve(__dirname, "dashboard");
|
||||||
|
|
||||||
|
function resolveRequestUser(headers) {
|
||||||
|
if (dashboardAuth.enabled) {
|
||||||
|
const accountUser = dashboardAuth.getUserFromRequest(headers);
|
||||||
|
if (accountUser) return accountUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenAuth.enabled && isAuthorizedRequest(headers, tokenAuth)) {
|
||||||
|
return {
|
||||||
|
username: "admin",
|
||||||
|
isAdmin: true,
|
||||||
|
authType: "token",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identityRequired) {
|
||||||
|
return {
|
||||||
|
username: "guest",
|
||||||
|
isAdmin: true,
|
||||||
|
authType: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: false,
|
logger: false,
|
||||||
bodyLimit: 1024 * 1024,
|
bodyLimit: 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setErrorHandler((error, _request, reply) => {
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
const statusCode = Number(error.statusCode) || 500;
|
const failure = toPublicErrorResponse(error, { logger });
|
||||||
reply.code(statusCode).send({
|
reply.code(failure.statusCode).send(failure.body);
|
||||||
error: error.message || "Internal Server Error",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
await runtime.close();
|
await runtime.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/", async (_request, reply) => {
|
app.addHook("onRequest", async (request, reply) => {
|
||||||
|
request.dashboardUser = resolveRequestUser(request.headers);
|
||||||
|
const pathname = parseRequestPathname(request);
|
||||||
|
const isApiPath = pathname.startsWith("/api/");
|
||||||
|
const isPublicApiPath =
|
||||||
|
pathname === "/api/login" || pathname === "/api/logout" || pathname === "/api/session";
|
||||||
|
|
||||||
|
if (dashboardAuth.enabled && pathname === "/" && !request.dashboardUser) {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login"));
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardAuth.enabled && pathname === "/login" && request.dashboardUser) {
|
||||||
|
return reply.redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardAuth.enabled && pathname === "/setup" && !request.dashboardUser) {
|
||||||
|
return reply.redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApiPath && !isPublicApiPath && !request.dashboardUser) {
|
||||||
|
reply.header(
|
||||||
|
"www-authenticate",
|
||||||
|
dashboardAuth.enabled
|
||||||
|
? 'Basic realm="airwatcher-dashboard"'
|
||||||
|
: 'Bearer realm="airwatcher-dashboard"'
|
||||||
|
);
|
||||||
|
throw createHttpError(401, "Unauthorized");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/", async (request, reply) => {
|
||||||
|
if (dashboardAuth.enabled && !request.dashboardUser) {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/"));
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +155,64 @@ async function createFastifyDashboardServer(options = {}) {
|
|||||||
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.js"));
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/login", async (_request, reply) => {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/login.css", async (_request, reply) => {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login.css"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/login.js", async (_request, reply) => {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/setup", async (_request, reply) => {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/setup"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/setup.css", async (_request, reply) => {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/setup.css"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/setup.js", async (_request, reply) => {
|
||||||
|
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/setup.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/login", async (request, reply) => {
|
||||||
|
if (!dashboardAuth.enabled) {
|
||||||
|
throw createHttpError(400, "계정 로그인이 비활성화되어 있습니다. DASHBOARD_USERS를 설정하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body || {};
|
||||||
|
const loginResult = dashboardAuth.login(body.username, body.password);
|
||||||
|
if (!loginResult) {
|
||||||
|
reply.code(401);
|
||||||
|
return { error: "아이디 또는 비밀번호가 올바르지 않습니다." };
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("set-cookie", loginResult.setCookie);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
user: toPublicUser(loginResult.user),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/logout", async (request, reply) => {
|
||||||
|
const result = dashboardAuth.logout(request.headers);
|
||||||
|
reply.header("set-cookie", result.setCookie);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/session", async (request) => ({
|
||||||
|
authenticated: Boolean(request.dashboardUser),
|
||||||
|
user: toPublicUser(request.dashboardUser),
|
||||||
|
auth: {
|
||||||
|
accountAuthEnabled: dashboardAuth.enabled,
|
||||||
|
tokenAuthEnabled: tokenAuth.enabled,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
app.get("/api/health", async () => ({
|
app.get("/api/health", async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
@@ -67,41 +220,54 @@ async function createFastifyDashboardServer(options = {}) {
|
|||||||
watchCount: runtime.watcher.listWatchIds().length,
|
watchCount: runtime.watcher.listWatchIds().length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/config", async () => runtime.info);
|
app.get("/api/config", async (request) => ({
|
||||||
|
...serverInfo,
|
||||||
|
currentUser: toPublicUser(request.dashboardUser),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/api/me", async (request) => api.getMe({ user: request.dashboardUser }));
|
||||||
|
|
||||||
|
app.patch("/api/me/telegram", async (request) => {
|
||||||
|
return api.updateMyTelegram(request.body || {}, { user: request.dashboardUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/me/telegram/test", async (request) => {
|
||||||
|
return api.sendMyTelegramTest({ user: request.dashboardUser });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/system", async () => ({
|
app.get("/api/system", async () => ({
|
||||||
controls: runtime.watcher.getGlobalControls(),
|
controls: runtime.watcher.getGlobalControls(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.patch("/api/system", async (request) => {
|
app.patch("/api/system", async (request) => {
|
||||||
return api.updateSystem(request.body || {});
|
return api.updateSystem(request.body || {}, { user: request.dashboardUser });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/parse", async (request) => {
|
app.post("/api/parse", async (request) => {
|
||||||
return api.parseInput(request.body || {});
|
return api.parseInput(request.body || {}, { user: request.dashboardUser });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/watches", async () => api.listWatches());
|
app.get("/api/watches", async (request) => api.listWatches({ user: request.dashboardUser }));
|
||||||
|
|
||||||
app.post("/api/watches", async (request, reply) => {
|
app.post("/api/watches", async (request, reply) => {
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
return api.createWatch(request.body || {});
|
return api.createWatch(request.body || {}, { user: request.dashboardUser });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/events", async (request) => {
|
app.get("/api/events", async (request) => {
|
||||||
return api.listEvents(request.query?.limit);
|
return api.listEvents(request.query?.limit, { user: request.dashboardUser });
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/watches/:watchId/poll", async (request) => {
|
|
||||||
return api.pollWatch(decodeWatchId(request.params.watchId));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/watches/:watchId", async (request) => {
|
app.patch("/api/watches/:watchId", async (request) => {
|
||||||
return api.updateWatch(decodeWatchId(request.params.watchId), request.body || {});
|
return api.updateWatch(decodeWatchId(request.params.watchId), request.body || {}, {
|
||||||
|
user: request.dashboardUser,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/watches/:watchId", async (request) => {
|
app.delete("/api/watches/:watchId", async (request) => {
|
||||||
return api.deleteWatch(decodeWatchId(request.params.watchId));
|
return api.deleteWatch(decodeWatchId(request.params.watchId), {
|
||||||
|
user: request.dashboardUser,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setNotFoundHandler(async (request, reply) => {
|
app.setNotFoundHandler(async (request, reply) => {
|
||||||
@@ -121,7 +287,7 @@ async function createFastifyDashboardServer(options = {}) {
|
|||||||
close: async () => {
|
close: async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
},
|
},
|
||||||
info: runtime.info,
|
info: serverInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +303,7 @@ async function runCli() {
|
|||||||
process.stdout.write(`[WARN] ${dashboard.info.dbWarning}\n`);
|
process.stdout.write(`[WARN] ${dashboard.info.dbWarning}\n`);
|
||||||
}
|
}
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec}\n`
|
`dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec} authEnabled=${dashboard.info.authEnabled}\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ function toIntegerOrNull(value) {
|
|||||||
return Math.round(n);
|
return Math.round(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value, fallback) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeDateWindow(value) {
|
function sanitizeDateWindow(value) {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const from = typeof value.from === "string" ? value.from : null;
|
const from = typeof value.from === "string" ? value.from : null;
|
||||||
@@ -119,13 +125,44 @@ function uniqueStrings(values) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferTripType(segments) {
|
function inferTripType(segments) {
|
||||||
if (!segments || segments.length < 2) return "unknown";
|
if (!segments || segments.length === 0) return "unknown";
|
||||||
|
if (segments.length === 1) return "unknown";
|
||||||
|
const first = segments[0];
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (segments.length === 2 && first.from === last.to && first.to === last.from) return "round_trip";
|
||||||
|
return "multi_city";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and corrects the tripType based on actual segments.
|
||||||
|
* LLM often mis-classifies open_jaw or multi_city as round_trip.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - round_trip with 1 segment: OK (return leg generated by crawler)
|
||||||
|
* - round_trip with 2 segments: only valid if A→B / B→A (exact reverse)
|
||||||
|
* - round_trip with 2 segments where cities differ: → multi_city
|
||||||
|
* - open_jaw: → multi_city (URL builders don't distinguish)
|
||||||
|
* - 3+ segments: always multi_city
|
||||||
|
*/
|
||||||
|
function correctTripType(tripType, segments) {
|
||||||
|
if (!segments || segments.length === 0) return tripType || "unknown";
|
||||||
|
|
||||||
|
// open_jaw → multi_city (URL builders treat them identically)
|
||||||
|
if (tripType === "open_jaw") return "multi_city";
|
||||||
|
|
||||||
|
if (segments.length >= 3) return "multi_city";
|
||||||
|
|
||||||
|
if (tripType === "round_trip" && segments.length === 2) {
|
||||||
const first = segments[0];
|
const first = segments[0];
|
||||||
const second = segments[1];
|
const second = segments[1];
|
||||||
if (first.from === second.to && first.to === second.from) return "round_trip";
|
// A→B / B→A is genuine round_trip; anything else is multi_city
|
||||||
if (first.from === second.to && first.to !== second.from) return "open_jaw";
|
if (first.from !== second.to || first.to !== second.from) {
|
||||||
return "multi_city";
|
return "multi_city";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tripType || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function recomputeMissingFields(params) {
|
function recomputeMissingFields(params) {
|
||||||
const missingFields = [];
|
const missingFields = [];
|
||||||
@@ -152,10 +189,11 @@ function mergeWithFallback(llmObject, fallbackParams, input, now) {
|
|||||||
...uniqueStrings(source.warnings),
|
...uniqueStrings(source.warnings),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tripType =
|
const rawTripType =
|
||||||
typeof source.tripType === "string" && source.tripType.trim()
|
typeof source.tripType === "string" && source.tripType.trim()
|
||||||
? source.tripType
|
? source.tripType.trim()
|
||||||
: inferTripType(segments);
|
: inferTripType(segments);
|
||||||
|
const tripType = correctTripType(rawTripType, segments);
|
||||||
|
|
||||||
const parsed = {
|
const parsed = {
|
||||||
rawInput: input,
|
rawInput: input,
|
||||||
@@ -183,7 +221,8 @@ function buildPrompt(input, nowDate) {
|
|||||||
' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | 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,',
|
' "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},',
|
' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},',
|
||||||
' "tripType": "round_trip|open_jaw|multi_city|unknown",',
|
' "tripType": "round_trip|multi_city|unknown",',
|
||||||
|
" // round_trip: same origin/destination pair reversed (A→B, B→A). multi_city: different cities on any leg (A→B, C→A or A→B, C→D).",
|
||||||
' "warnings": [string],',
|
' "warnings": [string],',
|
||||||
' "missingFields": [string]',
|
' "missingFields": [string]',
|
||||||
"}",
|
"}",
|
||||||
@@ -200,6 +239,10 @@ function createOpenAIClient(options = {}) {
|
|||||||
|
|
||||||
const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
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 model = options.model || process.env.OPENAI_MODEL || "gpt-4.1-mini";
|
||||||
|
const timeoutMs = parsePositiveInt(
|
||||||
|
options.timeoutMs !== undefined ? options.timeoutMs : process.env.LLM_REQUEST_TIMEOUT_MS,
|
||||||
|
20000
|
||||||
|
);
|
||||||
const fetchImpl = options.fetch || global.fetch;
|
const fetchImpl = options.fetch || global.fetch;
|
||||||
if (typeof fetchImpl !== "function") {
|
if (typeof fetchImpl !== "function") {
|
||||||
throw new Error("global fetch is unavailable. Node.js 18+ is required.");
|
throw new Error("global fetch is unavailable. Node.js 18+ is required.");
|
||||||
@@ -208,6 +251,12 @@ function createOpenAIClient(options = {}) {
|
|||||||
const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
|
const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||||
|
|
||||||
return async ({ input, now }) => {
|
return async ({ input, now }) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetchImpl(endpoint, {
|
const response = await fetchImpl(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -230,6 +279,7 @@ function createOpenAIClient(options = {}) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -240,6 +290,14 @@ function createOpenAIClient(options = {}) {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const content = payload?.choices?.[0]?.message?.content;
|
const content = payload?.choices?.[0]?.message?.content;
|
||||||
return tryParseJsonObject(content);
|
return tryParseJsonObject(content);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
throw new Error(`OpenAI API request timed out after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
src/pollingConfig.js
Normal file
42
src/pollingConfig.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const MIN_CRAWL_INTERVAL_SEC = 60 * 60;
|
||||||
|
const MIN_CRAWL_INTERVAL_MS = MIN_CRAWL_INTERVAL_SEC * 1000;
|
||||||
|
|
||||||
|
function isMissing(value) {
|
||||||
|
return value === undefined || value === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntegerInterval(rawValue, minValue, label) {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
throw new Error(`${label} 값은 정수여야 합니다: ${rawValue}`);
|
||||||
|
}
|
||||||
|
if (parsed < minValue) {
|
||||||
|
throw new Error(`${label} 값은 ${minValue} 이상이어야 합니다.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCrawlIntervalSec(rawValue, fallback = MIN_CRAWL_INTERVAL_SEC) {
|
||||||
|
if (isMissing(rawValue)) {
|
||||||
|
return parseIntegerInterval(fallback, MIN_CRAWL_INTERVAL_SEC, "crawl interval(sec)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseIntegerInterval(rawValue, MIN_CRAWL_INTERVAL_SEC, "crawl interval(sec)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCrawlIntervalMs(rawValue, fallback = MIN_CRAWL_INTERVAL_MS) {
|
||||||
|
if (isMissing(rawValue)) {
|
||||||
|
return parseIntegerInterval(fallback, MIN_CRAWL_INTERVAL_MS, "crawl interval(ms)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseIntegerInterval(rawValue, MIN_CRAWL_INTERVAL_MS, "crawl interval(ms)");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MIN_CRAWL_INTERVAL_SEC,
|
||||||
|
MIN_CRAWL_INTERVAL_MS,
|
||||||
|
normalizeCrawlIntervalSec,
|
||||||
|
normalizeCrawlIntervalMs,
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
|
const { normalizeCrawlIntervalMs } = require("./pollingConfig");
|
||||||
|
|
||||||
function cloneJson(value) {
|
function cloneJson(value) {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
@@ -97,6 +98,7 @@ function buildAlertEvent(watch, previousSnapshot, currentSnapshot) {
|
|||||||
const uniqueReasons = [...new Set(reasons)];
|
const uniqueReasons = [...new Set(reasons)];
|
||||||
return {
|
return {
|
||||||
watchId: watch.id,
|
watchId: watch.id,
|
||||||
|
ownerId: watch.ownerId || null,
|
||||||
rawInput: watch.rawInput,
|
rawInput: watch.rawInput,
|
||||||
eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0],
|
eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0],
|
||||||
reasons: uniqueReasons,
|
reasons: uniqueReasons,
|
||||||
@@ -121,10 +123,7 @@ class PriceWatcher {
|
|||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.crawler = options.crawler;
|
this.crawler = options.crawler;
|
||||||
this.notifier = options.notifier;
|
this.notifier = options.notifier;
|
||||||
this.pollIntervalMs =
|
this.pollIntervalMs = normalizeCrawlIntervalMs(options.pollIntervalMs);
|
||||||
Number.isFinite(Number(options.pollIntervalMs)) && Number(options.pollIntervalMs) > 0
|
|
||||||
? Number(options.pollIntervalMs)
|
|
||||||
: 60000;
|
|
||||||
this.logger = options.logger || console;
|
this.logger = options.logger || console;
|
||||||
this.now = options.now || (() => new Date());
|
this.now = options.now || (() => new Date());
|
||||||
this.onWatchPolled =
|
this.onWatchPolled =
|
||||||
@@ -139,6 +138,7 @@ class PriceWatcher {
|
|||||||
|
|
||||||
this.watches = new Map();
|
this.watches = new Map();
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
|
this.pollingInFlight = false;
|
||||||
this.globalControls = {
|
this.globalControls = {
|
||||||
crawlingEnabled: true,
|
crawlingEnabled: true,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
@@ -148,6 +148,7 @@ class PriceWatcher {
|
|||||||
toPublicWatch(watch) {
|
toPublicWatch(watch) {
|
||||||
return {
|
return {
|
||||||
id: watch.id,
|
id: watch.id,
|
||||||
|
ownerId: watch.ownerId || null,
|
||||||
rawInput: watch.rawInput,
|
rawInput: watch.rawInput,
|
||||||
searchParams: cloneJson(watch.searchParams),
|
searchParams: cloneJson(watch.searchParams),
|
||||||
alertRules: cloneJson(watch.alertRules),
|
alertRules: cloneJson(watch.alertRules),
|
||||||
@@ -201,6 +202,11 @@ class PriceWatcher {
|
|||||||
if (Object.prototype.hasOwnProperty.call(patch, "rawInput")) {
|
if (Object.prototype.hasOwnProperty.call(patch, "rawInput")) {
|
||||||
watch.rawInput = typeof patch.rawInput === "string" ? patch.rawInput : watch.rawInput;
|
watch.rawInput = typeof patch.rawInput === "string" ? patch.rawInput : watch.rawInput;
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, "ownerId")) {
|
||||||
|
const ownerId =
|
||||||
|
typeof patch.ownerId === "string" && patch.ownerId.trim() ? patch.ownerId.trim() : null;
|
||||||
|
watch.ownerId = ownerId;
|
||||||
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) {
|
if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) {
|
||||||
if (!patch.searchParams || typeof patch.searchParams !== "object") {
|
if (!patch.searchParams || typeof patch.searchParams !== "object") {
|
||||||
throw new Error("searchParams must be an object");
|
throw new Error("searchParams must be an object");
|
||||||
@@ -241,6 +247,7 @@ class PriceWatcher {
|
|||||||
|
|
||||||
addWatch({
|
addWatch({
|
||||||
id,
|
id,
|
||||||
|
ownerId = null,
|
||||||
rawInput,
|
rawInput,
|
||||||
searchParams,
|
searchParams,
|
||||||
alertRules,
|
alertRules,
|
||||||
@@ -263,6 +270,7 @@ class PriceWatcher {
|
|||||||
const nowIso = new Date(this.now()).toISOString();
|
const nowIso = new Date(this.now()).toISOString();
|
||||||
this.watches.set(watchId, {
|
this.watches.set(watchId, {
|
||||||
id: watchId,
|
id: watchId,
|
||||||
|
ownerId: typeof ownerId === "string" && ownerId.trim() ? ownerId.trim() : null,
|
||||||
rawInput: typeof rawInput === "string" ? rawInput : "",
|
rawInput: typeof rawInput === "string" ? rawInput : "",
|
||||||
searchParams,
|
searchParams,
|
||||||
alertRules: normalizeAlertRules(alertRules),
|
alertRules: normalizeAlertRules(alertRules),
|
||||||
@@ -301,6 +309,18 @@ class PriceWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async pollAll() {
|
async pollAll() {
|
||||||
|
if (this.pollingInFlight) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
skipped: {
|
||||||
|
reason: "poll_cycle_in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollingInFlight = true;
|
||||||
|
try {
|
||||||
if (!this.globalControls.crawlingEnabled) {
|
if (!this.globalControls.crawlingEnabled) {
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
for (const watch of this.watches.values()) {
|
for (const watch of this.watches.values()) {
|
||||||
@@ -333,6 +353,9 @@ class PriceWatcher {
|
|||||||
results.push(await this.pollWatch(watch.id));
|
results.push(await this.pollWatch(watch.id));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
} finally {
|
||||||
|
this.pollingInFlight = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async pollWatch(watchId) {
|
async pollWatch(watchId) {
|
||||||
@@ -359,16 +382,86 @@ class PriceWatcher {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IP 차단 방지를 위한 최종 안전장치: 필수 정보 부재 시 크롤링 건너뜀
|
||||||
|
const params = watch.searchParams || {};
|
||||||
|
if (!params.segments || params.segments.length === 0 || !params.departureDateWindow?.from) {
|
||||||
|
const result = {
|
||||||
|
watchId: watch.id,
|
||||||
|
skipped: { reason: "missing_essential_search_params" },
|
||||||
|
};
|
||||||
|
await this.emitPolled(watch, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let normalizedOffers = [];
|
||||||
|
const searchParams = watch.searchParams || {};
|
||||||
|
const byCabin = searchParams.passengers?.byCabin || {};
|
||||||
|
const activeCabins = Object.entries(byCabin).filter(([_, count]) => count > 0);
|
||||||
|
|
||||||
|
if (activeCabins.length > 1) {
|
||||||
|
// 다중 클래스(예: 비즈니스 2, 이코노미 1) 쪼개서 크롤링
|
||||||
|
const sameFlightMode = searchParams.constraints?.sameFlightForAllPassengers !== false;
|
||||||
|
const cabinResults = [];
|
||||||
|
let totalBestPrice = 0;
|
||||||
|
let subOffers = [];
|
||||||
|
const firstCurrency = "KRW";
|
||||||
|
|
||||||
|
for (const [cabin, count] of activeCabins) {
|
||||||
|
const singleCabinParams = cloneJson(searchParams);
|
||||||
|
singleCabinParams.passengers.total = count;
|
||||||
|
singleCabinParams.passengers.byCabin = { [cabin]: count };
|
||||||
|
|
||||||
|
const offers = await this.crawler.getQuotes({
|
||||||
|
watchId: watch.id,
|
||||||
|
rawInput: watch.rawInput,
|
||||||
|
searchParams: singleCabinParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = normalizeOffers(offers);
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new Error(`Crawler returned no valid offers for cabin: ${cabin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cabinResults.push({ cabin, count, offers: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameFlightMode) {
|
||||||
|
// [추후 고도화 필요] 동일 비행기(편명, 시간) 매칭 로직.
|
||||||
|
// 현재는 Mock 또는 단순 크롤러 결과이므로 각 클래스 최저가를 합산하되, '동일 항공편 유지'라는 플래그만 UI에 전달.
|
||||||
|
for (const res of cabinResults) {
|
||||||
|
const best = res.offers[0];
|
||||||
|
totalBestPrice += best.price;
|
||||||
|
subOffers.push({ cabin: res.cabin, paxCount: res.count, price: best.price, provider: best.provider, metadata: best.metadata || null });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 개별 편명 최저가 허용 시, 무조건 각 결과의 최저가를 합산
|
||||||
|
for (const res of cabinResults) {
|
||||||
|
const best = res.offers[0];
|
||||||
|
totalBestPrice += best.price;
|
||||||
|
subOffers.push({ cabin: res.cabin, paxCount: res.count, price: best.price, provider: best.provider, metadata: best.metadata || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedOffer = {
|
||||||
|
provider: "mixed-cabins",
|
||||||
|
price: totalBestPrice,
|
||||||
|
currency: cabinResults[0].offers[0]?.currency || firstCurrency,
|
||||||
|
metadata: subOffers[0]?.metadata || null,
|
||||||
|
subOffers,
|
||||||
|
};
|
||||||
|
normalizedOffers = [combinedOffer];
|
||||||
|
} else {
|
||||||
const offers = await this.crawler.getQuotes({
|
const offers = await this.crawler.getQuotes({
|
||||||
watchId: watch.id,
|
watchId: watch.id,
|
||||||
rawInput: watch.rawInput,
|
rawInput: watch.rawInput,
|
||||||
searchParams: watch.searchParams,
|
searchParams: watch.searchParams,
|
||||||
});
|
});
|
||||||
const normalizedOffers = normalizeOffers(offers);
|
normalizedOffers = normalizeOffers(offers);
|
||||||
if (normalizedOffers.length === 0) {
|
if (normalizedOffers.length === 0) {
|
||||||
throw new Error("Crawler returned no valid offers");
|
throw new Error("Crawler returned no valid offers");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bestOffer = normalizedOffers[0];
|
const bestOffer = normalizedOffers[0];
|
||||||
const currentSnapshot = {
|
const currentSnapshot = {
|
||||||
@@ -388,7 +481,9 @@ class PriceWatcher {
|
|||||||
if (alert) {
|
if (alert) {
|
||||||
if (this.globalControls.alertsEnabled && watch.alertsEnabled) {
|
if (this.globalControls.alertsEnabled && watch.alertsEnabled) {
|
||||||
try {
|
try {
|
||||||
await this.notifier.notify(alert);
|
await this.notifier.notify(alert, {
|
||||||
|
watch: this.toPublicWatch(watch),
|
||||||
|
});
|
||||||
notificationSent = true;
|
notificationSent = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const at = new Date(this.now()).toISOString();
|
const at = new Date(this.now()).toISOString();
|
||||||
|
|||||||
52
test/apiAuth.test.js
Normal file
52
test/apiAuth.test.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { isAuthorizedRequest, resolveApiAuth } = require("../src/apiAuth");
|
||||||
|
|
||||||
|
test("resolveApiAuth requires token by default in production", () => {
|
||||||
|
assert.throws(() => resolveApiAuth({ nodeEnv: "production" }), /DASHBOARD_API_TOKEN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveApiAuth is disabled by default in non-production", () => {
|
||||||
|
const authConfig = resolveApiAuth({ nodeEnv: "development" });
|
||||||
|
assert.equal(authConfig.enabled, false);
|
||||||
|
assert.equal(authConfig.token, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isAuthorizedRequest supports bearer and x-api-key headers", () => {
|
||||||
|
const authConfig = resolveApiAuth({
|
||||||
|
nodeEnv: "production",
|
||||||
|
apiToken: "top-secret-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isAuthorizedRequest(
|
||||||
|
{
|
||||||
|
authorization: "Bearer top-secret-token",
|
||||||
|
},
|
||||||
|
authConfig
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isAuthorizedRequest(
|
||||||
|
{
|
||||||
|
"x-api-key": "top-secret-token",
|
||||||
|
},
|
||||||
|
authConfig
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isAuthorizedRequest(
|
||||||
|
{
|
||||||
|
authorization: "Bearer wrong-token",
|
||||||
|
},
|
||||||
|
authConfig
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
404
test/crawlerUrls.test.js
Normal file
404
test/crawlerUrls.test.js
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
// const { buildNaverUrl } = require("../src/crawlers/naver");
|
||||||
|
const { buildSkyscannerUrl } = require("../src/crawlers/skyscanner");
|
||||||
|
const { buildGoogleUrl } = require("../src/crawlers/google");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeParams(overrides = {}) {
|
||||||
|
return {
|
||||||
|
tripType: "one_way",
|
||||||
|
segments: [{ from: "ICN", to: "NRT" }],
|
||||||
|
passengers: { total: 1, byCabin: {} },
|
||||||
|
departureDateWindow: { from: "2026-03-15" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Naver URL tests (provider 일시 제외)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// test("naver: one-way URL format", () => {
|
||||||
|
// const url = buildNaverUrl(makeParams());
|
||||||
|
// assert.match(url, /flight\.naver\.com\/flights\/international\/ICN-NRT-20260315/);
|
||||||
|
// assert.match(url, /adult=1/);
|
||||||
|
// assert.match(url, /fareType=Y/);
|
||||||
|
// assert.ok(!url.includes("/NRT-ICN-"));
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("naver: round-trip URL has separate outbound and return segments", () => {
|
||||||
|
// const url = buildNaverUrl(makeParams({ tripType: "round_trip", stayDurationDays: { minDays: 7 } }));
|
||||||
|
// assert.match(url, /ICN-NRT-20260315/);
|
||||||
|
// assert.match(url, /\/NRT-ICN-20260322/);
|
||||||
|
// assert.match(url, /fareType=Y/);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("naver: round-trip defaults to 7-day return when stayDurationDays is missing", () => {
|
||||||
|
// const url = buildNaverUrl(makeParams({ tripType: "round_trip" }));
|
||||||
|
// assert.match(url, /\/NRT-ICN-20260322/);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("naver: multi-city uses /flights/multi and colon separators", () => {
|
||||||
|
// const url = buildNaverUrl(makeParams({
|
||||||
|
// tripType: "multi_city",
|
||||||
|
// segments: [{ from: "ICN", to: "NRT" }, { from: "NRT", to: "ICN" }],
|
||||||
|
// }));
|
||||||
|
// assert.match(url, /\/flights\/multi\?/);
|
||||||
|
// assert.match(url, /ICN:NRT:20260315/);
|
||||||
|
// assert.match(url, /NRT:ICN:20260318/);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("naver: business class maps to fareType=C", () => {
|
||||||
|
// const url = buildNaverUrl(makeParams({ passengers: { total: 2, byCabin: { business: 2 } } }));
|
||||||
|
// assert.match(url, /fareType=C/);
|
||||||
|
// assert.match(url, /adult=2/);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("naver: first class maps to fareType=F", () => {
|
||||||
|
// const url = buildNaverUrl(makeParams({ passengers: { total: 1, byCabin: { first: 1 } } }));
|
||||||
|
// assert.match(url, /fareType=F/);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("naver: empty segments returns base URL", () => {
|
||||||
|
// const url = buildNaverUrl({ segments: [] });
|
||||||
|
// assert.equal(url, "https://flight.naver.com");
|
||||||
|
// });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skyscanner URL tests (/transport/d/ format with YYYY-MM-DD)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("skyscanner: one-way uses /transport/d/ with YYYY-MM-DD", () => {
|
||||||
|
const url = buildSkyscannerUrl(makeParams());
|
||||||
|
assert.match(url, /\/transport\/d\/icn\/2026-03-15\/nrt\//);
|
||||||
|
assert.match(url, /adultsv2=1/);
|
||||||
|
assert.match(url, /cabinclass=economy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: round-trip path has outbound and return legs", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({
|
||||||
|
tripType: "round_trip",
|
||||||
|
stayDurationDays: { minDays: 5 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// /icn/2026-03-15/nrt/nrt/2026-03-20/icn/
|
||||||
|
assert.match(url, /\/icn\/2026-03-15\/nrt\/nrt\/2026-03-20\/icn\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: round-trip defaults to 7-day return", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({ tripType: "round_trip" })
|
||||||
|
);
|
||||||
|
assert.match(url, /\/nrt\/2026-03-22\/icn\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: multi-city path-based with multiple legs", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({
|
||||||
|
tripType: "multi_city",
|
||||||
|
segments: [
|
||||||
|
{ from: "ICN", to: "MAD" },
|
||||||
|
{ from: "BCN", to: "ICN" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// /transport/d/icn/2026-03-15/mad/bcn/2026-03-22/icn/ (default 7-day stay)
|
||||||
|
assert.match(url, /\/transport\/d\/icn\/2026-03-15\/mad\/bcn\/2026-03-22\/icn\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: business cabin class", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({ passengers: { total: 1, byCabin: { business: 1 } } })
|
||||||
|
);
|
||||||
|
assert.match(url, /cabinclass=business/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: duration param (total journey minutes)", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({ constraints: { maxJourneyHours: { hours: 33.5 } } })
|
||||||
|
);
|
||||||
|
assert.match(url, /duration=2010/); // 33.5 * 60
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: maxStops=0 → stops=!oneStop,!twoPlusStops (direct only)", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({ constraints: { maxStops: 0 } })
|
||||||
|
);
|
||||||
|
const stops = new URL(url).searchParams.get("stops");
|
||||||
|
assert.equal(stops, "!oneStop,!twoPlusStops");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: maxStops=1 → stops=!twoPlusStops (direct + 1 stop)", () => {
|
||||||
|
const url = buildSkyscannerUrl(
|
||||||
|
makeParams({ constraints: { maxStops: 1 } })
|
||||||
|
);
|
||||||
|
const stops = new URL(url).searchParams.get("stops");
|
||||||
|
assert.equal(stops, "!twoPlusStops");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: no maxStops → no stops param", () => {
|
||||||
|
const url = buildSkyscannerUrl(makeParams());
|
||||||
|
const stops = new URL(url).searchParams.get("stops");
|
||||||
|
assert.equal(stops, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: matches real URL structure (multi-city + business + stops + duration)", () => {
|
||||||
|
const url = buildSkyscannerUrl({
|
||||||
|
tripType: "multi_city",
|
||||||
|
segments: [
|
||||||
|
{ from: "ICN", to: "MAD" },
|
||||||
|
{ from: "BCN", to: "ICN" },
|
||||||
|
],
|
||||||
|
passengers: { total: 2, byCabin: { business: 2 } },
|
||||||
|
departureDateWindow: { from: "2026-11-26" },
|
||||||
|
stayDurationDays: { minDays: 19 },
|
||||||
|
constraints: { maxStops: 1, maxJourneyHours: { hours: 33.5 } },
|
||||||
|
});
|
||||||
|
// Path: return leg uses stayDurationDays.minDays=19 → 2026-11-26 + 19 = 2026-12-15
|
||||||
|
assert.match(url, /\/transport\/d\/icn\/2026-11-26\/mad\/bcn\/2026-12-15\/icn\//);
|
||||||
|
// Params
|
||||||
|
assert.match(url, /adultsv2=2/);
|
||||||
|
assert.match(url, /cabinclass=business/);
|
||||||
|
assert.match(url, /duration=2010/);
|
||||||
|
const stops = new URL(url).searchParams.get("stops");
|
||||||
|
assert.equal(stops, "!twoPlusStops");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skyscanner: empty segments returns base URL", () => {
|
||||||
|
const url = buildSkyscannerUrl({ segments: [] });
|
||||||
|
assert.equal(url, "https://www.skyscanner.co.kr");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Google URL tests (protobuf tfs format)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: decode URL-safe base64 tfs param and parse protobuf fields.
|
||||||
|
* Returns a flat-ish structure for easy assertions.
|
||||||
|
*/
|
||||||
|
function decodeTfs(url) {
|
||||||
|
const tfs = new URL(url).searchParams.get("tfs");
|
||||||
|
if (!tfs) return null;
|
||||||
|
const std = tfs.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const buf = Buffer.from(std, "base64");
|
||||||
|
return parseProtobuf(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVarint(buf, pos) {
|
||||||
|
let value = 0n;
|
||||||
|
let shift = 0n;
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const byte = buf[pos++];
|
||||||
|
value |= BigInt(byte & 0x7f) << shift;
|
||||||
|
shift += 7n;
|
||||||
|
if ((byte & 0x80) === 0) break;
|
||||||
|
}
|
||||||
|
return { value: Number(value), next: pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProtobuf(buf) {
|
||||||
|
const results = [];
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < buf.length) {
|
||||||
|
const tag = readVarint(buf, pos);
|
||||||
|
pos = tag.next;
|
||||||
|
const fieldNum = tag.value >> 3;
|
||||||
|
const wireType = tag.value & 0x7;
|
||||||
|
if (wireType === 0) {
|
||||||
|
const val = readVarint(buf, pos);
|
||||||
|
pos = val.next;
|
||||||
|
results.push({ f: fieldNum, t: "varint", v: val.value });
|
||||||
|
} else if (wireType === 2) {
|
||||||
|
const len = readVarint(buf, pos);
|
||||||
|
pos = len.next;
|
||||||
|
const data = buf.slice(pos, pos + len.value);
|
||||||
|
pos += len.value;
|
||||||
|
const str = data.toString("utf8");
|
||||||
|
if (/^[\x20-\x7E]+$/.test(str)) {
|
||||||
|
results.push({ f: fieldNum, t: "str", v: str });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const nested = parseProtobuf(data);
|
||||||
|
results.push({ f: fieldNum, t: "msg", v: nested });
|
||||||
|
} catch {
|
||||||
|
results.push({ f: fieldNum, t: "bytes", v: data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract all field_3 (segment) messages from decoded tfs */
|
||||||
|
function getSegments(decoded) {
|
||||||
|
return decoded.filter((f) => f.f === 3 && f.t === "msg").map((f) => f.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a field value in a decoded protobuf array */
|
||||||
|
function findField(decoded, fieldNum) {
|
||||||
|
const f = decoded.find((x) => x.f === fieldNum);
|
||||||
|
return f ? f.v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("google: uses protobuf tfs format (not ?q= query)", () => {
|
||||||
|
const url = buildGoogleUrl(makeParams());
|
||||||
|
assert.match(url, /\/flights\/search\?tfs=/);
|
||||||
|
assert.match(url, /&tfu=/);
|
||||||
|
assert.ok(!url.includes("?q="));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: one-way has 1 segment with correct airports and date", () => {
|
||||||
|
const url = buildGoogleUrl(makeParams());
|
||||||
|
const decoded = decodeTfs(url);
|
||||||
|
const segs = getSegments(decoded);
|
||||||
|
assert.equal(segs.length, 1);
|
||||||
|
// Date
|
||||||
|
assert.equal(findField(segs[0], 2), "2026-03-15");
|
||||||
|
// Origin: field 13 → nested field 2 = "ICN"
|
||||||
|
const origin = findField(segs[0], 13);
|
||||||
|
assert.equal(findField(origin, 2), "ICN");
|
||||||
|
// Dest: field 14 → nested field 2 = "NRT"
|
||||||
|
const dest = findField(segs[0], 14);
|
||||||
|
assert.equal(findField(dest, 2), "NRT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: round-trip has 2 segments with reversed airports", () => {
|
||||||
|
const url = buildGoogleUrl(
|
||||||
|
makeParams({ tripType: "round_trip", stayDurationDays: { minDays: 7 } })
|
||||||
|
);
|
||||||
|
const decoded = decodeTfs(url);
|
||||||
|
const segs = getSegments(decoded);
|
||||||
|
assert.equal(segs.length, 2);
|
||||||
|
// Outbound: ICN → NRT on 2026-03-15
|
||||||
|
assert.equal(findField(segs[0], 2), "2026-03-15");
|
||||||
|
assert.equal(findField(findField(segs[0], 13), 2), "ICN");
|
||||||
|
assert.equal(findField(findField(segs[0], 14), 2), "NRT");
|
||||||
|
// Return: NRT → ICN on 2026-03-22
|
||||||
|
assert.equal(findField(segs[1], 2), "2026-03-22");
|
||||||
|
assert.equal(findField(findField(segs[1], 13), 2), "NRT");
|
||||||
|
assert.equal(findField(findField(segs[1], 14), 2), "ICN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: round-trip defaults to 7-day return when stayDurationDays is missing", () => {
|
||||||
|
const url = buildGoogleUrl(makeParams({ tripType: "round_trip" }));
|
||||||
|
const segs = getSegments(decodeTfs(url));
|
||||||
|
assert.equal(segs.length, 2);
|
||||||
|
assert.equal(findField(segs[1], 2), "2026-03-22");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: maxJourneyHours encodes as field_12 in minutes", () => {
|
||||||
|
const url = buildGoogleUrl(
|
||||||
|
makeParams({ constraints: { maxJourneyHours: { hours: 9 } } })
|
||||||
|
);
|
||||||
|
const segs = getSegments(decodeTfs(url));
|
||||||
|
assert.equal(findField(segs[0], 12), 540); // 9h * 60
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: maxStops encodes as field_5", () => {
|
||||||
|
const url = buildGoogleUrl(
|
||||||
|
makeParams({ constraints: { maxStops: 1 } })
|
||||||
|
);
|
||||||
|
const segs = getSegments(decodeTfs(url));
|
||||||
|
assert.equal(findField(segs[0], 5), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: maxStops=0 means direct flights only", () => {
|
||||||
|
const url = buildGoogleUrl(
|
||||||
|
makeParams({ constraints: { maxStops: 0 } })
|
||||||
|
);
|
||||||
|
const segs = getSegments(decodeTfs(url));
|
||||||
|
assert.equal(findField(segs[0], 5), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: no maxStops omits field_5", () => {
|
||||||
|
const url = buildGoogleUrl(makeParams());
|
||||||
|
const segs = getSegments(decodeTfs(url));
|
||||||
|
assert.equal(findField(segs[0], 5), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: byte-exact match with known duration-filter URL", () => {
|
||||||
|
const url = buildGoogleUrl({
|
||||||
|
tripType: "round_trip",
|
||||||
|
segments: [{ from: "ICN", to: "NRT" }],
|
||||||
|
passengers: { total: 1, byCabin: {} },
|
||||||
|
departureDateWindow: { from: "2026-03-15" },
|
||||||
|
stayDurationDays: { minDays: 7 },
|
||||||
|
constraints: { maxJourneyHours: { hours: 9 } },
|
||||||
|
});
|
||||||
|
const got = new URL(url).searchParams.get("tfs");
|
||||||
|
const expected =
|
||||||
|
"CBwQAhohEgoyMDI2LTAzLTE1YJwEagcIARIDSUNOcgcIARIDTlJUGiESCjIwMjYtMDMtMjJgnARqBwgBEgNOUlRyBwgBEgNJQ05AAUgBcAGCAQsI____________AZgBAQ";
|
||||||
|
assert.equal(got, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: byte-exact match with known 1-stop URL", () => {
|
||||||
|
const url = buildGoogleUrl({
|
||||||
|
tripType: "round_trip",
|
||||||
|
segments: [{ from: "ICN", to: "NRT" }],
|
||||||
|
passengers: { total: 1, byCabin: {} },
|
||||||
|
departureDateWindow: { from: "2026-03-15" },
|
||||||
|
stayDurationDays: { minDays: 7 },
|
||||||
|
constraints: { maxStops: 1, maxJourneyHours: { hours: 9 } },
|
||||||
|
});
|
||||||
|
const got = new URL(url).searchParams.get("tfs");
|
||||||
|
const expected =
|
||||||
|
"CBwQAhojEgoyMDI2LTAzLTE1KAFgnARqBwgBEgNJQ05yBwgBEgNOUlQaIxIKMjAyNi0wMy0yMigBYJwEagcIARIDTlJUcgcIARIDSUNOQAFIAXABggELCP___________wGYAQE";
|
||||||
|
assert.equal(got, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: field 19 encodes trip type (1=RT, 2=OW, 3=MC)", () => {
|
||||||
|
const ow = buildGoogleUrl(makeParams({ tripType: "one_way" }));
|
||||||
|
assert.equal(findField(decodeTfs(ow), 19), 2);
|
||||||
|
|
||||||
|
const rt = buildGoogleUrl(makeParams({ tripType: "round_trip" }));
|
||||||
|
assert.equal(findField(decodeTfs(rt), 19), 1);
|
||||||
|
|
||||||
|
const mc = buildGoogleUrl(makeParams({
|
||||||
|
tripType: "multi_city",
|
||||||
|
segments: [{ from: "ICN", to: "NRT" }, { from: "NRT", to: "LAX" }],
|
||||||
|
}));
|
||||||
|
assert.equal(findField(decodeTfs(mc), 19), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: multi-city has correct number of segments", () => {
|
||||||
|
const url = buildGoogleUrl(
|
||||||
|
makeParams({
|
||||||
|
tripType: "multi_city",
|
||||||
|
segments: [
|
||||||
|
{ from: "ICN", to: "NRT" },
|
||||||
|
{ from: "NRT", to: "LAX" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const decoded = decodeTfs(url);
|
||||||
|
assert.equal(findField(decoded, 2), 2);
|
||||||
|
const segs = getSegments(decoded);
|
||||||
|
assert.equal(segs.length, 2);
|
||||||
|
assert.equal(findField(findField(segs[1], 13), 2), "NRT");
|
||||||
|
assert.equal(findField(findField(segs[1], 14), 2), "LAX");
|
||||||
|
assert.equal(findField(segs[1], 2), "2026-03-22"); // default 7-day stay
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: business class encodes as cabinClass=3 (field_9)", () => {
|
||||||
|
const url = buildGoogleUrl(
|
||||||
|
makeParams({ passengers: { total: 2, byCabin: { business: 2 } } })
|
||||||
|
);
|
||||||
|
const decoded = decodeTfs(url);
|
||||||
|
assert.equal(findField(decoded, 8), 2); // adults
|
||||||
|
assert.equal(findField(decoded, 9), 3); // business = 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("google: empty segments returns base URL", () => {
|
||||||
|
const url = buildGoogleUrl({ segments: [] });
|
||||||
|
assert.equal(url, "https://www.google.com/travel/flights");
|
||||||
|
});
|
||||||
133
test/dashboardRuntime.test.js
Normal file
133
test/dashboardRuntime.test.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { createDashboardRuntime } = require("../src/dashboardRuntime");
|
||||||
|
|
||||||
|
function createStore({ controls }) {
|
||||||
|
const events = [];
|
||||||
|
const watches = [
|
||||||
|
{
|
||||||
|
id: "watch-1",
|
||||||
|
rawInput: "인천->도쿄",
|
||||||
|
searchParams: { segments: [{ from: "ICN", to: "NRT" }], departureDateWindow: { from: "2026-06-01" } },
|
||||||
|
alertRules: {
|
||||||
|
targetPrice: null,
|
||||||
|
notifyOnPriceChange: true,
|
||||||
|
notifyOnFirstResult: true,
|
||||||
|
},
|
||||||
|
pollingEnabled: true,
|
||||||
|
alertsEnabled: true,
|
||||||
|
createdAt: "2026-02-19T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-02-19T00:00:00.000Z",
|
||||||
|
lastSnapshot: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
async init() {},
|
||||||
|
async close() {},
|
||||||
|
async listWatches() {
|
||||||
|
return watches;
|
||||||
|
},
|
||||||
|
async getWatch() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async saveWatch(watch) {
|
||||||
|
return watch;
|
||||||
|
},
|
||||||
|
async deleteWatch() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async savePollResult() {},
|
||||||
|
async saveEvent(event) {
|
||||||
|
events.push(event);
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
async listEvents() {
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
async getGlobalControls() {
|
||||||
|
return controls;
|
||||||
|
},
|
||||||
|
async setGlobalControls(patch = {}) {
|
||||||
|
controls = { ...controls, ...patch };
|
||||||
|
return controls;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("runtime stores failed notification state when notifier throws", async () => {
|
||||||
|
const store = createStore({
|
||||||
|
controls: { crawlingEnabled: true, alertsEnabled: true },
|
||||||
|
});
|
||||||
|
const crawler = {
|
||||||
|
async getQuotes() {
|
||||||
|
return [{ provider: "mock", price: 120000, currency: "KRW" }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const notifier = {
|
||||||
|
async notify() {
|
||||||
|
throw new Error("network down");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = await createDashboardRuntime({
|
||||||
|
store,
|
||||||
|
crawler,
|
||||||
|
notifier,
|
||||||
|
logger: { error: () => {} },
|
||||||
|
pollIntervalSec: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(store.events.length, 1);
|
||||||
|
const payload = store.events[0].payload;
|
||||||
|
assert.equal(payload.notificationState, "failed");
|
||||||
|
assert.equal(payload.notificationSent, false);
|
||||||
|
assert.equal(payload.notificationSuppressed, undefined);
|
||||||
|
assert.equal(payload.notificationError.phase, "notify");
|
||||||
|
assert.match(payload.notificationError.message, /Notifier failed/);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime stores suppressed notification state when alerts are disabled", async () => {
|
||||||
|
let notifyCalls = 0;
|
||||||
|
const store = createStore({
|
||||||
|
controls: { crawlingEnabled: true, alertsEnabled: false },
|
||||||
|
});
|
||||||
|
const crawler = {
|
||||||
|
async getQuotes() {
|
||||||
|
return [{ provider: "mock", price: 120000, currency: "KRW" }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const notifier = {
|
||||||
|
async notify() {
|
||||||
|
notifyCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = await createDashboardRuntime({
|
||||||
|
store,
|
||||||
|
crawler,
|
||||||
|
notifier,
|
||||||
|
logger: { error: () => {} },
|
||||||
|
pollIntervalSec: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(store.events.length, 1);
|
||||||
|
const payload = store.events[0].payload;
|
||||||
|
assert.equal(payload.notificationState, "suppressed");
|
||||||
|
assert.equal(payload.notificationSent, false);
|
||||||
|
assert.equal(payload.notificationSuppressed, true);
|
||||||
|
assert.equal(payload.notificationError, null);
|
||||||
|
assert.equal(notifyCalls, 0);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,7 +2,38 @@
|
|||||||
|
|
||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { InMemoryDashboardStore } = require("../src/dashboardStore");
|
const {
|
||||||
|
InMemoryDashboardStore,
|
||||||
|
MySqlDashboardStore,
|
||||||
|
createDashboardStore,
|
||||||
|
} = require("../src/dashboardStore");
|
||||||
|
|
||||||
|
async function withPatchedEnv(patch, fn) {
|
||||||
|
const backup = {};
|
||||||
|
for (const [key] of Object.entries(patch)) {
|
||||||
|
backup[key] = process.env[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of Object.entries(backup)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("in-memory store persists watch, poll result, events and controls", async () => {
|
test("in-memory store persists watch, poll result, events and controls", async () => {
|
||||||
const store = new InMemoryDashboardStore();
|
const store = new InMemoryDashboardStore();
|
||||||
@@ -63,3 +94,130 @@ test("in-memory store persists watch, poll result, events and controls", async (
|
|||||||
assert.equal(controlsAfter.crawlingEnabled, false);
|
assert.equal(controlsAfter.crawlingEnabled, false);
|
||||||
assert.equal(controlsAfter.alertsEnabled, false);
|
assert.equal(controlsAfter.alertsEnabled, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mysql mode fails closed without fallback when configuration is missing", async () => {
|
||||||
|
await withPatchedEnv(
|
||||||
|
{
|
||||||
|
DASHBOARD_DB: undefined,
|
||||||
|
MYSQL_URL: undefined,
|
||||||
|
MYSQL_HOST: undefined,
|
||||||
|
MYSQL_USER: undefined,
|
||||||
|
MYSQL_DATABASE: undefined,
|
||||||
|
DASHBOARD_ALLOW_MEMORY_FALLBACK: undefined,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
createDashboardStore({
|
||||||
|
mode: "mysql",
|
||||||
|
allowMemoryFallback: false,
|
||||||
|
}),
|
||||||
|
/MySQL 연결 정보가 필요합니다/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mysql mode can fallback to memory only when explicitly allowed", async () => {
|
||||||
|
await withPatchedEnv(
|
||||||
|
{
|
||||||
|
DASHBOARD_DB: undefined,
|
||||||
|
MYSQL_URL: undefined,
|
||||||
|
MYSQL_HOST: undefined,
|
||||||
|
MYSQL_USER: undefined,
|
||||||
|
MYSQL_DATABASE: undefined,
|
||||||
|
DASHBOARD_ALLOW_MEMORY_FALLBACK: undefined,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const setup = await createDashboardStore({
|
||||||
|
mode: "mysql",
|
||||||
|
allowMemoryFallback: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.engine, "memory");
|
||||||
|
assert.match(setup.warning, /메모리 저장소/);
|
||||||
|
await setup.store.close();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mysql store init fails when required tables are missing", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const store = new MySqlDashboardStore(
|
||||||
|
{
|
||||||
|
query: async (sql) => {
|
||||||
|
calls.push(sql);
|
||||||
|
return [[{ TABLE_NAME: "projects" }], []];
|
||||||
|
},
|
||||||
|
end: async () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => store.init(),
|
||||||
|
/MySQL playground 스키마가 준비되지 않았습니다\..*project_documents.*project_events.*project_settings/
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mysql store init succeeds when all required tables exist", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const store = new MySqlDashboardStore(
|
||||||
|
{
|
||||||
|
query: async (sql, params) => {
|
||||||
|
calls.push(sql);
|
||||||
|
if (String(sql).includes("FROM information_schema.TABLES")) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{ TABLE_NAME: "projects" },
|
||||||
|
{ TABLE_NAME: "project_documents" },
|
||||||
|
{ TABLE_NAME: "project_events" },
|
||||||
|
{ TABLE_NAME: "project_settings" },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (String(sql).includes("INSERT INTO projects")) {
|
||||||
|
assert.equal(params[0], "air-watcher");
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected query: ${sql}`);
|
||||||
|
},
|
||||||
|
end: async () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.doesNotReject(() => store.init());
|
||||||
|
assert.equal(store.schemaMode, "playground");
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mysql store supports custom projectKey", async () => {
|
||||||
|
let insertedProjectKey = null;
|
||||||
|
const store = new MySqlDashboardStore({
|
||||||
|
query: async (sql, params) => {
|
||||||
|
if (String(sql).includes("FROM information_schema.TABLES")) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{ TABLE_NAME: "projects" },
|
||||||
|
{ TABLE_NAME: "project_documents" },
|
||||||
|
{ TABLE_NAME: "project_events" },
|
||||||
|
{ TABLE_NAME: "project_settings" },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (String(sql).includes("INSERT INTO projects")) {
|
||||||
|
insertedProjectKey = params[0];
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected query: ${sql}`);
|
||||||
|
},
|
||||||
|
end: async () => {},
|
||||||
|
}, {
|
||||||
|
projectKey: "mini-app-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.init();
|
||||||
|
assert.equal(insertedProjectKey, "mini-app-a");
|
||||||
|
});
|
||||||
|
|||||||
26
test/dashboardUtils.test.js
Normal file
26
test/dashboardUtils.test.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { createHttpError, toPublicErrorResponse } = require("../src/dashboardUtils");
|
||||||
|
|
||||||
|
test("toPublicErrorResponse keeps 4xx error details", () => {
|
||||||
|
const failure = toPublicErrorResponse(createHttpError(400, "잘못된 요청"));
|
||||||
|
assert.equal(failure.statusCode, 400);
|
||||||
|
assert.equal(failure.body.error, "잘못된 요청");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toPublicErrorResponse masks 5xx errors", () => {
|
||||||
|
const loggerCalls = [];
|
||||||
|
const failure = toPublicErrorResponse(new Error("db password exposed"), {
|
||||||
|
logger: {
|
||||||
|
error(message) {
|
||||||
|
loggerCalls.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(failure.statusCode, 500);
|
||||||
|
assert.equal(failure.body.error, "Internal Server Error");
|
||||||
|
assert.equal(loggerCalls.length, 1);
|
||||||
|
});
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { extractFlightSearchRequest } = require("../src/llmParameterExtractor");
|
const {
|
||||||
|
createOpenAIClient,
|
||||||
|
extractFlightSearchRequest,
|
||||||
|
} = require("../src/llmParameterExtractor");
|
||||||
|
|
||||||
test("uses LLM output when client is provided", async () => {
|
test("uses LLM output when client is provided", async () => {
|
||||||
const llmClient = async () => ({
|
const llmClient = async () => ({
|
||||||
@@ -67,3 +70,102 @@ test("falls back to rule parser when LLM client fails", async () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("corrects LLM round_trip to multi_city when segments have different cities", async () => {
|
||||||
|
// LLM incorrectly says round_trip for ICN→MAD / BCN→ICN (MAD ≠ BCN)
|
||||||
|
const llmClient = async () => ({
|
||||||
|
departureDateWindow: { from: "2026-11-25", to: "2026-11-25" },
|
||||||
|
stayDurationDays: { minDays: 12, maxDays: 12 },
|
||||||
|
segments: [
|
||||||
|
{ from: "ICN", to: "MAD" },
|
||||||
|
{ from: "BCN", to: "ICN" },
|
||||||
|
],
|
||||||
|
passengers: { total: 3, byCabin: { economy: 1, premium_economy: 0, business: 2, first: 0 } },
|
||||||
|
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: 1, maxJourneyHours: { hours: 42, operator: "<" } },
|
||||||
|
tripType: "round_trip",
|
||||||
|
warnings: [],
|
||||||
|
missingFields: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await extractFlightSearchRequest("인천-마드리드, 바르셀로나-인천", {
|
||||||
|
now: new Date("2026-02-20T00:00:00Z"),
|
||||||
|
llmClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.source, "llm");
|
||||||
|
// Must be corrected to multi_city, not round_trip
|
||||||
|
assert.equal(result.params.tripType, "multi_city");
|
||||||
|
assert.equal(result.params.segments.length, 2);
|
||||||
|
assert.equal(result.params.segments[0].from, "ICN");
|
||||||
|
assert.equal(result.params.segments[1].from, "BCN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps round_trip when segments are genuinely reversed (A→B / B→A)", async () => {
|
||||||
|
const llmClient = async () => ({
|
||||||
|
departureDateWindow: { from: "2026-06-01", to: "2026-06-01" },
|
||||||
|
stayDurationDays: { minDays: 7, maxDays: 7 },
|
||||||
|
segments: [
|
||||||
|
{ from: "ICN", to: "NRT" },
|
||||||
|
{ from: "NRT", to: "ICN" },
|
||||||
|
],
|
||||||
|
passengers: { total: 1, byCabin: { economy: 1, premium_economy: 0, business: 0, first: 0 } },
|
||||||
|
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: null, maxJourneyHours: null },
|
||||||
|
tripType: "round_trip",
|
||||||
|
warnings: [],
|
||||||
|
missingFields: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await extractFlightSearchRequest("인천-도쿄 왕복", {
|
||||||
|
now: new Date("2026-02-20T00:00:00Z"),
|
||||||
|
llmClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.params.tripType, "round_trip");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("corrects open_jaw to multi_city", async () => {
|
||||||
|
const llmClient = async () => ({
|
||||||
|
departureDateWindow: { from: "2026-06-01", to: "2026-06-01" },
|
||||||
|
stayDurationDays: { minDays: 10, maxDays: 10 },
|
||||||
|
segments: [
|
||||||
|
{ from: "ICN", to: "MAD" },
|
||||||
|
{ from: "BCN", to: "ICN" },
|
||||||
|
],
|
||||||
|
passengers: { total: 1, byCabin: { economy: 1, premium_economy: 0, business: 0, first: 0 } },
|
||||||
|
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: null, maxJourneyHours: null },
|
||||||
|
tripType: "open_jaw",
|
||||||
|
warnings: [],
|
||||||
|
missingFields: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await extractFlightSearchRequest("인천-마드리드, 바르셀로나-인천", {
|
||||||
|
now: new Date("2026-02-20T00:00:00Z"),
|
||||||
|
llmClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.params.tripType, "multi_city");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createOpenAIClient aborts timed out requests", async () => {
|
||||||
|
const llmClient = createOpenAIClient({
|
||||||
|
apiKey: "dummy-key",
|
||||||
|
timeoutMs: 10,
|
||||||
|
fetch: async (_url, options) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
options.signal.addEventListener("abort", () => {
|
||||||
|
const abortError = new Error("aborted");
|
||||||
|
abortError.name = "AbortError";
|
||||||
|
reject(abortError);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
llmClient({
|
||||||
|
input: "임의 입력",
|
||||||
|
now: new Date("2026-02-19T00:00:00Z"),
|
||||||
|
}),
|
||||||
|
/timed out/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
28
test/pollingConfig.test.js
Normal file
28
test/pollingConfig.test.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const {
|
||||||
|
MIN_CRAWL_INTERVAL_MS,
|
||||||
|
MIN_CRAWL_INTERVAL_SEC,
|
||||||
|
normalizeCrawlIntervalMs,
|
||||||
|
normalizeCrawlIntervalSec,
|
||||||
|
} = require("../src/pollingConfig");
|
||||||
|
|
||||||
|
test("normalizeCrawlIntervalSec enforces minimum 1 hour", () => {
|
||||||
|
assert.equal(normalizeCrawlIntervalSec(undefined), MIN_CRAWL_INTERVAL_SEC);
|
||||||
|
assert.equal(normalizeCrawlIntervalSec(3600), 3600);
|
||||||
|
assert.equal(normalizeCrawlIntervalSec(7200), 7200);
|
||||||
|
assert.throws(() => normalizeCrawlIntervalSec(60), /3600 이상이어야 합니다/);
|
||||||
|
assert.throws(() => normalizeCrawlIntervalSec(3599), /3600 이상이어야 합니다/);
|
||||||
|
assert.throws(() => normalizeCrawlIntervalSec("abc"), /정수여야 합니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeCrawlIntervalMs enforces minimum 1 hour", () => {
|
||||||
|
assert.equal(normalizeCrawlIntervalMs(undefined), MIN_CRAWL_INTERVAL_MS);
|
||||||
|
assert.equal(normalizeCrawlIntervalMs(3600000), 3600000);
|
||||||
|
assert.equal(normalizeCrawlIntervalMs(7200000), 7200000);
|
||||||
|
assert.throws(() => normalizeCrawlIntervalMs(60000), /3600000 이상이어야 합니다/);
|
||||||
|
assert.throws(() => normalizeCrawlIntervalMs(3599999), /3600000 이상이어야 합니다/);
|
||||||
|
assert.throws(() => normalizeCrawlIntervalMs("bad"), /정수여야 합니다/);
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@ test("emits threshold alerts when crossing and improving below threshold", async
|
|||||||
rawInput: "인천-마드리드 추적",
|
rawInput: "인천-마드리드 추적",
|
||||||
searchParams: {
|
searchParams: {
|
||||||
segments: [{ from: "ICN", to: "MAD" }],
|
segments: [{ from: "ICN", to: "MAD" }],
|
||||||
|
departureDateWindow: { from: "2026-06-01" },
|
||||||
},
|
},
|
||||||
alertRules: {
|
alertRules: {
|
||||||
targetPrice: 900,
|
targetPrice: 900,
|
||||||
@@ -79,6 +80,7 @@ test("emits price change alerts when price changes", async () => {
|
|||||||
rawInput: "인천-마드리드 추적",
|
rawInput: "인천-마드리드 추적",
|
||||||
searchParams: {
|
searchParams: {
|
||||||
segments: [{ from: "ICN", to: "MAD" }],
|
segments: [{ from: "ICN", to: "MAD" }],
|
||||||
|
departureDateWindow: { from: "2026-06-01" },
|
||||||
},
|
},
|
||||||
alertRules: {
|
alertRules: {
|
||||||
notifyOnPriceChange: true,
|
notifyOnPriceChange: true,
|
||||||
@@ -114,6 +116,7 @@ test("keeps crawl snapshot even when notifier fails", async () => {
|
|||||||
rawInput: "인천-마드리드 추적",
|
rawInput: "인천-마드리드 추적",
|
||||||
searchParams: {
|
searchParams: {
|
||||||
segments: [{ from: "ICN", to: "MAD" }],
|
segments: [{ from: "ICN", to: "MAD" }],
|
||||||
|
departureDateWindow: { from: "2026-06-01" },
|
||||||
},
|
},
|
||||||
alertRules: {
|
alertRules: {
|
||||||
targetPrice: 980000,
|
targetPrice: 980000,
|
||||||
@@ -132,3 +135,53 @@ test("keeps crawl snapshot even when notifier fails", async () => {
|
|||||||
assert.equal(watch.lastSnapshot.bestPrice, 950000);
|
assert.equal(watch.lastSnapshot.bestPrice, 950000);
|
||||||
assert.equal(watch.lastError.phase, "notify");
|
assert.equal(watch.lastError.phase, "notify");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pollAll skips when another poll cycle is already in progress", async () => {
|
||||||
|
let release = null;
|
||||||
|
let started = null;
|
||||||
|
|
||||||
|
const startedPromise = new Promise((resolve) => {
|
||||||
|
started = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const watcher = new PriceWatcher({
|
||||||
|
crawler: {
|
||||||
|
async getQuotes() {
|
||||||
|
started();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provider: "slow-crawler",
|
||||||
|
price: 999000,
|
||||||
|
currency: "KRW",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notifier: {
|
||||||
|
async notify() {},
|
||||||
|
},
|
||||||
|
logger: createSilentLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.addWatch({
|
||||||
|
rawInput: "인천-마드리드 추적",
|
||||||
|
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
|
||||||
|
alertRules: {
|
||||||
|
notifyOnPriceChange: true,
|
||||||
|
targetPrice: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstCycle = watcher.pollAll();
|
||||||
|
await startedPromise;
|
||||||
|
|
||||||
|
const skipped = await watcher.pollAll();
|
||||||
|
assert.equal(skipped.length, 1);
|
||||||
|
assert.equal(skipped[0].skipped.reason, "poll_cycle_in_progress");
|
||||||
|
|
||||||
|
release();
|
||||||
|
await firstCycle;
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test("global crawling toggle skips polling", async () => {
|
|||||||
|
|
||||||
const watchId = watcher.addWatch({
|
const watchId = watcher.addWatch({
|
||||||
rawInput: "테스트",
|
rawInput: "테스트",
|
||||||
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
|
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
|
||||||
alertRules: { targetPrice: 900, notifyOnPriceChange: true },
|
alertRules: { targetPrice: 900, notifyOnPriceChange: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ test("watch-level polling toggle skips polling", async () => {
|
|||||||
|
|
||||||
const watchId = watcher.addWatch({
|
const watchId = watcher.addWatch({
|
||||||
rawInput: "테스트",
|
rawInput: "테스트",
|
||||||
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
|
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
|
||||||
alertRules: { targetPrice: null, notifyOnPriceChange: true },
|
alertRules: { targetPrice: null, notifyOnPriceChange: true },
|
||||||
pollingEnabled: false,
|
pollingEnabled: false,
|
||||||
});
|
});
|
||||||
@@ -91,7 +91,7 @@ test("alerts can be suppressed while still computing alert events", async () =>
|
|||||||
|
|
||||||
const watchId = watcher.addWatch({
|
const watchId = watcher.addWatch({
|
||||||
rawInput: "테스트",
|
rawInput: "테스트",
|
||||||
searchParams: { segments: [{ from: "ICN", to: "MAD" }] },
|
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
|
||||||
alertRules: { targetPrice: 950, notifyOnPriceChange: true },
|
alertRules: { targetPrice: 950, notifyOnPriceChange: true },
|
||||||
alertsEnabled: false,
|
alertsEnabled: false,
|
||||||
});
|
});
|
||||||
@@ -103,3 +103,22 @@ test("alerts can be suppressed while still computing alert events", async () =>
|
|||||||
assert.equal(second.alert.eventType, "target_price");
|
assert.equal(second.alert.eventType, "target_price");
|
||||||
assert.equal(second.alert.notificationSuppressed, true);
|
assert.equal(second.alert.notificationSuppressed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("poll interval under 1 hour throws immediately", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
new PriceWatcher({
|
||||||
|
crawler: {
|
||||||
|
async getQuotes() {
|
||||||
|
return [{ provider: "x", price: 1000, currency: "KRW" }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notifier: {
|
||||||
|
async notify() {},
|
||||||
|
},
|
||||||
|
pollIntervalMs: 1000,
|
||||||
|
logger: createSilentLogger(),
|
||||||
|
}),
|
||||||
|
/3600000 이상이어야 합니다/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user