@detiq/crawler 1.1.7 → 1.1.9
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/dist/accessibility-auditor.js +74 -54
- package/dist/ai-login.js +249 -227
- package/dist/auth-token-rotator.js +67 -57
- package/dist/auth.js +336 -299
- package/dist/browser-extension.js +142 -104
- package/dist/captcha-solver.js +125 -98
- package/dist/color-contrast.js +89 -77
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +71 -54
- package/dist/cookie-consent.js +83 -68
- package/dist/cookie-jar.js +60 -53
- package/dist/crawler.js +5345 -4486
- package/dist/custom-js-injector.js +61 -45
- package/dist/dark-mode-capture.js +81 -67
- package/dist/dead-link-detector.js +55 -47
- package/dist/element-hash.js +12 -8
- package/dist/email-otp.js +195 -154
- package/dist/engine-router.js +98 -87
- package/dist/export/bulk-export.js +55 -70
- package/dist/export/vector-export.js +105 -77
- package/dist/extractors/advanced.d.ts +10 -0
- package/dist/extractors/advanced.js +266 -249
- package/dist/extractors/artifact-envelope.js +58 -59
- package/dist/failure-analysis-agent.js +33 -37
- package/dist/feature-flag-detector.js +66 -51
- package/dist/form-detector.js +75 -55
- package/dist/frontier.js +101 -76
- package/dist/gap-pass.js +89 -68
- package/dist/graphql-detector.js +72 -62
- package/dist/http-engine.js +330 -282
- package/dist/incremental-diff.js +87 -54
- package/dist/index.d.ts +2 -0
- package/dist/index.js +28 -63
- package/dist/index.js.map +2 -2
- package/dist/input-fuzzer.js +97 -93
- package/dist/interactive-probe.d.ts +36 -0
- package/dist/interactive-probe.js +827 -715
- package/dist/job-queue.js +2169 -2151
- package/dist/js-coverage.js +56 -52
- package/dist/js-router-detector.js +117 -86
- package/dist/language-detector.js +63 -51
- package/dist/link-classifier.js +50 -32
- package/dist/live-view.js +108 -91
- package/dist/locator-extractor.d.ts +37 -0
- package/dist/locator-extractor.js +199 -0
- package/dist/locator-extractor.js.map +7 -0
- package/dist/logger.js +7 -9
- package/dist/markdown-extract.js +61 -48
- package/dist/native-crawler.js +946 -780
- package/dist/network-observer.js +156 -135
- package/dist/network-throttle.js +40 -29
- package/dist/openapi-generator.js +92 -77
- package/dist/pdf-extractor.js +90 -72
- package/dist/performance-metrics.js +102 -80
- package/dist/phash.js +144 -125
- package/dist/pii-scrubber.js +68 -65
- package/dist/playwright-recorder.js +58 -70
- package/dist/policies/crawl-safety.js +34 -31
- package/dist/profiles/domain-profile.js +125 -130
- package/dist/proxy.js +172 -146
- package/dist/rate-limiter.js +65 -58
- package/dist/redirect-tracker.js +64 -60
- package/dist/redis-frontier.js +136 -116
- package/dist/redis-queue.js +144 -157
- package/dist/response-cache.js +140 -127
- package/dist/responsive-detector.js +46 -42
- package/dist/retry-policy.js +50 -46
- package/dist/robots-txt.js +83 -69
- package/dist/screen-reader.js +70 -45
- package/dist/screenshot-masker.js +77 -59
- package/dist/seo-checker.js +107 -88
- package/dist/session-capture.js +212 -192
- package/dist/session-export.js +133 -107
- package/dist/sitemap-writer.js +41 -38
- package/dist/sitemap.js +202 -141
- package/dist/skyvern.js +148 -123
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +90 -74
- package/dist/stealth/human-behavior.js +173 -156
- package/dist/telemetry.js +82 -68
- package/dist/test-executor.js +335 -309
- package/dist/tls-fingerprint.js +88 -77
- package/dist/url-pattern-completer.js +90 -71
- package/dist/visual-diff-verdict.js +38 -23
- package/dist/webhook-notifier.js +69 -71
- package/dist/websocket-detector.js +68 -56
- package/dist/worker.js +38 -29
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/locator-extractor.ts +226 -0
- package/types/index.d.ts +2 -0
- package/types/locator-extractor.d.ts +37 -0
|
@@ -1,60 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket detection — intercepts WS connections opened during page crawl.
|
|
3
|
+
* Records connection URLs, message schemas, and message counts.
|
|
4
|
+
* This lets ZeTa discover real-time API contracts (chat, notifications, presence)
|
|
5
|
+
* that would be invisible to HTTP-only crawlers.
|
|
6
|
+
*/
|
|
1
7
|
function inferProtocol(sample) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const onCreated = (evt) => {
|
|
15
|
-
const { requestId, url, timestamp } = evt ?? {};
|
|
16
|
-
if (!requestId || !url) return;
|
|
17
|
-
connections.set(requestId, {
|
|
18
|
-
url,
|
|
19
|
-
framesSent: 0,
|
|
20
|
-
framesReceived: 0,
|
|
21
|
-
samples: [],
|
|
22
|
-
protocol: "unknown",
|
|
23
|
-
openedAt: new Date(timestamp * 1e3).toISOString()
|
|
24
|
-
});
|
|
25
|
-
};
|
|
26
|
-
const onFrame = (evt, direction) => {
|
|
27
|
-
const { requestId, response } = evt ?? {};
|
|
28
|
-
if (!requestId) return;
|
|
29
|
-
const conn = connections.get(requestId);
|
|
30
|
-
if (!conn) return;
|
|
31
|
-
if (direction === "sent") conn.framesSent++;
|
|
32
|
-
else conn.framesReceived++;
|
|
33
|
-
if (conn.samples.length < 10 && response?.payloadData) {
|
|
34
|
-
const payload = String(response.payloadData).slice(0, 500);
|
|
35
|
-
conn.samples.push({ direction, payload, ts: (/* @__PURE__ */ new Date()).toISOString() });
|
|
36
|
-
if (conn.protocol === "unknown") conn.protocol = inferProtocol(payload);
|
|
8
|
+
try {
|
|
9
|
+
const obj = JSON.parse(sample);
|
|
10
|
+
if (obj?.jsonrpc)
|
|
11
|
+
return 'json-rpc';
|
|
12
|
+
if (obj?.type === 'connection_init' || obj?.type === 'subscribe')
|
|
13
|
+
return 'graphql-ws';
|
|
14
|
+
if (typeof obj?.sid === 'string' || sample.startsWith('0{'))
|
|
15
|
+
return 'socket.io';
|
|
16
|
+
return 'json';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return 'raw';
|
|
37
20
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
21
|
+
}
|
|
22
|
+
export function attachWebSocketDetector(cdpSession) {
|
|
23
|
+
const connections = new Map();
|
|
24
|
+
const onCreated = (evt) => {
|
|
25
|
+
const { requestId, url, timestamp } = evt ?? {};
|
|
26
|
+
if (!requestId || !url)
|
|
27
|
+
return;
|
|
28
|
+
connections.set(requestId, {
|
|
29
|
+
url,
|
|
30
|
+
framesSent: 0,
|
|
31
|
+
framesReceived: 0,
|
|
32
|
+
samples: [],
|
|
33
|
+
protocol: 'unknown',
|
|
34
|
+
openedAt: new Date(timestamp * 1000).toISOString(),
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const onFrame = (evt, direction) => {
|
|
38
|
+
const { requestId, response } = evt ?? {};
|
|
39
|
+
if (!requestId)
|
|
40
|
+
return;
|
|
41
|
+
const conn = connections.get(requestId);
|
|
42
|
+
if (!conn)
|
|
43
|
+
return;
|
|
44
|
+
if (direction === 'sent')
|
|
45
|
+
conn.framesSent++;
|
|
46
|
+
else
|
|
47
|
+
conn.framesReceived++;
|
|
48
|
+
if (conn.samples.length < 10 && response?.payloadData) {
|
|
49
|
+
const payload = String(response.payloadData).slice(0, 500);
|
|
50
|
+
conn.samples.push({ direction, payload, ts: new Date().toISOString() });
|
|
51
|
+
if (conn.protocol === 'unknown')
|
|
52
|
+
conn.protocol = inferProtocol(payload);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const onSent = (evt) => onFrame(evt, 'sent');
|
|
56
|
+
const onReceived = (evt) => onFrame(evt, 'received');
|
|
57
|
+
if (cdpSession?.on) {
|
|
58
|
+
cdpSession.on('Network.webSocketCreated', onCreated);
|
|
59
|
+
cdpSession.on('Network.webSocketFrameSent', onSent);
|
|
60
|
+
cdpSession.on('Network.webSocketFrameReceived', onReceived);
|
|
54
61
|
}
|
|
55
|
-
|
|
62
|
+
return {
|
|
63
|
+
getConnections: () => [...connections.values()],
|
|
64
|
+
detach() {
|
|
65
|
+
if (cdpSession?.off) {
|
|
66
|
+
cdpSession.off('Network.webSocketCreated', onCreated);
|
|
67
|
+
cdpSession.off('Network.webSocketFrameSent', onSent);
|
|
68
|
+
cdpSession.off('Network.webSocketFrameReceived', onReceived);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
56
72
|
}
|
|
57
|
-
export {
|
|
58
|
-
attachWebSocketDetector
|
|
59
|
-
};
|
|
60
|
-
//# sourceMappingURL=websocket-detector.js.map
|
package/dist/worker.js
CHANGED
|
@@ -1,33 +1,42 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Standalone worker process entrypoint for a dedicated crawler/worker container,
|
|
3
|
+
* separate from the API server (see infrastructure/ecs-task-definitions/crawler.json).
|
|
4
|
+
*
|
|
5
|
+
* index.ts is a side-effect-free library barrel imported by @detiq/api — it must NOT
|
|
6
|
+
* start any background loop itself, or importing a single helper from @detiq/crawler
|
|
7
|
+
* would silently spin up a second worker inside the API process too. This file is
|
|
8
|
+
* the actual entrypoint that keeps a dedicated worker container's process alive and
|
|
9
|
+
* consuming jobs: `node dist/worker.js`.
|
|
10
|
+
*/
|
|
11
|
+
import { startJobWorkerLoop, processDataRetentionPurge } from './job-queue.js';
|
|
12
|
+
import { logger } from './logger.js';
|
|
3
13
|
async function main() {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
logger.info('[worker] starting standalone crawler worker process');
|
|
15
|
+
// Durable DB-backed queue — claims every non-CRAWL job type, and CRAWL too when
|
|
16
|
+
// JOB_QUEUE_BACKEND isn't 'sqs'. Safe to run in many worker replicas at once.
|
|
17
|
+
startJobWorkerLoop();
|
|
18
|
+
// Daily data-retention purge: runs immediately on startup then every 24 h.
|
|
19
|
+
// Only runs in the dedicated worker container (this file), not in the API
|
|
20
|
+
// process, so there is exactly one purge sweep regardless of API replica count.
|
|
21
|
+
const runPurge = () => {
|
|
22
|
+
processDataRetentionPurge().catch((err) => {
|
|
23
|
+
logger.error({ err: String(err) }, '[worker] data-retention purge failed');
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
runPurge();
|
|
27
|
+
setInterval(runPurge, 24 * 60 * 60 * 1000);
|
|
28
|
+
// Optional: also consume the SQS transport for horizontally-scaled CRAWL fleets.
|
|
29
|
+
if (process.env.JOB_QUEUE_BACKEND === 'sqs') {
|
|
30
|
+
const { startSqsWorker } = await import('./sqs-queue.js');
|
|
31
|
+
startSqsWorker().catch((err) => {
|
|
32
|
+
logger.error({ err: String(err) }, '[worker] SQS worker crashed');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
20
36
|
}
|
|
21
|
-
process.on(
|
|
22
|
-
|
|
23
|
-
process.exit(0);
|
|
24
|
-
});
|
|
25
|
-
process.on("SIGINT", () => {
|
|
26
|
-
logger.info("[worker] SIGINT received, exiting");
|
|
27
|
-
process.exit(0);
|
|
28
|
-
});
|
|
37
|
+
process.on('SIGTERM', () => { logger.info('[worker] SIGTERM received, exiting'); process.exit(0); });
|
|
38
|
+
process.on('SIGINT', () => { logger.info('[worker] SIGINT received, exiting'); process.exit(0); });
|
|
29
39
|
main().catch((err) => {
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
logger.error({ err: String(err) }, '[worker] fatal startup error');
|
|
41
|
+
process.exit(1);
|
|
32
42
|
});
|
|
33
|
-
//# sourceMappingURL=worker.js.map
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -35,3 +35,5 @@ export { startSpan, endSpan, withSpan, increment, flushMetrics } from './telemet
|
|
|
35
35
|
export type { Span, FinishedSpan } from './telemetry.js';
|
|
36
36
|
export { analyzeFailure } from './failure-analysis-agent.js';
|
|
37
37
|
export type { FailureContext } from './failure-analysis-agent.js';
|
|
38
|
+
export { elementToPlaywrightLocator, elementsToPlaywrightLocators, formatLocatorsForPrompt } from './locator-extractor.js';
|
|
39
|
+
export type { PlaywrightLocator } from './locator-extractor.js';
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts raw DB Element records (role, meaning, notes JSON) into Playwright-idiomatic locator strings.
|
|
3
|
+
* Priority: data-testid → aria-label/placeholder (inputs) → role+name (buttons/links) → heading → text → id
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface PlaywrightLocator {
|
|
7
|
+
key: string; // camelCase property name for page object
|
|
8
|
+
expression: string; // e.g. "page.getByRole('button', { name: 'Submit' })"
|
|
9
|
+
elementType: string; // button | input | link | heading | text | element
|
|
10
|
+
confidence: 'high' | 'medium' | 'low';
|
|
11
|
+
source: 'testid' | 'label' | 'placeholder' | 'role-name' | 'heading' | 'text' | 'id';
|
|
12
|
+
description: string; // human-readable label
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse the notes JSON string stored on Element records. */
|
|
16
|
+
function parseNotes(notes: unknown): Record<string, string> {
|
|
17
|
+
if (!notes) return {};
|
|
18
|
+
try {
|
|
19
|
+
const parsed = typeof notes === 'string' ? JSON.parse(notes) : notes;
|
|
20
|
+
if (parsed && typeof parsed === 'object') return parsed as Record<string, string>;
|
|
21
|
+
} catch {}
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Map crawler role strings to Playwright role values. */
|
|
26
|
+
function mapRole(role?: string | null): string | null {
|
|
27
|
+
if (!role) return null;
|
|
28
|
+
const r = role.toLowerCase().trim();
|
|
29
|
+
if (r === 'button' || r === 'submit' || r === 'reset') return 'button';
|
|
30
|
+
if (r === 'link' || r === 'a' || r === 'anchor') return 'link';
|
|
31
|
+
if (r === 'textbox' || r === 'input' || r === 'text' || r === 'email' || r === 'password' || r === 'tel' || r === 'number' || r === 'search') return 'textbox';
|
|
32
|
+
if (r === 'combobox' || r === 'select' || r === 'dropdown') return 'combobox';
|
|
33
|
+
if (r === 'checkbox') return 'checkbox';
|
|
34
|
+
if (r === 'radio') return 'radio';
|
|
35
|
+
if (r === 'heading' || r === 'h1' || r === 'h2' || r === 'h3' || r === 'h4' || r === 'h5' || r === 'h6') return 'heading';
|
|
36
|
+
if (r === 'img' || r === 'image') return 'img';
|
|
37
|
+
if (r === 'nav' || r === 'navigation') return 'navigation';
|
|
38
|
+
return r;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Convert "Submit Form Button" → "submitFormButton" */
|
|
42
|
+
function toKey(text: string): string {
|
|
43
|
+
return text
|
|
44
|
+
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
|
45
|
+
.replace(/\s+(.)/g, (_, c) => c.toUpperCase())
|
|
46
|
+
.replace(/^(.)/, (_, c) => c.toLowerCase())
|
|
47
|
+
.replace(/\s/g, '')
|
|
48
|
+
.slice(0, 40)
|
|
49
|
+
|| 'element';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Convert a single DB Element record to a Playwright locator. Returns null if insufficient data. */
|
|
53
|
+
export function elementToPlaywrightLocator(element: {
|
|
54
|
+
role?: string | null;
|
|
55
|
+
meaning?: string | null;
|
|
56
|
+
text?: string | null;
|
|
57
|
+
notes?: unknown;
|
|
58
|
+
selector?: string | null;
|
|
59
|
+
}): PlaywrightLocator | null {
|
|
60
|
+
const attrs = parseNotes(element.notes);
|
|
61
|
+
const testId = attrs['data-testid'];
|
|
62
|
+
const ariaLabel = attrs['aria-label'] || element.meaning;
|
|
63
|
+
const id = attrs['id'];
|
|
64
|
+
const placeholder = attrs['placeholder'];
|
|
65
|
+
const name = attrs['name'];
|
|
66
|
+
const text = (element.text ?? '').trim();
|
|
67
|
+
const meaning = (element.meaning ?? '').trim();
|
|
68
|
+
const role = mapRole(element.role);
|
|
69
|
+
|
|
70
|
+
const label = meaning || text || testId || ariaLabel || placeholder || id || name || 'element';
|
|
71
|
+
const key = toKey(label);
|
|
72
|
+
|
|
73
|
+
// Priority 1: data-testid
|
|
74
|
+
if (testId) {
|
|
75
|
+
return {
|
|
76
|
+
key,
|
|
77
|
+
expression: `page.getByTestId(${JSON.stringify(testId)})`,
|
|
78
|
+
elementType: role ?? 'element',
|
|
79
|
+
confidence: 'high',
|
|
80
|
+
source: 'testid',
|
|
81
|
+
description: meaning || testId,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Priority 2: input/combobox/textarea with label → getByLabel; with placeholder → getByPlaceholder
|
|
86
|
+
if (role === 'textbox' || role === 'combobox') {
|
|
87
|
+
if (ariaLabel) {
|
|
88
|
+
return {
|
|
89
|
+
key,
|
|
90
|
+
expression: `page.getByLabel(${JSON.stringify(ariaLabel)})`,
|
|
91
|
+
elementType: 'input',
|
|
92
|
+
confidence: 'high',
|
|
93
|
+
source: 'label',
|
|
94
|
+
description: ariaLabel,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (placeholder) {
|
|
98
|
+
return {
|
|
99
|
+
key,
|
|
100
|
+
expression: `page.getByPlaceholder(${JSON.stringify(placeholder)})`,
|
|
101
|
+
elementType: 'input',
|
|
102
|
+
confidence: 'high',
|
|
103
|
+
source: 'placeholder',
|
|
104
|
+
description: placeholder,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Priority 3: checkbox/radio → getByLabel
|
|
110
|
+
if ((role === 'checkbox' || role === 'radio') && ariaLabel) {
|
|
111
|
+
return {
|
|
112
|
+
key,
|
|
113
|
+
expression: `page.getByLabel(${JSON.stringify(ariaLabel)})`,
|
|
114
|
+
elementType: role,
|
|
115
|
+
confidence: 'high',
|
|
116
|
+
source: 'label',
|
|
117
|
+
description: ariaLabel,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Priority 4: button/link with meaningful name → getByRole
|
|
122
|
+
if ((role === 'button' || role === 'link') && meaning) {
|
|
123
|
+
return {
|
|
124
|
+
key,
|
|
125
|
+
expression: `page.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(meaning)} })`,
|
|
126
|
+
elementType: role,
|
|
127
|
+
confidence: 'high',
|
|
128
|
+
source: 'role-name',
|
|
129
|
+
description: meaning,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Priority 5: heading → getByRole heading
|
|
134
|
+
if (role === 'heading' && (meaning || text)) {
|
|
135
|
+
const name2 = meaning || text;
|
|
136
|
+
return {
|
|
137
|
+
key,
|
|
138
|
+
expression: `page.getByRole('heading', { name: ${JSON.stringify(name2)} })`,
|
|
139
|
+
elementType: 'heading',
|
|
140
|
+
confidence: 'medium',
|
|
141
|
+
source: 'heading',
|
|
142
|
+
description: name2,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Priority 6: existing CSS selector from crawler
|
|
147
|
+
if (element.selector && element.selector.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
key,
|
|
150
|
+
expression: `page.locator(${JSON.stringify(element.selector)})`,
|
|
151
|
+
elementType: role ?? 'element',
|
|
152
|
+
confidence: 'medium',
|
|
153
|
+
source: 'id',
|
|
154
|
+
description: meaning || text || element.selector,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Priority 7: id from attrs
|
|
159
|
+
if (id) {
|
|
160
|
+
return {
|
|
161
|
+
key,
|
|
162
|
+
expression: `page.locator(${JSON.stringify('#' + id)})`,
|
|
163
|
+
elementType: role ?? 'element',
|
|
164
|
+
confidence: 'medium',
|
|
165
|
+
source: 'id',
|
|
166
|
+
description: meaning || id,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Priority 8: fallback to text content
|
|
171
|
+
if (meaning || text) {
|
|
172
|
+
const t = meaning || text;
|
|
173
|
+
return {
|
|
174
|
+
key,
|
|
175
|
+
expression: `page.getByText(${JSON.stringify(t)}, { exact: false })`,
|
|
176
|
+
elementType: role ?? 'text',
|
|
177
|
+
confidence: 'low',
|
|
178
|
+
source: 'text',
|
|
179
|
+
description: t,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Convert an array of DB Element records to deduplicated Playwright locators.
|
|
188
|
+
* Deduplicates by key — higher confidence wins.
|
|
189
|
+
*/
|
|
190
|
+
export function elementsToPlaywrightLocators(elements: Array<{
|
|
191
|
+
role?: string | null;
|
|
192
|
+
meaning?: string | null;
|
|
193
|
+
text?: string | null;
|
|
194
|
+
notes?: unknown;
|
|
195
|
+
selector?: string | null;
|
|
196
|
+
isInteractive?: boolean | null;
|
|
197
|
+
}>): PlaywrightLocator[] {
|
|
198
|
+
const CONFIDENCE_RANK = { high: 0, medium: 1, low: 2 };
|
|
199
|
+
const byKey = new Map<string, PlaywrightLocator>();
|
|
200
|
+
|
|
201
|
+
for (const el of elements) {
|
|
202
|
+
const loc = elementToPlaywrightLocator(el);
|
|
203
|
+
if (!loc) continue;
|
|
204
|
+
const existing = byKey.get(loc.key);
|
|
205
|
+
if (!existing || CONFIDENCE_RANK[loc.confidence] < CONFIDENCE_RANK[existing.confidence]) {
|
|
206
|
+
byKey.set(loc.key, loc);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return Array.from(byKey.values());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Format locators as a block for injection into LLM script generation prompts.
|
|
215
|
+
* Returns empty string if no locators.
|
|
216
|
+
*/
|
|
217
|
+
export function formatLocatorsForPrompt(locators: PlaywrightLocator[]): string {
|
|
218
|
+
if (locators.length === 0) return '';
|
|
219
|
+
const lines = locators.map(l =>
|
|
220
|
+
` ${l.key}: ${l.expression} // ${l.description} [${l.confidence}]`
|
|
221
|
+
);
|
|
222
|
+
return [
|
|
223
|
+
'EXTRACTED PAGE LOCATORS (use these EXACTLY — do not invent other selectors; these were verified against the live page):',
|
|
224
|
+
...lines,
|
|
225
|
+
].join('\n');
|
|
226
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -35,3 +35,5 @@ export { startSpan, endSpan, withSpan, increment, flushMetrics } from './telemet
|
|
|
35
35
|
export type { Span, FinishedSpan } from './telemetry.js';
|
|
36
36
|
export { analyzeFailure } from './failure-analysis-agent.js';
|
|
37
37
|
export type { FailureContext } from './failure-analysis-agent.js';
|
|
38
|
+
export { elementToPlaywrightLocator, elementsToPlaywrightLocators, formatLocatorsForPrompt } from './locator-extractor.js';
|
|
39
|
+
export type { PlaywrightLocator } from './locator-extractor.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts raw DB Element records (role, meaning, notes JSON) into Playwright-idiomatic locator strings.
|
|
3
|
+
* Priority: data-testid → aria-label/placeholder (inputs) → role+name (buttons/links) → heading → text → id
|
|
4
|
+
*/
|
|
5
|
+
export interface PlaywrightLocator {
|
|
6
|
+
key: string;
|
|
7
|
+
expression: string;
|
|
8
|
+
elementType: string;
|
|
9
|
+
confidence: 'high' | 'medium' | 'low';
|
|
10
|
+
source: 'testid' | 'label' | 'placeholder' | 'role-name' | 'heading' | 'text' | 'id';
|
|
11
|
+
description: string;
|
|
12
|
+
}
|
|
13
|
+
/** Convert a single DB Element record to a Playwright locator. Returns null if insufficient data. */
|
|
14
|
+
export declare function elementToPlaywrightLocator(element: {
|
|
15
|
+
role?: string | null;
|
|
16
|
+
meaning?: string | null;
|
|
17
|
+
text?: string | null;
|
|
18
|
+
notes?: unknown;
|
|
19
|
+
selector?: string | null;
|
|
20
|
+
}): PlaywrightLocator | null;
|
|
21
|
+
/**
|
|
22
|
+
* Convert an array of DB Element records to deduplicated Playwright locators.
|
|
23
|
+
* Deduplicates by key — higher confidence wins.
|
|
24
|
+
*/
|
|
25
|
+
export declare function elementsToPlaywrightLocators(elements: Array<{
|
|
26
|
+
role?: string | null;
|
|
27
|
+
meaning?: string | null;
|
|
28
|
+
text?: string | null;
|
|
29
|
+
notes?: unknown;
|
|
30
|
+
selector?: string | null;
|
|
31
|
+
isInteractive?: boolean | null;
|
|
32
|
+
}>): PlaywrightLocator[];
|
|
33
|
+
/**
|
|
34
|
+
* Format locators as a block for injection into LLM script generation prompts.
|
|
35
|
+
* Returns empty string if no locators.
|
|
36
|
+
*/
|
|
37
|
+
export declare function formatLocatorsForPrompt(locators: PlaywrightLocator[]): string;
|