leapfrog-mcp 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/adaptive-wait.d.ts +72 -0
  2. package/dist/adaptive-wait.js +695 -0
  3. package/dist/api-intelligence.d.ts +82 -0
  4. package/dist/api-intelligence.js +575 -0
  5. package/dist/captcha-solver.d.ts +26 -0
  6. package/dist/captcha-solver.js +547 -0
  7. package/dist/cdp-connector.d.ts +33 -0
  8. package/dist/cdp-connector.js +176 -0
  9. package/dist/consent-dismiss.d.ts +33 -0
  10. package/dist/consent-dismiss.js +358 -0
  11. package/dist/crash-recovery.d.ts +74 -0
  12. package/dist/crash-recovery.js +242 -0
  13. package/dist/domain-knowledge.d.ts +149 -0
  14. package/dist/domain-knowledge.js +449 -0
  15. package/dist/harness-intelligence.d.ts +65 -0
  16. package/dist/harness-intelligence.js +432 -0
  17. package/dist/humanize-fingerprint.d.ts +42 -0
  18. package/dist/humanize-fingerprint.js +161 -0
  19. package/dist/humanize-mouse.d.ts +95 -0
  20. package/dist/humanize-mouse.js +275 -0
  21. package/dist/humanize-pause.d.ts +48 -0
  22. package/dist/humanize-pause.js +111 -0
  23. package/dist/humanize-scroll.d.ts +67 -0
  24. package/dist/humanize-scroll.js +185 -0
  25. package/dist/humanize-typing.d.ts +60 -0
  26. package/dist/humanize-typing.js +258 -0
  27. package/dist/humanize-utils.d.ts +62 -0
  28. package/dist/humanize-utils.js +100 -0
  29. package/dist/intervention.d.ts +65 -0
  30. package/dist/intervention.js +591 -0
  31. package/dist/logger.d.ts +13 -0
  32. package/dist/logger.js +47 -0
  33. package/dist/network-intelligence.d.ts +70 -0
  34. package/dist/network-intelligence.js +424 -0
  35. package/dist/page-classifier.d.ts +33 -0
  36. package/dist/page-classifier.js +1000 -0
  37. package/dist/paginate.d.ts +42 -0
  38. package/dist/paginate.js +693 -0
  39. package/dist/recording.d.ts +72 -0
  40. package/dist/recording.js +934 -0
  41. package/dist/script-executor.d.ts +31 -0
  42. package/dist/script-executor.js +249 -0
  43. package/dist/session-hud.d.ts +20 -0
  44. package/dist/session-hud.js +134 -0
  45. package/dist/snapshot-differ.d.ts +26 -0
  46. package/dist/snapshot-differ.js +225 -0
  47. package/dist/ssrf.d.ts +28 -0
  48. package/dist/ssrf.js +290 -0
  49. package/dist/stealth-audit.d.ts +27 -0
  50. package/dist/stealth-audit.js +719 -0
  51. package/dist/stealth.d.ts +195 -0
  52. package/dist/stealth.js +1157 -0
  53. package/dist/tab-manager.d.ts +14 -0
  54. package/dist/tab-manager.js +306 -0
  55. package/dist/tiles-coordinator.d.ts +106 -0
  56. package/dist/tiles-coordinator.js +358 -0
  57. package/package.json +1 -1
@@ -0,0 +1,1000 @@
1
+ // ─── Page Classifier ──────────────────────────────────────────────────────
2
+ //
3
+ // LLM-free page classification using weighted signal scoring.
4
+ // Operates on URL, HTTP status, snapshot text, and optional meta tags.
5
+ // Zero browser access — pure function, no side effects, no async.
6
+ //
7
+ // ─── Snapshot text parser ─────────────────────────────────────────────────
8
+ const SNAP_LINE_RE = /^(\s*)(@e\d+)\s+(\w[\w-]*)\s+"([^"]*)"/;
9
+ const HEADING_LEVEL_RE = /\(h(\d)\)/;
10
+ const PRICE_RE = /(?:\$|£|€|¥|₹)\s?\d[\d,.]+|\d[\d,.]+\s?(?:USD|EUR|GBP|JPY|INR)/gi;
11
+ function parseSnapshotText(text) {
12
+ const elements = [];
13
+ for (const line of text.split("\n")) {
14
+ const match = line.match(SNAP_LINE_RE);
15
+ if (!match)
16
+ continue;
17
+ const el = {
18
+ ref: match[2],
19
+ role: match[3],
20
+ name: match[4],
21
+ };
22
+ if (el.role === "heading") {
23
+ const lvl = line.match(HEADING_LEVEL_RE);
24
+ if (lvl)
25
+ el.level = parseInt(lvl[1], 10);
26
+ }
27
+ elements.push(el);
28
+ }
29
+ const headings = elements.filter((e) => e.role === "heading");
30
+ const buttons = elements.filter((e) => e.role === "button");
31
+ const links = elements.filter((e) => e.role === "link");
32
+ const textboxes = elements.filter((e) => e.role === "textbox" || e.role === "searchbox");
33
+ const comboboxes = elements.filter((e) => e.role === "combobox" || e.role === "listbox");
34
+ const images = elements.filter((e) => e.role === "img");
35
+ const INTERACTIVE_ROLES = new Set([
36
+ "button",
37
+ "link",
38
+ "textbox",
39
+ "checkbox",
40
+ "radio",
41
+ "combobox",
42
+ "listbox",
43
+ "option",
44
+ "menuitem",
45
+ "tab",
46
+ "switch",
47
+ "slider",
48
+ "spinbutton",
49
+ "searchbox",
50
+ ]);
51
+ const interactiveCount = elements.filter((e) => INTERACTIVE_ROLES.has(e.role)).length;
52
+ const hasPasswordField = textboxes.some((t) => /password|passwd/i.test(t.name));
53
+ // Count price patterns in the full snapshot text
54
+ const priceMatches = text.match(PRICE_RE);
55
+ const priceCount = priceMatches ? priceMatches.length : 0;
56
+ return {
57
+ elements,
58
+ headings,
59
+ buttons,
60
+ links,
61
+ textboxes,
62
+ comboboxes,
63
+ images,
64
+ hasPasswordField,
65
+ priceCount,
66
+ interactiveCount,
67
+ totalCount: elements.length,
68
+ rawText: text,
69
+ };
70
+ }
71
+ // ─── Helpers ──────────────────────────────────────────────────────────────
72
+ /** Case-insensitive check: does any element's name match the pattern? */
73
+ function anyNameMatches(elements, pattern) {
74
+ return elements.some((e) => pattern.test(e.name));
75
+ }
76
+ /** Count elements whose name matches a pattern */
77
+ function countNameMatches(elements, pattern) {
78
+ return elements.filter((e) => pattern.test(e.name)).length;
79
+ }
80
+ /** Check if snapshot raw text contains a pattern (case-insensitive) */
81
+ function snapshotContains(snapshot, pattern) {
82
+ return pattern.test(snapshot.rawText);
83
+ }
84
+ // ─── Search engine hosts ──────────────────────────────────────────────────
85
+ const SEARCH_ENGINE_HOSTS = new Set([
86
+ "google.com",
87
+ "www.google.com",
88
+ "bing.com",
89
+ "www.bing.com",
90
+ "duckduckgo.com",
91
+ "www.duckduckgo.com",
92
+ "yahoo.com",
93
+ "search.yahoo.com",
94
+ "baidu.com",
95
+ "www.baidu.com",
96
+ ]);
97
+ // ─── Media hosts ──────────────────────────────────────────────────────────
98
+ const MEDIA_HOSTS = new Set([
99
+ "youtube.com",
100
+ "www.youtube.com",
101
+ "vimeo.com",
102
+ "www.vimeo.com",
103
+ "twitch.tv",
104
+ "www.twitch.tv",
105
+ "spotify.com",
106
+ "open.spotify.com",
107
+ ]);
108
+ // ─── Signal definitions ───────────────────────────────────────────────────
109
+ const LOGIN_SIGNALS = [
110
+ {
111
+ name: "url:login-path",
112
+ weight: 3,
113
+ test: (ctx) => /\/(login|signin|sign-in|auth|sso)(\/|$|\?)/i.test(ctx.url.pathname),
114
+ },
115
+ {
116
+ name: "url:register-path",
117
+ weight: 3,
118
+ test: (ctx) => /\/(register|signup|sign-up|join)(\/|$|\?)/i.test(ctx.url.pathname),
119
+ },
120
+ {
121
+ name: "snapshot:password-field",
122
+ weight: 5,
123
+ test: (ctx) => ctx.snapshot.hasPasswordField,
124
+ },
125
+ {
126
+ name: "snapshot:few-text-inputs",
127
+ weight: 3,
128
+ test: (ctx) => {
129
+ const count = ctx.snapshot.textboxes.length;
130
+ return count >= 1 && count <= 3;
131
+ },
132
+ },
133
+ {
134
+ name: "snapshot:login-button",
135
+ weight: 3,
136
+ test: (ctx) => anyNameMatches(ctx.snapshot.buttons, /\b(log\s*in|sign\s*in|sign\s*up|register|create\s*account)\b/i),
137
+ },
138
+ {
139
+ name: "snapshot:oauth-buttons",
140
+ weight: 2,
141
+ test: (ctx) => {
142
+ const all = [...ctx.snapshot.buttons, ...ctx.snapshot.links];
143
+ return anyNameMatches(all, /\b(google|github|facebook|apple|sso|microsoft|twitter)\b/i);
144
+ },
145
+ },
146
+ {
147
+ name: "snapshot:forgot-password",
148
+ weight: 2,
149
+ test: (ctx) => anyNameMatches(ctx.snapshot.links, /\b(forgot|reset)\s*(your\s*)?password\b/i),
150
+ },
151
+ ];
152
+ const SEARCH_RESULTS_SIGNALS = [
153
+ {
154
+ name: "url:search-path",
155
+ weight: 4,
156
+ test: (ctx) => /\/(search|results)(\/|$|\?)/i.test(ctx.url.pathname) ||
157
+ ctx.url.searchParams.has("q") ||
158
+ ctx.url.searchParams.has("query") ||
159
+ ctx.url.searchParams.has("s"),
160
+ },
161
+ {
162
+ name: "url:search-engine-host",
163
+ weight: 5,
164
+ test: (ctx) => SEARCH_ENGINE_HOSTS.has(ctx.url.hostname),
165
+ },
166
+ {
167
+ name: "meta:jsonld-search",
168
+ weight: 5,
169
+ test: (ctx) => ctx.meta.jsonLdType === "SearchResultsPage",
170
+ },
171
+ {
172
+ name: "snapshot:results-heading",
173
+ weight: 3,
174
+ test: (ctx) => anyNameMatches(ctx.snapshot.headings, /\bresults?\b|showing\s+\d+\s+results?\b/i),
175
+ },
176
+ {
177
+ name: "snapshot:many-similar-links",
178
+ weight: 2,
179
+ test: (ctx) => ctx.snapshot.links.length >= 5,
180
+ },
181
+ {
182
+ name: "url:pagination-params",
183
+ weight: 2,
184
+ test: (ctx) => ctx.url.searchParams.has("page") ||
185
+ ctx.url.searchParams.has("start") ||
186
+ ctx.url.searchParams.has("p"),
187
+ },
188
+ ];
189
+ const PRODUCT_SIGNALS = [
190
+ {
191
+ name: "meta:jsonld-product",
192
+ weight: 6,
193
+ test: (ctx) => ctx.meta.jsonLdType === "Product",
194
+ },
195
+ {
196
+ name: "meta:og-product",
197
+ weight: 4,
198
+ test: (ctx) => ctx.meta.ogType === "product" || ctx.meta.ogType === "product:item",
199
+ },
200
+ {
201
+ name: "url:product-path",
202
+ weight: 3,
203
+ test: (ctx) => /\/(product|dp|item|p|pd)(\/|$)/i.test(ctx.url.pathname),
204
+ },
205
+ {
206
+ name: "snapshot:price-pattern",
207
+ weight: 4,
208
+ test: (ctx) => ctx.snapshot.priceCount >= 1,
209
+ },
210
+ {
211
+ name: "snapshot:add-to-cart",
212
+ weight: 4,
213
+ test: (ctx) => anyNameMatches(ctx.snapshot.buttons, /\b(add\s*to\s*cart|buy\s*now|add\s*to\s*bag)\b/i),
214
+ },
215
+ {
216
+ name: "snapshot:quantity-input",
217
+ weight: 2,
218
+ test: (ctx) => anyNameMatches([...ctx.snapshot.textboxes, ...ctx.snapshot.comboboxes], /\bquantity|qty\b/i),
219
+ },
220
+ {
221
+ name: "snapshot:rating-review",
222
+ weight: 2,
223
+ test: (ctx) => snapshotContains(ctx.snapshot, /\d+(\.\d+)?\s*(star|★|\/\s*5)|reviews?\b/i),
224
+ },
225
+ ];
226
+ const PRODUCT_LIST_SIGNALS = [
227
+ {
228
+ name: "url:category-path",
229
+ weight: 3,
230
+ test: (ctx) => /\/(category|collection|shop|c|browse)(\/|$)/i.test(ctx.url.pathname),
231
+ },
232
+ {
233
+ name: "meta:jsonld-collection",
234
+ weight: 5,
235
+ test: (ctx) => ctx.meta.jsonLdType === "CollectionPage" ||
236
+ ctx.meta.jsonLdType === "ItemList",
237
+ },
238
+ {
239
+ name: "snapshot:many-prices",
240
+ weight: 4,
241
+ test: (ctx) => ctx.snapshot.priceCount >= 4,
242
+ },
243
+ {
244
+ name: "snapshot:filter-sort",
245
+ weight: 3,
246
+ test: (ctx) => {
247
+ const all = [
248
+ ...ctx.snapshot.buttons,
249
+ ...ctx.snapshot.comboboxes,
250
+ ...ctx.snapshot.links,
251
+ ];
252
+ return anyNameMatches(all, /\b(filter|sort\s*by|show\s*\d+\s*per|refine)\b/i);
253
+ },
254
+ },
255
+ {
256
+ name: "snapshot:pagination",
257
+ weight: 2,
258
+ test: (ctx) => anyNameMatches(ctx.snapshot.links, /\b(next|prev(ious)?|page\s*\d+)\b/i),
259
+ },
260
+ {
261
+ name: "url:pagination-path",
262
+ weight: 2,
263
+ test: (ctx) => /\/page\/\d+/i.test(ctx.url.pathname) ||
264
+ ctx.url.searchParams.has("page") ||
265
+ ctx.url.searchParams.has("p"),
266
+ },
267
+ ];
268
+ const CHECKOUT_SIGNALS = [
269
+ {
270
+ name: "url:checkout-path",
271
+ weight: 4,
272
+ test: (ctx) => /\/(cart|checkout|payment|order|basket)(\/|$|\?)/i.test(ctx.url.pathname),
273
+ },
274
+ {
275
+ name: "snapshot:credit-card-fields",
276
+ weight: 5,
277
+ test: (ctx) => anyNameMatches(ctx.snapshot.textboxes, /\b(card\s*number|cvv|cvc|expir|security\s*code|credit\s*card)\b/i),
278
+ },
279
+ {
280
+ name: "snapshot:place-order-button",
281
+ weight: 4,
282
+ test: (ctx) => anyNameMatches(ctx.snapshot.buttons, /\b(place\s*order|complete\s*purchase|pay\s*now|confirm\s*order|submit\s*order)\b/i),
283
+ },
284
+ {
285
+ name: "snapshot:shipping-fields",
286
+ weight: 3,
287
+ test: (ctx) => anyNameMatches(ctx.snapshot.textboxes, /\b(street|address|city|zip|postal|state|shipping)\b/i),
288
+ },
289
+ {
290
+ name: "snapshot:order-summary",
291
+ weight: 3,
292
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(order\s*summary|subtotal|total|order\s*total)\b/i),
293
+ },
294
+ {
295
+ name: "snapshot:payment-methods",
296
+ weight: 3,
297
+ test: (ctx) => {
298
+ const all = [
299
+ ...ctx.snapshot.buttons,
300
+ ...ctx.snapshot.links,
301
+ ...ctx.snapshot.images,
302
+ ];
303
+ return anyNameMatches(all, /\b(visa|mastercard|paypal|apple\s*pay|google\s*pay|amex)\b/i);
304
+ },
305
+ },
306
+ ];
307
+ const ARTICLE_SIGNALS = [
308
+ {
309
+ name: "meta:jsonld-article",
310
+ weight: 6,
311
+ test: (ctx) => /^(Article|NewsArticle|BlogPosting|BlogPost)$/i.test(ctx.meta.jsonLdType ?? ""),
312
+ },
313
+ {
314
+ name: "meta:og-article",
315
+ weight: 4,
316
+ test: (ctx) => ctx.meta.ogType === "article",
317
+ },
318
+ {
319
+ name: "url:article-path",
320
+ weight: 3,
321
+ test: (ctx) => /\/(blog|post|article|news|wiki)(\/|$)/i.test(ctx.url.pathname) ||
322
+ /\/20\d{2}\//.test(ctx.url.pathname),
323
+ },
324
+ {
325
+ name: "snapshot:single-h1",
326
+ weight: 2,
327
+ test: (ctx) => {
328
+ const h1s = ctx.snapshot.headings.filter((h) => h.level === 1);
329
+ return h1s.length === 1;
330
+ },
331
+ },
332
+ {
333
+ name: "snapshot:author-date",
334
+ weight: 3,
335
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(by\s+[A-Z]|author|published|date|byline|written\s+by)\b/i),
336
+ },
337
+ {
338
+ name: "snapshot:article-landmark",
339
+ weight: 3,
340
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "article"),
341
+ },
342
+ {
343
+ name: "snapshot:high-text-ratio",
344
+ weight: 3,
345
+ test: (ctx) => {
346
+ if (ctx.snapshot.totalCount === 0)
347
+ return false;
348
+ const ratio = ctx.snapshot.interactiveCount / ctx.snapshot.totalCount;
349
+ // Low interactive ratio = mostly text content
350
+ return ratio < 0.4 && ctx.snapshot.totalCount > 5;
351
+ },
352
+ },
353
+ {
354
+ name: "snapshot:table-of-contents",
355
+ weight: 5,
356
+ test: (ctx) => {
357
+ // Navigation element with hierarchical links, or elements with TOC-like id/class
358
+ const hasTocNav = ctx.snapshot.elements.some((e) => (e.role === "navigation" &&
359
+ /\b(toc|contents|table.of.contents)\b/i.test(e.name)) ||
360
+ /\b(toc|table.of.contents|contents)\b/i.test(e.name));
361
+ // Also detect numbered section links (e.g. "1 Introduction", "2.1 History")
362
+ const numberedLinks = ctx.snapshot.links.filter((l) => /^\d+(\.\d+)*\s+\w/.test(l.name));
363
+ return hasTocNav || numberedLinks.length >= 4;
364
+ },
365
+ },
366
+ {
367
+ name: "snapshot:heading-hierarchy-depth",
368
+ weight: 5,
369
+ test: (ctx) => {
370
+ // At least 3 distinct heading levels present (e.g. h2, h3, h4)
371
+ const levels = new Set(ctx.snapshot.headings.map((h) => h.level).filter((l) => l !== undefined));
372
+ return levels.size >= 3;
373
+ },
374
+ },
375
+ {
376
+ name: "snapshot:references-section",
377
+ weight: 5,
378
+ test: (ctx) => anyNameMatches(ctx.snapshot.headings, /\b(references|citations|bibliography|further\s+reading|see\s+also|works\s+cited|sources|notes\s+and\s+references)\b/i),
379
+ },
380
+ {
381
+ name: "snapshot:inline-citations",
382
+ weight: 4,
383
+ test: (ctx) => {
384
+ // Bracketed numbers [1][2][3] or many <cite>/<sup> elements
385
+ const bracketCitations = ctx.snapshot.rawText.match(/\[\d+\]/g);
386
+ const citeElements = ctx.snapshot.elements.filter((e) => e.role === "superscript" || /\bcite\b/i.test(e.role));
387
+ return (bracketCitations !== null && bracketCitations.length >= 5) ||
388
+ citeElements.length >= 5;
389
+ },
390
+ },
391
+ {
392
+ name: "snapshot:many-headings",
393
+ weight: 4,
394
+ test: (ctx) => {
395
+ // Long-form content with many section headings (15+)
396
+ return ctx.snapshot.headings.length >= 15;
397
+ },
398
+ },
399
+ {
400
+ name: "snapshot:high-element-count-low-interactive",
401
+ weight: 3,
402
+ test: (ctx) => {
403
+ // Large page (200+ elements) with low interactive ratio = long-form text
404
+ if (ctx.snapshot.totalCount < 200)
405
+ return false;
406
+ const ratio = ctx.snapshot.interactiveCount / ctx.snapshot.totalCount;
407
+ return ratio < 0.5;
408
+ },
409
+ },
410
+ ];
411
+ const DASHBOARD_SIGNALS = [
412
+ {
413
+ name: "url:dashboard-path",
414
+ weight: 4,
415
+ test: (ctx) => /\/(dashboard|admin|analytics|app|console)(\/|$|\?)/i.test(ctx.url.pathname),
416
+ },
417
+ {
418
+ name: "snapshot:nav-sidebar",
419
+ weight: 3,
420
+ test: (ctx) => {
421
+ // Look for navigation element followed by many links
422
+ const navs = ctx.snapshot.elements.filter((e) => e.role === "navigation");
423
+ // Heuristic: 8+ links total suggests sidebar nav
424
+ return navs.length > 0 && ctx.snapshot.links.length >= 8;
425
+ },
426
+ },
427
+ {
428
+ name: "snapshot:data-tables",
429
+ weight: 3,
430
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "table" || e.role === "grid" || e.role === "treegrid"),
431
+ },
432
+ {
433
+ name: "snapshot:charts-widgets",
434
+ weight: 2,
435
+ test: (ctx) => ctx.snapshot.images.length >= 2 ||
436
+ ctx.snapshot.elements.some((e) => e.role === "figure"),
437
+ },
438
+ {
439
+ name: "snapshot:high-interactive-density",
440
+ weight: 3,
441
+ test: (ctx) => {
442
+ if (ctx.snapshot.totalCount === 0)
443
+ return false;
444
+ const ratio = ctx.snapshot.interactiveCount / ctx.snapshot.totalCount;
445
+ return ratio > 0.7 && ctx.snapshot.interactiveCount > 10;
446
+ },
447
+ },
448
+ {
449
+ name: "snapshot:logout-settings",
450
+ weight: 2,
451
+ test: (ctx) => {
452
+ const all = [...ctx.snapshot.links, ...ctx.snapshot.buttons];
453
+ return anyNameMatches(all, /\b(log\s*out|sign\s*out|settings)\b/i);
454
+ },
455
+ },
456
+ {
457
+ name: "meta:noindex",
458
+ weight: 2,
459
+ test: (ctx) => /noindex/i.test(ctx.meta.robots ?? ""),
460
+ },
461
+ ];
462
+ const FORM_SIGNALS = [
463
+ {
464
+ name: "url:form-path",
465
+ weight: 3,
466
+ test: (ctx) => /\/(contact|apply|feedback|survey|form)(\/|$|\?)/i.test(ctx.url.pathname),
467
+ },
468
+ {
469
+ name: "snapshot:many-text-inputs",
470
+ weight: 3,
471
+ test: (ctx) => ctx.snapshot.textboxes.length >= 4,
472
+ },
473
+ {
474
+ name: "snapshot:submit-button",
475
+ weight: 3,
476
+ test: (ctx) => anyNameMatches(ctx.snapshot.buttons, /\b(submit|send|send\s*message|request|apply)\b/i) &&
477
+ !anyNameMatches(ctx.snapshot.buttons, /\b(log\s*in|sign\s*in|pay|place\s*order)\b/i),
478
+ },
479
+ {
480
+ name: "snapshot:has-textarea",
481
+ weight: 3,
482
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "textbox" && /message|comment|note|description/i.test(e.name)),
483
+ },
484
+ {
485
+ name: "snapshot:form-labels",
486
+ weight: 3,
487
+ test: (ctx) => countNameMatches(ctx.snapshot.textboxes, /\b(name|email|phone|company|subject)\b/i) >= 2,
488
+ },
489
+ {
490
+ name: "snapshot:no-password",
491
+ weight: 2,
492
+ test: (ctx) => !ctx.snapshot.hasPasswordField,
493
+ },
494
+ ];
495
+ const ERROR_SIGNALS = [
496
+ {
497
+ name: "http:error-status",
498
+ weight: 8,
499
+ test: (ctx) => (ctx.status ?? 0) >= 400,
500
+ },
501
+ {
502
+ name: "snapshot:error-heading",
503
+ weight: 5,
504
+ test: (ctx) => anyNameMatches(ctx.snapshot.headings, /\b(404|not\s*found|error|403|500|503|forbidden|server\s*error)\b/i),
505
+ },
506
+ {
507
+ name: "snapshot:few-interactive",
508
+ weight: 2,
509
+ test: (ctx) => ctx.snapshot.interactiveCount < 5,
510
+ },
511
+ {
512
+ name: "snapshot:go-back-link",
513
+ weight: 2,
514
+ test: (ctx) => anyNameMatches(ctx.snapshot.links, /\b(go\s*back|go\s*home|home|return|back\s*to)\b/i),
515
+ },
516
+ {
517
+ name: "snapshot:error-body-text",
518
+ weight: 3,
519
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(page\s*not\s*found|access\s*denied|server\s*error|something\s*went\s*wrong)\b/i),
520
+ },
521
+ ];
522
+ const CHALLENGE_SIGNALS = [
523
+ {
524
+ name: "snapshot:verification-text",
525
+ weight: 6,
526
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(verify\s+(you\s+are|that\s+you('re|\s+are))\s+human|are\s+you\s+a\s+robot|i'?m\s+not\s+a\s+robot|prove\s+you('re|\s+are)\s+human|captcha|security\s+check|bot\s+detection|unusual\s+traffic|automated\s+queries)\b/i),
527
+ },
528
+ {
529
+ name: "snapshot:challenge-action",
530
+ weight: 5,
531
+ test: (ctx) => {
532
+ const all = [...ctx.snapshot.buttons, ...ctx.snapshot.links];
533
+ return anyNameMatches(all, /\b(click\s+to\s+continue|press\s+(&|and)\s+hold|i'?m\s+not\s+a\s+robot|verify|continue\s+shopping)\b/i);
534
+ },
535
+ },
536
+ {
537
+ name: "snapshot:access-denied-text",
538
+ weight: 5,
539
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(access\s+denied|blocked|sorry.*can'?t\s+(access|process)|request\s+blocked|connection\s+has\s+been\s+blocked)\b/i),
540
+ },
541
+ {
542
+ name: "snapshot:challenge-title",
543
+ weight: 4,
544
+ test: (ctx) => anyNameMatches(ctx.snapshot.headings, /\b(access\s+denied|security\s+check|just\s+a\s+moment|please\s+wait|attention\s+required|one\s+more\s+step|verify|before\s+you\s+continue)\b/i),
545
+ },
546
+ {
547
+ name: "snapshot:cloudflare-signals",
548
+ weight: 5,
549
+ test: (ctx) => snapshotContains(ctx.snapshot, /\bray\s*id\b/i) ||
550
+ (snapshotContains(ctx.snapshot, /\bcloudflare\b/i) &&
551
+ ctx.snapshot.totalCount < 30),
552
+ },
553
+ {
554
+ name: "snapshot:sparse-page",
555
+ weight: 3,
556
+ test: (ctx) => ctx.snapshot.interactiveCount < 10 && ctx.snapshot.totalCount < 30,
557
+ },
558
+ {
559
+ name: "snapshot:checkbox-verify",
560
+ weight: 4,
561
+ test: (ctx) => {
562
+ const hasCheckbox = ctx.snapshot.elements.some((e) => e.role === "checkbox");
563
+ const hasVerifyText = snapshotContains(ctx.snapshot, /\b(not\s+a\s+robot|human|verify|confirm)\b/i);
564
+ return hasCheckbox && hasVerifyText;
565
+ },
566
+ },
567
+ {
568
+ name: "snapshot:single-action-sparse",
569
+ weight: 3,
570
+ test: (ctx) => {
571
+ // Single primary button on an otherwise empty page
572
+ return ctx.snapshot.buttons.length <= 2 &&
573
+ ctx.snapshot.textboxes.length === 0 &&
574
+ ctx.snapshot.totalCount < 25;
575
+ },
576
+ },
577
+ ];
578
+ const LANDING_SIGNALS = [
579
+ {
580
+ name: "url:root-path",
581
+ weight: 3,
582
+ test: (ctx) => {
583
+ const p = ctx.url.pathname;
584
+ return p === "/" || p === "/home" || p === "/home/" || p === "";
585
+ },
586
+ },
587
+ {
588
+ name: "url:marketing-path",
589
+ weight: 3,
590
+ test: (ctx) => /\/(pricing|features|about|plans)(\/|$|\?)/i.test(ctx.url.pathname),
591
+ },
592
+ {
593
+ name: "snapshot:cta-buttons",
594
+ weight: 4,
595
+ test: (ctx) => anyNameMatches(ctx.snapshot.buttons, /\b(get\s*started|try\s*free|sign\s*up|learn\s*more|start\s*free|request\s*demo)\b/i),
596
+ },
597
+ {
598
+ name: "snapshot:hero-heading",
599
+ weight: 2,
600
+ test: (ctx) => {
601
+ const h1s = ctx.snapshot.headings.filter((h) => h.level === 1);
602
+ return h1s.length === 1;
603
+ },
604
+ },
605
+ {
606
+ name: "meta:og-website",
607
+ weight: 2,
608
+ test: (ctx) => ctx.meta.ogType === "website",
609
+ },
610
+ {
611
+ name: "snapshot:varied-sections",
612
+ weight: 2,
613
+ test: (ctx) => {
614
+ // Multiple headings at different levels suggest sections
615
+ const levels = new Set(ctx.snapshot.headings.map((h) => h.level));
616
+ return levels.size >= 2 && ctx.snapshot.headings.length >= 3;
617
+ },
618
+ },
619
+ {
620
+ name: "meta:no-noindex",
621
+ weight: 2,
622
+ test: (ctx) => !/noindex/i.test(ctx.meta.robots ?? ""),
623
+ },
624
+ ];
625
+ const DOCUMENTATION_SIGNALS = [
626
+ {
627
+ name: "url:docs-path",
628
+ weight: 4,
629
+ test: (ctx) => /\/(docs|documentation|api|reference|wiki|guide)(\/|$|\?)/i.test(ctx.url.pathname),
630
+ },
631
+ {
632
+ name: "snapshot:sidebar-nav",
633
+ weight: 3,
634
+ test: (ctx) => {
635
+ const navs = ctx.snapshot.elements.filter((e) => e.role === "navigation");
636
+ return navs.length > 0 && ctx.snapshot.links.length >= 10;
637
+ },
638
+ },
639
+ {
640
+ name: "snapshot:code-blocks",
641
+ weight: 3,
642
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "code" || e.role === "pre") || snapshotContains(ctx.snapshot, /\bcode\b/i),
643
+ },
644
+ {
645
+ name: "snapshot:breadcrumbs",
646
+ weight: 2,
647
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "navigation" &&
648
+ /breadcrumb/i.test(e.name)),
649
+ },
650
+ {
651
+ name: "snapshot:tech-headings",
652
+ weight: 3,
653
+ test: (ctx) => anyNameMatches(ctx.snapshot.headings, /\b(version|api|method|parameter|endpoint|syntax|usage|install|config|reference)\b/i),
654
+ },
655
+ {
656
+ name: "meta:jsonld-tech-article",
657
+ weight: 5,
658
+ test: (ctx) => ctx.meta.jsonLdType === "TechArticle" ||
659
+ ctx.meta.jsonLdType === "APIReference",
660
+ },
661
+ ];
662
+ const PROFILE_SIGNALS = [
663
+ {
664
+ name: "url:profile-path",
665
+ weight: 4,
666
+ test: (ctx) => /\/(profile|account|settings|user|me|preferences)(\/|$|\?)/i.test(ctx.url.pathname),
667
+ },
668
+ {
669
+ name: "snapshot:avatar-image",
670
+ weight: 2,
671
+ test: (ctx) => anyNameMatches(ctx.snapshot.images, /\b(avatar|profile|photo|picture)\b/i),
672
+ },
673
+ {
674
+ name: "snapshot:edit-profile-button",
675
+ weight: 3,
676
+ test: (ctx) => anyNameMatches(ctx.snapshot.buttons, /\b(edit|update|save|change\s*password|edit\s*profile)\b/i),
677
+ },
678
+ {
679
+ name: "snapshot:personal-info-fields",
680
+ weight: 3,
681
+ test: (ctx) => countNameMatches(ctx.snapshot.textboxes, /\b(name|email|bio|phone|username|display\s*name)\b/i) >= 2,
682
+ },
683
+ {
684
+ name: "snapshot:account-links",
685
+ weight: 2,
686
+ test: (ctx) => anyNameMatches(ctx.snapshot.links, /\b(account|security|privacy|notification|billing|subscription)\b/i),
687
+ },
688
+ {
689
+ name: "snapshot:single-user-name",
690
+ weight: 2,
691
+ test: (ctx) => {
692
+ // A single h1 with a person-like name (not a page title pattern)
693
+ const h1s = ctx.snapshot.headings.filter((h) => h.level === 1);
694
+ return (h1s.length === 1 &&
695
+ anyNameMatches(ctx.snapshot.elements, /\b(avatar|profile)\b/i));
696
+ },
697
+ },
698
+ ];
699
+ const MEDIA_SIGNALS = [
700
+ {
701
+ name: "url:media-path",
702
+ weight: 4,
703
+ test: (ctx) => /\/(watch|video|play|gallery|album|episode|listen)(\/|$|\?)/i.test(ctx.url.pathname),
704
+ },
705
+ {
706
+ name: "snapshot:video-audio-elements",
707
+ weight: 5,
708
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "video" ||
709
+ e.role === "audio" ||
710
+ /\b(video\s*player|audio\s*player)\b/i.test(e.name)),
711
+ },
712
+ {
713
+ name: "snapshot:playback-controls",
714
+ weight: 3,
715
+ test: (ctx) => anyNameMatches([...ctx.snapshot.buttons, ...ctx.snapshot.elements], /\b(play|pause|mute|unmute|fullscreen|volume|rewind|forward)\b/i),
716
+ },
717
+ {
718
+ name: "snapshot:timestamp-duration",
719
+ weight: 2,
720
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b\d{1,2}:\d{2}(:\d{2})?\b/),
721
+ },
722
+ {
723
+ name: "snapshot:related-content",
724
+ weight: 3,
725
+ test: (ctx) => anyNameMatches([...ctx.snapshot.headings, ...ctx.snapshot.elements], /\b(related|recommended|up\s*next|more\s*videos|you\s*may\s*also)\b/i),
726
+ },
727
+ ];
728
+ // P0-5: Feed page signals (Reddit, HackerNews, social feeds)
729
+ const FEED_SIGNALS = [
730
+ {
731
+ name: "url:feed-host",
732
+ weight: 5,
733
+ test: (ctx) => /\b(reddit\.com|news\.ycombinator\.com|lobste\.rs|hackernews|slashdot\.org)\b/i.test(ctx.url.hostname),
734
+ },
735
+ {
736
+ name: "url:feed-path",
737
+ weight: 3,
738
+ test: (ctx) => /\/(feed|timeline|hot|new|top|rising|popular|trending)(\/|$|\?)/i.test(ctx.url.pathname),
739
+ },
740
+ {
741
+ name: "url:feed-host-root",
742
+ weight: 3,
743
+ test: (ctx) => {
744
+ // Known feed hosts at root path — feeds like HN serve the feed at "/", not "/feed"
745
+ const isRoot = ctx.url.pathname === "/" || ctx.url.pathname === "";
746
+ const isFeedHost = /\b(reddit\.com|news\.ycombinator\.com|lobste\.rs|hackernews|slashdot\.org)\b/i.test(ctx.url.hostname);
747
+ return isRoot && isFeedHost;
748
+ },
749
+ },
750
+ {
751
+ name: "snapshot:repeated-item-links",
752
+ weight: 4,
753
+ test: (ctx) => {
754
+ // P0-5: Repeated sibling detection — 5+ links with similar structure suggests feed items
755
+ return ctx.snapshot.links.length >= 10;
756
+ },
757
+ },
758
+ {
759
+ name: "snapshot:link-dense-minimal-dom",
760
+ weight: 3,
761
+ test: (ctx) => {
762
+ // Minimal DOMs where links dominate — high link:element ratio suggests a link feed
763
+ // e.g. HN has few total elements but most are links to stories
764
+ if (ctx.snapshot.totalCount < 5)
765
+ return false;
766
+ const linkRatio = ctx.snapshot.links.length / ctx.snapshot.totalCount;
767
+ return linkRatio > 0.5 && ctx.snapshot.links.length >= 5;
768
+ },
769
+ },
770
+ {
771
+ name: "snapshot:table-feed-structure",
772
+ weight: 3,
773
+ test: (ctx) => {
774
+ // Table-based feeds (HN-style): raw text contains repeated table-row feed markers
775
+ // Detects athing/subtext (HN), or repeated row-like patterns in minimal DOMs
776
+ return snapshotContains(ctx.snapshot, /\b(athing|subtext|titleline|storylink|comhead)\b/i) ||
777
+ // Generic: repeated numbered items like "1.", "2.", ... in raw text (HN-style ranking)
778
+ (ctx.snapshot.rawText.match(/^\s*\d+\.\s/gm) ?? []).length >= 5;
779
+ },
780
+ },
781
+ {
782
+ name: "snapshot:vote-score-elements",
783
+ weight: 4,
784
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(upvote|downvote|points?|score|karma|▲|▼)\b/i) ||
785
+ countNameMatches(ctx.snapshot.buttons, /\b(upvote|downvote|vote)\b/i) >= 2,
786
+ },
787
+ {
788
+ name: "snapshot:timestamps",
789
+ weight: 3,
790
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(\d+\s+(hours?|minutes?|days?|weeks?|months?)\s+ago|submitted|posted)\b/i),
791
+ },
792
+ {
793
+ name: "snapshot:comment-counts",
794
+ weight: 3,
795
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b\d+\s*comments?\b/i),
796
+ },
797
+ ];
798
+ // P0-5: Q&A page signals (StackOverflow, Q&A sites)
799
+ const QA_SIGNALS = [
800
+ {
801
+ name: "url:qa-host",
802
+ weight: 5,
803
+ test: (ctx) => /\b(stackoverflow\.com|stackexchange\.com|superuser\.com|askubuntu\.com|serverfault\.com|quora\.com)\b/i.test(ctx.url.hostname),
804
+ },
805
+ {
806
+ name: "url:qa-path",
807
+ weight: 3,
808
+ test: (ctx) => /\/(questions?|answers?|ask)(\/|$|\?)/i.test(ctx.url.pathname),
809
+ },
810
+ {
811
+ name: "snapshot:question-answer-structure",
812
+ weight: 4,
813
+ test: (ctx) => anyNameMatches(ctx.snapshot.headings, /\b(question|answer|asked|solution)\b/i) ||
814
+ snapshotContains(ctx.snapshot, /\b(asked|answered|modified)\b/i),
815
+ },
816
+ {
817
+ name: "snapshot:vote-counts",
818
+ weight: 4,
819
+ test: (ctx) => countNameMatches(ctx.snapshot.buttons, /\b(up\s*vote|down\s*vote|vote)\b/i) >= 2,
820
+ },
821
+ {
822
+ name: "snapshot:accepted-answer",
823
+ weight: 5,
824
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(accepted\s*answer|best\s*answer|✓|✔)\b/i) ||
825
+ anyNameMatches(ctx.snapshot.elements, /\b(accepted|checkmark)\b/i),
826
+ },
827
+ {
828
+ name: "snapshot:tags-badges",
829
+ weight: 3,
830
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(tagged|tags?:)\b/i) ||
831
+ anyNameMatches(ctx.snapshot.links, /^(javascript|python|java|html|css|react|node|sql|c\+\+|php|ruby|swift|go|rust|typescript)\b/i),
832
+ },
833
+ ];
834
+ // P0-5: Ecommerce homepage signals (Amazon, eBay, marketplace)
835
+ const ECOMMERCE_SIGNALS = [
836
+ {
837
+ name: "url:ecommerce-host",
838
+ weight: 5,
839
+ test: (ctx) => /\b(amazon\.|ebay\.|walmart\.|etsy\.|shopify\.|aliexpress\.|target\.com|bestbuy\.com)\b/i.test(ctx.url.hostname),
840
+ },
841
+ {
842
+ name: "snapshot:cart-icon",
843
+ weight: 4,
844
+ test: (ctx) => anyNameMatches([...ctx.snapshot.buttons, ...ctx.snapshot.links], /\b(cart|basket|bag|shopping\s*cart)\b/i),
845
+ },
846
+ {
847
+ name: "snapshot:product-grid-prices",
848
+ weight: 4,
849
+ test: (ctx) => ctx.snapshot.priceCount >= 3 &&
850
+ ctx.snapshot.images.length >= 3,
851
+ },
852
+ {
853
+ name: "snapshot:prominent-search",
854
+ weight: 3,
855
+ test: (ctx) => ctx.snapshot.elements.some((e) => e.role === "searchbox") ||
856
+ anyNameMatches(ctx.snapshot.textboxes, /\b(search|find)\b/i),
857
+ },
858
+ {
859
+ name: "snapshot:deals-categories",
860
+ weight: 3,
861
+ test: (ctx) => snapshotContains(ctx.snapshot, /\b(deal|sale|discount|% off|save\s+\$|clearance|department|categories)\b/i),
862
+ },
863
+ {
864
+ name: "snapshot:repeated-product-elements",
865
+ weight: 3,
866
+ test: (ctx) => {
867
+ // P0-5: Repeated sibling detection — many images + prices = product grid
868
+ return ctx.snapshot.images.length >= 5 && ctx.snapshot.priceCount >= 4;
869
+ },
870
+ },
871
+ ];
872
+ // ─── Type profiles ────────────────────────────────────────────────────────
873
+ const TYPE_PROFILES = [
874
+ { type: "login", maxPossible: 21, signals: LOGIN_SIGNALS },
875
+ { type: "search-results", maxPossible: 21, signals: SEARCH_RESULTS_SIGNALS },
876
+ { type: "product", maxPossible: 25, signals: PRODUCT_SIGNALS },
877
+ { type: "product-list", maxPossible: 19, signals: PRODUCT_LIST_SIGNALS },
878
+ { type: "checkout", maxPossible: 22, signals: CHECKOUT_SIGNALS },
879
+ { type: "article", maxPossible: 30, signals: ARTICLE_SIGNALS },
880
+ { type: "dashboard", maxPossible: 19, signals: DASHBOARD_SIGNALS },
881
+ { type: "form", maxPossible: 17, signals: FORM_SIGNALS },
882
+ { type: "error", maxPossible: 20, signals: ERROR_SIGNALS },
883
+ { type: "challenge", maxPossible: 35, signals: CHALLENGE_SIGNALS },
884
+ { type: "landing", maxPossible: 18, signals: LANDING_SIGNALS },
885
+ { type: "documentation", maxPossible: 20, signals: DOCUMENTATION_SIGNALS },
886
+ { type: "profile", maxPossible: 16, signals: PROFILE_SIGNALS },
887
+ { type: "media", maxPossible: 17, signals: MEDIA_SIGNALS },
888
+ { type: "feed", maxPossible: 31, signals: FEED_SIGNALS }, // P0-5
889
+ { type: "qa", maxPossible: 24, signals: QA_SIGNALS }, // P0-5
890
+ { type: "ecommerce", maxPossible: 22, signals: ECOMMERCE_SIGNALS }, // P0-5
891
+ ];
892
+ function scoreType(profile, ctx) {
893
+ let score = 0;
894
+ const firedSignals = [];
895
+ for (const signal of profile.signals) {
896
+ if (signal.test(ctx)) {
897
+ score += signal.weight;
898
+ firedSignals.push(signal.name);
899
+ }
900
+ }
901
+ return {
902
+ score,
903
+ confidence: profile.maxPossible > 0 ? score / profile.maxPossible : 0,
904
+ firedSignals,
905
+ };
906
+ }
907
+ // ─── Public API ───────────────────────────────────────────────────────────
908
+ const CONFIDENCE_THRESHOLD = 0.3;
909
+ const ALL_PAGE_TYPES = [
910
+ "login",
911
+ "search-results",
912
+ "product",
913
+ "product-list",
914
+ "checkout",
915
+ "article",
916
+ "dashboard",
917
+ "form",
918
+ "error",
919
+ "challenge",
920
+ "landing",
921
+ "documentation",
922
+ "profile",
923
+ "media",
924
+ "feed", // P0-5
925
+ "qa", // P0-5
926
+ "ecommerce", // P0-5
927
+ "unknown",
928
+ ];
929
+ function emptyScores() {
930
+ const scores = {};
931
+ for (const t of ALL_PAGE_TYPES) {
932
+ scores[t] = 0;
933
+ }
934
+ return scores;
935
+ }
936
+ export class PageClassifier {
937
+ /** Classify a page based on URL, snapshot, and optional meta */
938
+ static classify(input) {
939
+ let url;
940
+ try {
941
+ url = new URL(input.url);
942
+ }
943
+ catch {
944
+ url = new URL("https://unknown.invalid");
945
+ }
946
+ const snapshot = parseSnapshotText(input.snapshotText);
947
+ const ctx = {
948
+ url,
949
+ status: input.status,
950
+ snapshot,
951
+ meta: {
952
+ ogType: input.meta?.ogType,
953
+ jsonLdType: input.meta?.jsonLdType,
954
+ robots: input.meta?.robots,
955
+ description: input.meta?.description,
956
+ },
957
+ };
958
+ const allScores = emptyScores();
959
+ let bestType = "unknown";
960
+ let bestConfidence = 0;
961
+ let bestSignals = [];
962
+ for (const profile of TYPE_PROFILES) {
963
+ const result = scoreType(profile, ctx);
964
+ allScores[profile.type] = result.score;
965
+ if (result.confidence > bestConfidence) {
966
+ bestConfidence = result.confidence;
967
+ bestType = profile.type;
968
+ bestSignals = result.firedSignals;
969
+ }
970
+ }
971
+ // Apply confidence threshold
972
+ if (bestConfidence < CONFIDENCE_THRESHOLD) {
973
+ bestType = "unknown";
974
+ bestSignals = [];
975
+ bestConfidence = 0;
976
+ }
977
+ return {
978
+ type: bestType,
979
+ confidence: Math.round(bestConfidence * 100) / 100,
980
+ signals: bestSignals,
981
+ allScores,
982
+ metadata: {
983
+ formFields: snapshot.textboxes.length,
984
+ interactiveElements: snapshot.interactiveCount,
985
+ hasPassword: snapshot.hasPasswordField,
986
+ hasPrice: snapshot.priceCount > 0,
987
+ jsonLdType: input.meta?.jsonLdType,
988
+ ogType: input.meta?.ogType,
989
+ },
990
+ };
991
+ }
992
+ /** Quick classify from URL only (Layer 1 only, lower confidence) */
993
+ static classifyUrl(url) {
994
+ return PageClassifier.classify({
995
+ url,
996
+ snapshotText: "",
997
+ });
998
+ }
999
+ }
1000
+ export default PageClassifier;