539 lines
16 KiB
JavaScript
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,
|
|
};
|