From be88b4fcec0d0e8fe91c8c162cd2910a0327153e Mon Sep 17 00:00:00 2001 From: chungyeong Date: Thu, 5 Mar 2026 11:00:45 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=ED=98=84=EC=9E=AC=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=A4=91=EA=B0=84=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 15 +- README.md | 91 ++- docker-compose.yml | 49 +- package-lock.json | 1107 ++++++++++++++++++++++++++++ package.json | 8 +- playground_schema.sql | 93 +++ src/apiAuth.js | 84 +++ src/cli.js | 20 +- src/crawlerClient.js | 14 + src/crawlerServer.js | 64 ++ src/crawlers/baseCrawler.js | 426 +++++++++++ src/crawlers/google.js | 792 ++++++++++++++++++++ src/crawlers/naver.js | 117 +++ src/crawlers/skyscanner.js | 185 +++++ src/dashboard/dashboard.css | 106 ++- src/dashboard/dashboard.js | 328 ++++++++- src/dashboard/index.html | 28 +- src/dashboard/login.css | 116 +++ src/dashboard/login.html | 41 ++ src/dashboard/login.js | 86 +++ src/dashboard/setup.css | 194 +++++ src/dashboard/setup.html | 78 ++ src/dashboard/setup.js | 151 ++++ src/dashboardApi.js | 319 +++++++- src/dashboardAssets.js | 12 + src/dashboardAuth.js | 269 +++++++ src/dashboardRuntime.js | 96 ++- src/dashboardServer.js | 217 +++++- src/dashboardStore.js | 627 +++++++++++----- src/dashboardUtils.js | 32 + src/fastifyDashboardServer.js | 206 +++++- src/llmParameterExtractor.js | 132 +++- src/pollingConfig.js | 42 ++ src/priceWatcher.js | 173 ++++- test/apiAuth.test.js | 52 ++ test/crawlerUrls.test.js | 404 ++++++++++ test/dashboardRuntime.test.js | 133 ++++ test/dashboardStore.test.js | 160 +++- test/dashboardUtils.test.js | 26 + test/llmParameterExtractor.test.js | 104 ++- test/pollingConfig.test.js | 28 + test/priceWatcher.test.js | 53 ++ test/priceWatcherControls.test.js | 25 +- 43 files changed, 6837 insertions(+), 466 deletions(-) create mode 100644 package-lock.json create mode 100644 playground_schema.sql create mode 100644 src/apiAuth.js create mode 100644 src/crawlerServer.js create mode 100644 src/crawlers/baseCrawler.js create mode 100644 src/crawlers/google.js create mode 100644 src/crawlers/naver.js create mode 100644 src/crawlers/skyscanner.js create mode 100644 src/dashboard/login.css create mode 100644 src/dashboard/login.html create mode 100644 src/dashboard/login.js create mode 100644 src/dashboard/setup.css create mode 100644 src/dashboard/setup.html create mode 100644 src/dashboard/setup.js create mode 100644 src/dashboardAuth.js create mode 100644 src/pollingConfig.js create mode 100644 test/apiAuth.test.js create mode 100644 test/crawlerUrls.test.js create mode 100644 test/dashboardRuntime.test.js create mode 100644 test/dashboardUtils.test.js create mode 100644 test/pollingConfig.test.js diff --git a/Dockerfile b/Dockerfile index 69bf5d1..7834ba2 100644 --- a/Dockerfile +++ b/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 @@ -9,7 +18,9 @@ COPY . . ENV NODE_ENV=production \ DASHBOARD_HOST=0.0.0.0 \ - DASHBOARD_PORT=3000 + DASHBOARD_PORT=3000 \ + # Chrome이 컨테이너 내에서 sandbox 없이 실행될 수 있도록 + CHROME_NO_SANDBOX=true EXPOSE 3000 diff --git a/README.md b/README.md index 95414e6..6aa633c 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ npm run parse -- "11월 말부터 12월 초까지 출발하는 일정 여행 기 LLM 기반 파라미터 가공 + 주기적 가격 추적 실행: ```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`: 목표 가격 이하 도달 시 알림 기준 - `--alert-on both|change|threshold`: 알림 조건 (가격 변동/임계값) - `--rule-only`: LLM 호출 없이 규칙 파서만 사용 @@ -199,6 +199,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 환경 변수: - `OPENAI_API_KEY`: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다. - `OPENAI_MODEL` (선택): 기본값 `gpt-4.1-mini` +- `LLM_REQUEST_TIMEOUT_MS` (선택): LLM HTTP 타임아웃(ms), 기본값 `20000` - `CRAWLER_ENDPOINT` (선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용. - `CRAWLER_PROVIDERS` (선택): `skyscanner,naver,google` 형태 우선순위 목록. 미설정 시 단일 `CRAWLER_ENDPOINT` 또는 mock 사용. - `CRAWLER_ENDPOINT_SKYSCANNER` (선택): Skyscanner 전용 엔드포인트 @@ -224,6 +225,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, ```bash OPENAI_API_KEY= OPENAI_MODEL=gpt-4.1-mini +LLM_REQUEST_TIMEOUT_MS=20000 CRAWLER_ENDPOINT= CRAWLER_PROVIDERS=skyscanner,naver,google @@ -293,12 +295,12 @@ npm run dashboard:fastify 대시보드에서 가능한 작업: - 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인 -- 파싱된 조건으로 watch 생성 및 즉시 조회 +- 파싱된 조건으로 watch 생성 - watch별 `크롤링 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_USER=your_user MYSQL_PASSWORD=your_password -MYSQL_DATABASE=airwatcher +MYSQL_DATABASE=your_database ``` 또는 단일 URL 사용: ```bash 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_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 ` 또는 `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 환경변수도 없으면 메모리 저장소로 동작한다. - `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=`) +- 이벤트: `project_events` (`stream='watch_events'`) +- 전역/유저 설정: `project_settings` (`global_controls`, `user_profiles` 등) ### 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 ``` -### 7.3 Docker / Compose +### 7.3 Docker / Compose (앱 단독) Docker 이미지 빌드: @@ -349,16 +412,20 @@ Docker 이미지 빌드: docker build -t airwatcher . ``` -Docker Compose 기동 (`app + mysql`): +Docker Compose 기동 (`app` only): ```bash -docker compose up --build +docker compose up --build app ``` 주요 기본값: - 앱: `http://127.0.0.1:3000` - 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`를 전달해야 한다. 메모리 모드로 앱만 쓰려면: diff --git a/docker-compose.yml b/docker-compose.yml index c51c3c0..ca608e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,14 +10,24 @@ services: DASHBOARD_HOST: 0.0.0.0 DASHBOARD_PORT: 3000 DASHBOARD_DB: ${DASHBOARD_DB:-mysql} - DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-60} - MYSQL_HOST: mysql - MYSQL_PORT: 3306 - MYSQL_USER: ${MYSQL_USER:-airwatcher} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher} - MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher} + DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-3600} + DASHBOARD_REQUIRE_AUTH: ${DASHBOARD_REQUIRE_AUTH:-true} + DASHBOARD_API_TOKEN: ${DASHBOARD_API_TOKEN:-} + DASHBOARD_USERS: ${DASHBOARD_USERS:-} + DASHBOARD_ADMIN_USERS: ${DASHBOARD_ADMIN_USERS:-} + 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_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini} + LLM_REQUEST_TIMEOUT_MS: ${LLM_REQUEST_TIMEOUT_MS:-20000} CRAWLER_ENDPOINT: ${CRAWLER_ENDPOINT:-} CRAWLER_PROVIDERS: ${CRAWLER_PROVIDERS:-} CRAWLER_ENDPOINT_SKYSCANNER: ${CRAWLER_ENDPOINT_SKYSCANNER:-} @@ -33,30 +43,3 @@ services: TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_API_BASE: ${TELEGRAM_API_BASE:-https://api.telegram.org} NOTIFY_WEBHOOK_URL: ${NOTIFY_WEBHOOK_URL:-} - depends_on: - mysql: - condition: service_healthy - - mysql: - image: mysql:8.4 - restart: unless-stopped - command: --default-authentication-plugin=mysql_native_password - ports: - - "${MYSQL_PORT_HOST:-3306}:3306" - environment: - MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher} - MYSQL_USER: ${MYSQL_USER:-airwatcher} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher} - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} - healthcheck: - test: - - CMD-SHELL - - mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1 - interval: 5s - timeout: 3s - retries: 20 - volumes: - - mysql_data:/var/lib/mysql - -volumes: - mysql_data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..62e967d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1107 @@ +{ + "name": "airplane", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "airplane", + "version": "0.1.0", + "dependencies": { + "fastify": "^5.2.2", + "mysql2": "^3.15.2", + "patchright": "^1.57.0" + }, + "devDependencies": { + "concurrently": "^9.2.1" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.17.4.tgz", + "integrity": "sha512-RnfuK5tyIuaiPMWOCTTl4vQX/mQXqSA8eoIbwvWccadvPGvh+BYWWVecInMS5s7wcLUkze8LqJzwB/+A4uwuAA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/patchright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/patchright/-/patchright-1.57.0.tgz", + "integrity": "sha512-pxbI/D65QiFuCY3qUXKQONRhplR3rkYFhry5ieimEbzLNxu/xfOYizQRyuMgc6F5ZoZ37QNIwZz9PWEfn6aC1Q==", + "license": "Apache-2.0", + "dependencies": { + "patchright-core": "1.57.0" + }, + "bin": { + "patchright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/patchright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/patchright-core/-/patchright-core-1.57.0.tgz", + "integrity": "sha512-um/9Wue7IFAa9UDLacjNgDn62ub5GJe1b1qouvYpELIF9rsFVMNhRo/rRXYajupLwp5xKJ0sSjOV6sw8/HarBQ==", + "license": "Apache-2.0", + "bin": { + "patchright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json index 965270b..a49bae4 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,21 @@ "private": true, "type": "commonjs", "scripts": { + "start": "concurrently \"npm run crawler\" \"npm run dashboard\"", "parse": "node src/cli.js", "watch": "node src/cli.js watch", "dashboard": "node src/dashboardServer.js", "dashboard:fastify": "node src/fastifyDashboardServer.js", "sample:skyscanner": "node src/skyscannerSampleServer.js", + "crawler": "node src/crawlerServer.js", "test": "node --test" }, "dependencies": { "fastify": "^5.2.2", - "mysql2": "^3.15.2" + "mysql2": "^3.15.2", + "patchright": "^1.57.0" + }, + "devDependencies": { + "concurrently": "^9.2.1" } } diff --git a/playground_schema.sql b/playground_schema.sql new file mode 100644 index 0000000..581c8fc --- /dev/null +++ b/playground_schema.sql @@ -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') diff --git a/src/apiAuth.js b/src/apiAuth.js new file mode 100644 index 0000000..20e640e --- /dev/null +++ b/src/apiAuth.js @@ -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, +}; diff --git a/src/cli.js b/src/cli.js index 4e2a623..160bc70 100644 --- a/src/cli.js +++ b/src/cli.js @@ -8,6 +8,7 @@ const { loadDotEnv } = require("./envLoader"); const { extractFlightSearchRequest } = require("./llmParameterExtractor"); const { createCrawlerClient } = require("./crawlerClient"); const { createNotifier } = require("./notifier"); +const { MIN_CRAWL_INTERVAL_SEC } = require("./pollingConfig"); const { PriceWatcher } = require("./priceWatcher"); loadDotEnv(); @@ -36,7 +37,7 @@ function parseNumberValue(value, flagName) { function parseWatchOptions(tokens) { const options = { - intervalSec: 60, + intervalSec: MIN_CRAWL_INTERVAL_SEC, targetPrice: null, alertOn: "both", useLlm: true, @@ -93,8 +94,10 @@ function parseWatchOptions(tokens) { throw new Error(`알 수 없는 옵션: ${token}`); } - if (!Number.isInteger(options.intervalSec) || options.intervalSec <= 0) { - throw new Error("--interval-sec 값은 1 이상의 정수여야 합니다."); + if (!Number.isInteger(options.intervalSec) || options.intervalSec < MIN_CRAWL_INTERVAL_SEC) { + throw new Error( + `--interval-sec 값은 ${MIN_CRAWL_INTERVAL_SEC} 이상의 정수여야 합니다.` + ); } if ( @@ -129,6 +132,17 @@ async function runWatch(tokens) { 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({ crawler: createCrawlerClient(), notifier: createNotifier(), diff --git a/src/crawlerClient.js b/src/crawlerClient.js index f6186d1..8fb55cf 100644 --- a/src/crawlerClient.js +++ b/src/crawlerClient.js @@ -359,6 +359,20 @@ function createMultiSourceCrawler(options = {}) { return { 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") { const primary = sources[0]; try { diff --git a/src/crawlerServer.js b/src/crawlerServer.js new file mode 100644 index 0000000..e00b27e --- /dev/null +++ b/src/crawlerServer.js @@ -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 }; \ No newline at end of file diff --git a/src/crawlers/baseCrawler.js b/src/crawlers/baseCrawler.js new file mode 100644 index 0000000..cac169f --- /dev/null +++ b/src/crawlers/baseCrawler.js @@ -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): + *
+ * + * + *
+ * + * 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, +}; diff --git a/src/crawlers/google.js b/src/crawlers/google.js new file mode 100644 index 0000000..4d7ec4a --- /dev/null +++ b/src/crawlers/google.js @@ -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 }; diff --git a/src/crawlers/naver.js b/src/crawlers/naver.js new file mode 100644 index 0000000..f994b20 --- /dev/null +++ b/src/crawlers/naver.js @@ -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 }; diff --git a/src/crawlers/skyscanner.js b/src/crawlers/skyscanner.js new file mode 100644 index 0000000..a2ca6c0 --- /dev/null +++ b/src/crawlers/skyscanner.js @@ -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 }; diff --git a/src/dashboard/dashboard.css b/src/dashboard/dashboard.css index 5f9ec69..b172334 100644 --- a/src/dashboard/dashboard.css +++ b/src/dashboard/dashboard.css @@ -64,6 +64,28 @@ body { 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 { margin: 3px 0 0; font-size: clamp(1.4rem, 1.7vw, 1.9rem); @@ -127,7 +149,7 @@ input[type="number"]:focus { margin-top: 12px; display: grid; gap: 10px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } .switch-field { @@ -169,6 +191,11 @@ input[type="number"]:focus { transition: transform 0.16s ease, opacity 0.16s ease; } +.btn.small { + padding: 7px 10px; + font-size: 0.82rem; +} + .btn:hover { transform: translateY(-1px); } @@ -208,6 +235,10 @@ input[type="number"]:focus { color: var(--ink-sub); } +.hidden { + display: none !important; +} + .summary strong { color: var(--accent); } @@ -295,6 +326,60 @@ input[type="number"]:focus { 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 { margin-top: 10px; display: flex; @@ -321,6 +406,11 @@ input[type="number"]:focus { color: #ffb4b4; } +.badge.warn { + background: rgba(255, 183, 77, 0.2); + color: #ffd08a; +} + .event-head { display: flex; justify-content: space-between; @@ -339,6 +429,20 @@ input[type="number"]:focus { } @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 { grid-template-columns: 1fr; } diff --git a/src/dashboard/dashboard.js b/src/dashboard/dashboard.js index ed4e096..7aa22e5 100644 --- a/src/dashboard/dashboard.js +++ b/src/dashboard/dashboard.js @@ -3,8 +3,13 @@ const elements = { configBanner: document.getElementById("configBanner"), + systemPanel: document.getElementById("systemPanel"), + setupLink: document.getElementById("setupLink"), + logoutBtn: document.getElementById("logoutBtn"), queryInput: document.getElementById("queryInput"), useLlm: document.getElementById("useLlm"), + provider: document.getElementById("provider"), + sameFlight: document.getElementById("sameFlight"), alertOn: document.getElementById("alertOn"), targetPrice: document.getElementById("targetPrice"), parseBtn: document.getElementById("parseBtn"), @@ -21,17 +26,118 @@ parsed: null, watches: [], events: [], + user: null, + apiToken: "", + auth: { + accountAuthEnabled: false, + tokenAuthEnabled: false, + }, controls: { crawlingEnabled: 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, "'"); + } + function formatPrice(price, currency) { if (!Number.isFinite(Number(price))) return "N/A"; 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(`${escapeHtml(label)}`); + if (leg.airline) airlineParts.push(escapeHtml(leg.airline)); + if (leg.route) airlineParts.push(escapeHtml(leg.route)); + if (airlineParts.length > 0) { + lines.push(`
${airlineParts.join(" ")}
`); + } + + // 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(`
${timeParts.join(" · ")}
`); + } + + // 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(`
${stopsStr}
`); + } + + 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('
'); + return `
${legsHtml}
`; + } + + // 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 `
[${escapeHtml(metadata.flightNumber)}] ${escapeHtml(metadata.departureTime || "")}
`; + } + return ""; + } + + const html = renderLeg(metadata, null); + return html ? `
${html}
` : ""; + } + function formatDate(value) { if (!value) return "-"; 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, { - headers: { "content-type": "application/json" }, + headers, + credentials: "same-origin", ...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(() => ({})); if (!response.ok) { throw new Error(payload.error || `요청 실패 (${response.status})`); @@ -100,13 +235,15 @@ elements.parseSummary.classList.remove("empty"); elements.parseSummary.innerHTML = [ - `
파서: ${parsedPayload.source}
`, - `
구간: ${segmentText || "미입력"}
`, - `
출발 윈도우: ${windowText}
`, - `
체류 기간: ${stayText}
`, - `
탑승객: ${paxText}
`, - `
최대 여정시간: ${journeyText}
`, - `
누락 필드: ${missing.length > 0 ? missing.join(", ") : "없음"}
`, + `
파서: ${escapeHtml(parsedPayload.source || "unknown")}
`, + `
구간: ${escapeHtml(segmentText || "미입력")}
`, + `
출발 윈도우: ${escapeHtml(windowText)}
`, + `
체류 기간: ${escapeHtml(stayText)}
`, + `
탑승객: ${escapeHtml(paxText)}
`, + `
최대 여정시간: ${escapeHtml(journeyText)}
`, + `
누락 필드: ${escapeHtml( + missing.length > 0 ? missing.join(", ") : "없음" + )}
`, ].join(""); } @@ -144,23 +281,64 @@ const alertOn = watchAlertOn(watch); 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 ? `🔗 예매하기` : ""; + + 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 = ` +
+ ${subOffers.map(sub => { + const subMeta = sub.metadata || {}; + const subUrl = subMeta.url || sub.url; + // 동일 항공편 모드면 상단에 이미 표시되므로 sub-offer에 중복 표시 안 함 + const subFlightDetails = sameFlightMode ? "" : renderFlightDetails(subMeta); + return ` +
+ ${escapeHtml(sub.cabin)} (${sub.paxCount}명) + ${escapeHtml(formatPrice(sub.price, currency))} ${subUrl ? `🔗` : ''} +
+ ${subFlightDetails}`; + }).join('')} +
+ `; + } + return ` -
+
-

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

-

watchId: ${watch.id}

+

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

+

+ watchId: ${escapeHtml(watch.id)} + ${sameFlightMode ? '동일 항공편' : '개별 최저가'} +

+ ${flightDetailsHtml ? `${flightDetailsHtml}${urlStr ? `

${urlStr}

` : ""}` : (urlStr ? `

${urlStr}

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

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

` : ""} + ${ + watch.lastError + ? `

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

` + : "" + }
`; }) @@ -192,23 +373,45 @@ elements.eventList.innerHTML = state.events .map((event) => { const payload = event.payload || {}; - const sent = payload.notificationSent === true; - const badgeClass = sent ? "ok" : "off"; - const badgeLabel = sent ? "알림 발송" : "알림 억제"; + const notificationState = + typeof payload.notificationState === "string" + ? 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 `
- ${payload.eventType || event.eventType || "event"} + ${escapeHtml(payload.eventType || event.eventType || "event")} ${badgeLabel}

- watchId: ${event.watchId}
- 가격: ${formatPrice(payload.currentBestPrice, payload.currency)} + watchId: ${escapeHtml(event.watchId)}
+ 가격: ${escapeHtml(formatPrice(payload.currentBestPrice, payload.currency))} ${Number.isFinite(Number(payload.previousBestPrice)) - ? ` (이전 ${formatPrice(payload.previousBestPrice, payload.currency)})` + ? ` (이전 ${escapeHtml(formatPrice(payload.previousBestPrice, payload.currency))})` : ""}
- 시각: ${formatDate(event.observedAt)} + 시각: ${escapeHtml(formatDate(event.observedAt))} + ${ + notificationState === "failed" && payload.notificationError?.message + ? `
오류: ${escapeHtml(payload.notificationError.message)}` + : "" + }

`; @@ -223,7 +426,51 @@ function setConfigBanner(config) { 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() { @@ -269,11 +516,12 @@ const payload = { input, useLlm: elements.useLlm.checked, + provider: elements.provider.value || undefined, + sameFlight: elements.sameFlight.value === "true", alertOn: elements.alertOn.value, targetPrice: readTargetPrice(), pollingEnabled: true, alertsEnabled: true, - pollNow: true, }; const created = await api("/api/watches", { @@ -301,7 +549,7 @@ if (!action || !watchId) return; 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 (isButtonAction && event.type !== "click") return; @@ -328,14 +576,6 @@ return; } - if (action === "poll") { - await api(`/api/watches/${encodeURIComponent(watchId)}/poll`, { - method: "POST", - }); - await refreshAll(); - return; - } - if (action === "delete") { await api(`/api/watches/${encodeURIComponent(watchId)}`, { method: "DELETE", @@ -358,8 +598,14 @@ async function bootstrap() { try { + state.apiToken = readStoredApiToken(); + await ensureSession(); const config = await api("/api/config"); + if (config && config.currentUser) { + state.user = config.currentUser; + } setConfigBanner(config); + renderSessionUi(); await refreshAll(); @@ -386,6 +632,12 @@ onGlobalToggle().catch((error) => alert(error.message)); }); + if (elements.logoutBtn) { + elements.logoutBtn.addEventListener("click", () => { + onLogout().catch((error) => alert(error.message)); + }); + } + setInterval(() => { refreshAll().catch(() => {}); }, 5000); diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 5f38c05..ab9b377 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -14,7 +14,13 @@

AIR-WATCHER

Flight Watch Dashboard

-
초기화 중...
+
+
초기화 중...
+
+ 텔레그램 설정 + +
+
@@ -32,6 +38,24 @@ LLM 파싱 사용 + + + + + + + + +

+ +
+ + + + + diff --git a/src/dashboard/login.js b/src/dashboard/login.js new file mode 100644 index 0000000..b98026d --- /dev/null +++ b/src/dashboard/login.js @@ -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(() => {}); +})(); diff --git a/src/dashboard/setup.css b/src/dashboard/setup.css new file mode 100644 index 0000000..347c69c --- /dev/null +++ b/src/dashboard/setup.css @@ -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; + } +} diff --git a/src/dashboard/setup.html b/src/dashboard/setup.html new file mode 100644 index 0000000..c17783a --- /dev/null +++ b/src/dashboard/setup.html @@ -0,0 +1,78 @@ + + + + + + Air-Watcher Telegram Setup + + + +
+
+
+

AIR-WATCHER

+

텔레그램 설정

+

로딩 중...

+
+
+ 대시보드 + +
+
+ +
+

내 알림 설정

+
+ + + + + + + + + + +
+ + +
+ +

+
+
+ +
+

텔레그램 연동 안내

+
    +
  1. 텔레그램에서 @BotFather에게 /newbot 명령으로 봇을 만듭니다.
  2. +
  3. 발급된 Bot Token을 위 설정에 저장합니다. (또는 운영자가 서버 전역 토큰을 설정)
  4. +
  5. 내가 만든 봇과 1:1 대화를 시작하고 아무 메시지나 1개 보냅니다.
  6. +
  7. + 브라우저에서 + https://api.telegram.org/bot<TOKEN>/getUpdates + 를 열어 chat.id 값을 확인합니다. +
  8. +
  9. 확인한 chat.id를 위 수신 Chat ID에 저장하고 테스트 전송을 눌러 확인합니다.
  10. +
+
+
+ + + + diff --git a/src/dashboard/setup.js b/src/dashboard/setup.js new file mode 100644 index 0000000..06a5d0d --- /dev/null +++ b/src/dashboard/setup.js @@ -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 || "초기화 실패"); + }); +})(); diff --git a/src/dashboardApi.js b/src/dashboardApi.js index 6c38a7c..bd39e9b 100644 --- a/src/dashboardApi.js +++ b/src/dashboardApi.js @@ -6,6 +6,7 @@ const { normalizeAlertOn, parseTargetPrice, } = require("./alertRules"); +const { TelegramNotifier } = require("./notifier"); const { createHttpError, parseBoolean } = require("./dashboardUtils"); const { extractFlightSearchRequest } = require("./llmParameterExtractor"); @@ -21,8 +22,58 @@ function hasOwnProperty(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 }) { - async function parseInput(body = {}) { + async function parseInput(body = {}, context = {}) { + requireUser(context); const input = readInput(body); return extractFlightSearchRequest(input, { @@ -30,37 +81,129 @@ function createDashboardApi({ watcher, store }) { }); } - async function createWatch(body = {}) { - const extracted = await parseInput(body); + async function createWatch(body = {}, context = {}) { + const user = requireUser(context); + const extracted = await parseInput(body, context); 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({ targetPrice: body.targetPrice, alertOn: body.alertOn || "both", }); - const watchId = watcher.addWatch({ - rawInput: input, - searchParams: extracted.params, - alertRules, - pollingEnabled: parseBoolean(body.pollingEnabled, true), - alertsEnabled: parseBoolean(body.alertsEnabled, true), - }); + const paramVariations = []; + const baseParams = extracted.params; + const window = baseParams.departureDateWindow; + const stay = baseParams.stayDurationDays; - const created = watcher.getWatch(watchId); - await store.saveWatch(created); - - if (parseBoolean(body.pollNow, false)) { - await watcher.pollWatch(watchId); + 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({ + ownerId: user.username, + rawInput: watchInput, + searchParams: params, + alertRules, + pollingEnabled: parseBoolean(body.pollingEnabled, true), + alertsEnabled: parseBoolean(body.alertsEnabled, true), + }); + + const created = watcher.getWatch(watchId); + await store.saveWatch(created); + + if (!firstWatchId) firstWatchId = watchId; + } + + // 신규 추가 후 즉시 순차 크롤링을 백그라운드에서 트리거합니다. + watcher.pollAll().catch((error) => { + // eslint-disable-next-line no-console + console.error("[DashboardAPI] Immediate poll failed:", error); + }); + return { - watch: watcher.getWatch(watchId), + watch: watcher.getWatch(firstWatchId), 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({ crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled), alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled), @@ -69,35 +212,30 @@ function createDashboardApi({ watcher, store }) { return { controls }; } - function listWatches() { + function listWatches(context = {}) { + const user = requireUser(context); + const allWatches = watcher.listWatches(); return { 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 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 }; } - async function pollWatch(watchId) { + async function updateWatch(watchId, body = {}, context = {}) { + const user = requireUser(context); const existing = watcher.getWatch(watchId); - if (!existing) { - throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); - } - - const pollResult = await watcher.pollWatch(watchId); - return { - watch: watcher.getWatch(watchId), - pollResult, - }; - } - - async function updateWatch(watchId, body = {}) { - const existing = watcher.getWatch(watchId); - if (!existing) { + if (!existing || !canAccessWatch(user, existing)) { throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); } @@ -129,19 +267,124 @@ function createDashboardApi({ watcher, store }) { 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); await store.deleteWatch(watchId); 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 { createWatch, deleteWatch, + getMe, listEvents, listWatches, parseInput, - pollWatch, + sendMyTelegramTest, + updateMyTelegram, updateSystem, updateWatch, }; diff --git a/src/dashboardAssets.js b/src/dashboardAssets.js index 96a1aec..b4c3919 100644 --- a/src/dashboardAssets.js +++ b/src/dashboardAssets.js @@ -6,11 +6,23 @@ const { createHttpError } = require("./dashboardUtils"); const ASSET_MAP = { "/": { 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" }, + "/login.css": { file: "login.css", contentType: "text/css; charset=utf-8" }, + "/setup.css": { file: "setup.css", contentType: "text/css; charset=utf-8" }, "/dashboard.js": { file: "dashboard.js", 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) { diff --git a/src/dashboardAuth.js b/src/dashboardAuth.js new file mode 100644 index 0000000..c70ec19 --- /dev/null +++ b/src/dashboardAuth.js @@ -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, +}; diff --git a/src/dashboardRuntime.js b/src/dashboardRuntime.js index aec3ce5..2b147e8 100644 --- a/src/dashboardRuntime.js +++ b/src/dashboardRuntime.js @@ -3,13 +3,14 @@ const { createCrawlerClient } = require("./crawlerClient"); const { createDashboardStore } = require("./dashboardStore"); 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 { parsePort } = require("./dashboardUtils"); function toRestoredWatchPayload(watch) { return { id: watch.id, + ownerId: watch.ownerId || null, rawInput: watch.rawInput, searchParams: watch.searchParams, 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 = {}) { loadDotEnv(); const logger = options.logger || console; - const pollIntervalSec = parsePort( - options.pollIntervalSec || process.env.DASHBOARD_POLL_INTERVAL_SEC, - 60 + const pollIntervalSec = normalizeCrawlIntervalSec( + options.pollIntervalSec !== undefined + ? options.pollIntervalSec + : process.env.DASHBOARD_POLL_INTERVAL_SEC, + MIN_CRAWL_INTERVAL_SEC ); const storeSetup = @@ -38,7 +105,12 @@ async function createDashboardRuntime(options = {}) { const store = storeSetup.store; 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({ crawler, @@ -50,8 +122,18 @@ async function createDashboardRuntime(options = {}) { await store.savePollResult(watch.id, result); 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({ watchId: watch.id, + ownerId: watch.ownerId || null, eventType: result.alert.eventType || "unknown", observedAt: result.alert.observedAt || @@ -61,6 +143,8 @@ async function createDashboardRuntime(options = {}) { payload: { ...result.alert, notificationSent: result.notificationSent === true, + notificationState, + notificationError: notificationState === "failed" ? result.error : null, }, }); } diff --git a/src/dashboardServer.js b/src/dashboardServer.js index f0e66e9..276081f 100644 --- a/src/dashboardServer.js +++ b/src/dashboardServer.js @@ -3,16 +3,13 @@ const http = require("node:http"); const path = require("node:path"); -const { - buildAlertRules, - inferAlertOn, - normalizeAlertOn, - parseTargetPrice, -} = require("./alertRules"); +const { isAuthorizedRequest, resolveApiAuth } = require("./apiAuth"); +const { buildAlertRules, inferAlertOn, normalizeAlertOn, parseTargetPrice } = require("./alertRules"); const { createDashboardApi } = require("./dashboardApi"); +const { createDashboardAuth } = require("./dashboardAuth"); const { loadDashboardAsset } = require("./dashboardAssets"); const { createDashboardRuntime } = require("./dashboardRuntime"); -const { createHttpError, decodeWatchId, parsePort } = require("./dashboardUtils"); +const { createHttpError, decodeWatchId, parsePort, toPublicErrorResponse } = require("./dashboardUtils"); function parseJsonBody(req) { 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); res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "content-length": Buffer.byteLength(payload), + ...extraHeaders, }); res.end(payload); } +function sendRedirect(res, location) { + res.writeHead(302, { + location, + "cache-control": "no-store", + }); + res.end(); +} + function sendStaticAsset(res, asset) { res.writeHead(200, { "content-type": asset.contentType, @@ -65,24 +71,167 @@ function sendStaticAsset(res, asset) { 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 = {}) { + 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 api = createDashboardApi({ watcher: runtime.watcher, 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"); + 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) => { try { const requestUrl = new URL(req.url || "/", "http://localhost"); 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); if (staticAsset) { sendStaticAsset(res, staticAsset); return; } + if (pathname.startsWith("/api/")) { + if (!currentUser) { + sendUnauthorized(res); + return; + } + } + if (req.method === "GET" && pathname === "/api/health") { sendJson(res, 200, { ok: true, @@ -94,7 +243,26 @@ async function createDashboardServer(options = {}) { } 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; } @@ -107,37 +275,30 @@ async function createDashboardServer(options = {}) { if (req.method === "PATCH" && pathname === "/api/system") { const body = await parseJsonBody(req); - sendJson(res, 200, await api.updateSystem(body)); + sendJson(res, 200, await api.updateSystem(body, { user: currentUser })); return; } if (req.method === "POST" && pathname === "/api/parse") { const body = await parseJsonBody(req); - sendJson(res, 200, await api.parseInput(body)); + sendJson(res, 200, await api.parseInput(body, { user: currentUser })); return; } if (req.method === "GET" && pathname === "/api/watches") { - sendJson(res, 200, api.listWatches()); + sendJson(res, 200, api.listWatches({ user: currentUser })); return; } if (req.method === "POST" && pathname === "/api/watches") { const body = await parseJsonBody(req); - sendJson(res, 201, await api.createWatch(body)); + sendJson(res, 201, await api.createWatch(body, { user: currentUser })); return; } if (req.method === "GET" && pathname === "/api/events") { const limit = requestUrl.searchParams.get("limit"); - sendJson(res, 200, await api.listEvents(limit)); - return; - } - - const watchPollMatch = pathname.match(/^\/api\/watches\/([^/]+)\/poll$/); - if (req.method === "POST" && watchPollMatch) { - const watchId = decodeWatchId(watchPollMatch[1]); - sendJson(res, 200, await api.pollWatch(watchId)); + sendJson(res, 200, await api.listEvents(limit, { user: currentUser })); return; } @@ -147,12 +308,12 @@ async function createDashboardServer(options = {}) { if (req.method === "PATCH") { const body = await parseJsonBody(req); - sendJson(res, 200, await api.updateWatch(watchId, body)); + sendJson(res, 200, await api.updateWatch(watchId, body, { user: currentUser })); return; } if (req.method === "DELETE") { - sendJson(res, 200, await api.deleteWatch(watchId)); + sendJson(res, 200, await api.deleteWatch(watchId, { user: currentUser })); return; } } @@ -161,10 +322,8 @@ async function createDashboardServer(options = {}) { error: "Not found", }); } catch (error) { - const statusCode = Number(error.statusCode) || 500; - sendJson(res, statusCode, { - error: error.message || "Internal Server Error", - }); + const failure = toPublicErrorResponse(error, { logger }); + sendJson(res, failure.statusCode, failure.body); } }); @@ -180,7 +339,7 @@ async function createDashboardServer(options = {}) { watcher: runtime.watcher, store: runtime.store, close, - info: runtime.info, + info: serverInfo, }; } @@ -195,7 +354,7 @@ async function runCli() { process.stdout.write(`[WARN] ${app.info.dbWarning}\n`); } 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` ); }); diff --git a/src/dashboardStore.js b/src/dashboardStore.js index c060865..e945dab 100644 --- a/src/dashboardStore.js +++ b/src/dashboardStore.js @@ -9,6 +9,18 @@ function toBoolean(value, fallback = true) { 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) { const date = isoString ? new Date(isoString) : new Date(); 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 { constructor() { this.watches = new Map(); this.events = []; + this.userProfiles = new Map(); this.globalControls = { crawlingEnabled: true, alertsEnabled: true, @@ -75,6 +182,7 @@ class InMemoryDashboardStore { async saveWatch(watch) { const next = cloneJson(watch); + next.ownerId = toOptionalString(next.ownerId); this.watches.set(next.id, next); return next; } @@ -109,6 +217,7 @@ class InMemoryDashboardStore { const stored = { id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`, watchId: event.watchId, + ownerId: toOptionalString(event.ownerId), eventType: event.eventType, payload: cloneJson(event.payload), observedAt: event.observedAt, @@ -121,9 +230,13 @@ class InMemoryDashboardStore { return cloneJson(stored); } - async listEvents(limit = 50) { + async listEvents(limit = 50, options = {}) { 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() { @@ -147,11 +260,45 @@ class InMemoryDashboardStore { 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 { - constructor(pool) { + constructor(pool, options = {}) { this.pool = pool; + this.projectKey = toOptionalString(options.projectKey) || DEFAULT_PROJECT_KEY; + this.schemaPreference = normalizeDbSchemaMode(options.schemaMode); + this.schemaMode = null; } static async create(options = {}) { @@ -179,220 +326,263 @@ class MySqlDashboardStore { timezone: "Z", }); - const store = new MySqlDashboardStore(pool); + const store = new MySqlDashboardStore(pool, { + projectKey: options.projectKey, + schemaMode: options.schemaMode, + }); await store.init(); return store; } async init() { - await this.pool.query(` - CREATE TABLE IF NOT EXISTS watches ( - id VARCHAR(64) NOT NULL, - raw_input TEXT NOT NULL, - parsed_params JSON NOT NULL, - alert_rules JSON NOT NULL, - polling_enabled TINYINT(1) NOT NULL DEFAULT 1, - alerts_enabled TINYINT(1) NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL, - updated_at DATETIME(3) NOT NULL, - last_snapshot JSON NULL, - last_error JSON NULL, - PRIMARY KEY (id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); + if (this.schemaPreference !== "auto" && this.schemaPreference !== "playground") { + throw new Error("DASHBOARD_DB_SCHEMA는 playground 또는 auto만 지원합니다."); + } - await this.pool.query(` - CREATE TABLE IF NOT EXISTS watch_events ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - watch_id VARCHAR(64) NOT NULL, - event_type VARCHAR(64) NOT NULL, - payload JSON NOT NULL, - observed_at DATETIME(3) NOT NULL, - created_at DATETIME(3) NOT NULL, - PRIMARY KEY (id), - INDEX idx_watch_events_watch_id (watch_id, observed_at DESC) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); + const inspectTables = PLAYGROUND_REQUIRED_TABLES; + const [rows] = await this.pool.query( + ` + SELECT TABLE_NAME + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (${inspectTables.map(() => "?").join(", ")}) + `, + inspectTables + ); - await this.pool.query(` - CREATE TABLE IF NOT EXISTS app_settings ( - setting_key VARCHAR(64) NOT NULL, - setting_value JSON NOT NULL, - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (setting_key) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - `); + const existing = new Set( + rows.map((row) => String(row.TABLE_NAME || row.table_name || "")) + ); + const missingPlayground = PLAYGROUND_REQUIRED_TABLES.filter((tableName) => !existing.has(tableName)); + if (missingPlayground.length > 0) { + throw new Error( + `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() { await this.pool.end(); } - async listWatches() { - const [rows] = await this.pool.query(` - SELECT - id, - raw_input, - parsed_params, - alert_rules, - polling_enabled, - alerts_enabled, - created_at, - updated_at, - last_snapshot, - last_error - FROM watches - ORDER BY created_at DESC - `); + async getSettingJson(settingKey, fallbackValue) { + const [rows] = await this.pool.query( + ` + SELECT setting_value + FROM project_settings + WHERE project_key = ? + AND setting_key = ? + LIMIT 1 + `, + [this.projectKey, settingKey] + ); + if (rows.length === 0) { + return cloneJson(fallbackValue); + } + return parseJsonColumn(rows[0].setting_value, cloneJson(fallbackValue)); + } - return rows.map((row) => ({ - id: row.id, - rawInput: row.raw_input, - searchParams: parseJsonColumn(row.parsed_params, {}), - alertRules: parseJsonColumn(row.alert_rules, {}), - pollingEnabled: toBoolean(row.polling_enabled, true), - alertsEnabled: toBoolean(row.alerts_enabled, true), - createdAt: fromSqlDateTime(row.created_at), - updatedAt: fromSqlDateTime(row.updated_at), - lastSnapshot: parseJsonColumn(row.last_snapshot, null), - lastError: parseJsonColumn(row.last_error, null), - })); + async upsertSettingJson(settingKey, value) { + const now = toSqlDateTime(new Date().toISOString()); + await this.pool.query( + ` + INSERT INTO project_settings (project_key, setting_key, setting_value, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + setting_value = VALUES(setting_value), + updated_at = VALUES(updated_at) + `, + [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) { const [rows] = await this.pool.query( ` - SELECT - id, - raw_input, - parsed_params, - alert_rules, - polling_enabled, - alerts_enabled, - created_at, - updated_at, - last_snapshot, - last_error - FROM watches - WHERE id = ? + SELECT doc_key, data_json, created_at, updated_at + FROM project_documents + WHERE project_key = ? + AND doc_type = 'watch' + AND doc_key = ? LIMIT 1 `, - [watchId] + [this.projectKey, watchId] ); - if (rows.length === 0) return null; - const row = rows[0]; - return { - id: row.id, - rawInput: row.raw_input, - searchParams: parseJsonColumn(row.parsed_params, {}), - alertRules: parseJsonColumn(row.alert_rules, {}), - pollingEnabled: toBoolean(row.polling_enabled, true), - alertsEnabled: toBoolean(row.alerts_enabled, true), - createdAt: fromSqlDateTime(row.created_at), - updatedAt: fromSqlDateTime(row.updated_at), - lastSnapshot: parseJsonColumn(row.last_snapshot, null), - lastError: parseJsonColumn(row.last_error, null), - }; + return this.fromWatchDocumentRow(rows[0]); } async saveWatch(watch) { - const createdAt = watch.createdAt || new Date().toISOString(); - const updatedAt = watch.updatedAt || createdAt; - + const persisted = this.toWatchDocument(watch); await this.pool.query( ` - INSERT INTO watches ( - id, - raw_input, - parsed_params, - alert_rules, - polling_enabled, - alerts_enabled, + INSERT INTO project_documents ( + project_key, + doc_type, + doc_key, + data_json, + meta_json, created_at, - updated_at, - last_snapshot, - last_error - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + updated_at + ) + VALUES (?, 'watch', ?, ?, NULL, ?, ?) ON DUPLICATE KEY UPDATE - raw_input = VALUES(raw_input), - parsed_params = VALUES(parsed_params), - alert_rules = VALUES(alert_rules), - polling_enabled = VALUES(polling_enabled), - alerts_enabled = VALUES(alerts_enabled), - updated_at = VALUES(updated_at), - last_snapshot = VALUES(last_snapshot), - last_error = VALUES(last_error) + data_json = VALUES(data_json), + updated_at = VALUES(updated_at) `, [ + this.projectKey, watch.id, - watch.rawInput || "", - JSON.stringify(watch.searchParams || {}), - JSON.stringify(watch.alertRules || {}), - watch.pollingEnabled === false ? 0 : 1, - watch.alertsEnabled === false ? 0 : 1, - toSqlDateTime(createdAt), - toSqlDateTime(updatedAt), - watch.lastSnapshot ? JSON.stringify(watch.lastSnapshot) : null, - watch.lastError ? JSON.stringify(watch.lastError) : null, + JSON.stringify(persisted), + toSqlDateTime(persisted.createdAt), + toSqlDateTime(persisted.updatedAt), ] ); - return this.getWatch(watch.id); } 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; } async savePollResult(watchId, pollResult) { - const updates = []; - const params = []; - const nowIso = new Date().toISOString(); + const watch = await this.getWatch(watchId); + if (!watch) return; + let touched = false; if (pollResult && pollResult.snapshot) { - updates.push("last_snapshot = ?"); - params.push(JSON.stringify(pollResult.snapshot)); - updates.push("last_error = NULL"); + watch.lastSnapshot = cloneJson(pollResult.snapshot); + watch.lastError = null; + touched = true; } - if (pollResult && pollResult.error) { - updates.push("last_error = ?"); - params.push(JSON.stringify(pollResult.error)); + watch.lastError = cloneJson(pollResult.error); + touched = true; } + if (!touched) return; - if (updates.length === 0) { - return; - } - - updates.push("updated_at = ?"); - params.push(toSqlDateTime(nowIso)); - params.push(watchId); - - await this.pool.query( - ` - UPDATE watches - SET ${updates.join(", ")} - WHERE id = ? - `, - params - ); + watch.updatedAt = new Date().toISOString(); + await this.saveWatch(watch); } async saveEvent(event) { const createdAt = new Date().toISOString(); const observedAt = event.observedAt || createdAt; + const ownerId = toOptionalString(event.ownerId); + const payload = injectEventOwnerPayload(event.payload, ownerId); const [result] = await this.pool.query( ` - INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO project_events ( + project_key, + stream, + event_type, + payload_json, + observed_at, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?) `, [ - event.watchId, + this.projectKey, + "watch_events", event.eventType || "unknown", - JSON.stringify(event.payload || {}), + JSON.stringify({ + watchId: event.watchId, + payload, + }), toSqlDateTime(observedAt), toSqlDateTime(createdAt), ] @@ -401,6 +591,7 @@ class MySqlDashboardStore { return { id: String(result.insertId), watchId: event.watchId, + ownerId, eventType: event.eventType, payload: cloneJson(event.payload || {}), 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 ownerIdFilter = toOptionalString(options.ownerId); + const fetchLimit = ownerIdFilter ? Math.min(1000, Math.max(safeLimit * 5, 200)) : safeLimit; + const [rows] = await this.pool.query( ` - SELECT id, watch_id, event_type, payload, observed_at, created_at - FROM watch_events + SELECT id, event_type, payload_json, observed_at, created_at + FROM project_events + WHERE project_key = ? + AND stream = 'watch_events' ORDER BY created_at DESC LIMIT ? `, - [safeLimit] + [this.projectKey, fetchLimit] ); - return rows.map((row) => ({ - id: String(row.id), - watchId: row.watch_id, - eventType: row.event_type, - payload: parseJsonColumn(row.payload, {}), - observedAt: fromSqlDateTime(row.observed_at), - createdAt: fromSqlDateTime(row.created_at), - })); + 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), + watchId: toOptionalString(envelope.watchId), + ownerId: extracted.ownerId || toOptionalString(envelope.ownerId), + eventType: row.event_type, + payload: extracted.payload, + observedAt: fromSqlDateTime(row.observed_at), + createdAt: fromSqlDateTime(row.created_at), + }; + }) + .filter((event) => { + if (!ownerIdFilter) return true; + return event.ownerId === ownerIdFilter; + }); + + return mapped.slice(0, safeLimit); } async getGlobalControls() { - const [rows] = await this.pool.query( - ` - SELECT setting_value - FROM app_settings - WHERE setting_key = 'global_controls' - LIMIT 1 - ` - ); - - if (rows.length === 0) { - return { - crawlingEnabled: true, - alertsEnabled: true, - }; - } - - const setting = parseJsonColumn(rows[0].setting_value, {}); + const setting = await this.getSettingJson("global_controls", {}); return { crawlingEnabled: toBoolean(setting.crawlingEnabled, true), alertsEnabled: toBoolean(setting.alertsEnabled, true), @@ -466,19 +662,42 @@ class MySqlDashboardStore { next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled); } - await this.pool.query( - ` - INSERT INTO app_settings (setting_key, setting_value, updated_at) - VALUES ('global_controls', ?, ?) - ON DUPLICATE KEY UPDATE - setting_value = VALUES(setting_value), - updated_at = VALUES(updated_at) - `, - [JSON.stringify(next), toSqlDateTime(new Date().toISOString())] - ); + await this.upsertSettingJson("global_controls", 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) { @@ -491,6 +710,12 @@ async function createDashboardStore(options = {}) { const modeRaw = options.mode || process.env.DASHBOARD_DB || ""; const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : ""; const isStrictMySqlMode = mode === "mysql"; + const allowMemoryFallback = parseBooleanFlag( + options.allowMemoryFallback !== undefined + ? options.allowMemoryFallback + : process.env.DASHBOARD_ALLOW_MEMORY_FALLBACK, + false + ); const prefersMySql = isStrictMySqlMode || Boolean(process.env.MYSQL_URL) || @@ -514,12 +739,14 @@ async function createDashboardStore(options = {}) { password: options.mysqlPassword || process.env.MYSQL_PASSWORD, database: options.mysqlDatabase || process.env.MYSQL_DATABASE, 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 (isStrictMySqlMode) { + if (!allowMemoryFallback) { 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, }; } catch (error) { - if (isStrictMySqlMode) { - throw error; + if (!allowMemoryFallback) { + throw new Error(`MySQL 초기화 실패: ${error.message}`); } const fallbackStore = new InMemoryDashboardStore(); diff --git a/src/dashboardUtils.js b/src/dashboardUtils.js index 2ab13bf..a0472ee 100644 --- a/src/dashboardUtils.js +++ b/src/dashboardUtils.js @@ -6,6 +6,14 @@ function createHttpError(statusCode, message) { 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) { if (value === undefined) return fallback; 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 = { createHttpError, decodeWatchId, parseBoolean, parsePort, + toPublicErrorResponse, }; diff --git a/src/fastifyDashboardServer.js b/src/fastifyDashboardServer.js index 2ab0498..670143b 100644 --- a/src/fastifyDashboardServer.js +++ b/src/fastifyDashboardServer.js @@ -3,10 +3,12 @@ const path = require("node:path"); const Fastify = require("fastify"); +const { isAuthorizedRequest, resolveApiAuth } = require("./apiAuth"); const { createDashboardApi } = require("./dashboardApi"); +const { createDashboardAuth } = require("./dashboardAuth"); const { loadDashboardAsset } = require("./dashboardAssets"); const { createDashboardRuntime } = require("./dashboardRuntime"); -const { decodeWatchId, parsePort } = require("./dashboardUtils"); +const { createHttpError, decodeWatchId, parsePort, toPublicErrorResponse } = require("./dashboardUtils"); function sendStaticAsset(reply, asset) { 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 = {}) { + 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 api = createDashboardApi({ watcher: runtime.watcher, 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"); + 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({ logger: false, bodyLimit: 1024 * 1024, }); app.setErrorHandler((error, _request, reply) => { - const statusCode = Number(error.statusCode) || 500; - reply.code(statusCode).send({ - error: error.message || "Internal Server Error", - }); + const failure = toPublicErrorResponse(error, { logger }); + reply.code(failure.statusCode).send(failure.body); }); app.addHook("onClose", async () => { 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, "/")); }); @@ -60,6 +155,64 @@ async function createFastifyDashboardServer(options = {}) { 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 () => ({ ok: true, now: new Date().toISOString(), @@ -67,41 +220,54 @@ async function createFastifyDashboardServer(options = {}) { 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 () => ({ controls: runtime.watcher.getGlobalControls(), })); 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) => { - 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) => { reply.code(201); - return api.createWatch(request.body || {}); + return api.createWatch(request.body || {}, { user: request.dashboardUser }); }); app.get("/api/events", async (request) => { - return api.listEvents(request.query?.limit); - }); - - app.post("/api/watches/:watchId/poll", async (request) => { - return api.pollWatch(decodeWatchId(request.params.watchId)); + return api.listEvents(request.query?.limit, { user: request.dashboardUser }); }); 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) => { - return api.deleteWatch(decodeWatchId(request.params.watchId)); + return api.deleteWatch(decodeWatchId(request.params.watchId), { + user: request.dashboardUser, + }); }); app.setNotFoundHandler(async (request, reply) => { @@ -121,7 +287,7 @@ async function createFastifyDashboardServer(options = {}) { close: async () => { 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( - `dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec}\n` + `dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec} authEnabled=${dashboard.info.authEnabled}\n` ); const shutdown = () => { diff --git a/src/llmParameterExtractor.js b/src/llmParameterExtractor.js index 089d3f1..f40965f 100644 --- a/src/llmParameterExtractor.js +++ b/src/llmParameterExtractor.js @@ -31,6 +31,12 @@ function toIntegerOrNull(value) { 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) { if (!value || typeof value !== "object") return null; const from = typeof value.from === "string" ? value.from : null; @@ -119,14 +125,45 @@ function uniqueStrings(values) { } 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 second = segments[1]; - if (first.from === second.to && first.to === second.from) return "round_trip"; - if (first.from === second.to && first.to !== second.from) return "open_jaw"; + 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 second = segments[1]; + // A→B / B→A is genuine round_trip; anything else is multi_city + if (first.from !== second.to || first.to !== second.from) { + return "multi_city"; + } + } + + return tripType || "unknown"; +} + function recomputeMissingFields(params) { const missingFields = []; if (!params.departureDateWindow) missingFields.push("departureDateWindow"); @@ -152,10 +189,11 @@ function mergeWithFallback(llmObject, fallbackParams, input, now) { ...uniqueStrings(source.warnings), ]); - const tripType = + const rawTripType = typeof source.tripType === "string" && source.tripType.trim() - ? source.tripType + ? source.tripType.trim() : inferTripType(segments); + const tripType = correctTripType(rawTripType, segments); const parsed = { rawInput: input, @@ -183,7 +221,8 @@ function buildPrompt(input, nowDate) { ' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | null,', ' "passengers": {"total":number,"byCabin":{"economy":number,"premium_economy":number,"business":number,"first":number}} | null,', ' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},', - ' "tripType": "round_trip|open_jaw|multi_city|unknown",', + ' "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],', ' "missingFields": [string]', "}", @@ -200,6 +239,10 @@ function createOpenAIClient(options = {}) { 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 timeoutMs = parsePositiveInt( + options.timeoutMs !== undefined ? options.timeoutMs : process.env.LLM_REQUEST_TIMEOUT_MS, + 20000 + ); const fetchImpl = options.fetch || global.fetch; if (typeof fetchImpl !== "function") { throw new Error("global fetch is unavailable. Node.js 18+ is required."); @@ -208,38 +251,53 @@ function createOpenAIClient(options = {}) { const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`; return async ({ input, now }) => { - const response = await fetchImpl(endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - temperature: 0, - response_format: { type: "json_object" }, - messages: [ - { - role: "system", - content: - "You are a parser. Output valid JSON only. Do not wrap in markdown or prose.", - }, - { - role: "user", - content: buildPrompt(input, now), - }, - ], - }), - }); + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + }, timeoutMs); - if (!response.ok) { - const message = await response.text(); - throw new Error(`OpenAI API request failed (${response.status}): ${message}`); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: + "You are a parser. Output valid JSON only. Do not wrap in markdown or prose.", + }, + { + role: "user", + content: buildPrompt(input, now), + }, + ], + }), + signal: abortController.signal, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(`OpenAI API request failed (${response.status}): ${message}`); + } + + const payload = await response.json(); + const content = payload?.choices?.[0]?.message?.content; + return tryParseJsonObject(content); + } catch (error) { + if (error?.name === "AbortError") { + throw new Error(`OpenAI API request timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeout); } - - const payload = await response.json(); - const content = payload?.choices?.[0]?.message?.content; - return tryParseJsonObject(content); }; } diff --git a/src/pollingConfig.js b/src/pollingConfig.js new file mode 100644 index 0000000..390d959 --- /dev/null +++ b/src/pollingConfig.js @@ -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, +}; diff --git a/src/priceWatcher.js b/src/priceWatcher.js index 3443d17..94770c2 100644 --- a/src/priceWatcher.js +++ b/src/priceWatcher.js @@ -1,6 +1,7 @@ "use strict"; const crypto = require("node:crypto"); +const { normalizeCrawlIntervalMs } = require("./pollingConfig"); function cloneJson(value) { if (value === undefined) return undefined; @@ -97,6 +98,7 @@ function buildAlertEvent(watch, previousSnapshot, currentSnapshot) { const uniqueReasons = [...new Set(reasons)]; return { watchId: watch.id, + ownerId: watch.ownerId || null, rawInput: watch.rawInput, eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0], reasons: uniqueReasons, @@ -121,10 +123,7 @@ class PriceWatcher { constructor(options = {}) { this.crawler = options.crawler; this.notifier = options.notifier; - this.pollIntervalMs = - Number.isFinite(Number(options.pollIntervalMs)) && Number(options.pollIntervalMs) > 0 - ? Number(options.pollIntervalMs) - : 60000; + this.pollIntervalMs = normalizeCrawlIntervalMs(options.pollIntervalMs); this.logger = options.logger || console; this.now = options.now || (() => new Date()); this.onWatchPolled = @@ -139,6 +138,7 @@ class PriceWatcher { this.watches = new Map(); this.timer = null; + this.pollingInFlight = false; this.globalControls = { crawlingEnabled: true, alertsEnabled: true, @@ -148,6 +148,7 @@ class PriceWatcher { toPublicWatch(watch) { return { id: watch.id, + ownerId: watch.ownerId || null, rawInput: watch.rawInput, searchParams: cloneJson(watch.searchParams), alertRules: cloneJson(watch.alertRules), @@ -201,6 +202,11 @@ class PriceWatcher { if (Object.prototype.hasOwnProperty.call(patch, "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 (!patch.searchParams || typeof patch.searchParams !== "object") { throw new Error("searchParams must be an object"); @@ -241,6 +247,7 @@ class PriceWatcher { addWatch({ id, + ownerId = null, rawInput, searchParams, alertRules, @@ -263,6 +270,7 @@ class PriceWatcher { const nowIso = new Date(this.now()).toISOString(); this.watches.set(watchId, { id: watchId, + ownerId: typeof ownerId === "string" && ownerId.trim() ? ownerId.trim() : null, rawInput: typeof rawInput === "string" ? rawInput : "", searchParams, alertRules: normalizeAlertRules(alertRules), @@ -301,38 +309,53 @@ class PriceWatcher { } async pollAll() { - if (!this.globalControls.crawlingEnabled) { - const skipped = []; - for (const watch of this.watches.values()) { - const result = { - watchId: watch.id, + if (this.pollingInFlight) { + return [ + { skipped: { - reason: "global_crawling_disabled", + reason: "poll_cycle_in_progress", }, - }; - skipped.push(result); - await this.emitPolled(watch, result); - } - return skipped; + }, + ]; } - const results = []; - for (const watch of this.watches.values()) { - if (!watch.pollingEnabled) { - const result = { - watchId: watch.id, - skipped: { - reason: "watch_polling_disabled", - }, - }; - results.push(result); - await this.emitPolled(watch, result); - continue; + this.pollingInFlight = true; + try { + if (!this.globalControls.crawlingEnabled) { + const skipped = []; + for (const watch of this.watches.values()) { + const result = { + watchId: watch.id, + skipped: { + reason: "global_crawling_disabled", + }, + }; + skipped.push(result); + await this.emitPolled(watch, result); + } + return skipped; } - // Poll sequentially to keep provider-side rate limits predictable. - results.push(await this.pollWatch(watch.id)); + + const results = []; + for (const watch of this.watches.values()) { + if (!watch.pollingEnabled) { + const result = { + watchId: watch.id, + skipped: { + reason: "watch_polling_disabled", + }, + }; + results.push(result); + await this.emitPolled(watch, result); + continue; + } + // Poll sequentially to keep provider-side rate limits predictable. + results.push(await this.pollWatch(watch.id)); + } + return results; + } finally { + this.pollingInFlight = false; } - return results; } async pollWatch(watchId) { @@ -359,15 +382,85 @@ class PriceWatcher { return result; } - try { - const offers = await this.crawler.getQuotes({ + // IP 차단 방지를 위한 최종 안전장치: 필수 정보 부재 시 크롤링 건너뜀 + const params = watch.searchParams || {}; + if (!params.segments || params.segments.length === 0 || !params.departureDateWindow?.from) { + const result = { watchId: watch.id, - rawInput: watch.rawInput, - searchParams: watch.searchParams, - }); - const normalizedOffers = normalizeOffers(offers); - if (normalizedOffers.length === 0) { - throw new Error("Crawler returned no valid offers"); + skipped: { reason: "missing_essential_search_params" }, + }; + await this.emitPolled(watch, result); + return result; + } + + 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({ + watchId: watch.id, + rawInput: watch.rawInput, + searchParams: watch.searchParams, + }); + normalizedOffers = normalizeOffers(offers); + if (normalizedOffers.length === 0) { + throw new Error("Crawler returned no valid offers"); + } } const bestOffer = normalizedOffers[0]; @@ -388,7 +481,9 @@ class PriceWatcher { if (alert) { if (this.globalControls.alertsEnabled && watch.alertsEnabled) { try { - await this.notifier.notify(alert); + await this.notifier.notify(alert, { + watch: this.toPublicWatch(watch), + }); notificationSent = true; } catch (error) { const at = new Date(this.now()).toISOString(); diff --git a/test/apiAuth.test.js b/test/apiAuth.test.js new file mode 100644 index 0000000..7c55fda --- /dev/null +++ b/test/apiAuth.test.js @@ -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 + ); +}); diff --git a/test/crawlerUrls.test.js b/test/crawlerUrls.test.js new file mode 100644 index 0000000..b03bdd9 --- /dev/null +++ b/test/crawlerUrls.test.js @@ -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"); +}); diff --git a/test/dashboardRuntime.test.js b/test/dashboardRuntime.test.js new file mode 100644 index 0000000..5f62d7a --- /dev/null +++ b/test/dashboardRuntime.test.js @@ -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(); + } +}); diff --git a/test/dashboardStore.test.js b/test/dashboardStore.test.js index c65ccc2..818cd19 100644 --- a/test/dashboardStore.test.js +++ b/test/dashboardStore.test.js @@ -2,7 +2,38 @@ const test = require("node:test"); 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 () => { 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.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"); +}); diff --git a/test/dashboardUtils.test.js b/test/dashboardUtils.test.js new file mode 100644 index 0000000..eea8a05 --- /dev/null +++ b/test/dashboardUtils.test.js @@ -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); +}); diff --git a/test/llmParameterExtractor.test.js b/test/llmParameterExtractor.test.js index 0b9e40d..aa40d8f 100644 --- a/test/llmParameterExtractor.test.js +++ b/test/llmParameterExtractor.test.js @@ -2,7 +2,10 @@ const test = require("node:test"); 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 () => { const llmClient = async () => ({ @@ -67,3 +70,102 @@ test("falls back to rule parser when LLM client fails", async () => { 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/ + ); +}); diff --git a/test/pollingConfig.test.js b/test/pollingConfig.test.js new file mode 100644 index 0000000..626d44a --- /dev/null +++ b/test/pollingConfig.test.js @@ -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"), /정수여야 합니다/); +}); diff --git a/test/priceWatcher.test.js b/test/priceWatcher.test.js index 7aca9ee..6dde7fd 100644 --- a/test/priceWatcher.test.js +++ b/test/priceWatcher.test.js @@ -45,6 +45,7 @@ test("emits threshold alerts when crossing and improving below threshold", async rawInput: "인천-마드리드 추적", searchParams: { segments: [{ from: "ICN", to: "MAD" }], + departureDateWindow: { from: "2026-06-01" }, }, alertRules: { targetPrice: 900, @@ -79,6 +80,7 @@ test("emits price change alerts when price changes", async () => { rawInput: "인천-마드리드 추적", searchParams: { segments: [{ from: "ICN", to: "MAD" }], + departureDateWindow: { from: "2026-06-01" }, }, alertRules: { notifyOnPriceChange: true, @@ -114,6 +116,7 @@ test("keeps crawl snapshot even when notifier fails", async () => { rawInput: "인천-마드리드 추적", searchParams: { segments: [{ from: "ICN", to: "MAD" }], + departureDateWindow: { from: "2026-06-01" }, }, alertRules: { targetPrice: 980000, @@ -132,3 +135,53 @@ test("keeps crawl snapshot even when notifier fails", async () => { assert.equal(watch.lastSnapshot.bestPrice, 950000); 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; +}); diff --git a/test/priceWatcherControls.test.js b/test/priceWatcherControls.test.js index 693df93..24a5f30 100644 --- a/test/priceWatcherControls.test.js +++ b/test/priceWatcherControls.test.js @@ -28,7 +28,7 @@ test("global crawling toggle skips polling", async () => { const watchId = watcher.addWatch({ rawInput: "테스트", - searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } }, alertRules: { targetPrice: 900, notifyOnPriceChange: true }, }); @@ -57,7 +57,7 @@ test("watch-level polling toggle skips polling", async () => { const watchId = watcher.addWatch({ rawInput: "테스트", - searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } }, alertRules: { targetPrice: null, notifyOnPriceChange: true }, pollingEnabled: false, }); @@ -91,7 +91,7 @@ test("alerts can be suppressed while still computing alert events", async () => const watchId = watcher.addWatch({ rawInput: "테스트", - searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, + searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } }, alertRules: { targetPrice: 950, notifyOnPriceChange: true }, 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.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 이상이어야 합니다/ + ); +});