chore: 현재 작업 중간 커밋
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { normalizeCrawlIntervalMs } = require("./pollingConfig");
|
||||
|
||||
function cloneJson(value) {
|
||||
if (value === undefined) return undefined;
|
||||
@@ -97,6 +98,7 @@ function buildAlertEvent(watch, previousSnapshot, currentSnapshot) {
|
||||
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,
|
||||
@@ -121,10 +123,7 @@ 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.pollIntervalMs = normalizeCrawlIntervalMs(options.pollIntervalMs);
|
||||
this.logger = options.logger || console;
|
||||
this.now = options.now || (() => new Date());
|
||||
this.onWatchPolled =
|
||||
@@ -139,6 +138,7 @@ class PriceWatcher {
|
||||
|
||||
this.watches = new Map();
|
||||
this.timer = null;
|
||||
this.pollingInFlight = false;
|
||||
this.globalControls = {
|
||||
crawlingEnabled: true,
|
||||
alertsEnabled: true,
|
||||
@@ -148,6 +148,7 @@ class PriceWatcher {
|
||||
toPublicWatch(watch) {
|
||||
return {
|
||||
id: watch.id,
|
||||
ownerId: watch.ownerId || null,
|
||||
rawInput: watch.rawInput,
|
||||
searchParams: cloneJson(watch.searchParams),
|
||||
alertRules: cloneJson(watch.alertRules),
|
||||
@@ -201,6 +202,11 @@ class PriceWatcher {
|
||||
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");
|
||||
@@ -241,6 +247,7 @@ class PriceWatcher {
|
||||
|
||||
addWatch({
|
||||
id,
|
||||
ownerId = null,
|
||||
rawInput,
|
||||
searchParams,
|
||||
alertRules,
|
||||
@@ -263,6 +270,7 @@ class PriceWatcher {
|
||||
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),
|
||||
@@ -301,38 +309,53 @@ class PriceWatcher {
|
||||
}
|
||||
|
||||
async pollAll() {
|
||||
if (!this.globalControls.crawlingEnabled) {
|
||||
const skipped = [];
|
||||
for (const watch of this.watches.values()) {
|
||||
const result = {
|
||||
watchId: watch.id,
|
||||
if (this.pollingInFlight) {
|
||||
return [
|
||||
{
|
||||
skipped: {
|
||||
reason: "global_crawling_disabled",
|
||||
reason: "poll_cycle_in_progress",
|
||||
},
|
||||
};
|
||||
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;
|
||||
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;
|
||||
}
|
||||
// Poll sequentially to keep provider-side rate limits predictable.
|
||||
results.push(await this.pollWatch(watch.id));
|
||||
|
||||
const results = [];
|
||||
for (const watch of this.watches.values()) {
|
||||
if (!watch.pollingEnabled) {
|
||||
const result = {
|
||||
watchId: watch.id,
|
||||
skipped: {
|
||||
reason: "watch_polling_disabled",
|
||||
},
|
||||
};
|
||||
results.push(result);
|
||||
await this.emitPolled(watch, result);
|
||||
continue;
|
||||
}
|
||||
// Poll sequentially to keep provider-side rate limits predictable.
|
||||
results.push(await this.pollWatch(watch.id));
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
this.pollingInFlight = false;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async pollWatch(watchId) {
|
||||
@@ -359,15 +382,85 @@ class PriceWatcher {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const offers = await this.crawler.getQuotes({
|
||||
// IP 차단 방지를 위한 최종 안전장치: 필수 정보 부재 시 크롤링 건너뜀
|
||||
const params = watch.searchParams || {};
|
||||
if (!params.segments || params.segments.length === 0 || !params.departureDateWindow?.from) {
|
||||
const result = {
|
||||
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");
|
||||
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];
|
||||
@@ -388,7 +481,9 @@ class PriceWatcher {
|
||||
if (alert) {
|
||||
if (this.globalControls.alertsEnabled && watch.alertsEnabled) {
|
||||
try {
|
||||
await this.notifier.notify(alert);
|
||||
await this.notifier.notify(alert, {
|
||||
watch: this.toPublicWatch(watch),
|
||||
});
|
||||
notificationSent = true;
|
||||
} catch (error) {
|
||||
const at = new Date(this.now()).toISOString();
|
||||
|
||||
Reference in New Issue
Block a user