chore: 현재 작업 중간 커밋

This commit is contained in:
chungyeong
2026-03-05 11:00:45 +09:00
parent 02970df6af
commit be88b4fcec
43 changed files with 6837 additions and 466 deletions

View File

@@ -1,4 +1,13 @@
FROM node:22-alpine FROM node:22-slim
# Chrome 실행에 필요한 시스템 라이브러리 + Google Chrome Stable 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
wget gnupg ca-certificates fonts-noto-cjk \
&& wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
&& apt-get purge -y wget gnupg \
&& apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -9,7 +18,9 @@ COPY . .
ENV NODE_ENV=production \ ENV NODE_ENV=production \
DASHBOARD_HOST=0.0.0.0 \ DASHBOARD_HOST=0.0.0.0 \
DASHBOARD_PORT=3000 DASHBOARD_PORT=3000 \
# Chrome이 컨테이너 내에서 sandbox 없이 실행될 수 있도록
CHROME_NO_SANDBOX=true
EXPOSE 3000 EXPOSE 3000

View File

@@ -166,7 +166,7 @@ npm run parse -- "11월 말부터 12월 초까지 출발하는 일정 여행 기
LLM 기반 파라미터 가공 + 주기적 가격 추적 실행: LLM 기반 파라미터 가공 + 주기적 가격 추적 실행:
```bash ```bash
npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --interval-sec 60 --target-price 1300000 --alert-on both npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명" --interval-sec 3600 --target-price 1300000 --alert-on both
``` ```
한 번만 조회(테스트용): 한 번만 조회(테스트용):
@@ -191,7 +191,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드,
``` ```
옵션: 옵션:
- `--interval-sec`: 폴링 주기(초) - `--interval-sec`: 폴링 주기(초, 기본 `3600`, `3600` 미만 입력 시 즉시 에러)
- `--target-price`: 목표 가격 이하 도달 시 알림 기준 - `--target-price`: 목표 가격 이하 도달 시 알림 기준
- `--alert-on both|change|threshold`: 알림 조건 (가격 변동/임계값) - `--alert-on both|change|threshold`: 알림 조건 (가격 변동/임계값)
- `--rule-only`: LLM 호출 없이 규칙 파서만 사용 - `--rule-only`: LLM 호출 없이 규칙 파서만 사용
@@ -199,6 +199,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드,
환경 변수: 환경 변수:
- `OPENAI_API_KEY`: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다. - `OPENAI_API_KEY`: 설정 시 자연어 입력 파라미터를 LLM으로 보정한다.
- `OPENAI_MODEL` (선택): 기본값 `gpt-4.1-mini` - `OPENAI_MODEL` (선택): 기본값 `gpt-4.1-mini`
- `LLM_REQUEST_TIMEOUT_MS` (선택): LLM HTTP 타임아웃(ms), 기본값 `20000`
- `CRAWLER_ENDPOINT` (선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용. - `CRAWLER_ENDPOINT` (선택): 설정 시 해당 엔드포인트로 POST 하여 실크롤러 결과를 받는다. 미설정 시 mock 크롤러 사용.
- `CRAWLER_PROVIDERS` (선택): `skyscanner,naver,google` 형태 우선순위 목록. 미설정 시 단일 `CRAWLER_ENDPOINT` 또는 mock 사용. - `CRAWLER_PROVIDERS` (선택): `skyscanner,naver,google` 형태 우선순위 목록. 미설정 시 단일 `CRAWLER_ENDPOINT` 또는 mock 사용.
- `CRAWLER_ENDPOINT_SKYSCANNER` (선택): Skyscanner 전용 엔드포인트 - `CRAWLER_ENDPOINT_SKYSCANNER` (선택): Skyscanner 전용 엔드포인트
@@ -224,6 +225,7 @@ npm run watch -- "11월 말부터 12월 초까지 출발, 인천->마드리드,
```bash ```bash
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini OPENAI_MODEL=gpt-4.1-mini
LLM_REQUEST_TIMEOUT_MS=20000
CRAWLER_ENDPOINT= CRAWLER_ENDPOINT=
CRAWLER_PROVIDERS=skyscanner,naver,google CRAWLER_PROVIDERS=skyscanner,naver,google
@@ -293,12 +295,12 @@ npm run dashboard:fastify
대시보드에서 가능한 작업: 대시보드에서 가능한 작업:
- 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인 - 자연어 입력을 LLM/규칙 파서로 파싱해 검색 조건 JSON 확인
- 파싱된 조건으로 watch 생성 및 즉시 조회 - 파싱된 조건으로 watch 생성
- watch별 `크롤링 ON/OFF`, `알림 ON/OFF` 토글 - watch별 `크롤링 ON/OFF`, `알림 ON/OFF` 토글
- 전역 `전체 크롤링 ON/OFF`, `전체 알림 ON/OFF` 토글 - 전역 `전체 크롤링 ON/OFF`, `전체 알림 ON/OFF` 토글
- 최근 가격 이벤트(목표가 도달/가격 변동) 확인 - 최근 가격 이벤트(목표가 도달/가격 변동) 확인
### 7.1 MySQL 연동 (개인 DB 사용) ### 7.1 MySQL 연동 (외부 DB 사용)
환경변수 설정: 환경변수 설정:
@@ -308,26 +310,87 @@ MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306 MYSQL_PORT=3306
MYSQL_USER=your_user MYSQL_USER=your_user
MYSQL_PASSWORD=your_password MYSQL_PASSWORD=your_password
MYSQL_DATABASE=airwatcher MYSQL_DATABASE=your_database
``` ```
또는 단일 URL 사용: 또는 단일 URL 사용:
```bash ```bash
DASHBOARD_DB=mysql DASHBOARD_DB=mysql
MYSQL_URL=mysql://user:password@127.0.0.1:3306/airwatcher MYSQL_URL=mysql://user:password@db.example.com:3306/your_database
``` ```
추가 서버 환경변수: 추가 서버 환경변수:
- `DASHBOARD_HOST` (기본 `127.0.0.1`) - `DASHBOARD_HOST` (기본 `127.0.0.1`)
- `DASHBOARD_PORT` (기본 `3000`) - `DASHBOARD_PORT` (기본 `3000`)
- `DASHBOARD_POLL_INTERVAL_SEC` (기본 `60`) - `DASHBOARD_POLL_INTERVAL_SEC` (기본 `3600`, `3600` 미만/비정수 입력 시 서버 시작 에러)
- `DASHBOARD_USERS` (선택, `username:password,username2:password2` 형식. 설정 시 로그인 페이지 + 계정별 세션 인증 활성화)
- `DASHBOARD_ADMIN_USERS` (선택, 전역 제어 권한 계정 목록. 미설정 시 `DASHBOARD_USERS` 첫 번째 계정을 admin으로 간주)
- `DASHBOARD_SESSION_TTL_SEC` (선택, 기본 `604800`초 = 7일)
- `DASHBOARD_REQUIRE_AUTH` (기본: `NODE_ENV=production`이면 `true`, 아니면 `false`)
- `DASHBOARD_API_TOKEN` (`DASHBOARD_REQUIRE_AUTH=true`일 때 필수, API `Authorization: Bearer <token>` 또는 `X-API-Key`로 전달)
- `DASHBOARD_ALLOW_MEMORY_FALLBACK` (기본 `false`, `true`일 때만 MySQL 초기화 실패/설정 누락 시 메모리 저장소 fallback 허용)
- `DASHBOARD_DB_SCHEMA` (선택, `auto|playground`, 기본 `playground`)
- `DASHBOARD_PROJECT_KEY` (선택, 기본 `air-watcher`, playground 스키마에서 프로젝트 네임스페이스 키)
- `LLM_REQUEST_TIMEOUT_MS` (기본 `20000`, LLM 파싱 요청 타임아웃)
참고: 참고:
- `DASHBOARD_DB=mysql`인데 MySQL 연결 정보가 없으면 서버가 에러를 반환한다. - 애플리케이션은 외부 MySQL 연결해 row CRUD만 수행한다.
- 애플리케이션은 DB/테이블 생성이나 마이그레이션을 수행하지 않는다.
- `DASHBOARD_DB=mysql`인데 MySQL 연결 정보가 없으면 서버가 시작 실패한다. (`DASHBOARD_ALLOW_MEMORY_FALLBACK=true`면 메모리 fallback 가능)
- MySQL 초기화 실패도 동일하게 기본은 시작 실패이며, fallback은 명시적으로 허용해야 한다.
- `DASHBOARD_DB`를 비우고 MySQL 환경변수도 없으면 메모리 저장소로 동작한다. - `DASHBOARD_DB`를 비우고 MySQL 환경변수도 없으면 메모리 저장소로 동작한다.
- `CRAWLER_ENDPOINT` 미설정 시 mock 가격으로 동작하므로 실데이터 추적 시 실제 크롤러 API를 연결해야 한다. - `CRAWLER_ENDPOINT` 미설정 시 mock 가격으로 동작하므로 실데이터 추적 시 실제 크롤러 API를 연결해야 한다.
- `DASHBOARD_DB_SCHEMA``playground`(또는 `auto`)만 지원한다.
### 7.1.1 계정 분리 + 텔레그램 설정 페이지
- 로그인 페이지: `/login`
- 텔레그램 설정/가이드 페이지: `/setup`
- 계정별로 watch/event가 분리되어, 일반 사용자는 본인 watch만 조회/수정 가능
- 텔레그램은 계정별 `chatId`/`botToken`(선택, 서버 기본 토큰 fallback) 저장 가능
예시:
```bash
DASHBOARD_USERS=alice:alice_pw,bob:bob_pw
DASHBOARD_ADMIN_USERS=alice
```
참고:
- `DASHBOARD_USERS`를 설정하면 브라우저 로그인 기반 인증이 우선 사용된다.
- 토큰 인증(`DASHBOARD_API_TOKEN`)은 기존 자동화/API 호출 용도로 병행 가능하다.
### 7.1.2 단일 Playground DB에 여러 소형 프로젝트 수용
소형 프로젝트를 여러 개 운영한다면 DB를 프로젝트별로 쪼개기보다, 하나의 DB(예: `playground`)에서 `project_key` 네임스페이스로 분리하는 방식이 실용적이다.
- 스키마 파일: `playground_schema.sql`
- 핵심 테이블:
- `projects`: 프로젝트 목록/메타
- `project_documents`: 프로젝트별 문서(JSON) 저장
- `project_events`: 프로젝트별 이벤트 로그
- `project_settings`: 프로젝트별 설정 key-value
적용 예시:
```bash
mysql -u root -p playground < playground_schema.sql
```
Air-Watcher를 playground 스키마로 실행하려면:
```bash
DASHBOARD_DB=mysql
DASHBOARD_DB_SCHEMA=playground
DASHBOARD_PROJECT_KEY=air-watcher
```
Air-Watcher 매핑 권장:
- watch 레코드: `project_documents` (`doc_type='watch'`, `doc_key=<watchId>`)
- 이벤트: `project_events` (`stream='watch_events'`)
- 전역/유저 설정: `project_settings` (`global_controls`, `user_profiles` 등)
### 7.2 Fastify 전환 뼈대 ### 7.2 Fastify 전환 뼈대
@@ -341,7 +404,7 @@ MYSQL_URL=mysql://user:password@127.0.0.1:3306/airwatcher
DASHBOARD_HOST=127.0.0.1 DASHBOARD_PORT=3000 npm run dashboard:fastify DASHBOARD_HOST=127.0.0.1 DASHBOARD_PORT=3000 npm run dashboard:fastify
``` ```
### 7.3 Docker / Compose ### 7.3 Docker / Compose (앱 단독)
Docker 이미지 빌드: Docker 이미지 빌드:
@@ -349,16 +412,20 @@ Docker 이미지 빌드:
docker build -t airwatcher . docker build -t airwatcher .
``` ```
Docker Compose 기동 (`app + mysql`): Docker Compose 기동 (`app` only):
```bash ```bash
docker compose up --build docker compose up --build app
``` ```
주요 기본값: 주요 기본값:
- 앱: `http://127.0.0.1:3000` - 앱: `http://127.0.0.1:3000`
- DB 모드: `DASHBOARD_DB=mysql` - DB 모드: `DASHBOARD_DB=mysql`
- MySQL 컨테이너: `127.0.0.1:3306` (기본 계정 `airwatcher/airwatcher`) - MySQL은 외부 서버를 사용하며 Compose에서 DB 컨테이너를 띄우지 않는다.
- 인증: `DASHBOARD_REQUIRE_AUTH=true` (토큰 미설정 시 앱 시작 실패)
참고:
- `.env` 또는 쉘 환경변수로 `MYSQL_URL` 또는 `MYSQL_HOST`/`MYSQL_USER`/`MYSQL_DATABASE`를 전달해야 한다.
메모리 모드로 앱만 쓰려면: 메모리 모드로 앱만 쓰려면:

View File

@@ -10,14 +10,24 @@ services:
DASHBOARD_HOST: 0.0.0.0 DASHBOARD_HOST: 0.0.0.0
DASHBOARD_PORT: 3000 DASHBOARD_PORT: 3000
DASHBOARD_DB: ${DASHBOARD_DB:-mysql} DASHBOARD_DB: ${DASHBOARD_DB:-mysql}
DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-60} DASHBOARD_POLL_INTERVAL_SEC: ${DASHBOARD_POLL_INTERVAL_SEC:-3600}
MYSQL_HOST: mysql DASHBOARD_REQUIRE_AUTH: ${DASHBOARD_REQUIRE_AUTH:-true}
MYSQL_PORT: 3306 DASHBOARD_API_TOKEN: ${DASHBOARD_API_TOKEN:-}
MYSQL_USER: ${MYSQL_USER:-airwatcher} DASHBOARD_USERS: ${DASHBOARD_USERS:-}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher} DASHBOARD_ADMIN_USERS: ${DASHBOARD_ADMIN_USERS:-}
MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher} DASHBOARD_SESSION_TTL_SEC: ${DASHBOARD_SESSION_TTL_SEC:-604800}
DASHBOARD_ALLOW_MEMORY_FALLBACK: ${DASHBOARD_ALLOW_MEMORY_FALLBACK:-false}
DASHBOARD_DB_SCHEMA: ${DASHBOARD_DB_SCHEMA:-playground}
DASHBOARD_PROJECT_KEY: ${DASHBOARD_PROJECT_KEY:-air-watcher}
MYSQL_URL: ${MYSQL_URL:-}
MYSQL_HOST: ${MYSQL_HOST:-}
MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_USER: ${MYSQL_USER:-}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
MYSQL_DATABASE: ${MYSQL_DATABASE:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini} OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini}
LLM_REQUEST_TIMEOUT_MS: ${LLM_REQUEST_TIMEOUT_MS:-20000}
CRAWLER_ENDPOINT: ${CRAWLER_ENDPOINT:-} CRAWLER_ENDPOINT: ${CRAWLER_ENDPOINT:-}
CRAWLER_PROVIDERS: ${CRAWLER_PROVIDERS:-} CRAWLER_PROVIDERS: ${CRAWLER_PROVIDERS:-}
CRAWLER_ENDPOINT_SKYSCANNER: ${CRAWLER_ENDPOINT_SKYSCANNER:-} CRAWLER_ENDPOINT_SKYSCANNER: ${CRAWLER_ENDPOINT_SKYSCANNER:-}
@@ -33,30 +43,3 @@ services:
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
TELEGRAM_API_BASE: ${TELEGRAM_API_BASE:-https://api.telegram.org} TELEGRAM_API_BASE: ${TELEGRAM_API_BASE:-https://api.telegram.org}
NOTIFY_WEBHOOK_URL: ${NOTIFY_WEBHOOK_URL:-} NOTIFY_WEBHOOK_URL: ${NOTIFY_WEBHOOK_URL:-}
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8.4
restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password
ports:
- "${MYSQL_PORT_HOST:-3306}:3306"
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-airwatcher}
MYSQL_USER: ${MYSQL_USER:-airwatcher}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-airwatcher}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
healthcheck:
test:
- CMD-SHELL
- mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD || exit 1
interval: 5s
timeout: 3s
retries: 20
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:

1107
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,21 @@
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
"start": "concurrently \"npm run crawler\" \"npm run dashboard\"",
"parse": "node src/cli.js", "parse": "node src/cli.js",
"watch": "node src/cli.js watch", "watch": "node src/cli.js watch",
"dashboard": "node src/dashboardServer.js", "dashboard": "node src/dashboardServer.js",
"dashboard:fastify": "node src/fastifyDashboardServer.js", "dashboard:fastify": "node src/fastifyDashboardServer.js",
"sample:skyscanner": "node src/skyscannerSampleServer.js", "sample:skyscanner": "node src/skyscannerSampleServer.js",
"crawler": "node src/crawlerServer.js",
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"fastify": "^5.2.2", "fastify": "^5.2.2",
"mysql2": "^3.15.2" "mysql2": "^3.15.2",
"patchright": "^1.57.0"
},
"devDependencies": {
"concurrently": "^9.2.1"
} }
} }

93
playground_schema.sql Normal file
View File

@@ -0,0 +1,93 @@
-- Multi-project playground schema (MySQL)
-- Purpose: keep many small projects in one database using project_key namespace.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS projects (
project_key VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
meta_json LONGTEXT NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (project_key),
KEY idx_projects_updated_at (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS project_documents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
project_key VARCHAR(100) NOT NULL,
doc_type VARCHAR(100) NOT NULL,
doc_key VARCHAR(191) NOT NULL,
data_json LONGTEXT NOT NULL,
meta_json LONGTEXT NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_project_documents_namespace (project_key, doc_type, doc_key),
KEY idx_project_documents_project_type_updated (project_key, doc_type, updated_at),
KEY idx_project_documents_project_updated (project_key, updated_at),
CONSTRAINT fk_project_documents_project_key
FOREIGN KEY (project_key) REFERENCES projects(project_key)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS project_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
project_key VARCHAR(100) NOT NULL,
stream VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload_json LONGTEXT NOT NULL,
observed_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
KEY idx_project_events_project_stream_created (project_key, stream, created_at),
KEY idx_project_events_project_created (project_key, created_at),
CONSTRAINT fk_project_events_project_key
FOREIGN KEY (project_key) REFERENCES projects(project_key)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS project_settings (
project_key VARCHAR(100) NOT NULL,
setting_key VARCHAR(191) NOT NULL,
setting_value LONGTEXT NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (project_key, setting_key),
KEY idx_project_settings_updated_at (updated_at),
CONSTRAINT fk_project_settings_project_key
FOREIGN KEY (project_key) REFERENCES projects(project_key)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed one project namespace for this repository (optional)
INSERT INTO projects (
project_key,
name,
description,
meta_json,
created_at,
updated_at
)
VALUES (
'air-watcher',
'Air Watcher',
'Flight price watcher dashboard and alerts',
JSON_OBJECT('owner', 'team', 'status', 'active'),
UTC_TIMESTAMP(3),
UTC_TIMESTAMP(3)
)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
meta_json = VALUES(meta_json),
updated_at = VALUES(updated_at);
-- Mapping guide for Air-Watcher:
-- 1) watch row -> project_documents (doc_type='watch', doc_key=watch_id)
-- 2) app settings -> project_settings (setting_key='global_controls' etc.)
-- 3) user profiles -> project_settings (setting_key='user_profiles')
-- 4) watch alert events -> project_events (stream='watch_events')

84
src/apiAuth.js Normal file
View File

@@ -0,0 +1,84 @@
"use strict";
const crypto = require("node:crypto");
const { parseBoolean } = require("./dashboardUtils");
function normalizeToken(value) {
if (typeof value !== "string") return "";
return value.trim();
}
function readHeaderValue(value) {
if (Array.isArray(value)) {
const first = value.find((item) => typeof item === "string");
return typeof first === "string" ? first : "";
}
return typeof value === "string" ? value : "";
}
function parseBearerToken(authorizationHeader) {
const normalized = readHeaderValue(authorizationHeader).trim();
if (!normalized) return "";
const matched = normalized.match(/^Bearer\s+(.+)$/i);
if (!matched) return "";
return normalizeToken(matched[1]);
}
function secureEqual(left, right) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function resolveApiAuth(options = {}) {
const nodeEnvRaw = options.nodeEnv !== undefined ? options.nodeEnv : process.env.NODE_ENV;
const nodeEnv = typeof nodeEnvRaw === "string" ? nodeEnvRaw.trim().toLowerCase() : "";
const requireAuthRaw =
options.requireAuth !== undefined ? options.requireAuth : process.env.DASHBOARD_REQUIRE_AUTH;
const enabled = parseBoolean(requireAuthRaw, nodeEnv === "production");
const tokenRaw =
options.apiToken !== undefined ? options.apiToken : process.env.DASHBOARD_API_TOKEN;
const token = normalizeToken(typeof tokenRaw === "string" ? tokenRaw : "");
if (enabled && !token) {
throw new Error("DASHBOARD_API_TOKEN must be set when dashboard auth is enabled.");
}
return {
enabled,
token,
};
}
function isAuthorizedRequest(headers, authConfig) {
if (!authConfig || authConfig.enabled !== true) {
return true;
}
const expectedToken = normalizeToken(authConfig.token);
if (!expectedToken) {
return false;
}
const bearerToken = parseBearerToken(headers?.authorization);
if (bearerToken && secureEqual(bearerToken, expectedToken)) {
return true;
}
const apiKey = normalizeToken(readHeaderValue(headers?.["x-api-key"]));
if (apiKey && secureEqual(apiKey, expectedToken)) {
return true;
}
return false;
}
module.exports = {
isAuthorizedRequest,
resolveApiAuth,
};

View File

@@ -8,6 +8,7 @@ const { loadDotEnv } = require("./envLoader");
const { extractFlightSearchRequest } = require("./llmParameterExtractor"); const { extractFlightSearchRequest } = require("./llmParameterExtractor");
const { createCrawlerClient } = require("./crawlerClient"); const { createCrawlerClient } = require("./crawlerClient");
const { createNotifier } = require("./notifier"); const { createNotifier } = require("./notifier");
const { MIN_CRAWL_INTERVAL_SEC } = require("./pollingConfig");
const { PriceWatcher } = require("./priceWatcher"); const { PriceWatcher } = require("./priceWatcher");
loadDotEnv(); loadDotEnv();
@@ -36,7 +37,7 @@ function parseNumberValue(value, flagName) {
function parseWatchOptions(tokens) { function parseWatchOptions(tokens) {
const options = { const options = {
intervalSec: 60, intervalSec: MIN_CRAWL_INTERVAL_SEC,
targetPrice: null, targetPrice: null,
alertOn: "both", alertOn: "both",
useLlm: true, useLlm: true,
@@ -93,8 +94,10 @@ function parseWatchOptions(tokens) {
throw new Error(`알 수 없는 옵션: ${token}`); throw new Error(`알 수 없는 옵션: ${token}`);
} }
if (!Number.isInteger(options.intervalSec) || options.intervalSec <= 0) { if (!Number.isInteger(options.intervalSec) || options.intervalSec < MIN_CRAWL_INTERVAL_SEC) {
throw new Error("--interval-sec 값은 1 이상의 정수여야 합니다."); throw new Error(
`--interval-sec 값은 ${MIN_CRAWL_INTERVAL_SEC} 이상의 정수여야 합니다.`
);
} }
if ( if (
@@ -129,6 +132,17 @@ async function runWatch(tokens) {
preferRuleParser: !options.useLlm, preferRuleParser: !options.useLlm,
}); });
const missing = extracted.params.missingFields || [];
if (missing.includes("segments")) {
throw new Error("출발지 또는 도착지 정보가 파악되지 않았습니다. 문장을 다시 작성해주세요.");
}
if (missing.includes("departureDateWindow")) {
throw new Error("출발 날짜 정보가 파악되지 않았습니다. (예: '11월 25일에 출발') 문장을 구체적으로 적어주세요.");
}
if (extracted.params.tripType === "round_trip" && missing.includes("stayDurationDays")) {
throw new Error("왕복 여정입니다만, 체류 기간(며칠 동안 여행하는지)이 파악되지 않았습니다. (예: '12일 체류')");
}
const watcher = new PriceWatcher({ const watcher = new PriceWatcher({
crawler: createCrawlerClient(), crawler: createCrawlerClient(),
notifier: createNotifier(), notifier: createNotifier(),

View File

@@ -359,6 +359,20 @@ function createMultiSourceCrawler(options = {}) {
return { return {
async getQuotes(request) { async getQuotes(request) {
const explicitProvider = normalizeProviderName(request.searchParams?.provider);
if (explicitProvider) {
const target = sources.find((s) => s.provider === explicitProvider);
if (target) {
try {
return await target.crawler.getQuotes(request);
} catch (error) {
throw new Error(`Explicit provider failed (${target.provider}): ${error.message}`);
}
} else {
throw new Error(`Requested provider not available: ${explicitProvider}`);
}
}
if (routingStrategy === "primaryOnly") { if (routingStrategy === "primaryOnly") {
const primary = sources[0]; const primary = sources[0];
try { try {

64
src/crawlerServer.js Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
"use strict";
const Fastify = require("fastify");
// const { scrapeSkyscanner } = require("./crawlers/skyscanner");
// const { scrapeNaver } = require("./crawlers/naver");
const { scrapeGoogle } = require("./crawlers/google");
async function startCrawlerServer() {
const app = Fastify({
logger: true,
});
const port = process.env.CRAWLER_SERVER_PORT || 8787;
const host = process.env.CRAWLER_SERVER_HOST || "127.0.0.1";
// app.post("/skyscanner", async (request, reply) => {
// const { searchParams } = request.body || {};
// try {
// const offers = await scrapeSkyscanner(searchParams);
// return { currency: "KRW", offers };
// } catch (err) {
// app.log.error(err);
// reply.code(500).send({ error: err.message });
// }
// });
// app.post("/naver", async (request, reply) => {
// const { searchParams } = request.body || {};
// try {
// const offers = await scrapeNaver(searchParams);
// return { currency: "KRW", offers };
// } catch (err) {
// app.log.error(err);
// reply.code(500).send({ error: err.message });
// }
// });
app.post("/google", async (request, reply) => {
const { searchParams } = request.body || {};
try {
const offers = await scrapeGoogle(searchParams);
return { currency: "KRW", offers };
} catch (err) {
app.log.error(err);
reply.code(500).send({ error: err.message });
}
});
try {
await app.listen({ host, port });
console.log(`[Crawler Server] listening on http://${host}:${port}`);
console.log(`Available endpoints: /google`); // skyscanner, naver 일시 제외
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
if (require.main === module) {
startCrawlerServer();
}
module.exports = { startCrawlerServer };

426
src/crawlers/baseCrawler.js Normal file
View File

@@ -0,0 +1,426 @@
"use strict";
const { chromium } = require("patchright");
const path = require("path");
const fs = require("fs");
const os = require("os");
const PROFILE_DIR = path.join(os.homedir(), ".air-watcher", "chrome-profile");
const DEBUG_DIR = path.join(__dirname, "..", "..", "debug");
// ---------------------------------------------------------------------------
// Browser singleton — one Chrome instance shared across all requests
// ---------------------------------------------------------------------------
let _browserContext = null;
let _browserLaunchPromise = null;
async function getBrowserContext() {
if (_browserContext) return _browserContext;
if (_browserLaunchPromise) return _browserLaunchPromise;
_browserLaunchPromise = (async () => {
// Default to headed mode (less detectable); set CRAWLER_HEADLESS=true to override
const isHeadless = process.env.CRAWLER_HEADLESS === "true";
fs.mkdirSync(PROFILE_DIR, { recursive: true });
const launchOpts = {
headless: isHeadless,
viewport: null, // natural window size — fixed viewport is a bot fingerprint
locale: "ko-KR",
timezoneId: "Asia/Seoul",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--lang=ko-KR,ko",
],
// Patchright handles automation flag removal internally
};
let context;
try {
context = await chromium.launchPersistentContext(PROFILE_DIR, {
channel: "chrome",
...launchOpts,
});
console.log("[Crawler] Browser started: real Chrome (persistent context)");
} catch (_) {
console.log("[Crawler] Chrome not found, falling back to Chromium");
context = await chromium.launchPersistentContext(PROFILE_DIR, launchOpts);
console.log("[Crawler] Browser started: Chromium (persistent context)");
}
context.on("close", () => {
_browserContext = null;
_browserLaunchPromise = null;
});
_browserContext = context;
_browserLaunchPromise = null;
return context;
})();
return _browserLaunchPromise;
}
async function closeBrowser() {
if (_browserContext) {
try { await _browserContext.close(); } catch (_) {}
_browserContext = null;
_browserLaunchPromise = null;
}
}
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, async () => {
await closeBrowser();
process.exit(0);
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function randomDelay(min = 500, max = 2000) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
async function humanScroll(page) {
const scrollAmount = Math.floor(Math.random() * 400) + 200;
await page.mouse.wheel(0, scrollAmount);
await page.waitForTimeout(randomDelay(300, 800));
}
/**
* Dismisses common cookie consent banners.
*/
async function dismissCookieConsent(page) {
const cssSelectors = [
'button[aria-label*="동의"]',
'button[aria-label*="Accept"]',
'form[action*="consent"] button',
'[data-consent="accept"]',
'#acceptCookieButton',
'button[data-testid="accept-cookie-policy"]',
];
for (const sel of cssSelectors) {
try {
const btn = await page.$(sel);
if (btn) {
await btn.click();
console.log(`[Crawler] Dismissed cookie consent: ${sel}`);
await page.waitForTimeout(500);
return;
}
} catch (_) {}
}
const textLabels = ["동의", "수락", "Accept all", "Accept", "모두 동의"];
for (const label of textLabels) {
try {
const loc = page.locator("button", { hasText: label });
if (await loc.count() > 0) {
await loc.first().click();
console.log(`[Crawler] Dismissed cookie consent: button with "${label}"`);
await page.waitForTimeout(500);
return;
}
} catch (_) {}
}
}
// ---------------------------------------------------------------------------
// Debug: save HTML + screenshot + all frame HTML
// ---------------------------------------------------------------------------
async function saveDebugDump(page, provider) {
try {
fs.mkdirSync(DEBUG_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const prefix = path.join(DEBUG_DIR, `${provider}-${timestamp}`);
// Screenshot
await page.screenshot({ path: `${prefix}.png`, fullPage: true });
console.log(`[Debug] Screenshot: ${prefix}.png`);
// Main page HTML
const mainHtml = await page.content();
fs.writeFileSync(`${prefix}-main.html`, mainHtml, "utf8");
console.log(`[Debug] Main HTML: ${prefix}-main.html`);
// All frame URLs + HTML
const frames = page.frames();
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
if (frame === page.mainFrame()) continue;
try {
const frameHtml = await frame.content();
const framePath = `${prefix}-frame${i}.html`;
fs.writeFileSync(framePath, frameHtml, "utf8");
console.log(`[Debug] Frame ${i} (${frame.url()}): ${framePath}`);
} catch (_) {
console.log(`[Debug] Frame ${i} (${frame.url()}): could not read`);
}
}
} catch (e) {
console.log("[Debug] Failed to save debug dump:", e.message);
}
}
// ---------------------------------------------------------------------------
// Bot challenge: PerimeterX PRESS & HOLD
// ---------------------------------------------------------------------------
const CHALLENGE_INDICATORS = [
"PRESS & HOLD", "press & hold", "Press & Hold",
"person or a robot", "사람인지 로봇인지",
"길게 누르기", "누르고 유지",
];
/**
* Detects PerimeterX / Skyscanner bot challenge page.
*/
async function isChallengePage(page) {
// Fast check: PerimeterX captcha element exists?
const hasPxCaptcha = await page.$("#px-captcha");
if (hasPxCaptcha) return true;
// Fallback: check visible text
const bodyText = await page.evaluate(() => document.body.innerText || "");
return CHALLENGE_INDICATORS.some((t) => bodyText.includes(t));
}
/**
* Solves PerimeterX "PRESS & HOLD" challenge.
*
* PerimeterX structure (from HTML dump):
* <div id="px-captcha" style="display: block; min-width: 310px;">
* <iframe style="display: none;" ...></iframe>
* <!-- captcha.js async-injects the PRESS & HOLD button here -->
* </div>
*
* The captcha.js script loads async and renders the button inside #px-captcha.
* We must wait for it to render before interacting.
*/
async function handlePressAndHold(page, attempt = 1) {
if (attempt > 3) {
console.log("[Crawler] PRESS & HOLD: max retries reached");
await saveDebugDump(page, "challenge-failed");
return false;
}
if (!(await isChallengePage(page))) return false;
console.log(`[Crawler] Detected PerimeterX challenge (attempt ${attempt})...`);
// Wait for captcha.js to render the button inside #px-captcha
// The script loads async, so the button may not exist yet
try {
await page.waitForFunction(
() => {
const el = document.getElementById("px-captcha");
if (!el) return false;
// Wait until the captcha div has actual rendered content (height > 50px)
return el.offsetHeight > 50;
},
{ timeout: 15000 }
);
console.log("[Crawler] PerimeterX captcha rendered");
} catch (_) {
console.log("[Crawler] Timeout waiting for captcha render, trying anyway...");
}
// Small extra delay for any final rendering
await page.waitForTimeout(randomDelay(500, 1500));
// Strategy 1: Use #px-captcha element directly (most reliable)
let btnPos = null;
const pxCaptcha = await page.$("#px-captcha");
if (pxCaptcha) {
const box = await pxCaptcha.boundingBox();
if (box && box.width > 30 && box.height > 30) {
btnPos = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
console.log(`[Crawler] Using #px-captcha center: (${Math.round(btnPos.x)}, ${Math.round(btnPos.y)}), size: ${Math.round(box.width)}x${Math.round(box.height)}`);
}
}
// Strategy 2: Search DOM for any element containing challenge text
if (!btnPos) {
btnPos = await page.evaluate(() => {
const variants = ["PRESS & HOLD", "길게 누르기", "누르고 유지"];
const all = document.querySelectorAll("*");
for (const el of all) {
const text = (el.innerText || el.textContent || "").trim();
const match = variants.some((v) => text.toUpperCase().includes(v.toUpperCase()));
if (!match) continue;
const rect = el.getBoundingClientRect();
if (rect.width > 50 && rect.width < 500 && rect.height > 25 && rect.height < 200) {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
}
return null;
});
if (btnPos) {
console.log(`[Crawler] Found button by text at (${Math.round(btnPos.x)}, ${Math.round(btnPos.y)})`);
}
}
if (!btnPos) {
console.log("[Crawler] Could not locate captcha button, saving debug dump...");
await saveDebugDump(page, "challenge");
return false;
}
// Human-like mouse movement to button
const jitterX = (Math.random() - 0.5) * 6;
const jitterY = (Math.random() - 0.5) * 6;
await page.mouse.move(btnPos.x + jitterX, btnPos.y + jitterY, {
steps: 15 + Math.floor(Math.random() * 10),
});
await page.waitForTimeout(randomDelay(300, 700));
// Press and hold for 5-8 seconds (PerimeterX needs longer holds)
await page.mouse.down();
console.log("[Crawler] Holding button...");
const holdTime = randomDelay(5000, 8000);
await page.waitForTimeout(holdTime);
await page.mouse.up();
console.log(`[Crawler] Released after ${holdTime}ms`);
// Wait for page reaction (navigation or DOM change)
try {
await page.waitForNavigation({ timeout: 15000, waitUntil: "domcontentloaded" });
} catch (_) {}
await page.waitForTimeout(randomDelay(2000, 4000));
// Check if challenge is gone
if (!(await isChallengePage(page))) {
console.log("[Crawler] Bot challenge solved!");
return true;
}
console.log("[Crawler] Challenge still present, retrying...");
return handlePressAndHold(page, attempt + 1);
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
async function navigateWithHumanBehavior(page, url, opts = {}) {
const { waitUntil = "domcontentloaded", timeout = 60000 } = opts;
await page.goto(url, { waitUntil, timeout });
try {
await page.waitForLoadState("networkidle", { timeout: 30000 });
} catch (_) {
console.log("[Crawler] networkidle timeout, continuing...");
}
// Handle bot challenges
const challengeSolved = await handlePressAndHold(page);
if (challengeSolved) {
try {
await page.waitForLoadState("networkidle", { timeout: 30000 });
} catch (_) {}
}
await dismissCookieConsent(page);
await page.waitForTimeout(randomDelay(1500, 3000));
for (let i = 0; i < 3; i++) {
await humanScroll(page);
await page.waitForTimeout(randomDelay(500, 1000));
}
}
// ---------------------------------------------------------------------------
// Price extraction
// ---------------------------------------------------------------------------
function extractPricesFromText(text, minPrice) {
const prices = [];
const patterns = [/[\d,]+\s*원/g, /₩\s*[\d,]+/g, /KRW\s*[\d,]+/g];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(text)) !== null) {
const num = parseInt(match[0].replace(/[^0-9]/g, ""), 10);
if (!isNaN(num) && num >= minPrice) prices.push(num);
}
}
return prices;
}
async function extractPricesFromPage(page, opts = {}) {
const { minPrice = 10000 } = opts;
// Extract from main frame
const mainPrices = await page.evaluate((minP) => {
const text = document.body.innerText || "";
const prices = [];
const patterns = [/[\d,]+\s*원/g, /₩\s*[\d,]+/g, /KRW\s*[\d,]+/g];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(text)) !== null) {
const num = parseInt(match[0].replace(/[^0-9]/g, ""), 10);
if (!isNaN(num) && num >= minP) prices.push(num);
}
}
return prices;
}, minPrice);
// Also extract from child frames (Google Flights uses iframes)
const framePrices = [];
for (const frame of page.frames()) {
if (frame === page.mainFrame()) continue;
try {
const fp = await frame.evaluate((minP) => {
const text = document.body?.innerText || "";
const prices = [];
const patterns = [/[\d,]+\s*원/g, /₩\s*[\d,]+/g, /KRW\s*[\d,]+/g];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(text)) !== null) {
const num = parseInt(match[0].replace(/[^0-9]/g, ""), 10);
if (!isNaN(num) && num >= minP) prices.push(num);
}
}
return prices;
}, minPrice);
framePrices.push(...fp);
} catch (_) {}
}
const all = [...mainPrices, ...framePrices];
return [...new Set(all)].sort((a, b) => a - b);
}
// ---------------------------------------------------------------------------
// withBrowser — shared browser, new tab per request
// ---------------------------------------------------------------------------
async function withBrowser(task) {
const context = await getBrowserContext();
const page = await context.newPage();
try {
return await task(page);
} catch (error) {
console.error("[Crawler Error]", error);
throw error;
} finally {
await page.close();
}
}
module.exports = {
withBrowser,
closeBrowser,
randomDelay,
humanScroll,
navigateWithHumanBehavior,
extractPricesFromPage,
dismissCookieConsent,
saveDebugDump,
};

792
src/crawlers/google.js Normal file
View File

@@ -0,0 +1,792 @@
"use strict";
const { withBrowser, navigateWithHumanBehavior, extractPricesFromPage, saveDebugDump, randomDelay } = require("./baseCrawler");
// ---------------------------------------------------------------------------
// Known airline names for Korean-language Google Flights
// ---------------------------------------------------------------------------
const KNOWN_AIRLINES = [
"대한항공", "아시아나항공", "제주항공", "진에어", "티웨이항공",
"에어부산", "에어서울", "에어프레미아", "이스타항공",
"일본항공", "전일본공수", "중국국제항공", "중국남방항공", "중국동방항공",
"캐세이퍼시픽항공", "싱가포르항공", "타이항공", "베트남항공",
"에미레이트항공", "카타르항공", "터키항공", "에티하드항공",
"루프트한자", "에어프랑스", "KLM", "브리티시 에어웨이즈", "이베리아항공",
"핀에어", "스칸디나비아항공", "SAS항공", "LOT 폴란드항공",
"스위스국제항공", "오스트리아항공", "TAP 포르투갈",
"유나이티드항공", "델타항공", "아메리칸항공", "에어캐나다",
"콴타스", "뉴질랜드항공", "에어뉴질랜드",
"피치항공", "스쿠트", "에어아시아", "세부퍼시픽",
];
// ---------------------------------------------------------------------------
// Structured flight card extraction from Google Flights DOM
// ---------------------------------------------------------------------------
/**
* Extracts structured flight data from Google Flights result cards.
*
* Tries DOM selectors first, falls back to innerText regex parsing.
* Returns an array of structured flight objects or null if extraction fails.
*/
async function extractFlightCardsFromPage(page, { minPrice = 10000 } = {}) {
const results = await page.evaluate(({ knownAirlines, minP }) => {
// --- Helper: parse price from text ---
function parsePrice(text) {
const patterns = [/₩\s*([\d,]+)/, /([\d,]+)\s*원/];
for (const p of patterns) {
const m = text.match(p);
if (m) {
const num = parseInt(m[1].replace(/,/g, ""), 10);
if (!isNaN(num) && num >= minP) return num;
}
}
return null;
}
// --- Helper: parse duration string to minutes ---
function parseDurationMinutes(text) {
const m = text.match(/(\d+)\s*시간\s*(?:(\d+)\s*분)?/);
if (!m) return null;
return parseInt(m[1], 10) * 60 + (m[2] ? parseInt(m[2], 10) : 0);
}
// --- Helper: find all airlines in text ---
function findAirlines(text) {
const found = [];
for (const name of knownAirlines) {
// Find all occurrences with position
let idx = text.indexOf(name);
while (idx !== -1) {
found.push({ name, pos: idx });
idx = text.indexOf(name, idx + name.length);
}
}
// Fallback: generic pattern
if (found.length === 0) {
const re = /[\uAC00-\uD7A3A-Za-z]+(?:항공|에어(?:라인)?)/g;
let m;
while ((m = re.exec(text)) !== null) {
found.push({ name: m[0], pos: m.index });
}
}
// Deduplicate by position, sort by position
found.sort((a, b) => a.pos - b.pos);
return found;
}
// --- Helper: extract fields from card innerText (multi-leg) ---
function parseCardText(text) {
const result = {};
// Price
result.price = parsePrice(text);
// Find all time pairs: 오전/오후 HH:MM 오전/오후 HH:MM+N
const timePattern = /(오[전후])\s*(\d{1,2}:\d{2})\s*[\-~]\s*(오[전후])\s*(\d{1,2}:\d{2})(\+\d+)?/g;
const timePairs = [];
let tm;
while ((tm = timePattern.exec(text)) !== null) {
timePairs.push({
departureTime: `${tm[1]} ${tm[2]}`,
arrivalTime: `${tm[3]} ${tm[4]}${tm[5] || ""}`,
pos: tm.index,
});
}
// Find all durations
const durationPattern = /(\d+시간(?:\s*\d+분)?)/g;
const durations = [];
let dm;
while ((dm = durationPattern.exec(text)) !== null) {
durations.push({
duration: dm[1],
durationMinutes: parseDurationMinutes(dm[1]),
pos: dm.index,
});
}
// Find all stops markers
const stopsMarkers = [];
const directPattern = /직항/g;
let sm;
while ((sm = directPattern.exec(text)) !== null) {
stopsMarkers.push({ stops: 0, stopsText: "직항", pos: sm.index });
}
const stopsPattern = /경유\s*(\d+)\s*회/g;
while ((sm = stopsPattern.exec(text)) !== null) {
stopsMarkers.push({ stops: parseInt(sm[1], 10), stopsText: sm[0], pos: sm.index });
}
stopsMarkers.sort((a, b) => a.pos - b.pos);
// Find all routes: XXXYYY
const routePattern = /\b([A-Z]{3})\s*[\-]\s*([A-Z]{3})\b/g;
const routes = [];
let rm;
while ((rm = routePattern.exec(text)) !== null) {
routes.push({ route: `${rm[1]}${rm[2]}`, pos: rm.index });
}
// Find all layovers: "N시간 M분 XXX"
const layoverPattern = /(\d+시간(?:\s*\d+분)?)\s+([A-Z]{3})/g;
const allLayovers = [];
let lm;
while ((lm = layoverPattern.exec(text)) !== null) {
allLayovers.push({
duration: lm[1],
durationMinutes: parseDurationMinutes(lm[1]),
airportCode: lm[2],
pos: lm.index,
});
}
// Find airlines with positions
const airlines = findAirlines(text);
// Determine number of legs from time pairs (most reliable signal)
const legCount = Math.max(timePairs.length, routes.length, 1);
if (legCount <= 1) {
// Single leg — flat structure (backward compatible)
if (airlines.length > 0) result.airline = airlines[0].name;
if (timePairs.length > 0) {
result.departureTime = timePairs[0].departureTime;
result.arrivalTime = timePairs[0].arrivalTime;
}
if (durations.length > 0) {
result.duration = durations[0].duration;
result.durationMinutes = durations[0].durationMinutes;
}
if (stopsMarkers.length > 0) {
result.stops = stopsMarkers[0].stops;
result.stopsText = stopsMarkers[0].stopsText;
}
if (routes.length > 0) result.route = routes[0].route;
// Layovers: filter out main duration
const legLayovers = allLayovers.filter(l => !result.duration || l.duration !== result.duration);
if (legLayovers.length > 0) result.layovers = legLayovers;
return result;
}
// Multi-leg — build legs array
// Assign each extracted item to the nearest leg by position
function assignToLeg(items, legAnchors) {
const assigned = legAnchors.map(() => []);
for (const item of items) {
let bestLeg = 0;
let bestDist = Infinity;
for (let i = 0; i < legAnchors.length; i++) {
const dist = Math.abs(item.pos - legAnchors[i]);
if (dist < bestDist) { bestDist = dist; bestLeg = i; }
}
assigned[bestLeg].push(item);
}
return assigned;
}
// Use timePair positions as leg anchors; fall back to route positions
const legAnchors = timePairs.length >= legCount
? timePairs.map(t => t.pos)
: routes.map(r => r.pos);
const airlinesByLeg = assignToLeg(airlines, legAnchors);
const durationsByLeg = assignToLeg(durations, legAnchors);
const stopsByLeg = assignToLeg(stopsMarkers, legAnchors);
const routesByLeg = assignToLeg(routes, legAnchors);
const layoversByLeg = assignToLeg(allLayovers, legAnchors);
const legs = [];
for (let i = 0; i < legCount; i++) {
const leg = {};
if (airlinesByLeg[i] && airlinesByLeg[i].length > 0) leg.airline = airlinesByLeg[i][0].name;
if (timePairs[i]) {
leg.departureTime = timePairs[i].departureTime;
leg.arrivalTime = timePairs[i].arrivalTime;
}
if (durationsByLeg[i] && durationsByLeg[i].length > 0) {
leg.duration = durationsByLeg[i][0].duration;
leg.durationMinutes = durationsByLeg[i][0].durationMinutes;
}
if (stopsByLeg[i] && stopsByLeg[i].length > 0) {
leg.stops = stopsByLeg[i][0].stops;
leg.stopsText = stopsByLeg[i][0].stopsText;
}
if (routesByLeg[i] && routesByLeg[i].length > 0) leg.route = routesByLeg[i][0].route;
// Layovers for this leg (filter out main duration)
const legDuration = leg.duration;
const legLayovers = (layoversByLeg[i] || []).filter(l => !legDuration || l.duration !== legDuration);
if (legLayovers.length > 0) {
leg.layovers = legLayovers.map(l => ({ duration: l.duration, durationMinutes: l.durationMinutes, airportCode: l.airportCode }));
}
legs.push(leg);
}
result.legs = legs;
// Also set top-level fields from first leg for backward compatibility
const first = legs[0];
if (first) {
if (first.airline) result.airline = first.airline;
if (first.departureTime) result.departureTime = first.departureTime;
if (first.arrivalTime) result.arrivalTime = first.arrivalTime;
if (first.duration) result.duration = first.duration;
if (first.durationMinutes) result.durationMinutes = first.durationMinutes;
if (first.stops !== undefined) result.stops = first.stops;
if (first.stopsText) result.stopsText = first.stopsText;
if (first.route) result.route = first.route;
if (first.layovers) result.layovers = first.layovers;
}
return result;
}
// --- Strategy 1: Find flight card elements by known selectors ---
const cardSelectors = [
'li.pIav2d',
'li[class*="pIav2d"]',
'[role="listitem"][class*="pIav2d"]',
'ul[class*="Rk10dc"] > li',
'div[class*="yR1fYc"]',
];
let cards = [];
for (const sel of cardSelectors) {
const els = document.querySelectorAll(sel);
if (els.length > 0) {
cards = Array.from(els);
break;
}
}
// Strategy 2: broader selectors
if (cards.length === 0) {
const listItems = document.querySelectorAll('[role="listitem"]');
// Filter to items that contain a price
cards = Array.from(listItems).filter(el => {
const t = el.innerText || "";
return /₩\s*[\d,]+|[\d,]+\s*원/.test(t);
});
}
if (cards.length === 0) return null;
const results = [];
for (let i = 0; i < cards.length; i++) {
const text = cards[i].innerText || "";
const parsed = parseCardText(text);
if (!parsed.price) continue;
parsed.rank = i + 1;
results.push(parsed);
}
return results.length > 0 ? results : null;
}, { knownAirlines: KNOWN_AIRLINES, minP: minPrice });
return results;
}
// ---------------------------------------------------------------------------
// Minimal protobuf encoder (wire format only, no .proto schema needed)
// ---------------------------------------------------------------------------
function writeVarint(value) {
const bytes = [];
// Handle negative values: protobuf encodes them as 10-byte uint64
if (value < 0) {
const big = BigInt(value) & 0xFFFFFFFFFFFFFFFFn;
let v = big;
for (let i = 0; i < 9; i++) {
bytes.push(Number(v & 0x7Fn) | 0x80);
v >>= 7n;
}
bytes.push(Number(v & 0x7Fn));
return Buffer.from(bytes);
}
let v = value >>> 0;
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80);
v >>>= 7;
}
bytes.push(v);
return Buffer.from(bytes);
}
function writeField(fieldNumber, wireType) {
return writeVarint((fieldNumber << 3) | wireType);
}
function varintField(fieldNumber, value) {
return Buffer.concat([writeField(fieldNumber, 0), writeVarint(value)]);
}
function stringField(fieldNumber, str) {
const buf = Buffer.from(str, "utf8");
return Buffer.concat([writeField(fieldNumber, 2), writeVarint(buf.length), buf]);
}
function messageField(fieldNumber, messageBuf) {
return Buffer.concat([writeField(fieldNumber, 2), writeVarint(messageBuf.length), messageBuf]);
}
// ---------------------------------------------------------------------------
// Google Flights protobuf TFS builder
// ---------------------------------------------------------------------------
/**
* Protobuf schema (reverse-engineered from Google Flights URLs):
*
* TFS {
* field 1 (varint): 28 (constant)
* field 2 (varint): number of segments
* field 3 (message, repeated): Segment {
* field 2 (string): date "YYYY-MM-DD"
* field 5 (varint): maxStops (0=direct, 1=1stop, omit=unlimited)
* field 12 (varint): maxDurationMinutes
* field 13 (message): origin { field 1: 1, field 2: IATA }
* field 14 (message): dest { field 1: 1, field 2: IATA }
* }
* field 8 (varint): adults
* field 9 (varint): cabinClass (1=economy,2=premEco,3=business,4=first)
* field 14 (varint): 1 (constant)
* field 16 (message): { field 1: -1 } (no-limit sentinel)
* field 19 (varint): tripType (1=round_trip, 2=one_way, 3=multi_city)
* }
*/
const CABIN_CLASS_MAP = {
economy: 1,
premium_economy: 2,
business: 3,
first: 4,
};
const TRIP_TYPE_MAP = {
round_trip: 1,
one_way: 2,
multi_city: 3,
};
function buildAirportMsg(iata) {
return Buffer.concat([varintField(1, 1), stringField(2, iata.toUpperCase())]);
}
function buildSegmentMsg(date, from, to, opts = {}) {
const parts = [stringField(2, date)];
if (opts.maxStops !== undefined && opts.maxStops !== null) {
parts.push(varintField(5, opts.maxStops));
}
if (opts.maxDurationMinutes) {
parts.push(varintField(12, opts.maxDurationMinutes));
}
parts.push(messageField(13, buildAirportMsg(from)));
parts.push(messageField(14, buildAirportMsg(to)));
return Buffer.concat(parts);
}
function buildTfs(flightSegments, adults, cabinClassNum, tripType) {
const parts = [];
parts.push(varintField(1, 28));
parts.push(varintField(2, flightSegments.length));
for (const seg of flightSegments) {
parts.push(messageField(3, seg));
}
parts.push(varintField(8, adults));
parts.push(varintField(9, cabinClassNum));
parts.push(varintField(14, 1));
// field 16: { field 1: -1 } — no-limit sentinel
parts.push(messageField(16, varintField(1, -1)));
// field 19: trip type (1=round_trip, 2=one_way, 3=multi_city)
parts.push(varintField(19, TRIP_TYPE_MAP[tripType] || 1));
return Buffer.concat(parts);
}
function toUrlSafeBase64(buf) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// TFU is constant (default settings)
const TFU = "EgYIABAAGAA";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function getDStr(date) {
const d = new Date(date);
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
/**
* Builds a Google Flights URL using protobuf-encoded tfs parameter.
*
* Supports:
* - tripType: one_way / round_trip / multi_city
* - segments[].from / to
* - departureDateWindow.from
* - stayDurationDays.minDays (default 7 for round_trip)
* - passengers.total, passengers.byCabin
* - constraints.maxStops (0=direct, 1=1stop, 2=2stops, null=unlimited)
* - constraints.maxJourneyHours.hours → converted to minutes
*/
function buildGoogleUrl(searchParams) {
const segments = searchParams.segments || [];
if (segments.length === 0) return "https://www.google.com/travel/flights";
// open_jaw is treated as multi_city for URL building
const tripType = searchParams.tripType === "open_jaw" ? "multi_city" : searchParams.tripType;
const adults = searchParams.passengers?.total || 1;
const byCabin = searchParams.passengers?.byCabin || {};
let cabinKey = "economy";
if (byCabin.first > 0) cabinKey = "first";
else if (byCabin.business > 0) cabinKey = "business";
else if (byCabin.premium_economy > 0) cabinKey = "premium_economy";
const cabinClassNum = CABIN_CLASS_MAP[cabinKey];
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const maxStops = searchParams.constraints?.maxStops ?? null;
const maxDurationMinutes = searchParams.constraints?.maxJourneyHours?.hours
? searchParams.constraints.maxJourneyHours.hours * 60
: null;
const segOpts = {
maxStops: maxStops !== null && maxStops !== undefined ? maxStops : undefined,
maxDurationMinutes: maxDurationMinutes || undefined,
};
const flightSegments = [];
if (tripType === "round_trip") {
const from = segments[0].from;
const to = segments[0].to;
const departDate = searchParams.departureDateWindow?.from || tomorrow;
const d1 = getDStr(departDate);
const stayDays = searchParams.stayDurationDays?.minDays || 7;
const returnDate = new Date(departDate);
returnDate.setDate(returnDate.getDate() + stayDays);
const d2 = getDStr(returnDate);
flightSegments.push(buildSegmentMsg(d1, from, to, segOpts));
flightSegments.push(buildSegmentMsg(d2, to, from, segOpts));
} else if (tripType === "multi_city") {
const departDate = new Date(searchParams.departureDateWindow?.from || tomorrow);
const stayDays = searchParams.stayDurationDays?.minDays || 7;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const segDate = i === 0
? departDate
: new Date(departDate.getTime() + stayDays * 24 * 60 * 60 * 1000);
flightSegments.push(buildSegmentMsg(getDStr(segDate), seg.from, seg.to, segOpts));
}
} else {
// one_way
const from = segments[0].from;
const to = segments[0].to;
const d1 = searchParams.departureDateWindow?.from
? getDStr(searchParams.departureDateWindow.from)
: getDStr(tomorrow);
flightSegments.push(buildSegmentMsg(d1, from, to, segOpts));
}
const tfsBuf = buildTfs(flightSegments, adults, cabinClassNum, tripType);
const tfsEncoded = toUrlSafeBase64(tfsBuf);
return `https://www.google.com/travel/flights/search?tfs=${tfsEncoded}&tfu=${TFU}`;
}
// ---------------------------------------------------------------------------
// Google Flights error page detection & retry
// ---------------------------------------------------------------------------
const ERROR_INDICATORS = ["오류가 발생했습니다", "오류가 발생", "죄송합니다", "Something went wrong", "sorry"];
async function isGoogleErrorPage(page) {
return page.evaluate((indicators) => {
const text = document.body?.innerText || "";
return indicators.some((t) => text.includes(t));
}, ERROR_INDICATORS);
}
async function clickRefreshButton(page) {
// Try Korean "새로고침" button first, then English
const labels = ["새로고침", "Refresh", "Try again", "다시 시도"];
for (const label of labels) {
try {
const loc = page.locator("button", { hasText: label });
if (await loc.count() > 0) {
await loc.first().click();
console.log(`[Google Flights] Clicked "${label}" button`);
return true;
}
} catch (_) {}
}
// Fallback: look for any visible button-like element with those texts
for (const label of labels) {
try {
const loc = page.locator(`a, [role="button"]`, { hasText: label });
if (await loc.count() > 0) {
await loc.first().click();
console.log(`[Google Flights] Clicked "${label}" link/role-button`);
return true;
}
} catch (_) {}
}
return false;
}
async function waitForFlightResults(page, { timeout = 30000 } = {}) {
// Race all selectors concurrently — first one to appear wins
const selectors = [
'[class*="pIav2d"]', // flight result list
'[class*="YMlIz"]', // price element
'li[class*="result"]',
'[role="listitem"]',
'[class*="price"], [class*="Price"]',
];
try {
const result = await Promise.race([
...selectors.map(sel =>
page.waitForSelector(sel, { timeout }).then(() => sel)
),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)),
]);
console.log(`[Google Flights] Found elements matching: ${result}`);
return true;
} catch (_) {
return false;
}
}
// ---------------------------------------------------------------------------
// Click a specific flight card by 0-based index
// ---------------------------------------------------------------------------
async function clickFlightCard(page, cardIndex) {
const selectors = [
'li.pIav2d',
'li[class*="pIav2d"]',
'[role="listitem"][class*="pIav2d"]',
'ul[class*="Rk10dc"] > li',
];
for (const sel of selectors) {
try {
const loc = page.locator(sel);
const count = await loc.count();
if (count > cardIndex) {
await loc.nth(cardIndex).click();
console.log(`[Google Flights] Clicked card #${cardIndex + 1} via Playwright locator (${sel})`);
return true;
}
} catch (_) {}
}
// Fallback: role=listitem with price text
try {
const loc = page.locator('[role="listitem"]').filter({ hasText: /₩/ });
const count = await loc.count();
if (count > cardIndex) {
await loc.nth(cardIndex).click();
console.log(`[Google Flights] Clicked card #${cardIndex + 1} via fallback locator`);
return true;
}
} catch (_) {}
console.log(`[Google Flights] Failed to click card #${cardIndex + 1}`);
return false;
}
function buildLegFromCard(card) {
const leg = {};
if (card.airline) leg.airline = card.airline;
if (card.departureTime) leg.departureTime = card.departureTime;
if (card.arrivalTime) leg.arrivalTime = card.arrivalTime;
if (card.duration) leg.duration = card.duration;
if (card.durationMinutes) leg.durationMinutes = card.durationMinutes;
if (card.route) leg.route = card.route;
if (card.stops !== undefined) leg.stops = card.stops;
if (card.stopsText) leg.stopsText = card.stopsText;
if (card.layovers) leg.layovers = card.layovers;
return leg;
}
const MAX_RETRIES = 3;
async function navigateAndWaitForResults(page, url) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
// Wait for flight result selectors instead of networkidle
// (Google Flights SPA never reaches networkidle due to background requests)
await waitForFlightResults(page);
// Give the page extra time for JS rendering
await page.waitForTimeout(randomDelay(2000, 4000));
}
async function scrapeGoogle(searchParams) {
const url = buildGoogleUrl(searchParams);
console.log(`[Google Flights] Navigating to ${url}`);
return withBrowser(async (page) => {
// Initial navigation — skip networkidle (Google Flights never settles)
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(randomDelay(1500, 3000));
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
// Check for Google Flights error page
if (await isGoogleErrorPage(page)) {
console.log(`[Google Flights] Error page detected (attempt ${attempt}/${MAX_RETRIES})`);
if (attempt >= MAX_RETRIES) {
console.log("[Google Flights] Max retries reached, giving up.");
await saveDebugDump(page, "google-error-final");
return [];
}
if (attempt === 1) {
// Strategy 1: Click "새로고침" refresh button
const clicked = await clickRefreshButton(page);
if (clicked) {
await waitForFlightResults(page);
await page.waitForTimeout(randomDelay(2000, 3000));
continue;
}
// If no refresh button, fall through to strategy 2
console.log("[Google Flights] No refresh button, trying page reload...");
await page.reload({ waitUntil: "domcontentloaded", timeout: 60000 });
await waitForFlightResults(page);
await page.waitForTimeout(randomDelay(2000, 3000));
continue;
}
// Strategy 2: Navigate via Google Flights homepage first (warm-up)
console.log("[Google Flights] Trying homepage warm-up approach...");
await page.goto("https://www.google.com/travel/flights", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
await page.waitForTimeout(randomDelay(2000, 4000));
// Now navigate to the actual search URL
await navigateAndWaitForResults(page, url);
continue;
}
// No error page — try to find flight results
const found = await waitForFlightResults(page);
if (found) break;
// No results and no error — wait a bit more
console.log("[Google Flights] No result selectors found, waiting extra time...");
await page.waitForTimeout(8000);
// Check again if error page appeared during wait
if (await isGoogleErrorPage(page)) continue;
break;
}
// Extra scroll to load more results
for (let i = 0; i < 2; i++) {
await page.mouse.wheel(0, 600);
await page.waitForTimeout(1500);
}
// Try structured flight card extraction first (outbound / first segment)
const outboundCards = await extractFlightCardsFromPage(page, { minPrice: 10000 });
if (outboundCards && outboundCards.length > 0) {
console.log(`[Google Flights] Extracted ${outboundCards.length} outbound flight cards`);
const sortedOutbound = [...outboundCards].sort((a, b) => a.price - b.price);
// For round_trip / multi_city: click cheapest outbound to reveal return flights
let returnLeg = null;
const tripType = searchParams.tripType || "round_trip";
if (tripType !== "one_way") {
const cheapest = sortedOutbound[0];
console.log(`[Google Flights] Clicking outbound #${cheapest.rank} (₩${cheapest.price.toLocaleString()}) to see return flights...`);
const clicked = await clickFlightCard(page, cheapest.rank - 1);
if (clicked) {
// Wait for page transition and return flight cards to load
await page.waitForTimeout(randomDelay(2000, 3000));
await waitForFlightResults(page, { timeout: 20000 });
await page.waitForTimeout(randomDelay(1000, 2000));
// Scroll to load more return results
await page.mouse.wheel(0, 400);
await page.waitForTimeout(1000);
const returnCards = await extractFlightCardsFromPage(page, { minPrice: 10000 });
if (returnCards && returnCards.length > 0) {
const sortedReturn = [...returnCards].sort((a, b) => a.price - b.price);
returnLeg = buildLegFromCard(sortedReturn[0]);
console.log(`[Google Flights] Extracted ${returnCards.length} return flight cards (best: ₩${sortedReturn[0].price.toLocaleString()} ${returnLeg.airline || ""} ${returnLeg.route || ""})`);
} else {
console.log("[Google Flights] No return flight cards found after selecting outbound");
}
}
}
return sortedOutbound.map((card) => {
const outboundLeg = buildLegFromCard(card);
const legs = [outboundLeg];
if (returnLeg) legs.push(returnLeg);
return {
provider: "google",
price: card.price,
currency: "KRW",
metadata: {
url,
rank: card.rank,
legs: legs.length > 1 ? legs : undefined,
// Top-level fields from first leg for backward compatibility
airline: card.airline || null,
departureTime: card.departureTime || null,
arrivalTime: card.arrivalTime || null,
duration: card.duration || null,
durationMinutes: card.durationMinutes || null,
route: card.route || null,
stops: card.stops !== undefined ? card.stops : null,
stopsText: card.stopsText || null,
layovers: card.layovers || null,
flightNumber: null,
},
};
});
}
// Fallback: extract prices only (existing method)
console.log("[Google Flights] Structured extraction failed, falling back to price-only extraction");
const prices = await extractPricesFromPage(page, { minPrice: 10000 });
if (prices.length === 0) {
console.log("[Google Flights] No prices found on page.");
await saveDebugDump(page, "google");
return [];
}
console.log(`[Google Flights] Found ${prices.length} prices: ${prices.slice(0, 5).join(", ")}...`);
return prices.map((price, i) => ({
provider: "google",
price,
currency: "KRW",
metadata: { url, rank: i + 1 },
}));
});
}
module.exports = { scrapeGoogle, buildGoogleUrl };

117
src/crawlers/naver.js Normal file
View File

@@ -0,0 +1,117 @@
"use strict";
const { withBrowser, navigateWithHumanBehavior, extractPricesFromPage } = require("./baseCrawler");
/**
* Formats a date as YYYYMMDD for Naver Flights URLs.
*/
function getDStr(date) {
const d = new Date(date);
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}${mm}${dd}`;
}
/**
* Maps internal cabin class to Naver fareType code.
* economy → Y, premium_economy → P, business → C, first → F
*/
function getFareType(byCabin) {
if (byCabin.first > 0) return "F";
if (byCabin.business > 0) return "C";
if (byCabin.premium_economy > 0) return "P";
return "Y";
}
/**
* Builds a Naver Flights URL from structured search params.
*
* Round-trip format:
* /flights/international/ICN-NRT-20260315/NRT-ICN-20260322?adult=1&fareType=Y
*
* Multi-city format:
* /flights/multi?itinerary=ICN:NRT:20260315,NRT:ICN:20260322&adult=1&fareType=Y
*
* One-way format:
* /flights/international/ICN-NRT-20260315?adult=1&fareType=Y
*/
function buildNaverUrl(searchParams) {
const segments = searchParams.segments || [];
if (segments.length === 0) return "https://flight.naver.com";
const tripType = searchParams.tripType;
const adults = searchParams.passengers?.total || 1;
const byCabin = searchParams.passengers?.byCabin || {};
const fareType = getFareType(byCabin);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (tripType === "round_trip") {
const from = segments[0].from.toUpperCase();
const to = segments[0].to.toUpperCase();
const departDate = searchParams.departureDateWindow?.from || tomorrow;
const d1 = getDStr(departDate);
// Return date: use stayDurationDays if available, default to 7 days
const stayDays = searchParams.stayDurationDays?.minDays || 7;
const returnDate = new Date(departDate);
returnDate.setDate(returnDate.getDate() + stayDays);
const d2 = getDStr(returnDate);
// Naver round-trip: outbound segment / return segment (origin/dest reversed)
return `https://flight.naver.com/flights/international/${from}-${to}-${d1}/${to}-${from}-${d2}?adult=${adults}&fareType=${fareType}`;
}
if (tripType === "multi_city") {
// Naver multi-city: /flights/multi?itinerary=ICN:NRT:20260315,NRT:ICN:20260322
let currentBaseDate = new Date(searchParams.departureDateWindow?.from || tomorrow);
const itineraryParts = [];
for (const seg of segments) {
itineraryParts.push(`${seg.from.toUpperCase()}:${seg.to.toUpperCase()}:${getDStr(currentBaseDate)}`);
currentBaseDate.setDate(currentBaseDate.getDate() + 3);
}
return `https://flight.naver.com/flights/multi?itinerary=${itineraryParts.join(",")}&adult=${adults}&fareType=${fareType}`;
}
// one_way
const from = segments[0].from.toUpperCase();
const to = segments[0].to.toUpperCase();
const d1 = searchParams.departureDateWindow?.from ? getDStr(searchParams.departureDateWindow.from) : getDStr(tomorrow);
return `https://flight.naver.com/flights/international/${from}-${to}-${d1}?adult=${adults}&fareType=${fareType}`;
}
async function scrapeNaver(searchParams) {
const url = buildNaverUrl(searchParams);
console.log(`[Naver] Navigating to ${url}`);
return withBrowser(async (page) => {
await navigateWithHumanBehavior(page, url);
// Wait for price elements with generic selectors
try {
await page.waitForSelector('[class*="price"], [class*="Price"], [class*="fare"]', { timeout: 15000 });
} catch (e) {
console.log("[Naver] Timeout waiting for price elements.");
}
// Extract prices using generic Korean won patterns
const prices = await extractPricesFromPage(page);
if (prices.length === 0) {
console.log("[Naver] No prices found on page.");
return [];
}
return prices.map((price, i) => ({
provider: "naver",
price,
currency: "KRW",
metadata: { url, rank: i + 1 },
}));
});
}
module.exports = { scrapeNaver, buildNaverUrl };

185
src/crawlers/skyscanner.js Normal file
View File

@@ -0,0 +1,185 @@
"use strict";
const { withBrowser, navigateWithHumanBehavior, extractPricesFromPage, saveDebugDump } = require("./baseCrawler");
/**
* Formats a date as YYYY-MM-DD for Skyscanner URLs.
*/
function getDateStr(date) {
const d = new Date(date);
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
/**
* Maps internal cabin class names to Skyscanner's cabinclass param.
*/
function getCabinClass(byCabin) {
if (byCabin.first > 0) return "first";
if (byCabin.business > 0) return "business";
if (byCabin.premium_economy > 0) return "premiumeconomy";
return "economy";
}
/**
* Maps maxStops to Skyscanner's stops parameter.
*
* Skyscanner uses negation-based filtering:
* stops=!oneStop,!twoPlusStops → direct only (maxStops=0)
* stops=!twoPlusStops → direct + 1 stop (maxStops=1)
* (omit) → all flights (no limit)
*/
function getStopsParam(maxStops) {
if (maxStops === 0) return "!oneStop,!twoPlusStops";
if (maxStops === 1) return "!twoPlusStops";
return null;
}
/**
* Builds a Skyscanner URL from structured search params.
*
* Uses /transport/d/ path format with YYYY-MM-DD dates:
* One-way: /transport/d/icn/2026-03-15/nrt/
* Round-trip: /transport/d/icn/2026-03-15/nrt/nrt/2026-03-22/icn/
* Multi-city: /transport/d/icn/2026-11-26/mad/bcn/2026-12-15/icn/
*
* Supports:
* - constraints.maxStops (0=direct, 1=1stop max, null=all)
* - constraints.maxJourneyHours.hours → duration (total minutes across all legs)
*/
function buildSkyscannerUrl(searchParams) {
const segments = searchParams.segments || [];
if (segments.length === 0) return "https://www.skyscanner.co.kr";
// open_jaw is treated as multi_city for URL building
const tripType = searchParams.tripType === "open_jaw" ? "multi_city" : searchParams.tripType;
const adults = searchParams.passengers?.total || 1;
const byCabin = searchParams.passengers?.byCabin || {};
const cabinClass = getCabinClass(byCabin);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Build path segments: /origin/date/dest repeated for each leg
const pathParts = [];
if (tripType === "round_trip") {
const from = segments[0].from.toLowerCase();
const to = segments[0].to.toLowerCase();
const departDate = searchParams.departureDateWindow?.from || tomorrow;
const d1 = getDateStr(departDate);
const stayDays = searchParams.stayDurationDays?.minDays || 7;
const returnDate = new Date(departDate);
returnDate.setDate(returnDate.getDate() + stayDays);
const d2 = getDateStr(returnDate);
// Outbound: from/date/to, Return: to/date/from
pathParts.push(from, d1, to, to, d2, from);
} else if (tripType === "multi_city") {
const departDate = new Date(searchParams.departureDateWindow?.from || tomorrow);
const stayDays = searchParams.stayDurationDays?.minDays || 7;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const segDate = i === 0
? departDate
: new Date(departDate.getTime() + stayDays * 24 * 60 * 60 * 1000);
pathParts.push(seg.from.toLowerCase(), getDateStr(segDate), seg.to.toLowerCase());
}
} else {
// one_way
const from = segments[0].from.toLowerCase();
const to = segments[0].to.toLowerCase();
const d1 = searchParams.departureDateWindow?.from
? getDateStr(searchParams.departureDateWindow.from)
: getDateStr(tomorrow);
pathParts.push(from, d1, to);
}
const path = `/transport/d/${pathParts.join("/")}/`;
// Query params
const params = new URLSearchParams();
params.set("adultsv2", String(adults));
params.set("cabinclass", cabinClass);
params.set("childrenv2", "");
if (searchParams.constraints?.maxJourneyHours?.hours) {
params.set("duration", String(searchParams.constraints.maxJourneyHours.hours * 60));
}
const maxStops = searchParams.constraints?.maxStops;
if (maxStops !== undefined && maxStops !== null) {
const stopsVal = getStopsParam(maxStops);
if (stopsVal) {
params.set("stops", stopsVal);
}
}
return `https://www.skyscanner.co.kr${path}?${params.toString()}`;
}
async function scrapeSkyscanner(searchParams) {
const url = buildSkyscannerUrl(searchParams);
console.log(`[Skyscanner] Navigating to ${url}`);
return withBrowser(async (page) => {
await navigateWithHumanBehavior(page, url);
// Skyscanner-specific selectors for flight result cards and prices
const selectors = [
'[class*="FlightsResults"]',
'[class*="ResultsSummary"]',
'[class*="price"], [class*="Price"]',
'[class*="fqs-price"]',
'[class*="BpkText"]',
'a[href*="booking"]',
];
// Wait for any of the flight result selectors (longer timeout for SPA)
let found = false;
for (const sel of selectors) {
try {
await page.waitForSelector(sel, { timeout: 10000 });
console.log(`[Skyscanner] Found elements matching: ${sel}`);
found = true;
break;
} catch (_) {
// try next selector
}
}
if (!found) {
console.log("[Skyscanner] No result selectors found, waiting extra time...");
await page.waitForTimeout(10000);
}
// Extra scroll to load more results
for (let i = 0; i < 2; i++) {
await page.mouse.wheel(0, 600);
await page.waitForTimeout(1500);
}
// Extract prices using generic Korean won patterns
const prices = await extractPricesFromPage(page);
if (prices.length === 0) {
console.log("[Skyscanner] No prices found on page.");
await saveDebugDump(page, "skyscanner");
return [];
}
console.log(`[Skyscanner] Found ${prices.length} prices: ${prices.slice(0, 5).join(", ")}...`);
return prices.map((price, i) => ({
provider: "skyscanner",
price,
currency: "KRW",
metadata: { url, rank: i + 1 },
}));
});
}
module.exports = { scrapeSkyscanner, buildSkyscannerUrl };

View File

@@ -64,6 +64,28 @@ body {
align-items: center; align-items: center;
} }
.topbar-actions {
display: grid;
gap: 8px;
justify-items: end;
}
.session-actions {
display: flex;
align-items: center;
gap: 8px;
}
.topbar-link {
color: var(--accent);
text-decoration: none;
font-size: 0.86rem;
}
.topbar-link:hover {
text-decoration: underline;
}
.topbar h1 { .topbar h1 {
margin: 3px 0 0; margin: 3px 0 0;
font-size: clamp(1.4rem, 1.7vw, 1.9rem); font-size: clamp(1.4rem, 1.7vw, 1.9rem);
@@ -127,7 +149,7 @@ input[type="number"]:focus {
margin-top: 12px; margin-top: 12px;
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
} }
.switch-field { .switch-field {
@@ -169,6 +191,11 @@ input[type="number"]:focus {
transition: transform 0.16s ease, opacity 0.16s ease; transition: transform 0.16s ease, opacity 0.16s ease;
} }
.btn.small {
padding: 7px 10px;
font-size: 0.82rem;
}
.btn:hover { .btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -208,6 +235,10 @@ input[type="number"]:focus {
color: var(--ink-sub); color: var(--ink-sub);
} }
.hidden {
display: none !important;
}
.summary strong { .summary strong {
color: var(--accent); color: var(--accent);
} }
@@ -295,6 +326,60 @@ input[type="number"]:focus {
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
} }
.sub-offers {
margin-top: 12px;
padding: 10px;
background: rgba(0, 0, 0, 0.15);
border-radius: 8px;
border-left: 3px solid var(--accent);
display: grid;
gap: 8px;
}
.sub-offer-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.88rem;
}
.sub-offer-cabin {
font-weight: 600;
color: var(--ink-sub);
}
.flight-details {
margin-top: 8px;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.15);
border-radius: 8px;
border-left: 3px solid var(--accent);
display: grid;
gap: 4px;
}
.flight-airline {
font-weight: 700;
color: var(--accent);
font-size: 0.88rem;
}
.flight-times {
color: var(--ink-main);
font-size: 0.86rem;
}
.flight-stops {
color: var(--ink-sub);
font-size: 0.82rem;
}
.flight-leg-divider {
border: none;
border-top: 1px solid var(--line);
margin: 6px 0;
}
.item-actions { .item-actions {
margin-top: 10px; margin-top: 10px;
display: flex; display: flex;
@@ -321,6 +406,11 @@ input[type="number"]:focus {
color: #ffb4b4; color: #ffb4b4;
} }
.badge.warn {
background: rgba(255, 183, 77, 0.2);
color: #ffd08a;
}
.event-head { .event-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -339,6 +429,20 @@ input[type="number"]:focus {
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.topbar-actions {
width: 100%;
justify-items: start;
}
.session-actions {
flex-wrap: wrap;
}
.controls-grid { .controls-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -3,8 +3,13 @@
const elements = { const elements = {
configBanner: document.getElementById("configBanner"), configBanner: document.getElementById("configBanner"),
systemPanel: document.getElementById("systemPanel"),
setupLink: document.getElementById("setupLink"),
logoutBtn: document.getElementById("logoutBtn"),
queryInput: document.getElementById("queryInput"), queryInput: document.getElementById("queryInput"),
useLlm: document.getElementById("useLlm"), useLlm: document.getElementById("useLlm"),
provider: document.getElementById("provider"),
sameFlight: document.getElementById("sameFlight"),
alertOn: document.getElementById("alertOn"), alertOn: document.getElementById("alertOn"),
targetPrice: document.getElementById("targetPrice"), targetPrice: document.getElementById("targetPrice"),
parseBtn: document.getElementById("parseBtn"), parseBtn: document.getElementById("parseBtn"),
@@ -21,17 +26,118 @@
parsed: null, parsed: null,
watches: [], watches: [],
events: [], events: [],
user: null,
apiToken: "",
auth: {
accountAuthEnabled: false,
tokenAuthEnabled: false,
},
controls: { controls: {
crawlingEnabled: true, crawlingEnabled: true,
alertsEnabled: true, alertsEnabled: true,
}, },
}; };
function readStoredApiToken() {
try {
const token = sessionStorage.getItem("dashboard_api_token");
return typeof token === "string" ? token.trim() : "";
} catch (_error) {
return "";
}
}
function writeStoredApiToken(token) {
try {
if (!token) {
sessionStorage.removeItem("dashboard_api_token");
return;
}
sessionStorage.setItem("dashboard_api_token", token);
} catch (_error) {
// Ignore storage failures.
}
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatPrice(price, currency) { function formatPrice(price, currency) {
if (!Number.isFinite(Number(price))) return "N/A"; if (!Number.isFinite(Number(price))) return "N/A";
return `${new Intl.NumberFormat("ko-KR").format(Number(price))} ${currency || ""}`.trim(); return `${new Intl.NumberFormat("ko-KR").format(Number(price))} ${currency || ""}`.trim();
} }
function renderLeg(leg, label) {
const lines = [];
// airline + route
const airlineParts = [];
if (label) airlineParts.push(`<strong>${escapeHtml(label)}</strong>`);
if (leg.airline) airlineParts.push(escapeHtml(leg.airline));
if (leg.route) airlineParts.push(escapeHtml(leg.route));
if (airlineParts.length > 0) {
lines.push(`<div class="flight-airline">${airlineParts.join(" ")}</div>`);
}
// times + duration
const timeParts = [];
if (leg.departureTime && leg.arrivalTime) {
timeParts.push(`${escapeHtml(leg.departureTime)}${escapeHtml(leg.arrivalTime)}`);
}
if (leg.duration) {
timeParts.push(escapeHtml(leg.duration));
}
if (timeParts.length > 0) {
lines.push(`<div class="flight-times">${timeParts.join(" · ")}</div>`);
}
// stops + layover info
if (leg.stopsText || leg.stops === 0) {
let stopsStr = escapeHtml(leg.stopsText || "직항");
if (Array.isArray(leg.layovers) && leg.layovers.length > 0) {
const layoverStr = leg.layovers
.map(l => `${escapeHtml(l.duration || "")} ${escapeHtml(l.airportCode || "")}`.trim())
.join(", ");
stopsStr += ` (${layoverStr})`;
}
lines.push(`<div class="flight-stops">${stopsStr}</div>`);
}
return lines.join("");
}
function renderFlightDetails(metadata) {
if (!metadata) return "";
// Multi-leg: render each leg separately
if (Array.isArray(metadata.legs) && metadata.legs.length > 0) {
const legLabels = ["가는편", "오는편", "구간3", "구간4"];
const legsHtml = metadata.legs
.map((leg, i) => renderLeg(leg, metadata.legs.length > 1 ? legLabels[i] || `구간${i + 1}` : null))
.join('<hr class="flight-leg-divider">');
return `<div class="flight-details">${legsHtml}</div>`;
}
// Single leg: check if structured data is available
const hasStructured = metadata.airline || metadata.departureTime || metadata.duration || (metadata.stops !== undefined && metadata.stops !== null);
if (!hasStructured) {
if (metadata.flightNumber) {
return `<div class="flight-details"><span class="flight-airline">[${escapeHtml(metadata.flightNumber)}] ${escapeHtml(metadata.departureTime || "")}</span></div>`;
}
return "";
}
const html = renderLeg(metadata, null);
return html ? `<div class="flight-details">${html}</div>` : "";
}
function formatDate(value) { function formatDate(value) {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
@@ -47,12 +153,41 @@
}); });
} }
async function api(path, options) { async function api(path, options, retryOnUnauthorized = true) {
const headers = {
"content-type": "application/json",
...(options && options.headers ? options.headers : {}),
};
if (state.apiToken) {
headers.authorization = `Bearer ${state.apiToken}`;
}
const response = await fetch(path, { const response = await fetch(path, {
headers: { "content-type": "application/json" }, headers,
credentials: "same-origin",
...options, ...options,
}); });
if (response.status === 401) {
if (state.auth.accountAuthEnabled) {
window.location.href = "/login";
throw new Error("로그인이 필요합니다.");
}
if (retryOnUnauthorized && state.auth.tokenAuthEnabled) {
const prompted = prompt("API token을 입력하세요.");
const token = typeof prompted === "string" ? prompted.trim() : "";
if (token) {
state.apiToken = token;
writeStoredApiToken(token);
return api(path, options, false);
}
}
throw new Error("Unauthorized");
}
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(payload.error || `요청 실패 (${response.status})`); throw new Error(payload.error || `요청 실패 (${response.status})`);
@@ -100,13 +235,15 @@
elements.parseSummary.classList.remove("empty"); elements.parseSummary.classList.remove("empty");
elements.parseSummary.innerHTML = [ elements.parseSummary.innerHTML = [
`<div><strong>파서:</strong> ${parsedPayload.source}</div>`, `<div><strong>파서:</strong> ${escapeHtml(parsedPayload.source || "unknown")}</div>`,
`<div><strong>구간:</strong> ${segmentText || "미입력"}</div>`, `<div><strong>구간:</strong> ${escapeHtml(segmentText || "미입력")}</div>`,
`<div><strong>출발 윈도우:</strong> ${windowText}</div>`, `<div><strong>출발 윈도우:</strong> ${escapeHtml(windowText)}</div>`,
`<div><strong>체류 기간:</strong> ${stayText}</div>`, `<div><strong>체류 기간:</strong> ${escapeHtml(stayText)}</div>`,
`<div><strong>탑승객:</strong> ${paxText}</div>`, `<div><strong>탑승객:</strong> ${escapeHtml(paxText)}</div>`,
`<div><strong>최대 여정시간:</strong> ${journeyText}</div>`, `<div><strong>최대 여정시간:</strong> ${escapeHtml(journeyText)}</div>`,
`<div><strong>누락 필드:</strong> ${missing.length > 0 ? missing.join(", ") : "없음"}</div>`, `<div><strong>누락 필드:</strong> ${escapeHtml(
missing.length > 0 ? missing.join(", ") : "없음"
)}</div>`,
].join(""); ].join("");
} }
@@ -144,23 +281,64 @@
const alertOn = watchAlertOn(watch); const alertOn = watchAlertOn(watch);
const targetPrice = watch.alertRules ? watch.alertRules.targetPrice : null; const targetPrice = watch.alertRules ? watch.alertRules.targetPrice : null;
const bestOfferData = watch.lastSnapshot && watch.lastSnapshot.bestOffer ? watch.lastSnapshot.bestOffer : null;
const metadata = bestOfferData && bestOfferData.metadata ? bestOfferData.metadata : {};
const flightDetailsHtml = renderFlightDetails(metadata);
const urlStr = metadata.url ? `<a href="${escapeHtml(metadata.url)}" target="_blank" style="color:var(--accent); text-decoration:none; margin-left: 8px;">🔗 예매하기</a>` : "";
const subOffers = watch.lastSnapshot && watch.lastSnapshot.bestOffer && Array.isArray(watch.lastSnapshot.bestOffer.subOffers)
? watch.lastSnapshot.bestOffer.subOffers
: [];
const sameFlightMode = watch.searchParams && watch.searchParams.constraints && watch.searchParams.constraints.sameFlightForAllPassengers !== undefined
? watch.searchParams.constraints.sameFlightForAllPassengers
: true;
let subOffersHtml = "";
if (subOffers.length > 0) {
subOffersHtml = `
<div class="sub-offers">
${subOffers.map(sub => {
const subMeta = sub.metadata || {};
const subUrl = subMeta.url || sub.url;
// 동일 항공편 모드면 상단에 이미 표시되므로 sub-offer에 중복 표시 안 함
const subFlightDetails = sameFlightMode ? "" : renderFlightDetails(subMeta);
return `
<div class="sub-offer-item">
<span class="sub-offer-cabin">${escapeHtml(sub.cabin)} (${sub.paxCount})</span>
<span>${escapeHtml(formatPrice(sub.price, currency))} ${subUrl ? `<a href="${escapeHtml(subUrl)}" target="_blank" style="color:var(--accent); text-decoration:none;">🔗</a>` : ''}</span>
</div>
${subFlightDetails}`;
}).join('')}
</div>
`;
}
return ` return `
<article class="watch-item" data-watch-id="${watch.id}"> <article class="watch-item" data-watch-id="${escapeHtml(watch.id)}">
<header> <header>
<div> <div>
<h3 class="watch-title">${watch.rawInput || "(no input)"}</h3> <h3 class="watch-title">${escapeHtml(watch.rawInput || "(no input)")}</h3>
<p class="watch-sub">watchId: <code>${watch.id}</code></p> <p class="watch-sub">
watchId: <code>${escapeHtml(watch.id)}</code>
<span class="badge ${sameFlightMode ? 'ok' : 'warn'}" style="margin-left:8px;">${sameFlightMode ? '동일 항공편' : '개별 최저가'}</span>
</p>
${flightDetailsHtml ? `${flightDetailsHtml}${urlStr ? `<p class="watch-sub" style="margin-top: 4px;">${urlStr}</p>` : ""}` : (urlStr ? `<p class="watch-sub" style="margin-top: 4px;">${urlStr}</p>` : '')}
</div> </div>
<div class="price">${formatPrice(bestPrice, currency)}</div> <div class="price">${escapeHtml(formatPrice(bestPrice, currency))}</div>
</header> </header>
<div class="meta-grid"> <div class="meta-grid">
<div>provider: <code>${provider}</code></div> <div>provider: <code>${escapeHtml(provider)}</code></div>
<div>마지막 갱신: ${formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)}</div> <div>마지막 갱신: ${escapeHtml(
<div>alert mode: <code>${alertOn}</code></div> formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)
<div>target: ${targetPrice ? formatPrice(targetPrice, currency) : "-"}</div> )}</div>
<div>alert mode: <code>${escapeHtml(alertOn)}</code></div>
<div>target: ${escapeHtml(targetPrice ? formatPrice(targetPrice, currency) : "-")}</div>
</div> </div>
${subOffersHtml}
<div class="item-actions"> <div class="item-actions">
<label class="switch-field"> <label class="switch-field">
<input data-action="toggle-polling" type="checkbox" ${watch.pollingEnabled ? "checked" : ""} /> <input data-action="toggle-polling" type="checkbox" ${watch.pollingEnabled ? "checked" : ""} />
@@ -170,11 +348,14 @@
<input data-action="toggle-alerts" type="checkbox" ${watch.alertsEnabled ? "checked" : ""} /> <input data-action="toggle-alerts" type="checkbox" ${watch.alertsEnabled ? "checked" : ""} />
<span>알림</span> <span>알림</span>
</label> </label>
<button class="btn secondary" data-action="poll">즉시 조회</button>
<button class="btn danger" data-action="delete">삭제</button> <button class="btn danger" data-action="delete">삭제</button>
</div> </div>
${watch.lastError ? `<p class="watch-sub">오류: ${watch.lastError.message || "unknown"}</p>` : ""} ${
watch.lastError
? `<p class="watch-sub">오류: ${escapeHtml(watch.lastError.message || "unknown")}</p>`
: ""
}
</article> </article>
`; `;
}) })
@@ -192,23 +373,45 @@
elements.eventList.innerHTML = state.events elements.eventList.innerHTML = state.events
.map((event) => { .map((event) => {
const payload = event.payload || {}; const payload = event.payload || {};
const sent = payload.notificationSent === true; const notificationState =
const badgeClass = sent ? "ok" : "off"; typeof payload.notificationState === "string"
const badgeLabel = sent ? "알림 발송" : "알림 억제"; ? payload.notificationState
: payload.notificationSent === true
? "sent"
: payload.notificationSuppressed === true
? "suppressed"
: payload.notificationError
? "failed"
: "suppressed";
let badgeClass = "off";
let badgeLabel = "알림 억제";
if (notificationState === "sent") {
badgeClass = "ok";
badgeLabel = "알림 발송";
} else if (notificationState === "failed") {
badgeClass = "warn";
badgeLabel = "알림 실패";
}
return ` return `
<article class="event-item"> <article class="event-item">
<div class="event-head"> <div class="event-head">
<strong>${payload.eventType || event.eventType || "event"}</strong> <strong>${escapeHtml(payload.eventType || event.eventType || "event")}</strong>
<span class="badge ${badgeClass}">${badgeLabel}</span> <span class="badge ${badgeClass}">${badgeLabel}</span>
</div> </div>
<p> <p>
watchId: <code>${event.watchId}</code><br /> watchId: <code>${escapeHtml(event.watchId)}</code><br />
가격: ${formatPrice(payload.currentBestPrice, payload.currency)} 가격: ${escapeHtml(formatPrice(payload.currentBestPrice, payload.currency))}
${Number.isFinite(Number(payload.previousBestPrice)) ${Number.isFinite(Number(payload.previousBestPrice))
? ` (이전 ${formatPrice(payload.previousBestPrice, payload.currency)})` ? ` (이전 ${escapeHtml(formatPrice(payload.previousBestPrice, payload.currency))})`
: ""}<br /> : ""}<br />
시각: ${formatDate(event.observedAt)} 시각: ${escapeHtml(formatDate(event.observedAt))}
${
notificationState === "failed" && payload.notificationError?.message
? `<br />오류: ${escapeHtml(payload.notificationError.message)}`
: ""
}
</p> </p>
</article> </article>
`; `;
@@ -223,7 +426,51 @@
function setConfigBanner(config) { function setConfigBanner(config) {
const warning = config && config.dbWarning ? ` | warning: ${config.dbWarning}` : ""; const warning = config && config.dbWarning ? ` | warning: ${config.dbWarning}` : "";
elements.configBanner.textContent = `DB: ${config.dbEngine} | poll: ${config.pollIntervalSec}s${warning}`; const auth = config && config.authEnabled ? "on" : "off";
const username = state.user && state.user.username ? state.user.username : "-";
elements.configBanner.textContent =
`DB: ${config.dbEngine} | poll: ${config.pollIntervalSec}s | auth: ${auth} | user: ${username}${warning}`;
}
function renderSessionUi() {
const isAdmin = !!(state.user && state.user.isAdmin);
if (elements.systemPanel) {
elements.systemPanel.classList.toggle("hidden", !isAdmin);
}
if (elements.setupLink) {
elements.setupLink.href = "/setup";
}
}
async function ensureSession() {
const session = await api("/api/session");
state.auth = {
accountAuthEnabled: Boolean(session && session.auth && session.auth.accountAuthEnabled),
tokenAuthEnabled: Boolean(session && session.auth && session.auth.tokenAuthEnabled),
};
if (session && session.authenticated === true && session.user) {
state.user = session.user;
return;
}
if (state.auth.accountAuthEnabled) {
window.location.href = "/login";
throw new Error("로그인이 필요합니다.");
}
state.user = null;
}
async function onLogout() {
try {
await api("/api/logout", {
method: "POST",
});
} finally {
window.location.href = "/login";
}
} }
async function refreshAll() { async function refreshAll() {
@@ -269,11 +516,12 @@
const payload = { const payload = {
input, input,
useLlm: elements.useLlm.checked, useLlm: elements.useLlm.checked,
provider: elements.provider.value || undefined,
sameFlight: elements.sameFlight.value === "true",
alertOn: elements.alertOn.value, alertOn: elements.alertOn.value,
targetPrice: readTargetPrice(), targetPrice: readTargetPrice(),
pollingEnabled: true, pollingEnabled: true,
alertsEnabled: true, alertsEnabled: true,
pollNow: true,
}; };
const created = await api("/api/watches", { const created = await api("/api/watches", {
@@ -301,7 +549,7 @@
if (!action || !watchId) return; if (!action || !watchId) return;
const isToggleAction = action === "toggle-polling" || action === "toggle-alerts"; const isToggleAction = action === "toggle-polling" || action === "toggle-alerts";
const isButtonAction = action === "poll" || action === "delete"; const isButtonAction = action === "delete";
if (isToggleAction && event.type !== "change") return; if (isToggleAction && event.type !== "change") return;
if (isButtonAction && event.type !== "click") return; if (isButtonAction && event.type !== "click") return;
@@ -328,14 +576,6 @@
return; return;
} }
if (action === "poll") {
await api(`/api/watches/${encodeURIComponent(watchId)}/poll`, {
method: "POST",
});
await refreshAll();
return;
}
if (action === "delete") { if (action === "delete") {
await api(`/api/watches/${encodeURIComponent(watchId)}`, { await api(`/api/watches/${encodeURIComponent(watchId)}`, {
method: "DELETE", method: "DELETE",
@@ -358,8 +598,14 @@
async function bootstrap() { async function bootstrap() {
try { try {
state.apiToken = readStoredApiToken();
await ensureSession();
const config = await api("/api/config"); const config = await api("/api/config");
if (config && config.currentUser) {
state.user = config.currentUser;
}
setConfigBanner(config); setConfigBanner(config);
renderSessionUi();
await refreshAll(); await refreshAll();
@@ -386,6 +632,12 @@
onGlobalToggle().catch((error) => alert(error.message)); onGlobalToggle().catch((error) => alert(error.message));
}); });
if (elements.logoutBtn) {
elements.logoutBtn.addEventListener("click", () => {
onLogout().catch((error) => alert(error.message));
});
}
setInterval(() => { setInterval(() => {
refreshAll().catch(() => {}); refreshAll().catch(() => {});
}, 5000); }, 5000);

View File

@@ -14,7 +14,13 @@
<p class="eyebrow">AIR-WATCHER</p> <p class="eyebrow">AIR-WATCHER</p>
<h1>Flight Watch Dashboard</h1> <h1>Flight Watch Dashboard</h1>
</div> </div>
<div class="config" id="configBanner">초기화 중...</div> <div class="topbar-actions">
<div class="config" id="configBanner">초기화 중...</div>
<div class="session-actions">
<a class="topbar-link" href="/setup" id="setupLink">텔레그램 설정</a>
<button class="btn secondary small" id="logoutBtn" type="button">로그아웃</button>
</div>
</div>
</header> </header>
<section class="panel composer"> <section class="panel composer">
@@ -32,6 +38,24 @@
<span>LLM 파싱 사용</span> <span>LLM 파싱 사용</span>
</label> </label>
<label class="field">
<span>크롤러 소스</span>
<select id="provider">
<option value="">자동 (기본)</option>
<!-- <option value="skyscanner">스카이스캐너</option> -->
<!-- <option value="naver">네이버 항공권</option> -->
<option value="google">구글 플라이트</option>
</select>
</label>
<label class="field">
<span>좌석 조합 (다중 클래스)</span>
<select id="sameFlight">
<option value="true">동일 항공편 유지 (기본)</option>
<option value="false">가장 싼 개별 항공편 합산</option>
</select>
</label>
<label class="field"> <label class="field">
<span>알림 기준</span> <span>알림 기준</span>
<select id="alertOn"> <select id="alertOn">
@@ -56,7 +80,7 @@
<pre id="parseOutput" class="json-view">{}</pre> <pre id="parseOutput" class="json-view">{}</pre>
</section> </section>
<section class="panel system-panel"> <section class="panel system-panel" id="systemPanel">
<div class="section-title"> <div class="section-title">
<h2>전역 제어</h2> <h2>전역 제어</h2>
<p>전체 추적 동작을 한 번에 켜고 끌 수 있습니다.</p> <p>전체 추적 동작을 한 번에 켜고 끌 수 있습니다.</p>

116
src/dashboard/login.css Normal file
View File

@@ -0,0 +1,116 @@
:root {
--bg-deep: #071422;
--bg-soft: #163a56;
--ink-main: #f5fbff;
--ink-sub: #b9cee0;
--accent: #ff9654;
--accent-strong: #ff7f32;
--line: rgba(255, 255, 255, 0.16);
--panel: rgba(8, 25, 40, 0.8);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: "Space Grotesk", "Pretendard", "Noto Sans KR", sans-serif;
color: var(--ink-main);
background: radial-gradient(circle at 15% -10%, #2a5f8c 0%, var(--bg-deep) 42%),
linear-gradient(120deg, #0a1f32, var(--bg-soft));
}
.auth-layout {
min-height: 100vh;
display: grid;
place-items: center;
padding: 20px;
}
.auth-card {
width: min(420px, 100%);
border-radius: 18px;
border: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(8px);
padding: 22px 20px;
box-shadow: 0 22px 36px rgba(2, 10, 18, 0.35);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 0.76rem;
letter-spacing: 0.16em;
font-weight: 700;
}
h1 {
margin: 8px 0 0;
font-size: 1.5rem;
}
.sub {
margin: 8px 0 0;
color: var(--ink-sub);
font-size: 0.9rem;
}
.auth-form {
margin-top: 16px;
display: grid;
gap: 10px;
}
.field span {
display: block;
margin-bottom: 6px;
color: var(--ink-sub);
font-size: 0.84rem;
}
input {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.06);
color: var(--ink-main);
border-radius: 10px;
padding: 10px 11px;
font: inherit;
}
input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 150, 84, 0.22);
}
.btn {
margin-top: 4px;
border: 0;
border-radius: 10px;
padding: 10px 13px;
font: inherit;
font-weight: 700;
color: #1d1209;
background: linear-gradient(130deg, var(--accent), var(--accent-strong));
cursor: pointer;
}
.btn:disabled {
opacity: 0.65;
cursor: wait;
}
.message {
margin: 4px 0 0;
min-height: 1.2rem;
color: #ffbcbc;
font-size: 0.85rem;
}

41
src/dashboard/login.html Normal file
View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Air-Watcher Login</title>
<link rel="stylesheet" href="/login.css" />
</head>
<body>
<main class="auth-layout">
<section class="auth-card">
<p class="eyebrow">AIR-WATCHER</p>
<h1>로그인</h1>
<p class="sub">계정별 watch 목록과 텔레그램 알림을 분리해서 사용합니다.</p>
<form id="loginForm" class="auth-form">
<label class="field">
<span>아이디</span>
<input id="username" name="username" type="text" autocomplete="username" required />
</label>
<label class="field">
<span>비밀번호</span>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
/>
</label>
<button id="loginBtn" class="btn" type="submit">로그인</button>
<p id="message" class="message"></p>
</form>
</section>
</main>
<script src="/login.js"></script>
</body>
</html>

86
src/dashboard/login.js Normal file
View File

@@ -0,0 +1,86 @@
(function () {
"use strict";
const form = document.getElementById("loginForm");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const loginBtn = document.getElementById("loginBtn");
const message = document.getElementById("message");
function setMessage(text) {
message.textContent = text || "";
}
async function api(path, options) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {
"content-type": "application/json",
},
...options,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `요청 실패 (${response.status})`);
}
return payload;
}
async function redirectIfAlreadyLoggedIn() {
try {
const session = await api("/api/session");
if (session && session.authenticated === true) {
window.location.href = "/";
return;
}
const accountAuthEnabled = Boolean(
session && session.auth && session.auth.accountAuthEnabled
);
if (!accountAuthEnabled) {
setMessage("계정 로그인 모드가 비활성화되어 있습니다. 대시보드로 이동하세요.");
setTimeout(() => {
window.location.href = "/";
}, 1200);
}
} catch (_error) {
// Ignore session check errors on login page.
}
}
async function onSubmit(event) {
event.preventDefault();
setMessage("");
const username = usernameInput.value.trim();
const password = passwordInput.value.trim();
if (!username || !password) {
setMessage("아이디/비밀번호를 입력하세요.");
return;
}
loginBtn.disabled = true;
try {
await api("/api/login", {
method: "POST",
body: JSON.stringify({
username,
password,
}),
});
window.location.href = "/";
} catch (error) {
setMessage(error.message || "로그인에 실패했습니다.");
} finally {
loginBtn.disabled = false;
}
}
form.addEventListener("submit", (event) => {
onSubmit(event).catch((error) => {
setMessage(error.message || "로그인에 실패했습니다.");
});
});
redirectIfAlreadyLoggedIn().catch(() => {});
})();

194
src/dashboard/setup.css Normal file
View File

@@ -0,0 +1,194 @@
:root {
--bg-deep: #071422;
--bg-mid: #0d2234;
--bg-soft: #163a56;
--ink-main: #f5fbff;
--ink-sub: #b9cee0;
--accent: #ff9654;
--accent-strong: #ff7f32;
--line: rgba(255, 255, 255, 0.14);
--panel: rgba(8, 25, 40, 0.78);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: "Space Grotesk", "Pretendard", "Noto Sans KR", sans-serif;
color: var(--ink-main);
background: radial-gradient(circle at 20% -5%, #295a81 0%, var(--bg-deep) 42%),
linear-gradient(130deg, var(--bg-mid), var(--bg-soft));
}
.layout {
max-width: 900px;
margin: 0 auto;
padding: 26px 16px 42px;
display: grid;
gap: 14px;
}
.panel {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
background: var(--panel);
backdrop-filter: blur(8px);
box-shadow: 0 18px 30px rgba(3, 10, 17, 0.26);
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 0.76rem;
letter-spacing: 0.18em;
font-weight: 700;
}
h1 {
margin: 4px 0 0;
font-size: 1.45rem;
}
h2 {
margin: 0;
font-size: 1.05rem;
}
.sub {
margin: 8px 0 0;
color: var(--ink-sub);
font-size: 0.88rem;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.link-btn {
color: var(--accent);
text-decoration: none;
font-size: 0.86rem;
}
.link-btn:hover {
text-decoration: underline;
}
.form {
margin-top: 12px;
display: grid;
gap: 10px;
}
.field span {
display: block;
margin-bottom: 6px;
color: var(--ink-sub);
font-size: 0.84rem;
}
.field.row {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
padding: 8px 10px;
}
.field.row span {
margin: 0;
}
input[type="text"],
input[type="password"] {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.24);
background: rgba(255, 255, 255, 0.06);
color: var(--ink-main);
border-radius: 10px;
padding: 10px 11px;
font: inherit;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 150, 84, 0.22);
}
input[type="checkbox"] {
accent-color: var(--accent);
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
border: 0;
border-radius: 10px;
padding: 9px 12px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.btn.primary {
color: #1d1209;
background: linear-gradient(130deg, var(--accent), var(--accent-strong));
}
.btn.secondary {
color: var(--ink-main);
background: rgba(255, 255, 255, 0.12);
}
.status {
margin: 0;
min-height: 1.2rem;
color: #ffd4b8;
font-size: 0.84rem;
}
.guide {
margin: 12px 0 0;
padding-left: 18px;
color: var(--ink-sub);
line-height: 1.55;
}
.guide code {
color: var(--ink-main);
background: rgba(0, 0, 0, 0.22);
border-radius: 5px;
padding: 1px 4px;
}
@media (max-width: 720px) {
.topbar {
flex-direction: column;
align-items: flex-start;
}
}

78
src/dashboard/setup.html Normal file
View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Air-Watcher Telegram Setup</title>
<link rel="stylesheet" href="/setup.css" />
</head>
<body>
<main class="layout">
<header class="panel topbar">
<div>
<p class="eyebrow">AIR-WATCHER</p>
<h1>텔레그램 설정</h1>
<p id="userLabel" class="sub">로딩 중...</p>
</div>
<div class="actions">
<a class="link-btn" href="/">대시보드</a>
<button id="logoutBtn" class="btn secondary" type="button">로그아웃</button>
</div>
</header>
<section class="panel">
<h2>내 알림 설정</h2>
<form id="settingsForm" class="form">
<label class="field row">
<input id="telegramEnabled" type="checkbox" />
<span>텔레그램 알림 사용</span>
</label>
<label class="field">
<span>수신 Chat ID</span>
<input id="telegramChatId" type="text" placeholder="예: 123456789" />
</label>
<label class="field">
<span>Bot Token (선택: 비우면 서버 기본 토큰 사용)</span>
<input id="telegramBotToken" type="password" placeholder="123456:ABC..." />
</label>
<label class="field row">
<input id="clearBotToken" type="checkbox" />
<span>저장된 내 Bot Token 삭제</span>
</label>
<label class="field">
<span>Telegram API Base (선택)</span>
<input id="telegramApiBase" type="text" placeholder="https://api.telegram.org" />
</label>
<div class="button-row">
<button id="saveBtn" class="btn primary" type="submit">설정 저장</button>
<button id="testBtn" class="btn secondary" type="button">테스트 메시지 보내기</button>
</div>
<p id="statusMessage" class="status"></p>
</form>
</section>
<section class="panel">
<h2>텔레그램 연동 안내</h2>
<ol class="guide">
<li>텔레그램에서 <code>@BotFather</code>에게 <code>/newbot</code> 명령으로 봇을 만듭니다.</li>
<li>발급된 Bot Token을 위 설정에 저장합니다. (또는 운영자가 서버 전역 토큰을 설정)</li>
<li>내가 만든 봇과 1:1 대화를 시작하고 아무 메시지나 1개 보냅니다.</li>
<li>
브라우저에서
<code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code>
를 열어 <code>chat.id</code> 값을 확인합니다.
</li>
<li>확인한 <code>chat.id</code>를 위 <code>수신 Chat ID</code>에 저장하고 테스트 전송을 눌러 확인합니다.</li>
</ol>
</section>
</main>
<script src="/setup.js"></script>
</body>
</html>

151
src/dashboard/setup.js Normal file
View File

@@ -0,0 +1,151 @@
(function () {
"use strict";
const elements = {
userLabel: document.getElementById("userLabel"),
logoutBtn: document.getElementById("logoutBtn"),
settingsForm: document.getElementById("settingsForm"),
telegramEnabled: document.getElementById("telegramEnabled"),
telegramChatId: document.getElementById("telegramChatId"),
telegramBotToken: document.getElementById("telegramBotToken"),
clearBotToken: document.getElementById("clearBotToken"),
telegramApiBase: document.getElementById("telegramApiBase"),
saveBtn: document.getElementById("saveBtn"),
testBtn: document.getElementById("testBtn"),
statusMessage: document.getElementById("statusMessage"),
};
const state = {
user: null,
settings: null,
};
function setStatus(text) {
elements.statusMessage.textContent = text || "";
}
async function api(path, options) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {
"content-type": "application/json",
},
...options,
});
if (response.status === 401) {
window.location.href = "/login";
throw new Error("로그인이 필요합니다.");
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `요청 실패 (${response.status})`);
}
return payload;
}
function render() {
const user = state.user;
const settings = state.settings;
if (!user || !settings) return;
elements.userLabel.textContent = `${user.username} 계정`;
elements.telegramEnabled.checked = settings.telegramEnabled === true;
elements.telegramChatId.value = settings.telegramChatId || "";
elements.telegramApiBase.value = settings.telegramApiBase || "";
elements.telegramBotToken.value = "";
elements.clearBotToken.checked = false;
if (settings.hasTelegramBotToken) {
elements.telegramBotToken.placeholder = `저장됨 (${settings.telegramBotTokenMasked || "***"})`;
} else {
elements.telegramBotToken.placeholder = "123456:ABC...";
}
}
async function loadSessionAndSettings() {
const session = await api("/api/session");
if (!session || session.authenticated !== true || !session.user) {
window.location.href = "/login";
return;
}
state.user = session.user;
const me = await api("/api/me");
state.settings = me.settings;
render();
}
async function onSave(event) {
event.preventDefault();
setStatus("");
const payload = {
telegramEnabled: elements.telegramEnabled.checked,
telegramChatId: elements.telegramChatId.value.trim() || null,
telegramApiBase: elements.telegramApiBase.value.trim() || null,
};
const tokenValue = elements.telegramBotToken.value.trim();
if (elements.clearBotToken.checked) {
payload.telegramBotToken = null;
} else if (tokenValue) {
payload.telegramBotToken = tokenValue;
}
elements.saveBtn.disabled = true;
try {
const updated = await api("/api/me/telegram", {
method: "PATCH",
body: JSON.stringify(payload),
});
state.settings = updated.settings;
render();
setStatus("설정을 저장했습니다.");
} catch (error) {
setStatus(error.message || "설정 저장에 실패했습니다.");
} finally {
elements.saveBtn.disabled = false;
}
}
async function onTestSend() {
setStatus("");
elements.testBtn.disabled = true;
try {
const result = await api("/api/me/telegram/test", {
method: "POST",
});
setStatus(result.message || "테스트 전송 완료");
} catch (error) {
setStatus(error.message || "테스트 전송에 실패했습니다.");
} finally {
elements.testBtn.disabled = false;
}
}
async function onLogout() {
try {
await api("/api/logout", { method: "POST" });
} finally {
window.location.href = "/login";
}
}
elements.settingsForm.addEventListener("submit", (event) => {
onSave(event).catch((error) => setStatus(error.message || "설정 저장에 실패했습니다."));
});
elements.testBtn.addEventListener("click", () => {
onTestSend().catch((error) => setStatus(error.message || "테스트 전송에 실패했습니다."));
});
elements.logoutBtn.addEventListener("click", () => {
onLogout().catch((error) => setStatus(error.message || "로그아웃 실패"));
});
loadSessionAndSettings().catch((error) => {
setStatus(error.message || "초기화 실패");
});
})();

View File

@@ -6,6 +6,7 @@ const {
normalizeAlertOn, normalizeAlertOn,
parseTargetPrice, parseTargetPrice,
} = require("./alertRules"); } = require("./alertRules");
const { TelegramNotifier } = require("./notifier");
const { createHttpError, parseBoolean } = require("./dashboardUtils"); const { createHttpError, parseBoolean } = require("./dashboardUtils");
const { extractFlightSearchRequest } = require("./llmParameterExtractor"); const { extractFlightSearchRequest } = require("./llmParameterExtractor");
@@ -21,8 +22,58 @@ function hasOwnProperty(source, key) {
return Object.prototype.hasOwnProperty.call(source, key); return Object.prototype.hasOwnProperty.call(source, key);
} }
function requireUser(context = {}) {
const user = context.user;
if (!user || typeof user.username !== "string" || !user.username.trim()) {
throw createHttpError(401, "Unauthorized");
}
return {
username: user.username.trim(),
isAdmin: user.isAdmin === true,
};
}
function canAccessWatch(user, watch) {
if (!watch) return false;
if (user.isAdmin) return true;
return watch.ownerId === user.username;
}
function filterWatchesForUser(user, watches) {
if (user.isAdmin) return watches;
return watches.filter((watch) => watch.ownerId === user.username);
}
function toOptionalString(value) {
if (value === undefined || value === null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
function maskSecret(value) {
const secret = toOptionalString(value);
if (!secret) return "";
if (secret.length <= 6) return `${secret.slice(0, 1)}***`;
return `${secret.slice(0, 3)}***${secret.slice(-2)}`;
}
async function ensureUserProfile(store, username) {
if (!store || typeof store.getUserProfile !== "function") {
throw createHttpError(500, "store.getUserProfile is not available");
}
return store.getUserProfile(username);
}
async function updateUserProfile(store, username, patch) {
if (!store || typeof store.upsertUserProfile !== "function") {
throw createHttpError(500, "store.upsertUserProfile is not available");
}
return store.upsertUserProfile(username, patch);
}
function createDashboardApi({ watcher, store }) { function createDashboardApi({ watcher, store }) {
async function parseInput(body = {}) { async function parseInput(body = {}, context = {}) {
requireUser(context);
const input = readInput(body); const input = readInput(body);
return extractFlightSearchRequest(input, { return extractFlightSearchRequest(input, {
@@ -30,37 +81,129 @@ function createDashboardApi({ watcher, store }) {
}); });
} }
async function createWatch(body = {}) { async function createWatch(body = {}, context = {}) {
const extracted = await parseInput(body); const user = requireUser(context);
const extracted = await parseInput(body, context);
const input = readInput(body); const input = readInput(body);
// 필수 데이터 누락 시 조기 차단 (IP 차단 방지)
const missing = extracted.params.missingFields || [];
if (missing.includes("segments")) {
throw createHttpError(400, "출발지 또는 도착지 정보가 파악되지 않았습니다. 문장을 다시 작성해주세요.");
}
if (missing.includes("departureDateWindow")) {
throw createHttpError(400, "출발 날짜 정보가 파악되지 않았습니다. (예: '11월 25일에 출발') 문장을 구체적으로 적어주세요.");
}
if (extracted.params.tripType === "round_trip" && missing.includes("stayDurationDays")) {
throw createHttpError(400, "왕복 여정입니다만, 체류 기간(며칠 동안 여행하는지)이 파악되지 않았습니다. (예: '12일 체류')");
}
if (body.sameFlight !== undefined && extracted.params && extracted.params.constraints) {
extracted.params.constraints.sameFlightForAllPassengers = parseBoolean(body.sameFlight, true);
}
if (body.provider && typeof body.provider === "string") {
extracted.params.provider = body.provider.trim();
}
const alertRules = buildAlertRules({ const alertRules = buildAlertRules({
targetPrice: body.targetPrice, targetPrice: body.targetPrice,
alertOn: body.alertOn || "both", alertOn: body.alertOn || "both",
}); });
const watchId = watcher.addWatch({ const paramVariations = [];
rawInput: input, const baseParams = extracted.params;
searchParams: extracted.params, const window = baseParams.departureDateWindow;
alertRules, const stay = baseParams.stayDurationDays;
pollingEnabled: parseBoolean(body.pollingEnabled, true),
alertsEnabled: parseBoolean(body.alertsEnabled, true),
});
const created = watcher.getWatch(watchId); let startDates = [];
await store.saveWatch(created); if (window && window.from && window.to) {
const fromDate = new Date(window.from);
if (parseBoolean(body.pollNow, false)) { const toDate = new Date(window.to);
await watcher.pollWatch(watchId); 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 { return {
watch: watcher.getWatch(watchId), watch: watcher.getWatch(firstWatchId),
parserSource: extracted.source, parserSource: extracted.source,
createdCount: paramVariations.length,
}; };
} }
async function updateSystem(body = {}) { async function updateSystem(body = {}, context = {}) {
const user = requireUser(context);
if (!user.isAdmin) {
throw createHttpError(403, "관리자 계정만 시스템 전역 설정을 변경할 수 있습니다.");
}
const controls = watcher.setGlobalControls({ const controls = watcher.setGlobalControls({
crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled), crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled),
alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled), alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled),
@@ -69,35 +212,30 @@ function createDashboardApi({ watcher, store }) {
return { controls }; return { controls };
} }
function listWatches() { function listWatches(context = {}) {
const user = requireUser(context);
const allWatches = watcher.listWatches();
return { return {
controls: watcher.getGlobalControls(), controls: watcher.getGlobalControls(),
watches: watcher.listWatches(), watches: filterWatchesForUser(user, allWatches),
}; };
} }
async function listEvents(rawLimit) { async function listEvents(rawLimit, context = {}) {
const user = requireUser(context);
const limit = Number(rawLimit); const limit = Number(rawLimit);
const events = await store.listEvents(Number.isFinite(limit) ? limit : 50); const safeLimit = Number.isFinite(limit) ? limit : 50;
const events = await store.listEvents(
safeLimit,
user.isAdmin ? {} : { ownerId: user.username }
);
return { events }; return { events };
} }
async function pollWatch(watchId) { async function updateWatch(watchId, body = {}, context = {}) {
const user = requireUser(context);
const existing = watcher.getWatch(watchId); const existing = watcher.getWatch(watchId);
if (!existing) { if (!existing || !canAccessWatch(user, existing)) {
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
}
const pollResult = await watcher.pollWatch(watchId);
return {
watch: watcher.getWatch(watchId),
pollResult,
};
}
async function updateWatch(watchId, body = {}) {
const existing = watcher.getWatch(watchId);
if (!existing) {
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
} }
@@ -129,19 +267,124 @@ function createDashboardApi({ watcher, store }) {
return { watch: updated }; return { watch: updated };
} }
async function deleteWatch(watchId) { async function deleteWatch(watchId, context = {}) {
const user = requireUser(context);
const existing = watcher.getWatch(watchId);
if (!existing || !canAccessWatch(user, existing)) {
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
}
const existed = watcher.removeWatch(watchId); const existed = watcher.removeWatch(watchId);
await store.deleteWatch(watchId); await store.deleteWatch(watchId);
return { deleted: existed }; return { deleted: existed };
} }
async function getMe(context = {}) {
const user = requireUser(context);
const profile = await ensureUserProfile(store, user.username);
return {
user,
settings: {
telegramEnabled: profile.telegramEnabled === true,
telegramChatId: profile.telegramChatId || "",
telegramApiBase: profile.telegramApiBase || process.env.TELEGRAM_API_BASE || "",
hasTelegramBotToken: Boolean(profile.telegramBotToken),
telegramBotTokenMasked: maskSecret(profile.telegramBotToken),
},
};
}
async function updateMyTelegram(body = {}, context = {}) {
const user = requireUser(context);
const previous = await ensureUserProfile(store, user.username);
const patch = {};
if (hasOwnProperty(body, "telegramEnabled")) {
patch.telegramEnabled = parseBoolean(body.telegramEnabled, previous.telegramEnabled);
}
if (hasOwnProperty(body, "telegramChatId")) {
patch.telegramChatId = toOptionalString(body.telegramChatId);
}
if (hasOwnProperty(body, "telegramBotToken")) {
patch.telegramBotToken = toOptionalString(body.telegramBotToken);
}
if (hasOwnProperty(body, "telegramApiBase")) {
patch.telegramApiBase = toOptionalString(body.telegramApiBase);
}
const merged = {
...previous,
...patch,
};
if (merged.telegramEnabled === true && !merged.telegramChatId) {
throw createHttpError(400, "telegramEnabled=true이면 telegramChatId가 필요합니다.");
}
const updated = await updateUserProfile(store, user.username, patch);
return {
user,
settings: {
telegramEnabled: updated.telegramEnabled === true,
telegramChatId: updated.telegramChatId || "",
telegramApiBase: updated.telegramApiBase || process.env.TELEGRAM_API_BASE || "",
hasTelegramBotToken: Boolean(updated.telegramBotToken),
telegramBotTokenMasked: maskSecret(updated.telegramBotToken),
},
};
}
async function sendMyTelegramTest(context = {}) {
const user = requireUser(context);
const profile = await ensureUserProfile(store, user.username);
const botToken =
profile.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || process.env.NOTIFY_TELEGRAM_BOT_TOKEN;
const chatId = profile.telegramChatId;
if (!botToken || !chatId) {
throw createHttpError(
400,
"테스트 전송에 필요한 값이 부족합니다. telegramBotToken(또는 전역 TELEGRAM_BOT_TOKEN)과 telegramChatId를 설정하세요."
);
}
const notifier = new TelegramNotifier({
botToken,
chatId,
apiBase: profile.telegramApiBase || process.env.TELEGRAM_API_BASE,
});
await notifier.notify({
watchId: "telegram-setup-test",
eventType: "test",
currentBestPrice: 0,
previousBestPrice: null,
threshold: null,
currency: "KRW",
rawInput: `[${user.username}] 텔레그램 설정 테스트`,
observedAt: new Date().toISOString(),
bestOffer: {
provider: "setup",
},
});
return {
ok: true,
message: "텔레그램 테스트 메시지를 전송했습니다.",
};
}
return { return {
createWatch, createWatch,
deleteWatch, deleteWatch,
getMe,
listEvents, listEvents,
listWatches, listWatches,
parseInput, parseInput,
pollWatch, sendMyTelegramTest,
updateMyTelegram,
updateSystem, updateSystem,
updateWatch, updateWatch,
}; };

View File

@@ -6,11 +6,23 @@ const { createHttpError } = require("./dashboardUtils");
const ASSET_MAP = { const ASSET_MAP = {
"/": { file: "index.html", contentType: "text/html; charset=utf-8" }, "/": { file: "index.html", contentType: "text/html; charset=utf-8" },
"/login": { file: "login.html", contentType: "text/html; charset=utf-8" },
"/setup": { file: "setup.html", contentType: "text/html; charset=utf-8" },
"/dashboard.css": { file: "dashboard.css", contentType: "text/css; charset=utf-8" }, "/dashboard.css": { file: "dashboard.css", contentType: "text/css; charset=utf-8" },
"/login.css": { file: "login.css", contentType: "text/css; charset=utf-8" },
"/setup.css": { file: "setup.css", contentType: "text/css; charset=utf-8" },
"/dashboard.js": { "/dashboard.js": {
file: "dashboard.js", file: "dashboard.js",
contentType: "application/javascript; charset=utf-8", contentType: "application/javascript; charset=utf-8",
}, },
"/login.js": {
file: "login.js",
contentType: "application/javascript; charset=utf-8",
},
"/setup.js": {
file: "setup.js",
contentType: "application/javascript; charset=utf-8",
},
}; };
function loadDashboardAsset(assetsDir, requestPath) { function loadDashboardAsset(assetsDir, requestPath) {

269
src/dashboardAuth.js Normal file
View File

@@ -0,0 +1,269 @@
"use strict";
const crypto = require("node:crypto");
const SESSION_COOKIE_NAME = "airwatcher_sid";
const DEFAULT_SESSION_TTL_SEC = 60 * 60 * 24 * 7;
function normalizeUsername(value) {
if (typeof value !== "string") return "";
return value.trim().toLowerCase();
}
function normalizePassword(value) {
if (typeof value !== "string") return "";
return value.trim();
}
function secureEqual(left, right) {
const leftBuffer = Buffer.from(String(left));
const rightBuffer = Buffer.from(String(right));
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function parseCookieHeader(cookieHeader) {
if (typeof cookieHeader !== "string" || cookieHeader.trim() === "") return {};
return cookieHeader.split(";").reduce((acc, pair) => {
const index = pair.indexOf("=");
if (index <= 0) return acc;
const key = pair.slice(0, index).trim();
const value = pair.slice(index + 1).trim();
if (!key) return acc;
try {
acc[key] = decodeURIComponent(value);
} catch (_error) {
acc[key] = value;
}
return acc;
}, {});
}
function parseBasicAuthorization(headerValue) {
if (typeof headerValue !== "string") return null;
const matched = headerValue.trim().match(/^Basic\s+(.+)$/i);
if (!matched) return null;
try {
const decoded = Buffer.from(matched[1], "base64").toString("utf8");
const separator = decoded.indexOf(":");
if (separator < 0) return null;
return {
username: decoded.slice(0, separator),
password: decoded.slice(separator + 1),
};
} catch (_error) {
return null;
}
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
}
function parseUsers(rawUsers) {
if (typeof rawUsers !== "string" || rawUsers.trim() === "") {
return new Map();
}
const users = new Map();
const entries = rawUsers
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
for (const entry of entries) {
const separator = entry.indexOf(":");
if (separator <= 0) continue;
const username = normalizeUsername(entry.slice(0, separator));
const password = normalizePassword(entry.slice(separator + 1));
if (!username || !password) continue;
users.set(username, password);
}
return users;
}
function parseAdminUsers(rawAdminUsers, knownUsers) {
const admins = new Set();
if (typeof rawAdminUsers === "string" && rawAdminUsers.trim() !== "") {
for (const part of rawAdminUsers.split(/[\n,]/)) {
const username = normalizeUsername(part);
if (username) admins.add(username);
}
}
if (admins.size === 0 && knownUsers.size > 0) {
const firstUser = knownUsers.keys().next().value;
if (firstUser) admins.add(firstUser);
}
return admins;
}
function createDashboardAuth(options = {}) {
const users = parseUsers(
options.users !== undefined
? options.users
: process.env.DASHBOARD_USERS || process.env.DASHBOARD_BASIC_AUTH_USERS || ""
);
const admins = parseAdminUsers(
options.adminUsers !== undefined ? options.adminUsers : process.env.DASHBOARD_ADMIN_USERS,
users
);
const sessionTtlSec = parsePositiveInt(
options.sessionTtlSec !== undefined
? options.sessionTtlSec
: process.env.DASHBOARD_SESSION_TTL_SEC,
DEFAULT_SESSION_TTL_SEC
);
const sessions = new Map();
function isEnabled() {
return users.size > 0;
}
function isAdmin(username) {
return admins.has(normalizeUsername(username));
}
function toUser(username, authType) {
const normalized = normalizeUsername(username);
return {
username: normalized,
isAdmin: isAdmin(normalized),
authType,
};
}
function cleanupExpiredSessions() {
const now = Date.now();
for (const [sessionId, session] of sessions.entries()) {
if (!session || session.expiresAt <= now) {
sessions.delete(sessionId);
}
}
}
function verifyCredentials(username, password) {
const normalizedUsername = normalizeUsername(username);
const normalizedPassword = normalizePassword(password);
if (!normalizedUsername || !normalizedPassword) return null;
const expectedPassword = users.get(normalizedUsername);
if (!expectedPassword) return null;
return secureEqual(expectedPassword, normalizedPassword) ? normalizedUsername : null;
}
function issueSession(username) {
const sessionId = crypto.randomBytes(32).toString("hex");
const expiresAt = Date.now() + sessionTtlSec * 1000;
sessions.set(sessionId, {
username,
expiresAt,
});
return sessionId;
}
function readSessionIdFromHeaders(headers = {}) {
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie[0] : headers.cookie;
const cookies = parseCookieHeader(cookieHeader);
const sessionId = cookies[SESSION_COOKIE_NAME];
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
}
function buildSessionCookie(sessionId) {
return `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${sessionTtlSec}`;
}
function buildSessionClearCookie() {
return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
function login(username, password) {
if (!isEnabled()) {
throw new Error("Dashboard account login is not configured. Set DASHBOARD_USERS.");
}
const verifiedUsername = verifyCredentials(username, password);
if (!verifiedUsername) {
return null;
}
const sessionId = issueSession(verifiedUsername);
return {
user: toUser(verifiedUsername, "session"),
sessionId,
setCookie: buildSessionCookie(sessionId),
};
}
function getUserFromSession(headers = {}) {
cleanupExpiredSessions();
const sessionId = readSessionIdFromHeaders(headers);
if (!sessionId) return null;
const session = sessions.get(sessionId);
if (!session) return null;
if (session.expiresAt <= Date.now()) {
sessions.delete(sessionId);
return null;
}
return toUser(session.username, "session");
}
function getUserFromBasicAuth(headers = {}) {
const authHeader = Array.isArray(headers.authorization)
? headers.authorization[0]
: headers.authorization;
const parsed = parseBasicAuthorization(authHeader);
if (!parsed) return null;
const verifiedUsername = verifyCredentials(parsed.username, parsed.password);
if (!verifiedUsername) return null;
return toUser(verifiedUsername, "basic");
}
function getUserFromRequest(headers = {}) {
if (!isEnabled()) return null;
const fromSession = getUserFromSession(headers);
if (fromSession) return fromSession;
return getUserFromBasicAuth(headers);
}
function logout(headers = {}) {
const sessionId = readSessionIdFromHeaders(headers);
if (sessionId) {
sessions.delete(sessionId);
}
return {
setCookie: buildSessionClearCookie(),
};
}
return {
enabled: isEnabled(),
sessionCookieName: SESSION_COOKIE_NAME,
listUsers: () => Array.from(users.keys()),
getUserFromRequest,
login,
logout,
toUser,
};
}
module.exports = {
SESSION_COOKIE_NAME,
createDashboardAuth,
};

View File

@@ -3,13 +3,14 @@
const { createCrawlerClient } = require("./crawlerClient"); const { createCrawlerClient } = require("./crawlerClient");
const { createDashboardStore } = require("./dashboardStore"); const { createDashboardStore } = require("./dashboardStore");
const { loadDotEnv } = require("./envLoader"); const { loadDotEnv } = require("./envLoader");
const { createNotifier } = require("./notifier"); const { createNotifier, TelegramNotifier } = require("./notifier");
const { MIN_CRAWL_INTERVAL_SEC, normalizeCrawlIntervalSec } = require("./pollingConfig");
const { PriceWatcher } = require("./priceWatcher"); const { PriceWatcher } = require("./priceWatcher");
const { parsePort } = require("./dashboardUtils");
function toRestoredWatchPayload(watch) { function toRestoredWatchPayload(watch) {
return { return {
id: watch.id, id: watch.id,
ownerId: watch.ownerId || null,
rawInput: watch.rawInput, rawInput: watch.rawInput,
searchParams: watch.searchParams, searchParams: watch.searchParams,
alertRules: watch.alertRules, alertRules: watch.alertRules,
@@ -22,13 +23,79 @@ function toRestoredWatchPayload(watch) {
}; };
} }
function createDashboardNotifier({ store, fallbackNotifier, logger }) {
const notifierCache = new Map();
const fallbackIsGlobalTelegram = fallbackNotifier instanceof TelegramNotifier;
const globalTelegramBotToken =
process.env.TELEGRAM_BOT_TOKEN || process.env.NOTIFY_TELEGRAM_BOT_TOKEN || null;
const globalTelegramApiBase = process.env.TELEGRAM_API_BASE || undefined;
async function notifyViaTelegram(event, profile) {
const botToken = profile.telegramBotToken || globalTelegramBotToken;
const chatId = profile.telegramChatId;
if (!botToken || !chatId) {
throw new Error("telegramBotToken and telegramChatId are required");
}
const apiBase = profile.telegramApiBase || globalTelegramApiBase;
const cacheKey = `${botToken}|${chatId}|${apiBase || ""}`;
let notifier = notifierCache.get(cacheKey);
if (!notifier) {
notifier = new TelegramNotifier({
botToken,
chatId,
apiBase,
});
notifierCache.set(cacheKey, notifier);
}
await notifier.notify(event);
}
return {
async notify(event, context = {}) {
const ownerId =
context &&
context.watch &&
typeof context.watch.ownerId === "string" &&
context.watch.ownerId.trim()
? context.watch.ownerId.trim()
: null;
if (ownerId && store && typeof store.getUserProfile === "function") {
try {
const profile = await store.getUserProfile(ownerId);
if (profile && profile.telegramEnabled === true) {
await notifyViaTelegram(event, profile);
return;
}
if (fallbackIsGlobalTelegram) {
// Owner-bound watch should not leak to a shared/global telegram target.
return;
}
} catch (error) {
logger.error(`per-user notifier failed for ${ownerId}: ${error.message}`);
if (fallbackIsGlobalTelegram) {
return;
}
}
}
await fallbackNotifier.notify(event, context);
},
};
}
async function createDashboardRuntime(options = {}) { async function createDashboardRuntime(options = {}) {
loadDotEnv(); loadDotEnv();
const logger = options.logger || console; const logger = options.logger || console;
const pollIntervalSec = parsePort( const pollIntervalSec = normalizeCrawlIntervalSec(
options.pollIntervalSec || process.env.DASHBOARD_POLL_INTERVAL_SEC, options.pollIntervalSec !== undefined
60 ? options.pollIntervalSec
: process.env.DASHBOARD_POLL_INTERVAL_SEC,
MIN_CRAWL_INTERVAL_SEC
); );
const storeSetup = const storeSetup =
@@ -38,7 +105,12 @@ async function createDashboardRuntime(options = {}) {
const store = storeSetup.store; const store = storeSetup.store;
const crawler = options.crawler || createCrawlerClient(options.crawlerOptions || {}); const crawler = options.crawler || createCrawlerClient(options.crawlerOptions || {});
const notifier = options.notifier || createNotifier(options.notifierOptions || {}); const baseNotifier = options.notifier || createNotifier(options.notifierOptions || {});
const notifier = createDashboardNotifier({
store,
fallbackNotifier: baseNotifier,
logger,
});
const watcher = new PriceWatcher({ const watcher = new PriceWatcher({
crawler, crawler,
@@ -50,8 +122,18 @@ async function createDashboardRuntime(options = {}) {
await store.savePollResult(watch.id, result); await store.savePollResult(watch.id, result);
if (result && result.alert) { if (result && result.alert) {
const notificationState =
result.notificationSent === true
? "sent"
: result.error && result.error.phase === "notify"
? "failed"
: result.alert.notificationSuppressed === true
? "suppressed"
: "unknown";
await store.saveEvent({ await store.saveEvent({
watchId: watch.id, watchId: watch.id,
ownerId: watch.ownerId || null,
eventType: result.alert.eventType || "unknown", eventType: result.alert.eventType || "unknown",
observedAt: observedAt:
result.alert.observedAt || result.alert.observedAt ||
@@ -61,6 +143,8 @@ async function createDashboardRuntime(options = {}) {
payload: { payload: {
...result.alert, ...result.alert,
notificationSent: result.notificationSent === true, notificationSent: result.notificationSent === true,
notificationState,
notificationError: notificationState === "failed" ? result.error : null,
}, },
}); });
} }

View File

@@ -3,16 +3,13 @@
const http = require("node:http"); const http = require("node:http");
const path = require("node:path"); const path = require("node:path");
const { const { isAuthorizedRequest, resolveApiAuth } = require("./apiAuth");
buildAlertRules, const { buildAlertRules, inferAlertOn, normalizeAlertOn, parseTargetPrice } = require("./alertRules");
inferAlertOn,
normalizeAlertOn,
parseTargetPrice,
} = require("./alertRules");
const { createDashboardApi } = require("./dashboardApi"); const { createDashboardApi } = require("./dashboardApi");
const { createDashboardAuth } = require("./dashboardAuth");
const { loadDashboardAsset } = require("./dashboardAssets"); const { loadDashboardAsset } = require("./dashboardAssets");
const { createDashboardRuntime } = require("./dashboardRuntime"); const { createDashboardRuntime } = require("./dashboardRuntime");
const { createHttpError, decodeWatchId, parsePort } = require("./dashboardUtils"); const { createHttpError, decodeWatchId, parsePort, toPublicErrorResponse } = require("./dashboardUtils");
function parseJsonBody(req) { function parseJsonBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -46,16 +43,25 @@ function parseJsonBody(req) {
}); });
} }
function sendJson(res, statusCode, body) { function sendJson(res, statusCode, body, extraHeaders = {}) {
const payload = JSON.stringify(body); const payload = JSON.stringify(body);
res.writeHead(statusCode, { res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"cache-control": "no-store", "cache-control": "no-store",
"content-length": Buffer.byteLength(payload), "content-length": Buffer.byteLength(payload),
...extraHeaders,
}); });
res.end(payload); res.end(payload);
} }
function sendRedirect(res, location) {
res.writeHead(302, {
location,
"cache-control": "no-store",
});
res.end();
}
function sendStaticAsset(res, asset) { function sendStaticAsset(res, asset) {
res.writeHead(200, { res.writeHead(200, {
"content-type": asset.contentType, "content-type": asset.contentType,
@@ -65,24 +71,167 @@ function sendStaticAsset(res, asset) {
res.end(asset.content); res.end(asset.content);
} }
function toPublicUser(user) {
if (!user) return null;
return {
username: user.username,
isAdmin: user.isAdmin === true,
authType: user.authType || "unknown",
};
}
async function createDashboardServer(options = {}) { async function createDashboardServer(options = {}) {
const logger = options.logger || console;
const dashboardAuth = createDashboardAuth(options.accountAuth || {});
let tokenAuth;
try {
tokenAuth = resolveApiAuth(options.auth || {});
} catch (error) {
if (
dashboardAuth.enabled &&
typeof error?.message === "string" &&
error.message.includes("DASHBOARD_API_TOKEN")
) {
tokenAuth = {
enabled: false,
token: "",
};
} else {
throw error;
}
}
const runtime = await createDashboardRuntime(options); const runtime = await createDashboardRuntime(options);
const api = createDashboardApi({ const api = createDashboardApi({
watcher: runtime.watcher, watcher: runtime.watcher,
store: runtime.store, store: runtime.store,
}); });
const identityRequired = dashboardAuth.enabled || tokenAuth.enabled;
const serverInfo = {
...runtime.info,
authEnabled: identityRequired,
accountAuthEnabled: dashboardAuth.enabled,
tokenAuthEnabled: tokenAuth.enabled,
};
const assetsDir = path.resolve(__dirname, "dashboard"); const assetsDir = path.resolve(__dirname, "dashboard");
function resolveRequestUser(headers) {
if (dashboardAuth.enabled) {
const accountUser = dashboardAuth.getUserFromRequest(headers);
if (accountUser) return accountUser;
}
if (tokenAuth.enabled && isAuthorizedRequest(headers, tokenAuth)) {
return {
username: "admin",
isAdmin: true,
authType: "token",
};
}
if (!identityRequired) {
return {
username: "guest",
isAdmin: true,
authType: "none",
};
}
return null;
}
function sendUnauthorized(res) {
const authenticateHeader = dashboardAuth.enabled
? 'Basic realm="airwatcher-dashboard"'
: 'Bearer realm="airwatcher-dashboard"';
sendJson(
res,
401,
{ error: "Unauthorized" },
{ "www-authenticate": authenticateHeader }
);
}
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
const requestUrl = new URL(req.url || "/", "http://localhost"); const requestUrl = new URL(req.url || "/", "http://localhost");
const pathname = requestUrl.pathname; const pathname = requestUrl.pathname;
const currentUser = resolveRequestUser(req.headers);
if (req.method === "POST" && pathname === "/api/login") {
if (!dashboardAuth.enabled) {
throw createHttpError(400, "계정 로그인이 비활성화되어 있습니다. DASHBOARD_USERS를 설정하세요.");
}
const body = await parseJsonBody(req);
const loginResult = dashboardAuth.login(body.username, body.password);
if (!loginResult) {
sendJson(res, 401, { error: "아이디 또는 비밀번호가 올바르지 않습니다." });
return;
}
sendJson(
res,
200,
{
ok: true,
user: toPublicUser(loginResult.user),
},
{
"set-cookie": loginResult.setCookie,
}
);
return;
}
if (req.method === "POST" && pathname === "/api/logout") {
const logoutResult = dashboardAuth.logout(req.headers);
sendJson(res, 200, { ok: true }, { "set-cookie": logoutResult.setCookie });
return;
}
if (req.method === "GET" && pathname === "/api/session") {
sendJson(res, 200, {
authenticated: Boolean(currentUser),
user: toPublicUser(currentUser),
auth: {
accountAuthEnabled: dashboardAuth.enabled,
tokenAuthEnabled: tokenAuth.enabled,
},
});
return;
}
if (dashboardAuth.enabled && pathname === "/") {
if (!currentUser) {
const loginAsset = loadDashboardAsset(assetsDir, "/login");
sendStaticAsset(res, loginAsset);
return;
}
}
if (dashboardAuth.enabled && pathname === "/login" && currentUser) {
sendRedirect(res, "/");
return;
}
if (dashboardAuth.enabled && pathname === "/setup" && !currentUser) {
sendRedirect(res, "/login");
return;
}
const staticAsset = loadDashboardAsset(assetsDir, pathname); const staticAsset = loadDashboardAsset(assetsDir, pathname);
if (staticAsset) { if (staticAsset) {
sendStaticAsset(res, staticAsset); sendStaticAsset(res, staticAsset);
return; return;
} }
if (pathname.startsWith("/api/")) {
if (!currentUser) {
sendUnauthorized(res);
return;
}
}
if (req.method === "GET" && pathname === "/api/health") { if (req.method === "GET" && pathname === "/api/health") {
sendJson(res, 200, { sendJson(res, 200, {
ok: true, ok: true,
@@ -94,7 +243,26 @@ async function createDashboardServer(options = {}) {
} }
if (req.method === "GET" && pathname === "/api/config") { if (req.method === "GET" && pathname === "/api/config") {
sendJson(res, 200, runtime.info); sendJson(res, 200, {
...serverInfo,
currentUser: toPublicUser(currentUser),
});
return;
}
if (req.method === "GET" && pathname === "/api/me") {
sendJson(res, 200, await api.getMe({ user: currentUser }));
return;
}
if (req.method === "PATCH" && pathname === "/api/me/telegram") {
const body = await parseJsonBody(req);
sendJson(res, 200, await api.updateMyTelegram(body, { user: currentUser }));
return;
}
if (req.method === "POST" && pathname === "/api/me/telegram/test") {
sendJson(res, 200, await api.sendMyTelegramTest({ user: currentUser }));
return; return;
} }
@@ -107,37 +275,30 @@ async function createDashboardServer(options = {}) {
if (req.method === "PATCH" && pathname === "/api/system") { if (req.method === "PATCH" && pathname === "/api/system") {
const body = await parseJsonBody(req); const body = await parseJsonBody(req);
sendJson(res, 200, await api.updateSystem(body)); sendJson(res, 200, await api.updateSystem(body, { user: currentUser }));
return; return;
} }
if (req.method === "POST" && pathname === "/api/parse") { if (req.method === "POST" && pathname === "/api/parse") {
const body = await parseJsonBody(req); const body = await parseJsonBody(req);
sendJson(res, 200, await api.parseInput(body)); sendJson(res, 200, await api.parseInput(body, { user: currentUser }));
return; return;
} }
if (req.method === "GET" && pathname === "/api/watches") { if (req.method === "GET" && pathname === "/api/watches") {
sendJson(res, 200, api.listWatches()); sendJson(res, 200, api.listWatches({ user: currentUser }));
return; return;
} }
if (req.method === "POST" && pathname === "/api/watches") { if (req.method === "POST" && pathname === "/api/watches") {
const body = await parseJsonBody(req); const body = await parseJsonBody(req);
sendJson(res, 201, await api.createWatch(body)); sendJson(res, 201, await api.createWatch(body, { user: currentUser }));
return; return;
} }
if (req.method === "GET" && pathname === "/api/events") { if (req.method === "GET" && pathname === "/api/events") {
const limit = requestUrl.searchParams.get("limit"); const limit = requestUrl.searchParams.get("limit");
sendJson(res, 200, await api.listEvents(limit)); sendJson(res, 200, await api.listEvents(limit, { user: currentUser }));
return;
}
const watchPollMatch = pathname.match(/^\/api\/watches\/([^/]+)\/poll$/);
if (req.method === "POST" && watchPollMatch) {
const watchId = decodeWatchId(watchPollMatch[1]);
sendJson(res, 200, await api.pollWatch(watchId));
return; return;
} }
@@ -147,12 +308,12 @@ async function createDashboardServer(options = {}) {
if (req.method === "PATCH") { if (req.method === "PATCH") {
const body = await parseJsonBody(req); const body = await parseJsonBody(req);
sendJson(res, 200, await api.updateWatch(watchId, body)); sendJson(res, 200, await api.updateWatch(watchId, body, { user: currentUser }));
return; return;
} }
if (req.method === "DELETE") { if (req.method === "DELETE") {
sendJson(res, 200, await api.deleteWatch(watchId)); sendJson(res, 200, await api.deleteWatch(watchId, { user: currentUser }));
return; return;
} }
} }
@@ -161,10 +322,8 @@ async function createDashboardServer(options = {}) {
error: "Not found", error: "Not found",
}); });
} catch (error) { } catch (error) {
const statusCode = Number(error.statusCode) || 500; const failure = toPublicErrorResponse(error, { logger });
sendJson(res, statusCode, { sendJson(res, failure.statusCode, failure.body);
error: error.message || "Internal Server Error",
});
} }
}); });
@@ -180,7 +339,7 @@ async function createDashboardServer(options = {}) {
watcher: runtime.watcher, watcher: runtime.watcher,
store: runtime.store, store: runtime.store,
close, close,
info: runtime.info, info: serverInfo,
}; };
} }
@@ -195,7 +354,7 @@ async function runCli() {
process.stdout.write(`[WARN] ${app.info.dbWarning}\n`); process.stdout.write(`[WARN] ${app.info.dbWarning}\n`);
} }
process.stdout.write( process.stdout.write(
`dbEngine=${app.info.dbEngine} pollIntervalSec=${app.info.pollIntervalSec}\n` `dbEngine=${app.info.dbEngine} pollIntervalSec=${app.info.pollIntervalSec} authEnabled=${app.info.authEnabled}\n`
); );
}); });

View File

@@ -9,6 +9,18 @@ function toBoolean(value, fallback = true) {
return value !== false && value !== 0 && value !== "0"; return value !== false && value !== 0 && value !== "0";
} }
function parseBooleanFlag(value, fallback = false) {
if (value === undefined || value === null) return fallback;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) return true;
if (["false", "0", "no", "off", ""].includes(normalized)) return false;
}
return fallback;
}
function toSqlDateTime(isoString) { function toSqlDateTime(isoString) {
const date = isoString ? new Date(isoString) : new Date(); const date = isoString ? new Date(isoString) : new Date();
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
@@ -48,10 +60,105 @@ function parseJsonColumn(value, fallback) {
} }
} }
const INTERNAL_OWNER_KEY = "__ownerId";
const INTERNAL_EVENT_OWNER_KEY = "__ownerId";
const USER_PROFILES_SETTING_KEY = "user_profiles";
const PLAYGROUND_REQUIRED_TABLES = [
"projects",
"project_documents",
"project_events",
"project_settings",
];
const DEFAULT_PROJECT_KEY = "air-watcher";
function toOptionalString(value) {
if (value === undefined || value === null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
function normalizeDbSchemaMode(value) {
const normalized = toOptionalString(value);
if (!normalized) return "auto";
if (normalized === "playground" || normalized === "auto") {
return normalized;
}
return "auto";
}
function normalizeUserProfile(username, source = {}) {
const normalizedUsername = toOptionalString(username);
if (!normalizedUsername) {
throw new Error("username is required");
}
return {
username: normalizedUsername,
telegramEnabled: source.telegramEnabled === true,
telegramChatId: toOptionalString(source.telegramChatId),
telegramBotToken: toOptionalString(source.telegramBotToken),
telegramApiBase: toOptionalString(source.telegramApiBase),
updatedAt: toOptionalString(source.updatedAt) || new Date().toISOString(),
};
}
function injectInternalOwner(searchParams, ownerId) {
const params = cloneJson(searchParams || {});
if (ownerId) {
params[INTERNAL_OWNER_KEY] = ownerId;
} else {
delete params[INTERNAL_OWNER_KEY];
}
return params;
}
function extractInternalOwner(searchParams) {
const params = parseJsonColumn(searchParams, {});
const ownerId =
params && typeof params[INTERNAL_OWNER_KEY] === "string"
? params[INTERNAL_OWNER_KEY].trim()
: null;
if (params && typeof params === "object") {
delete params[INTERNAL_OWNER_KEY];
}
return {
ownerId: ownerId || null,
searchParams: params || {},
};
}
function injectEventOwnerPayload(payload, ownerId) {
const nextPayload = cloneJson(payload || {});
if (ownerId) {
nextPayload[INTERNAL_EVENT_OWNER_KEY] = ownerId;
}
return nextPayload;
}
function extractEventOwnerPayload(payload) {
const parsed = parseJsonColumn(payload, {});
const ownerId =
parsed && typeof parsed[INTERNAL_EVENT_OWNER_KEY] === "string"
? parsed[INTERNAL_EVENT_OWNER_KEY].trim()
: null;
if (parsed && typeof parsed === "object") {
delete parsed[INTERNAL_EVENT_OWNER_KEY];
}
return {
ownerId: ownerId || null,
payload: parsed || {},
};
}
class InMemoryDashboardStore { class InMemoryDashboardStore {
constructor() { constructor() {
this.watches = new Map(); this.watches = new Map();
this.events = []; this.events = [];
this.userProfiles = new Map();
this.globalControls = { this.globalControls = {
crawlingEnabled: true, crawlingEnabled: true,
alertsEnabled: true, alertsEnabled: true,
@@ -75,6 +182,7 @@ class InMemoryDashboardStore {
async saveWatch(watch) { async saveWatch(watch) {
const next = cloneJson(watch); const next = cloneJson(watch);
next.ownerId = toOptionalString(next.ownerId);
this.watches.set(next.id, next); this.watches.set(next.id, next);
return next; return next;
} }
@@ -109,6 +217,7 @@ class InMemoryDashboardStore {
const stored = { const stored = {
id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`, id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`,
watchId: event.watchId, watchId: event.watchId,
ownerId: toOptionalString(event.ownerId),
eventType: event.eventType, eventType: event.eventType,
payload: cloneJson(event.payload), payload: cloneJson(event.payload),
observedAt: event.observedAt, observedAt: event.observedAt,
@@ -121,9 +230,13 @@ class InMemoryDashboardStore {
return cloneJson(stored); return cloneJson(stored);
} }
async listEvents(limit = 50) { async listEvents(limit = 50, options = {}) {
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
return this.events.slice(0, safeLimit).map((event) => cloneJson(event)); const ownerId = toOptionalString(options.ownerId);
const filtered = ownerId
? this.events.filter((event) => event.ownerId === ownerId)
: this.events;
return filtered.slice(0, safeLimit).map((event) => cloneJson(event));
} }
async getGlobalControls() { async getGlobalControls() {
@@ -147,11 +260,45 @@ class InMemoryDashboardStore {
return cloneJson(this.globalControls); return cloneJson(this.globalControls);
} }
async getUserProfile(username) {
const normalizedUsername = toOptionalString(username);
if (!normalizedUsername) {
throw new Error("username is required");
}
const existing = this.userProfiles.get(normalizedUsername);
if (!existing) {
return normalizeUserProfile(normalizedUsername, {});
}
return cloneJson(existing);
}
async upsertUserProfile(username, patch = {}) {
const normalizedUsername = toOptionalString(username);
if (!normalizedUsername) {
throw new Error("username is required");
}
const previous = this.userProfiles.get(normalizedUsername) || normalizeUserProfile(normalizedUsername, {});
const next = normalizeUserProfile(normalizedUsername, {
...previous,
...patch,
updatedAt: new Date().toISOString(),
});
this.userProfiles.set(normalizedUsername, next);
return cloneJson(next);
}
} }
class MySqlDashboardStore { class MySqlDashboardStore {
constructor(pool) { constructor(pool, options = {}) {
this.pool = pool; this.pool = pool;
this.projectKey = toOptionalString(options.projectKey) || DEFAULT_PROJECT_KEY;
this.schemaPreference = normalizeDbSchemaMode(options.schemaMode);
this.schemaMode = null;
} }
static async create(options = {}) { static async create(options = {}) {
@@ -179,220 +326,263 @@ class MySqlDashboardStore {
timezone: "Z", timezone: "Z",
}); });
const store = new MySqlDashboardStore(pool); const store = new MySqlDashboardStore(pool, {
projectKey: options.projectKey,
schemaMode: options.schemaMode,
});
await store.init(); await store.init();
return store; return store;
} }
async init() { async init() {
await this.pool.query(` if (this.schemaPreference !== "auto" && this.schemaPreference !== "playground") {
CREATE TABLE IF NOT EXISTS watches ( throw new Error("DASHBOARD_DB_SCHEMA는 playground 또는 auto만 지원합니다.");
id VARCHAR(64) NOT NULL, }
raw_input TEXT NOT NULL,
parsed_params JSON NOT NULL,
alert_rules JSON NOT NULL,
polling_enabled TINYINT(1) NOT NULL DEFAULT 1,
alerts_enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
last_snapshot JSON NULL,
last_error JSON NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await this.pool.query(` const inspectTables = PLAYGROUND_REQUIRED_TABLES;
CREATE TABLE IF NOT EXISTS watch_events ( const [rows] = await this.pool.query(
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `
watch_id VARCHAR(64) NOT NULL, SELECT TABLE_NAME
event_type VARCHAR(64) NOT NULL, FROM information_schema.TABLES
payload JSON NOT NULL, WHERE TABLE_SCHEMA = DATABASE()
observed_at DATETIME(3) NOT NULL, AND TABLE_NAME IN (${inspectTables.map(() => "?").join(", ")})
created_at DATETIME(3) NOT NULL, `,
PRIMARY KEY (id), inspectTables
INDEX idx_watch_events_watch_id (watch_id, observed_at DESC) );
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await this.pool.query(` const existing = new Set(
CREATE TABLE IF NOT EXISTS app_settings ( rows.map((row) => String(row.TABLE_NAME || row.table_name || ""))
setting_key VARCHAR(64) NOT NULL, );
setting_value JSON NOT NULL, const missingPlayground = PLAYGROUND_REQUIRED_TABLES.filter((tableName) => !existing.has(tableName));
updated_at DATETIME(3) NOT NULL, if (missingPlayground.length > 0) {
PRIMARY KEY (setting_key) throw new Error(
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `MySQL playground 스키마가 준비되지 않았습니다. 누락 테이블: ${missingPlayground.join(", ")}. playground_schema.sql을 먼저 적용하세요.`
`); );
}
this.schemaMode = "playground";
await this.ensureProjectEntry();
}
async ensureProjectEntry() {
const now = toSqlDateTime(new Date().toISOString());
await this.pool.query(
`
INSERT INTO projects (
project_key,
name,
description,
meta_json,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
meta_json = VALUES(meta_json),
updated_at = VALUES(updated_at)
`,
[
this.projectKey,
this.projectKey,
"Managed by Air-Watcher dashboard",
JSON.stringify({ source: "air-watcher" }),
now,
now,
]
);
} }
async close() { async close() {
await this.pool.end(); await this.pool.end();
} }
async listWatches() { async getSettingJson(settingKey, fallbackValue) {
const [rows] = await this.pool.query(` const [rows] = await this.pool.query(
SELECT `
id, SELECT setting_value
raw_input, FROM project_settings
parsed_params, WHERE project_key = ?
alert_rules, AND setting_key = ?
polling_enabled, LIMIT 1
alerts_enabled, `,
created_at, [this.projectKey, settingKey]
updated_at, );
last_snapshot, if (rows.length === 0) {
last_error return cloneJson(fallbackValue);
FROM watches }
ORDER BY created_at DESC return parseJsonColumn(rows[0].setting_value, cloneJson(fallbackValue));
`); }
return rows.map((row) => ({ async upsertSettingJson(settingKey, value) {
id: row.id, const now = toSqlDateTime(new Date().toISOString());
rawInput: row.raw_input, await this.pool.query(
searchParams: parseJsonColumn(row.parsed_params, {}), `
alertRules: parseJsonColumn(row.alert_rules, {}), INSERT INTO project_settings (project_key, setting_key, setting_value, updated_at)
pollingEnabled: toBoolean(row.polling_enabled, true), VALUES (?, ?, ?, ?)
alertsEnabled: toBoolean(row.alerts_enabled, true), ON DUPLICATE KEY UPDATE
createdAt: fromSqlDateTime(row.created_at), setting_value = VALUES(setting_value),
updatedAt: fromSqlDateTime(row.updated_at), updated_at = VALUES(updated_at)
lastSnapshot: parseJsonColumn(row.last_snapshot, null), `,
lastError: parseJsonColumn(row.last_error, null), [this.projectKey, settingKey, JSON.stringify(value), now]
})); );
}
fromWatchDocumentRow(row) {
const source = parseJsonColumn(row.data_json, {});
const watch = source && typeof source === "object" ? source : {};
return {
id: toOptionalString(watch.id) || String(row.doc_key),
ownerId: toOptionalString(watch.ownerId),
rawInput: typeof watch.rawInput === "string" ? watch.rawInput : "",
searchParams: cloneJson(watch.searchParams || {}),
alertRules: cloneJson(watch.alertRules || {}),
pollingEnabled: toBoolean(watch.pollingEnabled, true),
alertsEnabled: toBoolean(watch.alertsEnabled, true),
createdAt: toOptionalString(watch.createdAt) || fromSqlDateTime(row.created_at),
updatedAt: toOptionalString(watch.updatedAt) || fromSqlDateTime(row.updated_at),
lastSnapshot: watch.lastSnapshot ? cloneJson(watch.lastSnapshot) : null,
lastError: watch.lastError ? cloneJson(watch.lastError) : null,
};
}
toWatchDocument(watch) {
const createdAt = toOptionalString(watch.createdAt) || new Date().toISOString();
const updatedAt = toOptionalString(watch.updatedAt) || createdAt;
return {
id: watch.id,
ownerId: toOptionalString(watch.ownerId),
rawInput: watch.rawInput || "",
searchParams: cloneJson(watch.searchParams || {}),
alertRules: cloneJson(watch.alertRules || {}),
pollingEnabled: watch.pollingEnabled !== false,
alertsEnabled: watch.alertsEnabled !== false,
createdAt,
updatedAt,
lastSnapshot: watch.lastSnapshot ? cloneJson(watch.lastSnapshot) : null,
lastError: watch.lastError ? cloneJson(watch.lastError) : null,
};
}
async listWatches() {
const [rows] = await this.pool.query(
`
SELECT doc_key, data_json, created_at, updated_at
FROM project_documents
WHERE project_key = ?
AND doc_type = 'watch'
ORDER BY created_at DESC
`,
[this.projectKey]
);
return rows.map((row) => this.fromWatchDocumentRow(row));
} }
async getWatch(watchId) { async getWatch(watchId) {
const [rows] = await this.pool.query( const [rows] = await this.pool.query(
` `
SELECT SELECT doc_key, data_json, created_at, updated_at
id, FROM project_documents
raw_input, WHERE project_key = ?
parsed_params, AND doc_type = 'watch'
alert_rules, AND doc_key = ?
polling_enabled,
alerts_enabled,
created_at,
updated_at,
last_snapshot,
last_error
FROM watches
WHERE id = ?
LIMIT 1 LIMIT 1
`, `,
[watchId] [this.projectKey, watchId]
); );
if (rows.length === 0) return null; if (rows.length === 0) return null;
const row = rows[0]; return this.fromWatchDocumentRow(rows[0]);
return {
id: row.id,
rawInput: row.raw_input,
searchParams: parseJsonColumn(row.parsed_params, {}),
alertRules: parseJsonColumn(row.alert_rules, {}),
pollingEnabled: toBoolean(row.polling_enabled, true),
alertsEnabled: toBoolean(row.alerts_enabled, true),
createdAt: fromSqlDateTime(row.created_at),
updatedAt: fromSqlDateTime(row.updated_at),
lastSnapshot: parseJsonColumn(row.last_snapshot, null),
lastError: parseJsonColumn(row.last_error, null),
};
} }
async saveWatch(watch) { async saveWatch(watch) {
const createdAt = watch.createdAt || new Date().toISOString(); const persisted = this.toWatchDocument(watch);
const updatedAt = watch.updatedAt || createdAt;
await this.pool.query( await this.pool.query(
` `
INSERT INTO watches ( INSERT INTO project_documents (
id, project_key,
raw_input, doc_type,
parsed_params, doc_key,
alert_rules, data_json,
polling_enabled, meta_json,
alerts_enabled,
created_at, created_at,
updated_at, updated_at
last_snapshot, )
last_error VALUES (?, 'watch', ?, ?, NULL, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
raw_input = VALUES(raw_input), data_json = VALUES(data_json),
parsed_params = VALUES(parsed_params), updated_at = VALUES(updated_at)
alert_rules = VALUES(alert_rules),
polling_enabled = VALUES(polling_enabled),
alerts_enabled = VALUES(alerts_enabled),
updated_at = VALUES(updated_at),
last_snapshot = VALUES(last_snapshot),
last_error = VALUES(last_error)
`, `,
[ [
this.projectKey,
watch.id, watch.id,
watch.rawInput || "", JSON.stringify(persisted),
JSON.stringify(watch.searchParams || {}), toSqlDateTime(persisted.createdAt),
JSON.stringify(watch.alertRules || {}), toSqlDateTime(persisted.updatedAt),
watch.pollingEnabled === false ? 0 : 1,
watch.alertsEnabled === false ? 0 : 1,
toSqlDateTime(createdAt),
toSqlDateTime(updatedAt),
watch.lastSnapshot ? JSON.stringify(watch.lastSnapshot) : null,
watch.lastError ? JSON.stringify(watch.lastError) : null,
] ]
); );
return this.getWatch(watch.id); return this.getWatch(watch.id);
} }
async deleteWatch(watchId) { async deleteWatch(watchId) {
const [result] = await this.pool.query(`DELETE FROM watches WHERE id = ?`, [watchId]); const [result] = await this.pool.query(
`
DELETE FROM project_documents
WHERE project_key = ?
AND doc_type = 'watch'
AND doc_key = ?
`,
[this.projectKey, watchId]
);
return result.affectedRows > 0; return result.affectedRows > 0;
} }
async savePollResult(watchId, pollResult) { async savePollResult(watchId, pollResult) {
const updates = []; const watch = await this.getWatch(watchId);
const params = []; if (!watch) return;
const nowIso = new Date().toISOString();
let touched = false;
if (pollResult && pollResult.snapshot) { if (pollResult && pollResult.snapshot) {
updates.push("last_snapshot = ?"); watch.lastSnapshot = cloneJson(pollResult.snapshot);
params.push(JSON.stringify(pollResult.snapshot)); watch.lastError = null;
updates.push("last_error = NULL"); touched = true;
} }
if (pollResult && pollResult.error) { if (pollResult && pollResult.error) {
updates.push("last_error = ?"); watch.lastError = cloneJson(pollResult.error);
params.push(JSON.stringify(pollResult.error)); touched = true;
} }
if (!touched) return;
if (updates.length === 0) { watch.updatedAt = new Date().toISOString();
return; await this.saveWatch(watch);
}
updates.push("updated_at = ?");
params.push(toSqlDateTime(nowIso));
params.push(watchId);
await this.pool.query(
`
UPDATE watches
SET ${updates.join(", ")}
WHERE id = ?
`,
params
);
} }
async saveEvent(event) { async saveEvent(event) {
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const observedAt = event.observedAt || createdAt; const observedAt = event.observedAt || createdAt;
const ownerId = toOptionalString(event.ownerId);
const payload = injectEventOwnerPayload(event.payload, ownerId);
const [result] = await this.pool.query( const [result] = await this.pool.query(
` `
INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at) INSERT INTO project_events (
VALUES (?, ?, ?, ?, ?) project_key,
stream,
event_type,
payload_json,
observed_at,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
`, `,
[ [
event.watchId, this.projectKey,
"watch_events",
event.eventType || "unknown", event.eventType || "unknown",
JSON.stringify(event.payload || {}), JSON.stringify({
watchId: event.watchId,
payload,
}),
toSqlDateTime(observedAt), toSqlDateTime(observedAt),
toSqlDateTime(createdAt), toSqlDateTime(createdAt),
] ]
@@ -401,6 +591,7 @@ class MySqlDashboardStore {
return { return {
id: String(result.insertId), id: String(result.insertId),
watchId: event.watchId, watchId: event.watchId,
ownerId,
eventType: event.eventType, eventType: event.eventType,
payload: cloneJson(event.payload || {}), payload: cloneJson(event.payload || {}),
observedAt, observedAt,
@@ -408,46 +599,51 @@ class MySqlDashboardStore {
}; };
} }
async listEvents(limit = 50) { async listEvents(limit = 50, options = {}) {
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
const ownerIdFilter = toOptionalString(options.ownerId);
const fetchLimit = ownerIdFilter ? Math.min(1000, Math.max(safeLimit * 5, 200)) : safeLimit;
const [rows] = await this.pool.query( const [rows] = await this.pool.query(
` `
SELECT id, watch_id, event_type, payload, observed_at, created_at SELECT id, event_type, payload_json, observed_at, created_at
FROM watch_events FROM project_events
WHERE project_key = ?
AND stream = 'watch_events'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? LIMIT ?
`, `,
[safeLimit] [this.projectKey, fetchLimit]
); );
return rows.map((row) => ({ const mapped = rows
id: String(row.id), .map((row) => {
watchId: row.watch_id, const envelope = parseJsonColumn(row.payload_json, {});
eventType: row.event_type, const rawPayload =
payload: parseJsonColumn(row.payload, {}), envelope && Object.prototype.hasOwnProperty.call(envelope, "payload")
observedAt: fromSqlDateTime(row.observed_at), ? envelope.payload
createdAt: fromSqlDateTime(row.created_at), : 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() { async getGlobalControls() {
const [rows] = await this.pool.query( const setting = await this.getSettingJson("global_controls", {});
`
SELECT setting_value
FROM app_settings
WHERE setting_key = 'global_controls'
LIMIT 1
`
);
if (rows.length === 0) {
return {
crawlingEnabled: true,
alertsEnabled: true,
};
}
const setting = parseJsonColumn(rows[0].setting_value, {});
return { return {
crawlingEnabled: toBoolean(setting.crawlingEnabled, true), crawlingEnabled: toBoolean(setting.crawlingEnabled, true),
alertsEnabled: toBoolean(setting.alertsEnabled, true), alertsEnabled: toBoolean(setting.alertsEnabled, true),
@@ -466,19 +662,42 @@ class MySqlDashboardStore {
next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled); next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled);
} }
await this.pool.query( await this.upsertSettingJson("global_controls", next);
`
INSERT INTO app_settings (setting_key, setting_value, updated_at)
VALUES ('global_controls', ?, ?)
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = VALUES(updated_at)
`,
[JSON.stringify(next), toSqlDateTime(new Date().toISOString())]
);
return next; return next;
} }
async getUserProfile(username) {
const normalizedUsername = toOptionalString(username);
if (!normalizedUsername) {
throw new Error("username is required");
}
const profiles = await this.getSettingJson(USER_PROFILES_SETTING_KEY, {});
const profileSource =
profiles && typeof profiles === "object" ? profiles[normalizedUsername] || {} : {};
return normalizeUserProfile(normalizedUsername, profileSource);
}
async upsertUserProfile(username, patch = {}) {
const normalizedUsername = toOptionalString(username);
if (!normalizedUsername) {
throw new Error("username is required");
}
const profiles = await this.getSettingJson(USER_PROFILES_SETTING_KEY, {});
const safeProfiles = profiles && typeof profiles === "object" ? { ...profiles } : {};
const previous = normalizeUserProfile(normalizedUsername, safeProfiles[normalizedUsername] || {});
const next = normalizeUserProfile(normalizedUsername, {
...previous,
...patch,
updatedAt: new Date().toISOString(),
});
safeProfiles[normalizedUsername] = next;
await this.upsertSettingJson(USER_PROFILES_SETTING_KEY, safeProfiles);
return next;
}
} }
function parsePort(rawPort, fallback) { function parsePort(rawPort, fallback) {
@@ -491,6 +710,12 @@ async function createDashboardStore(options = {}) {
const modeRaw = options.mode || process.env.DASHBOARD_DB || ""; const modeRaw = options.mode || process.env.DASHBOARD_DB || "";
const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : ""; const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : "";
const isStrictMySqlMode = mode === "mysql"; const isStrictMySqlMode = mode === "mysql";
const allowMemoryFallback = parseBooleanFlag(
options.allowMemoryFallback !== undefined
? options.allowMemoryFallback
: process.env.DASHBOARD_ALLOW_MEMORY_FALLBACK,
false
);
const prefersMySql = const prefersMySql =
isStrictMySqlMode || isStrictMySqlMode ||
Boolean(process.env.MYSQL_URL) || Boolean(process.env.MYSQL_URL) ||
@@ -514,12 +739,14 @@ async function createDashboardStore(options = {}) {
password: options.mysqlPassword || process.env.MYSQL_PASSWORD, password: options.mysqlPassword || process.env.MYSQL_PASSWORD,
database: options.mysqlDatabase || process.env.MYSQL_DATABASE, database: options.mysqlDatabase || process.env.MYSQL_DATABASE,
connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT, connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT,
projectKey: options.projectKey || process.env.DASHBOARD_PROJECT_KEY || DEFAULT_PROJECT_KEY,
schemaMode: options.schemaMode || process.env.DASHBOARD_DB_SCHEMA || "playground",
}; };
if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) { if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) {
if (isStrictMySqlMode) { if (!allowMemoryFallback) {
throw new Error( throw new Error(
"MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하세요." "MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하거나 DASHBOARD_ALLOW_MEMORY_FALLBACK=true를 지정하세요."
); );
} }
@@ -541,8 +768,8 @@ async function createDashboardStore(options = {}) {
warning: null, warning: null,
}; };
} catch (error) { } catch (error) {
if (isStrictMySqlMode) { if (!allowMemoryFallback) {
throw error; throw new Error(`MySQL 초기화 실패: ${error.message}`);
} }
const fallbackStore = new InMemoryDashboardStore(); const fallbackStore = new InMemoryDashboardStore();

View File

@@ -6,6 +6,14 @@ function createHttpError(statusCode, message) {
return error; return error;
} }
function normalizeStatusCode(statusCode, fallback = 500) {
const normalized = Number(statusCode);
if (!Number.isInteger(normalized) || normalized < 400 || normalized > 599) {
return fallback;
}
return normalized;
}
function parseBoolean(value, fallback = true) { function parseBoolean(value, fallback = true) {
if (value === undefined) return fallback; if (value === undefined) return fallback;
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
@@ -32,9 +40,33 @@ function decodeWatchId(value) {
} }
} }
function toPublicErrorResponse(error, options = {}) {
const logger = options.logger || null;
const statusCode = normalizeStatusCode(error?.statusCode, 500);
const shouldMask = statusCode >= 500;
const message =
shouldMask
? "Internal Server Error"
: typeof error?.message === "string" && error.message
? error.message
: "Bad Request";
if (shouldMask && logger && typeof logger.error === "function") {
logger.error(error?.stack || error?.message || String(error));
}
return {
statusCode,
body: {
error: message,
},
};
}
module.exports = { module.exports = {
createHttpError, createHttpError,
decodeWatchId, decodeWatchId,
parseBoolean, parseBoolean,
parsePort, parsePort,
toPublicErrorResponse,
}; };

View File

@@ -3,10 +3,12 @@
const path = require("node:path"); const path = require("node:path");
const Fastify = require("fastify"); const Fastify = require("fastify");
const { isAuthorizedRequest, resolveApiAuth } = require("./apiAuth");
const { createDashboardApi } = require("./dashboardApi"); const { createDashboardApi } = require("./dashboardApi");
const { createDashboardAuth } = require("./dashboardAuth");
const { loadDashboardAsset } = require("./dashboardAssets"); const { loadDashboardAsset } = require("./dashboardAssets");
const { createDashboardRuntime } = require("./dashboardRuntime"); const { createDashboardRuntime } = require("./dashboardRuntime");
const { decodeWatchId, parsePort } = require("./dashboardUtils"); const { createHttpError, decodeWatchId, parsePort, toPublicErrorResponse } = require("./dashboardUtils");
function sendStaticAsset(reply, asset) { function sendStaticAsset(reply, asset) {
reply reply
@@ -24,31 +26,124 @@ function parseRequestPathname(request) {
} }
} }
function toPublicUser(user) {
if (!user) return null;
return {
username: user.username,
isAdmin: user.isAdmin === true,
authType: user.authType || "unknown",
};
}
async function createFastifyDashboardServer(options = {}) { async function createFastifyDashboardServer(options = {}) {
const logger = options.logger || console;
const dashboardAuth = createDashboardAuth(options.accountAuth || {});
let tokenAuth;
try {
tokenAuth = resolveApiAuth(options.auth || {});
} catch (error) {
if (
dashboardAuth.enabled &&
typeof error?.message === "string" &&
error.message.includes("DASHBOARD_API_TOKEN")
) {
tokenAuth = {
enabled: false,
token: "",
};
} else {
throw error;
}
}
const runtime = await createDashboardRuntime(options); const runtime = await createDashboardRuntime(options);
const api = createDashboardApi({ const api = createDashboardApi({
watcher: runtime.watcher, watcher: runtime.watcher,
store: runtime.store, store: runtime.store,
}); });
const identityRequired = dashboardAuth.enabled || tokenAuth.enabled;
const serverInfo = {
...runtime.info,
authEnabled: identityRequired,
accountAuthEnabled: dashboardAuth.enabled,
tokenAuthEnabled: tokenAuth.enabled,
};
const assetsDir = path.resolve(__dirname, "dashboard"); const assetsDir = path.resolve(__dirname, "dashboard");
function resolveRequestUser(headers) {
if (dashboardAuth.enabled) {
const accountUser = dashboardAuth.getUserFromRequest(headers);
if (accountUser) return accountUser;
}
if (tokenAuth.enabled && isAuthorizedRequest(headers, tokenAuth)) {
return {
username: "admin",
isAdmin: true,
authType: "token",
};
}
if (!identityRequired) {
return {
username: "guest",
isAdmin: true,
authType: "none",
};
}
return null;
}
const app = Fastify({ const app = Fastify({
logger: false, logger: false,
bodyLimit: 1024 * 1024, bodyLimit: 1024 * 1024,
}); });
app.setErrorHandler((error, _request, reply) => { app.setErrorHandler((error, _request, reply) => {
const statusCode = Number(error.statusCode) || 500; const failure = toPublicErrorResponse(error, { logger });
reply.code(statusCode).send({ reply.code(failure.statusCode).send(failure.body);
error: error.message || "Internal Server Error",
});
}); });
app.addHook("onClose", async () => { app.addHook("onClose", async () => {
await runtime.close(); await runtime.close();
}); });
app.get("/", async (_request, reply) => { app.addHook("onRequest", async (request, reply) => {
request.dashboardUser = resolveRequestUser(request.headers);
const pathname = parseRequestPathname(request);
const isApiPath = pathname.startsWith("/api/");
const isPublicApiPath =
pathname === "/api/login" || pathname === "/api/logout" || pathname === "/api/session";
if (dashboardAuth.enabled && pathname === "/" && !request.dashboardUser) {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login"));
return reply;
}
if (dashboardAuth.enabled && pathname === "/login" && request.dashboardUser) {
return reply.redirect("/");
}
if (dashboardAuth.enabled && pathname === "/setup" && !request.dashboardUser) {
return reply.redirect("/login");
}
if (isApiPath && !isPublicApiPath && !request.dashboardUser) {
reply.header(
"www-authenticate",
dashboardAuth.enabled
? 'Basic realm="airwatcher-dashboard"'
: 'Bearer realm="airwatcher-dashboard"'
);
throw createHttpError(401, "Unauthorized");
}
});
app.get("/", async (request, reply) => {
if (dashboardAuth.enabled && !request.dashboardUser) {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login"));
return;
}
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/")); sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/"));
}); });
@@ -60,6 +155,64 @@ async function createFastifyDashboardServer(options = {}) {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.js")); sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.js"));
}); });
app.get("/login", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login"));
});
app.get("/login.css", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login.css"));
});
app.get("/login.js", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/login.js"));
});
app.get("/setup", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/setup"));
});
app.get("/setup.css", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/setup.css"));
});
app.get("/setup.js", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/setup.js"));
});
app.post("/api/login", async (request, reply) => {
if (!dashboardAuth.enabled) {
throw createHttpError(400, "계정 로그인이 비활성화되어 있습니다. DASHBOARD_USERS를 설정하세요.");
}
const body = request.body || {};
const loginResult = dashboardAuth.login(body.username, body.password);
if (!loginResult) {
reply.code(401);
return { error: "아이디 또는 비밀번호가 올바르지 않습니다." };
}
reply.header("set-cookie", loginResult.setCookie);
return {
ok: true,
user: toPublicUser(loginResult.user),
};
});
app.post("/api/logout", async (request, reply) => {
const result = dashboardAuth.logout(request.headers);
reply.header("set-cookie", result.setCookie);
return { ok: true };
});
app.get("/api/session", async (request) => ({
authenticated: Boolean(request.dashboardUser),
user: toPublicUser(request.dashboardUser),
auth: {
accountAuthEnabled: dashboardAuth.enabled,
tokenAuthEnabled: tokenAuth.enabled,
},
}));
app.get("/api/health", async () => ({ app.get("/api/health", async () => ({
ok: true, ok: true,
now: new Date().toISOString(), now: new Date().toISOString(),
@@ -67,41 +220,54 @@ async function createFastifyDashboardServer(options = {}) {
watchCount: runtime.watcher.listWatchIds().length, watchCount: runtime.watcher.listWatchIds().length,
})); }));
app.get("/api/config", async () => runtime.info); app.get("/api/config", async (request) => ({
...serverInfo,
currentUser: toPublicUser(request.dashboardUser),
}));
app.get("/api/me", async (request) => api.getMe({ user: request.dashboardUser }));
app.patch("/api/me/telegram", async (request) => {
return api.updateMyTelegram(request.body || {}, { user: request.dashboardUser });
});
app.post("/api/me/telegram/test", async (request) => {
return api.sendMyTelegramTest({ user: request.dashboardUser });
});
app.get("/api/system", async () => ({ app.get("/api/system", async () => ({
controls: runtime.watcher.getGlobalControls(), controls: runtime.watcher.getGlobalControls(),
})); }));
app.patch("/api/system", async (request) => { app.patch("/api/system", async (request) => {
return api.updateSystem(request.body || {}); return api.updateSystem(request.body || {}, { user: request.dashboardUser });
}); });
app.post("/api/parse", async (request) => { app.post("/api/parse", async (request) => {
return api.parseInput(request.body || {}); return api.parseInput(request.body || {}, { user: request.dashboardUser });
}); });
app.get("/api/watches", async () => api.listWatches()); app.get("/api/watches", async (request) => api.listWatches({ user: request.dashboardUser }));
app.post("/api/watches", async (request, reply) => { app.post("/api/watches", async (request, reply) => {
reply.code(201); reply.code(201);
return api.createWatch(request.body || {}); return api.createWatch(request.body || {}, { user: request.dashboardUser });
}); });
app.get("/api/events", async (request) => { app.get("/api/events", async (request) => {
return api.listEvents(request.query?.limit); return api.listEvents(request.query?.limit, { user: request.dashboardUser });
});
app.post("/api/watches/:watchId/poll", async (request) => {
return api.pollWatch(decodeWatchId(request.params.watchId));
}); });
app.patch("/api/watches/:watchId", async (request) => { app.patch("/api/watches/:watchId", async (request) => {
return api.updateWatch(decodeWatchId(request.params.watchId), request.body || {}); return api.updateWatch(decodeWatchId(request.params.watchId), request.body || {}, {
user: request.dashboardUser,
});
}); });
app.delete("/api/watches/:watchId", async (request) => { app.delete("/api/watches/:watchId", async (request) => {
return api.deleteWatch(decodeWatchId(request.params.watchId)); return api.deleteWatch(decodeWatchId(request.params.watchId), {
user: request.dashboardUser,
});
}); });
app.setNotFoundHandler(async (request, reply) => { app.setNotFoundHandler(async (request, reply) => {
@@ -121,7 +287,7 @@ async function createFastifyDashboardServer(options = {}) {
close: async () => { close: async () => {
await app.close(); await app.close();
}, },
info: runtime.info, info: serverInfo,
}; };
} }
@@ -137,7 +303,7 @@ async function runCli() {
process.stdout.write(`[WARN] ${dashboard.info.dbWarning}\n`); process.stdout.write(`[WARN] ${dashboard.info.dbWarning}\n`);
} }
process.stdout.write( process.stdout.write(
`dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec}\n` `dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec} authEnabled=${dashboard.info.authEnabled}\n`
); );
const shutdown = () => { const shutdown = () => {

View File

@@ -31,6 +31,12 @@ function toIntegerOrNull(value) {
return Math.round(n); return Math.round(n);
} }
function parsePositiveInt(value, fallback) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
}
function sanitizeDateWindow(value) { function sanitizeDateWindow(value) {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const from = typeof value.from === "string" ? value.from : null; const from = typeof value.from === "string" ? value.from : null;
@@ -119,14 +125,45 @@ function uniqueStrings(values) {
} }
function inferTripType(segments) { function inferTripType(segments) {
if (!segments || segments.length < 2) return "unknown"; if (!segments || segments.length === 0) return "unknown";
if (segments.length === 1) return "unknown";
const first = segments[0]; const first = segments[0];
const second = segments[1]; const last = segments[segments.length - 1];
if (first.from === second.to && first.to === second.from) return "round_trip"; if (segments.length === 2 && first.from === last.to && first.to === last.from) return "round_trip";
if (first.from === second.to && first.to !== second.from) return "open_jaw";
return "multi_city"; 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) { function recomputeMissingFields(params) {
const missingFields = []; const missingFields = [];
if (!params.departureDateWindow) missingFields.push("departureDateWindow"); if (!params.departureDateWindow) missingFields.push("departureDateWindow");
@@ -152,10 +189,11 @@ function mergeWithFallback(llmObject, fallbackParams, input, now) {
...uniqueStrings(source.warnings), ...uniqueStrings(source.warnings),
]); ]);
const tripType = const rawTripType =
typeof source.tripType === "string" && source.tripType.trim() typeof source.tripType === "string" && source.tripType.trim()
? source.tripType ? source.tripType.trim()
: inferTripType(segments); : inferTripType(segments);
const tripType = correctTripType(rawTripType, segments);
const parsed = { const parsed = {
rawInput: input, rawInput: input,
@@ -183,7 +221,8 @@ function buildPrompt(input, nowDate) {
' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | null,', ' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | null,',
' "passengers": {"total":number,"byCabin":{"economy":number,"premium_economy":number,"business":number,"first":number}} | null,', ' "passengers": {"total":number,"byCabin":{"economy":number,"premium_economy":number,"business":number,"first":number}} | null,',
' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},', ' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},',
' "tripType": "round_trip|open_jaw|multi_city|unknown",', ' "tripType": "round_trip|multi_city|unknown",',
" // round_trip: same origin/destination pair reversed (A→B, B→A). multi_city: different cities on any leg (A→B, C→A or A→B, C→D).",
' "warnings": [string],', ' "warnings": [string],',
' "missingFields": [string]', ' "missingFields": [string]',
"}", "}",
@@ -200,6 +239,10 @@ function createOpenAIClient(options = {}) {
const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"; const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const model = options.model || process.env.OPENAI_MODEL || "gpt-4.1-mini"; const model = options.model || process.env.OPENAI_MODEL || "gpt-4.1-mini";
const timeoutMs = parsePositiveInt(
options.timeoutMs !== undefined ? options.timeoutMs : process.env.LLM_REQUEST_TIMEOUT_MS,
20000
);
const fetchImpl = options.fetch || global.fetch; const fetchImpl = options.fetch || global.fetch;
if (typeof fetchImpl !== "function") { if (typeof fetchImpl !== "function") {
throw new Error("global fetch is unavailable. Node.js 18+ is required."); throw new Error("global fetch is unavailable. Node.js 18+ is required.");
@@ -208,38 +251,53 @@ function createOpenAIClient(options = {}) {
const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`; const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
return async ({ input, now }) => { return async ({ input, now }) => {
const response = await fetchImpl(endpoint, { const abortController = new AbortController();
method: "POST", const timeout = setTimeout(() => {
headers: { abortController.abort();
"content-type": "application/json", }, timeoutMs);
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
"You are a parser. Output valid JSON only. Do not wrap in markdown or prose.",
},
{
role: "user",
content: buildPrompt(input, now),
},
],
}),
});
if (!response.ok) { try {
const message = await response.text(); const response = await fetchImpl(endpoint, {
throw new Error(`OpenAI API request failed (${response.status}): ${message}`); 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);
}; };
} }

42
src/pollingConfig.js Normal file
View File

@@ -0,0 +1,42 @@
"use strict";
const MIN_CRAWL_INTERVAL_SEC = 60 * 60;
const MIN_CRAWL_INTERVAL_MS = MIN_CRAWL_INTERVAL_SEC * 1000;
function isMissing(value) {
return value === undefined || value === null;
}
function parseIntegerInterval(rawValue, minValue, label) {
const parsed = Number(rawValue);
if (!Number.isInteger(parsed)) {
throw new Error(`${label} 값은 정수여야 합니다: ${rawValue}`);
}
if (parsed < minValue) {
throw new Error(`${label} 값은 ${minValue} 이상이어야 합니다.`);
}
return parsed;
}
function normalizeCrawlIntervalSec(rawValue, fallback = MIN_CRAWL_INTERVAL_SEC) {
if (isMissing(rawValue)) {
return parseIntegerInterval(fallback, MIN_CRAWL_INTERVAL_SEC, "crawl interval(sec)");
}
return parseIntegerInterval(rawValue, MIN_CRAWL_INTERVAL_SEC, "crawl interval(sec)");
}
function normalizeCrawlIntervalMs(rawValue, fallback = MIN_CRAWL_INTERVAL_MS) {
if (isMissing(rawValue)) {
return parseIntegerInterval(fallback, MIN_CRAWL_INTERVAL_MS, "crawl interval(ms)");
}
return parseIntegerInterval(rawValue, MIN_CRAWL_INTERVAL_MS, "crawl interval(ms)");
}
module.exports = {
MIN_CRAWL_INTERVAL_SEC,
MIN_CRAWL_INTERVAL_MS,
normalizeCrawlIntervalSec,
normalizeCrawlIntervalMs,
};

View File

@@ -1,6 +1,7 @@
"use strict"; "use strict";
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const { normalizeCrawlIntervalMs } = require("./pollingConfig");
function cloneJson(value) { function cloneJson(value) {
if (value === undefined) return undefined; if (value === undefined) return undefined;
@@ -97,6 +98,7 @@ function buildAlertEvent(watch, previousSnapshot, currentSnapshot) {
const uniqueReasons = [...new Set(reasons)]; const uniqueReasons = [...new Set(reasons)];
return { return {
watchId: watch.id, watchId: watch.id,
ownerId: watch.ownerId || null,
rawInput: watch.rawInput, rawInput: watch.rawInput,
eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0], eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0],
reasons: uniqueReasons, reasons: uniqueReasons,
@@ -121,10 +123,7 @@ class PriceWatcher {
constructor(options = {}) { constructor(options = {}) {
this.crawler = options.crawler; this.crawler = options.crawler;
this.notifier = options.notifier; this.notifier = options.notifier;
this.pollIntervalMs = this.pollIntervalMs = normalizeCrawlIntervalMs(options.pollIntervalMs);
Number.isFinite(Number(options.pollIntervalMs)) && Number(options.pollIntervalMs) > 0
? Number(options.pollIntervalMs)
: 60000;
this.logger = options.logger || console; this.logger = options.logger || console;
this.now = options.now || (() => new Date()); this.now = options.now || (() => new Date());
this.onWatchPolled = this.onWatchPolled =
@@ -139,6 +138,7 @@ class PriceWatcher {
this.watches = new Map(); this.watches = new Map();
this.timer = null; this.timer = null;
this.pollingInFlight = false;
this.globalControls = { this.globalControls = {
crawlingEnabled: true, crawlingEnabled: true,
alertsEnabled: true, alertsEnabled: true,
@@ -148,6 +148,7 @@ class PriceWatcher {
toPublicWatch(watch) { toPublicWatch(watch) {
return { return {
id: watch.id, id: watch.id,
ownerId: watch.ownerId || null,
rawInput: watch.rawInput, rawInput: watch.rawInput,
searchParams: cloneJson(watch.searchParams), searchParams: cloneJson(watch.searchParams),
alertRules: cloneJson(watch.alertRules), alertRules: cloneJson(watch.alertRules),
@@ -201,6 +202,11 @@ class PriceWatcher {
if (Object.prototype.hasOwnProperty.call(patch, "rawInput")) { if (Object.prototype.hasOwnProperty.call(patch, "rawInput")) {
watch.rawInput = typeof patch.rawInput === "string" ? patch.rawInput : watch.rawInput; watch.rawInput = typeof patch.rawInput === "string" ? patch.rawInput : watch.rawInput;
} }
if (Object.prototype.hasOwnProperty.call(patch, "ownerId")) {
const ownerId =
typeof patch.ownerId === "string" && patch.ownerId.trim() ? patch.ownerId.trim() : null;
watch.ownerId = ownerId;
}
if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) { if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) {
if (!patch.searchParams || typeof patch.searchParams !== "object") { if (!patch.searchParams || typeof patch.searchParams !== "object") {
throw new Error("searchParams must be an object"); throw new Error("searchParams must be an object");
@@ -241,6 +247,7 @@ class PriceWatcher {
addWatch({ addWatch({
id, id,
ownerId = null,
rawInput, rawInput,
searchParams, searchParams,
alertRules, alertRules,
@@ -263,6 +270,7 @@ class PriceWatcher {
const nowIso = new Date(this.now()).toISOString(); const nowIso = new Date(this.now()).toISOString();
this.watches.set(watchId, { this.watches.set(watchId, {
id: watchId, id: watchId,
ownerId: typeof ownerId === "string" && ownerId.trim() ? ownerId.trim() : null,
rawInput: typeof rawInput === "string" ? rawInput : "", rawInput: typeof rawInput === "string" ? rawInput : "",
searchParams, searchParams,
alertRules: normalizeAlertRules(alertRules), alertRules: normalizeAlertRules(alertRules),
@@ -301,38 +309,53 @@ class PriceWatcher {
} }
async pollAll() { async pollAll() {
if (!this.globalControls.crawlingEnabled) { if (this.pollingInFlight) {
const skipped = []; return [
for (const watch of this.watches.values()) { {
const result = {
watchId: watch.id,
skipped: { skipped: {
reason: "global_crawling_disabled", reason: "poll_cycle_in_progress",
}, },
}; },
skipped.push(result); ];
await this.emitPolled(watch, result);
}
return skipped;
} }
const results = []; this.pollingInFlight = true;
for (const watch of this.watches.values()) { try {
if (!watch.pollingEnabled) { if (!this.globalControls.crawlingEnabled) {
const result = { const skipped = [];
watchId: watch.id, for (const watch of this.watches.values()) {
skipped: { const result = {
reason: "watch_polling_disabled", watchId: watch.id,
}, skipped: {
}; reason: "global_crawling_disabled",
results.push(result); },
await this.emitPolled(watch, result); };
continue; 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) { async pollWatch(watchId) {
@@ -359,15 +382,85 @@ class PriceWatcher {
return result; return result;
} }
try { // IP 차단 방지를 위한 최종 안전장치: 필수 정보 부재 시 크롤링 건너뜀
const offers = await this.crawler.getQuotes({ const params = watch.searchParams || {};
if (!params.segments || params.segments.length === 0 || !params.departureDateWindow?.from) {
const result = {
watchId: watch.id, watchId: watch.id,
rawInput: watch.rawInput, skipped: { reason: "missing_essential_search_params" },
searchParams: watch.searchParams, };
}); await this.emitPolled(watch, result);
const normalizedOffers = normalizeOffers(offers); return result;
if (normalizedOffers.length === 0) { }
throw new Error("Crawler returned no valid offers");
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]; const bestOffer = normalizedOffers[0];
@@ -388,7 +481,9 @@ class PriceWatcher {
if (alert) { if (alert) {
if (this.globalControls.alertsEnabled && watch.alertsEnabled) { if (this.globalControls.alertsEnabled && watch.alertsEnabled) {
try { try {
await this.notifier.notify(alert); await this.notifier.notify(alert, {
watch: this.toPublicWatch(watch),
});
notificationSent = true; notificationSent = true;
} catch (error) { } catch (error) {
const at = new Date(this.now()).toISOString(); const at = new Date(this.now()).toISOString();

52
test/apiAuth.test.js Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { isAuthorizedRequest, resolveApiAuth } = require("../src/apiAuth");
test("resolveApiAuth requires token by default in production", () => {
assert.throws(() => resolveApiAuth({ nodeEnv: "production" }), /DASHBOARD_API_TOKEN/);
});
test("resolveApiAuth is disabled by default in non-production", () => {
const authConfig = resolveApiAuth({ nodeEnv: "development" });
assert.equal(authConfig.enabled, false);
assert.equal(authConfig.token, "");
});
test("isAuthorizedRequest supports bearer and x-api-key headers", () => {
const authConfig = resolveApiAuth({
nodeEnv: "production",
apiToken: "top-secret-token",
});
assert.equal(
isAuthorizedRequest(
{
authorization: "Bearer top-secret-token",
},
authConfig
),
true
);
assert.equal(
isAuthorizedRequest(
{
"x-api-key": "top-secret-token",
},
authConfig
),
true
);
assert.equal(
isAuthorizedRequest(
{
authorization: "Bearer wrong-token",
},
authConfig
),
false
);
});

404
test/crawlerUrls.test.js Normal file
View File

@@ -0,0 +1,404 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
// const { buildNaverUrl } = require("../src/crawlers/naver");
const { buildSkyscannerUrl } = require("../src/crawlers/skyscanner");
const { buildGoogleUrl } = require("../src/crawlers/google");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeParams(overrides = {}) {
return {
tripType: "one_way",
segments: [{ from: "ICN", to: "NRT" }],
passengers: { total: 1, byCabin: {} },
departureDateWindow: { from: "2026-03-15" },
...overrides,
};
}
// ---------------------------------------------------------------------------
// Naver URL tests (provider 일시 제외)
// ---------------------------------------------------------------------------
// test("naver: one-way URL format", () => {
// const url = buildNaverUrl(makeParams());
// assert.match(url, /flight\.naver\.com\/flights\/international\/ICN-NRT-20260315/);
// assert.match(url, /adult=1/);
// assert.match(url, /fareType=Y/);
// assert.ok(!url.includes("/NRT-ICN-"));
// });
// test("naver: round-trip URL has separate outbound and return segments", () => {
// const url = buildNaverUrl(makeParams({ tripType: "round_trip", stayDurationDays: { minDays: 7 } }));
// assert.match(url, /ICN-NRT-20260315/);
// assert.match(url, /\/NRT-ICN-20260322/);
// assert.match(url, /fareType=Y/);
// });
// test("naver: round-trip defaults to 7-day return when stayDurationDays is missing", () => {
// const url = buildNaverUrl(makeParams({ tripType: "round_trip" }));
// assert.match(url, /\/NRT-ICN-20260322/);
// });
// test("naver: multi-city uses /flights/multi and colon separators", () => {
// const url = buildNaverUrl(makeParams({
// tripType: "multi_city",
// segments: [{ from: "ICN", to: "NRT" }, { from: "NRT", to: "ICN" }],
// }));
// assert.match(url, /\/flights\/multi\?/);
// assert.match(url, /ICN:NRT:20260315/);
// assert.match(url, /NRT:ICN:20260318/);
// });
// test("naver: business class maps to fareType=C", () => {
// const url = buildNaverUrl(makeParams({ passengers: { total: 2, byCabin: { business: 2 } } }));
// assert.match(url, /fareType=C/);
// assert.match(url, /adult=2/);
// });
// test("naver: first class maps to fareType=F", () => {
// const url = buildNaverUrl(makeParams({ passengers: { total: 1, byCabin: { first: 1 } } }));
// assert.match(url, /fareType=F/);
// });
// test("naver: empty segments returns base URL", () => {
// const url = buildNaverUrl({ segments: [] });
// assert.equal(url, "https://flight.naver.com");
// });
// ---------------------------------------------------------------------------
// Skyscanner URL tests (/transport/d/ format with YYYY-MM-DD)
// ---------------------------------------------------------------------------
test("skyscanner: one-way uses /transport/d/ with YYYY-MM-DD", () => {
const url = buildSkyscannerUrl(makeParams());
assert.match(url, /\/transport\/d\/icn\/2026-03-15\/nrt\//);
assert.match(url, /adultsv2=1/);
assert.match(url, /cabinclass=economy/);
});
test("skyscanner: round-trip path has outbound and return legs", () => {
const url = buildSkyscannerUrl(
makeParams({
tripType: "round_trip",
stayDurationDays: { minDays: 5 },
})
);
// /icn/2026-03-15/nrt/nrt/2026-03-20/icn/
assert.match(url, /\/icn\/2026-03-15\/nrt\/nrt\/2026-03-20\/icn\//);
});
test("skyscanner: round-trip defaults to 7-day return", () => {
const url = buildSkyscannerUrl(
makeParams({ tripType: "round_trip" })
);
assert.match(url, /\/nrt\/2026-03-22\/icn\//);
});
test("skyscanner: multi-city path-based with multiple legs", () => {
const url = buildSkyscannerUrl(
makeParams({
tripType: "multi_city",
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
})
);
// /transport/d/icn/2026-03-15/mad/bcn/2026-03-22/icn/ (default 7-day stay)
assert.match(url, /\/transport\/d\/icn\/2026-03-15\/mad\/bcn\/2026-03-22\/icn\//);
});
test("skyscanner: business cabin class", () => {
const url = buildSkyscannerUrl(
makeParams({ passengers: { total: 1, byCabin: { business: 1 } } })
);
assert.match(url, /cabinclass=business/);
});
test("skyscanner: duration param (total journey minutes)", () => {
const url = buildSkyscannerUrl(
makeParams({ constraints: { maxJourneyHours: { hours: 33.5 } } })
);
assert.match(url, /duration=2010/); // 33.5 * 60
});
test("skyscanner: maxStops=0 → stops=!oneStop,!twoPlusStops (direct only)", () => {
const url = buildSkyscannerUrl(
makeParams({ constraints: { maxStops: 0 } })
);
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, "!oneStop,!twoPlusStops");
});
test("skyscanner: maxStops=1 → stops=!twoPlusStops (direct + 1 stop)", () => {
const url = buildSkyscannerUrl(
makeParams({ constraints: { maxStops: 1 } })
);
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, "!twoPlusStops");
});
test("skyscanner: no maxStops → no stops param", () => {
const url = buildSkyscannerUrl(makeParams());
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, null);
});
test("skyscanner: matches real URL structure (multi-city + business + stops + duration)", () => {
const url = buildSkyscannerUrl({
tripType: "multi_city",
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
passengers: { total: 2, byCabin: { business: 2 } },
departureDateWindow: { from: "2026-11-26" },
stayDurationDays: { minDays: 19 },
constraints: { maxStops: 1, maxJourneyHours: { hours: 33.5 } },
});
// Path: return leg uses stayDurationDays.minDays=19 → 2026-11-26 + 19 = 2026-12-15
assert.match(url, /\/transport\/d\/icn\/2026-11-26\/mad\/bcn\/2026-12-15\/icn\//);
// Params
assert.match(url, /adultsv2=2/);
assert.match(url, /cabinclass=business/);
assert.match(url, /duration=2010/);
const stops = new URL(url).searchParams.get("stops");
assert.equal(stops, "!twoPlusStops");
});
test("skyscanner: empty segments returns base URL", () => {
const url = buildSkyscannerUrl({ segments: [] });
assert.equal(url, "https://www.skyscanner.co.kr");
});
// ---------------------------------------------------------------------------
// Google URL tests (protobuf tfs format)
// ---------------------------------------------------------------------------
/**
* Helper: decode URL-safe base64 tfs param and parse protobuf fields.
* Returns a flat-ish structure for easy assertions.
*/
function decodeTfs(url) {
const tfs = new URL(url).searchParams.get("tfs");
if (!tfs) return null;
const std = tfs.replace(/-/g, "+").replace(/_/g, "/");
const buf = Buffer.from(std, "base64");
return parseProtobuf(buf);
}
function readVarint(buf, pos) {
let value = 0n;
let shift = 0n;
while (pos < buf.length) {
const byte = buf[pos++];
value |= BigInt(byte & 0x7f) << shift;
shift += 7n;
if ((byte & 0x80) === 0) break;
}
return { value: Number(value), next: pos };
}
function parseProtobuf(buf) {
const results = [];
let pos = 0;
while (pos < buf.length) {
const tag = readVarint(buf, pos);
pos = tag.next;
const fieldNum = tag.value >> 3;
const wireType = tag.value & 0x7;
if (wireType === 0) {
const val = readVarint(buf, pos);
pos = val.next;
results.push({ f: fieldNum, t: "varint", v: val.value });
} else if (wireType === 2) {
const len = readVarint(buf, pos);
pos = len.next;
const data = buf.slice(pos, pos + len.value);
pos += len.value;
const str = data.toString("utf8");
if (/^[\x20-\x7E]+$/.test(str)) {
results.push({ f: fieldNum, t: "str", v: str });
} else {
try {
const nested = parseProtobuf(data);
results.push({ f: fieldNum, t: "msg", v: nested });
} catch {
results.push({ f: fieldNum, t: "bytes", v: data });
}
}
} else {
break;
}
}
return results;
}
/** Extract all field_3 (segment) messages from decoded tfs */
function getSegments(decoded) {
return decoded.filter((f) => f.f === 3 && f.t === "msg").map((f) => f.v);
}
/** Find a field value in a decoded protobuf array */
function findField(decoded, fieldNum) {
const f = decoded.find((x) => x.f === fieldNum);
return f ? f.v : undefined;
}
test("google: uses protobuf tfs format (not ?q= query)", () => {
const url = buildGoogleUrl(makeParams());
assert.match(url, /\/flights\/search\?tfs=/);
assert.match(url, /&tfu=/);
assert.ok(!url.includes("?q="));
});
test("google: one-way has 1 segment with correct airports and date", () => {
const url = buildGoogleUrl(makeParams());
const decoded = decodeTfs(url);
const segs = getSegments(decoded);
assert.equal(segs.length, 1);
// Date
assert.equal(findField(segs[0], 2), "2026-03-15");
// Origin: field 13 → nested field 2 = "ICN"
const origin = findField(segs[0], 13);
assert.equal(findField(origin, 2), "ICN");
// Dest: field 14 → nested field 2 = "NRT"
const dest = findField(segs[0], 14);
assert.equal(findField(dest, 2), "NRT");
});
test("google: round-trip has 2 segments with reversed airports", () => {
const url = buildGoogleUrl(
makeParams({ tripType: "round_trip", stayDurationDays: { minDays: 7 } })
);
const decoded = decodeTfs(url);
const segs = getSegments(decoded);
assert.equal(segs.length, 2);
// Outbound: ICN → NRT on 2026-03-15
assert.equal(findField(segs[0], 2), "2026-03-15");
assert.equal(findField(findField(segs[0], 13), 2), "ICN");
assert.equal(findField(findField(segs[0], 14), 2), "NRT");
// Return: NRT → ICN on 2026-03-22
assert.equal(findField(segs[1], 2), "2026-03-22");
assert.equal(findField(findField(segs[1], 13), 2), "NRT");
assert.equal(findField(findField(segs[1], 14), 2), "ICN");
});
test("google: round-trip defaults to 7-day return when stayDurationDays is missing", () => {
const url = buildGoogleUrl(makeParams({ tripType: "round_trip" }));
const segs = getSegments(decodeTfs(url));
assert.equal(segs.length, 2);
assert.equal(findField(segs[1], 2), "2026-03-22");
});
test("google: maxJourneyHours encodes as field_12 in minutes", () => {
const url = buildGoogleUrl(
makeParams({ constraints: { maxJourneyHours: { hours: 9 } } })
);
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 12), 540); // 9h * 60
});
test("google: maxStops encodes as field_5", () => {
const url = buildGoogleUrl(
makeParams({ constraints: { maxStops: 1 } })
);
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 5), 1);
});
test("google: maxStops=0 means direct flights only", () => {
const url = buildGoogleUrl(
makeParams({ constraints: { maxStops: 0 } })
);
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 5), 0);
});
test("google: no maxStops omits field_5", () => {
const url = buildGoogleUrl(makeParams());
const segs = getSegments(decodeTfs(url));
assert.equal(findField(segs[0], 5), undefined);
});
test("google: byte-exact match with known duration-filter URL", () => {
const url = buildGoogleUrl({
tripType: "round_trip",
segments: [{ from: "ICN", to: "NRT" }],
passengers: { total: 1, byCabin: {} },
departureDateWindow: { from: "2026-03-15" },
stayDurationDays: { minDays: 7 },
constraints: { maxJourneyHours: { hours: 9 } },
});
const got = new URL(url).searchParams.get("tfs");
const expected =
"CBwQAhohEgoyMDI2LTAzLTE1YJwEagcIARIDSUNOcgcIARIDTlJUGiESCjIwMjYtMDMtMjJgnARqBwgBEgNOUlRyBwgBEgNJQ05AAUgBcAGCAQsI____________AZgBAQ";
assert.equal(got, expected);
});
test("google: byte-exact match with known 1-stop URL", () => {
const url = buildGoogleUrl({
tripType: "round_trip",
segments: [{ from: "ICN", to: "NRT" }],
passengers: { total: 1, byCabin: {} },
departureDateWindow: { from: "2026-03-15" },
stayDurationDays: { minDays: 7 },
constraints: { maxStops: 1, maxJourneyHours: { hours: 9 } },
});
const got = new URL(url).searchParams.get("tfs");
const expected =
"CBwQAhojEgoyMDI2LTAzLTE1KAFgnARqBwgBEgNJQ05yBwgBEgNOUlQaIxIKMjAyNi0wMy0yMigBYJwEagcIARIDTlJUcgcIARIDSUNOQAFIAXABggELCP___________wGYAQE";
assert.equal(got, expected);
});
test("google: field 19 encodes trip type (1=RT, 2=OW, 3=MC)", () => {
const ow = buildGoogleUrl(makeParams({ tripType: "one_way" }));
assert.equal(findField(decodeTfs(ow), 19), 2);
const rt = buildGoogleUrl(makeParams({ tripType: "round_trip" }));
assert.equal(findField(decodeTfs(rt), 19), 1);
const mc = buildGoogleUrl(makeParams({
tripType: "multi_city",
segments: [{ from: "ICN", to: "NRT" }, { from: "NRT", to: "LAX" }],
}));
assert.equal(findField(decodeTfs(mc), 19), 3);
});
test("google: multi-city has correct number of segments", () => {
const url = buildGoogleUrl(
makeParams({
tripType: "multi_city",
segments: [
{ from: "ICN", to: "NRT" },
{ from: "NRT", to: "LAX" },
],
})
);
const decoded = decodeTfs(url);
assert.equal(findField(decoded, 2), 2);
const segs = getSegments(decoded);
assert.equal(segs.length, 2);
assert.equal(findField(findField(segs[1], 13), 2), "NRT");
assert.equal(findField(findField(segs[1], 14), 2), "LAX");
assert.equal(findField(segs[1], 2), "2026-03-22"); // default 7-day stay
});
test("google: business class encodes as cabinClass=3 (field_9)", () => {
const url = buildGoogleUrl(
makeParams({ passengers: { total: 2, byCabin: { business: 2 } } })
);
const decoded = decodeTfs(url);
assert.equal(findField(decoded, 8), 2); // adults
assert.equal(findField(decoded, 9), 3); // business = 3
});
test("google: empty segments returns base URL", () => {
const url = buildGoogleUrl({ segments: [] });
assert.equal(url, "https://www.google.com/travel/flights");
});

View File

@@ -0,0 +1,133 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createDashboardRuntime } = require("../src/dashboardRuntime");
function createStore({ controls }) {
const events = [];
const watches = [
{
id: "watch-1",
rawInput: "인천->도쿄",
searchParams: { segments: [{ from: "ICN", to: "NRT" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: {
targetPrice: null,
notifyOnPriceChange: true,
notifyOnFirstResult: true,
},
pollingEnabled: true,
alertsEnabled: true,
createdAt: "2026-02-19T00:00:00.000Z",
updatedAt: "2026-02-19T00:00:00.000Z",
lastSnapshot: null,
lastError: null,
},
];
return {
events,
async init() {},
async close() {},
async listWatches() {
return watches;
},
async getWatch() {
return null;
},
async saveWatch(watch) {
return watch;
},
async deleteWatch() {
return true;
},
async savePollResult() {},
async saveEvent(event) {
events.push(event);
return event;
},
async listEvents() {
return events;
},
async getGlobalControls() {
return controls;
},
async setGlobalControls(patch = {}) {
controls = { ...controls, ...patch };
return controls;
},
};
}
test("runtime stores failed notification state when notifier throws", async () => {
const store = createStore({
controls: { crawlingEnabled: true, alertsEnabled: true },
});
const crawler = {
async getQuotes() {
return [{ provider: "mock", price: 120000, currency: "KRW" }];
},
};
const notifier = {
async notify() {
throw new Error("network down");
},
};
const runtime = await createDashboardRuntime({
store,
crawler,
notifier,
logger: { error: () => {} },
pollIntervalSec: 3600,
});
try {
assert.equal(store.events.length, 1);
const payload = store.events[0].payload;
assert.equal(payload.notificationState, "failed");
assert.equal(payload.notificationSent, false);
assert.equal(payload.notificationSuppressed, undefined);
assert.equal(payload.notificationError.phase, "notify");
assert.match(payload.notificationError.message, /Notifier failed/);
} finally {
await runtime.close();
}
});
test("runtime stores suppressed notification state when alerts are disabled", async () => {
let notifyCalls = 0;
const store = createStore({
controls: { crawlingEnabled: true, alertsEnabled: false },
});
const crawler = {
async getQuotes() {
return [{ provider: "mock", price: 120000, currency: "KRW" }];
},
};
const notifier = {
async notify() {
notifyCalls += 1;
},
};
const runtime = await createDashboardRuntime({
store,
crawler,
notifier,
logger: { error: () => {} },
pollIntervalSec: 3600,
});
try {
assert.equal(store.events.length, 1);
const payload = store.events[0].payload;
assert.equal(payload.notificationState, "suppressed");
assert.equal(payload.notificationSent, false);
assert.equal(payload.notificationSuppressed, true);
assert.equal(payload.notificationError, null);
assert.equal(notifyCalls, 0);
} finally {
await runtime.close();
}
});

View File

@@ -2,7 +2,38 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { InMemoryDashboardStore } = require("../src/dashboardStore"); const {
InMemoryDashboardStore,
MySqlDashboardStore,
createDashboardStore,
} = require("../src/dashboardStore");
async function withPatchedEnv(patch, fn) {
const backup = {};
for (const [key] of Object.entries(patch)) {
backup[key] = process.env[key];
}
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await fn();
} finally {
for (const [key, value] of Object.entries(backup)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("in-memory store persists watch, poll result, events and controls", async () => { test("in-memory store persists watch, poll result, events and controls", async () => {
const store = new InMemoryDashboardStore(); const store = new InMemoryDashboardStore();
@@ -63,3 +94,130 @@ test("in-memory store persists watch, poll result, events and controls", async (
assert.equal(controlsAfter.crawlingEnabled, false); assert.equal(controlsAfter.crawlingEnabled, false);
assert.equal(controlsAfter.alertsEnabled, false); assert.equal(controlsAfter.alertsEnabled, false);
}); });
test("mysql mode fails closed without fallback when configuration is missing", async () => {
await withPatchedEnv(
{
DASHBOARD_DB: undefined,
MYSQL_URL: undefined,
MYSQL_HOST: undefined,
MYSQL_USER: undefined,
MYSQL_DATABASE: undefined,
DASHBOARD_ALLOW_MEMORY_FALLBACK: undefined,
},
async () => {
await assert.rejects(
() =>
createDashboardStore({
mode: "mysql",
allowMemoryFallback: false,
}),
/MySQL 연결 정보가 필요합니다/
);
}
);
});
test("mysql mode can fallback to memory only when explicitly allowed", async () => {
await withPatchedEnv(
{
DASHBOARD_DB: undefined,
MYSQL_URL: undefined,
MYSQL_HOST: undefined,
MYSQL_USER: undefined,
MYSQL_DATABASE: undefined,
DASHBOARD_ALLOW_MEMORY_FALLBACK: undefined,
},
async () => {
const setup = await createDashboardStore({
mode: "mysql",
allowMemoryFallback: true,
});
assert.equal(setup.engine, "memory");
assert.match(setup.warning, /메모리 저장소/);
await setup.store.close();
}
);
});
test("mysql store init fails when required tables are missing", async () => {
const calls = [];
const store = new MySqlDashboardStore(
{
query: async (sql) => {
calls.push(sql);
return [[{ TABLE_NAME: "projects" }], []];
},
end: async () => {},
}
);
await assert.rejects(
() => store.init(),
/MySQL playground 스키마가 준비되지 않았습니다\..*project_documents.*project_events.*project_settings/
);
assert.equal(calls.length, 1);
});
test("mysql store init succeeds when all required tables exist", async () => {
const calls = [];
const store = new MySqlDashboardStore(
{
query: async (sql, params) => {
calls.push(sql);
if (String(sql).includes("FROM information_schema.TABLES")) {
return [
[
{ TABLE_NAME: "projects" },
{ TABLE_NAME: "project_documents" },
{ TABLE_NAME: "project_events" },
{ TABLE_NAME: "project_settings" },
],
[],
];
}
if (String(sql).includes("INSERT INTO projects")) {
assert.equal(params[0], "air-watcher");
return [{ affectedRows: 1 }, []];
}
throw new Error(`unexpected query: ${sql}`);
},
end: async () => {},
}
);
await assert.doesNotReject(() => store.init());
assert.equal(store.schemaMode, "playground");
assert.equal(calls.length, 2);
});
test("mysql store supports custom projectKey", async () => {
let insertedProjectKey = null;
const store = new MySqlDashboardStore({
query: async (sql, params) => {
if (String(sql).includes("FROM information_schema.TABLES")) {
return [
[
{ TABLE_NAME: "projects" },
{ TABLE_NAME: "project_documents" },
{ TABLE_NAME: "project_events" },
{ TABLE_NAME: "project_settings" },
],
[],
];
}
if (String(sql).includes("INSERT INTO projects")) {
insertedProjectKey = params[0];
return [{ affectedRows: 1 }, []];
}
throw new Error(`unexpected query: ${sql}`);
},
end: async () => {},
}, {
projectKey: "mini-app-a",
});
await store.init();
assert.equal(insertedProjectKey, "mini-app-a");
});

View File

@@ -0,0 +1,26 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHttpError, toPublicErrorResponse } = require("../src/dashboardUtils");
test("toPublicErrorResponse keeps 4xx error details", () => {
const failure = toPublicErrorResponse(createHttpError(400, "잘못된 요청"));
assert.equal(failure.statusCode, 400);
assert.equal(failure.body.error, "잘못된 요청");
});
test("toPublicErrorResponse masks 5xx errors", () => {
const loggerCalls = [];
const failure = toPublicErrorResponse(new Error("db password exposed"), {
logger: {
error(message) {
loggerCalls.push(message);
},
},
});
assert.equal(failure.statusCode, 500);
assert.equal(failure.body.error, "Internal Server Error");
assert.equal(loggerCalls.length, 1);
});

View File

@@ -2,7 +2,10 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { extractFlightSearchRequest } = require("../src/llmParameterExtractor"); const {
createOpenAIClient,
extractFlightSearchRequest,
} = require("../src/llmParameterExtractor");
test("uses LLM output when client is provided", async () => { test("uses LLM output when client is provided", async () => {
const llmClient = async () => ({ const llmClient = async () => ({
@@ -67,3 +70,102 @@ test("falls back to rule parser when LLM client fails", async () => {
true true
); );
}); });
test("corrects LLM round_trip to multi_city when segments have different cities", async () => {
// LLM incorrectly says round_trip for ICN→MAD / BCN→ICN (MAD ≠ BCN)
const llmClient = async () => ({
departureDateWindow: { from: "2026-11-25", to: "2026-11-25" },
stayDurationDays: { minDays: 12, maxDays: 12 },
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
passengers: { total: 3, byCabin: { economy: 1, premium_economy: 0, business: 2, first: 0 } },
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: 1, maxJourneyHours: { hours: 42, operator: "<" } },
tripType: "round_trip",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("인천-마드리드, 바르셀로나-인천", {
now: new Date("2026-02-20T00:00:00Z"),
llmClient,
});
assert.equal(result.source, "llm");
// Must be corrected to multi_city, not round_trip
assert.equal(result.params.tripType, "multi_city");
assert.equal(result.params.segments.length, 2);
assert.equal(result.params.segments[0].from, "ICN");
assert.equal(result.params.segments[1].from, "BCN");
});
test("keeps round_trip when segments are genuinely reversed (A→B / B→A)", async () => {
const llmClient = async () => ({
departureDateWindow: { from: "2026-06-01", to: "2026-06-01" },
stayDurationDays: { minDays: 7, maxDays: 7 },
segments: [
{ from: "ICN", to: "NRT" },
{ from: "NRT", to: "ICN" },
],
passengers: { total: 1, byCabin: { economy: 1, premium_economy: 0, business: 0, first: 0 } },
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: null, maxJourneyHours: null },
tripType: "round_trip",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("인천-도쿄 왕복", {
now: new Date("2026-02-20T00:00:00Z"),
llmClient,
});
assert.equal(result.params.tripType, "round_trip");
});
test("corrects open_jaw to multi_city", async () => {
const llmClient = async () => ({
departureDateWindow: { from: "2026-06-01", to: "2026-06-01" },
stayDurationDays: { minDays: 10, maxDays: 10 },
segments: [
{ from: "ICN", to: "MAD" },
{ from: "BCN", to: "ICN" },
],
passengers: { total: 1, byCabin: { economy: 1, premium_economy: 0, business: 0, first: 0 } },
constraints: { sameFlightForAllPassengers: true, itineraryCount: null, maxStops: null, maxJourneyHours: null },
tripType: "open_jaw",
warnings: [],
missingFields: [],
});
const result = await extractFlightSearchRequest("인천-마드리드, 바르셀로나-인천", {
now: new Date("2026-02-20T00:00:00Z"),
llmClient,
});
assert.equal(result.params.tripType, "multi_city");
});
test("createOpenAIClient aborts timed out requests", async () => {
const llmClient = createOpenAIClient({
apiKey: "dummy-key",
timeoutMs: 10,
fetch: async (_url, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => {
const abortError = new Error("aborted");
abortError.name = "AbortError";
reject(abortError);
});
}),
});
await assert.rejects(
() =>
llmClient({
input: "임의 입력",
now: new Date("2026-02-19T00:00:00Z"),
}),
/timed out/
);
});

View File

@@ -0,0 +1,28 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
MIN_CRAWL_INTERVAL_MS,
MIN_CRAWL_INTERVAL_SEC,
normalizeCrawlIntervalMs,
normalizeCrawlIntervalSec,
} = require("../src/pollingConfig");
test("normalizeCrawlIntervalSec enforces minimum 1 hour", () => {
assert.equal(normalizeCrawlIntervalSec(undefined), MIN_CRAWL_INTERVAL_SEC);
assert.equal(normalizeCrawlIntervalSec(3600), 3600);
assert.equal(normalizeCrawlIntervalSec(7200), 7200);
assert.throws(() => normalizeCrawlIntervalSec(60), /3600 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalSec(3599), /3600 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalSec("abc"), /정수여야 합니다/);
});
test("normalizeCrawlIntervalMs enforces minimum 1 hour", () => {
assert.equal(normalizeCrawlIntervalMs(undefined), MIN_CRAWL_INTERVAL_MS);
assert.equal(normalizeCrawlIntervalMs(3600000), 3600000);
assert.equal(normalizeCrawlIntervalMs(7200000), 7200000);
assert.throws(() => normalizeCrawlIntervalMs(60000), /3600000 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalMs(3599999), /3600000 이상이어야 합니다/);
assert.throws(() => normalizeCrawlIntervalMs("bad"), /정수여야 합니다/);
});

View File

@@ -45,6 +45,7 @@ test("emits threshold alerts when crossing and improving below threshold", async
rawInput: "인천-마드리드 추적", rawInput: "인천-마드리드 추적",
searchParams: { searchParams: {
segments: [{ from: "ICN", to: "MAD" }], segments: [{ from: "ICN", to: "MAD" }],
departureDateWindow: { from: "2026-06-01" },
}, },
alertRules: { alertRules: {
targetPrice: 900, targetPrice: 900,
@@ -79,6 +80,7 @@ test("emits price change alerts when price changes", async () => {
rawInput: "인천-마드리드 추적", rawInput: "인천-마드리드 추적",
searchParams: { searchParams: {
segments: [{ from: "ICN", to: "MAD" }], segments: [{ from: "ICN", to: "MAD" }],
departureDateWindow: { from: "2026-06-01" },
}, },
alertRules: { alertRules: {
notifyOnPriceChange: true, notifyOnPriceChange: true,
@@ -114,6 +116,7 @@ test("keeps crawl snapshot even when notifier fails", async () => {
rawInput: "인천-마드리드 추적", rawInput: "인천-마드리드 추적",
searchParams: { searchParams: {
segments: [{ from: "ICN", to: "MAD" }], segments: [{ from: "ICN", to: "MAD" }],
departureDateWindow: { from: "2026-06-01" },
}, },
alertRules: { alertRules: {
targetPrice: 980000, targetPrice: 980000,
@@ -132,3 +135,53 @@ test("keeps crawl snapshot even when notifier fails", async () => {
assert.equal(watch.lastSnapshot.bestPrice, 950000); assert.equal(watch.lastSnapshot.bestPrice, 950000);
assert.equal(watch.lastError.phase, "notify"); assert.equal(watch.lastError.phase, "notify");
}); });
test("pollAll skips when another poll cycle is already in progress", async () => {
let release = null;
let started = null;
const startedPromise = new Promise((resolve) => {
started = resolve;
});
const watcher = new PriceWatcher({
crawler: {
async getQuotes() {
started();
await new Promise((resolve) => {
release = resolve;
});
return [
{
provider: "slow-crawler",
price: 999000,
currency: "KRW",
},
];
},
},
notifier: {
async notify() {},
},
logger: createSilentLogger(),
});
watcher.addWatch({
rawInput: "인천-마드리드 추적",
searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: {
notifyOnPriceChange: true,
targetPrice: null,
},
});
const firstCycle = watcher.pollAll();
await startedPromise;
const skipped = await watcher.pollAll();
assert.equal(skipped.length, 1);
assert.equal(skipped[0].skipped.reason, "poll_cycle_in_progress");
release();
await firstCycle;
});

View File

@@ -28,7 +28,7 @@ test("global crawling toggle skips polling", async () => {
const watchId = watcher.addWatch({ const watchId = watcher.addWatch({
rawInput: "테스트", rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: { targetPrice: 900, notifyOnPriceChange: true }, alertRules: { targetPrice: 900, notifyOnPriceChange: true },
}); });
@@ -57,7 +57,7 @@ test("watch-level polling toggle skips polling", async () => {
const watchId = watcher.addWatch({ const watchId = watcher.addWatch({
rawInput: "테스트", rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: { targetPrice: null, notifyOnPriceChange: true }, alertRules: { targetPrice: null, notifyOnPriceChange: true },
pollingEnabled: false, pollingEnabled: false,
}); });
@@ -91,7 +91,7 @@ test("alerts can be suppressed while still computing alert events", async () =>
const watchId = watcher.addWatch({ const watchId = watcher.addWatch({
rawInput: "테스트", rawInput: "테스트",
searchParams: { segments: [{ from: "ICN", to: "MAD" }] }, searchParams: { segments: [{ from: "ICN", to: "MAD" }], departureDateWindow: { from: "2026-06-01" } },
alertRules: { targetPrice: 950, notifyOnPriceChange: true }, alertRules: { targetPrice: 950, notifyOnPriceChange: true },
alertsEnabled: false, alertsEnabled: false,
}); });
@@ -103,3 +103,22 @@ test("alerts can be suppressed while still computing alert events", async () =>
assert.equal(second.alert.eventType, "target_price"); assert.equal(second.alert.eventType, "target_price");
assert.equal(second.alert.notificationSuppressed, true); assert.equal(second.alert.notificationSuppressed, true);
}); });
test("poll interval under 1 hour throws immediately", () => {
assert.throws(
() =>
new PriceWatcher({
crawler: {
async getQuotes() {
return [{ provider: "x", price: 1000, currency: "KRW" }];
},
},
notifier: {
async notify() {},
},
pollIntervalMs: 1000,
logger: createSilentLogger(),
}),
/3600000 이상이어야 합니다/
);
});