@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.
Files changed (34) hide show
  1. package/PRIVACY.md +111 -0
  2. package/README.md +13 -5
  3. package/RESPONSIBLE_USE.md +16 -0
  4. package/SECURITY.md +52 -0
  5. package/package.json +12 -6
  6. package/packages/agent-client/src/cli-args.js +24 -0
  7. package/packages/agent-client/src/cli.js +46 -52
  8. package/packages/agent-client/src/client.js +140 -5
  9. package/packages/agent-client/src/command-registry.js +56 -53
  10. package/packages/agent-client/src/content-extract.js +198 -0
  11. package/packages/agent-client/src/remotes.js +1 -1
  12. package/packages/agent-client/src/runtime.js +768 -35
  13. package/packages/agent-client/src/types.ts +131 -6
  14. package/packages/mcp-server/src/guidance.js +1 -1
  15. package/packages/mcp-server/src/handlers-capture.js +41 -5
  16. package/packages/mcp-server/src/handlers-dom.js +8 -2
  17. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  18. package/packages/mcp-server/src/handlers-page.js +41 -4
  19. package/packages/mcp-server/src/handlers-utils.js +67 -7
  20. package/packages/mcp-server/src/handlers.js +4 -4
  21. package/packages/mcp-server/src/server.js +102 -6
  22. package/packages/native-host/src/daemon.js +158 -6
  23. package/packages/native-host/src/native-host.js +3 -1
  24. package/packages/protocol/src/defaults.js +3 -0
  25. package/packages/protocol/src/errors.js +54 -1
  26. package/packages/protocol/src/protocol.js +297 -1
  27. package/packages/protocol/src/registry.js +66 -35
  28. package/packages/protocol/src/summary.js +87 -5
  29. package/packages/protocol/src/types.ts +234 -0
  30. package/skills/browser-bridge/SKILL.md +34 -13
  31. package/skills/browser-bridge/references/interaction.md +55 -11
  32. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  33. package/skills/browser-bridge/references/protocol.md +157 -82
  34. 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(healthResult.supported_versions) &&
425
- !healthResult.supported_versions.includes(getProtocolVersion())
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?.supported_versions)
470
- ? healthResult.supported_versions
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
- (_r, ref) => ({ elementRef: ref }),
43
- { resolve: true, printMethod: 'dom.describe' }
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, ref) => ({
49
- elementRef: ref,
62
+ (r) => ({
63
+ target: createAtomicTarget(r[0]),
50
64
  textBudget: r[1] ? parseIntArg(r[1], 'budget') : undefined,
51
65
  }),
52
- { resolve: true, printMethod: 'dom.get_text' }
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, ref) => ({ elementRef: ref, properties: parseCommaList(r[1]) }),
58
- { resolve: true, printMethod: 'styles.get_computed' }
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
- (_r, ref) => ({ elementRef: ref }),
64
- { resolve: true, printMethod: 'layout.get_box_model' }
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, ref) => ({
88
- target: { elementRef: ref },
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
- 'input.hover',
99
- 'bbx hover <ref|selector>',
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, ref) => ({
107
- elementRef: ref,
109
+ (r) => ({
110
+ target: createAtomicTarget(r[0]),
108
111
  maxLength: r[1] ? parseIntArg(r[1], 'maxLen') : undefined,
109
112
  }),
110
- { resolve: true }
113
+ {}
111
114
  ),
112
115
  'patch-style': createShortcutCommand(
113
116
  'patch.apply_styles',
114
117
  'bbx patch-style <ref|sel> prop=val',
115
- (r, ref) => ({
116
- target: { elementRef: ref },
118
+ (r) => ({
119
+ target: createAtomicTarget(r[0]),
117
120
  declarations: parsePropertyAssignments(r.slice(1)),
118
121
  }),
119
- { resolve: true }
122
+ {}
120
123
  ),
121
124
  'patch-text': createShortcutCommand(
122
125
  'patch.apply_dom',
123
126
  'bbx patch-text <ref|sel> <text...>',
124
- (r, ref) => ({
125
- target: { elementRef: ref },
127
+ (r) => ({
128
+ target: createAtomicTarget(r[0]),
126
129
  operation: 'set_text',
127
130
  value: r.slice(1).join(' '),
128
131
  }),
129
- { resolve: true, description: 'Apply a reversible DOM text patch' }
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, ref) => ({ elementRef: ref, attributes: parseCommaList(r[1]) }),
226
- { resolve: true, printMethod: 'dom.get_attributes' }
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
- (_r, ref) => ({ elementRef: ref }),
232
- { resolve: true, printMethod: 'styles.get_matched_rules' }
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 Diagnose install, daemon, extension, and access readiness',
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 Request Browser Bridge access for the focused window',
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] Capture partial element screenshot',
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 '&amp;';
138
+ if (character === '<') return '&lt;';
139
+ return '&gt;';
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 = {}) {