1import { Actor, log } from 'apify';
2import { z } from 'zod';
3import { computeDiff, extractLines, sha256, stripVolatile } from './lib.js';
4
5const InputSchema = z.object({
6 urls: z.array(z.string().min(1)).min(1, 'Provide at least one URL.'),
7 selector: z.string().optional(),
8 sensitivity: z.enum(['any', 'meaningful']).optional().default('meaningful'),
9 webhookUrl: z.string().url().optional(),
10});
11
12type Input = z.infer<typeof InputSchema>;
13
14const CHARGE_EVENT = 'url-checked';
15const STORE_NAME = 'semantic-change-snapshots';
16const CONCURRENCY = 5;
17const FETCH_TIMEOUT_MS = 30_000;
18const WEBHOOK_TIMEOUT_MS = 10_000;
19const MAX_STORED_LINES = 4_000;
20const MAX_DIFF_LINES = 8;
21const MAX_DIFF_EXCERPT_CHARS = 2_000;
22
23interface Snapshot {
24 hash: string;
25 lines: string[];
26 checkedAt: string;
27}
28
29interface CheckResult {
30 url: string;
31 changed: boolean;
32 change_summary: string;
33 diff_excerpt: string | null;
34 checked_at: string;
35 error?: string;
36}
37
38function normalizeUrl(raw: string): string {
39 const t = raw.trim();
40 if (!t) throw new Error('Empty URL');
41 return t.includes('://') ? t : `https://${t}`;
42}
43
44async function fetchHtml(url: string): Promise<string> {
45 const ac = new AbortController();
46 const t = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
47 try {
48 const res = await fetch(url, {
49 signal: ac.signal,
50 redirect: 'follow',
51 headers: {
52 'user-agent': 'Mozilla/5.0 (compatible; SemanticChangeDetector/1.0; +https://apify.com)',
53 accept: 'text/html,application/xhtml+xml',
54 },
55 });
56 if (!res.ok) {
57 throw new Error(`HTTP ${res.status} ${res.statusText}`);
58 }
59 return await res.text();
60 } finally {
61 clearTimeout(t);
62 }
63}
64
65function buildLines(html: string, selector: string | undefined, sensitivity: Input['sensitivity']): string[] {
66 const rawLines = extractLines(html, selector);
67 if (sensitivity === 'any') return rawLines;
68 return rawLines.map(stripVolatile).filter(Boolean);
69}
70
71function buildDiffExcerpt(added: string[], removed: string[]): string {
72 const parts: string[] = [];
73 for (const l of removed.slice(0, MAX_DIFF_LINES)) parts.push(`- ${l}`);
74 for (const l of added.slice(0, MAX_DIFF_LINES)) parts.push(`+ ${l}`);
75 let excerpt = parts.join('\n');
76 if (excerpt.length > MAX_DIFF_EXCERPT_CHARS) {
77 excerpt = `${excerpt.slice(0, MAX_DIFF_EXCERPT_CHARS)}…`;
78 }
79 return excerpt;
80}
81
82async function notifyWebhook(webhookUrl: string, payload: unknown): Promise<void> {
83 const ac = new AbortController();
84 const t = setTimeout(() => ac.abort(), WEBHOOK_TIMEOUT_MS);
85 try {
86 const res = await fetch(webhookUrl, {
87 method: 'POST',
88 signal: ac.signal,
89 headers: { 'content-type': 'application/json' },
90 body: JSON.stringify(payload),
91 });
92 if (!res.ok) {
93 log.warning(`Webhook POST to ${webhookUrl} returned HTTP ${res.status}`);
94 }
95 } catch (err) {
96 log.warning(`Webhook POST to ${webhookUrl} failed: ${err instanceof Error ? err.message : String(err)}`);
97 } finally {
98 clearTimeout(t);
99 }
100}
101
102async function checkUrl(rawUrl: string, input: Input, store: Awaited<ReturnType<typeof Actor.openKeyValueStore>>): Promise<CheckResult> {
103 const checkedAt = new Date().toISOString();
104
105 const key = `snapshot_${sha256(rawUrl)}`;
106
107 try {
108 const target = normalizeUrl(rawUrl);
109 const html = await fetchHtml(target);
110 const lines = buildLines(html, input.selector, input.sensitivity);
111 const joined = lines.join('\n');
112 const hash = sha256(joined);
113
114 const previous = await store.getValue<Snapshot>(key);
115
116 const snapshot: Snapshot = {
117 hash,
118 lines: lines.slice(0, MAX_STORED_LINES),
119 checkedAt,
120 };
121 await store.setValue(key, snapshot);
122
123 if (!previous) {
124 return {
125 url: rawUrl,
126 changed: false,
127 change_summary: 'No previous snapshot found. Baseline stored for future comparisons.',
128 diff_excerpt: null,
129 checked_at: checkedAt,
130 };
131 }
132
133 if (previous.hash === hash) {
134 return {
135 url: rawUrl,
136 changed: false,
137 change_summary: 'No change detected since last check.',
138 diff_excerpt: null,
139 checked_at: checkedAt,
140 };
141 }
142
143 const { added, removed } = computeDiff(previous.lines, lines);
144 const change_summary = `Content changed: ${added.length} line(s) added, ${removed.length} line(s) removed.`;
145 const diff_excerpt = buildDiffExcerpt(added, removed);
146
147 if (input.webhookUrl) {
148
149 void notifyWebhook(input.webhookUrl, {
150 url: rawUrl,
151 changed: true,
152 change_summary,
153 diff_excerpt,
154 checked_at: checkedAt,
155 });
156 }
157
158 return { url: rawUrl, changed: true, change_summary, diff_excerpt, checked_at: checkedAt };
159 } catch (err) {
160 const message = err instanceof Error ? err.message : String(err);
161 return {
162 url: rawUrl,
163 changed: false,
164 change_summary: 'Check failed.',
165 diff_excerpt: null,
166 checked_at: checkedAt,
167 error: message,
168 };
169 }
170}
171
172await Actor.init();
173
174const chargingManager = Actor.getChargingManager();
175await chargingManager.init();
176const pricingInfo = chargingManager.getPricingInfo();
177let chargeLimitReached = false;
178
179function remainingCharges(): number {
180 if (!pricingInfo.isPayPerEvent) return Number.POSITIVE_INFINITY;
181 return chargingManager.calculateMaxEventChargeCountWithinLimit(CHARGE_EVENT);
182}
183
184try {
185 const input = InputSchema.parse((await Actor.getInput()) ?? {});
186 const urls = [...new Set(input.urls.map((u) => u.trim()).filter(Boolean))];
187
188 if (urls.length === 0) {
189 throw new Error('No URLs provided.');
190 }
191
192 const store = await Actor.openKeyValueStore(STORE_NAME);
193 const dataset = await Actor.openDataset();
194
195 log.info(`Checking ${urls.length} URL(s) (sensitivity=${input.sensitivity})...`);
196
197 const active = new Set<Promise<void>>();
198
199 const processOne = async (url: string) => {
200 if (chargeLimitReached) return;
201 if (remainingCharges() <= 0) {
202 chargeLimitReached = true;
203 log.warning('Max total charge limit reached. Stopping further processing.');
204 return;
205 }
206
207 const result = await checkUrl(url, input, store);
208 await dataset.pushData(result);
209
210 try {
211 const chargeRes = await Actor.charge({ eventName: CHARGE_EVENT, count: 1 });
212 if (chargeRes?.eventChargeLimitReached) {
213 chargeLimitReached = true;
214 log.warning('Charge limit reached during this run. Stopping further processing.');
215 }
216 } catch (chargeErr) {
217 log.debug(`PPE charge skipped: ${chargeErr}`);
218 }
219
220 log.info(`${url}: ${result.changed ? 'CHANGED' : 'unchanged'}${result.error ? ` (error: ${result.error})` : ''}`);
221 };
222
223 for (const url of urls) {
224 if (chargeLimitReached) break;
225 const p = processOne(url).finally(() => active.delete(p));
226 active.add(p);
227 if (active.size >= CONCURRENCY) await Promise.race(active);
228 }
229
230 await Promise.all(active);
231
232 log.info(chargeLimitReached ? 'Stopped early due to charge limit.' : 'Done.');
233} catch (err) {
234 const message = err instanceof Error ? err.message : String(err);
235 log.error(`Actor failed: ${message}`);
236 throw err;
237} finally {
238 await Actor.exit();
239}