"use strict"; const crypto = require("node:crypto"); const { normalizeCrawlIntervalMs } = require("./pollingConfig"); 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, ownerId: watch.ownerId || null, 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 = normalizeCrawlIntervalMs(options.pollIntervalMs); 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.pollingInFlight = false; this.globalControls = { crawlingEnabled: true, alertsEnabled: true, }; } toPublicWatch(watch) { return { id: watch.id, ownerId: watch.ownerId || null, 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, "ownerId")) { const ownerId = typeof patch.ownerId === "string" && patch.ownerId.trim() ? patch.ownerId.trim() : null; watch.ownerId = ownerId; } if (Object.prototype.hasOwnProperty.call(patch, "searchParams")) { if (!patch.searchParams || typeof patch.searchParams !== "object") { throw new Error("searchParams must be an object"); } 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, ownerId = null, 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, ownerId: typeof ownerId === "string" && ownerId.trim() ? ownerId.trim() : null, 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.pollingInFlight) { return [ { skipped: { reason: "poll_cycle_in_progress", }, }, ]; } this.pollingInFlight = true; try { if (!this.globalControls.crawlingEnabled) { const skipped = []; for (const watch of this.watches.values()) { const result = { watchId: watch.id, skipped: { reason: "global_crawling_disabled", }, }; skipped.push(result); await this.emitPolled(watch, result); } return skipped; } const results = []; for (const watch of this.watches.values()) { if (!watch.pollingEnabled) { const result = { watchId: watch.id, skipped: { reason: "watch_polling_disabled", }, }; results.push(result); await this.emitPolled(watch, result); continue; } // Poll sequentially to keep provider-side rate limits predictable. results.push(await this.pollWatch(watch.id)); } return results; } finally { this.pollingInFlight = false; } } 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; } // IP 차단 방지를 위한 최종 안전장치: 필수 정보 부재 시 크롤링 건너뜀 const params = watch.searchParams || {}; if (!params.segments || params.segments.length === 0 || !params.departureDateWindow?.from) { const result = { watchId: watch.id, skipped: { reason: "missing_essential_search_params" }, }; await this.emitPolled(watch, result); return result; } try { let normalizedOffers = []; const searchParams = watch.searchParams || {}; const byCabin = searchParams.passengers?.byCabin || {}; const activeCabins = Object.entries(byCabin).filter(([_, count]) => count > 0); if (activeCabins.length > 1) { // 다중 클래스(예: 비즈니스 2, 이코노미 1) 쪼개서 크롤링 const sameFlightMode = searchParams.constraints?.sameFlightForAllPassengers !== false; const cabinResults = []; let totalBestPrice = 0; let subOffers = []; const firstCurrency = "KRW"; for (const [cabin, count] of activeCabins) { const singleCabinParams = cloneJson(searchParams); singleCabinParams.passengers.total = count; singleCabinParams.passengers.byCabin = { [cabin]: count }; const offers = await this.crawler.getQuotes({ watchId: watch.id, rawInput: watch.rawInput, searchParams: singleCabinParams, }); const normalized = normalizeOffers(offers); if (normalized.length === 0) { throw new Error(`Crawler returned no valid offers for cabin: ${cabin}`); } cabinResults.push({ cabin, count, offers: normalized }); } if (sameFlightMode) { // [추후 고도화 필요] 동일 비행기(편명, 시간) 매칭 로직. // 현재는 Mock 또는 단순 크롤러 결과이므로 각 클래스 최저가를 합산하되, '동일 항공편 유지'라는 플래그만 UI에 전달. for (const res of cabinResults) { const best = res.offers[0]; totalBestPrice += best.price; subOffers.push({ cabin: res.cabin, paxCount: res.count, price: best.price, provider: best.provider, metadata: best.metadata || null }); } } else { // 개별 편명 최저가 허용 시, 무조건 각 결과의 최저가를 합산 for (const res of cabinResults) { const best = res.offers[0]; totalBestPrice += best.price; subOffers.push({ cabin: res.cabin, paxCount: res.count, price: best.price, provider: best.provider, metadata: best.metadata || null }); } } const combinedOffer = { provider: "mixed-cabins", price: totalBestPrice, currency: cabinResults[0].offers[0]?.currency || firstCurrency, metadata: subOffers[0]?.metadata || null, subOffers, }; normalizedOffers = [combinedOffer]; } else { const offers = await this.crawler.getQuotes({ watchId: watch.id, rawInput: watch.rawInput, searchParams: watch.searchParams, }); normalizedOffers = normalizeOffers(offers); if (normalizedOffers.length === 0) { throw new Error("Crawler returned no valid offers"); } } const bestOffer = normalizedOffers[0]; const 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, { watch: this.toPublicWatch(watch), }); 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, };