initial commit
This commit is contained in:
443
src/priceWatcher.js
Normal file
443
src/priceWatcher.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user