chore: 현재 작업 중간 커밋

This commit is contained in:
chungyeong
2026-03-05 11:00:45 +09:00
parent 02970df6af
commit be88b4fcec
43 changed files with 6837 additions and 466 deletions

View File

@@ -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();