From 7e707e0fae1c4902e5918d5a63277d556be8b505 Mon Sep 17 00:00:00 2001 From: Ruhan Srivastava Date: Tue, 14 Jul 2026 00:54:48 +0530 Subject: [PATCH] fix: stop capturing a fresh screenshot on every unchanged check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrape+diff pipeline took a fresh, billable context.dev screenshot on every content and product check, before the change comparison — even when nothing had changed. The screenshot only backs the "current version" thumbnail on the monitor card; change detection is the markdown/price hash, not the image. So a default content monitor (1h interval) spent 24 screenshot captures a day on a page that never changed, on top of the markdown scrape it actually needs. Capture the screenshot on the first snapshot and whenever the scraped content or product snapshot actually changes — keyed off the same snapshot hash the pipeline already computes — and keep the last screenshot on an identical check. That roughly halves context.dev calls for the common case of a quiet page, with no change to which alerts fire: the thumbnail tracks the snapshot, so it refreshes on any real change and never goes stale, while price alerts stay gated on an actual price change. Also drop the manual AbortController + setTimeout timeout in the two AI helpers in favour of AbortSignal.timeout(), which releases the timer on every path (the manual timer leaked on the error path). Matches the pattern already used in notify-outbound-webhook.ts. --- src/lib/ai-alert-triage.ts | 5 +---- src/lib/ai-change-summary.ts | 5 +---- src/lib/scraper.ts | 33 ++++++++++++++++++++++++++------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/lib/ai-alert-triage.ts b/src/lib/ai-alert-triage.ts index 47786c0..c44c0c4 100644 --- a/src/lib/ai-alert-triage.ts +++ b/src/lib/ai-alert-triage.ts @@ -124,17 +124,14 @@ export async function triageChange(params: { ].join("\n"); try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS); const { text } = await generateText({ model: createLanguageModel(config), system: TRIAGE_SYSTEM_PROMPT, prompt: userMessage, maxOutputTokens: MAX_OUTPUT_TOKENS, temperature: 0, - abortSignal: controller.signal, + abortSignal: AbortSignal.timeout(LLM_TIMEOUT_MS), }); - clearTimeout(timeout); return parseTriageDecision(text); } catch (err) { // Fail open: a triage failure must never hide a real change. diff --git a/src/lib/ai-change-summary.ts b/src/lib/ai-change-summary.ts index e61bcb2..17a87e7 100644 --- a/src/lib/ai-change-summary.ts +++ b/src/lib/ai-change-summary.ts @@ -183,17 +183,14 @@ export async function summarizeChange(params: { ].join("\n"); try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS); const { text } = await generateText({ model: createLanguageModel(config), system: SYSTEM_PROMPT, prompt: userMessage, maxOutputTokens: MAX_OUTPUT_TOKENS, temperature: 0.2, - abortSignal: controller.signal, + abortSignal: AbortSignal.timeout(LLM_TIMEOUT_MS), }); - clearTimeout(timeout); const summary = text.trim(); return summary || null; } catch (err) { diff --git a/src/lib/scraper.ts b/src/lib/scraper.ts index d606fe1..3bc43db 100644 --- a/src/lib/scraper.ts +++ b/src/lib/scraper.ts @@ -201,8 +201,14 @@ async function handleContentTarget( if (!target.pageUrl) return; const page = target.pageUrl; const curr = await ensureMarkdown(page, cache); - await ensurePageScreenshot(page, cache); const prev = await latestSnapshot(website.id, "MARKDOWN", page); + // The screenshot only backs the "current version" thumbnail, not change + // detection (that's the markdown hash). Capture it on the first snapshot and + // whenever the content actually changes; an unchanged page keeps its last + // screenshot, so we don't spend a context.dev capture on every quiet check. + if (!prev || prev.hash !== curr.hash) { + await ensurePageScreenshot(page, cache); + } if (prev && prev.hash !== curr.hash) { const { preview, totalAdded, totalRemoved } = diffPreview(prev.payload, curr.md); alerts.push({ @@ -234,7 +240,6 @@ async function handleProductPriceTarget( if (!target.pageUrl) return; const page = target.pageUrl; const result = await extractProduct(page, { apiKey: cache.contextApiKey }); - await ensurePageScreenshot(page, cache); const snap: ProductSnapshotData = { is_product_page: result.is_product_page, platform: result.platform ?? null, @@ -247,11 +252,25 @@ async function handleProductPriceTarget( cache.product.set(page, value); const prev = await latestSnapshot(website.id, "PRODUCT", page); - if (!prev) return; - if (!snap.is_product_page) return; - const prevData = JSON.parse(prev.payload) as ProductSnapshotData; - if (!prevData.is_product_page) return; - if (priceChangeKey(prevData) === priceChangeKey(snap)) return; + const prevData = prev ? (JSON.parse(prev.payload) as ProductSnapshotData) : null; + const priceChanged = + prevData !== null && + snap.is_product_page && + prevData.is_product_page && + priceChangeKey(prevData) !== priceChangeKey(snap); + + // The screenshot only backs the "current version" thumbnail. Refresh it on the + // first snapshot and whenever the extracted product payload changes at all — + // keyed off the snapshot hash, mirroring the content handler — so the thumbnail + // can't go stale while the payload moves (name/platform/availability) even when + // the price is unchanged. An identical check keeps the last screenshot, so a + // quiet page doesn't spend a context.dev capture. Alert emission stays gated on + // an actual price change below. + if (!prev || prev.hash !== value.hash) { + await ensurePageScreenshot(page, cache); + } + + if (!priceChanged || !prevData) return; const name = snap.productName || page; const fmt = (p: number | null, c: string | null) =>