Files
air-watcher/src/priceWatcher.js
2026-03-05 11:00:45 +09:00

539 lines
16 KiB
JavaScript

"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,
};