@myagentroam/node 0.11.60 → 0.11.62
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/connector.js +11 -2
- package/dist/hosting/hosted-conversation-node-service.js +2 -0
- package/dist/runner/mar-agent/agent-network-extension.js +7 -0
- package/dist/runner/mar-agent/computer-use.js +2 -2
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +12 -1
- package/dist/runner/mar-agent/managed-run-controller.js +4 -1
- package/dist/runner/mar-agent/web-tools-extensions.js +11 -5
- package/dist/runner/mar-agent-runner.js +13 -2
- package/dist/service/web-fetch-contracts.js +32 -7
- package/dist/service/web-fetch-service.js +266 -126
- package/dist/service/web-markdown.js +16 -0
- package/dist/service/web-snapshots.js +188 -0
- package/dist/service/web-tool-service.js +132 -15
- package/package.json +7 -4
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { request as httpRequest } from 'node:http';
|
|
2
|
-
import { request as httpsRequest } from 'node:https';
|
|
3
|
-
import { TLSSocket } from 'node:tls';
|
|
4
|
-
import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
|
|
5
|
-
import { pipeline } from 'node:stream/promises';
|
|
6
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
2
|
import { AgentError } from '@myagentroam/agent';
|
|
8
3
|
import { chromium, errors } from 'playwright';
|
|
9
|
-
import {
|
|
4
|
+
import { convertWebDocument } from './web-markdown.js';
|
|
5
|
+
import { WEB_FETCH_MAX_BYTES, browserFetchRequestSchema } from './web-fetch-contracts.js';
|
|
10
6
|
const TIMEOUT_MS = 30_000;
|
|
11
7
|
const MAX_REDIRECTS = 5;
|
|
12
|
-
const MAX_BROWSER_REQUESTS =
|
|
8
|
+
const MAX_BROWSER_REQUESTS = 4;
|
|
9
|
+
const BROWSER_IDLE_MS = 30 * 60_000;
|
|
10
|
+
const BROWSER_MAX_AGE_MS = 60 * 60_000;
|
|
13
11
|
export class WebFetchError extends AgentError {
|
|
14
12
|
code;
|
|
15
13
|
status;
|
|
@@ -29,6 +27,12 @@ export class WebFetchService {
|
|
|
29
27
|
#active = new Set();
|
|
30
28
|
#allowUntrustedTls;
|
|
31
29
|
#launchBrowser;
|
|
30
|
+
#pendingContexts = new Set();
|
|
31
|
+
#browser;
|
|
32
|
+
#browserStartedAt;
|
|
33
|
+
#launching;
|
|
34
|
+
#browserClosing;
|
|
35
|
+
#idle;
|
|
32
36
|
#browserRequests = 0;
|
|
33
37
|
#closing;
|
|
34
38
|
constructor(options = {}) {
|
|
@@ -37,7 +41,7 @@ export class WebFetchService {
|
|
|
37
41
|
this.#launchBrowser = options.launchBrowser ?? chromium.launch.bind(chromium);
|
|
38
42
|
}
|
|
39
43
|
fetch(input, signal, policy = {}) {
|
|
40
|
-
const parsed =
|
|
44
|
+
const parsed = browserFetchRequestSchema.safeParse(input);
|
|
41
45
|
if (!parsed.success)
|
|
42
46
|
return Promise.reject(new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Web fetch input is invalid.'));
|
|
43
47
|
const timeout = AbortSignal.timeout(TIMEOUT_MS);
|
|
@@ -49,7 +53,7 @@ export class WebFetchService {
|
|
|
49
53
|
throw new WebFetchError('MAR_AGENT_WEB_FETCH_CANCELLED', 503, 'Web fetch was cancelled.');
|
|
50
54
|
if (error instanceof WebFetchError)
|
|
51
55
|
throw error;
|
|
52
|
-
throw upstreamFailure(error,
|
|
56
|
+
throw upstreamFailure(error, 'browser', 'browser');
|
|
53
57
|
});
|
|
54
58
|
this.#active.add(task);
|
|
55
59
|
void task.then(() => this.#active.delete(task), () => this.#active.delete(task));
|
|
@@ -60,119 +64,136 @@ export class WebFetchService {
|
|
|
60
64
|
}
|
|
61
65
|
async #close() {
|
|
62
66
|
this.#shutdown.abort();
|
|
67
|
+
if (this.#idle)
|
|
68
|
+
clearTimeout(this.#idle);
|
|
63
69
|
await Promise.allSettled([...this.#active]);
|
|
70
|
+
await this.#launching?.catch(() => undefined);
|
|
71
|
+
await Promise.allSettled([...this.#pendingContexts]);
|
|
72
|
+
await this.#browserClosing;
|
|
73
|
+
await this.#browser?.close().catch(() => undefined);
|
|
74
|
+
this.#browser = undefined;
|
|
64
75
|
}
|
|
65
|
-
|
|
66
|
-
signal.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
#scheduleIdle() {
|
|
77
|
+
if (this.#shutdown.signal.aborted || this.#browserRequests || !this.#browser || this.#idle)
|
|
78
|
+
return;
|
|
79
|
+
if (this.#expired()) {
|
|
80
|
+
this.#retireBrowser();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.#idle = setTimeout(() => {
|
|
84
|
+
this.#idle = undefined;
|
|
85
|
+
if (this.#browserRequests || !this.#browser)
|
|
86
|
+
return;
|
|
87
|
+
this.#retireBrowser();
|
|
88
|
+
}, BROWSER_IDLE_MS);
|
|
89
|
+
this.#idle.unref();
|
|
70
90
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let truncated = false;
|
|
112
|
-
try {
|
|
113
|
-
for await (const chunk of body) {
|
|
114
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
115
|
-
const remaining = WEB_FETCH_MAX_BYTES - bytes;
|
|
116
|
-
chunks.push(buffer.subarray(0, remaining));
|
|
117
|
-
bytes += Math.min(remaining, buffer.byteLength);
|
|
118
|
-
if (buffer.byteLength > remaining) {
|
|
119
|
-
truncated = true;
|
|
120
|
-
break;
|
|
91
|
+
#expired() {
|
|
92
|
+
return (this.#browserStartedAt !== undefined &&
|
|
93
|
+
Date.now() - this.#browserStartedAt >= BROWSER_MAX_AGE_MS);
|
|
94
|
+
}
|
|
95
|
+
#retireBrowser() {
|
|
96
|
+
const browser = this.#browser;
|
|
97
|
+
if (!browser)
|
|
98
|
+
return;
|
|
99
|
+
this.#browser = undefined;
|
|
100
|
+
this.#browserStartedAt = undefined;
|
|
101
|
+
const closing = browser
|
|
102
|
+
.close()
|
|
103
|
+
.catch(() => undefined)
|
|
104
|
+
.finally(() => {
|
|
105
|
+
if (this.#browserClosing === closing)
|
|
106
|
+
this.#browserClosing = undefined;
|
|
107
|
+
});
|
|
108
|
+
this.#browserClosing = closing;
|
|
109
|
+
}
|
|
110
|
+
#getBrowser() {
|
|
111
|
+
if (this.#browserClosing)
|
|
112
|
+
return this.#browserClosing.then(() => {
|
|
113
|
+
this.#shutdown.signal.throwIfAborted();
|
|
114
|
+
return this.#getBrowser();
|
|
115
|
+
});
|
|
116
|
+
if (this.#browser?.isConnected())
|
|
117
|
+
return Promise.resolve(this.#browser);
|
|
118
|
+
if (!this.#launching) {
|
|
119
|
+
const launching = this.#launchBrowser({
|
|
120
|
+
headless: true,
|
|
121
|
+
channel: 'chromium',
|
|
122
|
+
timeout: 15_000
|
|
123
|
+
})
|
|
124
|
+
.then((browser) => {
|
|
125
|
+
this.#browser = browser;
|
|
126
|
+
this.#browserStartedAt = Date.now();
|
|
127
|
+
browser.on('disconnected', () => {
|
|
128
|
+
if (this.#browser === browser) {
|
|
129
|
+
this.#browser = undefined;
|
|
130
|
+
this.#browserStartedAt = undefined;
|
|
121
131
|
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
if (!truncated && transferError)
|
|
130
|
-
throw transferError;
|
|
131
|
-
signal.throwIfAborted();
|
|
132
|
-
const text = new TextDecoder().decode(Buffer.concat(chunks), { stream: truncated });
|
|
133
|
-
const output = Buffer.from(text);
|
|
134
|
-
truncated ||= output.byteLength > WEB_FETCH_MAX_BYTES;
|
|
135
|
-
const content = new TextDecoder().decode(output.subarray(0, WEB_FETCH_MAX_BYTES), {
|
|
136
|
-
stream: truncated
|
|
132
|
+
});
|
|
133
|
+
return browser;
|
|
134
|
+
})
|
|
135
|
+
.finally(() => {
|
|
136
|
+
this.#launching = undefined;
|
|
137
|
+
this.#scheduleIdle();
|
|
137
138
|
});
|
|
138
|
-
|
|
139
|
+
this.#launching = launching;
|
|
139
140
|
}
|
|
140
|
-
|
|
141
|
+
return this.#launching;
|
|
142
|
+
}
|
|
143
|
+
async #execute(input, signal, allowUntrustedTls) {
|
|
144
|
+
signal.throwIfAborted();
|
|
145
|
+
return this.#fetchBrowser(input, signal, allowUntrustedTls);
|
|
141
146
|
}
|
|
142
147
|
async #fetchBrowser(input, signal, allowUntrustedTls) {
|
|
148
|
+
if (this.#expired()) {
|
|
149
|
+
if (this.#browserRequests > 0)
|
|
150
|
+
throw new WebFetchError('MAR_AGENT_WEB_FETCH_BUSY', 429, 'Node web fetch browser capacity is exhausted.');
|
|
151
|
+
if (this.#idle)
|
|
152
|
+
clearTimeout(this.#idle);
|
|
153
|
+
this.#idle = undefined;
|
|
154
|
+
this.#retireBrowser();
|
|
155
|
+
}
|
|
143
156
|
if (this.#browserRequests >= MAX_BROWSER_REQUESTS)
|
|
144
157
|
throw new WebFetchError('MAR_AGENT_WEB_FETCH_BUSY', 429, 'Node web fetch browser capacity is exhausted.');
|
|
145
158
|
this.#browserRequests++;
|
|
159
|
+
if (this.#idle)
|
|
160
|
+
clearTimeout(this.#idle);
|
|
161
|
+
this.#idle = undefined;
|
|
146
162
|
let browser;
|
|
147
163
|
let context;
|
|
148
164
|
let navigationFailure;
|
|
149
165
|
const cancel = () => {
|
|
150
|
-
void
|
|
166
|
+
void context?.close().catch(() => undefined);
|
|
151
167
|
};
|
|
152
168
|
signal.addEventListener('abort', cancel, { once: true });
|
|
153
169
|
try {
|
|
154
170
|
try {
|
|
155
|
-
browser = await this.#
|
|
156
|
-
headless: true,
|
|
157
|
-
channel: 'chromium',
|
|
158
|
-
timeout: 15_000
|
|
159
|
-
});
|
|
171
|
+
browser = await abortable(this.#getBrowser(), signal);
|
|
160
172
|
}
|
|
161
173
|
catch {
|
|
162
174
|
throw new WebFetchError('MAR_AGENT_WEB_FETCH_BROWSER_UNAVAILABLE', 503, 'Node Chromium could not start. Prepare the matching runtime with the Node browser installation script under the service account. On Linux, check Chromium system libraries; an administrator may need to install them.');
|
|
163
175
|
}
|
|
164
176
|
signal.throwIfAborted();
|
|
165
|
-
|
|
177
|
+
const creatingContext = browser.newContext({
|
|
166
178
|
acceptDownloads: false,
|
|
167
179
|
ignoreHTTPSErrors: allowUntrustedTls,
|
|
168
180
|
serviceWorkers: 'block'
|
|
169
181
|
});
|
|
182
|
+
const cleanup = creatingContext
|
|
183
|
+
.then(async (created) => {
|
|
184
|
+
if (signal.aborted)
|
|
185
|
+
await created.close().catch(() => undefined);
|
|
186
|
+
}, () => undefined)
|
|
187
|
+
.finally(() => this.#pendingContexts.delete(cleanup));
|
|
188
|
+
this.#pendingContexts.add(cleanup);
|
|
189
|
+
context = await abortable(creatingContext, signal);
|
|
190
|
+
signal.throwIfAborted();
|
|
170
191
|
let blockedNavigation = false;
|
|
171
192
|
let resourceRequests = 0;
|
|
172
193
|
await context.route('**/*', async (route) => {
|
|
173
194
|
const request = route.request();
|
|
174
195
|
resourceRequests++;
|
|
175
|
-
const valid =
|
|
196
|
+
const valid = browserFetchRequestSchema.safeParse({
|
|
176
197
|
url: request.url(),
|
|
177
198
|
mode: 'browser'
|
|
178
199
|
}).success;
|
|
@@ -199,8 +220,28 @@ export class WebFetchService {
|
|
|
199
220
|
const mainFrameId = frameTree.frame.id;
|
|
200
221
|
let navigationRequests = 0;
|
|
201
222
|
let interceptedRequests = 0;
|
|
223
|
+
let mainResponse;
|
|
224
|
+
cdp.on('Network.responseReceived', (event) => {
|
|
225
|
+
if (event.type === 'Document' && event.frameId === mainFrameId) {
|
|
226
|
+
mainResponse = {
|
|
227
|
+
requestId: event.requestId,
|
|
228
|
+
url: event.response.url,
|
|
229
|
+
status: event.response.status,
|
|
230
|
+
mimeType: event.response.mimeType
|
|
231
|
+
};
|
|
232
|
+
if (!navigationFailure && !readableContentType(mainResponse.mimeType)) {
|
|
233
|
+
try {
|
|
234
|
+
validateResponse(mainResponse.status, mainResponse.mimeType);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (error instanceof WebFetchError)
|
|
238
|
+
navigationFailure = error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
202
243
|
cdp.on('Fetch.requestPaused', (event) => {
|
|
203
|
-
const valid =
|
|
244
|
+
const valid = browserFetchRequestSchema.safeParse({
|
|
204
245
|
url: event.request.url,
|
|
205
246
|
mode: 'browser'
|
|
206
247
|
}).success;
|
|
@@ -225,6 +266,7 @@ export class WebFetchService {
|
|
|
225
266
|
}
|
|
226
267
|
});
|
|
227
268
|
});
|
|
269
|
+
await cdp.send('Network.enable');
|
|
228
270
|
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
229
271
|
let navigationResponse;
|
|
230
272
|
page.on('response', (response) => {
|
|
@@ -239,14 +281,27 @@ export class WebFetchService {
|
|
|
239
281
|
page.on('dialog', (dialog) => {
|
|
240
282
|
void dialog.dismiss().catch(() => undefined);
|
|
241
283
|
});
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
284
|
+
let response;
|
|
285
|
+
try {
|
|
286
|
+
response = await page.goto(input.url, {
|
|
287
|
+
waitUntil: 'domcontentloaded',
|
|
288
|
+
timeout: 25_000
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (navigationFailure)
|
|
293
|
+
throw navigationFailure;
|
|
294
|
+
if (!signal.aborted && mainResponse && !readableContentType(mainResponse.mimeType))
|
|
295
|
+
validateResponse(mainResponse.status, mainResponse.mimeType);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
246
298
|
if (!response)
|
|
247
299
|
throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Page navigation returned no response.');
|
|
248
300
|
navigationResponse ??= response;
|
|
249
|
-
|
|
301
|
+
const initialContentType = (await response.allHeaders())['content-type'] ?? 'text/html';
|
|
302
|
+
validateResponse(response.status(), initialContentType);
|
|
303
|
+
if (!/^text\/html\b/iu.test(initialContentType))
|
|
304
|
+
return await readTextResponse(response, initialContentType, mainResponse, cdp, signal);
|
|
250
305
|
// Wait for visible text to settle, not for long-lived network connections to become idle.
|
|
251
306
|
const start = Date.now();
|
|
252
307
|
let previous = '';
|
|
@@ -289,14 +344,78 @@ export class WebFetchService {
|
|
|
289
344
|
throw navigationFailure;
|
|
290
345
|
const finalUrl = page.url();
|
|
291
346
|
if (blockedNavigation ||
|
|
292
|
-
!
|
|
347
|
+
!browserFetchRequestSchema.safeParse({ url: finalUrl, mode: 'browser' }).success)
|
|
293
348
|
throw new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Page navigation URL is not supported.');
|
|
294
|
-
if (!snapshot
|
|
349
|
+
if (!snapshot || snapshot.url !== finalUrl)
|
|
295
350
|
throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
|
|
296
351
|
const contentType = ((await navigationResponse.allHeaders())['content-type'] ?? 'text/html').slice(0, 256);
|
|
297
352
|
validateResponse(navigationResponse.status(), contentType);
|
|
353
|
+
const html = /^text\/html\b/iu.test(contentType);
|
|
354
|
+
if (!html)
|
|
355
|
+
return await readTextResponse(navigationResponse, contentType, mainResponse, cdp, signal);
|
|
356
|
+
if (!snapshot.content)
|
|
357
|
+
throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
|
|
358
|
+
const source = html
|
|
359
|
+
? await page.evaluate((maxBytes) => {
|
|
360
|
+
const original = document.body;
|
|
361
|
+
if (!original)
|
|
362
|
+
return { content: '', truncated: false };
|
|
363
|
+
const clone = original.cloneNode(true);
|
|
364
|
+
const originals = [original, ...original.querySelectorAll('*')];
|
|
365
|
+
const copies = [clone, ...clone.querySelectorAll('*')];
|
|
366
|
+
for (let i = originals.length - 1; i >= 0; i--) {
|
|
367
|
+
const node = originals[i];
|
|
368
|
+
const copy = copies[i];
|
|
369
|
+
const style = getComputedStyle(node);
|
|
370
|
+
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(node.tagName) ||
|
|
371
|
+
style.display === 'none') {
|
|
372
|
+
if (i === 0)
|
|
373
|
+
clone.replaceChildren();
|
|
374
|
+
else
|
|
375
|
+
copy.remove();
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
|
|
379
|
+
// Descendants can override visibility; only this node's text is hidden.
|
|
380
|
+
for (const child of [...copy.childNodes]) {
|
|
381
|
+
if (child.nodeType === Node.TEXT_NODE)
|
|
382
|
+
child.remove();
|
|
383
|
+
}
|
|
384
|
+
if (copy.children.length === 0) {
|
|
385
|
+
if (i === 0)
|
|
386
|
+
clone.replaceChildren();
|
|
387
|
+
else
|
|
388
|
+
copy.remove();
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const attribute of ['href', 'src']) {
|
|
393
|
+
const value = copy.getAttribute(attribute);
|
|
394
|
+
if (value) {
|
|
395
|
+
try {
|
|
396
|
+
copy.setAttribute(attribute, new URL(value, document.baseURI).href);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* Keep original. */
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const bytes = new TextEncoder().encode(clone.innerHTML);
|
|
405
|
+
return {
|
|
406
|
+
content: new TextDecoder().decode(bytes.subarray(0, maxBytes), {
|
|
407
|
+
stream: bytes.length > maxBytes
|
|
408
|
+
}),
|
|
409
|
+
truncated: bytes.length > maxBytes
|
|
410
|
+
};
|
|
411
|
+
}, WEB_FETCH_MAX_BYTES)
|
|
412
|
+
: { content: snapshot.content, truncated: snapshot.truncated };
|
|
413
|
+
signal.throwIfAborted();
|
|
414
|
+
const converted = convertWebDocument(source.content, html);
|
|
415
|
+
if (!converted.content)
|
|
416
|
+
throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
|
|
298
417
|
return {
|
|
299
|
-
content:
|
|
418
|
+
content: converted.content,
|
|
300
419
|
data: {
|
|
301
420
|
url: finalUrl,
|
|
302
421
|
mode: 'browser',
|
|
@@ -304,7 +423,7 @@ export class WebFetchService {
|
|
|
304
423
|
contentType,
|
|
305
424
|
title: snapshot.title
|
|
306
425
|
},
|
|
307
|
-
truncated:
|
|
426
|
+
truncated: source.truncated || converted.truncated
|
|
308
427
|
};
|
|
309
428
|
}
|
|
310
429
|
catch (error) {
|
|
@@ -315,11 +434,58 @@ export class WebFetchService {
|
|
|
315
434
|
finally {
|
|
316
435
|
signal.removeEventListener('abort', cancel);
|
|
317
436
|
await context?.close().catch(() => undefined);
|
|
318
|
-
await browser?.close().catch(() => undefined);
|
|
319
437
|
this.#browserRequests--;
|
|
438
|
+
this.#scheduleIdle();
|
|
320
439
|
}
|
|
321
440
|
}
|
|
322
441
|
}
|
|
442
|
+
function abortable(promise, signal) {
|
|
443
|
+
if (signal.aborted)
|
|
444
|
+
return Promise.reject(signal.reason);
|
|
445
|
+
return new Promise((resolve, reject) => {
|
|
446
|
+
const abort = () => reject(signal.reason);
|
|
447
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
448
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async function readTextResponse(response, contentType, navigation, cdp, signal) {
|
|
452
|
+
const failure = await abortable(response.finished(), signal);
|
|
453
|
+
if (failure)
|
|
454
|
+
throw failure;
|
|
455
|
+
if (!navigation || navigation.url !== response.url())
|
|
456
|
+
throw new Error('BROWSER_RESPONSE_UNAVAILABLE');
|
|
457
|
+
// Response.body() can re-request an empty response; use this navigation only.
|
|
458
|
+
const body = await abortable(cdp.send('Network.getResponseBody', { requestId: navigation.requestId }), signal);
|
|
459
|
+
const bytes = Buffer.from(body.body, body.base64Encoded ? 'base64' : 'utf8');
|
|
460
|
+
signal.throwIfAborted();
|
|
461
|
+
const charset = body.base64Encoded
|
|
462
|
+
? (/charset\s*=\s*["']?([^\s;"']+)/iu.exec(contentType)?.[1] ?? 'utf-8')
|
|
463
|
+
: 'utf-8';
|
|
464
|
+
let decoder;
|
|
465
|
+
try {
|
|
466
|
+
decoder = new TextDecoder(charset);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
decoder = new TextDecoder('utf-8');
|
|
470
|
+
}
|
|
471
|
+
const sourceTruncated = bytes.length > WEB_FETCH_MAX_BYTES;
|
|
472
|
+
const text = decoder.decode(bytes.subarray(0, WEB_FETCH_MAX_BYTES), {
|
|
473
|
+
stream: sourceTruncated
|
|
474
|
+
});
|
|
475
|
+
const converted = convertWebDocument(text, false);
|
|
476
|
+
if (!converted.content)
|
|
477
|
+
throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
|
|
478
|
+
return {
|
|
479
|
+
content: converted.content,
|
|
480
|
+
data: {
|
|
481
|
+
url: response.url(),
|
|
482
|
+
mode: 'browser',
|
|
483
|
+
status: response.status(),
|
|
484
|
+
contentType: contentType.slice(0, 256)
|
|
485
|
+
},
|
|
486
|
+
truncated: sourceTruncated || converted.truncated
|
|
487
|
+
};
|
|
488
|
+
}
|
|
323
489
|
function readableContentType(contentType) {
|
|
324
490
|
return /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml)(?:[;\s]|$))/iu.test(contentType);
|
|
325
491
|
}
|
|
@@ -344,29 +510,3 @@ function validateResponse(status, contentType) {
|
|
|
344
510
|
if (!readableContentType(contentType))
|
|
345
511
|
throw new WebFetchError('MAR_AGENT_WEB_FETCH_UNSUPPORTED_CONTENT', 415, 'Response is not readable text.');
|
|
346
512
|
}
|
|
347
|
-
function requestHttp(url, signal, allowUntrustedTls) {
|
|
348
|
-
return new Promise((resolve, reject) => {
|
|
349
|
-
let phase = 'request';
|
|
350
|
-
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
|
|
351
|
-
signal,
|
|
352
|
-
headers: {
|
|
353
|
-
accept: 'text/html,application/json,text/plain;q=0.9,*/*;q=0.1',
|
|
354
|
-
'accept-encoding': 'identity',
|
|
355
|
-
'user-agent': 'MyAgentRoam/0.9'
|
|
356
|
-
},
|
|
357
|
-
...(url.protocol === 'https:' ? { rejectUnauthorized: !allowUntrustedTls } : {})
|
|
358
|
-
}, resolve);
|
|
359
|
-
request.once('socket', (socket) => {
|
|
360
|
-
if (socket instanceof TLSSocket && !request.reusedSocket) {
|
|
361
|
-
socket.once('connect', () => {
|
|
362
|
-
phase = 'tls';
|
|
363
|
-
});
|
|
364
|
-
socket.once('secureConnect', () => {
|
|
365
|
-
phase = 'request';
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
request.once('error', (error) => reject(upstreamFailure(error, 'http', phase)));
|
|
370
|
-
request.end();
|
|
371
|
-
});
|
|
372
|
-
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import TurndownService from 'turndown';
|
|
2
|
+
import { gfm } from 'turndown-plugin-gfm';
|
|
3
|
+
import { WEB_FETCH_MAX_BYTES } from './web-fetch-contracts.js';
|
|
4
|
+
const converter = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
|
5
|
+
converter.use(gfm);
|
|
6
|
+
export function convertWebDocument(content, html) {
|
|
7
|
+
const text = html ? converter.turndown(content) : content;
|
|
8
|
+
const bytes = Buffer.from(text);
|
|
9
|
+
const truncated = bytes.length > WEB_FETCH_MAX_BYTES;
|
|
10
|
+
return {
|
|
11
|
+
content: new TextDecoder().decode(bytes.subarray(0, WEB_FETCH_MAX_BYTES), {
|
|
12
|
+
stream: truncated
|
|
13
|
+
}),
|
|
14
|
+
truncated
|
|
15
|
+
};
|
|
16
|
+
}
|