@browserbridge/bbx 1.7.7 → 1.8.0
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/PRIVACY.md +111 -0
- package/README.md +13 -5
- package/RESPONSIBLE_USE.md +16 -0
- package/SECURITY.md +52 -0
- package/package.json +12 -6
- package/packages/agent-client/src/cli-args.js +24 -0
- package/packages/agent-client/src/cli.js +46 -52
- package/packages/agent-client/src/client.js +140 -5
- package/packages/agent-client/src/command-registry.js +56 -53
- package/packages/agent-client/src/content-extract.js +198 -0
- package/packages/agent-client/src/remotes.js +1 -1
- package/packages/agent-client/src/runtime.js +768 -35
- package/packages/agent-client/src/types.ts +131 -6
- package/packages/mcp-server/src/guidance.js +1 -1
- package/packages/mcp-server/src/handlers-capture.js +41 -5
- package/packages/mcp-server/src/handlers-dom.js +8 -2
- package/packages/mcp-server/src/handlers-navigation.js +2 -1
- package/packages/mcp-server/src/handlers-page.js +41 -4
- package/packages/mcp-server/src/handlers-utils.js +67 -7
- package/packages/mcp-server/src/handlers.js +4 -4
- package/packages/mcp-server/src/server.js +102 -6
- package/packages/native-host/src/daemon.js +158 -6
- package/packages/native-host/src/native-host.js +3 -1
- package/packages/protocol/src/defaults.js +3 -0
- package/packages/protocol/src/errors.js +54 -1
- package/packages/protocol/src/protocol.js +297 -1
- package/packages/protocol/src/registry.js +66 -35
- package/packages/protocol/src/summary.js +87 -5
- package/packages/protocol/src/types.ts +234 -0
- package/skills/browser-bridge/SKILL.md +34 -13
- package/skills/browser-bridge/references/interaction.md +55 -11
- package/skills/browser-bridge/references/patch-workflow.md +2 -0
- package/skills/browser-bridge/references/protocol.md +157 -82
- package/skills/browser-bridge/references/token-efficiency.md +19 -13
|
@@ -6,12 +6,16 @@ import net from 'node:net';
|
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
createFailure,
|
|
9
10
|
createRequest,
|
|
11
|
+
createSuccess,
|
|
10
12
|
DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
13
|
+
ERROR_CODES,
|
|
11
14
|
getProtocolVersion,
|
|
12
15
|
parseJsonLines,
|
|
13
16
|
setProtocolPackageVersion,
|
|
14
17
|
} from '../../protocol/src/index.js';
|
|
18
|
+
import { extractContentFromHtml } from './content-extract.js';
|
|
15
19
|
import {
|
|
16
20
|
createSocketBridgeTransport,
|
|
17
21
|
getBridgeTransport,
|
|
@@ -112,6 +116,7 @@ export class BridgeClient extends EventEmitter {
|
|
|
112
116
|
clientId = `agent_${randomUUID()}`,
|
|
113
117
|
defaultTimeoutMs = DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
|
|
114
118
|
autoReconnect = false,
|
|
119
|
+
checkProtocolOnConnect = true,
|
|
115
120
|
restartDaemonOnVersionMismatch = shouldAutoRestartDaemonOnVersionMismatch(),
|
|
116
121
|
restartDaemonFn = restartBridgeDaemon,
|
|
117
122
|
authToken = undefined,
|
|
@@ -123,6 +128,7 @@ export class BridgeClient extends EventEmitter {
|
|
|
123
128
|
this.clientId = clientId;
|
|
124
129
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
125
130
|
this.autoReconnect = autoReconnect;
|
|
131
|
+
this.checkProtocolOnConnect = checkProtocolOnConnect;
|
|
126
132
|
this.restartDaemonOnVersionMismatch = restartDaemonOnVersionMismatch;
|
|
127
133
|
this.restartDaemonFn = restartDaemonFn;
|
|
128
134
|
this.authToken = authToken;
|
|
@@ -246,6 +252,9 @@ export class BridgeClient extends EventEmitter {
|
|
|
246
252
|
|
|
247
253
|
this.protocolCompatibility = null;
|
|
248
254
|
this.protocolWarning = null;
|
|
255
|
+
if (!this.checkProtocolOnConnect) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
249
258
|
|
|
250
259
|
/** @type {ProtocolHealthResult | null} */
|
|
251
260
|
let healthResult = null;
|
|
@@ -312,6 +321,113 @@ export class BridgeClient extends EventEmitter {
|
|
|
312
321
|
meta,
|
|
313
322
|
});
|
|
314
323
|
|
|
324
|
+
if (method === 'page.extract_content') {
|
|
325
|
+
const params =
|
|
326
|
+
/** @type {import('../../protocol/src/types.js').NormalizedExtractContentParams} */ (
|
|
327
|
+
request.params
|
|
328
|
+
);
|
|
329
|
+
const titleResponse = params.includeMetadata
|
|
330
|
+
? await this.request({ method: 'page.get_state', tabId, meta, timeoutMs })
|
|
331
|
+
: null;
|
|
332
|
+
if (titleResponse && !titleResponse.ok) {
|
|
333
|
+
return {
|
|
334
|
+
...titleResponse,
|
|
335
|
+
id: request.id,
|
|
336
|
+
meta: { ...titleResponse.meta, method },
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const titleResult =
|
|
340
|
+
titleResponse?.ok && titleResponse.result && typeof titleResponse.result === 'object'
|
|
341
|
+
? /** @type {Record<string, unknown>} */ (titleResponse.result)
|
|
342
|
+
: {};
|
|
343
|
+
const readSnapshot = () =>
|
|
344
|
+
this.request({
|
|
345
|
+
method: 'dom.get_html',
|
|
346
|
+
tabId,
|
|
347
|
+
params: {
|
|
348
|
+
target: { selector: params.selector ?? 'body' },
|
|
349
|
+
outer: true,
|
|
350
|
+
maxLength: 50_000,
|
|
351
|
+
},
|
|
352
|
+
meta: { ...meta, token_budget: null },
|
|
353
|
+
timeoutMs,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
let snapshotResponse = await readSnapshot();
|
|
357
|
+
if (!snapshotResponse.ok) {
|
|
358
|
+
return {
|
|
359
|
+
...snapshotResponse,
|
|
360
|
+
id: request.id,
|
|
361
|
+
meta: { ...snapshotResponse.meta, method },
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
let snapshot = normalizeHtmlSnapshot(snapshotResponse.result);
|
|
365
|
+
if (snapshot.truncated) {
|
|
366
|
+
return createFailure(
|
|
367
|
+
request.id,
|
|
368
|
+
ERROR_CODES.RESULT_TRUNCATED,
|
|
369
|
+
'The bounded HTML snapshot was too large for semantic extraction.',
|
|
370
|
+
{
|
|
371
|
+
selector: params.selector,
|
|
372
|
+
omitted: snapshot.omitted,
|
|
373
|
+
maxLength: 50_000,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
method,
|
|
377
|
+
continuation_hint: 'Retry with a narrower selector.',
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let settlement;
|
|
383
|
+
if (params.consistency === 'settled') {
|
|
384
|
+
const deadline = Date.now() + params.settleTimeoutMs;
|
|
385
|
+
let timedOut = false;
|
|
386
|
+
while (true) {
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
388
|
+
const nextResponse = await readSnapshot();
|
|
389
|
+
if (!nextResponse.ok) {
|
|
390
|
+
return {
|
|
391
|
+
...nextResponse,
|
|
392
|
+
id: request.id,
|
|
393
|
+
meta: { ...nextResponse.meta, method },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const nextSnapshot = normalizeHtmlSnapshot(nextResponse.result);
|
|
397
|
+
if (nextSnapshot.truncated) {
|
|
398
|
+
return createFailure(
|
|
399
|
+
request.id,
|
|
400
|
+
ERROR_CODES.RESULT_TRUNCATED,
|
|
401
|
+
'The bounded HTML snapshot was too large for semantic extraction.',
|
|
402
|
+
{ selector: params.selector, omitted: nextSnapshot.omitted, maxLength: 50_000 },
|
|
403
|
+
{ method, continuation_hint: 'Retry with a narrower selector.' }
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (nextSnapshot.html === snapshot.html) {
|
|
407
|
+
snapshot = nextSnapshot;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
snapshot = nextSnapshot;
|
|
411
|
+
if (Date.now() >= deadline) {
|
|
412
|
+
timedOut = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
settlement = { requested: true, quietMs: 100, timedOut };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const result = extractContentFromHtml(snapshot.html, params, {
|
|
420
|
+
title: typeof titleResult.title === 'string' ? titleResult.title : undefined,
|
|
421
|
+
settlement,
|
|
422
|
+
});
|
|
423
|
+
return this.attachProtocolWarning(
|
|
424
|
+
createSuccess(request.id, result, {
|
|
425
|
+
method,
|
|
426
|
+
node_processed: true,
|
|
427
|
+
})
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
315
431
|
const responsePromise = new Promise((resolve, reject) => {
|
|
316
432
|
const timeoutId = setTimeout(() => {
|
|
317
433
|
this.waiting.delete(request.id);
|
|
@@ -419,10 +535,13 @@ export class BridgeClient extends EventEmitter {
|
|
|
419
535
|
? healthResult.supported_versions
|
|
420
536
|
: [];
|
|
421
537
|
const latestRemote = remoteVersions[0];
|
|
538
|
+
const extensionVersions = Array.isArray(healthResult?.extension_supported_versions)
|
|
539
|
+
? healthResult.extension_supported_versions
|
|
540
|
+
: healthResult?.supported_versions;
|
|
422
541
|
if (
|
|
423
542
|
healthResult?.extensionConnected === true &&
|
|
424
|
-
Array.isArray(
|
|
425
|
-
!
|
|
543
|
+
Array.isArray(extensionVersions) &&
|
|
544
|
+
!extensionVersions.includes(getProtocolVersion())
|
|
426
545
|
) {
|
|
427
546
|
return false;
|
|
428
547
|
}
|
|
@@ -466,9 +585,11 @@ export class BridgeClient extends EventEmitter {
|
|
|
466
585
|
* @returns {{ compatible: boolean, localVersion: string, remoteVersions: string[], warning?: string }}
|
|
467
586
|
*/
|
|
468
587
|
static checkProtocolVersion(healthResult) {
|
|
469
|
-
const remoteVersions = Array.isArray(healthResult?.
|
|
470
|
-
? healthResult.
|
|
471
|
-
:
|
|
588
|
+
const remoteVersions = Array.isArray(healthResult?.extension_supported_versions)
|
|
589
|
+
? healthResult.extension_supported_versions
|
|
590
|
+
: Array.isArray(healthResult?.supported_versions)
|
|
591
|
+
? healthResult.supported_versions
|
|
592
|
+
: [];
|
|
472
593
|
if (remoteVersions.length === 0) {
|
|
473
594
|
return {
|
|
474
595
|
compatible: true,
|
|
@@ -520,3 +641,17 @@ export class BridgeClient extends EventEmitter {
|
|
|
520
641
|
};
|
|
521
642
|
}
|
|
522
643
|
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @param {unknown} result
|
|
647
|
+
* @returns {{ html: string, truncated: boolean, omitted: number }}
|
|
648
|
+
*/
|
|
649
|
+
function normalizeHtmlSnapshot(result) {
|
|
650
|
+
const value =
|
|
651
|
+
result && typeof result === 'object' ? /** @type {Record<string, unknown>} */ (result) : {};
|
|
652
|
+
return {
|
|
653
|
+
html: typeof value.html === 'string' ? value.html : '',
|
|
654
|
+
truncated: value.truncated === true,
|
|
655
|
+
omitted: typeof value.omitted === 'number' ? value.omitted : 0,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
@@ -30,103 +30,106 @@ function createShortcutCommand(method, usage, build, options = {}) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Preserve explicit refs and pass selectors directly to methods that support
|
|
35
|
+
* atomic target resolution.
|
|
36
|
+
*
|
|
37
|
+
* @param {string | undefined} value
|
|
38
|
+
* @returns {{ elementRef: string } | { selector: string }}
|
|
39
|
+
*/
|
|
40
|
+
function createAtomicTarget(value) {
|
|
41
|
+
if (!value) throw new Error('A ref or selector is required.');
|
|
42
|
+
return value.startsWith('el_') ? { elementRef: value } : { selector: value };
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
/** @type {Record<string, ShortcutCommand>} */
|
|
34
46
|
export const SHORTCUT_COMMANDS = {
|
|
35
|
-
'access-request': createShortcutCommand('access.request', 'bbx access-request', () =>
|
|
47
|
+
'access-request': createShortcutCommand('access.request', 'bbx access-request [intent]', (r) =>
|
|
48
|
+
r[0] ? { intent: r[0] } : {}
|
|
49
|
+
),
|
|
36
50
|
'dom-query': createShortcutCommand('dom.query', 'bbx dom-query [selector]', (r) => ({
|
|
37
51
|
selector: r[0] || 'body',
|
|
38
52
|
})),
|
|
39
53
|
describe: createShortcutCommand(
|
|
40
54
|
'dom.describe',
|
|
41
55
|
'bbx describe <ref|selector>',
|
|
42
|
-
(
|
|
43
|
-
{
|
|
56
|
+
(r) => ({ target: createAtomicTarget(r[0]) }),
|
|
57
|
+
{ printMethod: 'dom.describe' }
|
|
44
58
|
),
|
|
45
59
|
text: createShortcutCommand(
|
|
46
60
|
'dom.get_text',
|
|
47
61
|
'bbx text <ref|selector> [budget]',
|
|
48
|
-
(r
|
|
49
|
-
|
|
62
|
+
(r) => ({
|
|
63
|
+
target: createAtomicTarget(r[0]),
|
|
50
64
|
textBudget: r[1] ? parseIntArg(r[1], 'budget') : undefined,
|
|
51
65
|
}),
|
|
52
|
-
{
|
|
66
|
+
{ printMethod: 'dom.get_text' }
|
|
53
67
|
),
|
|
54
68
|
styles: createShortcutCommand(
|
|
55
69
|
'styles.get_computed',
|
|
56
70
|
'bbx styles <ref|selector> [props]',
|
|
57
|
-
(r
|
|
58
|
-
{
|
|
71
|
+
(r) => ({ target: createAtomicTarget(r[0]), properties: parseCommaList(r[1]) }),
|
|
72
|
+
{ printMethod: 'styles.get_computed' }
|
|
59
73
|
),
|
|
60
74
|
box: createShortcutCommand(
|
|
61
75
|
'layout.get_box_model',
|
|
62
76
|
'bbx box <ref|selector>',
|
|
63
|
-
(
|
|
64
|
-
{
|
|
65
|
-
),
|
|
66
|
-
click: createShortcutCommand(
|
|
67
|
-
'input.click',
|
|
68
|
-
'bbx click <ref|selector> [button]',
|
|
69
|
-
(r, ref) => ({ target: { elementRef: ref }, button: r[1] }),
|
|
70
|
-
{ resolve: true }
|
|
71
|
-
),
|
|
72
|
-
focus: createShortcutCommand(
|
|
73
|
-
'input.focus',
|
|
74
|
-
'bbx focus <ref|selector>',
|
|
75
|
-
(_r, ref) => ({ target: { elementRef: ref } }),
|
|
76
|
-
{ resolve: true }
|
|
77
|
-
),
|
|
78
|
-
type: createShortcutCommand(
|
|
79
|
-
'input.type',
|
|
80
|
-
'bbx type <ref|selector> <text...>',
|
|
81
|
-
(r, ref) => ({ target: { elementRef: ref }, text: r.slice(1).join(' ') }),
|
|
82
|
-
{ resolve: true }
|
|
77
|
+
(r) => ({ target: createAtomicTarget(r[0]) }),
|
|
78
|
+
{ printMethod: 'layout.get_box_model' }
|
|
83
79
|
),
|
|
80
|
+
click: createShortcutCommand('input.click', 'bbx click <ref|selector> [button]', (r) => ({
|
|
81
|
+
target: createAtomicTarget(r[0]),
|
|
82
|
+
button: r[1],
|
|
83
|
+
})),
|
|
84
|
+
focus: createShortcutCommand('input.focus', 'bbx focus <ref|selector>', (r) => ({
|
|
85
|
+
target: createAtomicTarget(r[0]),
|
|
86
|
+
})),
|
|
87
|
+
type: createShortcutCommand('input.type', 'bbx type <ref|selector> <text...>', (r) => ({
|
|
88
|
+
target: createAtomicTarget(r[0]),
|
|
89
|
+
text: r.slice(1).join(' '),
|
|
90
|
+
})),
|
|
84
91
|
fill: createShortcutCommand(
|
|
85
92
|
'input.fill',
|
|
86
93
|
'bbx fill <ref|selector> <value>',
|
|
87
|
-
(r
|
|
88
|
-
target:
|
|
94
|
+
(r) => ({
|
|
95
|
+
target: createAtomicTarget(r[0]),
|
|
89
96
|
value: r.slice(1).join(' '),
|
|
90
97
|
mode: 'auto',
|
|
91
98
|
}),
|
|
92
99
|
{
|
|
93
|
-
resolve: true,
|
|
94
100
|
description: 'Set input/textarea value (React/Vue/Angular-safe, auto fallback to keystrokes)',
|
|
95
101
|
}
|
|
96
102
|
),
|
|
97
|
-
hover: createShortcutCommand(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
(_r, ref) => ({ target: { elementRef: ref } }),
|
|
101
|
-
{ resolve: true }
|
|
102
|
-
),
|
|
103
|
+
hover: createShortcutCommand('input.hover', 'bbx hover <ref|selector>', (r) => ({
|
|
104
|
+
target: createAtomicTarget(r[0]),
|
|
105
|
+
})),
|
|
103
106
|
html: createShortcutCommand(
|
|
104
107
|
'dom.get_html',
|
|
105
108
|
'bbx html <ref|selector> [maxLen]',
|
|
106
|
-
(r
|
|
107
|
-
|
|
109
|
+
(r) => ({
|
|
110
|
+
target: createAtomicTarget(r[0]),
|
|
108
111
|
maxLength: r[1] ? parseIntArg(r[1], 'maxLen') : undefined,
|
|
109
112
|
}),
|
|
110
|
-
{
|
|
113
|
+
{}
|
|
111
114
|
),
|
|
112
115
|
'patch-style': createShortcutCommand(
|
|
113
116
|
'patch.apply_styles',
|
|
114
117
|
'bbx patch-style <ref|sel> prop=val',
|
|
115
|
-
(r
|
|
116
|
-
target:
|
|
118
|
+
(r) => ({
|
|
119
|
+
target: createAtomicTarget(r[0]),
|
|
117
120
|
declarations: parsePropertyAssignments(r.slice(1)),
|
|
118
121
|
}),
|
|
119
|
-
{
|
|
122
|
+
{}
|
|
120
123
|
),
|
|
121
124
|
'patch-text': createShortcutCommand(
|
|
122
125
|
'patch.apply_dom',
|
|
123
126
|
'bbx patch-text <ref|sel> <text...>',
|
|
124
|
-
(r
|
|
125
|
-
target:
|
|
127
|
+
(r) => ({
|
|
128
|
+
target: createAtomicTarget(r[0]),
|
|
126
129
|
operation: 'set_text',
|
|
127
130
|
value: r.slice(1).join(' '),
|
|
128
131
|
}),
|
|
129
|
-
{
|
|
132
|
+
{ description: 'Apply a reversible DOM text patch' }
|
|
130
133
|
),
|
|
131
134
|
patches: createShortcutCommand('patch.list', 'bbx patches', () => ({}), {
|
|
132
135
|
printMethod: 'patch.list',
|
|
@@ -222,14 +225,14 @@ export const SHORTCUT_COMMANDS = {
|
|
|
222
225
|
attrs: createShortcutCommand(
|
|
223
226
|
'dom.get_attributes',
|
|
224
227
|
'bbx attrs <ref|selector> [attr1,...]',
|
|
225
|
-
(r
|
|
226
|
-
{
|
|
228
|
+
(r) => ({ target: createAtomicTarget(r[0]), attributes: parseCommaList(r[1]) }),
|
|
229
|
+
{ printMethod: 'dom.get_attributes' }
|
|
227
230
|
),
|
|
228
231
|
'matched-rules': createShortcutCommand(
|
|
229
232
|
'styles.get_matched_rules',
|
|
230
233
|
'bbx matched-rules <ref|selector>',
|
|
231
|
-
(
|
|
232
|
-
{
|
|
234
|
+
(r) => ({ target: createAtomicTarget(r[0]) }),
|
|
235
|
+
{ printMethod: 'styles.get_matched_rules' }
|
|
233
236
|
),
|
|
234
237
|
};
|
|
235
238
|
|
|
@@ -260,7 +263,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
260
263
|
'bbx install-skill [targets|all] [--global] [--project <path>] Install/update the managed Browser Bridge CLI skill',
|
|
261
264
|
'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
|
|
262
265
|
'bbx status Check bridge connection',
|
|
263
|
-
'bbx doctor
|
|
266
|
+
'bbx doctor Consolidated local runtime, setup, protocol, and readiness diagnostics',
|
|
264
267
|
'bbx restart Restart the local daemon and running MCP servers',
|
|
265
268
|
'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
|
|
266
269
|
' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
|
|
@@ -270,7 +273,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
270
273
|
'bbx remote list List remote destinations',
|
|
271
274
|
'bbx remote test <name> Ping a remote destination',
|
|
272
275
|
'bbx remote remove <name> Remove a remote destination',
|
|
273
|
-
'bbx access-request
|
|
276
|
+
'bbx access-request [intent] Request access with inspect|interact|capture|navigate|debugger|general intent',
|
|
274
277
|
'bbx logs Recent bridge logs',
|
|
275
278
|
'bbx tabs List available tabs',
|
|
276
279
|
'bbx tab-create [url] Create a new tab',
|
|
@@ -372,7 +375,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
|
|
|
372
375
|
{
|
|
373
376
|
title: 'Capture',
|
|
374
377
|
lines: [
|
|
375
|
-
'bbx screenshot [--tab <tabId>] <ref|selector> [path]
|
|
378
|
+
'bbx screenshot [--tab <tabId>] [--format png|jpeg|webp] [--quality 0-100] <ref|selector> [path]',
|
|
376
379
|
],
|
|
377
380
|
},
|
|
378
381
|
]);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { Readability } from '@mozilla/readability';
|
|
4
|
+
import { parseHTML } from 'linkedom';
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../protocol/src/types.js').ExtractContentResult} ExtractContentResult */
|
|
7
|
+
/** @typedef {import('../../protocol/src/types.js').NormalizedExtractContentParams} ExtractContentParams */
|
|
8
|
+
|
|
9
|
+
const METADATA_LIMITS = Object.freeze({ title: 300, byline: 200, excerpt: 500, siteName: 200 });
|
|
10
|
+
const EXCLUDED_SELECTOR =
|
|
11
|
+
'script,style,noscript,template,input,textarea,select,option,button,[hidden],[aria-hidden="true"]';
|
|
12
|
+
|
|
13
|
+
/** @param {string | null | undefined} value @param {number} limit @returns {string | undefined} */
|
|
14
|
+
function boundedText(value, limit) {
|
|
15
|
+
const normalized = String(value ?? '')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
return normalized ? normalized.slice(0, limit) : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {ParentNode} root @returns {void} */
|
|
22
|
+
function sanitize(root) {
|
|
23
|
+
for (const element of root.querySelectorAll(EXCLUDED_SELECTOR)) element.remove();
|
|
24
|
+
for (const element of root.querySelectorAll('[style]')) {
|
|
25
|
+
const style = element.getAttribute('style')?.toLowerCase() ?? '';
|
|
26
|
+
if (/display\s*:\s*none|visibility\s*:\s*hidden/.test(style)) element.remove();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {Element} element @returns {number} */
|
|
31
|
+
function meaningfulLength(element) {
|
|
32
|
+
return (element.textContent ?? '').replace(/\s+/g, ' ').trim().length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {ParentNode} root @returns {Element | null} */
|
|
36
|
+
function largestSemanticRoot(root) {
|
|
37
|
+
let best = null;
|
|
38
|
+
let bestLength = 0;
|
|
39
|
+
const candidates = [];
|
|
40
|
+
if (
|
|
41
|
+
'matches' in root &&
|
|
42
|
+
typeof (/** @type {{ matches?: unknown }} */ (root).matches) === 'function' &&
|
|
43
|
+
/** @type {Element} */ (root).matches('article,main,[role="main"]')
|
|
44
|
+
) {
|
|
45
|
+
candidates.push(/** @type {Element} */ (root));
|
|
46
|
+
}
|
|
47
|
+
candidates.push(...root.querySelectorAll('article,main,[role="main"]'));
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
const length = meaningfulLength(candidate);
|
|
50
|
+
if (length > bestLength) {
|
|
51
|
+
best = candidate;
|
|
52
|
+
bestLength = length;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return best;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {Node} node @returns {string} */
|
|
59
|
+
function markdownForNode(node) {
|
|
60
|
+
if (node.nodeType === 3) return (node.textContent ?? '').replace(/\s+/g, ' ');
|
|
61
|
+
if (node.nodeType !== 1) return '';
|
|
62
|
+
const element = /** @type {Element} */ (node);
|
|
63
|
+
const tag = element.tagName.toLowerCase();
|
|
64
|
+
const children = () => Array.from(element.childNodes, markdownForNode).join('');
|
|
65
|
+
if (/^h[1-6]$/.test(tag)) return `\n${'#'.repeat(Number(tag[1]))} ${children().trim()}\n\n`;
|
|
66
|
+
if (['p', 'div', 'section', 'article', 'main'].includes(tag)) {
|
|
67
|
+
return `\n${children().trim()}\n\n`;
|
|
68
|
+
}
|
|
69
|
+
if (tag === 'br') return '\n';
|
|
70
|
+
if (tag === 'hr') return '\n---\n';
|
|
71
|
+
if (tag === 'strong' || tag === 'b') return `**${children().trim()}**`;
|
|
72
|
+
if (tag === 'em' || tag === 'i') return `*${children().trim()}*`;
|
|
73
|
+
if (tag === 'code' && element.parentElement?.tagName.toLowerCase() !== 'pre') {
|
|
74
|
+
return `\`${(element.textContent ?? '').replace(/`/g, '\\`')}\``;
|
|
75
|
+
}
|
|
76
|
+
if (tag === 'pre') return `\n\`\`\`\n${element.textContent ?? ''}\n\`\`\`\n\n`;
|
|
77
|
+
if (tag === 'blockquote') {
|
|
78
|
+
return `\n${children()
|
|
79
|
+
.trim()
|
|
80
|
+
.split('\n')
|
|
81
|
+
.map((line) => `> ${line}`)
|
|
82
|
+
.join('\n')}\n\n`;
|
|
83
|
+
}
|
|
84
|
+
if (tag === 'li') {
|
|
85
|
+
const marker =
|
|
86
|
+
element.parentElement?.tagName.toLowerCase() === 'ol'
|
|
87
|
+
? `${Array.from(element.parentElement.children).indexOf(element) + 1}.`
|
|
88
|
+
: '-';
|
|
89
|
+
return `\n${marker} ${children().trim()}`;
|
|
90
|
+
}
|
|
91
|
+
if (tag === 'ul' || tag === 'ol') return `${children()}\n\n`;
|
|
92
|
+
if (tag === 'a') {
|
|
93
|
+
const label = children().trim();
|
|
94
|
+
const href = element.getAttribute('href')?.trim();
|
|
95
|
+
return href && !/^(?:javascript|data):/i.test(href) ? `[${label}](${href})` : label;
|
|
96
|
+
}
|
|
97
|
+
return children();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {Element} root @param {'text' | 'markdown'} format @returns {string} */
|
|
101
|
+
function formatRoot(root, format) {
|
|
102
|
+
if (format === 'markdown') {
|
|
103
|
+
return markdownForNode(root)
|
|
104
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
105
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
|
108
|
+
return (root.textContent ?? '')
|
|
109
|
+
.replace(/[ \t]+/g, ' ')
|
|
110
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
111
|
+
.trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {string} content @param {number} budget @param {'text' | 'markdown'} format */
|
|
115
|
+
function truncateContent(content, budget, format) {
|
|
116
|
+
if (content.length <= budget) return { content, truncated: false, omitted: 0 };
|
|
117
|
+
const boundary = Math.max(content.lastIndexOf('\n\n', budget), content.lastIndexOf(' ', budget));
|
|
118
|
+
const end = boundary >= Math.floor(budget * 0.7) ? boundary : budget;
|
|
119
|
+
let value = content.slice(0, end).trimEnd();
|
|
120
|
+
if (format === 'markdown' && (value.match(/```/g)?.length ?? 0) % 2 === 1) {
|
|
121
|
+
value = `${value.slice(0, Math.max(0, budget - 4)).trimEnd()}\n\`\`\``;
|
|
122
|
+
}
|
|
123
|
+
return { content: value, truncated: true, omitted: content.length - value.length };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Convert one bounded browser HTML snapshot into semantic content in Node.
|
|
128
|
+
* Raw HTML is consumed here and never included in the returned result.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} html
|
|
131
|
+
* @param {ExtractContentParams} params
|
|
132
|
+
* @param {{ title?: string, settlement?: ExtractContentResult['settlement'] }} [context]
|
|
133
|
+
* @returns {ExtractContentResult}
|
|
134
|
+
*/
|
|
135
|
+
export function extractContentFromHtml(html, params, context = {}) {
|
|
136
|
+
const escapedTitle = String(context.title ?? '').replace(/[&<>]/g, (character) => {
|
|
137
|
+
if (character === '&') return '&';
|
|
138
|
+
if (character === '<') return '<';
|
|
139
|
+
return '>';
|
|
140
|
+
});
|
|
141
|
+
const { document } = parseHTML(
|
|
142
|
+
`<!doctype html><html><head><title>${escapedTitle}</title></head><body>${html}</body></html>`
|
|
143
|
+
);
|
|
144
|
+
sanitize(document);
|
|
145
|
+
const semanticRoot = largestSemanticRoot(document.body);
|
|
146
|
+
const semanticLength = semanticRoot ? meaningfulLength(semanticRoot) : 0;
|
|
147
|
+
let readability = null;
|
|
148
|
+
try {
|
|
149
|
+
readability = new Readability(/** @type {Document} */ (document.cloneNode(true))).parse();
|
|
150
|
+
} catch {
|
|
151
|
+
readability = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @type {Element} */
|
|
155
|
+
let resultRoot;
|
|
156
|
+
/** @type {import('../../protocol/src/types.js').ExtractContentSource} */
|
|
157
|
+
let source;
|
|
158
|
+
if (readability && (readability.length ?? 0) >= Math.max(200, Math.floor(semanticLength * 0.2))) {
|
|
159
|
+
const template = document.createElement('template');
|
|
160
|
+
template.innerHTML = readability.content ?? '';
|
|
161
|
+
sanitize(template.content);
|
|
162
|
+
const wrapper = document.createElement('article');
|
|
163
|
+
wrapper.append(template.content.cloneNode(true));
|
|
164
|
+
resultRoot = wrapper;
|
|
165
|
+
source = 'readability';
|
|
166
|
+
} else if (semanticRoot && semanticLength > 0) {
|
|
167
|
+
resultRoot = semanticRoot;
|
|
168
|
+
source = 'semantic-root';
|
|
169
|
+
} else {
|
|
170
|
+
resultRoot = document.body;
|
|
171
|
+
source = 'body';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const formatted = formatRoot(resultRoot, params.format);
|
|
175
|
+
const bounded = truncateContent(formatted, params.textBudget, params.format);
|
|
176
|
+
const metadata = params.includeMetadata
|
|
177
|
+
? {
|
|
178
|
+
title: boundedText(readability?.title ?? context.title, METADATA_LIMITS.title),
|
|
179
|
+
byline: boundedText(readability?.byline, METADATA_LIMITS.byline),
|
|
180
|
+
excerpt: boundedText(readability?.excerpt, METADATA_LIMITS.excerpt),
|
|
181
|
+
siteName: boundedText(readability?.siteName, METADATA_LIMITS.siteName),
|
|
182
|
+
}
|
|
183
|
+
: {};
|
|
184
|
+
return {
|
|
185
|
+
format: params.format,
|
|
186
|
+
content: bounded.content,
|
|
187
|
+
...metadata,
|
|
188
|
+
source,
|
|
189
|
+
root: {
|
|
190
|
+
tag: resultRoot.tagName.toLowerCase(),
|
|
191
|
+
...(params.selector ? { selector: params.selector } : {}),
|
|
192
|
+
},
|
|
193
|
+
length: formatted.length,
|
|
194
|
+
truncated: bounded.truncated,
|
|
195
|
+
omitted: bounded.omitted,
|
|
196
|
+
...(context.settlement ? { settlement: context.settlement } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -287,7 +287,7 @@ function validateAuthToken(token) {
|
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
289
|
* @param {string | null | undefined} destinationId
|
|
290
|
-
* @param {{ defaultTimeoutMs?: number }} [options={}]
|
|
290
|
+
* @param {{ defaultTimeoutMs?: number, checkProtocolOnConnect?: boolean }} [options={}]
|
|
291
291
|
* @returns {Promise<BridgeClient>}
|
|
292
292
|
*/
|
|
293
293
|
export async function createBridgeClientForDestination(destinationId, options = {}) {
|