@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.
- package/Dockerfile +1 -1
- package/dist/accessibility-tree-crawler.js +156 -0
- package/dist/accessibility-tree-crawler.js.map +7 -0
- package/dist/advanced-ui-probes.js +310 -0
- package/dist/advanced-ui-probes.js.map +7 -0
- package/dist/content-chunker.js +2 -2
- package/dist/content-chunker.js.map +2 -2
- package/dist/crawl-session-manager.js +145 -0
- package/dist/crawl-session-manager.js.map +7 -0
- package/dist/crawler.js +1142 -263
- package/dist/crawler.js.map +2 -2
- package/dist/crud-interaction.js +264 -0
- package/dist/crud-interaction.js.map +7 -0
- package/dist/dropdown-enumerator.js +230 -0
- package/dist/dropdown-enumerator.js.map +7 -0
- package/dist/filter-pagination-crawler.js +299 -0
- package/dist/filter-pagination-crawler.js.map +7 -0
- package/dist/frontier.js +5 -3
- package/dist/frontier.js.map +2 -2
- package/dist/gap-pass.js +47 -14
- package/dist/gap-pass.js.map +2 -2
- package/dist/http-engine.js +28 -15
- package/dist/http-engine.js.map +2 -2
- package/dist/index.js +15 -1
- package/dist/index.js.map +2 -2
- package/dist/job-queue.js +12 -5
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +23 -0
- package/dist/locator-extractor.js.map +2 -2
- package/dist/multi-context-handler.js +182 -0
- package/dist/multi-context-handler.js.map +7 -0
- package/dist/nested-window-crawler.js +172 -0
- package/dist/nested-window-crawler.js.map +7 -0
- package/dist/network-action-correlator.js +209 -0
- package/dist/network-action-correlator.js.map +7 -0
- package/dist/performance-metrics-collector.js +155 -0
- package/dist/performance-metrics-collector.js.map +7 -0
- package/dist/performance-metrics.js.map +2 -2
- package/dist/policies/crawl-safety.js +5 -1
- package/dist/policies/crawl-safety.js.map +2 -2
- package/dist/profiles/domain-profile.js +21 -1
- package/dist/profiles/domain-profile.js.map +2 -2
- package/dist/rate-limiter.js +2 -2
- package/dist/rate-limiter.js.map +2 -2
- package/dist/screen-reader.js +1 -0
- package/dist/screen-reader.js.map +2 -2
- package/dist/service-worker-interceptor.js +136 -0
- package/dist/service-worker-interceptor.js.map +7 -0
- package/dist/session-capture.js +4 -2
- package/dist/session-capture.js.map +2 -2
- package/dist/shadow-dom-crawler.js +132 -0
- package/dist/shadow-dom-crawler.js.map +7 -0
- package/dist/sitemap.js +16 -10
- package/dist/sitemap.js.map +2 -2
- package/dist/state-graph.js +193 -0
- package/dist/state-graph.js.map +7 -0
- package/dist/stealth/human-behavior.js +36 -3
- package/dist/stealth/human-behavior.js.map +2 -2
- package/dist/url-pattern-completer.js +48 -14
- package/dist/url-pattern-completer.js.map +2 -2
- package/dist/visual-diff-verdict.js +3 -1
- package/dist/visual-diff-verdict.js.map +2 -2
- package/package.json +2 -1
- package/src/accessibility-tree-crawler.ts +246 -0
- package/src/advanced-ui-probes.ts +470 -0
- package/src/crawl-session-manager.ts +231 -0
- package/src/crawler.ts +1071 -142
- package/src/crud-interaction.ts +386 -0
- package/src/dropdown-enumerator.ts +312 -0
- package/src/filter-pagination-crawler.ts +500 -0
- package/src/gap-pass.ts +58 -16
- package/src/http-engine.ts +29 -15
- package/src/index.ts +14 -1
- package/src/job-queue.ts +3 -1
- package/src/locator-extractor.ts +45 -0
- package/src/multi-context-handler.ts +296 -0
- package/src/nested-window-crawler.ts +310 -0
- package/src/network-action-correlator.ts +320 -0
- package/src/performance-metrics-collector.ts +243 -0
- package/src/performance-metrics.ts +1 -1
- package/src/screen-reader.ts +4 -1
- package/src/service-worker-interceptor.ts +185 -0
- package/src/session-capture.ts +3 -0
- package/src/shadow-dom-crawler.ts +198 -0
- package/src/sitemap.ts +17 -10
- package/src/state-graph.ts +280 -0
- package/src/stealth/human-behavior.ts +38 -4
- package/src/url-pattern-completer.ts +61 -15
- package/tsconfig.json +1 -1
- package/types/accessibility-tree-crawler.d.ts +44 -0
- package/types/advanced-ui-probes.d.ts +47 -0
- package/types/crawl-session-manager.d.ts +44 -0
- package/types/crawler.d.ts +3 -1
- package/types/crud-interaction.d.ts +31 -0
- package/types/dropdown-enumerator.d.ts +20 -0
- package/types/filter-pagination-crawler.d.ts +52 -0
- package/types/gap-pass.d.ts +1 -0
- package/types/index.d.ts +14 -1
- package/types/job-queue.d.ts +18 -9
- package/types/locator-extractor.d.ts +7 -0
- package/types/multi-context-handler.d.ts +55 -0
- package/types/nested-window-crawler.d.ts +41 -0
- package/types/network-action-correlator.d.ts +47 -0
- package/types/performance-metrics-collector.d.ts +50 -0
- package/types/performance-metrics.d.ts +1 -2
- package/types/screen-reader.d.ts +1 -2
- package/types/service-worker-interceptor.d.ts +36 -0
- package/types/session-capture.d.ts +1 -1
- package/types/shadow-dom-crawler.d.ts +35 -0
- package/types/sitemap.d.ts +2 -2
- package/types/state-graph.d.ts +59 -0
- package/types/stealth/human-behavior.d.ts +1 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
async function waitForSettle(page, ms = 1500) {
|
|
2
|
+
await Promise.race([
|
|
3
|
+
page.waitForLoadState("networkidle").catch(() => void 0),
|
|
4
|
+
new Promise((r) => setTimeout(r, ms))
|
|
5
|
+
]);
|
|
6
|
+
}
|
|
7
|
+
async function captureFirstRow(page) {
|
|
8
|
+
try {
|
|
9
|
+
return await page.evaluate(() => {
|
|
10
|
+
const row = document.querySelector("tbody tr");
|
|
11
|
+
if (!row) return [];
|
|
12
|
+
return Array.from(row.querySelectorAll("td")).map((td) => td.innerText.trim());
|
|
13
|
+
});
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function readResultCount(page) {
|
|
19
|
+
try {
|
|
20
|
+
const text = await page.evaluate(() => document.body.innerText);
|
|
21
|
+
const match = text.match(/(\d[\d,]*)\s*(result|item|entr|record)/i);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
return parseInt(match[1].replace(/,/g, ""), 10);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function discoverFilters(page) {
|
|
29
|
+
const filters = [];
|
|
30
|
+
try {
|
|
31
|
+
const selectHandles = await page.locator('select:not([role="dialog"] select)').all();
|
|
32
|
+
for (const handle of selectHandles.slice(0, 10)) {
|
|
33
|
+
try {
|
|
34
|
+
const id = await handle.getAttribute("id").catch(() => null);
|
|
35
|
+
const name = await handle.getAttribute("name").catch(() => null);
|
|
36
|
+
const locatorHint = id ? `#${id}` : name ? `select[name="${name}"]` : "select";
|
|
37
|
+
let label = "";
|
|
38
|
+
if (id) {
|
|
39
|
+
label = await page.locator(`label[for="${id}"]`).innerText().catch(() => "");
|
|
40
|
+
}
|
|
41
|
+
if (!label) {
|
|
42
|
+
label = await handle.evaluate((el) => {
|
|
43
|
+
const lbl = el.closest("label") ?? el.previousElementSibling;
|
|
44
|
+
return lbl?.innerText?.trim() ?? "";
|
|
45
|
+
}).catch(() => "");
|
|
46
|
+
}
|
|
47
|
+
if (!label) label = name ?? id ?? "filter";
|
|
48
|
+
const rawOptions = await handle.evaluate(
|
|
49
|
+
(el) => Array.from(el.options).map((o) => ({ value: o.value, label: o.text.trim() }))
|
|
50
|
+
).catch(() => []);
|
|
51
|
+
const options = rawOptions.filter(
|
|
52
|
+
(o) => !(/^all$|^any$|^select|^--/i.test(o.label) || o.value === "")
|
|
53
|
+
);
|
|
54
|
+
if (options.length === 0) continue;
|
|
55
|
+
filters.push({ kind: "select", label, locatorHint, options });
|
|
56
|
+
if (filters.length >= 10) break;
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
if (filters.length < 10) {
|
|
63
|
+
try {
|
|
64
|
+
const searchHandles = await page.locator(
|
|
65
|
+
'input[type="search"]:not([role="dialog"] input), input[placeholder*="search" i]:not([role="dialog"] input)'
|
|
66
|
+
).all();
|
|
67
|
+
for (const handle of searchHandles.slice(0, 10 - filters.length)) {
|
|
68
|
+
try {
|
|
69
|
+
const id = await handle.getAttribute("id").catch(() => null);
|
|
70
|
+
const placeholder = await handle.getAttribute("placeholder").catch(() => null);
|
|
71
|
+
const locatorHint = id ? `#${id}` : placeholder ? `input[placeholder="${placeholder}"]` : 'input[type="search"]';
|
|
72
|
+
let label = "";
|
|
73
|
+
if (id) {
|
|
74
|
+
label = await page.locator(`label[for="${id}"]`).innerText().catch(() => "");
|
|
75
|
+
}
|
|
76
|
+
if (!label) label = placeholder ?? "search";
|
|
77
|
+
filters.push({ kind: "search", label, locatorHint, options: [] });
|
|
78
|
+
if (filters.length >= 10) break;
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return filters;
|
|
86
|
+
}
|
|
87
|
+
async function applyFilterAndCapture(page, filter, option) {
|
|
88
|
+
const state = {
|
|
89
|
+
filterLabel: filter.label,
|
|
90
|
+
filterValue: option.value,
|
|
91
|
+
resultCount: null,
|
|
92
|
+
firstRowValues: []
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
if (filter.kind === "select") {
|
|
96
|
+
await page.locator(filter.locatorHint).selectOption(option.value);
|
|
97
|
+
} else if (filter.kind === "search") {
|
|
98
|
+
await page.locator(filter.locatorHint).fill(option.value);
|
|
99
|
+
await page.locator(filter.locatorHint).press("Enter");
|
|
100
|
+
}
|
|
101
|
+
await waitForSettle(page, 1500);
|
|
102
|
+
state.resultCount = await readResultCount(page);
|
|
103
|
+
state.firstRowValues = await captureFirstRow(page);
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
if (filter.kind === "select") {
|
|
108
|
+
const firstOption = await page.locator(filter.locatorHint).evaluate(
|
|
109
|
+
(el) => el.options[0]?.value ?? ""
|
|
110
|
+
);
|
|
111
|
+
await page.locator(filter.locatorHint).selectOption(firstOption);
|
|
112
|
+
} else if (filter.kind === "search") {
|
|
113
|
+
await page.locator(filter.locatorHint).fill("");
|
|
114
|
+
await page.locator(filter.locatorHint).press("Enter");
|
|
115
|
+
}
|
|
116
|
+
await waitForSettle(page, 800);
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
async function crawlFilterStates(page, filters) {
|
|
122
|
+
const states = [];
|
|
123
|
+
let totalStates = 0;
|
|
124
|
+
for (const filter of filters) {
|
|
125
|
+
if (totalStates >= 15) break;
|
|
126
|
+
const { options } = filter;
|
|
127
|
+
if (options.length === 0) continue;
|
|
128
|
+
const indices = /* @__PURE__ */ new Set([
|
|
129
|
+
0,
|
|
130
|
+
Math.floor((options.length - 1) / 2),
|
|
131
|
+
options.length - 1
|
|
132
|
+
]);
|
|
133
|
+
const picked = Array.from(indices).slice(0, 3);
|
|
134
|
+
for (const idx of picked) {
|
|
135
|
+
if (totalStates >= 15) break;
|
|
136
|
+
const option = options[idx];
|
|
137
|
+
try {
|
|
138
|
+
const s = await applyFilterAndCapture(page, filter, option);
|
|
139
|
+
states.push(s);
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
totalStates++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return states;
|
|
146
|
+
}
|
|
147
|
+
async function detectPaginationKind(page) {
|
|
148
|
+
try {
|
|
149
|
+
const numberedCount = await page.locator('[aria-label*="page" i], .pagination button, [role="navigation"] button').count().catch(() => 0);
|
|
150
|
+
if (numberedCount > 1) return "numbered";
|
|
151
|
+
const loadMore = await page.locator('button:has-text("Load more"), button:has-text("Show more"), a:has-text("Load more")').count().catch(() => 0);
|
|
152
|
+
if (loadMore > 0) return "load-more";
|
|
153
|
+
const isInfinite = await page.evaluate(() => {
|
|
154
|
+
return document.documentElement.scrollHeight > window.innerHeight * 2;
|
|
155
|
+
}).catch(() => false);
|
|
156
|
+
if (isInfinite) return "infinite-scroll";
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
return "none";
|
|
160
|
+
}
|
|
161
|
+
async function crawlNumberedPagination(page) {
|
|
162
|
+
const pages = [];
|
|
163
|
+
const pageSizes = [];
|
|
164
|
+
try {
|
|
165
|
+
const sizeSelect = page.locator('select[name*="size" i], select[name*="per" i], select[aria-label*="per page" i]');
|
|
166
|
+
const sizeCount = await sizeSelect.count();
|
|
167
|
+
if (sizeCount > 0) {
|
|
168
|
+
const rawSizes = await sizeSelect.first().evaluate(
|
|
169
|
+
(el) => Array.from(el.options).map((o) => parseInt(o.value, 10)).filter((n) => !isNaN(n))
|
|
170
|
+
);
|
|
171
|
+
pageSizes.push(...rawSizes);
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
}
|
|
175
|
+
let totalPages;
|
|
176
|
+
try {
|
|
177
|
+
totalPages = await page.evaluate(() => {
|
|
178
|
+
const buttons = Array.from(
|
|
179
|
+
document.querySelectorAll('[aria-label*="page" i], .pagination button')
|
|
180
|
+
);
|
|
181
|
+
const nums = buttons.map((b) => parseInt(b.innerText.trim(), 10)).filter((n) => !isNaN(n));
|
|
182
|
+
return nums.length > 0 ? Math.max(...nums) : void 0;
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
pages.push({ pageNum: 1, rowValues: await captureFirstRow(page) });
|
|
187
|
+
for (let pageNum = 2; pageNum <= 20; pageNum++) {
|
|
188
|
+
try {
|
|
189
|
+
const nextBtn = page.locator(
|
|
190
|
+
'button[aria-label*="next" i], a[aria-label*="next" i], [role="navigation"] button:last-child'
|
|
191
|
+
).first();
|
|
192
|
+
const isDisabled = await nextBtn.isDisabled().catch(() => true);
|
|
193
|
+
if (isDisabled) break;
|
|
194
|
+
await nextBtn.click();
|
|
195
|
+
await waitForSettle(page, 2e3);
|
|
196
|
+
const rowValues = await captureFirstRow(page);
|
|
197
|
+
pages.push({ pageNum, rowValues });
|
|
198
|
+
} catch {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
kind: "numbered",
|
|
204
|
+
totalPages,
|
|
205
|
+
pageSizes: pageSizes.length > 0 ? pageSizes : void 0,
|
|
206
|
+
pages
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function captureInfiniteScroll(page) {
|
|
210
|
+
const pages = [];
|
|
211
|
+
let prevRowCount = -1;
|
|
212
|
+
let iteration = 0;
|
|
213
|
+
while (iteration < 10) {
|
|
214
|
+
try {
|
|
215
|
+
const rowCount = await page.evaluate(
|
|
216
|
+
() => document.querySelectorAll("tbody tr").length
|
|
217
|
+
).catch(() => 0);
|
|
218
|
+
if (rowCount === prevRowCount) break;
|
|
219
|
+
prevRowCount = rowCount;
|
|
220
|
+
pages.push({ pageNum: iteration + 1, rowValues: await captureFirstRow(page) });
|
|
221
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
222
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
223
|
+
} catch {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
iteration++;
|
|
227
|
+
}
|
|
228
|
+
const totalItems = await page.evaluate(
|
|
229
|
+
() => document.querySelectorAll("tbody tr").length
|
|
230
|
+
).catch(() => void 0);
|
|
231
|
+
return {
|
|
232
|
+
kind: "infinite-scroll",
|
|
233
|
+
totalItems: typeof totalItems === "number" ? totalItems : void 0,
|
|
234
|
+
pages
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async function captureSortColumns(page) {
|
|
238
|
+
const results = [];
|
|
239
|
+
try {
|
|
240
|
+
const sortable = await page.locator("[aria-sort]").all();
|
|
241
|
+
for (const th of sortable.slice(0, 5)) {
|
|
242
|
+
try {
|
|
243
|
+
const header = await th.innerText().catch(() => "");
|
|
244
|
+
await th.click();
|
|
245
|
+
await waitForSettle(page, 800);
|
|
246
|
+
const ariaSortAfterClick = await th.getAttribute("aria-sort").catch(() => null);
|
|
247
|
+
results.push({ header: header.trim(), ariaSortAfterClick });
|
|
248
|
+
await th.click().catch(() => void 0);
|
|
249
|
+
await waitForSettle(page, 600);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
256
|
+
}
|
|
257
|
+
async function crawlFiltersAndPagination(page) {
|
|
258
|
+
let filters = [];
|
|
259
|
+
try {
|
|
260
|
+
filters = await discoverFilters(page);
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
let filterStates = [];
|
|
264
|
+
try {
|
|
265
|
+
filterStates = await crawlFilterStates(page, filters);
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
let pagination = { kind: "none", pages: [] };
|
|
269
|
+
try {
|
|
270
|
+
const kind = await detectPaginationKind(page);
|
|
271
|
+
if (kind === "numbered") {
|
|
272
|
+
pagination = await crawlNumberedPagination(page);
|
|
273
|
+
} else if (kind === "load-more") {
|
|
274
|
+
pagination = await captureInfiniteScroll(page);
|
|
275
|
+
pagination = { ...pagination, kind: "load-more" };
|
|
276
|
+
} else if (kind === "infinite-scroll") {
|
|
277
|
+
pagination = await captureInfiniteScroll(page);
|
|
278
|
+
} else {
|
|
279
|
+
const rowValues = await captureFirstRow(page);
|
|
280
|
+
pagination = { kind: "none", pages: rowValues.length > 0 ? [{ pageNum: 1, rowValues }] : [] };
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
let sortColumns = [];
|
|
285
|
+
try {
|
|
286
|
+
sortColumns = await captureSortColumns(page);
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
filters,
|
|
291
|
+
filterStates,
|
|
292
|
+
pagination,
|
|
293
|
+
sortColumns
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
export {
|
|
297
|
+
crawlFiltersAndPagination
|
|
298
|
+
};
|
|
299
|
+
//# sourceMappingURL=filter-pagination-crawler.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/filter-pagination-crawler.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Filter state crawling and pagination capture.\n *\n * Discovers filter controls (select, checkbox-group, radio-group, search),\n * applies each meaningful option and records result counts and first-row\n * values, then detects and walks pagination (numbered, load-more, infinite\n * scroll). Also captures sort column behaviour.\n *\n * All per-filter / per-page errors are caught and skipped so one bad control\n * never aborts the full crawl.\n */\n\nimport type { Page } from 'playwright';\n\n// ---------------------------------------------------------------------------\n// Public interfaces\n// ---------------------------------------------------------------------------\n\nexport interface FilterOption {\n value: string;\n label: string;\n}\n\nexport interface FilterDef {\n kind: 'select' | 'checkbox-group' | 'radio-group' | 'search';\n label: string;\n locatorHint: string;\n options: FilterOption[];\n}\n\nexport interface FilterState {\n filterLabel: string;\n filterValue: string;\n resultCount: number | null;\n firstRowValues: string[];\n}\n\nexport interface PaginationCapture {\n kind: 'numbered' | 'load-more' | 'infinite-scroll' | 'none';\n totalPages?: number;\n totalItems?: number;\n pageSizes?: number[];\n pages: Array<{ pageNum: number; rowValues: string[] }>;\n}\n\nexport interface FilterPaginationResult {\n filters: FilterDef[];\n filterStates: FilterState[];\n pagination: PaginationCapture;\n sortColumns: Array<{ header: string; ariaSortAfterClick: string | null }>;\n searchCapture?: { noResultsText: string | null; sampleSuggestions: string[] };\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Pause for up to `ms` ms or until network is idle, whichever comes first. */\nasync function waitForSettle(page: Page, ms = 1500): Promise<void> {\n await Promise.race([\n page.waitForLoadState('networkidle').catch(() => undefined),\n new Promise<void>(r => setTimeout(r, ms)),\n ]);\n}\n\n/** Extract visible cell text from the first tbody row, if any. */\nasync function captureFirstRow(page: Page): Promise<string[]> {\n try {\n return await page.evaluate(() => {\n const row = document.querySelector('tbody tr');\n if (!row) return [];\n return Array.from(row.querySelectorAll('td')).map(td => td.innerText.trim());\n });\n } catch {\n return [];\n }\n}\n\n/** Read a result-count hint from visible page text using common patterns. */\nasync function readResultCount(page: Page): Promise<number | null> {\n try {\n const text = await page.evaluate(() => document.body.innerText);\n const match = text.match(/(\\d[\\d,]*)\\s*(result|item|entr|record)/i);\n if (!match) return null;\n return parseInt(match[1].replace(/,/g, ''), 10);\n } catch {\n return null;\n }\n}\n\n// ---------------------------------------------------------------------------\n// discoverFilters\n// ---------------------------------------------------------------------------\n\nasync function discoverFilters(page: Page): Promise<FilterDef[]> {\n const filters: FilterDef[] = [];\n\n // --- native <select> elements outside dialogs ---------------------------------\n try {\n const selectHandles = await page.locator('select:not([role=\"dialog\"] select)').all();\n for (const handle of selectHandles.slice(0, 10)) {\n try {\n const id = await handle.getAttribute('id').catch(() => null);\n const name = await handle.getAttribute('name').catch(() => null);\n const locatorHint = id ? `#${id}` : name ? `select[name=\"${name}\"]` : 'select';\n\n // Find associated label\n let label = '';\n if (id) {\n label = await page.locator(`label[for=\"${id}\"]`).innerText().catch(() => '');\n }\n if (!label) {\n label = await handle.evaluate((el: HTMLSelectElement) => {\n const lbl = el.closest('label') ?? el.previousElementSibling;\n return (lbl as HTMLElement | null)?.innerText?.trim() ?? '';\n }).catch(() => '');\n }\n if (!label) label = name ?? id ?? 'filter';\n\n // Collect options, skipping default/placeholder entries\n const rawOptions: FilterOption[] = await handle.evaluate((el: HTMLSelectElement) =>\n Array.from(el.options).map(o => ({ value: o.value, label: o.text.trim() })),\n ).catch(() => []);\n\n const options = rawOptions.filter(\n o => !(/^all$|^any$|^select|^--/i.test(o.label) || o.value === ''),\n );\n\n if (options.length === 0) continue;\n\n filters.push({ kind: 'select', label, locatorHint, options });\n if (filters.length >= 10) break;\n } catch {\n // skip this select\n }\n }\n } catch {\n // page.locator failed\n }\n\n // --- search inputs ------------------------------------------------------------\n if (filters.length < 10) {\n try {\n const searchHandles = await page\n .locator(\n 'input[type=\"search\"]:not([role=\"dialog\"] input), ' +\n 'input[placeholder*=\"search\" i]:not([role=\"dialog\"] input)',\n )\n .all();\n\n for (const handle of searchHandles.slice(0, 10 - filters.length)) {\n try {\n const id = await handle.getAttribute('id').catch(() => null);\n const placeholder = await handle.getAttribute('placeholder').catch(() => null);\n const locatorHint = id ? `#${id}` : placeholder ? `input[placeholder=\"${placeholder}\"]` : 'input[type=\"search\"]';\n\n let label = '';\n if (id) {\n label = await page.locator(`label[for=\"${id}\"]`).innerText().catch(() => '');\n }\n if (!label) label = placeholder ?? 'search';\n\n filters.push({ kind: 'search', label, locatorHint, options: [] });\n if (filters.length >= 10) break;\n } catch {\n // skip\n }\n }\n } catch {\n // locator failed\n }\n }\n\n return filters;\n}\n\n// ---------------------------------------------------------------------------\n// applyFilterAndCapture\n// ---------------------------------------------------------------------------\n\nasync function applyFilterAndCapture(\n page: Page,\n filter: FilterDef,\n option: FilterOption,\n): Promise<FilterState> {\n const state: FilterState = {\n filterLabel: filter.label,\n filterValue: option.value,\n resultCount: null,\n firstRowValues: [],\n };\n\n try {\n if (filter.kind === 'select') {\n await page.locator(filter.locatorHint).selectOption(option.value);\n } else if (filter.kind === 'search') {\n await page.locator(filter.locatorHint).fill(option.value);\n await page.locator(filter.locatorHint).press('Enter');\n }\n\n await waitForSettle(page, 1500);\n\n state.resultCount = await readResultCount(page);\n state.firstRowValues = await captureFirstRow(page);\n } catch {\n // non-fatal: return partial state\n }\n\n // Reset filter\n try {\n if (filter.kind === 'select') {\n const firstOption = await page.locator(filter.locatorHint).evaluate(\n (el: HTMLSelectElement) => el.options[0]?.value ?? '',\n );\n await page.locator(filter.locatorHint).selectOption(firstOption);\n } else if (filter.kind === 'search') {\n await page.locator(filter.locatorHint).fill('');\n await page.locator(filter.locatorHint).press('Enter');\n }\n await waitForSettle(page, 800);\n } catch {\n // ignore reset errors\n }\n\n return state;\n}\n\n// ---------------------------------------------------------------------------\n// crawlFilterStates\n// ---------------------------------------------------------------------------\n\nasync function crawlFilterStates(page: Page, filters: FilterDef[]): Promise<FilterState[]> {\n const states: FilterState[] = [];\n let totalStates = 0;\n\n for (const filter of filters) {\n if (totalStates >= 15) break;\n\n const { options } = filter;\n if (options.length === 0) continue;\n\n // Pick first, middle, last \u2014 deduplicated, max 3\n const indices = new Set<number>([\n 0,\n Math.floor((options.length - 1) / 2),\n options.length - 1,\n ]);\n const picked = Array.from(indices).slice(0, 3);\n\n for (const idx of picked) {\n if (totalStates >= 15) break;\n const option = options[idx];\n try {\n const s = await applyFilterAndCapture(page, filter, option);\n states.push(s);\n } catch {\n // skip\n }\n totalStates++;\n }\n }\n\n return states;\n}\n\n// ---------------------------------------------------------------------------\n// detectPaginationKind\n// ---------------------------------------------------------------------------\n\nasync function detectPaginationKind(\n page: Page,\n): Promise<'numbered' | 'load-more' | 'infinite-scroll' | 'none'> {\n try {\n // Numbered pagination: ARIA page buttons or .pagination\n const numberedCount = await page\n .locator('[aria-label*=\"page\" i], .pagination button, [role=\"navigation\"] button')\n .count()\n .catch(() => 0);\n if (numberedCount > 1) return 'numbered';\n\n // Load-more button\n const loadMore = await page\n .locator('button:has-text(\"Load more\"), button:has-text(\"Show more\"), a:has-text(\"Load more\")')\n .count()\n .catch(() => 0);\n if (loadMore > 0) return 'load-more';\n\n // Infinite scroll: scrollable content significantly taller than viewport\n const isInfinite = await page.evaluate(() => {\n return document.documentElement.scrollHeight > window.innerHeight * 2;\n }).catch(() => false);\n if (isInfinite) return 'infinite-scroll';\n } catch {\n // fall through\n }\n\n return 'none';\n}\n\n// ---------------------------------------------------------------------------\n// crawlNumberedPagination\n// ---------------------------------------------------------------------------\n\nasync function crawlNumberedPagination(page: Page): Promise<PaginationCapture> {\n const pages: Array<{ pageNum: number; rowValues: string[] }> = [];\n const pageSizes: number[] = [];\n\n // Read page size selector if present\n try {\n const sizeSelect = page.locator('select[name*=\"size\" i], select[name*=\"per\" i], select[aria-label*=\"per page\" i]');\n const sizeCount = await sizeSelect.count();\n if (sizeCount > 0) {\n const rawSizes = await sizeSelect.first().evaluate((el: HTMLSelectElement) =>\n Array.from(el.options).map(o => parseInt(o.value, 10)).filter(n => !isNaN(n)),\n );\n pageSizes.push(...rawSizes);\n }\n } catch {\n // ignore\n }\n\n // Determine total pages from pagination widget\n let totalPages: number | undefined;\n try {\n totalPages = await page.evaluate(() => {\n const buttons = Array.from(\n document.querySelectorAll('[aria-label*=\"page\" i], .pagination button'),\n );\n const nums = buttons\n .map(b => parseInt((b as HTMLElement).innerText.trim(), 10))\n .filter(n => !isNaN(n));\n return nums.length > 0 ? Math.max(...nums) : undefined;\n });\n } catch {\n // leave undefined\n }\n\n // Capture first page immediately\n pages.push({ pageNum: 1, rowValues: await captureFirstRow(page) });\n\n // Walk pages by clicking Next\n for (let pageNum = 2; pageNum <= 20; pageNum++) {\n try {\n const nextBtn = page.locator(\n 'button[aria-label*=\"next\" i], a[aria-label*=\"next\" i], [role=\"navigation\"] button:last-child',\n ).first();\n\n const isDisabled = await nextBtn.isDisabled().catch(() => true);\n if (isDisabled) break;\n\n await nextBtn.click();\n await waitForSettle(page, 2000);\n\n const rowValues = await captureFirstRow(page);\n pages.push({ pageNum, rowValues });\n } catch {\n break;\n }\n }\n\n return {\n kind: 'numbered',\n totalPages,\n pageSizes: pageSizes.length > 0 ? pageSizes : undefined,\n pages,\n };\n}\n\n// ---------------------------------------------------------------------------\n// captureInfiniteScroll\n// ---------------------------------------------------------------------------\n\nasync function captureInfiniteScroll(page: Page): Promise<PaginationCapture> {\n const pages: Array<{ pageNum: number; rowValues: string[] }> = [];\n\n let prevRowCount = -1;\n let iteration = 0;\n\n while (iteration < 10) {\n try {\n const rowCount = await page.evaluate(() =>\n document.querySelectorAll('tbody tr').length,\n ).catch(() => 0);\n\n if (rowCount === prevRowCount) break; // stable\n prevRowCount = rowCount;\n\n // Record current first-row snapshot per scroll step\n pages.push({ pageNum: iteration + 1, rowValues: await captureFirstRow(page) });\n\n await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n await new Promise<void>(r => setTimeout(r, 800));\n } catch {\n break;\n }\n iteration++;\n }\n\n const totalItems = await page.evaluate(() =>\n document.querySelectorAll('tbody tr').length,\n ).catch(() => undefined);\n\n return {\n kind: 'infinite-scroll',\n totalItems: typeof totalItems === 'number' ? totalItems : undefined,\n pages,\n };\n}\n\n// ---------------------------------------------------------------------------\n// captureSortColumns\n// ---------------------------------------------------------------------------\n\nasync function captureSortColumns(\n page: Page,\n): Promise<Array<{ header: string; ariaSortAfterClick: string | null }>> {\n const results: Array<{ header: string; ariaSortAfterClick: string | null }> = [];\n\n try {\n const sortable = await page.locator('[aria-sort]').all();\n for (const th of sortable.slice(0, 5)) {\n try {\n const header = await th.innerText().catch(() => '');\n await th.click();\n await waitForSettle(page, 800);\n const ariaSortAfterClick = await th.getAttribute('aria-sort').catch(() => null);\n results.push({ header: header.trim(), ariaSortAfterClick });\n\n // Reset by clicking again (most tables toggle back)\n await th.click().catch(() => undefined);\n await waitForSettle(page, 600);\n } catch {\n // skip this column\n }\n }\n } catch {\n // no sortable columns found\n }\n\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// crawlFiltersAndPagination \u2014 public orchestrator\n// ---------------------------------------------------------------------------\n\nexport async function crawlFiltersAndPagination(page: Page): Promise<FilterPaginationResult> {\n // 1. Discover filters\n let filters: FilterDef[] = [];\n try {\n filters = await discoverFilters(page);\n } catch {\n // proceed with empty filters\n }\n\n // 2. Apply filters and capture states\n let filterStates: FilterState[] = [];\n try {\n filterStates = await crawlFilterStates(page, filters);\n } catch {\n // proceed with empty states\n }\n\n // 3. Detect and crawl pagination\n let pagination: PaginationCapture = { kind: 'none', pages: [] };\n try {\n const kind = await detectPaginationKind(page);\n\n if (kind === 'numbered') {\n pagination = await crawlNumberedPagination(page);\n } else if (kind === 'load-more') {\n // Treat load-more similarly to infinite scroll capture\n pagination = await captureInfiniteScroll(page);\n pagination = { ...pagination, kind: 'load-more' };\n } else if (kind === 'infinite-scroll') {\n pagination = await captureInfiniteScroll(page);\n } else {\n // Capture single-page rows\n const rowValues = await captureFirstRow(page);\n pagination = { kind: 'none', pages: rowValues.length > 0 ? [{ pageNum: 1, rowValues }] : [] };\n }\n } catch {\n // leave pagination as none\n }\n\n // 4. Sort columns\n let sortColumns: Array<{ header: string; ariaSortAfterClick: string | null }> = [];\n try {\n sortColumns = await captureSortColumns(page);\n } catch {\n // proceed with empty sort\n }\n\n return {\n filters,\n filterStates,\n pagination,\n sortColumns,\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/frontier.js
CHANGED
|
@@ -2,11 +2,13 @@ function scoreUrl(url, keywords) {
|
|
|
2
2
|
try {
|
|
3
3
|
const u = new URL(url);
|
|
4
4
|
const segments = u.pathname.split("/").filter(Boolean);
|
|
5
|
-
|
|
5
|
+
const depthPenaltyWeight = parseFloat(process.env.CRAWLER_FRONTIER_DEPTH_PENALTY ?? "") || 1;
|
|
6
|
+
const keywordBoostWeight = parseFloat(process.env.CRAWLER_FRONTIER_KEYWORD_BOOST ?? "") || 10;
|
|
7
|
+
let score = -segments.length * depthPenaltyWeight;
|
|
6
8
|
if (keywords.length > 0) {
|
|
7
9
|
const lower = url.toLowerCase();
|
|
8
10
|
for (const kw of keywords) {
|
|
9
|
-
if (kw && lower.includes(kw)) score +=
|
|
11
|
+
if (kw && lower.includes(kw)) score += keywordBoostWeight;
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
14
|
return score;
|
|
@@ -41,7 +43,7 @@ class CrawlFrontier {
|
|
|
41
43
|
*/
|
|
42
44
|
push(url, pushOpts) {
|
|
43
45
|
if (this.urlFilter && !this.urlFilter.test(url)) return;
|
|
44
|
-
const sparseBoost = this.strategy === "BEST_FIRST" && pushOpts?.parentElements !== void 0 && pushOpts.parentElements < 5 ? 1 : 0;
|
|
46
|
+
const sparseBoost = this.strategy === "BEST_FIRST" && pushOpts?.parentElements !== void 0 && pushOpts.parentElements < (parseInt(process.env.CRAWLER_FRONTIER_SPARSE_PARENT_THRESHOLD ?? "") || 5) ? 1 : 0;
|
|
45
47
|
this.items.push({ url, score: this.strategy === "BEST_FIRST" ? scoreUrl(url, this.keywords) + sparseBoost : 0 });
|
|
46
48
|
}
|
|
47
49
|
/** Dequeues the next URL per this frontier's strategy. undefined if empty. */
|
package/dist/frontier.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/frontier.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Configurable crawl traversal strategy \u2014 matches crawl4AI's BFS/DFS/\n * Best-First deep-crawl strategies (ZeTa previously had a single hardcoded\n * FIFO queue, i.e. always BFS).\n *\n * Array-like on purpose (`length`, `shift()`, `push()`, `filter()`) so it\n * drops into crawler.ts's existing queue call sites with a rename, not a\n * rewrite \u2014 BFS's `shift()` behavior is byte-for-byte the same as the plain\n * array it replaces, so BFS (the default) has zero behavior change.\n */\n\nexport type TraversalStrategy = 'BFS' | 'DFS' | 'BEST_FIRST';\n\ninterface FrontierEntry {\n url: string;\n score: number;\n}\n\n/**\n * Best-First scoring heuristic: shallower paths score higher (breadth-like\n * bias toward top-level sections before deep sub-pages), boosted further by\n * any user-supplied keyword appearing in the URL. Simple and transparent by\n * design \u2014 crawl4AI's own default scorer is a similarly simple keyword-\n * relevance heuristic, not a learned model.\n */\nfunction scoreUrl(url: string, keywords: string[]): number {\n try {\n const u = new URL(url);\n const segments = u.pathname.split('/').filter(Boolean);\n let score = -segments.length;\n if (keywords.length > 0) {\n const lower = url.toLowerCase();\n for (const kw of keywords) {\n if (kw && lower.includes(kw)) score +=
|
|
5
|
-
"mappings": "AAyBA,SAAS,SAAS,KAAa,UAA4B;AACzD,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,UAAM,WAAW,EAAE,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACrD,QAAI,QAAQ,CAAC,SAAS;
|
|
4
|
+
"sourcesContent": ["/**\n * Configurable crawl traversal strategy \u2014 matches crawl4AI's BFS/DFS/\n * Best-First deep-crawl strategies (ZeTa previously had a single hardcoded\n * FIFO queue, i.e. always BFS).\n *\n * Array-like on purpose (`length`, `shift()`, `push()`, `filter()`) so it\n * drops into crawler.ts's existing queue call sites with a rename, not a\n * rewrite \u2014 BFS's `shift()` behavior is byte-for-byte the same as the plain\n * array it replaces, so BFS (the default) has zero behavior change.\n */\n\nexport type TraversalStrategy = 'BFS' | 'DFS' | 'BEST_FIRST';\n\ninterface FrontierEntry {\n url: string;\n score: number;\n}\n\n/**\n * Best-First scoring heuristic: shallower paths score higher (breadth-like\n * bias toward top-level sections before deep sub-pages), boosted further by\n * any user-supplied keyword appearing in the URL. Simple and transparent by\n * design \u2014 crawl4AI's own default scorer is a similarly simple keyword-\n * relevance heuristic, not a learned model.\n */\nfunction scoreUrl(url: string, keywords: string[]): number {\n try {\n const u = new URL(url);\n const segments = u.pathname.split('/').filter(Boolean);\n const depthPenaltyWeight = parseFloat(process.env.CRAWLER_FRONTIER_DEPTH_PENALTY ?? '') || 1;\n const keywordBoostWeight = parseFloat(process.env.CRAWLER_FRONTIER_KEYWORD_BOOST ?? '') || 10;\n let score = -segments.length * depthPenaltyWeight;\n if (keywords.length > 0) {\n const lower = url.toLowerCase();\n for (const kw of keywords) {\n if (kw && lower.includes(kw)) score += keywordBoostWeight;\n }\n }\n return score;\n } catch {\n return 0;\n }\n}\n\nexport class CrawlFrontier {\n private items: FrontierEntry[] = [];\n private readonly strategy: TraversalStrategy;\n private readonly keywords: string[];\n private readonly urlFilter: RegExp | null;\n\n constructor(strategy: TraversalStrategy, seedUrls: string[], keywords: string[] = [], urlFilter?: string) {\n this.strategy = strategy;\n this.keywords = keywords.map((k) => k.toLowerCase()).filter(Boolean);\n this.urlFilter = urlFilter\n ? (() => { try { return new RegExp(urlFilter, 'i'); } catch { return null; } })()\n : null;\n for (const url of seedUrls) this.push(url);\n }\n\n get length(): number {\n return this.items.length;\n }\n\n /**\n * P3b: sparse-parent boost \u2014 if the parent page had fewer than 5 interactive\n * elements (thin/canvas page), its child URLs score +1 in BEST_FIRST mode so\n * the frontier revisits sparse branches before deep-linked content.\n */\n push(url: string, pushOpts?: { parentElements?: number }): void {\n if (this.urlFilter && !this.urlFilter.test(url)) return;\n const sparseBoost =\n this.strategy === 'BEST_FIRST' &&\n pushOpts?.parentElements !== undefined &&\n pushOpts.parentElements < (parseInt(process.env.CRAWLER_FRONTIER_SPARSE_PARENT_THRESHOLD ?? '') || 5)\n ? 1\n : 0;\n this.items.push({ url, score: this.strategy === 'BEST_FIRST' ? scoreUrl(url, this.keywords) + sparseBoost : 0 });\n }\n\n /** Dequeues the next URL per this frontier's strategy. undefined if empty. */\n shift(): string | undefined {\n if (this.items.length === 0) return undefined;\n switch (this.strategy) {\n case 'DFS':\n return this.items.pop()!.url;\n case 'BEST_FIRST': {\n let bestIdx = 0;\n for (let i = 1; i < this.items.length; i++) {\n if (this.items[i].score > this.items[bestIdx].score) bestIdx = i;\n }\n return this.items.splice(bestIdx, 1)[0].url;\n }\n case 'BFS':\n default:\n return this.items.shift()!.url;\n }\n }\n\n filter(predicate: (url: string) => boolean): string[] {\n return this.items.filter((e) => predicate(e.url)).map((e) => e.url);\n }\n\n /** Remove queued URLs not matching the regex pattern. Returns count removed. */\n filterByPattern(pattern: string): number {\n try {\n const re = new RegExp(pattern, 'i');\n const before = this.items.length;\n this.items = this.items.filter((e) => re.test(e.url));\n return before - this.items.length;\n } catch {\n return 0;\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AAyBA,SAAS,SAAS,KAAa,UAA4B;AACzD,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,UAAM,WAAW,EAAE,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AACrD,UAAM,qBAAqB,WAAW,QAAQ,IAAI,kCAAkC,EAAE,KAAK;AAC3F,UAAM,qBAAqB,WAAW,QAAQ,IAAI,kCAAkC,EAAE,KAAK;AAC3F,QAAI,QAAQ,CAAC,SAAS,SAAS;AAC/B,QAAI,SAAS,SAAS,GAAG;AACvB,YAAM,QAAQ,IAAI,YAAY;AAC9B,iBAAW,MAAM,UAAU;AACzB,YAAI,MAAM,MAAM,SAAS,EAAE,EAAG,UAAS;AAAA,MACzC;AAAA,IACF;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,MAAM,cAAc;AAAA,EACjB,QAAyB,CAAC;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,UAA6B,UAAoB,WAAqB,CAAC,GAAG,WAAoB;AACxG,SAAK,WAAW;AAChB,SAAK,WAAW,SAAS,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC,EAAE,OAAO,OAAO;AACnE,SAAK,YAAY,aACZ,MAAM;AAAE,UAAI;AAAE,eAAO,IAAI,OAAO,WAAW,GAAG;AAAA,MAAG,QAAQ;AAAE,eAAO;AAAA,MAAM;AAAA,IAAE,GAAG,IAC9E;AACJ,eAAW,OAAO,SAAU,MAAK,KAAK,GAAG;AAAA,EAC3C;AAAA,EAEA,IAAI,SAAiB;AACnB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,KAAK,KAAa,UAA8C;AAC9D,QAAI,KAAK,aAAa,CAAC,KAAK,UAAU,KAAK,GAAG,EAAG;AACjD,UAAM,cACJ,KAAK,aAAa,gBAClB,UAAU,mBAAmB,UAC7B,SAAS,kBAAkB,SAAS,QAAQ,IAAI,4CAA4C,EAAE,KAAK,KAC/F,IACA;AACN,SAAK,MAAM,KAAK,EAAE,KAAK,OAAO,KAAK,aAAa,eAAe,SAAS,KAAK,KAAK,QAAQ,IAAI,cAAc,EAAE,CAAC;AAAA,EACjH;AAAA;AAAA,EAGA,QAA4B;AAC1B,QAAI,KAAK,MAAM,WAAW,EAAG,QAAO;AACpC,YAAQ,KAAK,UAAU;AAAA,MACrB,KAAK;AACH,eAAO,KAAK,MAAM,IAAI,EAAG;AAAA,MAC3B,KAAK,cAAc;AACjB,YAAI,UAAU;AACd,iBAAS,IAAI,GAAG,IAAI,KAAK,MAAM,QAAQ,KAAK;AAC1C,cAAI,KAAK,MAAM,CAAC,EAAE,QAAQ,KAAK,MAAM,OAAO,EAAE,MAAO,WAAU;AAAA,QACjE;AACA,eAAO,KAAK,MAAM,OAAO,SAAS,CAAC,EAAE,CAAC,EAAE;AAAA,MAC1C;AAAA,MACA,KAAK;AAAA,MACL;AACE,eAAO,KAAK,MAAM,MAAM,EAAG;AAAA,IAC/B;AAAA,EACF;AAAA,EAEA,OAAO,WAA+C;AACpD,WAAO,KAAK,MAAM,OAAO,CAAC,MAAM,UAAU,EAAE,GAAG,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG;AAAA,EACpE;AAAA;AAAA,EAGA,gBAAgB,SAAyB;AACvC,QAAI;AACF,YAAM,KAAK,IAAI,OAAO,SAAS,GAAG;AAClC,YAAM,SAAS,KAAK,MAAM;AAC1B,WAAK,QAAQ,KAAK,MAAM,OAAO,CAAC,MAAM,GAAG,KAAK,EAAE,GAAG,CAAC;AACpD,aAAO,SAAS,KAAK,MAAM;AAAA,IAC7B,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/gap-pass.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { logger as rootLogger } from "./logger.js";
|
|
2
2
|
const logger = rootLogger.child({ module: "gap-pass" });
|
|
3
|
+
function resolveTextLLM(llmCfg) {
|
|
4
|
+
if (llmCfg?.provider === "openai" && llmCfg.apiKey) {
|
|
5
|
+
return { provider: "openai", apiKey: llmCfg.apiKey, model: llmCfg.model, baseUrl: llmCfg.baseUrl };
|
|
6
|
+
}
|
|
7
|
+
if (llmCfg?.provider === "anthropic" && llmCfg.apiKey) {
|
|
8
|
+
return { provider: "anthropic", apiKey: llmCfg.apiKey, model: llmCfg.model };
|
|
9
|
+
}
|
|
10
|
+
if (process.env.OPENAI_API_KEY) {
|
|
11
|
+
return { provider: "openai", apiKey: process.env.OPENAI_API_KEY, model: "gpt-4o-mini" };
|
|
12
|
+
}
|
|
13
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
14
|
+
return { provider: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY, model: "claude-haiku-4-5-20251001" };
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
3
18
|
async function runGapAnalysis(screens, appUrl, llmCfg) {
|
|
4
|
-
|
|
19
|
+
const effective = resolveTextLLM(llmCfg);
|
|
20
|
+
if (!effective || screens.length < 2) return [];
|
|
5
21
|
const origin = (() => {
|
|
6
22
|
try {
|
|
7
23
|
return new URL(appUrl).origin;
|
|
@@ -17,7 +33,8 @@ async function runGapAnalysis(screens, appUrl, llmCfg) {
|
|
|
17
33
|
return s.url;
|
|
18
34
|
}
|
|
19
35
|
}).join("\n");
|
|
20
|
-
const
|
|
36
|
+
const systemPrompt = "You are a web application coverage analyst. Respond with valid JSON only.";
|
|
37
|
+
const userPrompt = `You are analyzing the screen map of a web application to find structural coverage gaps.
|
|
21
38
|
|
|
22
39
|
Discovered screens (${screens.length} total):
|
|
23
40
|
${screenList}
|
|
@@ -29,23 +46,39 @@ Based on these screens and common patterns for this type of web application, ide
|
|
|
29
46
|
Respond with ONLY valid JSON, no markdown:
|
|
30
47
|
{"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"}]}`;
|
|
31
48
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
let text = "";
|
|
50
|
+
if (effective.provider === "openai") {
|
|
51
|
+
const { OpenAI } = await import("openai");
|
|
52
|
+
const client = new OpenAI({ apiKey: effective.apiKey, ...effective.baseUrl ? { baseURL: effective.baseUrl } : {} });
|
|
53
|
+
const response = await client.chat.completions.create({
|
|
54
|
+
model: effective.model ?? "gpt-4o-mini",
|
|
55
|
+
max_tokens: 1024,
|
|
56
|
+
temperature: 0,
|
|
57
|
+
messages: [
|
|
58
|
+
{ role: "system", content: systemPrompt },
|
|
59
|
+
{ role: "user", content: userPrompt }
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
text = response.choices[0]?.message?.content ?? "";
|
|
63
|
+
} else {
|
|
64
|
+
const Anthropic = (await import("@anthropic-ai/sdk")).default;
|
|
65
|
+
const client = new Anthropic({ apiKey: effective.apiKey });
|
|
66
|
+
const response = await client.messages.create({
|
|
67
|
+
model: effective.model ?? "claude-haiku-4-5-20251001",
|
|
68
|
+
max_tokens: 1024,
|
|
69
|
+
temperature: 0,
|
|
70
|
+
// @ts-ignore
|
|
71
|
+
system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
|
|
72
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
73
|
+
});
|
|
74
|
+
text = response.content[0]?.text ?? "";
|
|
75
|
+
}
|
|
43
76
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
44
77
|
if (!jsonMatch) return [];
|
|
45
78
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
46
79
|
const rawGaps = parsed.gaps ?? [];
|
|
47
80
|
if (!Array.isArray(rawGaps) || rawGaps.length === 0) return [];
|
|
48
|
-
logger.info({ gaps: rawGaps.length }, "[P6] Gap analysis: LLM returned candidates");
|
|
81
|
+
logger.info({ gaps: rawGaps.length, provider: effective.provider }, "[P6] Gap analysis: LLM returned candidates");
|
|
49
82
|
const results = [];
|
|
50
83
|
for (const gap of rawGaps.slice(0, 15)) {
|
|
51
84
|
let confirmedUrl = null;
|
package/dist/gap-pass.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/gap-pass.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * P6: Gap Pass \u2014 LLM-guided second crawl pass\n *\n * After the primary BFS crawl completes, this module:\n * 1. Sends the discovered screen list to an LLM for gap analysis\n * 2. HEAD-validates each suggested URL\n * 3. Returns confirmed gap URLs for targeted re-crawl\n *\n * The caller (crawler.ts) is responsible for actually visiting the confirmed\n * URLs with the existing browser \u2014 this module only identifies them.\n */\n\nimport { logger as rootLogger } from './logger.js';\n\nconst logger = rootLogger.child({ module: 'gap-pass' });\n\nexport interface GapCandidate {\n category: string;\n urlPatterns: string[];\n confidence: number;\n rationale: string;\n confirmedUrl: string | null;\n headStatus: number | null;\n}\n\ninterface DiscoveredScreen {\n url: string;\n name?: string;\n elements?: number;\n}\n\ninterface LlmCfg {\n provider: string;\n model: string;\n apiKey?: string;\n}\n\n/**\n * Run LLM gap analysis + HEAD validation.\n * Returns candidates with `confirmedUrl` set for those where HEAD returned 200.\n */\nexport async function runGapAnalysis(\n screens: DiscoveredScreen[],\n appUrl: string,\n llmCfg: LlmCfg | null | undefined,\n): Promise<GapCandidate[]> {\n if (!
|
|
5
|
-
"mappings": "AAYA,SAAS,UAAU,kBAAkB;AAErC,MAAM,SAAS,WAAW,MAAM,EAAE,QAAQ,WAAW,CAAC;
|
|
4
|
+
"sourcesContent": ["/**\n * P6: Gap Pass \u2014 LLM-guided second crawl pass\n *\n * After the primary BFS crawl completes, this module:\n * 1. Sends the discovered screen list to an LLM for gap analysis\n * 2. HEAD-validates each suggested URL\n * 3. Returns confirmed gap URLs for targeted re-crawl\n *\n * The caller (crawler.ts) is responsible for actually visiting the confirmed\n * URLs with the existing browser \u2014 this module only identifies them.\n */\n\nimport { logger as rootLogger } from './logger.js';\n\nconst logger = rootLogger.child({ module: 'gap-pass' });\n\nexport interface GapCandidate {\n category: string;\n urlPatterns: string[];\n confidence: number;\n rationale: string;\n confirmedUrl: string | null;\n headStatus: number | null;\n}\n\ninterface DiscoveredScreen {\n url: string;\n name?: string;\n elements?: number;\n}\n\ninterface LlmCfg {\n provider: string;\n model: string;\n apiKey?: string;\n baseUrl?: string;\n}\n\n// Only openai and anthropic are supported here. NVIDIA, groq, ollama, etc. \u2192 skip, fall to env.\n/**\n * Resolve effective LLM. Priority: openai \u2192 anthropic \u2192 skip unsupported providers.\n * Falls back to env vars when platform provider is not supported.\n */\nfunction resolveTextLLM(llmCfg: LlmCfg | null | undefined): { provider: 'openai' | 'anthropic'; apiKey: string; model: string; baseUrl?: string } | null {\n if (llmCfg?.provider === 'openai' && llmCfg.apiKey) {\n return { provider: 'openai', apiKey: llmCfg.apiKey, model: llmCfg.model, baseUrl: llmCfg.baseUrl };\n }\n if (llmCfg?.provider === 'anthropic' && llmCfg.apiKey) {\n return { provider: 'anthropic', apiKey: llmCfg.apiKey, model: llmCfg.model };\n }\n // Unsupported provider (nvidia, groq, etc.) or no key \u2014 env fallback, openai first.\n if (process.env.OPENAI_API_KEY) {\n return { provider: 'openai', apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' };\n }\n if (process.env.ANTHROPIC_API_KEY) {\n return { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY, model: 'claude-haiku-4-5-20251001' };\n }\n return null;\n}\n\n/**\n * Run LLM gap analysis + HEAD validation.\n * Returns candidates with `confirmedUrl` set for those where HEAD returned 200.\n */\nexport async function runGapAnalysis(\n screens: DiscoveredScreen[],\n appUrl: string,\n llmCfg: LlmCfg | null | undefined,\n): Promise<GapCandidate[]> {\n const effective = resolveTextLLM(llmCfg);\n if (!effective || screens.length < 2) return [];\n\n const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })();\n if (!origin) return [];\n\n // Cap context \u2014 only send url + name, not full element data\n const screenList = screens\n .slice(0, 80)\n .map(s => {\n try { return new URL(s.url).pathname + (s.name ? ` (${s.name})` : ''); } catch { return s.url; }\n })\n .join('\\n');\n\n const systemPrompt = 'You are a web application coverage analyst. Respond with valid JSON only.';\n const userPrompt = `You are analyzing the screen map of a web application to find structural coverage gaps.\n\nDiscovered screens (${screens.length} total):\n${screenList}\n\nApp URL: ${appUrl}\n\nBased on these screens and common patterns for this type of web application, identify screen categories that are likely present but missing. Focus on pages that would exist in a typical SaaS or web app but are not represented above.\n\nRespond with ONLY valid JSON, no markdown:\n{\"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\"}]}`;\n\n try {\n let text = '';\n\n if (effective.provider === 'openai') {\n const { OpenAI } = await import('openai');\n const client = new OpenAI({ apiKey: effective.apiKey, ...(effective.baseUrl ? { baseURL: effective.baseUrl } : {}) });\n const response = await client.chat.completions.create({\n model: effective.model ?? 'gpt-4o-mini',\n max_tokens: 1024,\n temperature: 0,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n });\n text = response.choices[0]?.message?.content ?? '';\n } else {\n const Anthropic = (await import('@anthropic-ai/sdk')).default;\n const client = new Anthropic({ apiKey: effective.apiKey });\n const response = await client.messages.create({\n model: effective.model ?? 'claude-haiku-4-5-20251001',\n max_tokens: 1024,\n temperature: 0,\n // @ts-ignore\n system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],\n messages: [{ role: 'user', content: userPrompt }],\n });\n text = (response.content[0] as any)?.text ?? '';\n }\n\n const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n if (!jsonMatch) return [];\n const parsed = JSON.parse(jsonMatch[0]);\n const rawGaps: Array<{ category: string; urlPatterns: string[]; confidence: number; rationale: string }> =\n parsed.gaps ?? [];\n\n if (!Array.isArray(rawGaps) || rawGaps.length === 0) return [];\n logger.info({ gaps: rawGaps.length, provider: effective.provider }, '[P6] Gap analysis: LLM returned candidates');\n\n // HEAD-validate each URL pattern\n const results: GapCandidate[] = [];\n for (const gap of rawGaps.slice(0, 15)) {\n let confirmedUrl: string | null = null;\n let headStatus: number | null = null;\n\n for (const pattern of (gap.urlPatterns ?? []).slice(0, 4)) {\n const fullUrl = pattern.startsWith('http') ? pattern : `${origin}${pattern.startsWith('/') ? '' : '/'}${pattern}`;\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 6_000);\n const res = await fetch(fullUrl, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }).catch(() => null);\n clearTimeout(timeout);\n headStatus = res?.status ?? null;\n if (res?.ok) { confirmedUrl = fullUrl; break; }\n } catch { /* non-fatal */ }\n }\n\n results.push({ category: gap.category, urlPatterns: gap.urlPatterns, confidence: gap.confidence, rationale: gap.rationale, confirmedUrl, headStatus });\n logger.info({ category: gap.category, confirmedUrl, headStatus }, '[P6] Gap candidate checked');\n }\n\n const confirmed = results.filter(r => r.confirmedUrl).length;\n logger.info({ total: results.length, confirmed }, '[P6] Gap pass analysis complete');\n return results;\n } catch (err) {\n logger.warn({ err: String(err) }, '[P6] Gap analysis failed \u2014 non-fatal');\n return [];\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|