@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
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Filter state crawling and pagination capture.
3
+ *
4
+ * Discovers filter controls (select, checkbox-group, radio-group, search),
5
+ * applies each meaningful option and records result counts and first-row
6
+ * values, then detects and walks pagination (numbered, load-more, infinite
7
+ * scroll). Also captures sort column behaviour.
8
+ *
9
+ * All per-filter / per-page errors are caught and skipped so one bad control
10
+ * never aborts the full crawl.
11
+ */
12
+
13
+ import type { Page } from 'playwright';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Public interfaces
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface FilterOption {
20
+ value: string;
21
+ label: string;
22
+ }
23
+
24
+ export interface FilterDef {
25
+ kind: 'select' | 'checkbox-group' | 'radio-group' | 'search';
26
+ label: string;
27
+ locatorHint: string;
28
+ options: FilterOption[];
29
+ }
30
+
31
+ export interface FilterState {
32
+ filterLabel: string;
33
+ filterValue: string;
34
+ resultCount: number | null;
35
+ firstRowValues: string[];
36
+ }
37
+
38
+ export interface PaginationCapture {
39
+ kind: 'numbered' | 'load-more' | 'infinite-scroll' | 'none';
40
+ totalPages?: number;
41
+ totalItems?: number;
42
+ pageSizes?: number[];
43
+ pages: Array<{ pageNum: number; rowValues: string[] }>;
44
+ }
45
+
46
+ export interface FilterPaginationResult {
47
+ filters: FilterDef[];
48
+ filterStates: FilterState[];
49
+ pagination: PaginationCapture;
50
+ sortColumns: Array<{ header: string; ariaSortAfterClick: string | null }>;
51
+ searchCapture?: { noResultsText: string | null; sampleSuggestions: string[] };
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Internal helpers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** Pause for up to `ms` ms or until network is idle, whichever comes first. */
59
+ async function waitForSettle(page: Page, ms = 1500): Promise<void> {
60
+ await Promise.race([
61
+ page.waitForLoadState('networkidle').catch(() => undefined),
62
+ new Promise<void>(r => setTimeout(r, ms)),
63
+ ]);
64
+ }
65
+
66
+ /** Extract visible cell text from the first tbody row, if any. */
67
+ async function captureFirstRow(page: Page): Promise<string[]> {
68
+ try {
69
+ return await page.evaluate(() => {
70
+ const row = document.querySelector('tbody tr');
71
+ if (!row) return [];
72
+ return Array.from(row.querySelectorAll('td')).map(td => td.innerText.trim());
73
+ });
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /** Read a result-count hint from visible page text using common patterns. */
80
+ async function readResultCount(page: Page): Promise<number | null> {
81
+ try {
82
+ const text = await page.evaluate(() => document.body.innerText);
83
+ const match = text.match(/(\d[\d,]*)\s*(result|item|entr|record)/i);
84
+ if (!match) return null;
85
+ return parseInt(match[1].replace(/,/g, ''), 10);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // discoverFilters
93
+ // ---------------------------------------------------------------------------
94
+
95
+ async function discoverFilters(page: Page): Promise<FilterDef[]> {
96
+ const filters: FilterDef[] = [];
97
+
98
+ // --- native <select> elements outside dialogs ---------------------------------
99
+ try {
100
+ const selectHandles = await page.locator('select:not([role="dialog"] select)').all();
101
+ for (const handle of selectHandles.slice(0, 10)) {
102
+ try {
103
+ const id = await handle.getAttribute('id').catch(() => null);
104
+ const name = await handle.getAttribute('name').catch(() => null);
105
+ const locatorHint = id ? `#${id}` : name ? `select[name="${name}"]` : 'select';
106
+
107
+ // Find associated label
108
+ let label = '';
109
+ if (id) {
110
+ label = await page.locator(`label[for="${id}"]`).innerText().catch(() => '');
111
+ }
112
+ if (!label) {
113
+ label = await handle.evaluate((el: HTMLSelectElement) => {
114
+ const lbl = el.closest('label') ?? el.previousElementSibling;
115
+ return (lbl as HTMLElement | null)?.innerText?.trim() ?? '';
116
+ }).catch(() => '');
117
+ }
118
+ if (!label) label = name ?? id ?? 'filter';
119
+
120
+ // Collect options, skipping default/placeholder entries
121
+ const rawOptions: FilterOption[] = await handle.evaluate((el: HTMLSelectElement) =>
122
+ Array.from(el.options).map(o => ({ value: o.value, label: o.text.trim() })),
123
+ ).catch(() => []);
124
+
125
+ const options = rawOptions.filter(
126
+ o => !(/^all$|^any$|^select|^--/i.test(o.label) || o.value === ''),
127
+ );
128
+
129
+ if (options.length === 0) continue;
130
+
131
+ filters.push({ kind: 'select', label, locatorHint, options });
132
+ if (filters.length >= 10) break;
133
+ } catch {
134
+ // skip this select
135
+ }
136
+ }
137
+ } catch {
138
+ // page.locator failed
139
+ }
140
+
141
+ // --- search inputs ------------------------------------------------------------
142
+ if (filters.length < 10) {
143
+ try {
144
+ const searchHandles = await page
145
+ .locator(
146
+ 'input[type="search"]:not([role="dialog"] input), ' +
147
+ 'input[placeholder*="search" i]:not([role="dialog"] input)',
148
+ )
149
+ .all();
150
+
151
+ for (const handle of searchHandles.slice(0, 10 - filters.length)) {
152
+ try {
153
+ const id = await handle.getAttribute('id').catch(() => null);
154
+ const placeholder = await handle.getAttribute('placeholder').catch(() => null);
155
+ const locatorHint = id ? `#${id}` : placeholder ? `input[placeholder="${placeholder}"]` : 'input[type="search"]';
156
+
157
+ let label = '';
158
+ if (id) {
159
+ label = await page.locator(`label[for="${id}"]`).innerText().catch(() => '');
160
+ }
161
+ if (!label) label = placeholder ?? 'search';
162
+
163
+ filters.push({ kind: 'search', label, locatorHint, options: [] });
164
+ if (filters.length >= 10) break;
165
+ } catch {
166
+ // skip
167
+ }
168
+ }
169
+ } catch {
170
+ // locator failed
171
+ }
172
+ }
173
+
174
+ return filters;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // applyFilterAndCapture
179
+ // ---------------------------------------------------------------------------
180
+
181
+ async function applyFilterAndCapture(
182
+ page: Page,
183
+ filter: FilterDef,
184
+ option: FilterOption,
185
+ ): Promise<FilterState> {
186
+ const state: FilterState = {
187
+ filterLabel: filter.label,
188
+ filterValue: option.value,
189
+ resultCount: null,
190
+ firstRowValues: [],
191
+ };
192
+
193
+ try {
194
+ if (filter.kind === 'select') {
195
+ await page.locator(filter.locatorHint).selectOption(option.value);
196
+ } else if (filter.kind === 'search') {
197
+ await page.locator(filter.locatorHint).fill(option.value);
198
+ await page.locator(filter.locatorHint).press('Enter');
199
+ }
200
+
201
+ await waitForSettle(page, 1500);
202
+
203
+ state.resultCount = await readResultCount(page);
204
+ state.firstRowValues = await captureFirstRow(page);
205
+ } catch {
206
+ // non-fatal: return partial state
207
+ }
208
+
209
+ // Reset filter
210
+ try {
211
+ if (filter.kind === 'select') {
212
+ const firstOption = await page.locator(filter.locatorHint).evaluate(
213
+ (el: HTMLSelectElement) => el.options[0]?.value ?? '',
214
+ );
215
+ await page.locator(filter.locatorHint).selectOption(firstOption);
216
+ } else if (filter.kind === 'search') {
217
+ await page.locator(filter.locatorHint).fill('');
218
+ await page.locator(filter.locatorHint).press('Enter');
219
+ }
220
+ await waitForSettle(page, 800);
221
+ } catch {
222
+ // ignore reset errors
223
+ }
224
+
225
+ return state;
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // crawlFilterStates
230
+ // ---------------------------------------------------------------------------
231
+
232
+ async function crawlFilterStates(page: Page, filters: FilterDef[]): Promise<FilterState[]> {
233
+ const states: FilterState[] = [];
234
+ let totalStates = 0;
235
+
236
+ for (const filter of filters) {
237
+ if (totalStates >= 15) break;
238
+
239
+ const { options } = filter;
240
+ if (options.length === 0) continue;
241
+
242
+ // Pick first, middle, last — deduplicated, max 3
243
+ const indices = new Set<number>([
244
+ 0,
245
+ Math.floor((options.length - 1) / 2),
246
+ options.length - 1,
247
+ ]);
248
+ const picked = Array.from(indices).slice(0, 3);
249
+
250
+ for (const idx of picked) {
251
+ if (totalStates >= 15) break;
252
+ const option = options[idx];
253
+ try {
254
+ const s = await applyFilterAndCapture(page, filter, option);
255
+ states.push(s);
256
+ } catch {
257
+ // skip
258
+ }
259
+ totalStates++;
260
+ }
261
+ }
262
+
263
+ return states;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // detectPaginationKind
268
+ // ---------------------------------------------------------------------------
269
+
270
+ async function detectPaginationKind(
271
+ page: Page,
272
+ ): Promise<'numbered' | 'load-more' | 'infinite-scroll' | 'none'> {
273
+ try {
274
+ // Numbered pagination: ARIA page buttons or .pagination
275
+ const numberedCount = await page
276
+ .locator('[aria-label*="page" i], .pagination button, [role="navigation"] button')
277
+ .count()
278
+ .catch(() => 0);
279
+ if (numberedCount > 1) return 'numbered';
280
+
281
+ // Load-more button
282
+ const loadMore = await page
283
+ .locator('button:has-text("Load more"), button:has-text("Show more"), a:has-text("Load more")')
284
+ .count()
285
+ .catch(() => 0);
286
+ if (loadMore > 0) return 'load-more';
287
+
288
+ // Infinite scroll: scrollable content significantly taller than viewport
289
+ const isInfinite = await page.evaluate(() => {
290
+ return document.documentElement.scrollHeight > window.innerHeight * 2;
291
+ }).catch(() => false);
292
+ if (isInfinite) return 'infinite-scroll';
293
+ } catch {
294
+ // fall through
295
+ }
296
+
297
+ return 'none';
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // crawlNumberedPagination
302
+ // ---------------------------------------------------------------------------
303
+
304
+ async function crawlNumberedPagination(page: Page): Promise<PaginationCapture> {
305
+ const pages: Array<{ pageNum: number; rowValues: string[] }> = [];
306
+ const pageSizes: number[] = [];
307
+
308
+ // Read page size selector if present
309
+ try {
310
+ const sizeSelect = page.locator('select[name*="size" i], select[name*="per" i], select[aria-label*="per page" i]');
311
+ const sizeCount = await sizeSelect.count();
312
+ if (sizeCount > 0) {
313
+ const rawSizes = await sizeSelect.first().evaluate((el: HTMLSelectElement) =>
314
+ Array.from(el.options).map(o => parseInt(o.value, 10)).filter(n => !isNaN(n)),
315
+ );
316
+ pageSizes.push(...rawSizes);
317
+ }
318
+ } catch {
319
+ // ignore
320
+ }
321
+
322
+ // Determine total pages from pagination widget
323
+ let totalPages: number | undefined;
324
+ try {
325
+ totalPages = await page.evaluate(() => {
326
+ const buttons = Array.from(
327
+ document.querySelectorAll('[aria-label*="page" i], .pagination button'),
328
+ );
329
+ const nums = buttons
330
+ .map(b => parseInt((b as HTMLElement).innerText.trim(), 10))
331
+ .filter(n => !isNaN(n));
332
+ return nums.length > 0 ? Math.max(...nums) : undefined;
333
+ });
334
+ } catch {
335
+ // leave undefined
336
+ }
337
+
338
+ // Capture first page immediately
339
+ pages.push({ pageNum: 1, rowValues: await captureFirstRow(page) });
340
+
341
+ // Walk pages by clicking Next
342
+ for (let pageNum = 2; pageNum <= 20; pageNum++) {
343
+ try {
344
+ const nextBtn = page.locator(
345
+ 'button[aria-label*="next" i], a[aria-label*="next" i], [role="navigation"] button:last-child',
346
+ ).first();
347
+
348
+ const isDisabled = await nextBtn.isDisabled().catch(() => true);
349
+ if (isDisabled) break;
350
+
351
+ await nextBtn.click();
352
+ await waitForSettle(page, 2000);
353
+
354
+ const rowValues = await captureFirstRow(page);
355
+ pages.push({ pageNum, rowValues });
356
+ } catch {
357
+ break;
358
+ }
359
+ }
360
+
361
+ return {
362
+ kind: 'numbered',
363
+ totalPages,
364
+ pageSizes: pageSizes.length > 0 ? pageSizes : undefined,
365
+ pages,
366
+ };
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // captureInfiniteScroll
371
+ // ---------------------------------------------------------------------------
372
+
373
+ async function captureInfiniteScroll(page: Page): Promise<PaginationCapture> {
374
+ const pages: Array<{ pageNum: number; rowValues: string[] }> = [];
375
+
376
+ let prevRowCount = -1;
377
+ let iteration = 0;
378
+
379
+ while (iteration < 10) {
380
+ try {
381
+ const rowCount = await page.evaluate(() =>
382
+ document.querySelectorAll('tbody tr').length,
383
+ ).catch(() => 0);
384
+
385
+ if (rowCount === prevRowCount) break; // stable
386
+ prevRowCount = rowCount;
387
+
388
+ // Record current first-row snapshot per scroll step
389
+ pages.push({ pageNum: iteration + 1, rowValues: await captureFirstRow(page) });
390
+
391
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
392
+ await new Promise<void>(r => setTimeout(r, 800));
393
+ } catch {
394
+ break;
395
+ }
396
+ iteration++;
397
+ }
398
+
399
+ const totalItems = await page.evaluate(() =>
400
+ document.querySelectorAll('tbody tr').length,
401
+ ).catch(() => undefined);
402
+
403
+ return {
404
+ kind: 'infinite-scroll',
405
+ totalItems: typeof totalItems === 'number' ? totalItems : undefined,
406
+ pages,
407
+ };
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // captureSortColumns
412
+ // ---------------------------------------------------------------------------
413
+
414
+ async function captureSortColumns(
415
+ page: Page,
416
+ ): Promise<Array<{ header: string; ariaSortAfterClick: string | null }>> {
417
+ const results: Array<{ header: string; ariaSortAfterClick: string | null }> = [];
418
+
419
+ try {
420
+ const sortable = await page.locator('[aria-sort]').all();
421
+ for (const th of sortable.slice(0, 5)) {
422
+ try {
423
+ const header = await th.innerText().catch(() => '');
424
+ await th.click();
425
+ await waitForSettle(page, 800);
426
+ const ariaSortAfterClick = await th.getAttribute('aria-sort').catch(() => null);
427
+ results.push({ header: header.trim(), ariaSortAfterClick });
428
+
429
+ // Reset by clicking again (most tables toggle back)
430
+ await th.click().catch(() => undefined);
431
+ await waitForSettle(page, 600);
432
+ } catch {
433
+ // skip this column
434
+ }
435
+ }
436
+ } catch {
437
+ // no sortable columns found
438
+ }
439
+
440
+ return results;
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // crawlFiltersAndPagination — public orchestrator
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export async function crawlFiltersAndPagination(page: Page): Promise<FilterPaginationResult> {
448
+ // 1. Discover filters
449
+ let filters: FilterDef[] = [];
450
+ try {
451
+ filters = await discoverFilters(page);
452
+ } catch {
453
+ // proceed with empty filters
454
+ }
455
+
456
+ // 2. Apply filters and capture states
457
+ let filterStates: FilterState[] = [];
458
+ try {
459
+ filterStates = await crawlFilterStates(page, filters);
460
+ } catch {
461
+ // proceed with empty states
462
+ }
463
+
464
+ // 3. Detect and crawl pagination
465
+ let pagination: PaginationCapture = { kind: 'none', pages: [] };
466
+ try {
467
+ const kind = await detectPaginationKind(page);
468
+
469
+ if (kind === 'numbered') {
470
+ pagination = await crawlNumberedPagination(page);
471
+ } else if (kind === 'load-more') {
472
+ // Treat load-more similarly to infinite scroll capture
473
+ pagination = await captureInfiniteScroll(page);
474
+ pagination = { ...pagination, kind: 'load-more' };
475
+ } else if (kind === 'infinite-scroll') {
476
+ pagination = await captureInfiniteScroll(page);
477
+ } else {
478
+ // Capture single-page rows
479
+ const rowValues = await captureFirstRow(page);
480
+ pagination = { kind: 'none', pages: rowValues.length > 0 ? [{ pageNum: 1, rowValues }] : [] };
481
+ }
482
+ } catch {
483
+ // leave pagination as none
484
+ }
485
+
486
+ // 4. Sort columns
487
+ let sortColumns: Array<{ header: string; ariaSortAfterClick: string | null }> = [];
488
+ try {
489
+ sortColumns = await captureSortColumns(page);
490
+ } catch {
491
+ // proceed with empty sort
492
+ }
493
+
494
+ return {
495
+ filters,
496
+ filterStates,
497
+ pagination,
498
+ sortColumns,
499
+ };
500
+ }
package/src/gap-pass.ts CHANGED
@@ -33,6 +33,29 @@ interface LlmCfg {
33
33
  provider: string;
34
34
  model: string;
35
35
  apiKey?: string;
36
+ baseUrl?: string;
37
+ }
38
+
39
+ // Only openai and anthropic are supported here. NVIDIA, groq, ollama, etc. → skip, fall to env.
40
+ /**
41
+ * Resolve effective LLM. Priority: openai → anthropic → skip unsupported providers.
42
+ * Falls back to env vars when platform provider is not supported.
43
+ */
44
+ function resolveTextLLM(llmCfg: LlmCfg | null | undefined): { provider: 'openai' | 'anthropic'; apiKey: string; model: string; baseUrl?: string } | null {
45
+ if (llmCfg?.provider === 'openai' && llmCfg.apiKey) {
46
+ return { provider: 'openai', apiKey: llmCfg.apiKey, model: llmCfg.model, baseUrl: llmCfg.baseUrl };
47
+ }
48
+ if (llmCfg?.provider === 'anthropic' && llmCfg.apiKey) {
49
+ return { provider: 'anthropic', apiKey: llmCfg.apiKey, model: llmCfg.model };
50
+ }
51
+ // Unsupported provider (nvidia, groq, etc.) or no key — env fallback, openai first.
52
+ if (process.env.OPENAI_API_KEY) {
53
+ return { provider: 'openai', apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' };
54
+ }
55
+ if (process.env.ANTHROPIC_API_KEY) {
56
+ return { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY, model: 'claude-haiku-4-5-20251001' };
57
+ }
58
+ return null;
36
59
  }
37
60
 
38
61
  /**
@@ -44,7 +67,8 @@ export async function runGapAnalysis(
44
67
  appUrl: string,
45
68
  llmCfg: LlmCfg | null | undefined,
46
69
  ): Promise<GapCandidate[]> {
47
- if (!llmCfg?.apiKey || screens.length < 2) return [];
70
+ const effective = resolveTextLLM(llmCfg);
71
+ if (!effective || screens.length < 2) return [];
48
72
 
49
73
  const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })();
50
74
  if (!origin) return [];
@@ -57,7 +81,8 @@ export async function runGapAnalysis(
57
81
  })
58
82
  .join('\n');
59
83
 
60
- const prompt = `You are analyzing the screen map of a web application to find structural coverage gaps.
84
+ const systemPrompt = 'You are a web application coverage analyst. Respond with valid JSON only.';
85
+ const userPrompt = `You are analyzing the screen map of a web application to find structural coverage gaps.
61
86
 
62
87
  Discovered screens (${screens.length} total):
63
88
  ${screenList}
@@ -70,18 +95,35 @@ Respond with ONLY valid JSON, no markdown:
70
95
  {"gaps":[{"category":"User profile edit","urlPatterns":["/profile/edit","/account/profile"],"confidence":0.8,"rationale":"Profile view found but no edit page"},{"category":"Billing history","urlPatterns":["/settings/billing","/account/billing"],"confidence":0.7,"rationale":"Settings section found but no billing page"}]}`;
71
96
 
72
97
  try {
73
- const Anthropic = (await import('@anthropic-ai/sdk')).default;
74
- const client = new Anthropic({ apiKey: llmCfg.apiKey });
75
- const response = await client.messages.create({
76
- model: llmCfg.model ?? process.env.CRAWLER_DEFAULT_MODEL ?? 'claude-haiku-4-5-20251001',
77
- max_tokens: 1024,
78
- temperature: 0,
79
- // @ts-ignore
80
- system: [{ type: 'text', text: 'You are a web application coverage analyst. Respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],
81
- messages: [{ role: 'user', content: prompt }],
82
- });
83
-
84
- const text = (response.content[0] as any)?.text ?? '';
98
+ let text = '';
99
+
100
+ if (effective.provider === 'openai') {
101
+ const { OpenAI } = await import('openai');
102
+ const client = new OpenAI({ apiKey: effective.apiKey, ...(effective.baseUrl ? { baseURL: effective.baseUrl } : {}) });
103
+ const response = await client.chat.completions.create({
104
+ model: effective.model ?? 'gpt-4o-mini',
105
+ max_tokens: 1024,
106
+ temperature: 0,
107
+ messages: [
108
+ { role: 'system', content: systemPrompt },
109
+ { role: 'user', content: userPrompt },
110
+ ],
111
+ });
112
+ text = response.choices[0]?.message?.content ?? '';
113
+ } else {
114
+ const Anthropic = (await import('@anthropic-ai/sdk')).default;
115
+ const client = new Anthropic({ apiKey: effective.apiKey });
116
+ const response = await client.messages.create({
117
+ model: effective.model ?? 'claude-haiku-4-5-20251001',
118
+ max_tokens: 1024,
119
+ temperature: 0,
120
+ // @ts-ignore
121
+ system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
122
+ messages: [{ role: 'user', content: userPrompt }],
123
+ });
124
+ text = (response.content[0] as any)?.text ?? '';
125
+ }
126
+
85
127
  const jsonMatch = text.match(/\{[\s\S]*\}/);
86
128
  if (!jsonMatch) return [];
87
129
  const parsed = JSON.parse(jsonMatch[0]);
@@ -89,9 +131,9 @@ Respond with ONLY valid JSON, no markdown:
89
131
  parsed.gaps ?? [];
90
132
 
91
133
  if (!Array.isArray(rawGaps) || rawGaps.length === 0) return [];
92
- logger.info({ gaps: rawGaps.length }, '[P6] Gap analysis: LLM returned candidates');
134
+ logger.info({ gaps: rawGaps.length, provider: effective.provider }, '[P6] Gap analysis: LLM returned candidates');
93
135
 
94
- // HEAD-validate each URL pattern, take the first 200
136
+ // HEAD-validate each URL pattern
95
137
  const results: GapCandidate[] = [];
96
138
  for (const gap of rawGaps.slice(0, 15)) {
97
139
  let confirmedUrl: string | null = null;