@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
package/dist/network-observer.js
CHANGED
|
@@ -1,149 +1,170 @@
|
|
|
1
|
-
import { redactJson } from
|
|
1
|
+
import { redactJson } from './policies/crawl-safety.js';
|
|
2
2
|
const API_PATTERN = /\/(api|graphql|gql|rest|v\d+)\//i;
|
|
3
3
|
const ASSET_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map|json\.map)(\?|$)/i;
|
|
4
4
|
function normalizeApiUrl(url) {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const u = new URL(url);
|
|
6
|
+
return u.pathname
|
|
7
|
+
.replace(/\/[a-f0-9-]{8,}(?=\/|$)/gi, '/{id}')
|
|
8
|
+
.replace(/\/c[a-z0-9]{20,}(?=\/|$)/g, '/{id}')
|
|
9
|
+
.replace(/\/\d+(?=\/|$)/g, '/{id}');
|
|
7
10
|
}
|
|
8
11
|
function inferShape(value) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.length > 0 ? [inferShape(value[0])] : [];
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [key, item] of Object.entries(value).slice(0, 40))
|
|
17
|
+
out[key] = inferShape(item);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
return typeof value;
|
|
16
21
|
}
|
|
22
|
+
/** P4: Extract page-URL candidates from a JSON response body string. */
|
|
17
23
|
function extractPageUrls(jsonStr) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return [...found];
|
|
29
|
-
}
|
|
30
|
-
function attachNetworkObserver(context, sink, onPageUrlDiscovered) {
|
|
31
|
-
const seen = /* @__PURE__ */ new Map();
|
|
32
|
-
const requestTimes = /* @__PURE__ */ new Map();
|
|
33
|
-
context.on("request", (request) => {
|
|
34
|
-
try {
|
|
35
|
-
const url = request.url();
|
|
36
|
-
const rtype = request.resourceType();
|
|
37
|
-
if (rtype !== "xhr" && rtype !== "fetch" || !API_PATTERN.test(url) || ASSET_EXT.test(url)) return;
|
|
38
|
-
const method = request.method();
|
|
39
|
-
const normalized = normalizeApiUrl(url);
|
|
40
|
-
const key = `${method}:${normalized}`;
|
|
41
|
-
requestTimes.set(url, Date.now());
|
|
42
|
-
if (!seen.has(key)) {
|
|
43
|
-
const row = { method, urlPattern: normalized, requestKind: rtype };
|
|
44
|
-
seen.set(key, row);
|
|
45
|
-
sink.push(row);
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
24
|
+
const PAGE_URL = /"(?:url|href|path|link|route|to|src|redirect)"\s*:\s*"(\/[\w\-\/\.~@%]+)"/g;
|
|
25
|
+
const ASSET = /\.(png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|css|js|map|json)$/i;
|
|
26
|
+
const API_PATH = /^\/(api|v\d+|graphql|gql|rest|rpc|static|_next|assets)\//i;
|
|
27
|
+
const found = new Set();
|
|
28
|
+
let m;
|
|
29
|
+
PAGE_URL.lastIndex = 0;
|
|
30
|
+
while ((m = PAGE_URL.exec(jsonStr)) !== null) {
|
|
31
|
+
const u = m[1];
|
|
32
|
+
if (u && u.length > 1 && u.length < 120 && !ASSET.test(u) && !API_PATH.test(u))
|
|
33
|
+
found.add(u);
|
|
48
34
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (onPageUrlDiscovered) {
|
|
73
|
-
for (const u of extractPageUrls(JSON.stringify(body))) onPageUrlDiscovered(u);
|
|
74
|
-
}
|
|
35
|
+
return [...found];
|
|
36
|
+
}
|
|
37
|
+
export function attachNetworkObserver(context, sink, onPageUrlDiscovered) {
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
const requestTimes = new Map();
|
|
40
|
+
context.on('request', (request) => {
|
|
41
|
+
try {
|
|
42
|
+
const url = request.url();
|
|
43
|
+
const rtype = request.resourceType();
|
|
44
|
+
if ((rtype !== 'xhr' && rtype !== 'fetch') || !API_PATTERN.test(url) || ASSET_EXT.test(url))
|
|
45
|
+
return;
|
|
46
|
+
const method = request.method();
|
|
47
|
+
const normalized = normalizeApiUrl(url);
|
|
48
|
+
const key = `${method}:${normalized}`;
|
|
49
|
+
requestTimes.set(url, Date.now());
|
|
50
|
+
if (!seen.has(key)) {
|
|
51
|
+
const row = { method, urlPattern: normalized, requestKind: rtype };
|
|
52
|
+
seen.set(key, row);
|
|
53
|
+
sink.push(row);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// non-fatal
|
|
75
58
|
}
|
|
76
|
-
|
|
77
|
-
|
|
59
|
+
});
|
|
60
|
+
context.on('response', async (response) => {
|
|
78
61
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
62
|
+
const request = response.request();
|
|
63
|
+
const url = request.url();
|
|
64
|
+
const rtype = request.resourceType();
|
|
65
|
+
if ((rtype !== 'xhr' && rtype !== 'fetch') || !API_PATTERN.test(url) || ASSET_EXT.test(url))
|
|
66
|
+
return;
|
|
67
|
+
const normalized = normalizeApiUrl(url);
|
|
68
|
+
const key = `${request.method()}:${normalized}`;
|
|
69
|
+
const durationMs = Date.now() - (requestTimes.get(url) ?? Date.now());
|
|
70
|
+
let row = seen.get(key);
|
|
71
|
+
if (!row) {
|
|
72
|
+
row = { method: request.method(), urlPattern: normalized, requestKind: rtype };
|
|
73
|
+
seen.set(key, row);
|
|
74
|
+
sink.push(row);
|
|
75
|
+
}
|
|
76
|
+
row.statusCode = response.status();
|
|
77
|
+
row.durationMs = durationMs;
|
|
78
|
+
const contentType = response.headers()['content-type'] ?? '';
|
|
79
|
+
if (contentType.includes('application/json')) {
|
|
80
|
+
const body = await response.json().catch(() => undefined);
|
|
81
|
+
if (body !== undefined) {
|
|
82
|
+
row.responseShape = redactJson(inferShape(body));
|
|
83
|
+
// P4: scan JSON body for page-URL candidates (non-API paths)
|
|
84
|
+
if (onPageUrlDiscovered) {
|
|
85
|
+
for (const u of extractPageUrls(JSON.stringify(body)))
|
|
86
|
+
onPageUrlDiscovered(u);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (response.status() >= 400) {
|
|
91
|
+
try {
|
|
92
|
+
const errText = await response.text();
|
|
93
|
+
row.errorBody = errText.slice(0, 300);
|
|
94
|
+
}
|
|
95
|
+
catch { /* non-fatal */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// non-fatal
|
|
82
100
|
}
|
|
83
|
-
}
|
|
84
|
-
} catch {
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
function attachHarCapture(cdpSession, pageTitle = "Page") {
|
|
89
|
-
const entries = [];
|
|
90
|
-
const pending = /* @__PURE__ */ new Map();
|
|
91
|
-
const pageId = `page_${String(Date.now())}`;
|
|
92
|
-
const pageStarted = (/* @__PURE__ */ new Date()).toISOString();
|
|
93
|
-
const onRequest = (evt) => {
|
|
94
|
-
const { requestId, request } = evt ?? {};
|
|
95
|
-
if (!requestId || !request) return;
|
|
96
|
-
const headers = {};
|
|
97
|
-
for (const h of request.headers ?? []) if (h.name) headers[String(h.name).toLowerCase()] = String(h.value ?? "");
|
|
98
|
-
pending.set(requestId, { method: request.method ?? "GET", url: request.url ?? "", headers, startMs: Date.now() });
|
|
99
|
-
};
|
|
100
|
-
const onResponse = (evt) => {
|
|
101
|
-
const { requestId, response } = evt ?? {};
|
|
102
|
-
if (!requestId || !response) return;
|
|
103
|
-
const req = pending.get(requestId);
|
|
104
|
-
if (!req) return;
|
|
105
|
-
pending.delete(requestId);
|
|
106
|
-
const durationMs = Date.now() - req.startMs;
|
|
107
|
-
const responseHeaders = {};
|
|
108
|
-
for (const h of response.headers ?? []) if (h.name) responseHeaders[String(h.name).toLowerCase()] = String(h.value ?? "");
|
|
109
|
-
entries.push({
|
|
110
|
-
startedAt: new Date(req.startMs).toISOString(),
|
|
111
|
-
method: req.method,
|
|
112
|
-
url: req.url,
|
|
113
|
-
status: response.status ?? 0,
|
|
114
|
-
statusText: response.statusText ?? "",
|
|
115
|
-
requestHeaders: req.headers,
|
|
116
|
-
responseHeaders,
|
|
117
|
-
contentType: responseHeaders["content-type"] ?? "",
|
|
118
|
-
bodySize: response.encodedDataLength ?? -1,
|
|
119
|
-
durationMs,
|
|
120
|
-
timings: { send: 0, wait: Math.round(durationMs * 0.9), receive: Math.round(durationMs * 0.1) }
|
|
121
101
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Full HAR capture via CDP — intercepts ALL network traffic, not just API endpoints.
|
|
105
|
+
* Returns a controller with getHar() and detach().
|
|
106
|
+
*/
|
|
107
|
+
export function attachHarCapture(cdpSession, pageTitle = 'Page') {
|
|
108
|
+
const entries = [];
|
|
109
|
+
const pending = new Map();
|
|
110
|
+
const pageId = `page_${String(Date.now())}`;
|
|
111
|
+
const pageStarted = new Date().toISOString();
|
|
112
|
+
const onRequest = (evt) => {
|
|
113
|
+
const { requestId, request } = evt ?? {};
|
|
114
|
+
if (!requestId || !request)
|
|
115
|
+
return;
|
|
116
|
+
const headers = {};
|
|
117
|
+
for (const h of (request.headers ?? []))
|
|
118
|
+
if (h.name)
|
|
119
|
+
headers[String(h.name).toLowerCase()] = String(h.value ?? '');
|
|
120
|
+
pending.set(requestId, { method: request.method ?? 'GET', url: request.url ?? '', headers, startMs: Date.now() });
|
|
121
|
+
};
|
|
122
|
+
const onResponse = (evt) => {
|
|
123
|
+
const { requestId, response } = evt ?? {};
|
|
124
|
+
if (!requestId || !response)
|
|
125
|
+
return;
|
|
126
|
+
const req = pending.get(requestId);
|
|
127
|
+
if (!req)
|
|
128
|
+
return;
|
|
129
|
+
pending.delete(requestId);
|
|
130
|
+
const durationMs = Date.now() - req.startMs;
|
|
131
|
+
const responseHeaders = {};
|
|
132
|
+
for (const h of (response.headers ?? []))
|
|
133
|
+
if (h.name)
|
|
134
|
+
responseHeaders[String(h.name).toLowerCase()] = String(h.value ?? '');
|
|
135
|
+
entries.push({
|
|
136
|
+
startedAt: new Date(req.startMs).toISOString(),
|
|
137
|
+
method: req.method,
|
|
138
|
+
url: req.url,
|
|
139
|
+
status: response.status ?? 0,
|
|
140
|
+
statusText: response.statusText ?? '',
|
|
141
|
+
requestHeaders: req.headers,
|
|
142
|
+
responseHeaders,
|
|
143
|
+
contentType: responseHeaders['content-type'] ?? '',
|
|
144
|
+
bodySize: response.encodedDataLength ?? -1,
|
|
145
|
+
durationMs,
|
|
146
|
+
timings: { send: 0, wait: Math.round(durationMs * 0.9), receive: Math.round(durationMs * 0.1) },
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
if (cdpSession?.on) {
|
|
150
|
+
cdpSession.on('Network.requestWillBeSent', onRequest);
|
|
151
|
+
cdpSession.on('Network.responseReceived', onResponse);
|
|
142
152
|
}
|
|
143
|
-
|
|
153
|
+
return {
|
|
154
|
+
getHar() {
|
|
155
|
+
return {
|
|
156
|
+
version: '1.2',
|
|
157
|
+
creator: { name: 'ZeTa-Crawler', version: '1.0' },
|
|
158
|
+
pages: [{ id: pageId, title: pageTitle, startedAt: pageStarted }],
|
|
159
|
+
entries: [...entries],
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
detach() {
|
|
163
|
+
if (cdpSession?.off) {
|
|
164
|
+
cdpSession.off('Network.requestWillBeSent', onRequest);
|
|
165
|
+
cdpSession.off('Network.responseReceived', onResponse);
|
|
166
|
+
}
|
|
167
|
+
pending.clear();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
144
170
|
}
|
|
145
|
-
export {
|
|
146
|
-
attachHarCapture,
|
|
147
|
-
attachNetworkObserver
|
|
148
|
-
};
|
|
149
|
-
//# sourceMappingURL=network-observer.js.map
|
package/dist/network-throttle.js
CHANGED
|
@@ -1,34 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network throttle simulation via CDP.
|
|
3
|
+
*
|
|
4
|
+
* Simulates different network conditions during crawl to test how the app
|
|
5
|
+
* behaves under slow connections. Useful for detecting loading states, error
|
|
6
|
+
* handling, and performance degradation on poor networks.
|
|
7
|
+
*
|
|
8
|
+
* Conditions mirror Chrome DevTools presets:
|
|
9
|
+
* - slow3g: ~500kbps download, high latency
|
|
10
|
+
* - fast3g: ~1.6Mbps download, medium latency
|
|
11
|
+
* - 4g: ~9Mbps download, low latency
|
|
12
|
+
* - offline: no connectivity
|
|
13
|
+
* - none: no throttle (default)
|
|
14
|
+
*/
|
|
1
15
|
const PRESETS = {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
16
|
+
none: { offline: false, downloadThroughput: -1, uploadThroughput: -1, latency: 0 },
|
|
17
|
+
slow3g: { offline: false, downloadThroughput: 500 * 1024 / 8, uploadThroughput: 500 * 1024 / 8, latency: 2_000 },
|
|
18
|
+
fast3g: { offline: false, downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8, latency: 562.5 },
|
|
19
|
+
'4g': { offline: false, downloadThroughput: 9 * 1024 * 1024 / 8, uploadThroughput: 2.67 * 1024 * 1024 / 8, latency: 85 },
|
|
20
|
+
offline: { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
|
|
7
21
|
};
|
|
8
|
-
async function applyNetworkThrottle(cdpSession, preset) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
export async function applyNetworkThrottle(cdpSession, preset) {
|
|
23
|
+
if (!cdpSession?.send)
|
|
24
|
+
return;
|
|
25
|
+
const condition = PRESETS[preset];
|
|
26
|
+
try {
|
|
27
|
+
await cdpSession.send('Network.enable');
|
|
28
|
+
await cdpSession.send('Network.emulateNetworkConditions', condition);
|
|
29
|
+
}
|
|
30
|
+
catch { /* non-fatal — CDP may not be available */ }
|
|
16
31
|
}
|
|
17
|
-
async function clearNetworkThrottle(cdpSession) {
|
|
18
|
-
|
|
32
|
+
export async function clearNetworkThrottle(cdpSession) {
|
|
33
|
+
await applyNetworkThrottle(cdpSession, 'none');
|
|
19
34
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
/** Apply throttle, run fn, restore. Returns fn's result. */
|
|
36
|
+
export async function withNetworkThrottle(cdpSession, preset, fn, restoreAfter = true) {
|
|
37
|
+
await applyNetworkThrottle(cdpSession, preset);
|
|
38
|
+
try {
|
|
39
|
+
return await fn();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (restoreAfter)
|
|
43
|
+
await clearNetworkThrottle(cdpSession).catch(() => { });
|
|
44
|
+
}
|
|
28
45
|
}
|
|
29
|
-
export {
|
|
30
|
-
applyNetworkThrottle,
|
|
31
|
-
clearNetworkThrottle,
|
|
32
|
-
withNetworkThrottle
|
|
33
|
-
};
|
|
34
|
-
//# sourceMappingURL=network-throttle.js.map
|
|
@@ -1,89 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 3.0 spec generator from observed network traffic.
|
|
3
|
+
*
|
|
4
|
+
* Takes the ApiEndpointObservationInput[] collected by the network observer
|
|
5
|
+
* during crawl and generates a minimal but valid OpenAPI 3.0 spec.
|
|
6
|
+
*
|
|
7
|
+
* The spec captures: endpoints, methods, observed response shapes, status codes.
|
|
8
|
+
* It does NOT capture request bodies (we'd need to intercept requests too) — that's
|
|
9
|
+
* marked as a TODO. But it's enough for generating smoke-test suites.
|
|
10
|
+
*/
|
|
1
11
|
function extractPathParams(urlPattern) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
const params = [];
|
|
13
|
+
const matches = urlPattern.matchAll(/\{(\w+)\}/g);
|
|
14
|
+
for (const m of matches) {
|
|
15
|
+
params.push({ name: m[1], in: 'path', required: true, schema: { type: 'string' } });
|
|
16
|
+
}
|
|
17
|
+
return params;
|
|
8
18
|
}
|
|
9
19
|
function methodSummary(method, path) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
const resource = path.split('/').filter(s => !s.startsWith('{') && s.length > 0).pop() ?? 'resource';
|
|
21
|
+
const summaries = {
|
|
22
|
+
GET: `Get ${resource}`,
|
|
23
|
+
POST: `Create ${resource}`,
|
|
24
|
+
PUT: `Update ${resource}`,
|
|
25
|
+
PATCH: `Partially update ${resource}`,
|
|
26
|
+
DELETE: `Delete ${resource}`,
|
|
27
|
+
};
|
|
28
|
+
return summaries[method.toUpperCase()] ?? `${method} ${resource}`;
|
|
19
29
|
}
|
|
20
30
|
function inferTag(path) {
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
const parts = path.split('/').filter(s => s.length > 0 && !s.startsWith('{'));
|
|
32
|
+
return parts[0] ?? 'default';
|
|
23
33
|
}
|
|
24
|
-
function generateOpenApiSpec(observations, opts = {}) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
for (const obs of observations) {
|
|
33
|
-
const path = obs.urlPattern;
|
|
34
|
-
const method = obs.method.toLowerCase();
|
|
35
|
-
if (!spec.paths[path]) spec.paths[path] = {};
|
|
36
|
-
const params = extractPathParams(path);
|
|
37
|
-
const statusCode = obs.statusCode ?? 200;
|
|
38
|
-
const responses = {
|
|
39
|
-
[String(statusCode)]: {
|
|
40
|
-
description: statusCode < 400 ? "Successful response" : "Error response",
|
|
41
|
-
content: obs.responseShape ? { "application/json": { schema: shapeToJsonSchema(obs.responseShape) } } : void 0
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
const op = {
|
|
45
|
-
summary: methodSummary(obs.method, path),
|
|
46
|
-
parameters: params.length > 0 ? params : void 0,
|
|
47
|
-
responses,
|
|
48
|
-
tags: [inferTag(path)]
|
|
34
|
+
export function generateOpenApiSpec(observations, opts = {}) {
|
|
35
|
+
const { title = 'ZeTa Discovered API', baseUrl = '/', version = '1.0.0' } = opts;
|
|
36
|
+
const spec = {
|
|
37
|
+
openapi: '3.0.3',
|
|
38
|
+
info: { title, version, description: 'Auto-generated by ZeTa crawler from observed network traffic.' },
|
|
39
|
+
servers: [{ url: baseUrl }],
|
|
40
|
+
paths: {},
|
|
49
41
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
for (const obs of observations) {
|
|
43
|
+
const path = obs.urlPattern;
|
|
44
|
+
const method = obs.method.toLowerCase();
|
|
45
|
+
if (!spec.paths[path])
|
|
46
|
+
spec.paths[path] = {};
|
|
47
|
+
const params = extractPathParams(path);
|
|
48
|
+
const statusCode = obs.statusCode ?? 200;
|
|
49
|
+
const responses = {
|
|
50
|
+
[String(statusCode)]: {
|
|
51
|
+
description: statusCode < 400 ? 'Successful response' : 'Error response',
|
|
52
|
+
content: obs.responseShape
|
|
53
|
+
? { 'application/json': { schema: shapeToJsonSchema(obs.responseShape) } }
|
|
54
|
+
: undefined,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const op = {
|
|
58
|
+
summary: methodSummary(obs.method, path),
|
|
59
|
+
parameters: params.length > 0 ? params : undefined,
|
|
60
|
+
responses,
|
|
61
|
+
tags: [inferTag(path)],
|
|
62
|
+
};
|
|
63
|
+
spec.paths[path][method] = op;
|
|
64
|
+
}
|
|
65
|
+
return spec;
|
|
53
66
|
}
|
|
54
67
|
function shapeToJsonSchema(shape) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
if (Array.isArray(shape))
|
|
69
|
+
return { type: 'array', items: shape.length > 0 ? shapeToJsonSchema(shape[0]) : {} };
|
|
70
|
+
if (shape && typeof shape === 'object') {
|
|
71
|
+
const props = {};
|
|
72
|
+
for (const [k, v] of Object.entries(shape))
|
|
73
|
+
props[k] = shapeToJsonSchema(v);
|
|
74
|
+
return { type: 'object', properties: props };
|
|
75
|
+
}
|
|
76
|
+
return { type: String(shape) };
|
|
62
77
|
}
|
|
63
|
-
function specToYaml(spec) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
export function specToYaml(spec) {
|
|
79
|
+
// Minimal YAML serializer — avoids yaml dep
|
|
80
|
+
function toYaml(val, indent = 0) {
|
|
81
|
+
const pad = ' '.repeat(indent);
|
|
82
|
+
if (val === null || val === undefined)
|
|
83
|
+
return 'null';
|
|
84
|
+
if (typeof val === 'string')
|
|
85
|
+
return val.includes('\n') || val.includes(':') ? `"${val.replace(/"/g, '\\"')}"` : val;
|
|
86
|
+
if (typeof val === 'number' || typeof val === 'boolean')
|
|
87
|
+
return String(val);
|
|
88
|
+
if (Array.isArray(val))
|
|
89
|
+
return val.map(v => `\n${pad}- ${toYaml(v, indent + 1).trimStart()}`).join('') || '[]';
|
|
90
|
+
if (typeof val === 'object') {
|
|
91
|
+
const entries = Object.entries(val);
|
|
92
|
+
if (entries.length === 0)
|
|
93
|
+
return '{}';
|
|
94
|
+
return entries.map(([k, v]) => {
|
|
95
|
+
const vStr = toYaml(v, indent + 1);
|
|
96
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
97
|
+
? `\n${pad}${k}:${vStr}`
|
|
98
|
+
: `\n${pad}${k}: ${vStr.trimStart()}`;
|
|
99
|
+
}).join('');
|
|
100
|
+
}
|
|
101
|
+
return String(val);
|
|
80
102
|
}
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
return `openapi: "${spec.openapi}"${toYaml(spec, 0)}`;
|
|
103
|
+
return `openapi: "${spec.openapi}"${toYaml(spec, 0)}`;
|
|
84
104
|
}
|
|
85
|
-
export {
|
|
86
|
-
generateOpenApiSpec,
|
|
87
|
-
specToYaml
|
|
88
|
-
};
|
|
89
|
-
//# sourceMappingURL=openapi-generator.js.map
|