initial commit

This commit is contained in:
chungyeong
2026-02-19 17:28:58 +09:00
commit 02970df6af
34 changed files with 5673 additions and 0 deletions

68
src/alertRules.js Normal file
View File

@@ -0,0 +1,68 @@
"use strict";
function createAlertRuleError(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function normalizeAlertOn(alertOnRaw) {
const alertOn = typeof alertOnRaw === "string" ? alertOnRaw.trim().toLowerCase() : "both";
if (["both", "change", "threshold"].includes(alertOn)) {
return alertOn;
}
throw createAlertRuleError("alertOn 값은 both|change|threshold 중 하나여야 합니다.");
}
function parseTargetPrice(value, { allowUndefined = false } = {}) {
if (value === undefined) {
if (allowUndefined) return undefined;
return null;
}
if (value === null || value === "") return null;
const n = Number(value);
if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
throw createAlertRuleError("targetPrice 값은 1 이상의 정수여야 합니다.");
}
return Math.round(n);
}
function buildAlertRules({ targetPrice, alertOn }) {
const normalizedAlertOn = normalizeAlertOn(alertOn || "both");
const normalizedTargetPrice = parseTargetPrice(targetPrice);
if (normalizedAlertOn === "threshold" && normalizedTargetPrice === null) {
throw createAlertRuleError("alertOn이 threshold이면 targetPrice가 필요합니다.");
}
return {
targetPrice: normalizedTargetPrice,
notifyOnPriceChange: normalizedAlertOn === "both" || normalizedAlertOn === "change",
notifyOnFirstResult: false,
};
}
function inferAlertOn(alertRules) {
const targetPrice = alertRules?.targetPrice;
const hasThreshold =
targetPrice !== null &&
targetPrice !== undefined &&
targetPrice !== "" &&
Number.isFinite(Number(targetPrice)) &&
Number(targetPrice) > 0;
const notifyOnChange = alertRules?.notifyOnPriceChange !== false;
if (hasThreshold && notifyOnChange) return "both";
if (hasThreshold) return "threshold";
if (notifyOnChange) return "change";
return "change";
}
module.exports = {
buildAlertRules,
inferAlertOn,
normalizeAlertOn,
parseTargetPrice,
};

207
src/cli.js Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const { buildAlertRules } = require("./alertRules");
const { parseFlightSearchRequest } = require("./naturalLanguageFlightParser");
const { loadDotEnv } = require("./envLoader");
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
const { createCrawlerClient } = require("./crawlerClient");
const { createNotifier } = require("./notifier");
const { PriceWatcher } = require("./priceWatcher");
loadDotEnv();
function readInput(inputTokens) {
const argInput = inputTokens.join(" ").trim();
if (argInput.length > 0) return argInput;
if (!process.stdin.isTTY) {
const stdin = fs.readFileSync(0, "utf8").trim();
if (stdin) return stdin;
}
throw new Error(
"입력 문장이 없습니다. 예: npm run parse -- \"11월 말부터 12월 초까지 ...\" 또는 npm run watch -- \"11월 말부터 12월 초까지 ...\""
);
}
function parseNumberValue(value, flagName) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${flagName} 값이 숫자가 아닙니다: ${value}`);
}
return parsed;
}
function parseWatchOptions(tokens) {
const options = {
intervalSec: 60,
targetPrice: null,
alertOn: "both",
useLlm: true,
once: false,
};
const inputTokens = [];
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (!token.startsWith("--")) {
inputTokens.push(token);
continue;
}
if (token === "--interval-sec") {
const value = tokens[i + 1];
if (!value) throw new Error("--interval-sec 다음에 초 단위 값을 입력하세요.");
options.intervalSec = parseNumberValue(value, "--interval-sec");
i += 1;
continue;
}
if (token === "--target-price") {
const value = tokens[i + 1];
if (!value) throw new Error("--target-price 다음에 금액을 입력하세요.");
options.targetPrice = parseNumberValue(value, "--target-price");
i += 1;
continue;
}
if (token === "--alert-on") {
const value = tokens[i + 1];
if (!value) throw new Error("--alert-on 다음에 both|change|threshold를 입력하세요.");
options.alertOn = value;
i += 1;
continue;
}
if (token === "--rule-only") {
options.useLlm = false;
continue;
}
if (token === "--use-llm") {
options.useLlm = true;
continue;
}
if (token === "--once") {
options.once = true;
continue;
}
throw new Error(`알 수 없는 옵션: ${token}`);
}
if (!Number.isInteger(options.intervalSec) || options.intervalSec <= 0) {
throw new Error("--interval-sec 값은 1 이상의 정수여야 합니다.");
}
if (
options.targetPrice !== null &&
(!Number.isInteger(options.targetPrice) || options.targetPrice <= 0)
) {
throw new Error("--target-price 값은 1 이상의 정수여야 합니다.");
}
if (!["both", "change", "threshold"].includes(options.alertOn)) {
throw new Error("--alert-on 값은 both|change|threshold 중 하나여야 합니다.");
}
if (options.alertOn === "threshold" && options.targetPrice === null) {
throw new Error("--alert-on threshold 사용 시 --target-price가 필요합니다.");
}
return { options, inputTokens };
}
async function runParse(inputTokens) {
const input = readInput(inputTokens);
const result = parseFlightSearchRequest(input);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
async function runWatch(tokens) {
const { options, inputTokens } = parseWatchOptions(tokens);
const input = readInput(inputTokens);
const extracted = await extractFlightSearchRequest(input, {
preferRuleParser: !options.useLlm,
});
const watcher = new PriceWatcher({
crawler: createCrawlerClient(),
notifier: createNotifier(),
pollIntervalMs: options.intervalSec * 1000,
});
const watchId = watcher.addWatch({
rawInput: input,
searchParams: extracted.params,
alertRules: buildAlertRules({
targetPrice: options.targetPrice,
alertOn: options.alertOn,
}),
});
if (options.once) {
const pollResult = await watcher.pollWatch(watchId);
process.stdout.write(
`${JSON.stringify(
{
watchId,
source: extracted.source,
searchParams: extracted.params,
pollResult,
},
null,
2
)}\n`
);
return;
}
process.stdout.write(
`watchId=${watchId} source=${extracted.source} intervalSec=${options.intervalSec}\n`
);
process.stdout.write(`${JSON.stringify(extracted.params, null, 2)}\n`);
await watcher.start();
process.stdout.write("가격 추적을 시작했습니다. 종료하려면 Ctrl+C를 누르세요.\n");
await new Promise((resolve) => {
const shutdown = () => {
watcher.stop();
resolve();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
});
}
async function main() {
const argv = process.argv.slice(2);
const command = argv[0];
if (command === "watch") {
await runWatch(argv.slice(1));
return;
}
if (command === "parse") {
await runParse(argv.slice(1));
return;
}
await runParse(argv);
}
try {
main().catch((error) => {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
});
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
}

427
src/crawlerClient.js Normal file
View File

@@ -0,0 +1,427 @@
"use strict";
const ROUTING_STRATEGY_MAP = {
primaryonly: "primaryOnly",
priorityfallback: "priorityFallback",
parallelrace: "parallelRace",
};
function pickOptionOrEnv(optionValue, envKey) {
return optionValue !== undefined ? optionValue : process.env[envKey];
}
function normalizeProviderName(provider) {
if (typeof provider !== "string") return "";
return provider.trim().toLowerCase();
}
function toProviderEnvKey(provider) {
const normalized = provider.trim().toUpperCase().replace(/[^A-Z0-9]+/g, "_");
return `CRAWLER_ENDPOINT_${normalized}`;
}
function parseProviderList(rawProviders) {
if (Array.isArray(rawProviders)) {
return [...new Set(rawProviders.map((item) => normalizeProviderName(String(item))).filter(Boolean))];
}
if (typeof rawProviders !== "string") return [];
return [
...new Set(
rawProviders
.split(",")
.map((item) => normalizeProviderName(item))
.filter(Boolean)
),
];
}
function normalizeRoutingStrategy(rawStrategy) {
if (rawStrategy === undefined || rawStrategy === null || rawStrategy === "") {
return "priorityFallback";
}
if (typeof rawStrategy !== "string") {
throw new Error("routing strategy must be a string");
}
const collapsed = rawStrategy.trim().toLowerCase().replace(/[\s_-]+/g, "");
const normalized = ROUTING_STRATEGY_MAP[collapsed];
if (!normalized) {
throw new Error(
`Unsupported routing strategy: ${rawStrategy}. Use primaryOnly|priorityFallback|parallelRace`
);
}
return normalized;
}
function normalizeOffer(offer, defaultCurrency, fallbackProvider) {
if (!offer || typeof offer !== "object") return null;
const price = Number(offer.price);
if (!Number.isFinite(price) || price <= 0) return null;
const provider =
typeof offer.provider === "string" && offer.provider.trim()
? offer.provider.trim()
: fallbackProvider || "unknown-provider";
const currency =
typeof offer.currency === "string" && offer.currency.trim()
? offer.currency.trim().toUpperCase()
: defaultCurrency;
return {
provider,
price: Math.round(price),
currency,
fetchedAt: new Date().toISOString(),
metadata: offer.metadata || null,
};
}
function normalizeOffers(offers, defaultCurrency = "KRW", fallbackProvider) {
if (!Array.isArray(offers)) return [];
return offers
.map((offer) => normalizeOffer(offer, defaultCurrency, fallbackProvider))
.filter(Boolean);
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function shouldRetryStatus(statusCode) {
return statusCode === 408 || statusCode === 429 || statusCode >= 500;
}
function isTransientMessage(message) {
return /timeout|timed out|network|fetch failed|socket|econn|enotfound|eai_again/i.test(message);
}
function isAbortError(error) {
return error?.name === "AbortError";
}
function withRetriableFlag(error, retriable) {
if (error && typeof error === "object") {
error.retriable = retriable;
return error;
}
const wrapped = new Error(String(error));
wrapped.retriable = retriable;
return wrapped;
}
function toRequestError(error, requestTimeoutMs) {
if (isAbortError(error)) {
return withRetriableFlag(
new Error(`Crawler request timed out after ${requestTimeoutMs}ms`),
true
);
}
if (!(error instanceof Error)) {
return withRetriableFlag(error, false);
}
if (typeof error.retriable === "boolean") {
return error;
}
return withRetriableFlag(error, isTransientMessage(error.message || ""));
}
function computeRetryDelayMs(attempt, baseDelayMs, maxDelayMs) {
const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
return Math.min(maxDelayMs, exponential);
}
function stableHash(text) {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
}
return hash;
}
function serializeSearchSeed(searchParams) {
const segments = Array.isArray(searchParams?.segments) ? searchParams.segments : [];
const segmentText =
segments.length > 0
? segments.map((segment) => `${segment.from || "?"}-${segment.to || "?"}`).join("|")
: "no-segments";
return JSON.stringify({
segmentText,
departureDateWindow: searchParams?.departureDateWindow || null,
stayDurationDays: searchParams?.stayDurationDays || null,
passengers: searchParams?.passengers?.total || null,
});
}
function createMockCrawler() {
let tick = 0;
return {
async getQuotes({ searchParams }) {
tick += 1;
const seed = stableHash(serializeSearchSeed(searchParams));
const basePrice = 700000 + (seed % 450000);
const driftFactor = ((seed + tick * 17) % 80) - 40;
const best = Math.max(150000, basePrice + driftFactor * 2500);
return [
{
provider: "mock-ota-a",
price: Math.round(best * 1.03),
currency: "KRW",
},
{
provider: "mock-ota-b",
price: Math.round(best),
currency: "KRW",
},
{
provider: "mock-ota-c",
price: Math.round(best * 1.06),
currency: "KRW",
},
];
},
};
}
function createEndpointCrawler(options = {}) {
const endpoint = pickOptionOrEnv(options.endpoint, "CRAWLER_ENDPOINT");
if (!endpoint) {
throw new Error("Crawler endpoint is required");
}
const fallbackProvider = normalizeProviderName(options.provider);
const fetchImpl = options.fetch || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("global fetch is unavailable. Node.js 18+ is required.");
}
const requestTimeoutMs =
parsePositiveInt(
pickOptionOrEnv(options.requestTimeoutMs, "CRAWLER_REQUEST_TIMEOUT_MS"),
15000
);
const maxAttempts = parsePositiveInt(
pickOptionOrEnv(options.maxAttempts, "CRAWLER_MAX_ATTEMPTS"),
2
);
const retryBaseDelayMs = parsePositiveInt(
pickOptionOrEnv(options.retryBaseDelayMs, "CRAWLER_RETRY_BASE_DELAY_MS"),
300
);
const retryMaxDelayMs = parsePositiveInt(
pickOptionOrEnv(options.retryMaxDelayMs, "CRAWLER_RETRY_MAX_DELAY_MS"),
3000
);
return {
async getQuotes({ watchId, searchParams }) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const abortController = new AbortController();
const timeout = setTimeout(() => {
abortController.abort();
}, requestTimeoutMs);
try {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
watchId,
searchParams,
}),
signal: abortController.signal,
});
if (!response.ok) {
const body = await response.text();
throw withRetriableFlag(
new Error(`Crawler request failed (${response.status}): ${body}`),
shouldRetryStatus(response.status)
);
}
const payload = await response.json();
const offers = Array.isArray(payload) ? payload : payload?.offers;
const defaultCurrency =
typeof payload?.currency === "string" ? payload.currency.toUpperCase() : "KRW";
const normalized = normalizeOffers(offers, defaultCurrency, fallbackProvider);
if (normalized.length === 0) {
throw withRetriableFlag(new Error("Crawler returned no valid offers"), false);
}
return normalized;
} catch (error) {
const requestError = toRequestError(error, requestTimeoutMs);
lastError = requestError;
const shouldRetry =
requestError.retriable === true && attempt < maxAttempts;
if (!shouldRetry) {
throw requestError;
}
await sleep(computeRetryDelayMs(attempt, retryBaseDelayMs, retryMaxDelayMs));
} finally {
clearTimeout(timeout);
}
}
throw lastError || new Error("Crawler request failed");
},
};
}
function buildProviderEndpointMap(options = {}) {
if (!options.providerEndpoints || typeof options.providerEndpoints !== "object") {
return new Map();
}
const map = new Map();
for (const [provider, endpoint] of Object.entries(options.providerEndpoints)) {
const normalizedProvider = normalizeProviderName(provider);
if (!normalizedProvider) continue;
if (typeof endpoint !== "string" || endpoint.trim() === "") continue;
map.set(normalizedProvider, endpoint.trim());
}
return map;
}
function createMultiSourceCrawler(options = {}) {
const providers = parseProviderList(pickOptionOrEnv(options.providers, "CRAWLER_PROVIDERS"));
if (providers.length === 0) return null;
const routingStrategy = normalizeRoutingStrategy(
pickOptionOrEnv(options.routingStrategy, "CRAWLER_ROUTING_STRATEGY")
);
const sharedEndpointRaw = pickOptionOrEnv(options.endpoint, "CRAWLER_ENDPOINT");
const sharedEndpoint =
typeof sharedEndpointRaw === "string" && sharedEndpointRaw.trim()
? sharedEndpointRaw.trim()
: null;
const providerEndpointMap = buildProviderEndpointMap(options);
const missingProviders = [];
const providerClients = providers.map((provider) => {
const providerSpecificFromEnv = process.env[toProviderEnvKey(provider)];
const providerSpecific =
providerEndpointMap.get(provider) ||
(typeof providerSpecificFromEnv === "string" && providerSpecificFromEnv.trim()
? providerSpecificFromEnv.trim()
: null);
const endpoint = providerSpecific || sharedEndpoint;
if (!endpoint) {
missingProviders.push(provider);
return null;
}
return {
provider,
crawler: createEndpointCrawler({
...options,
endpoint,
provider,
}),
};
});
if (missingProviders.length > 0) {
throw new Error(
`Missing endpoint for provider(s): ${missingProviders.join(
", "
)}. Set CRAWLER_ENDPOINT_<PROVIDER> or CRAWLER_ENDPOINT.`
);
}
const sources = providerClients.filter(Boolean);
if (sources.length === 0) {
throw new Error("No valid providers configured");
}
return {
async getQuotes(request) {
if (routingStrategy === "primaryOnly") {
const primary = sources[0];
try {
return await primary.crawler.getQuotes(request);
} catch (error) {
throw new Error(`Primary provider failed (${primary.provider}): ${error.message}`);
}
}
if (routingStrategy === "parallelRace") {
const attempts = sources.map(({ provider, crawler }) =>
crawler.getQuotes(request).then((offers) => ({ provider, offers }))
);
try {
const winner = await Promise.any(attempts);
return winner.offers;
} catch (error) {
const reasons = Array.isArray(error?.errors)
? error.errors.map((item) => item?.message || String(item))
: [error?.message || String(error)];
throw new Error(`All provider requests failed: ${reasons.join(" | ")}`);
}
}
const errors = [];
for (const { provider, crawler } of sources) {
try {
return await crawler.getQuotes(request);
} catch (error) {
errors.push(`${provider}: ${error.message}`);
}
}
throw new Error(`All provider requests failed: ${errors.join(" | ")}`);
},
};
}
function createCrawlerClient(options = {}) {
if (options.client && typeof options.client.getQuotes === "function") {
return options.client;
}
if (typeof options.getQuotes === "function") {
return { getQuotes: options.getQuotes };
}
const multiSourceCrawler = createMultiSourceCrawler(options);
if (multiSourceCrawler) {
return multiSourceCrawler;
}
const endpoint = pickOptionOrEnv(options.endpoint, "CRAWLER_ENDPOINT");
if (endpoint) {
return createEndpointCrawler(options);
}
return createMockCrawler();
}
module.exports = {
createCrawlerClient,
createEndpointCrawler,
createMultiSourceCrawler,
createMockCrawler,
};

349
src/dashboard/dashboard.css Normal file
View File

@@ -0,0 +1,349 @@
:root {
--bg-deep: #071422;
--bg-mid: #0d2234;
--bg-soft: #163a56;
--ink-main: #f5fbff;
--ink-sub: #b9cee0;
--accent: #ff9654;
--accent-strong: #ff7f32;
--line: rgba(255, 255, 255, 0.14);
--panel: rgba(8, 25, 40, 0.74);
--danger: #ff6f6f;
--ok: #38e0a2;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: "Space Grotesk", "Pretendard", "Noto Sans KR", sans-serif;
color: var(--ink-main);
background: radial-gradient(circle at 20% -5%, #295a81 0%, var(--bg-deep) 42%),
linear-gradient(130deg, var(--bg-mid), var(--bg-soft));
}
.page-gradient {
position: fixed;
inset: 0;
pointer-events: none;
background: radial-gradient(circle at 85% 20%, rgba(255, 150, 84, 0.22), transparent 36%),
radial-gradient(circle at 20% 80%, rgba(91, 173, 255, 0.2), transparent 35%);
}
.layout {
position: relative;
z-index: 1;
max-width: 1100px;
margin: 0 auto;
padding: 28px 16px 42px;
display: grid;
gap: 14px;
grid-template-columns: 1fr;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
backdrop-filter: blur(8px);
padding: 16px;
box-shadow: 0 18px 30px rgba(3, 10, 17, 0.26);
}
.topbar {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
}
.topbar h1 {
margin: 3px 0 0;
font-size: clamp(1.4rem, 1.7vw, 1.9rem);
letter-spacing: 0.01em;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 0.76rem;
letter-spacing: 0.18em;
font-weight: 700;
}
.config {
color: var(--ink-sub);
font-size: 0.85rem;
text-align: right;
}
.section-title h2 {
margin: 0;
font-size: 1.05rem;
}
.section-title p {
margin: 6px 0 0;
color: var(--ink-sub);
font-size: 0.88rem;
}
.label {
display: block;
margin-top: 14px;
margin-bottom: 6px;
color: var(--ink-sub);
font-size: 0.88rem;
}
textarea,
select,
input[type="number"] {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.24);
background: rgba(255, 255, 255, 0.06);
color: var(--ink-main);
border-radius: 10px;
padding: 10px 11px;
font: inherit;
}
textarea:focus,
select:focus,
input[type="number"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 150, 84, 0.22);
}
.controls-grid {
margin-top: 12px;
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.switch-field {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
padding: 8px 10px;
color: var(--ink-sub);
}
.switch-field input {
accent-color: var(--accent);
}
.field span {
display: block;
color: var(--ink-sub);
font-size: 0.8rem;
margin-bottom: 6px;
}
.action-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.btn {
border: 0;
border-radius: 10px;
padding: 10px 14px;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn.primary {
background: linear-gradient(130deg, var(--accent), var(--accent-strong));
color: #1d1209;
}
.btn.secondary {
background: rgba(255, 255, 255, 0.12);
color: var(--ink-main);
}
.btn.danger {
background: rgba(255, 111, 111, 0.2);
color: #ffd8d8;
}
.summary {
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
background: rgba(255, 255, 255, 0.03);
line-height: 1.45;
}
.summary.empty,
.watch-list.empty,
.event-list.empty,
.json-view.empty {
color: var(--ink-sub);
}
.summary strong {
color: var(--accent);
}
.json-view {
margin: 10px 0 0;
white-space: pre-wrap;
max-height: 220px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
background: rgba(0, 0, 0, 0.2);
font-size: 0.82rem;
}
.toggle-row {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.watch-list,
.event-list {
margin-top: 10px;
display: grid;
gap: 10px;
}
.watch-item,
.event-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
animation: rise 0.26s ease;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.watch-item header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.watch-title {
margin: 0;
font-weight: 700;
font-size: 0.96rem;
}
.watch-sub {
margin: 6px 0 0;
color: var(--ink-sub);
font-size: 0.84rem;
}
.price {
font-size: 1rem;
font-weight: 700;
}
.meta-grid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
font-size: 0.82rem;
color: var(--ink-sub);
}
.meta-grid code {
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.item-actions {
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 3px 8px;
font-size: 0.74rem;
font-weight: 600;
}
.badge.ok {
background: rgba(56, 224, 162, 0.14);
color: #98f2cf;
}
.badge.off {
background: rgba(255, 111, 111, 0.16);
color: #ffb4b4;
}
.event-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.event-head strong {
color: var(--accent);
}
.event-item p {
margin: 8px 0 0;
color: var(--ink-sub);
font-size: 0.84rem;
}
@media (max-width: 860px) {
.controls-grid {
grid-template-columns: 1fr;
}
.meta-grid {
grid-template-columns: 1fr;
}
}

398
src/dashboard/dashboard.js Normal file
View File

@@ -0,0 +1,398 @@
(function () {
"use strict";
const elements = {
configBanner: document.getElementById("configBanner"),
queryInput: document.getElementById("queryInput"),
useLlm: document.getElementById("useLlm"),
alertOn: document.getElementById("alertOn"),
targetPrice: document.getElementById("targetPrice"),
parseBtn: document.getElementById("parseBtn"),
createWatchBtn: document.getElementById("createWatchBtn"),
parseSummary: document.getElementById("parseSummary"),
parseOutput: document.getElementById("parseOutput"),
globalCrawling: document.getElementById("globalCrawling"),
globalAlerts: document.getElementById("globalAlerts"),
watchList: document.getElementById("watchList"),
eventList: document.getElementById("eventList"),
};
const state = {
parsed: null,
watches: [],
events: [],
controls: {
crawlingEnabled: true,
alertsEnabled: true,
},
};
function formatPrice(price, currency) {
if (!Number.isFinite(Number(price))) return "N/A";
return `${new Intl.NumberFormat("ko-KR").format(Number(price))} ${currency || ""}`.trim();
}
function formatDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
async function api(path, options) {
const response = await fetch(path, {
headers: { "content-type": "application/json" },
...options,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `요청 실패 (${response.status})`);
}
return payload;
}
function readTargetPrice() {
const raw = elements.targetPrice.value.trim();
if (!raw) return null;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error("목표 가격은 1 이상의 정수여야 합니다.");
}
return value;
}
function renderParsed(parsedPayload) {
state.parsed = parsedPayload;
elements.parseOutput.classList.remove("empty");
elements.parseOutput.textContent = JSON.stringify(parsedPayload, null, 2);
const params = parsedPayload.params || {};
const segments = Array.isArray(params.segments) ? params.segments : [];
const segmentText = segments
.map((segment) => `${segment.from || "?"} -> ${segment.to || "?"}`)
.join(" / ");
const windowText = params.departureDateWindow
? `${params.departureDateWindow.from} ~ ${params.departureDateWindow.to}`
: "미입력";
const stayText = params.stayDurationDays
? `${params.stayDurationDays.minDays}~${params.stayDurationDays.maxDays}`
: "미입력";
const paxText = Number.isFinite(Number(params.passengers && params.passengers.total))
? `${params.passengers.total}`
: "미입력";
const maxJourney = params.constraints && params.constraints.maxJourneyHours;
const journeyText = maxJourney ? `${maxJourney.hours}시간 ${maxJourney.operator}` : "미입력";
const missing = Array.isArray(params.missingFields) ? params.missingFields : [];
elements.parseSummary.classList.remove("empty");
elements.parseSummary.innerHTML = [
`<div><strong>파서:</strong> ${parsedPayload.source}</div>`,
`<div><strong>구간:</strong> ${segmentText || "미입력"}</div>`,
`<div><strong>출발 윈도우:</strong> ${windowText}</div>`,
`<div><strong>체류 기간:</strong> ${stayText}</div>`,
`<div><strong>탑승객:</strong> ${paxText}</div>`,
`<div><strong>최대 여정시간:</strong> ${journeyText}</div>`,
`<div><strong>누락 필드:</strong> ${missing.length > 0 ? missing.join(", ") : "없음"}</div>`,
].join("");
}
function watchAlertOn(watch) {
const targetPrice = watch && watch.alertRules ? watch.alertRules.targetPrice : null;
const hasThreshold =
targetPrice !== null &&
targetPrice !== undefined &&
targetPrice !== "" &&
Number.isFinite(Number(targetPrice)) &&
Number(targetPrice) > 0;
const notifyOnChange = watch && watch.alertRules ? watch.alertRules.notifyOnPriceChange !== false : true;
if (hasThreshold && notifyOnChange) return "both";
if (hasThreshold) return "threshold";
if (notifyOnChange) return "change";
return "change";
}
function renderWatches() {
if (!state.watches.length) {
elements.watchList.className = "watch-list empty";
elements.watchList.textContent = "아직 등록된 watch가 없습니다.";
return;
}
elements.watchList.className = "watch-list";
elements.watchList.innerHTML = state.watches
.map((watch) => {
const bestPrice = watch.lastSnapshot ? watch.lastSnapshot.bestPrice : null;
const currency = watch.lastSnapshot ? watch.lastSnapshot.currency : "KRW";
const provider = watch.lastSnapshot && watch.lastSnapshot.bestOffer
? watch.lastSnapshot.bestOffer.provider
: "-";
const alertOn = watchAlertOn(watch);
const targetPrice = watch.alertRules ? watch.alertRules.targetPrice : null;
return `
<article class="watch-item" data-watch-id="${watch.id}">
<header>
<div>
<h3 class="watch-title">${watch.rawInput || "(no input)"}</h3>
<p class="watch-sub">watchId: <code>${watch.id}</code></p>
</div>
<div class="price">${formatPrice(bestPrice, currency)}</div>
</header>
<div class="meta-grid">
<div>provider: <code>${provider}</code></div>
<div>마지막 갱신: ${formatDate(watch.lastSnapshot && watch.lastSnapshot.polledAt)}</div>
<div>alert mode: <code>${alertOn}</code></div>
<div>target: ${targetPrice ? formatPrice(targetPrice, currency) : "-"}</div>
</div>
<div class="item-actions">
<label class="switch-field">
<input data-action="toggle-polling" type="checkbox" ${watch.pollingEnabled ? "checked" : ""} />
<span>크롤링</span>
</label>
<label class="switch-field">
<input data-action="toggle-alerts" type="checkbox" ${watch.alertsEnabled ? "checked" : ""} />
<span>알림</span>
</label>
<button class="btn secondary" data-action="poll">즉시 조회</button>
<button class="btn danger" data-action="delete">삭제</button>
</div>
${watch.lastError ? `<p class="watch-sub">오류: ${watch.lastError.message || "unknown"}</p>` : ""}
</article>
`;
})
.join("");
}
function renderEvents() {
if (!state.events.length) {
elements.eventList.className = "event-list empty";
elements.eventList.textContent = "이벤트가 아직 없습니다.";
return;
}
elements.eventList.className = "event-list";
elements.eventList.innerHTML = state.events
.map((event) => {
const payload = event.payload || {};
const sent = payload.notificationSent === true;
const badgeClass = sent ? "ok" : "off";
const badgeLabel = sent ? "알림 발송" : "알림 억제";
return `
<article class="event-item">
<div class="event-head">
<strong>${payload.eventType || event.eventType || "event"}</strong>
<span class="badge ${badgeClass}">${badgeLabel}</span>
</div>
<p>
watchId: <code>${event.watchId}</code><br />
가격: ${formatPrice(payload.currentBestPrice, payload.currency)}
${Number.isFinite(Number(payload.previousBestPrice))
? ` (이전 ${formatPrice(payload.previousBestPrice, payload.currency)})`
: ""}<br />
시각: ${formatDate(event.observedAt)}
</p>
</article>
`;
})
.join("");
}
function renderControls() {
elements.globalCrawling.checked = !!state.controls.crawlingEnabled;
elements.globalAlerts.checked = !!state.controls.alertsEnabled;
}
function setConfigBanner(config) {
const warning = config && config.dbWarning ? ` | warning: ${config.dbWarning}` : "";
elements.configBanner.textContent = `DB: ${config.dbEngine} | poll: ${config.pollIntervalSec}s${warning}`;
}
async function refreshAll() {
const [watchPayload, eventPayload] = await Promise.all([
api("/api/watches"),
api("/api/events?limit=20"),
]);
state.watches = watchPayload.watches || [];
state.controls = watchPayload.controls || state.controls;
state.events = eventPayload.events || [];
renderControls();
renderWatches();
renderEvents();
}
async function onParse() {
const input = elements.queryInput.value.trim();
if (!input) {
alert("입력 문장을 작성하세요.");
return;
}
const parsed = await api("/api/parse", {
method: "POST",
body: JSON.stringify({
input,
useLlm: elements.useLlm.checked,
}),
});
renderParsed(parsed);
}
async function onCreateWatch() {
const input = elements.queryInput.value.trim();
if (!input) {
alert("입력 문장을 작성하세요.");
return;
}
const payload = {
input,
useLlm: elements.useLlm.checked,
alertOn: elements.alertOn.value,
targetPrice: readTargetPrice(),
pollingEnabled: true,
alertsEnabled: true,
pollNow: true,
};
const created = await api("/api/watches", {
method: "POST",
body: JSON.stringify(payload),
});
if (created && created.watch) {
renderParsed({ source: created.parserSource || "unknown", params: created.watch.searchParams });
}
await refreshAll();
}
async function onWatchAction(event) {
const target = event.target;
if (!target || !target.closest) return;
const container = target.closest(".watch-item");
if (!container) return;
const watchId = container.getAttribute("data-watch-id");
const action = target.getAttribute("data-action");
if (!action || !watchId) return;
const isToggleAction = action === "toggle-polling" || action === "toggle-alerts";
const isButtonAction = action === "poll" || action === "delete";
if (isToggleAction && event.type !== "change") return;
if (isButtonAction && event.type !== "click") return;
if (action === "toggle-polling") {
await api(`/api/watches/${encodeURIComponent(watchId)}`, {
method: "PATCH",
body: JSON.stringify({
pollingEnabled: target.checked,
}),
});
await refreshAll();
return;
}
if (action === "toggle-alerts") {
await api(`/api/watches/${encodeURIComponent(watchId)}`, {
method: "PATCH",
body: JSON.stringify({
alertsEnabled: target.checked,
}),
});
await refreshAll();
return;
}
if (action === "poll") {
await api(`/api/watches/${encodeURIComponent(watchId)}/poll`, {
method: "POST",
});
await refreshAll();
return;
}
if (action === "delete") {
await api(`/api/watches/${encodeURIComponent(watchId)}`, {
method: "DELETE",
});
await refreshAll();
}
}
async function onGlobalToggle() {
await api("/api/system", {
method: "PATCH",
body: JSON.stringify({
crawlingEnabled: elements.globalCrawling.checked,
alertsEnabled: elements.globalAlerts.checked,
}),
});
await refreshAll();
}
async function bootstrap() {
try {
const config = await api("/api/config");
setConfigBanner(config);
await refreshAll();
elements.parseBtn.addEventListener("click", () => {
onParse().catch((error) => alert(error.message));
});
elements.createWatchBtn.addEventListener("click", () => {
onCreateWatch().catch((error) => alert(error.message));
});
elements.watchList.addEventListener("click", (event) => {
onWatchAction(event).catch((error) => alert(error.message));
});
elements.watchList.addEventListener("change", (event) => {
onWatchAction(event).catch((error) => alert(error.message));
});
elements.globalCrawling.addEventListener("change", () => {
onGlobalToggle().catch((error) => alert(error.message));
});
elements.globalAlerts.addEventListener("change", () => {
onGlobalToggle().catch((error) => alert(error.message));
});
setInterval(() => {
refreshAll().catch(() => {});
}, 5000);
} catch (error) {
alert(`초기화 실패: ${error.message}`);
}
}
bootstrap();
})();

96
src/dashboard/index.html Normal file
View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Air-Watcher Dashboard</title>
<link rel="stylesheet" href="/dashboard.css" />
</head>
<body>
<div class="page-gradient" aria-hidden="true"></div>
<main class="layout">
<header class="topbar panel">
<div>
<p class="eyebrow">AIR-WATCHER</p>
<h1>Flight Watch Dashboard</h1>
</div>
<div class="config" id="configBanner">초기화 중...</div>
</header>
<section class="panel composer">
<div class="section-title">
<h2>LLM 입력 파싱</h2>
<p>자연어를 넣으면 조회 조건을 자동 정리합니다.</p>
</div>
<label for="queryInput" class="label">항공권 요청 문장</label>
<textarea id="queryInput" rows="4" placeholder="예: 11월 말~12월 초 출발, 인천->마드리드, 20시간 이하, 비즈니스 2명"></textarea>
<div class="controls-grid">
<label class="switch-field">
<input id="useLlm" type="checkbox" checked />
<span>LLM 파싱 사용</span>
</label>
<label class="field">
<span>알림 기준</span>
<select id="alertOn">
<option value="both">가격변동 + 목표가</option>
<option value="change">가격변동만</option>
<option value="threshold">목표가만</option>
</select>
</label>
<label class="field">
<span>목표 가격 (원)</span>
<input id="targetPrice" type="number" min="1" step="1" placeholder="예: 1300000" />
</label>
</div>
<div class="action-row">
<button id="parseBtn" class="btn secondary">파싱만 실행</button>
<button id="createWatchBtn" class="btn primary">추적 추가</button>
</div>
<div id="parseSummary" class="summary empty">파싱 결과 요약이 여기에 표시됩니다.</div>
<pre id="parseOutput" class="json-view">{}</pre>
</section>
<section class="panel system-panel">
<div class="section-title">
<h2>전역 제어</h2>
<p>전체 추적 동작을 한 번에 켜고 끌 수 있습니다.</p>
</div>
<div class="toggle-row">
<label class="switch-field">
<input id="globalCrawling" type="checkbox" />
<span>전체 크롤링 ON/OFF</span>
</label>
<label class="switch-field">
<input id="globalAlerts" type="checkbox" />
<span>전체 알림 ON/OFF</span>
</label>
</div>
</section>
<section class="panel watches-panel">
<div class="section-title">
<h2>추적 목록</h2>
<p>각 항목별로 크롤링/알림 토글을 조정하세요.</p>
</div>
<div id="watchList" class="watch-list empty">아직 등록된 watch가 없습니다.</div>
</section>
<section class="panel events-panel">
<div class="section-title">
<h2>최근 이벤트</h2>
<p>목표가 도달/가격 변동 이벤트를 확인합니다.</p>
</div>
<div id="eventList" class="event-list empty">이벤트가 아직 없습니다.</div>
</section>
</main>
<script src="/dashboard.js"></script>
</body>
</html>

152
src/dashboardApi.js Normal file
View File

@@ -0,0 +1,152 @@
"use strict";
const {
buildAlertRules,
inferAlertOn,
normalizeAlertOn,
parseTargetPrice,
} = require("./alertRules");
const { createHttpError, parseBoolean } = require("./dashboardUtils");
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
function readInput(body) {
const input = typeof body.input === "string" ? body.input.trim() : "";
if (!input) {
throw createHttpError(400, "input 문자열이 필요합니다.");
}
return input;
}
function hasOwnProperty(source, key) {
return Object.prototype.hasOwnProperty.call(source, key);
}
function createDashboardApi({ watcher, store }) {
async function parseInput(body = {}) {
const input = readInput(body);
return extractFlightSearchRequest(input, {
preferRuleParser: parseBoolean(body.useLlm, true) === false,
});
}
async function createWatch(body = {}) {
const extracted = await parseInput(body);
const input = readInput(body);
const alertRules = buildAlertRules({
targetPrice: body.targetPrice,
alertOn: body.alertOn || "both",
});
const watchId = watcher.addWatch({
rawInput: input,
searchParams: extracted.params,
alertRules,
pollingEnabled: parseBoolean(body.pollingEnabled, true),
alertsEnabled: parseBoolean(body.alertsEnabled, true),
});
const created = watcher.getWatch(watchId);
await store.saveWatch(created);
if (parseBoolean(body.pollNow, false)) {
await watcher.pollWatch(watchId);
}
return {
watch: watcher.getWatch(watchId),
parserSource: extracted.source,
};
}
async function updateSystem(body = {}) {
const controls = watcher.setGlobalControls({
crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled),
alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled),
});
await store.setGlobalControls(controls);
return { controls };
}
function listWatches() {
return {
controls: watcher.getGlobalControls(),
watches: watcher.listWatches(),
};
}
async function listEvents(rawLimit) {
const limit = Number(rawLimit);
const events = await store.listEvents(Number.isFinite(limit) ? limit : 50);
return { events };
}
async function pollWatch(watchId) {
const existing = watcher.getWatch(watchId);
if (!existing) {
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
}
const pollResult = await watcher.pollWatch(watchId);
return {
watch: watcher.getWatch(watchId),
pollResult,
};
}
async function updateWatch(watchId, body = {}) {
const existing = watcher.getWatch(watchId);
if (!existing) {
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
}
const patch = {};
if (hasOwnProperty(body, "pollingEnabled")) {
patch.pollingEnabled = parseBoolean(body.pollingEnabled, existing.pollingEnabled);
}
if (hasOwnProperty(body, "alertsEnabled")) {
patch.alertsEnabled = parseBoolean(body.alertsEnabled, existing.alertsEnabled);
}
const hasAlertModePatch = hasOwnProperty(body, "alertOn") || hasOwnProperty(body, "targetPrice");
if (hasAlertModePatch) {
const targetPrice = hasOwnProperty(body, "targetPrice")
? parseTargetPrice(body.targetPrice)
: existing.alertRules.targetPrice;
const alertOn = hasOwnProperty(body, "alertOn")
? normalizeAlertOn(body.alertOn)
: inferAlertOn(existing.alertRules);
patch.alertRules = buildAlertRules({
targetPrice,
alertOn,
});
}
const updated = watcher.updateWatch(watchId, patch);
await store.saveWatch(updated);
return { watch: updated };
}
async function deleteWatch(watchId) {
const existed = watcher.removeWatch(watchId);
await store.deleteWatch(watchId);
return { deleted: existed };
}
return {
createWatch,
deleteWatch,
listEvents,
listWatches,
parseInput,
pollWatch,
updateSystem,
updateWatch,
};
}
module.exports = {
createDashboardApi,
};

34
src/dashboardAssets.js Normal file
View File

@@ -0,0 +1,34 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { createHttpError } = require("./dashboardUtils");
const ASSET_MAP = {
"/": { file: "index.html", contentType: "text/html; charset=utf-8" },
"/dashboard.css": { file: "dashboard.css", contentType: "text/css; charset=utf-8" },
"/dashboard.js": {
file: "dashboard.js",
contentType: "application/javascript; charset=utf-8",
},
};
function loadDashboardAsset(assetsDir, requestPath) {
const asset = ASSET_MAP[requestPath];
if (!asset) return null;
const filePath = path.join(assetsDir, asset.file);
if (!fs.existsSync(filePath)) {
throw createHttpError(500, `대시보드 파일이 없습니다: ${asset.file}`);
}
return {
content: fs.readFileSync(filePath),
contentType: asset.contentType,
};
}
module.exports = {
ASSET_MAP,
loadDashboardAsset,
};

104
src/dashboardRuntime.js Normal file
View File

@@ -0,0 +1,104 @@
"use strict";
const { createCrawlerClient } = require("./crawlerClient");
const { createDashboardStore } = require("./dashboardStore");
const { loadDotEnv } = require("./envLoader");
const { createNotifier } = require("./notifier");
const { PriceWatcher } = require("./priceWatcher");
const { parsePort } = require("./dashboardUtils");
function toRestoredWatchPayload(watch) {
return {
id: watch.id,
rawInput: watch.rawInput,
searchParams: watch.searchParams,
alertRules: watch.alertRules,
pollingEnabled: watch.pollingEnabled,
alertsEnabled: watch.alertsEnabled,
lastSnapshot: watch.lastSnapshot,
lastError: watch.lastError,
createdAt: watch.createdAt,
updatedAt: watch.updatedAt,
};
}
async function createDashboardRuntime(options = {}) {
loadDotEnv();
const logger = options.logger || console;
const pollIntervalSec = parsePort(
options.pollIntervalSec || process.env.DASHBOARD_POLL_INTERVAL_SEC,
60
);
const storeSetup =
options.store && options.store.saveWatch
? { engine: "custom", store: options.store, warning: null }
: await createDashboardStore(options.store || {});
const store = storeSetup.store;
const crawler = options.crawler || createCrawlerClient(options.crawlerOptions || {});
const notifier = options.notifier || createNotifier(options.notifierOptions || {});
const watcher = new PriceWatcher({
crawler,
notifier,
pollIntervalMs: pollIntervalSec * 1000,
logger,
onWatchPolled: async ({ watch, result }) => {
try {
await store.savePollResult(watch.id, result);
if (result && result.alert) {
await store.saveEvent({
watchId: watch.id,
eventType: result.alert.eventType || "unknown",
observedAt:
result.alert.observedAt ||
result.snapshot?.polledAt ||
result.error?.at ||
new Date().toISOString(),
payload: {
...result.alert,
notificationSent: result.notificationSent === true,
},
});
}
} catch (error) {
logger.error(`dashboard persistence hook failed: ${error.message}`);
}
},
});
const savedGlobalControls = await store.getGlobalControls();
watcher.setGlobalControls(savedGlobalControls);
const savedWatches = await store.listWatches();
for (const watch of savedWatches) {
watcher.addWatch(toRestoredWatchPayload(watch));
}
await watcher.start();
const close = async () => {
watcher.stop();
if (store && typeof store.close === "function") {
await store.close();
}
};
return {
watcher,
store,
close,
info: {
dbEngine: storeSetup.engine,
dbWarning: storeSetup.warning,
pollIntervalSec,
},
};
}
module.exports = {
createDashboardRuntime,
};

225
src/dashboardServer.js Normal file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env node
"use strict";
const http = require("node:http");
const path = require("node:path");
const {
buildAlertRules,
inferAlertOn,
normalizeAlertOn,
parseTargetPrice,
} = require("./alertRules");
const { createDashboardApi } = require("./dashboardApi");
const { loadDashboardAsset } = require("./dashboardAssets");
const { createDashboardRuntime } = require("./dashboardRuntime");
const { createHttpError, decodeWatchId, parsePort } = require("./dashboardUtils");
function parseJsonBody(req) {
return new Promise((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1024 * 1024) {
reject(createHttpError(413, "요청 본문이 너무 큽니다."));
req.destroy();
}
});
req.on("end", () => {
if (!raw.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch (_error) {
reject(createHttpError(400, "JSON 본문 파싱에 실패했습니다."));
}
});
req.on("error", (error) => {
reject(error);
});
});
}
function sendJson(res, statusCode, body) {
const payload = JSON.stringify(body);
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
"content-length": Buffer.byteLength(payload),
});
res.end(payload);
}
function sendStaticAsset(res, asset) {
res.writeHead(200, {
"content-type": asset.contentType,
"cache-control": "no-store",
"content-length": asset.content.length,
});
res.end(asset.content);
}
async function createDashboardServer(options = {}) {
const runtime = await createDashboardRuntime(options);
const api = createDashboardApi({
watcher: runtime.watcher,
store: runtime.store,
});
const assetsDir = path.resolve(__dirname, "dashboard");
const server = http.createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || "/", "http://localhost");
const pathname = requestUrl.pathname;
const staticAsset = loadDashboardAsset(assetsDir, pathname);
if (staticAsset) {
sendStaticAsset(res, staticAsset);
return;
}
if (req.method === "GET" && pathname === "/api/health") {
sendJson(res, 200, {
ok: true,
now: new Date().toISOString(),
dbEngine: runtime.info.dbEngine,
watchCount: runtime.watcher.listWatchIds().length,
});
return;
}
if (req.method === "GET" && pathname === "/api/config") {
sendJson(res, 200, runtime.info);
return;
}
if (req.method === "GET" && pathname === "/api/system") {
sendJson(res, 200, {
controls: runtime.watcher.getGlobalControls(),
});
return;
}
if (req.method === "PATCH" && pathname === "/api/system") {
const body = await parseJsonBody(req);
sendJson(res, 200, await api.updateSystem(body));
return;
}
if (req.method === "POST" && pathname === "/api/parse") {
const body = await parseJsonBody(req);
sendJson(res, 200, await api.parseInput(body));
return;
}
if (req.method === "GET" && pathname === "/api/watches") {
sendJson(res, 200, api.listWatches());
return;
}
if (req.method === "POST" && pathname === "/api/watches") {
const body = await parseJsonBody(req);
sendJson(res, 201, await api.createWatch(body));
return;
}
if (req.method === "GET" && pathname === "/api/events") {
const limit = requestUrl.searchParams.get("limit");
sendJson(res, 200, await api.listEvents(limit));
return;
}
const watchPollMatch = pathname.match(/^\/api\/watches\/([^/]+)\/poll$/);
if (req.method === "POST" && watchPollMatch) {
const watchId = decodeWatchId(watchPollMatch[1]);
sendJson(res, 200, await api.pollWatch(watchId));
return;
}
const watchMatch = pathname.match(/^\/api\/watches\/([^/]+)$/);
if (watchMatch) {
const watchId = decodeWatchId(watchMatch[1]);
if (req.method === "PATCH") {
const body = await parseJsonBody(req);
sendJson(res, 200, await api.updateWatch(watchId, body));
return;
}
if (req.method === "DELETE") {
sendJson(res, 200, await api.deleteWatch(watchId));
return;
}
}
sendJson(res, 404, {
error: "Not found",
});
} catch (error) {
const statusCode = Number(error.statusCode) || 500;
sendJson(res, statusCode, {
error: error.message || "Internal Server Error",
});
}
});
async function close() {
await new Promise((resolve) => {
server.close(() => resolve());
});
await runtime.close();
}
return {
server,
watcher: runtime.watcher,
store: runtime.store,
close,
info: runtime.info,
};
}
async function runCli() {
const host = process.env.DASHBOARD_HOST || "127.0.0.1";
const port = parsePort(process.env.DASHBOARD_PORT, 3000);
const app = await createDashboardServer();
app.server.listen(port, host, () => {
process.stdout.write(`Dashboard server listening on http://${host}:${port}\n`);
if (app.info.dbWarning) {
process.stdout.write(`[WARN] ${app.info.dbWarning}\n`);
}
process.stdout.write(
`dbEngine=${app.info.dbEngine} pollIntervalSec=${app.info.pollIntervalSec}\n`
);
});
const shutdown = () => {
void app.close().finally(() => {
process.exit(0);
});
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
if (require.main === module) {
runCli().catch((error) => {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
});
}
module.exports = {
buildAlertRules,
createDashboardServer,
inferAlertOn,
normalizeAlertOn,
parseTargetPrice,
};

562
src/dashboardStore.js Normal file
View File

@@ -0,0 +1,562 @@
"use strict";
function cloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
function toBoolean(value, fallback = true) {
if (value === undefined || value === null) return fallback;
return value !== false && value !== 0 && value !== "0";
}
function toSqlDateTime(isoString) {
const date = isoString ? new Date(isoString) : new Date();
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid date: ${isoString}`);
}
const yyyy = String(date.getUTCFullYear());
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const mi = String(date.getUTCMinutes()).padStart(2, "0");
const ss = String(date.getUTCSeconds()).padStart(2, "0");
const mmm = String(date.getUTCMilliseconds()).padStart(3, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}.${mmm}`;
}
function fromSqlDateTime(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function parseJsonColumn(value, fallback) {
if (value === null || value === undefined) {
return fallback;
}
if (typeof value === "object") {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return fallback;
}
}
class InMemoryDashboardStore {
constructor() {
this.watches = new Map();
this.events = [];
this.globalControls = {
crawlingEnabled: true,
alertsEnabled: true,
};
}
async init() {}
async close() {}
async listWatches() {
return Array.from(this.watches.values())
.map((watch) => cloneJson(watch))
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
}
async getWatch(watchId) {
const watch = this.watches.get(watchId);
return watch ? cloneJson(watch) : null;
}
async saveWatch(watch) {
const next = cloneJson(watch);
this.watches.set(next.id, next);
return next;
}
async deleteWatch(watchId) {
return this.watches.delete(watchId);
}
async savePollResult(watchId, pollResult) {
const watch = this.watches.get(watchId);
if (!watch) return;
let touched = false;
if (pollResult && pollResult.snapshot) {
watch.lastSnapshot = cloneJson(pollResult.snapshot);
watch.lastError = null;
touched = true;
}
if (pollResult && pollResult.error) {
watch.lastError = cloneJson(pollResult.error);
touched = true;
}
if (touched) {
watch.updatedAt = new Date().toISOString();
}
}
async saveEvent(event) {
const stored = {
id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`,
watchId: event.watchId,
eventType: event.eventType,
payload: cloneJson(event.payload),
observedAt: event.observedAt,
createdAt: new Date().toISOString(),
};
this.events.unshift(stored);
if (this.events.length > 1000) {
this.events.length = 1000;
}
return cloneJson(stored);
}
async listEvents(limit = 50) {
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
return this.events.slice(0, safeLimit).map((event) => cloneJson(event));
}
async getGlobalControls() {
return cloneJson(this.globalControls);
}
async setGlobalControls(patch = {}) {
if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) {
this.globalControls.crawlingEnabled = toBoolean(
patch.crawlingEnabled,
this.globalControls.crawlingEnabled
);
}
if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) {
this.globalControls.alertsEnabled = toBoolean(
patch.alertsEnabled,
this.globalControls.alertsEnabled
);
}
return cloneJson(this.globalControls);
}
}
class MySqlDashboardStore {
constructor(pool) {
this.pool = pool;
}
static async create(options = {}) {
let mysql;
try {
// Optional dependency: only loaded when MySQL mode is used.
mysql = require("mysql2/promise");
} catch (_error) {
throw new Error(
"mysql2 패키지가 필요합니다. `npm install mysql2` 후 다시 실행하세요."
);
}
const pool = mysql.createPool({
uri: options.uri,
host: options.host,
port: options.port,
user: options.user,
password: options.password,
database: options.database,
waitForConnections: true,
connectionLimit: Number(options.connectionLimit) || 5,
queueLimit: 0,
charset: "utf8mb4",
timezone: "Z",
});
const store = new MySqlDashboardStore(pool);
await store.init();
return store;
}
async init() {
await this.pool.query(`
CREATE TABLE IF NOT EXISTS watches (
id VARCHAR(64) NOT NULL,
raw_input TEXT NOT NULL,
parsed_params JSON NOT NULL,
alert_rules JSON NOT NULL,
polling_enabled TINYINT(1) NOT NULL DEFAULT 1,
alerts_enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
last_snapshot JSON NULL,
last_error JSON NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS watch_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
watch_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
observed_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
INDEX idx_watch_events_watch_id (watch_id, observed_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS app_settings (
setting_key VARCHAR(64) NOT NULL,
setting_value JSON NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
async close() {
await this.pool.end();
}
async listWatches() {
const [rows] = await this.pool.query(`
SELECT
id,
raw_input,
parsed_params,
alert_rules,
polling_enabled,
alerts_enabled,
created_at,
updated_at,
last_snapshot,
last_error
FROM watches
ORDER BY created_at DESC
`);
return rows.map((row) => ({
id: row.id,
rawInput: row.raw_input,
searchParams: parseJsonColumn(row.parsed_params, {}),
alertRules: parseJsonColumn(row.alert_rules, {}),
pollingEnabled: toBoolean(row.polling_enabled, true),
alertsEnabled: toBoolean(row.alerts_enabled, true),
createdAt: fromSqlDateTime(row.created_at),
updatedAt: fromSqlDateTime(row.updated_at),
lastSnapshot: parseJsonColumn(row.last_snapshot, null),
lastError: parseJsonColumn(row.last_error, null),
}));
}
async getWatch(watchId) {
const [rows] = await this.pool.query(
`
SELECT
id,
raw_input,
parsed_params,
alert_rules,
polling_enabled,
alerts_enabled,
created_at,
updated_at,
last_snapshot,
last_error
FROM watches
WHERE id = ?
LIMIT 1
`,
[watchId]
);
if (rows.length === 0) return null;
const row = rows[0];
return {
id: row.id,
rawInput: row.raw_input,
searchParams: parseJsonColumn(row.parsed_params, {}),
alertRules: parseJsonColumn(row.alert_rules, {}),
pollingEnabled: toBoolean(row.polling_enabled, true),
alertsEnabled: toBoolean(row.alerts_enabled, true),
createdAt: fromSqlDateTime(row.created_at),
updatedAt: fromSqlDateTime(row.updated_at),
lastSnapshot: parseJsonColumn(row.last_snapshot, null),
lastError: parseJsonColumn(row.last_error, null),
};
}
async saveWatch(watch) {
const createdAt = watch.createdAt || new Date().toISOString();
const updatedAt = watch.updatedAt || createdAt;
await this.pool.query(
`
INSERT INTO watches (
id,
raw_input,
parsed_params,
alert_rules,
polling_enabled,
alerts_enabled,
created_at,
updated_at,
last_snapshot,
last_error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
raw_input = VALUES(raw_input),
parsed_params = VALUES(parsed_params),
alert_rules = VALUES(alert_rules),
polling_enabled = VALUES(polling_enabled),
alerts_enabled = VALUES(alerts_enabled),
updated_at = VALUES(updated_at),
last_snapshot = VALUES(last_snapshot),
last_error = VALUES(last_error)
`,
[
watch.id,
watch.rawInput || "",
JSON.stringify(watch.searchParams || {}),
JSON.stringify(watch.alertRules || {}),
watch.pollingEnabled === false ? 0 : 1,
watch.alertsEnabled === false ? 0 : 1,
toSqlDateTime(createdAt),
toSqlDateTime(updatedAt),
watch.lastSnapshot ? JSON.stringify(watch.lastSnapshot) : null,
watch.lastError ? JSON.stringify(watch.lastError) : null,
]
);
return this.getWatch(watch.id);
}
async deleteWatch(watchId) {
const [result] = await this.pool.query(`DELETE FROM watches WHERE id = ?`, [watchId]);
return result.affectedRows > 0;
}
async savePollResult(watchId, pollResult) {
const updates = [];
const params = [];
const nowIso = new Date().toISOString();
if (pollResult && pollResult.snapshot) {
updates.push("last_snapshot = ?");
params.push(JSON.stringify(pollResult.snapshot));
updates.push("last_error = NULL");
}
if (pollResult && pollResult.error) {
updates.push("last_error = ?");
params.push(JSON.stringify(pollResult.error));
}
if (updates.length === 0) {
return;
}
updates.push("updated_at = ?");
params.push(toSqlDateTime(nowIso));
params.push(watchId);
await this.pool.query(
`
UPDATE watches
SET ${updates.join(", ")}
WHERE id = ?
`,
params
);
}
async saveEvent(event) {
const createdAt = new Date().toISOString();
const observedAt = event.observedAt || createdAt;
const [result] = await this.pool.query(
`
INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at)
VALUES (?, ?, ?, ?, ?)
`,
[
event.watchId,
event.eventType || "unknown",
JSON.stringify(event.payload || {}),
toSqlDateTime(observedAt),
toSqlDateTime(createdAt),
]
);
return {
id: String(result.insertId),
watchId: event.watchId,
eventType: event.eventType,
payload: cloneJson(event.payload || {}),
observedAt,
createdAt,
};
}
async listEvents(limit = 50) {
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
const [rows] = await this.pool.query(
`
SELECT id, watch_id, event_type, payload, observed_at, created_at
FROM watch_events
ORDER BY created_at DESC
LIMIT ?
`,
[safeLimit]
);
return rows.map((row) => ({
id: String(row.id),
watchId: row.watch_id,
eventType: row.event_type,
payload: parseJsonColumn(row.payload, {}),
observedAt: fromSqlDateTime(row.observed_at),
createdAt: fromSqlDateTime(row.created_at),
}));
}
async getGlobalControls() {
const [rows] = await this.pool.query(
`
SELECT setting_value
FROM app_settings
WHERE setting_key = 'global_controls'
LIMIT 1
`
);
if (rows.length === 0) {
return {
crawlingEnabled: true,
alertsEnabled: true,
};
}
const setting = parseJsonColumn(rows[0].setting_value, {});
return {
crawlingEnabled: toBoolean(setting.crawlingEnabled, true),
alertsEnabled: toBoolean(setting.alertsEnabled, true),
};
}
async setGlobalControls(patch = {}) {
const next = {
...(await this.getGlobalControls()),
};
if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) {
next.crawlingEnabled = toBoolean(patch.crawlingEnabled, next.crawlingEnabled);
}
if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) {
next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled);
}
await this.pool.query(
`
INSERT INTO app_settings (setting_key, setting_value, updated_at)
VALUES ('global_controls', ?, ?)
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = VALUES(updated_at)
`,
[JSON.stringify(next), toSqlDateTime(new Date().toISOString())]
);
return next;
}
}
function parsePort(rawPort, fallback) {
const n = Number(rawPort);
if (!Number.isInteger(n) || n <= 0) return fallback;
return n;
}
async function createDashboardStore(options = {}) {
const modeRaw = options.mode || process.env.DASHBOARD_DB || "";
const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : "";
const isStrictMySqlMode = mode === "mysql";
const prefersMySql =
isStrictMySqlMode ||
Boolean(process.env.MYSQL_URL) ||
Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE);
if (!prefersMySql || mode === "memory") {
const store = new InMemoryDashboardStore();
await store.init();
return {
engine: "memory",
store,
warning: null,
};
}
const mysqlOptions = {
uri: options.mysqlUrl || process.env.MYSQL_URL,
host: options.mysqlHost || process.env.MYSQL_HOST,
port: parsePort(options.mysqlPort || process.env.MYSQL_PORT, 3306),
user: options.mysqlUser || process.env.MYSQL_USER,
password: options.mysqlPassword || process.env.MYSQL_PASSWORD,
database: options.mysqlDatabase || process.env.MYSQL_DATABASE,
connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT,
};
if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) {
if (isStrictMySqlMode) {
throw new Error(
"MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하세요."
);
}
const fallbackStore = new InMemoryDashboardStore();
await fallbackStore.init();
return {
engine: "memory",
store: fallbackStore,
warning:
"MySQL 환경변수가 없어 메모리 저장소를 사용합니다. MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하면 MySQL로 전환됩니다.",
};
}
try {
const store = await MySqlDashboardStore.create(mysqlOptions);
return {
engine: "mysql",
store,
warning: null,
};
} catch (error) {
if (isStrictMySqlMode) {
throw error;
}
const fallbackStore = new InMemoryDashboardStore();
await fallbackStore.init();
return {
engine: "memory",
store: fallbackStore,
warning: `MySQL 초기화 실패로 메모리 저장소를 사용합니다: ${error.message}`,
};
}
}
module.exports = {
InMemoryDashboardStore,
MySqlDashboardStore,
createDashboardStore,
};

40
src/dashboardUtils.js Normal file
View File

@@ -0,0 +1,40 @@
"use strict";
function createHttpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function parseBoolean(value, fallback = true) {
if (value === undefined) return fallback;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) return true;
if (["false", "0", "no", "off"].includes(normalized)) return false;
}
return fallback;
}
function parsePort(rawValue, fallback) {
const n = Number(rawValue);
if (!Number.isInteger(n) || n <= 0 || n > 65535) return fallback;
return n;
}
function decodeWatchId(value) {
try {
return decodeURIComponent(value);
} catch (_error) {
throw createHttpError(400, "watchId 경로가 올바르지 않습니다.");
}
}
module.exports = {
createHttpError,
decodeWatchId,
parseBoolean,
parsePort,
};

50
src/envLoader.js Normal file
View File

@@ -0,0 +1,50 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function decodeEnvValue(rawValue) {
const value = rawValue.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
const inner = value.slice(1, -1);
if (value.startsWith('"')) {
return inner
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\");
}
return inner;
}
return value;
}
function loadDotEnv(filePath = path.resolve(process.cwd(), ".env")) {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = line.indexOf("=");
if (separatorIndex <= 0) continue;
const key = line.slice(0, separatorIndex).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (process.env[key] !== undefined) continue;
const rawValue = line.slice(separatorIndex + 1);
process.env[key] = decodeEnvValue(rawValue);
}
}
module.exports = {
decodeEnvValue,
loadDotEnv,
};

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
"use strict";
const path = require("node:path");
const Fastify = require("fastify");
const { createDashboardApi } = require("./dashboardApi");
const { loadDashboardAsset } = require("./dashboardAssets");
const { createDashboardRuntime } = require("./dashboardRuntime");
const { decodeWatchId, parsePort } = require("./dashboardUtils");
function sendStaticAsset(reply, asset) {
reply
.code(200)
.header("content-type", asset.contentType)
.header("cache-control", "no-store")
.send(asset.content);
}
function parseRequestPathname(request) {
try {
return new URL(request.raw.url || "/", "http://localhost").pathname;
} catch (_error) {
return request.url || "/";
}
}
async function createFastifyDashboardServer(options = {}) {
const runtime = await createDashboardRuntime(options);
const api = createDashboardApi({
watcher: runtime.watcher,
store: runtime.store,
});
const assetsDir = path.resolve(__dirname, "dashboard");
const app = Fastify({
logger: false,
bodyLimit: 1024 * 1024,
});
app.setErrorHandler((error, _request, reply) => {
const statusCode = Number(error.statusCode) || 500;
reply.code(statusCode).send({
error: error.message || "Internal Server Error",
});
});
app.addHook("onClose", async () => {
await runtime.close();
});
app.get("/", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/"));
});
app.get("/dashboard.css", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.css"));
});
app.get("/dashboard.js", async (_request, reply) => {
sendStaticAsset(reply, loadDashboardAsset(assetsDir, "/dashboard.js"));
});
app.get("/api/health", async () => ({
ok: true,
now: new Date().toISOString(),
dbEngine: runtime.info.dbEngine,
watchCount: runtime.watcher.listWatchIds().length,
}));
app.get("/api/config", async () => runtime.info);
app.get("/api/system", async () => ({
controls: runtime.watcher.getGlobalControls(),
}));
app.patch("/api/system", async (request) => {
return api.updateSystem(request.body || {});
});
app.post("/api/parse", async (request) => {
return api.parseInput(request.body || {});
});
app.get("/api/watches", async () => api.listWatches());
app.post("/api/watches", async (request, reply) => {
reply.code(201);
return api.createWatch(request.body || {});
});
app.get("/api/events", async (request) => {
return api.listEvents(request.query?.limit);
});
app.post("/api/watches/:watchId/poll", async (request) => {
return api.pollWatch(decodeWatchId(request.params.watchId));
});
app.patch("/api/watches/:watchId", async (request) => {
return api.updateWatch(decodeWatchId(request.params.watchId), request.body || {});
});
app.delete("/api/watches/:watchId", async (request) => {
return api.deleteWatch(decodeWatchId(request.params.watchId));
});
app.setNotFoundHandler(async (request, reply) => {
const staticAsset = loadDashboardAsset(assetsDir, parseRequestPathname(request));
if (staticAsset) {
sendStaticAsset(reply, staticAsset);
return;
}
reply.code(404).send({ error: "Not found" });
});
return {
app,
watcher: runtime.watcher,
store: runtime.store,
close: async () => {
await app.close();
},
info: runtime.info,
};
}
async function runCli() {
const host = process.env.DASHBOARD_HOST || "127.0.0.1";
const port = parsePort(process.env.DASHBOARD_PORT, 3000);
const dashboard = await createFastifyDashboardServer();
await dashboard.app.listen({ host, port });
process.stdout.write(`Fastify dashboard server listening on http://${host}:${port}\n`);
if (dashboard.info.dbWarning) {
process.stdout.write(`[WARN] ${dashboard.info.dbWarning}\n`);
}
process.stdout.write(
`dbEngine=${dashboard.info.dbEngine} pollIntervalSec=${dashboard.info.pollIntervalSec}\n`
);
const shutdown = () => {
void dashboard.close().finally(() => {
process.exit(0);
});
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
if (require.main === module) {
runCli().catch((error) => {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
});
}
module.exports = {
createFastifyDashboardServer,
};

View File

@@ -0,0 +1,283 @@
"use strict";
const { parseFlightSearchRequest } = require("./naturalLanguageFlightParser");
function tryParseJsonObject(text) {
if (typeof text !== "string") {
throw new Error("LLM response is not a string");
}
const trimmed = text.trim();
try {
return JSON.parse(trimmed);
} catch (_error) {
// Continue to bracket-based recovery.
}
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace < 0 || lastBrace < 0 || firstBrace >= lastBrace) {
throw new Error("LLM response did not include a valid JSON object");
}
const sliced = trimmed.slice(firstBrace, lastBrace + 1);
return JSON.parse(sliced);
}
function toIntegerOrNull(value) {
if (value === null || value === undefined) return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return Math.round(n);
}
function sanitizeDateWindow(value) {
if (!value || typeof value !== "object") return null;
const from = typeof value.from === "string" ? value.from : null;
const to = typeof value.to === "string" ? value.to : null;
if (!from || !to) return null;
return { from, to };
}
function sanitizeStayDuration(value) {
if (!value || typeof value !== "object") return null;
const minDays = toIntegerOrNull(value.minDays);
const maxDays = toIntegerOrNull(value.maxDays);
if (minDays === null || maxDays === null) return null;
return { minDays: Math.min(minDays, maxDays), maxDays: Math.max(minDays, maxDays) };
}
function sanitizeSegments(value) {
if (!Array.isArray(value)) return null;
const segments = value
.map((segment) => {
if (!segment || typeof segment !== "object") return null;
const from = typeof segment.from === "string" ? segment.from.trim() : "";
const to = typeof segment.to === "string" ? segment.to.trim() : "";
if (!from || !to) return null;
return { from: from.toUpperCase(), to: to.toUpperCase() };
})
.filter(Boolean);
return segments.length > 0 ? segments : null;
}
function sanitizePassengers(value) {
if (!value || typeof value !== "object") return null;
const byCabinSource = value.byCabin || {};
const byCabin = {
economy: Math.max(0, toIntegerOrNull(byCabinSource.economy) || 0),
premium_economy: Math.max(0, toIntegerOrNull(byCabinSource.premium_economy) || 0),
business: Math.max(0, toIntegerOrNull(byCabinSource.business) || 0),
first: Math.max(0, toIntegerOrNull(byCabinSource.first) || 0),
};
const computedTotal = Object.values(byCabin).reduce((acc, n) => acc + n, 0);
const providedTotal = toIntegerOrNull(value.total);
const total = providedTotal !== null ? Math.max(providedTotal, computedTotal) : computedTotal;
if (total <= 0) return null;
return { byCabin, total };
}
function sanitizeMaxJourneyHours(value) {
if (!value || typeof value !== "object") return null;
const hours = toIntegerOrNull(value.hours);
const operator = value.operator === "<=" ? "<=" : value.operator === "<" ? "<" : null;
if (hours === null || operator === null) return null;
return { hours, operator };
}
function sanitizeConstraints(value, fallbackConstraints) {
const source = value && typeof value === "object" ? value : {};
return {
sameFlightForAllPassengers:
typeof source.sameFlightForAllPassengers === "boolean"
? source.sameFlightForAllPassengers
: fallbackConstraints.sameFlightForAllPassengers,
itineraryCount:
source.itineraryCount === null
? null
: toIntegerOrNull(source.itineraryCount) ?? fallbackConstraints.itineraryCount,
maxStops:
source.maxStops === null ? null : toIntegerOrNull(source.maxStops) ?? fallbackConstraints.maxStops,
maxJourneyHours: sanitizeMaxJourneyHours(source.maxJourneyHours) || fallbackConstraints.maxJourneyHours,
};
}
function uniqueStrings(values) {
if (!Array.isArray(values)) return [];
const seen = new Set();
const result = [];
for (const item of values) {
if (typeof item !== "string") continue;
if (seen.has(item)) continue;
seen.add(item);
result.push(item);
}
return result;
}
function inferTripType(segments) {
if (!segments || segments.length < 2) return "unknown";
const first = segments[0];
const second = segments[1];
if (first.from === second.to && first.to === second.from) return "round_trip";
if (first.from === second.to && first.to !== second.from) return "open_jaw";
return "multi_city";
}
function recomputeMissingFields(params) {
const missingFields = [];
if (!params.departureDateWindow) missingFields.push("departureDateWindow");
if (!params.stayDurationDays) missingFields.push("stayDurationDays");
if (!params.passengers) missingFields.push("passengers");
if (!params.segments) missingFields.push("segments");
if (!params.constraints.maxJourneyHours) missingFields.push("maxJourneyHours");
return missingFields;
}
function mergeWithFallback(llmObject, fallbackParams, input, now) {
const source = llmObject && typeof llmObject === "object" ? llmObject : {};
const departureDateWindow =
sanitizeDateWindow(source.departureDateWindow) || fallbackParams.departureDateWindow;
const stayDurationDays = sanitizeStayDuration(source.stayDurationDays) || fallbackParams.stayDurationDays;
const segments = sanitizeSegments(source.segments) || fallbackParams.segments;
const passengers = sanitizePassengers(source.passengers) || fallbackParams.passengers;
const constraints = sanitizeConstraints(source.constraints, fallbackParams.constraints);
const warnings = uniqueStrings([
...fallbackParams.warnings,
...uniqueStrings(source.warnings),
]);
const tripType =
typeof source.tripType === "string" && source.tripType.trim()
? source.tripType
: inferTripType(segments);
const parsed = {
rawInput: input,
parsedAt: new Date(now).toISOString(),
tripType,
departureDateWindow,
stayDurationDays,
segments,
passengers,
constraints,
warnings,
};
parsed.missingFields = recomputeMissingFields(parsed);
return parsed;
}
function buildPrompt(input, nowDate) {
return [
"Extract flight-search parameters from user text and return JSON only.",
"Use this JSON schema keys exactly:",
"{",
' "departureDateWindow": {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"} | null,',
' "stayDurationDays": {"minDays": number, "maxDays": number} | null,',
' "segments": [{"from":"IATA or city code","to":"IATA or city code"}] | null,',
' "passengers": {"total":number,"byCabin":{"economy":number,"premium_economy":number,"business":number,"first":number}} | null,',
' "constraints": {"sameFlightForAllPassengers":boolean,"itineraryCount":number|null,"maxStops":number|null,"maxJourneyHours":{"hours":number,"operator":"<|<="}|null},',
' "tripType": "round_trip|open_jaw|multi_city|unknown",',
' "warnings": [string],',
' "missingFields": [string]',
"}",
"When information is missing, set null and add key names in missingFields.",
`Today date is ${new Date(nowDate).toISOString().slice(0, 10)}.`,
"",
`User input: ${input}`,
].join("\n");
}
function createOpenAIClient(options = {}) {
const apiKey = options.apiKey || process.env.OPENAI_API_KEY;
if (!apiKey) return null;
const baseUrl = options.baseUrl || process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const model = options.model || process.env.OPENAI_MODEL || "gpt-4.1-mini";
const fetchImpl = options.fetch || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("global fetch is unavailable. Node.js 18+ is required.");
}
const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
return async ({ input, now }) => {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
"You are a parser. Output valid JSON only. Do not wrap in markdown or prose.",
},
{
role: "user",
content: buildPrompt(input, now),
},
],
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`OpenAI API request failed (${response.status}): ${message}`);
}
const payload = await response.json();
const content = payload?.choices?.[0]?.message?.content;
return tryParseJsonObject(content);
};
}
async function extractFlightSearchRequest(input, options = {}) {
if (typeof input !== "string" || input.trim() === "") {
throw new Error("input must be a non-empty string");
}
const now = options.now || new Date();
const fallbackParams = parseFlightSearchRequest(input, { now });
if (options.preferRuleParser) {
return { source: "rule_parser", params: fallbackParams };
}
try {
const llmClient = options.llmClient || createOpenAIClient(options);
if (!llmClient) {
return { source: "rule_parser", params: fallbackParams };
}
const llmRaw = await llmClient({ input, now });
const merged = mergeWithFallback(llmRaw, fallbackParams, input, now);
return { source: "llm", params: merged };
} catch (error) {
return {
source: "rule_parser",
params: {
...fallbackParams,
warnings: [
...fallbackParams.warnings,
`LLM extraction fallback triggered: ${error.message}`,
],
},
};
}
}
module.exports = {
createOpenAIClient,
extractFlightSearchRequest,
};

View File

@@ -0,0 +1,252 @@
"use strict";
const CITY_TO_AIRPORT = {
인천: "ICN",
서울: "ICN",
김포: "GMP",
마드리드: "MAD",
바르셀로나: "BCN",
incheon: "ICN",
seoul: "ICN",
madrid: "MAD",
barcelona: "BCN",
};
const CABIN_PATTERNS = [
{ regex: /비즈니스\s*(\d+)\s*(?:명|석|개)?/gi, key: "business" },
{
regex: /프리미엄\s*이코노미\s*(\d+)\s*(?:명|석|개)?/gi,
key: "premium_economy",
},
{ regex: /이코노미\s*(\d+)\s*(?:명|석|개)?/gi, key: "economy" },
{ regex: /퍼스트\s*(\d+)\s*(?:명|석|개)?/gi, key: "first" },
];
function normalize(text) {
return text
.trim()
.replace(/[,]/g, " ")
.replace(/\s+/g, " ");
}
function daysInMonth(year, month1Based) {
return new Date(Date.UTC(year, month1Based, 0)).getUTCDate();
}
function rangeByBucket(bucket, year, month1Based) {
const lastDay = daysInMonth(year, month1Based);
if (bucket === "초") return [1, Math.min(10, lastDay)];
if (bucket === "중") return [11, Math.min(20, lastDay)];
if (bucket === "말") return [21, lastDay];
return [1, lastDay];
}
function dateToIso(year, month1Based, day) {
return `${year}-${String(month1Based).padStart(2, "0")}-${String(day).padStart(
2,
"0"
)}`;
}
function inferYearsForMonthRange(nowDate, startMonth, endMonth, endBucket) {
const now = new Date(nowDate);
const thisYear = now.getUTCFullYear();
const crossesYear = endMonth < startMonth;
let startYear = thisYear;
let endYear = crossesYear ? thisYear + 1 : thisYear;
const [, endMaxDay] = rangeByBucket(endBucket, endYear, endMonth);
const candidateEnd = new Date(Date.UTC(endYear, endMonth - 1, endMaxDay, 23, 59, 59));
if (candidateEnd < now) {
startYear += 1;
endYear += 1;
}
return { startYear, endYear };
}
function parseDepartureDateWindow(text, nowDate) {
const monthBucketRange =
/(\d{1,2})\s*월\s*(초|중|말)\s*부터\s*(\d{1,2})\s*월\s*(초|중|말)\s*까지/i;
const m = text.match(monthBucketRange);
if (!m) return null;
const startMonth = Number(m[1]);
const startBucket = m[2];
const endMonth = Number(m[3]);
const endBucket = m[4];
if (startMonth < 1 || startMonth > 12 || endMonth < 1 || endMonth > 12) {
return null;
}
const { startYear, endYear } = inferYearsForMonthRange(
nowDate,
startMonth,
endMonth,
endBucket
);
const [startMinDay] = rangeByBucket(startBucket, startYear, startMonth);
const [, endMaxDay] = rangeByBucket(endBucket, endYear, endMonth);
return {
raw: `${startMonth}${startBucket}부터 ${endMonth}${endBucket}까지`,
from: dateToIso(startYear, startMonth, startMinDay),
to: dateToIso(endYear, endMonth, endMaxDay),
};
}
function parseStayDuration(text) {
const m = text.match(/(\d{1,2})\s*(?:~|-|)\s*(\d{1,2})\s*일/i);
if (!m) return null;
const a = Number(m[1]);
const b = Number(m[2]);
return {
minDays: Math.min(a, b),
maxDays: Math.max(a, b),
};
}
function parsePassengers(text) {
const cabinCounts = {
economy: 0,
premium_economy: 0,
business: 0,
first: 0,
};
let remainingText = text;
for (const { regex, key } of CABIN_PATTERNS) {
const copy = new RegExp(regex.source, regex.flags);
remainingText = remainingText.replace(copy, (_full, count) => {
cabinCounts[key] += Number(count);
return " ";
});
}
const total = Object.values(cabinCounts).reduce((acc, n) => acc + n, 0);
return total > 0 ? { byCabin: cabinCounts, total } : null;
}
function normalizeLocationToken(token) {
return token
.trim()
.replace(/[^\p{L}\p{N}]/gu, "")
.toLowerCase();
}
function toAirportCode(raw) {
const cleaned = normalizeLocationToken(raw);
if (/^[a-z]{3}$/.test(cleaned)) return cleaned.toUpperCase();
return CITY_TO_AIRPORT[cleaned] || raw.trim();
}
function parseSegments(text) {
const inbound = text.match(
/([A-Za-z가-힣]{2,20})\s*(?:->|→|>)\s*([A-Za-z가-힣]{2,20})\s*인/i
);
const outbound = text.match(
/([A-Za-z가-힣]{2,20})\s*(?:->|→|>)\s*([A-Za-z가-힣]{2,20})\s*아웃/i
);
if (inbound && outbound) {
return [
{ from: toAirportCode(inbound[1]), to: toAirportCode(inbound[2]) },
{ from: toAirportCode(outbound[1]), to: toAirportCode(outbound[2]) },
];
}
const genericSegments = [];
const regex = /([A-Za-z가-힣]{2,20})\s*(?:->|→|>)\s*([A-Za-z가-힣]{2,20})/gi;
let m;
while ((m = regex.exec(text)) !== null) {
genericSegments.push({
from: toAirportCode(m[1]),
to: toAirportCode(m[2]),
});
}
return genericSegments.length > 0 ? genericSegments : null;
}
function inferTripType(segments) {
if (!segments || segments.length < 2) return "unknown";
const first = segments[0];
const second = segments[1];
if (first.from === second.to && first.to === second.from) return "round_trip";
if (first.from === second.to && first.to !== second.from) return "open_jaw";
return "multi_city";
}
function parseMaxJourneyHours(text) {
const m = text.match(/(\d{1,2})\s*시간\s*(미만|이하)/i);
if (!m) return null;
return {
hours: Number(m[1]),
operator: m[2] === "미만" ? "<" : "<=",
};
}
function parseMaxStops(text) {
const m = text.match(/(\d+)\s*회\s*경유/i);
return m ? Number(m[1]) : null;
}
function parseItineraryCount(text) {
const m = text.match(/총\s*(\d+)\s*회/i);
return m ? Number(m[1]) : null;
}
function parseFlightSearchRequest(input, options = {}) {
if (typeof input !== "string" || input.trim() === "") {
throw new Error("input must be a non-empty string");
}
const now = options.now || new Date();
const text = normalize(input);
const departureDateWindow = parseDepartureDateWindow(text, now);
const stayDuration = parseStayDuration(text);
const passengers = parsePassengers(text);
const segments = parseSegments(text);
const sameFlightForAllPassengers = /동일\s*항공편/i.test(text);
const maxJourney = parseMaxJourneyHours(text);
const maxStops = parseMaxStops(text);
const itineraryCount = parseItineraryCount(text);
const warnings = [];
if (/총\s*\d+\s*회/i.test(text) && maxStops === null && !/경유/i.test(text)) {
warnings.push(
"'총 N회'는 현재 여정 수로 해석됩니다. 경유 제한 의도라면 'N회 경유'처럼 입력하세요."
);
}
const missingFields = [];
if (!departureDateWindow) missingFields.push("departureDateWindow");
if (!stayDuration) missingFields.push("stayDurationDays");
if (!passengers) missingFields.push("passengers");
if (!segments) missingFields.push("segments");
if (!maxJourney) missingFields.push("maxJourneyHours");
return {
rawInput: input,
parsedAt: new Date(now).toISOString(),
tripType: inferTripType(segments),
departureDateWindow,
stayDurationDays: stayDuration,
segments,
passengers,
constraints: {
sameFlightForAllPassengers,
itineraryCount,
maxStops,
maxJourneyHours: maxJourney,
},
warnings,
missingFields,
};
}
module.exports = {
parseFlightSearchRequest,
};

184
src/notifier.js Normal file
View File

@@ -0,0 +1,184 @@
"use strict";
function formatPrice(price, currency) {
if (!Number.isFinite(price)) return `N/A ${currency || ""}`.trim();
const formatter = new Intl.NumberFormat("ko-KR");
return `${formatter.format(price)} ${currency || ""}`.trim();
}
function formatTelegramMessage(event) {
const lines = ["Air-Watcher price alert"];
if (event.eventType) lines.push(`Type: ${event.eventType}`);
lines.push(`Current best: ${formatPrice(event.currentBestPrice, event.currency)}`);
if (Number.isFinite(event.previousBestPrice)) {
lines.push(`Previous best: ${formatPrice(event.previousBestPrice, event.currency)}`);
}
if (Number.isFinite(event.threshold)) {
lines.push(`Target threshold: ${formatPrice(event.threshold, event.currency)}`);
}
if (event.bestOffer?.provider) {
lines.push(`Provider: ${event.bestOffer.provider}`);
}
if (event.rawInput) {
lines.push(`Query: ${event.rawInput}`);
}
if (event.observedAt) {
lines.push(`Observed at: ${event.observedAt}`);
}
if (event.watchId) {
lines.push(`Watch ID: ${event.watchId}`);
}
return lines.join("\n");
}
class ConsoleNotifier {
constructor(options = {}) {
this.logger = options.logger || console;
}
async notify(event) {
const priceText = formatPrice(event.currentBestPrice, event.currency);
const prevText =
event.previousBestPrice === null
? "N/A"
: formatPrice(event.previousBestPrice, event.currency);
this.logger.log(
`[ALERT] watchId=${event.watchId} type=${event.eventType} price=${priceText} prev=${prevText}`
);
}
}
class WebhookNotifier {
constructor(options = {}) {
this.webhookUrl = options.webhookUrl;
this.fetch = options.fetch || global.fetch;
if (!this.webhookUrl) {
throw new Error("webhookUrl is required");
}
if (typeof this.fetch !== "function") {
throw new Error("global fetch is unavailable. Node.js 18+ is required.");
}
}
async notify(event) {
const response = await this.fetch(this.webhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(event),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Webhook notification failed (${response.status}): ${body}`);
}
}
}
class TelegramNotifier {
constructor(options = {}) {
this.botToken = options.botToken;
this.chatId = options.chatId;
this.apiBase = options.apiBase || "https://api.telegram.org";
this.fetch = options.fetch || global.fetch;
if (!this.botToken) {
throw new Error("telegram bot token is required");
}
if (!this.chatId) {
throw new Error("telegram chat id is required");
}
if (typeof this.fetch !== "function") {
throw new Error("global fetch is unavailable. Node.js 18+ is required.");
}
this.endpoint = `${this.apiBase.replace(/\/$/, "")}/bot${this.botToken}/sendMessage`;
}
async notify(event) {
const response = await this.fetch(this.endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
chat_id: this.chatId,
text: formatTelegramMessage(event),
disable_web_page_preview: true,
}),
});
const rawBody = await response.text();
let payload = null;
if (rawBody) {
try {
payload = JSON.parse(rawBody);
} catch (_error) {
payload = null;
}
}
if (!response.ok) {
const message = payload?.description || rawBody || "request failed";
throw new Error(`Telegram notification failed (${response.status}): ${message}`);
}
if (!payload || payload.ok !== true) {
const message = payload?.description || rawBody || "unknown response";
throw new Error(`Telegram notification failed: ${message}`);
}
}
}
function createNotifier(options = {}) {
if (options.notifier && typeof options.notifier.notify === "function") {
return options.notifier;
}
const channelRaw = options.channel || process.env.NOTIFY_CHANNEL || "";
const channel = typeof channelRaw === "string" ? channelRaw.trim().toLowerCase() : "";
const telegramBotToken =
options.telegramBotToken ||
process.env.TELEGRAM_BOT_TOKEN ||
process.env.NOTIFY_TELEGRAM_BOT_TOKEN;
const telegramChatId =
options.telegramChatId || process.env.TELEGRAM_CHAT_ID || process.env.NOTIFY_TELEGRAM_CHAT_ID;
if (channel === "telegram" || (!channel && telegramBotToken && telegramChatId)) {
return new TelegramNotifier({
botToken: telegramBotToken,
chatId: telegramChatId,
apiBase: options.telegramApiBase || process.env.TELEGRAM_API_BASE,
fetch: options.fetch,
});
}
const webhookUrl = options.webhookUrl || process.env.NOTIFY_WEBHOOK_URL;
if (channel === "webhook" || (!channel && webhookUrl)) {
return new WebhookNotifier({
webhookUrl,
fetch: options.fetch,
});
}
if (channel && channel !== "console") {
throw new Error(`Unsupported NOTIFY_CHANNEL: ${channel}`);
}
return new ConsoleNotifier({
logger: options.logger,
});
}
module.exports = {
ConsoleNotifier,
TelegramNotifier,
WebhookNotifier,
createNotifier,
formatTelegramMessage,
};

443
src/priceWatcher.js Normal file
View File

@@ -0,0 +1,443 @@
"use strict";
const crypto = require("node:crypto");
function cloneJson(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function newWatchId() {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `watch-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
}
function normalizeAlertRules(alertRules = {}) {
const targetPrice =
alertRules.targetPrice === null || alertRules.targetPrice === undefined
? null
: Number(alertRules.targetPrice);
return {
targetPrice:
targetPrice !== null && Number.isFinite(targetPrice) && targetPrice > 0
? Math.round(targetPrice)
: null,
notifyOnPriceChange: alertRules.notifyOnPriceChange !== false,
notifyOnFirstResult: alertRules.notifyOnFirstResult === true,
};
}
function normalizeToggle(value, defaultValue = true) {
if (value === undefined) return defaultValue;
return value !== false;
}
function normalizeOffers(offers) {
if (!Array.isArray(offers)) return [];
return offers
.map((offer) => {
if (!offer || typeof offer !== "object") return null;
const price = Number(offer.price);
if (!Number.isFinite(price) || price <= 0) return null;
const currency =
typeof offer.currency === "string" && offer.currency.trim()
? offer.currency.trim().toUpperCase()
: "KRW";
const provider =
typeof offer.provider === "string" && offer.provider.trim()
? offer.provider.trim()
: "unknown-provider";
return {
...offer,
provider,
currency,
price: Math.round(price),
};
})
.filter(Boolean)
.sort((a, b) => a.price - b.price);
}
function buildAlertEvent(watch, previousSnapshot, currentSnapshot) {
const reasons = [];
const { targetPrice, notifyOnPriceChange, notifyOnFirstResult } = watch.alertRules;
if (targetPrice !== null && currentSnapshot.bestPrice <= targetPrice) {
const crossedThreshold = !previousSnapshot || previousSnapshot.bestPrice > targetPrice;
const betterPriceBelowThreshold =
previousSnapshot &&
previousSnapshot.bestPrice <= targetPrice &&
currentSnapshot.bestPrice < previousSnapshot.bestPrice;
if (crossedThreshold || betterPriceBelowThreshold) {
reasons.push("target_price");
}
}
if (
notifyOnPriceChange &&
previousSnapshot &&
previousSnapshot.bestPrice !== currentSnapshot.bestPrice
) {
reasons.push("price_changed");
}
if (notifyOnFirstResult && !previousSnapshot) {
reasons.push("first_result");
}
if (reasons.length === 0) return null;
const uniqueReasons = [...new Set(reasons)];
return {
watchId: watch.id,
rawInput: watch.rawInput,
eventType: uniqueReasons.includes("target_price") ? "target_price" : uniqueReasons[0],
reasons: uniqueReasons,
threshold: targetPrice,
previousBestPrice: previousSnapshot ? previousSnapshot.bestPrice : null,
currentBestPrice: currentSnapshot.bestPrice,
currency: currentSnapshot.currency,
bestOffer: currentSnapshot.bestOffer,
observedAt: currentSnapshot.polledAt,
};
}
function createWatchError({ message, at, phase }) {
return {
message: typeof message === "string" && message ? message : "Unknown error",
at,
phase: phase || "crawl",
};
}
class PriceWatcher {
constructor(options = {}) {
this.crawler = options.crawler;
this.notifier = options.notifier;
this.pollIntervalMs =
Number.isFinite(Number(options.pollIntervalMs)) && Number(options.pollIntervalMs) > 0
? Number(options.pollIntervalMs)
: 60000;
this.logger = options.logger || console;
this.now = options.now || (() => new Date());
this.onWatchPolled =
typeof options.onWatchPolled === "function" ? options.onWatchPolled : null;
if (!this.crawler || typeof this.crawler.getQuotes !== "function") {
throw new Error("crawler.getQuotes function is required");
}
if (!this.notifier || typeof this.notifier.notify !== "function") {
throw new Error("notifier.notify function is required");
}
this.watches = new Map();
this.timer = null;
this.globalControls = {
crawlingEnabled: true,
alertsEnabled: true,
};
}
toPublicWatch(watch) {
return {
id: watch.id,
rawInput: watch.rawInput,
searchParams: cloneJson(watch.searchParams),
alertRules: cloneJson(watch.alertRules),
pollingEnabled: watch.pollingEnabled,
alertsEnabled: watch.alertsEnabled,
createdAt: watch.createdAt,
updatedAt: watch.updatedAt,
lastSnapshot: cloneJson(watch.lastSnapshot),
lastError: cloneJson(watch.lastError),
};
}
getWatch(watchId) {
const watch = this.watches.get(watchId);
return watch ? this.toPublicWatch(watch) : null;
}
listWatches() {
return Array.from(this.watches.values())
.map((watch) => this.toPublicWatch(watch))
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
}
setGlobalControls(patch = {}) {
if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) {
this.globalControls.crawlingEnabled = normalizeToggle(
patch.crawlingEnabled,
this.globalControls.crawlingEnabled
);
}
if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) {
this.globalControls.alertsEnabled = normalizeToggle(
patch.alertsEnabled,
this.globalControls.alertsEnabled
);
}
return { ...this.globalControls };
}
getGlobalControls() {
return { ...this.globalControls };
}
updateWatch(watchId, patch = {}) {
const watch = this.watches.get(watchId);
if (!watch) {
throw new Error(`Unknown watchId: ${watchId}`);
}
if (Object.prototype.hasOwnProperty.call(patch, "rawInput")) {
watch.rawInput = typeof patch.rawInput === "string" ? patch.rawInput : watch.rawInput;
}
if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) {
if (!patch.searchParams || typeof patch.searchParams !== "object") {
throw new Error("searchParams must be an object");
}
watch.searchParams = patch.searchParams;
}
if (Object.prototype.hasOwnProperty.call(patch, "alertRules")) {
watch.alertRules = normalizeAlertRules(patch.alertRules);
}
if (Object.prototype.hasOwnProperty.call(patch, "pollingEnabled")) {
watch.pollingEnabled = normalizeToggle(patch.pollingEnabled, watch.pollingEnabled);
}
if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) {
watch.alertsEnabled = normalizeToggle(patch.alertsEnabled, watch.alertsEnabled);
}
if (Object.prototype.hasOwnProperty.call(patch, "lastSnapshot")) {
watch.lastSnapshot = patch.lastSnapshot || null;
}
if (Object.prototype.hasOwnProperty.call(patch, "lastError")) {
watch.lastError = patch.lastError || null;
}
watch.updatedAt = new Date(this.now()).toISOString();
return this.toPublicWatch(watch);
}
async emitPolled(watch, result) {
if (!this.onWatchPolled) return;
try {
await this.onWatchPolled({
watch: this.toPublicWatch(watch),
result: cloneJson(result),
});
} catch (error) {
this.logger.error(`[watch:${watch.id}] post-poll hook failed: ${error.message}`);
}
}
addWatch({
id,
rawInput,
searchParams,
alertRules,
pollingEnabled = true,
alertsEnabled = true,
lastSnapshot = null,
lastError = null,
createdAt,
updatedAt,
}) {
if (!searchParams || typeof searchParams !== "object") {
throw new Error("searchParams is required");
}
const watchId = id || newWatchId();
if (this.watches.has(watchId)) {
throw new Error(`watchId already exists: ${watchId}`);
}
const nowIso = new Date(this.now()).toISOString();
this.watches.set(watchId, {
id: watchId,
rawInput: typeof rawInput === "string" ? rawInput : "",
searchParams,
alertRules: normalizeAlertRules(alertRules),
pollingEnabled: normalizeToggle(pollingEnabled, true),
alertsEnabled: normalizeToggle(alertsEnabled, true),
createdAt: typeof createdAt === "string" ? createdAt : nowIso,
updatedAt: typeof updatedAt === "string" ? updatedAt : nowIso,
lastSnapshot: lastSnapshot || null,
lastError: lastError || null,
});
return watchId;
}
removeWatch(watchId) {
return this.watches.delete(watchId);
}
listWatchIds() {
return Array.from(this.watches.keys());
}
async start() {
if (this.timer) return;
await this.pollAll();
this.timer = setInterval(() => {
void this.pollAll();
}, this.pollIntervalMs);
}
stop() {
if (!this.timer) return;
clearInterval(this.timer);
this.timer = null;
}
async pollAll() {
if (!this.globalControls.crawlingEnabled) {
const skipped = [];
for (const watch of this.watches.values()) {
const result = {
watchId: watch.id,
skipped: {
reason: "global_crawling_disabled",
},
};
skipped.push(result);
await this.emitPolled(watch, result);
}
return skipped;
}
const results = [];
for (const watch of this.watches.values()) {
if (!watch.pollingEnabled) {
const result = {
watchId: watch.id,
skipped: {
reason: "watch_polling_disabled",
},
};
results.push(result);
await this.emitPolled(watch, result);
continue;
}
// Poll sequentially to keep provider-side rate limits predictable.
results.push(await this.pollWatch(watch.id));
}
return results;
}
async pollWatch(watchId) {
const watch = this.watches.get(watchId);
if (!watch) {
throw new Error(`Unknown watchId: ${watchId}`);
}
if (!this.globalControls.crawlingEnabled) {
const result = {
watchId: watch.id,
skipped: { reason: "global_crawling_disabled" },
};
await this.emitPolled(watch, result);
return result;
}
if (!watch.pollingEnabled) {
const result = {
watchId: watch.id,
skipped: { reason: "watch_polling_disabled" },
};
await this.emitPolled(watch, result);
return result;
}
try {
const offers = await this.crawler.getQuotes({
watchId: watch.id,
rawInput: watch.rawInput,
searchParams: watch.searchParams,
});
const normalizedOffers = normalizeOffers(offers);
if (normalizedOffers.length === 0) {
throw new Error("Crawler returned no valid offers");
}
const bestOffer = normalizedOffers[0];
const currentSnapshot = {
polledAt: new Date(this.now()).toISOString(),
bestPrice: bestOffer.price,
currency: bestOffer.currency,
bestOffer,
offers: normalizedOffers,
};
const alert = buildAlertEvent(watch, watch.lastSnapshot, currentSnapshot);
watch.lastSnapshot = currentSnapshot;
watch.updatedAt = currentSnapshot.polledAt;
let notificationSent = false;
let notificationError = null;
if (alert) {
if (this.globalControls.alertsEnabled && watch.alertsEnabled) {
try {
await this.notifier.notify(alert);
notificationSent = true;
} catch (error) {
const at = new Date(this.now()).toISOString();
notificationError = createWatchError({
message: `Notifier failed: ${error.message}`,
at,
phase: "notify",
});
this.logger.error(`[watch:${watch.id}] notify failed: ${error.message}`);
}
} else {
alert.notificationSuppressed = true;
}
}
watch.lastError = notificationError;
if (notificationError) {
watch.updatedAt = notificationError.at;
}
const result = {
watchId: watch.id,
snapshot: currentSnapshot,
alert,
notificationSent,
};
if (notificationError) {
result.error = notificationError;
}
await this.emitPolled(watch, result);
return result;
} catch (error) {
watch.lastError = createWatchError({
message: error.message,
at: new Date(this.now()).toISOString(),
phase: "crawl",
});
watch.updatedAt = watch.lastError.at;
this.logger.error(`[watch:${watch.id}] poll failed: ${error.message}`);
const result = {
watchId: watch.id,
error: watch.lastError,
};
await this.emitPolled(watch, result);
return result;
}
}
}
module.exports = {
PriceWatcher,
};

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env node
"use strict";
const http = require("node:http");
function stableHash(text) {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
}
return hash;
}
function serializeSearchSeed(searchParams) {
const segments = Array.isArray(searchParams?.segments) ? searchParams.segments : [];
const segmentText =
segments.length > 0
? segments
.map((segment) => `${segment.from || "?"}-${segment.to || "?"}`)
.join("|")
.toUpperCase()
: "NO_SEGMENTS";
return JSON.stringify({
segmentText,
departureDateWindow: searchParams?.departureDateWindow || null,
stayDurationDays: searchParams?.stayDurationDays || null,
passengers: searchParams?.passengers?.total || null,
});
}
function buildSampleOffers(searchParams) {
const seed = stableHash(serializeSearchSeed(searchParams));
const base = 650000 + (seed % 500000);
return [
{
provider: "skyscanner",
price: Math.round(base),
currency: "KRW",
metadata: {
source: "skyscanner-sample",
rank: 1,
},
},
{
provider: "skyscanner",
price: Math.round(base * 1.04),
currency: "KRW",
metadata: {
source: "skyscanner-sample",
rank: 2,
},
},
{
provider: "skyscanner",
price: Math.round(base * 1.08),
currency: "KRW",
metadata: {
source: "skyscanner-sample",
rank: 3,
},
},
];
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1024 * 1024) {
reject(new Error("Payload too large"));
req.destroy();
}
});
req.on("end", () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch (error) {
reject(new Error(`Invalid JSON payload: ${error.message}`));
}
});
req.on("error", (error) => {
reject(error);
});
});
}
function sendJson(res, statusCode, body) {
const payload = JSON.stringify(body);
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
});
res.end(payload);
}
function createSkyscannerSampleHandler(options = {}) {
const routePath =
typeof options.routePath === "string" && options.routePath.trim()
? options.routePath.trim()
: "/skyscanner";
return async (req, res) => {
if (req.method !== "POST" || req.url !== routePath) {
sendJson(res, 404, {
error: "Not found",
expected: `POST ${routePath}`,
});
return;
}
try {
const body = await readJsonBody(req);
const requestPayload =
body && typeof body.request === "object" && body.request ? body.request : body;
const searchParams =
requestPayload && typeof requestPayload.searchParams === "object"
? requestPayload.searchParams
: {};
sendJson(res, 200, {
currency: "KRW",
offers: buildSampleOffers(searchParams),
});
} catch (error) {
sendJson(res, 400, { error: error.message });
}
};
}
function createSkyscannerSampleServer(options = {}) {
return http.createServer(createSkyscannerSampleHandler(options));
}
function runCli() {
const host = process.env.SKYSCANNER_SAMPLE_HOST || "127.0.0.1";
const portRaw = process.env.SKYSCANNER_SAMPLE_PORT || "8787";
const routePath = process.env.SKYSCANNER_SAMPLE_PATH || "/skyscanner";
const port = Number(portRaw);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid SKYSCANNER_SAMPLE_PORT: ${portRaw}`);
}
const server = createSkyscannerSampleServer({ routePath });
server.listen(port, host, () => {
process.stdout.write(
`Skyscanner sample server listening on http://${host}:${port}${routePath}\n`
);
});
const shutdown = () => {
server.close(() => {
process.exit(0);
});
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
if (require.main === module) {
try {
runCli();
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
}
}
module.exports = {
buildSampleOffers,
createSkyscannerSampleHandler,
createSkyscannerSampleServer,
};