@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
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
|
-
|
|
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,
|
|
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 <
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
123
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
@@ -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>;
|