initial commit

This commit is contained in:
chungyeong
2026-02-19 17:28:58 +09:00
commit 02970df6af
34 changed files with 5673 additions and 0 deletions

443
src/priceWatcher.js Normal file
View File

@@ -0,0 +1,443 @@
"use strict";
const crypto = require("node:crypto");
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,
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 =
Number.isFinite(Number(options.pollIntervalMs)) && Number(options.pollIntervalMs) > 0
? Number(options.pollIntervalMs)
: 60000;
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.globalControls = {
crawlingEnabled: true,
alertsEnabled: true,
};
}
toPublicWatch(watch) {
return {
id: watch.id,
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, "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,
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,
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.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;
}
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;
}
try {
const offers = await this.crawler.getQuotes({
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");
}
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);
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,
};