@detiq/crawler 1.1.13 → 1.1.15

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 (112) hide show
  1. package/Dockerfile +1 -1
  2. package/dist/accessibility-tree-crawler.js +156 -0
  3. package/dist/accessibility-tree-crawler.js.map +7 -0
  4. package/dist/advanced-ui-probes.js +310 -0
  5. package/dist/advanced-ui-probes.js.map +7 -0
  6. package/dist/content-chunker.js +2 -2
  7. package/dist/content-chunker.js.map +2 -2
  8. package/dist/crawl-session-manager.js +145 -0
  9. package/dist/crawl-session-manager.js.map +7 -0
  10. package/dist/crawler.js +1142 -263
  11. package/dist/crawler.js.map +2 -2
  12. package/dist/crud-interaction.js +264 -0
  13. package/dist/crud-interaction.js.map +7 -0
  14. package/dist/dropdown-enumerator.js +230 -0
  15. package/dist/dropdown-enumerator.js.map +7 -0
  16. package/dist/filter-pagination-crawler.js +299 -0
  17. package/dist/filter-pagination-crawler.js.map +7 -0
  18. package/dist/frontier.js +5 -3
  19. package/dist/frontier.js.map +2 -2
  20. package/dist/gap-pass.js +47 -14
  21. package/dist/gap-pass.js.map +2 -2
  22. package/dist/http-engine.js +28 -15
  23. package/dist/http-engine.js.map +2 -2
  24. package/dist/index.js +15 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/job-queue.js +12 -5
  27. package/dist/job-queue.js.map +2 -2
  28. package/dist/locator-extractor.js +23 -0
  29. package/dist/locator-extractor.js.map +2 -2
  30. package/dist/multi-context-handler.js +182 -0
  31. package/dist/multi-context-handler.js.map +7 -0
  32. package/dist/nested-window-crawler.js +172 -0
  33. package/dist/nested-window-crawler.js.map +7 -0
  34. package/dist/network-action-correlator.js +209 -0
  35. package/dist/network-action-correlator.js.map +7 -0
  36. package/dist/performance-metrics-collector.js +155 -0
  37. package/dist/performance-metrics-collector.js.map +7 -0
  38. package/dist/performance-metrics.js.map +2 -2
  39. package/dist/policies/crawl-safety.js +5 -1
  40. package/dist/policies/crawl-safety.js.map +2 -2
  41. package/dist/profiles/domain-profile.js +21 -1
  42. package/dist/profiles/domain-profile.js.map +2 -2
  43. package/dist/rate-limiter.js +2 -2
  44. package/dist/rate-limiter.js.map +2 -2
  45. package/dist/screen-reader.js +1 -0
  46. package/dist/screen-reader.js.map +2 -2
  47. package/dist/service-worker-interceptor.js +136 -0
  48. package/dist/service-worker-interceptor.js.map +7 -0
  49. package/dist/session-capture.js +4 -2
  50. package/dist/session-capture.js.map +2 -2
  51. package/dist/shadow-dom-crawler.js +132 -0
  52. package/dist/shadow-dom-crawler.js.map +7 -0
  53. package/dist/sitemap.js +16 -10
  54. package/dist/sitemap.js.map +2 -2
  55. package/dist/state-graph.js +193 -0
  56. package/dist/state-graph.js.map +7 -0
  57. package/dist/stealth/human-behavior.js +36 -3
  58. package/dist/stealth/human-behavior.js.map +2 -2
  59. package/dist/url-pattern-completer.js +48 -14
  60. package/dist/url-pattern-completer.js.map +2 -2
  61. package/dist/visual-diff-verdict.js +3 -1
  62. package/dist/visual-diff-verdict.js.map +2 -2
  63. package/package.json +2 -1
  64. package/src/accessibility-tree-crawler.ts +246 -0
  65. package/src/advanced-ui-probes.ts +470 -0
  66. package/src/crawl-session-manager.ts +231 -0
  67. package/src/crawler.ts +1071 -142
  68. package/src/crud-interaction.ts +386 -0
  69. package/src/dropdown-enumerator.ts +312 -0
  70. package/src/filter-pagination-crawler.ts +500 -0
  71. package/src/gap-pass.ts +58 -16
  72. package/src/http-engine.ts +29 -15
  73. package/src/index.ts +14 -1
  74. package/src/job-queue.ts +3 -1
  75. package/src/locator-extractor.ts +45 -0
  76. package/src/multi-context-handler.ts +296 -0
  77. package/src/nested-window-crawler.ts +310 -0
  78. package/src/network-action-correlator.ts +320 -0
  79. package/src/performance-metrics-collector.ts +243 -0
  80. package/src/performance-metrics.ts +1 -1
  81. package/src/screen-reader.ts +4 -1
  82. package/src/service-worker-interceptor.ts +185 -0
  83. package/src/session-capture.ts +3 -0
  84. package/src/shadow-dom-crawler.ts +198 -0
  85. package/src/sitemap.ts +17 -10
  86. package/src/state-graph.ts +280 -0
  87. package/src/stealth/human-behavior.ts +38 -4
  88. package/src/url-pattern-completer.ts +61 -15
  89. package/tsconfig.json +1 -1
  90. package/types/accessibility-tree-crawler.d.ts +44 -0
  91. package/types/advanced-ui-probes.d.ts +47 -0
  92. package/types/crawl-session-manager.d.ts +44 -0
  93. package/types/crawler.d.ts +3 -1
  94. package/types/crud-interaction.d.ts +31 -0
  95. package/types/dropdown-enumerator.d.ts +20 -0
  96. package/types/filter-pagination-crawler.d.ts +52 -0
  97. package/types/gap-pass.d.ts +1 -0
  98. package/types/index.d.ts +14 -1
  99. package/types/job-queue.d.ts +18 -9
  100. package/types/locator-extractor.d.ts +7 -0
  101. package/types/multi-context-handler.d.ts +55 -0
  102. package/types/nested-window-crawler.d.ts +41 -0
  103. package/types/network-action-correlator.d.ts +47 -0
  104. package/types/performance-metrics-collector.d.ts +50 -0
  105. package/types/performance-metrics.d.ts +1 -2
  106. package/types/screen-reader.d.ts +1 -2
  107. package/types/service-worker-interceptor.d.ts +36 -0
  108. package/types/session-capture.d.ts +1 -1
  109. package/types/shadow-dom-crawler.d.ts +35 -0
  110. package/types/sitemap.d.ts +2 -2
  111. package/types/state-graph.d.ts +59 -0
  112. package/types/stealth/human-behavior.d.ts +1 -1
package/src/sitemap.ts CHANGED
@@ -13,7 +13,12 @@ import { logger } from './logger.js';
13
13
  const FETCH_TIMEOUT_MS = 10_000;
14
14
  const MAX_SITEMAP_INDEX_DEPTH = 3; // sitemap index -> sub-sitemaps -> (no deeper nesting expected)
15
15
  const MAX_SUB_SITEMAPS = 50; // bound fan-out from a single sitemap index
16
- const MAX_URLS = 5000; // sane upper bound so a huge/malicious sitemap can't blow up memory
16
+
17
+ async function getSitemapMaxUrls(): Promise<number> {
18
+ const { getSystemConfig } = await import('@detiq/core');
19
+ const cfg = await getSystemConfig('crawl_tuning');
20
+ return parseInt(String(cfg?.sitemapMaxUrls ?? '5000'), 10);
21
+ }
17
22
 
18
23
  function toArray<T>(x: T | T[] | undefined | null): T[] {
19
24
  if (x == null) return [];
@@ -44,7 +49,7 @@ export function sameSite(candidateUrl: string, siteOrigin: string): boolean {
44
49
  * and deduped. Pure function — no I/O — for direct unit testing without a
45
50
  * real HTTP fetch.
46
51
  */
47
- export function extractUrlsFromParsedSitemap(parsed: any, origin: string): { pageUrls: string[]; subSitemapUrls: string[] } {
52
+ export function extractUrlsFromParsedSitemap(parsed: any, origin: string, maxUrls = 5000): { pageUrls: string[]; subSitemapUrls: string[] } {
48
53
  const rawPageUrls: string[] = [];
49
54
  let subSitemapUrls: string[] = [];
50
55
 
@@ -66,7 +71,7 @@ export function extractUrlsFromParsedSitemap(parsed: any, origin: string): { pag
66
71
  // Same-site only (www/protocol-tolerant) — a sitemap listing external URLs shouldn't seed this project's queue.
67
72
  const sameOrigin = rawPageUrls.filter((u) => sameSite(u, origin));
68
73
 
69
- return { pageUrls: [...new Set(sameOrigin)].slice(0, MAX_URLS), subSitemapUrls };
74
+ return { pageUrls: [...new Set(sameOrigin)].slice(0, maxUrls), subSitemapUrls };
70
75
  }
71
76
 
72
77
  async function fetchAndParseXml(url: string, dispatcher?: any): Promise<any | null> {
@@ -103,22 +108,23 @@ async function fetchAndParseXml(url: string, dispatcher?: any): Promise<any | nu
103
108
  * Fetches and parses a sitemap (or sitemap index, recursed up to a small
104
109
  * depth) into a flat, deduped list of same-origin URLs.
105
110
  */
106
- export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth = 0, dispatcher?: any): Promise<string[]> {
111
+ export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth = 0, dispatcher?: any, maxUrls?: number): Promise<string[]> {
107
112
  if (depth > MAX_SITEMAP_INDEX_DEPTH) return [];
113
+ if (maxUrls === undefined) maxUrls = await getSitemapMaxUrls();
108
114
  const parsed = await fetchAndParseXml(sitemapUrl, dispatcher);
109
115
  if (!parsed) return [];
110
116
 
111
- const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin);
117
+ const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin, maxUrls);
112
118
  const collected = [...pageUrls];
113
119
 
114
- if (subSitemapUrls.length > 0 && collected.length < MAX_URLS) {
120
+ if (subSitemapUrls.length > 0 && collected.length < maxUrls) {
115
121
  const subResults = await Promise.all(
116
- subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher))
122
+ subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher, maxUrls))
117
123
  );
118
124
  for (const sub of subResults) collected.push(...sub);
119
125
  }
120
126
 
121
- return [...new Set(collected)].slice(0, MAX_URLS);
127
+ return [...new Set(collected)].slice(0, maxUrls);
122
128
  }
123
129
 
124
130
  /**
@@ -192,6 +198,7 @@ export async function discoverProjectUrls(
192
198
  try { origin = new URL(appUrl).origin; } catch { return { candidates: [], sitemapFound: false }; }
193
199
 
194
200
  const dispatcher = buildProxyDispatcher(opts?.proxyUrl);
201
+ const maxUrls = await getSitemapMaxUrls();
195
202
 
196
203
  // 1. robots.txt → declared sitemaps
197
204
  let robotsSitemaps: string[] = [];
@@ -216,11 +223,11 @@ export async function discoverProjectUrls(
216
223
  const seen = new Map<string, UrlCandidate>();
217
224
  let sitemapFound = false;
218
225
  for (const sm of sitemapTargets) {
219
- const urls = await fetchSitemapUrls(sm, origin, 0, dispatcher);
226
+ const urls = await fetchSitemapUrls(sm, origin, 0, dispatcher, maxUrls);
220
227
  if (urls.length) sitemapFound = true;
221
228
  const source: UrlCandidate['source'] = robotsSet.has(sm) ? 'robots' : 'sitemap';
222
229
  for (const u of urls) if (!seen.has(u)) seen.set(u, { url: u, source });
223
230
  }
224
231
 
225
- return { candidates: [...seen.values()].slice(0, MAX_URLS), sitemapFound };
232
+ return { candidates: [...seen.values()].slice(0, maxUrls), sitemapFound };
226
233
  }
@@ -0,0 +1,280 @@
1
+ import type { Page } from 'playwright';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Internal: FNV-1a 32-bit SimHash
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function fnv1a32(token: string): number {
8
+ let h = 2166136261;
9
+ for (let i = 0; i < token.length; i++) {
10
+ h = Math.imul(h ^ token.charCodeAt(i), 16777619);
11
+ h = h >>> 0;
12
+ }
13
+ return h;
14
+ }
15
+
16
+ export function simHash32(text: string): number {
17
+ const tokens = text
18
+ .split(/\W+/)
19
+ .filter((t) => t.length > 2)
20
+ .slice(0, 500);
21
+
22
+ const vec = new Array<number>(32).fill(0);
23
+
24
+ for (const token of tokens) {
25
+ const h = fnv1a32(token);
26
+ for (let b = 0; b < 32; b++) {
27
+ vec[b] += (h >>> b) & 1 ? 1 : -1;
28
+ }
29
+ }
30
+
31
+ let result = 0;
32
+ for (let b = 0; b < 32; b++) {
33
+ if (vec[b] > 0) result |= 1 << b;
34
+ }
35
+ return result >>> 0;
36
+ }
37
+
38
+ export function hammingDistance32(a: number, b: number): number {
39
+ let n = (a ^ b) >>> 0;
40
+ n = n - ((n >>> 1) & 0x55555555);
41
+ n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
42
+ n = (n + (n >>> 4)) & 0x0f0f0f0f;
43
+ return Math.imul(n, 0x01010101) >>> 24;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // URL normalisation
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const STRIP_PARAMS = new Set([
51
+ 'utm_source', 'utm_medium', 'utm_campaign',
52
+ 'ref', '_ga', 'fbclid', 'session_id',
53
+ ]);
54
+
55
+ export function normalizeUrl(raw: string): string {
56
+ try {
57
+ const u = new URL(raw);
58
+ u.hash = '';
59
+ const keep: Array<[string, string]> = [];
60
+ u.searchParams.forEach((v, k) => {
61
+ if (!STRIP_PARAMS.has(k)) keep.push([k, v]);
62
+ });
63
+ keep.sort((a, b) => a[0].localeCompare(b[0]));
64
+ u.search = keep.length
65
+ ? '?' + keep.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
66
+ : '';
67
+ return u.toString();
68
+ } catch {
69
+ return raw;
70
+ }
71
+ }
72
+
73
+ export function computeStateId(normalizedUrl: string, hash: number): string {
74
+ let path = normalizedUrl.slice(0, 40).replace(/[^a-zA-Z0-9]/g, '_');
75
+ const hex = hash.toString(16).padStart(8, '0');
76
+ return `${path}_${hex}`;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Public types
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface UIStateNode {
84
+ id: string;
85
+ url: string;
86
+ normalizedUrl: string;
87
+ title: string;
88
+ interactiveElementCount: number;
89
+ domFingerprint: string;
90
+ simHashValue: number;
91
+ visitedAt: string;
92
+ }
93
+
94
+ export interface StateTransition {
95
+ fromId: string;
96
+ toId: string;
97
+ action: string;
98
+ label: string;
99
+ triggeredAt: string;
100
+ }
101
+
102
+ export interface CoverageReport {
103
+ totalStates: number;
104
+ totalTransitions: number;
105
+ uniqueUrls: number;
106
+ interactiveElementsTotal: number;
107
+ estimatedCoveragePercent: number;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Page fingerprinting
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export async function fingerprintPage(
115
+ page: Page,
116
+ visitedAt: string,
117
+ ): Promise<{
118
+ fingerprint: string;
119
+ visibleText: string;
120
+ interactiveCount: number;
121
+ simHashValue: number;
122
+ }> {
123
+ let visibleText = '';
124
+ let interactiveCount = 0;
125
+
126
+ try {
127
+ visibleText = await page.evaluate(() => {
128
+ const el = document.querySelector('main') ?? document.body;
129
+ return (el as HTMLElement).innerText ?? '';
130
+ });
131
+ } catch {
132
+ visibleText = '';
133
+ }
134
+
135
+ try {
136
+ interactiveCount = await page.evaluate(() =>
137
+ document.querySelectorAll(
138
+ 'a[href], button, input, select, textarea, [role="button"], [role="link"], [tabindex]',
139
+ ).length,
140
+ );
141
+ } catch {
142
+ interactiveCount = 0;
143
+ }
144
+
145
+ const simHashValue = simHash32(visibleText + visitedAt.slice(0, 10));
146
+ const fingerprint = simHashValue.toString(16).padStart(8, '0');
147
+
148
+ return { fingerprint, visibleText, interactiveCount, simHashValue };
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // ScreenTransitionGraph
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export class ScreenTransitionGraph {
156
+ private states = new Map<string, UIStateNode>();
157
+ private transitions: StateTransition[] = [];
158
+ /** Maps simHash value → array of state ids with that hash (for near-dup lookup) */
159
+ private hashIndex = new Map<number, string[]>();
160
+
161
+ // -------------------------------------------------------------------------
162
+ // State management
163
+ // -------------------------------------------------------------------------
164
+
165
+ addState(node: UIStateNode): { added: boolean; duplicateOf?: string } {
166
+ if (this.states.has(node.id)) {
167
+ return { added: false, duplicateOf: node.id };
168
+ }
169
+
170
+ // Check near-duplicate via hamming distance across all indexed hashes
171
+ for (const [indexedHash, ids] of this.hashIndex) {
172
+ if (hammingDistance32(node.simHashValue, indexedHash) < 3) {
173
+ return { added: false, duplicateOf: ids[0] };
174
+ }
175
+ }
176
+
177
+ this.states.set(node.id, node);
178
+
179
+ const bucket = this.hashIndex.get(node.simHashValue);
180
+ if (bucket) {
181
+ bucket.push(node.id);
182
+ } else {
183
+ this.hashIndex.set(node.simHashValue, [node.id]);
184
+ }
185
+
186
+ return { added: true };
187
+ }
188
+
189
+ addTransition(
190
+ fromId: string,
191
+ toId: string,
192
+ action: string,
193
+ label: string,
194
+ triggeredAt: string,
195
+ ): void {
196
+ this.transitions.push({ fromId, toId, action, label, triggeredAt });
197
+ }
198
+
199
+ isDuplicate(url: string, visibleText: string, threshold = 3): boolean {
200
+ const normalized = normalizeUrl(url);
201
+ const hash = simHash32(visibleText);
202
+ for (const [indexedHash] of this.hashIndex) {
203
+ if (hammingDistance32(hash, indexedHash) < threshold) return true;
204
+ }
205
+ // Also check exact normalized URL match
206
+ for (const node of this.states.values()) {
207
+ if (node.normalizedUrl === normalized) return true;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ // -------------------------------------------------------------------------
213
+ // Reporting
214
+ // -------------------------------------------------------------------------
215
+
216
+ getCoverageReport(): CoverageReport {
217
+ const totalStates = this.states.size;
218
+ const totalTransitions = this.transitions.length;
219
+ const uniqueUrls = new Set(
220
+ Array.from(this.states.values()).map((n) => n.normalizedUrl),
221
+ ).size;
222
+ const interactiveElementsTotal = Array.from(this.states.values()).reduce(
223
+ (sum, n) => sum + n.interactiveElementCount,
224
+ 0,
225
+ );
226
+ const estimatedCoveragePercent = Math.min(
227
+ 100,
228
+ (totalTransitions / Math.max(1, totalStates)) * 50,
229
+ );
230
+ return {
231
+ totalStates,
232
+ totalTransitions,
233
+ uniqueUrls,
234
+ interactiveElementsTotal,
235
+ estimatedCoveragePercent,
236
+ };
237
+ }
238
+
239
+ // -------------------------------------------------------------------------
240
+ // Serialisation
241
+ // -------------------------------------------------------------------------
242
+
243
+ toJSON(): { states: UIStateNode[]; transitions: StateTransition[] } {
244
+ return {
245
+ states: Array.from(this.states.values()),
246
+ transitions: this.transitions,
247
+ };
248
+ }
249
+
250
+ static fromJSON(data: { states: UIStateNode[]; transitions: StateTransition[] }): ScreenTransitionGraph {
251
+ const graph = new ScreenTransitionGraph();
252
+ for (const node of data.states) {
253
+ graph.states.set(node.id, node);
254
+ const bucket = graph.hashIndex.get(node.simHashValue);
255
+ if (bucket) {
256
+ bucket.push(node.id);
257
+ } else {
258
+ graph.hashIndex.set(node.simHashValue, [node.id]);
259
+ }
260
+ }
261
+ graph.transitions = [...data.transitions];
262
+ return graph;
263
+ }
264
+
265
+ // -------------------------------------------------------------------------
266
+ // Accessors
267
+ // -------------------------------------------------------------------------
268
+
269
+ getState(id: string): UIStateNode | undefined {
270
+ return this.states.get(id);
271
+ }
272
+
273
+ getAllStates(): UIStateNode[] {
274
+ return Array.from(this.states.values());
275
+ }
276
+
277
+ getTransitionsFrom(id: string): StateTransition[] {
278
+ return this.transitions.filter((t) => t.fromId === id);
279
+ }
280
+ }
@@ -104,29 +104,62 @@ export async function injectFingerprintOverrides(page: Page): Promise<void> {
104
104
 
105
105
  export async function infiniteScrollToBottom(
106
106
  page: Page,
107
- opts: { maxScrolls?: number; scrollDelay?: number; stabilizeMs?: number } = {}
107
+ opts: { maxScrolls?: number; scrollDelay?: number; stabilizeMs?: number } = {},
108
+ rawPage?: any,
108
109
  ): Promise<{ scrollCount: number; heightReached: number }> {
109
110
  const { maxScrolls = 50, scrollDelay = 400, stabilizeMs = 600 } = opts;
110
111
  let scrollCount = 0;
111
112
  let stableScrollCount = 0;
112
113
  let previousHeight = 0;
113
114
 
115
+ // One CDP session for wheel events (triggers IntersectionObserver-based virtual scrollers).
116
+ // React-window / react-virtualized / TanStack Virtual ignore window.scrollBy but respond to
117
+ // wheel events because they attach their own scroll listeners to the list container.
118
+ let cdpForWheel: any = null;
119
+ let viewW = 1280;
120
+ let viewH = 800;
121
+ if (rawPage && typeof rawPage.context === 'function') {
122
+ try {
123
+ const ctx = rawPage.context();
124
+ if (typeof ctx?.newCDPSession === 'function') {
125
+ cdpForWheel = await ctx.newCDPSession(rawPage);
126
+ const { result } = await cdpForWheel.send('Runtime.evaluate', {
127
+ expression: `({"w":window.innerWidth,"h":window.innerHeight})`,
128
+ returnByValue: true,
129
+ }).catch(() => ({ result: { value: null } }));
130
+ if (result?.value) { viewW = result.value.w ?? 1280; viewH = result.value.h ?? 800; }
131
+ }
132
+ } catch { cdpForWheel = null; }
133
+ }
134
+
114
135
  try {
115
136
  while (scrollCount < maxScrolls) {
116
137
  previousHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
117
138
 
118
139
  const scrollBy = 500 + Math.floor(Math.random() * 300);
140
+ // window.scrollBy handles standard scroll containers
119
141
  await page.evaluate((by: number) => window.scrollBy(0, by), scrollBy).catch(() => {});
142
+ // CDP wheel event triggers IntersectionObserver + virtual list scroll listeners
143
+ if (cdpForWheel) {
144
+ await cdpForWheel.send('Input.dispatchMouseEvent', {
145
+ type: 'mouseWheel',
146
+ x: Math.floor(viewW / 2),
147
+ y: Math.floor(viewH / 2),
148
+ deltaX: 0,
149
+ deltaY: scrollBy,
150
+ modifiers: 0,
151
+ }).catch(() => {});
152
+ }
120
153
  await humanDelay(scrollDelay - 100, scrollDelay + 200);
121
154
 
122
- // Wait for potential lazy-loaded content
123
- await page.waitForTimeout(stabilizeMs).catch(() => {});
155
+ // Use Node.js setTimeout — page.waitForTimeout may not be available on Stagehand proxy
156
+ await new Promise<void>(r => setTimeout(r, stabilizeMs));
124
157
  scrollCount++;
125
158
 
126
159
  const newHeight: number = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
127
160
  if (newHeight - previousHeight < 10) {
128
161
  stableScrollCount++;
129
- if (stableScrollCount >= 3) break; // no meaningful new content after 3 consecutive non-growing scrolls
162
+ if (stableScrollCount >= 3) break; // no new content after 3 consecutive non-growing scrolls
130
163
  } else {
131
164
  stableScrollCount = 0;
132
165
  }
@@ -134,6 +167,7 @@ export async function infiniteScrollToBottom(
134
167
  }
135
168
  } catch { /* non-fatal */ }
136
169
 
170
+ if (cdpForWheel) cdpForWheel.detach().catch(() => {});
137
171
  return { scrollCount, heightReached: previousHeight };
138
172
  }
139
173
 
@@ -27,6 +27,33 @@ interface LlmCfg {
27
27
  baseUrl?: string;
28
28
  }
29
29
 
30
+ // Only these two providers are supported by this module (they use standard SDK calls).
31
+ // NVIDIA, groq, ollama, openrouter, etc. are skipped — fall through to env fallback.
32
+ const SUPPORTED = new Set(['openai', 'anthropic']);
33
+
34
+ /**
35
+ * Resolve the effective LLM to use. Priority: openai → anthropic → skip others.
36
+ * Falls back to env vars when the platform-configured provider is unsupported.
37
+ */
38
+ function resolveTextLLM(llmCfg: LlmCfg | null | undefined): { provider: 'openai' | 'anthropic'; apiKey: string; model: string; baseUrl?: string } | null {
39
+ // Use platform config if it's a supported provider with a key
40
+ if (llmCfg?.provider === 'openai' && llmCfg.apiKey) {
41
+ return { provider: 'openai', apiKey: llmCfg.apiKey, model: llmCfg.model, baseUrl: llmCfg.baseUrl };
42
+ }
43
+ if (llmCfg?.provider === 'anthropic' && llmCfg.apiKey) {
44
+ return { provider: 'anthropic', apiKey: llmCfg.apiKey, model: llmCfg.model };
45
+ }
46
+ // Platform provider is unsupported (nvidia, groq, etc.) or no key — fall back to env vars.
47
+ // OpenAI takes priority over Anthropic.
48
+ if (process.env.OPENAI_API_KEY) {
49
+ return { provider: 'openai', apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' };
50
+ }
51
+ if (process.env.ANTHROPIC_API_KEY) {
52
+ return { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY, model: 'claude-haiku-4-5-20251001' };
53
+ }
54
+ return null;
55
+ }
56
+
30
57
  /**
31
58
  * Returns validated URL candidates (HEAD=200) predicted by LLM from
32
59
  * the pattern of already-discovered URLs.
@@ -36,7 +63,8 @@ export async function runUrlPatternCompletion(
36
63
  appUrl: string,
37
64
  llmCfg: LlmCfg | null | undefined,
38
65
  ): Promise<UrlCandidate[]> {
39
- if (!llmCfg?.apiKey || discoveredUrls.length < 2) return [];
66
+ const effective = resolveTextLLM(llmCfg);
67
+ if (!effective || discoveredUrls.length < 2) return [];
40
68
 
41
69
  const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })();
42
70
  if (!origin) return [];
@@ -49,7 +77,8 @@ export async function runUrlPatternCompletion(
49
77
 
50
78
  if (internalUrls.length < 2) return [];
51
79
 
52
- const prompt = `You are analyzing a web application's URL structure to find screens that exist but weren't discovered during automated crawling.
80
+ const systemPrompt = 'You are a URL pattern analyst. Always respond with valid JSON only.';
81
+ const userPrompt = `You are analyzing a web application's URL structure to find screens that exist but weren't discovered during automated crawling.
53
82
 
54
83
  Discovered URLs:
55
84
  ${internalUrls.map(u => { try { return new URL(u).pathname + new URL(u).search; } catch { return u; } }).join('\n')}
@@ -67,18 +96,35 @@ Respond with ONLY valid JSON, no explanation:
67
96
  {"candidates":[{"path":"/settings/billing","confidence":0.85,"reason":"settings subpage pattern from /settings/profile"},{"path":"/admin/users","confidence":0.6,"reason":"admin prefix implied by /admin/dashboard"}]}`;
68
97
 
69
98
  try {
70
- const Anthropic = (await import('@anthropic-ai/sdk')).default;
71
- const client = new Anthropic({ apiKey: llmCfg.apiKey });
72
- const response = await client.messages.create({
73
- model: llmCfg.model ?? process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-haiku-4-5-20251001',
74
- max_tokens: 1024,
75
- temperature: 0,
76
- // @ts-ignore: cache_control supported in beta
77
- system: [{ type: 'text', text: 'You are a URL pattern analyst. Always respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],
78
- messages: [{ role: 'user', content: prompt }],
79
- });
80
-
81
- const text = (response.content[0] as any)?.text ?? '';
99
+ let text = '';
100
+
101
+ if (effective.provider === 'openai') {
102
+ const { OpenAI } = await import('openai');
103
+ const client = new OpenAI({ apiKey: effective.apiKey, ...(effective.baseUrl ? { baseURL: effective.baseUrl } : {}) });
104
+ const response = await client.chat.completions.create({
105
+ model: effective.model ?? 'gpt-4o-mini',
106
+ max_tokens: 1024,
107
+ temperature: 0,
108
+ messages: [
109
+ { role: 'system', content: systemPrompt },
110
+ { role: 'user', content: userPrompt },
111
+ ],
112
+ });
113
+ text = response.choices[0]?.message?.content ?? '';
114
+ } else {
115
+ const Anthropic = (await import('@anthropic-ai/sdk')).default;
116
+ const client = new Anthropic({ apiKey: effective.apiKey });
117
+ const response = await client.messages.create({
118
+ model: effective.model ?? 'claude-haiku-4-5-20251001',
119
+ max_tokens: 1024,
120
+ temperature: 0,
121
+ // @ts-ignore: cache_control supported in beta
122
+ system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
123
+ messages: [{ role: 'user', content: userPrompt }],
124
+ });
125
+ text = (response.content[0] as any)?.text ?? '';
126
+ }
127
+
82
128
  const jsonMatch = text.match(/\{[\s\S]*\}/);
83
129
  if (!jsonMatch) return [];
84
130
  const parsed = JSON.parse(jsonMatch[0]);
@@ -106,7 +152,7 @@ Respond with ONLY valid JSON, no explanation:
106
152
  }
107
153
 
108
154
  const hits = results.filter(r => r.status === 'hit');
109
- logger.info({ total: results.length, hits: hits.length }, '[url-completer] pattern completion done');
155
+ logger.info({ total: results.length, hits: hits.length, provider: effective.provider }, '[url-completer] pattern completion done');
110
156
  return results;
111
157
  } catch (err) {
112
158
  logger.warn({ err: String(err) }, '[url-completer] LLM call failed — skipping pattern completion');
package/tsconfig.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "compilerOptions": {
4
4
  "rootDir": "src",
5
5
  "outDir": "dist",
6
- "lib": ["ES2022", "DOM"],
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
7
  "isolatedModules": true,
8
8
  "composite": false,
9
9
  "noEmitOnError": false
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Accessibility tree crawler — extract landmark, heading, and ARIA metadata from crawled pages.
3
+ *
4
+ * Captures: landmarks with interactive counts, heading hierarchy with violation detection,
5
+ * interactive elements missing accessible names, live regions, and skip-link presence.
6
+ *
7
+ * This data feeds ZeTa's accessibility test generator to surface WCAG 2.1 Level A/AA
8
+ * failures (missing names, heading skips, absent skip links, unlabeled live regions).
9
+ */
10
+ export interface LandmarkInfo {
11
+ role: string;
12
+ label: string;
13
+ elementId: string;
14
+ interactiveCount: number;
15
+ }
16
+ export interface HeadingInfo {
17
+ level: number;
18
+ text: string;
19
+ elementId: string;
20
+ }
21
+ export interface MissingNameEl {
22
+ tagName: string;
23
+ role: string;
24
+ elementId: string;
25
+ htmlSnippet: string;
26
+ }
27
+ export interface LiveRegionInfo {
28
+ role: string;
29
+ ariaLive: string;
30
+ ariaAtomic: string;
31
+ currentText: string;
32
+ }
33
+ export interface AccessibilityTreeResult {
34
+ landmarkCount: number;
35
+ landmarks: LandmarkInfo[];
36
+ headings: HeadingInfo[];
37
+ headingHierarchyViolations: string[];
38
+ missingNameElements: MissingNameEl[];
39
+ missingNameCount: number;
40
+ liveRegions: LiveRegionInfo[];
41
+ totalInteractiveCount: number;
42
+ hasSkipLink: boolean;
43
+ }
44
+ export declare function crawlAccessibilityTree(page: any): Promise<AccessibilityTreeResult>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Advanced UI probes — detect and exercise drag-drop, date pickers, sliders,
3
+ * and typeahead/autocomplete widgets on a crawled page.
4
+ *
5
+ * Results feed ZeTa's test generator so it can emit interaction-level test
6
+ * cases for these widget families instead of falling back to generic clicks.
7
+ *
8
+ * Each probe is independently try/caught so a broken widget does not abort
9
+ * the others. All browser-side code runs inside page.evaluate / page.$$eval
10
+ * to avoid serialisation issues with DOM handles.
11
+ */
12
+ import type { Page } from 'playwright';
13
+ export interface DragDropCapture {
14
+ library: 'html5' | 'dnd-kit' | 'react-beautiful-dnd' | 'sortablejs' | 'pointer-event' | 'none';
15
+ draggableCount: number;
16
+ dropZoneCount: number;
17
+ testAttempted: boolean;
18
+ testCompleted: boolean;
19
+ }
20
+ export interface DatePickerCapture {
21
+ fieldLabel: string;
22
+ pickerType: 'calendar-popup' | 'native-date' | 'native-datetime' | 'none';
23
+ minDate?: string;
24
+ maxDate?: string;
25
+ disabledDatesFound: boolean;
26
+ sampleSelectedValue?: string;
27
+ }
28
+ export interface SliderCapture {
29
+ label: string;
30
+ min: number;
31
+ max: number;
32
+ step: number;
33
+ currentValue: number;
34
+ }
35
+ export interface TypeaheadCapture {
36
+ label: string;
37
+ minCharsToTrigger: number;
38
+ sampleSuggestions: string[];
39
+ noResultsText: string | null;
40
+ }
41
+ export interface AdvancedProbeResult {
42
+ dragDrop: DragDropCapture;
43
+ datePickers: DatePickerCapture[];
44
+ sliders: SliderCapture[];
45
+ typeaheads: TypeaheadCapture[];
46
+ }
47
+ export declare function probeAdvancedUI(page: Page): Promise<AdvancedProbeResult>;