chrome-bridge-mcp 1.17.0 → 1.19.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/README.md +5 -1
- package/extension/lib/find-text.js +97 -0
- package/extension/manifest.json +1 -1
- package/extension/service-worker.js +166 -63
- package/package.json +1 -1
- package/server/cli.js +5 -2
- package/server/index.js +4 -0
- package/server/protocol.js +5 -2
- package/server/tools.js +155 -45
- package/skills/chrome-bridge/SKILL.md +19 -2
package/README.md
CHANGED
|
@@ -180,7 +180,11 @@ paste into a chat.
|
|
|
180
180
|
| `Port N is held by a process that is not chrome-bridge` | Something else owns the port. Free it or change `CHROME_BRIDGE_PORT`. |
|
|
181
181
|
| `execute_js` fails | Enable **Allow user scripts** in `chrome://extensions` → Chrome Bridge → Details (Chrome 138+; on 135-137 enable Developer Mode). |
|
|
182
182
|
| `read_console` returns `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page isn't injectable (`chrome://`). Reload it. |
|
|
183
|
-
| Screenshot times out |
|
|
183
|
+
| Screenshot times out, or `image readback failed` | A minimized or fully covered window stops producing frames; captures fail after 10 s. Bring the window forward. |
|
|
184
|
+
| `wait_for`/`scroll` return `page_hidden: true`, pages stop updating | Same cause: Chrome does not render a hidden page and slows its timers (after a few minutes, to one wake-up per minute). `get_page_info` reports `visibility`. Bring the window on screen, or `create_tab` with `new_window` and bounds. |
|
|
185
|
+
| `screenshot presets` says `NOT APPLIED` | Presets resize the window; they do not emulate a phone (no device pixel ratio, UA or touch). The window manager enforces a minimum width, and no window exceeds the screen (e.g. at most a 1536×686 viewport on a 1536×864 ChromeOS screen). For phone emulation or larger viewports use a headless browser. |
|
|
186
|
+
| `type_text` returns `mismatch: true` | The field rejected the value. Events from an extension have `isTrusted: false`, and some widgets (date pickers, search boxes with tokenizers) discard them. Try `mode: 'keys'`, then the site's own controls (the calendar buttons), or `handoff`. |
|
|
187
|
+
| `[media removed: request limit]` instead of a screenshot | Written by the MCP client, not by the bridge: too many images in one request. Use `save_to`, or `element_screenshot` with `region` and a small `scale`. |
|
|
184
188
|
| Commands work, then stop | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
|
|
185
189
|
| Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
|
|
186
190
|
| Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* findTextInPage gira nella pagina: chrome.scripting ne serializza il
|
|
3
|
+
* sorgente, quindi è autocontenuta (niente import, niente chiusure).
|
|
4
|
+
*
|
|
5
|
+
* Cerca sul testo degli elementi, non dei singoli nodi di testo: un'etichetta
|
|
6
|
+
* spezzata in più nodi («<span>Prestazioni</span> e <b>clic</b>», o con
|
|
7
|
+
* ) prima non veniva mai trovata, ed è la forma normale dei menu di
|
|
8
|
+
* Meta. Fra le parole dell'ago vale qualunque sequenza di spazi o .
|
|
9
|
+
* Ogni occorrenza è riportata una volta, dall'elemento più interno che la
|
|
10
|
+
* contiene tutta; si entra anche negli shadow root aperti.
|
|
11
|
+
*
|
|
12
|
+
* Il costo resta basso sulle pagine grandi: si scende solo nei rami il cui
|
|
13
|
+
* testo contiene l'ago.
|
|
14
|
+
*/
|
|
15
|
+
export function findTextInPage(needleRaw, caseSensitive, maxResults, doc = document, win = window) {
|
|
16
|
+
const words = String(needleRaw || '').split(/[\s ]+/).filter(Boolean);
|
|
17
|
+
if (!words.length) return { count: 0, matches: [] };
|
|
18
|
+
const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\s\\u00a0]+');
|
|
19
|
+
const flags = caseSensitive ? 'g' : 'gi';
|
|
20
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
21
|
+
|
|
22
|
+
const textOf = (node) => {
|
|
23
|
+
if (node.nodeType === 3) return node.textContent || '';
|
|
24
|
+
if (node.nodeType !== 1 || SKIP.has(node.tagName)) return '';
|
|
25
|
+
let out = '';
|
|
26
|
+
for (const c of node.childNodes) out += textOf(c);
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
const ranges = (text) => {
|
|
30
|
+
const re = new RegExp(pattern, flags);
|
|
31
|
+
const out = [];
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(text))) out.push([m.index, m.index + m[0].length]);
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
36
|
+
const selectorFor = (el) => {
|
|
37
|
+
if (el.id) return `#${el.id}`;
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
const cls = el.classList && el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
|
|
40
|
+
return `${tag}${cls}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const matches = [];
|
|
44
|
+
const visit = (el, inShadow) => {
|
|
45
|
+
if (matches.length >= maxResults || SKIP.has(el.tagName)) return;
|
|
46
|
+
const text = textOf(el);
|
|
47
|
+
const found = ranges(text);
|
|
48
|
+
if (!found.length) return;
|
|
49
|
+
// Intervalli dei figli elemento nel testo di el: textOf concatena i figli
|
|
50
|
+
// in ordine, quindi gli offset coincidono.
|
|
51
|
+
const kids = [];
|
|
52
|
+
let off = 0;
|
|
53
|
+
for (const c of el.childNodes) {
|
|
54
|
+
const len = textOf(c).length;
|
|
55
|
+
if (c.nodeType === 1) kids.push({ el: c, from: off, to: off + len });
|
|
56
|
+
off += len;
|
|
57
|
+
}
|
|
58
|
+
const owned = found.filter(([a, b]) => !kids.some((k) => a >= k.from && b <= k.to));
|
|
59
|
+
if (owned.length) {
|
|
60
|
+
const rect = el.getBoundingClientRect();
|
|
61
|
+
const flat = text.replace(/[\s ]/g, ' ');
|
|
62
|
+
for (const [a, b] of owned) {
|
|
63
|
+
if (matches.length >= maxResults) break;
|
|
64
|
+
matches.push({
|
|
65
|
+
selector: selectorFor(el),
|
|
66
|
+
context: flat.substring(Math.max(0, a - 40), b + 40).replace(/ +/g, ' ').trim(),
|
|
67
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
68
|
+
position: { x: Math.round(rect.x + win.scrollX), y: Math.round(rect.y + win.scrollY) },
|
|
69
|
+
...(inShadow ? { in_shadow: true } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const k of kids) {
|
|
74
|
+
if (found.some(([a, b]) => a >= k.from && b <= k.to)) visit(k.el, inShadow);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Gli shadow root non compaiono nel testo dell'ospite: si cercano a parte,
|
|
79
|
+
// anche annidati, dopo il documento.
|
|
80
|
+
const roots = [];
|
|
81
|
+
const collectShadows = (root) => {
|
|
82
|
+
for (const el of root.querySelectorAll('*')) {
|
|
83
|
+
if (el.shadowRoot) {
|
|
84
|
+
roots.push(el.shadowRoot);
|
|
85
|
+
collectShadows(el.shadowRoot);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (doc.body) {
|
|
90
|
+
visit(doc.body, false);
|
|
91
|
+
collectShadows(doc.body);
|
|
92
|
+
}
|
|
93
|
+
for (const root of roots) {
|
|
94
|
+
for (const c of root.children) visit(c, true);
|
|
95
|
+
}
|
|
96
|
+
return { count: matches.length, matches };
|
|
97
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.19.0",
|
|
5
5
|
"description": "Bridge your browser to Claude Code: 59 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
|
|
6
6
|
"homepage_url": "https://github.com/frsorrentino/chrome-bridge",
|
|
7
7
|
"minimum_chrome_version": "135",
|
|
@@ -11,6 +11,7 @@ import { pageFingerprint } from './lib/page-fingerprint.js';
|
|
|
11
11
|
import { createPacer } from './lib/capture-pacing.js';
|
|
12
12
|
import { computeTiles } from './lib/tile-layout.js';
|
|
13
13
|
import { classifyDownload } from './lib/download-state.js';
|
|
14
|
+
import { findTextInPage } from './lib/find-text.js';
|
|
14
15
|
const { pushError } = globalThis.__cbTelemetry;
|
|
15
16
|
|
|
16
17
|
const DEFAULT_PORT = 8765;
|
|
@@ -603,11 +604,60 @@ async function captureVisible(windowId, options = { format: 'png' }) {
|
|
|
603
604
|
}, 10000);
|
|
604
605
|
chrome.tabs.captureVisibleTab(windowId, options).then(
|
|
605
606
|
(dataUrl) => { clearTimeout(timer); resolve(dataUrl); },
|
|
606
|
-
(err) => {
|
|
607
|
+
(err) => {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
// «image readback failed» è lo stesso guasto della finestra che non
|
|
610
|
+
// disegna, detto dal compositor: stesso messaggio, stesso rimedio.
|
|
611
|
+
if (/readback failed/i.test(err?.message || '')) {
|
|
612
|
+
reject(new Error(`Capture failed (${err.message}): the window is not rendering frames (fully occluded, minimized, or frozen). Bring the Chrome window at least partially on screen and retry.`));
|
|
613
|
+
} else {
|
|
614
|
+
reject(err);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
607
617
|
);
|
|
608
618
|
});
|
|
609
619
|
}
|
|
610
620
|
|
|
621
|
+
// --- Utility: scadenze tenute dal service worker ---
|
|
622
|
+
// Le attese che girano nella pagina usano setTimeout, e Chrome raggruppa i
|
|
623
|
+
// timer delle pagine nascoste fino a un risveglio al minuto: il controllo
|
|
624
|
+
// della scadenza scattava tardi e vinceva il timeout di trasporto (60 s per
|
|
625
|
+
// un'attesa da 25, osservato su Meta Ads Manager il 23/09/2026). Il service
|
|
626
|
+
// worker non è rallentato: la scadenza la tiene lui.
|
|
627
|
+
const HIDDEN_HINT = 'The page is hidden (window minimized, covered or in the background): Chrome slows its timers and stops rendering. Bring the window on screen, or create_tab new_window with bounds, then retry.';
|
|
628
|
+
|
|
629
|
+
async function pageHidden(tabId) {
|
|
630
|
+
try {
|
|
631
|
+
const q = chrome.scripting.executeScript({ target: { tabId }, func: () => document.visibilityState === 'hidden' });
|
|
632
|
+
const r = await Promise.race([q, new Promise((res) => setTimeout(() => res(null), 1000))]);
|
|
633
|
+
return r?.[0]?.result === true;
|
|
634
|
+
} catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Esegue run(); se non risponde entro deadlineMs restituisce onTimeout. In
|
|
640
|
+
// entrambi i casi un esito negativo su pagina nascosta porta page_hidden e il
|
|
641
|
+
// suggerimento, così chi chiama non ritenta tre volte la stessa attesa.
|
|
642
|
+
async function withDeadline(tabId, deadlineMs, run, onTimeout, isNegative) {
|
|
643
|
+
const DEADLINE = Symbol('deadline');
|
|
644
|
+
let timer;
|
|
645
|
+
let result;
|
|
646
|
+
try {
|
|
647
|
+
result = await Promise.race([
|
|
648
|
+
run(),
|
|
649
|
+
new Promise((res) => { timer = setTimeout(() => res(DEADLINE), deadlineMs); }),
|
|
650
|
+
]);
|
|
651
|
+
} finally {
|
|
652
|
+
clearTimeout(timer);
|
|
653
|
+
}
|
|
654
|
+
if (result === DEADLINE) result = { ...onTimeout };
|
|
655
|
+
if (result && isNegative(result) && await pageHidden(tabId)) {
|
|
656
|
+
return { ...result, page_hidden: true, hint: HIDDEN_HINT };
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
|
|
611
661
|
// --- Utility: rendi visibile un tab per la cattura SENZA rubare il focus OS ---
|
|
612
662
|
// captureVisibleTab richiede solo che il tab sia attivo nella sua finestra,
|
|
613
663
|
// non che la finestra sia in primo piano. Al termine ripristina il tab che
|
|
@@ -683,7 +733,7 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
|
|
|
683
733
|
|
|
684
734
|
// --- Implementazione comandi ---
|
|
685
735
|
|
|
686
|
-
async function cmdGetTabs({ include_windows = false } = {}) {
|
|
736
|
+
async function cmdGetTabs({ include_windows = false, ended } = {}) {
|
|
687
737
|
const tabs = await chrome.tabs.query({});
|
|
688
738
|
const list = tabs.map((t) => ({
|
|
689
739
|
id: t.id,
|
|
@@ -691,8 +741,17 @@ async function cmdGetTabs({ include_windows = false } = {}) {
|
|
|
691
741
|
title: t.title,
|
|
692
742
|
active: t.active,
|
|
693
743
|
windowId: t.windowId,
|
|
744
|
+
// Una scheda scartata da Chrome (memory saver) esiste ancora ma non ha un
|
|
745
|
+
// renderer: execute_js e compagni falliscono finché non torna in primo piano.
|
|
746
|
+
...(t.discarded ? { discarded: true } : {}),
|
|
694
747
|
}));
|
|
695
|
-
|
|
748
|
+
// ended = id che il server sa suoi e non trova più: la forma ad array resta
|
|
749
|
+
// quella di sempre per chi non lo chiede.
|
|
750
|
+
const open = new Set(list.map((t) => t.id));
|
|
751
|
+
const endings = Array.isArray(ended) && ended.length
|
|
752
|
+
? Object.fromEntries(ended.filter((id) => !open.has(id)).map((id) => [id, tabEndings.get(id) ?? { reason: 'unknown' }]))
|
|
753
|
+
: null;
|
|
754
|
+
if (!include_windows) return endings ? { tabs: list, ended: endings } : list;
|
|
696
755
|
|
|
697
756
|
// Senza geometria e stato non si può decidere dove mettere una finestra: si
|
|
698
757
|
// può solo indovinare. `left` in particolare è ciò che identifica il monitor
|
|
@@ -712,7 +771,7 @@ async function cmdGetTabs({ include_windows = false } = {}) {
|
|
|
712
771
|
// provarci.
|
|
713
772
|
scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
|
|
714
773
|
}));
|
|
715
|
-
return { tabs: list, windows };
|
|
774
|
+
return endings ? { tabs: list, windows, ended: endings } : { tabs: list, windows };
|
|
716
775
|
}
|
|
717
776
|
|
|
718
777
|
async function cmdNavigate({ url, tab_id }) {
|
|
@@ -1161,7 +1220,14 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
|
|
|
1161
1220
|
if (typeof v === 'number') winOpts[k] = v;
|
|
1162
1221
|
}
|
|
1163
1222
|
const win = await chrome.windows.create(winOpts);
|
|
1223
|
+
// La finestra nuova può nascere dietro un'altra (lo decide il window
|
|
1224
|
+
// manager): allora la pagina resta hidden e non disegna. Lo si dice qui,
|
|
1225
|
+
// non dopo tre attese scadute.
|
|
1226
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1227
|
+
const hidden = await pageHidden(tab_id);
|
|
1164
1228
|
return {
|
|
1229
|
+
visibility: hidden ? 'hidden' : 'visible',
|
|
1230
|
+
...(hidden ? { hint: HIDDEN_HINT } : {}),
|
|
1165
1231
|
moved: tab_id,
|
|
1166
1232
|
url: before.url,
|
|
1167
1233
|
from_window: before.windowId,
|
|
@@ -1189,12 +1255,26 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
|
|
|
1189
1255
|
};
|
|
1190
1256
|
}
|
|
1191
1257
|
|
|
1258
|
+
// Perché una scheda non c'è più: «No tab with id» da solo non distingue una
|
|
1259
|
+
// chiusura a mano da una sostituzione. In memoria e limitato: se il service
|
|
1260
|
+
// worker riparte il registro si svuota e il motivo torna 'unknown'.
|
|
1261
|
+
const tabEndings = new Map();
|
|
1262
|
+
const TAB_ENDINGS_MAX = 200;
|
|
1263
|
+
const bridgeClosing = new Set();
|
|
1264
|
+
|
|
1265
|
+
function recordTabEnding(tabId, ending) {
|
|
1266
|
+
if (bridgeClosing.delete(tabId) && ending.reason === 'closed') ending = { reason: 'closed_by_bridge' };
|
|
1267
|
+
tabEndings.set(tabId, { ...ending, at: new Date().toISOString() });
|
|
1268
|
+
if (tabEndings.size > TAB_ENDINGS_MAX) tabEndings.delete(tabEndings.keys().next().value);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1192
1271
|
async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
|
|
1193
1272
|
if (!action) throw new Error('Missing required parameter: action');
|
|
1194
1273
|
const tabId = await resolveTabId(tab_id);
|
|
1195
1274
|
|
|
1196
1275
|
if (action === 'close') {
|
|
1197
|
-
|
|
1276
|
+
bridgeClosing.add(tabId);
|
|
1277
|
+
try { await chrome.tabs.remove(tabId); } catch (e) { bridgeClosing.delete(tabId); throw e; }
|
|
1198
1278
|
return { action, closed: tabId };
|
|
1199
1279
|
}
|
|
1200
1280
|
if (action === 'discard') {
|
|
@@ -1321,6 +1401,9 @@ async function cmdGetPageInfo({ tab_id, frame_id }) {
|
|
|
1321
1401
|
url: location.href,
|
|
1322
1402
|
doctype: document.doctype ? document.doctype.name : null,
|
|
1323
1403
|
charset: document.characterSet,
|
|
1404
|
+
// hidden = finestra minimizzata, coperta o in secondo piano: la pagina
|
|
1405
|
+
// non disegna e i suoi timer rallentano. Va saputo prima di attendere.
|
|
1406
|
+
visibility: document.visibilityState,
|
|
1324
1407
|
metas,
|
|
1325
1408
|
scripts,
|
|
1326
1409
|
stylesheets,
|
|
@@ -1731,7 +1814,7 @@ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, vi
|
|
|
1731
1814
|
const tabId = await resolveTabId(tab_id);
|
|
1732
1815
|
const clampedInterval = Math.max(interval, 50);
|
|
1733
1816
|
|
|
1734
|
-
const
|
|
1817
|
+
const run = () => chrome.scripting.executeScript({
|
|
1735
1818
|
target: scriptTarget(tabId, frame_id),
|
|
1736
1819
|
func: (sel, tout, intv, vis) => {
|
|
1737
1820
|
function deepQuery(sel) {
|
|
@@ -1783,8 +1866,9 @@ async function cmdWaitForElement({ selector, timeout = 10000, interval = 200, vi
|
|
|
1783
1866
|
},
|
|
1784
1867
|
args: [selector, timeout, clampedInterval, visible],
|
|
1785
1868
|
world: 'MAIN',
|
|
1786
|
-
});
|
|
1787
|
-
return
|
|
1869
|
+
}).then((results) => results?.[0]?.result ?? { found: false, error: 'No result' });
|
|
1870
|
+
return withDeadline(tabId, timeout + 1000, run,
|
|
1871
|
+
{ found: false, error: `Element not found within ${timeout}ms: ${selector}` }, (r) => r.found === false);
|
|
1788
1872
|
}
|
|
1789
1873
|
|
|
1790
1874
|
// --- scroll_to ---
|
|
@@ -2035,11 +2119,16 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
|
|
|
2035
2119
|
}
|
|
2036
2120
|
const winNow = await chrome.windows.get(tab.windowId);
|
|
2037
2121
|
const geometry = { left: winNow.left, top: winNow.top, width: winNow.width, height: winNow.height, state: winNow.state };
|
|
2038
|
-
|
|
2122
|
+
// Uno zoom sotto il 100% allarga il viewport in CSS px oltre lo schermo:
|
|
2123
|
+
// senza questo numero un 2226 px su uno schermo da 1536 non si spiega.
|
|
2124
|
+
let zoom = null;
|
|
2125
|
+
try { zoom = await chrome.tabs.getZoom(tabId); } catch { /* scheda senza zoom (chrome://) */ }
|
|
2126
|
+
if (read_only) return { actual, window: geometry, zoom, viewport_error };
|
|
2039
2127
|
const win = winNow;
|
|
2040
2128
|
return {
|
|
2041
2129
|
requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
|
|
2042
2130
|
actual,
|
|
2131
|
+
zoom,
|
|
2043
2132
|
viewport_error,
|
|
2044
2133
|
// Il window manager può accettare e ignorare: senza il confronto una
|
|
2045
2134
|
// finestra rimasta ferma passerebbe per spostata.
|
|
@@ -3513,41 +3602,7 @@ async function cmdFindText({ text, case_sensitive = false, max_results = 20, tab
|
|
|
3513
3602
|
const tabId = await resolveTabId(tab_id);
|
|
3514
3603
|
const results = await chrome.scripting.executeScript({
|
|
3515
3604
|
target: { tabId },
|
|
3516
|
-
func:
|
|
3517
|
-
const needle = caseSensitive ? needleRaw : needleRaw.toLowerCase();
|
|
3518
|
-
const matches = [];
|
|
3519
|
-
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
|
3520
|
-
acceptNode: (n) => {
|
|
3521
|
-
const p = n.parentElement;
|
|
3522
|
-
if (!p || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(p.tagName)) return NodeFilter.FILTER_REJECT;
|
|
3523
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
3524
|
-
},
|
|
3525
|
-
});
|
|
3526
|
-
const selectorFor = (el) => {
|
|
3527
|
-
if (el.id) return `#${el.id}`;
|
|
3528
|
-
const tag = el.tagName.toLowerCase();
|
|
3529
|
-
const cls = el.classList.length ? `.${[...el.classList].slice(0, 2).join('.')}` : '';
|
|
3530
|
-
return `${tag}${cls}`;
|
|
3531
|
-
};
|
|
3532
|
-
let node;
|
|
3533
|
-
while ((node = walker.nextNode()) && matches.length < maxResults) {
|
|
3534
|
-
const hay = caseSensitive ? node.textContent : node.textContent.toLowerCase();
|
|
3535
|
-
let idx = hay.indexOf(needle);
|
|
3536
|
-
while (idx !== -1 && matches.length < maxResults) {
|
|
3537
|
-
const parent = node.parentElement;
|
|
3538
|
-
const rect = parent.getBoundingClientRect();
|
|
3539
|
-
const ctx = node.textContent.substring(Math.max(0, idx - 40), idx + needleRaw.length + 40);
|
|
3540
|
-
matches.push({
|
|
3541
|
-
selector: selectorFor(parent),
|
|
3542
|
-
context: ctx.trim(),
|
|
3543
|
-
visible: rect.width > 0 && rect.height > 0,
|
|
3544
|
-
position: { x: Math.round(rect.x + window.scrollX), y: Math.round(rect.y + window.scrollY) },
|
|
3545
|
-
});
|
|
3546
|
-
idx = hay.indexOf(needle, idx + 1);
|
|
3547
|
-
}
|
|
3548
|
-
}
|
|
3549
|
-
return { count: matches.length, matches };
|
|
3550
|
-
},
|
|
3605
|
+
func: findTextInPage,
|
|
3551
3606
|
args: [text, case_sensitive, max_results],
|
|
3552
3607
|
world: 'MAIN',
|
|
3553
3608
|
});
|
|
@@ -4425,7 +4480,7 @@ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200,
|
|
|
4425
4480
|
if (!text) throw new Error('Missing required parameter: text');
|
|
4426
4481
|
const tabId = await resolveTabId(tab_id);
|
|
4427
4482
|
|
|
4428
|
-
const
|
|
4483
|
+
const run = () => chrome.scripting.executeScript({
|
|
4429
4484
|
target: scriptTarget(tabId, frame_id),
|
|
4430
4485
|
func: (needle, scopeSel, tout, intv) => new Promise((resolve) => {
|
|
4431
4486
|
const started = Date.now();
|
|
@@ -4454,9 +4509,10 @@ async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200,
|
|
|
4454
4509
|
}),
|
|
4455
4510
|
args: [text, selector || null, timeout, Math.max(50, interval)],
|
|
4456
4511
|
world: 'MAIN',
|
|
4457
|
-
});
|
|
4512
|
+
}).then((results) => results?.[0]?.result ?? { found: false, error: 'No result' });
|
|
4458
4513
|
|
|
4459
|
-
return
|
|
4514
|
+
return withDeadline(tabId, timeout + 1000, run,
|
|
4515
|
+
{ found: false, error: `Text not found within ${timeout}ms: ${text}` }, (r) => r.found === false);
|
|
4460
4516
|
}
|
|
4461
4517
|
|
|
4462
4518
|
async function cmdSavePage({ tab_id }) {
|
|
@@ -4561,9 +4617,16 @@ async function cmdHttpAuth({ action, username, password }) {
|
|
|
4561
4617
|
|
|
4562
4618
|
// --- Tab lifecycle: cleanup injection state ---
|
|
4563
4619
|
|
|
4564
|
-
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
4620
|
+
chrome.tabs.onRemoved.addListener((tabId, info) => {
|
|
4565
4621
|
browserNetLog.delete(tabId);
|
|
4566
4622
|
mainFrameHeaders.delete(tabId);
|
|
4623
|
+
recordTabEnding(tabId, { reason: info?.isWindowClosing ? 'window_closed' : 'closed' });
|
|
4624
|
+
});
|
|
4625
|
+
|
|
4626
|
+
// Chrome sostituisce l'id quando rimpiazza la scheda (prerender, alcuni
|
|
4627
|
+
// discard): per chi teneva il vecchio id è una scheda sparita senza chiusura.
|
|
4628
|
+
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
|
4629
|
+
recordTabEnding(removedTabId, { reason: 'replaced', replaced_by: addedTabId });
|
|
4567
4630
|
});
|
|
4568
4631
|
|
|
4569
4632
|
// --- get_interactives ---
|
|
@@ -4677,52 +4740,92 @@ async function cmdWaitForFunction({ expression, timeout = 10000, polling_ms = 10
|
|
|
4677
4740
|
|
|
4678
4741
|
// --- scroll_until ---
|
|
4679
4742
|
|
|
4680
|
-
async function cmdScrollUntil({ until = 'no_new_content', selector, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
|
|
4743
|
+
async function cmdScrollUntil({ until = 'no_new_content', selector, container, max_scrolls = 20, step_px, settle_ms = 400, tab_id }) {
|
|
4681
4744
|
const tabId = await resolveTabId(tab_id);
|
|
4682
4745
|
if (until === 'network_idle') await ensureNetworkHook(tabId);
|
|
4683
|
-
const
|
|
4746
|
+
const run = () => chrome.scripting.executeScript({
|
|
4684
4747
|
target: { tabId },
|
|
4685
|
-
func: (mode, sel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
|
|
4748
|
+
func: (mode, sel, containerSel, maxScrolls, stepPx, settleMs) => new Promise((resolve) => {
|
|
4686
4749
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4750
|
+
const docEl = document.scrollingElement || document.documentElement;
|
|
4751
|
+
const scrollable = (el) => {
|
|
4752
|
+
if (el.scrollHeight <= el.clientHeight + 2) return false;
|
|
4753
|
+
const oy = getComputedStyle(el).overflowY;
|
|
4754
|
+
return oy === 'auto' || oy === 'scroll' || oy === 'overlay';
|
|
4755
|
+
};
|
|
4756
|
+
const describe = (el) => {
|
|
4757
|
+
if (el.id) return `#${el.id}`;
|
|
4758
|
+
const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
4759
|
+
return el.tagName.toLowerCase() + cls;
|
|
4760
|
+
};
|
|
4761
|
+
// Le app come Ads Manager bloccano il documento e scorrono in un div
|
|
4762
|
+
// interno: scorrendo solo window la condizione «bottom» era vera al
|
|
4763
|
+
// primo giro. Senza container esplicito si prende il contenitore
|
|
4764
|
+
// scorrevole più grande, ma solo se il documento non scorre.
|
|
4765
|
+
let box = null;
|
|
4766
|
+
if (containerSel) {
|
|
4767
|
+
box = document.querySelector(containerSel);
|
|
4768
|
+
if (!box) { resolve({ stopped_reason: 'error', error: `Container not found: ${containerSel}`, scrolls: 0 }); return; }
|
|
4769
|
+
} else if (docEl.scrollHeight <= innerHeight + 2
|
|
4770
|
+
// overflow hidden su html o body: l'utente non può scorrere il
|
|
4771
|
+
// documento anche se è più alto della finestra (e scrollBy sì).
|
|
4772
|
+
|| [document.documentElement, document.body].some((e) => e && getComputedStyle(e).overflowY === 'hidden')) {
|
|
4773
|
+
let best = null;
|
|
4774
|
+
let bestArea = 0;
|
|
4775
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
4776
|
+
if (el.clientHeight < 50 || !scrollable(el)) continue;
|
|
4777
|
+
const area = el.clientWidth * el.clientHeight;
|
|
4778
|
+
if (area > bestArea) { best = el; bestArea = area; }
|
|
4779
|
+
}
|
|
4780
|
+
box = best;
|
|
4781
|
+
}
|
|
4782
|
+
const s = box
|
|
4783
|
+
? { by: (d) => box.scrollBy(0, d), pos: () => box.scrollTop, height: () => box.scrollHeight, view: () => box.clientHeight, name: describe(box) }
|
|
4784
|
+
: { by: (d) => window.scrollBy(0, d), pos: () => window.scrollY, height: () => docEl.scrollHeight, view: () => innerHeight, name: 'document' };
|
|
4785
|
+
const done = (stopped_reason, scrolls) => resolve({ stopped_reason, scrolls, finalScrollY: Math.round(s.pos()), container: s.name });
|
|
4687
4786
|
(async () => {
|
|
4688
4787
|
let scrolls = 0;
|
|
4689
|
-
let lastHeight =
|
|
4788
|
+
let lastHeight = s.height();
|
|
4690
4789
|
let stableCount = 0;
|
|
4691
|
-
const step = stepPx ||
|
|
4790
|
+
const step = stepPx || s.view();
|
|
4692
4791
|
for (scrolls = 0; scrolls < maxScrolls; scrolls++) {
|
|
4693
4792
|
if (mode === 'element' && sel) {
|
|
4694
4793
|
const el = document.querySelector(sel);
|
|
4695
4794
|
if (el) {
|
|
4696
4795
|
const r = el.getBoundingClientRect();
|
|
4697
4796
|
const vis = r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden';
|
|
4698
|
-
if (vis) {
|
|
4797
|
+
if (vis) { done('element_found', scrolls); return; }
|
|
4699
4798
|
}
|
|
4700
4799
|
}
|
|
4701
|
-
|
|
4800
|
+
s.by(step);
|
|
4702
4801
|
await sleep(settleMs);
|
|
4703
4802
|
|
|
4704
4803
|
if (mode === 'network_idle') {
|
|
4705
4804
|
const inflight = window.__chromeBridge_inflight || 0;
|
|
4706
4805
|
const last = window.__chromeBridge_lastNetActivity || Date.now();
|
|
4707
|
-
if (inflight === 0 && Date.now() - last >= settleMs) {
|
|
4806
|
+
if (inflight === 0 && Date.now() - last >= settleMs) { done('network_idle', scrolls + 1); return; }
|
|
4708
4807
|
}
|
|
4709
4808
|
|
|
4710
|
-
const h =
|
|
4809
|
+
const h = s.height();
|
|
4711
4810
|
if (h === lastHeight) {
|
|
4712
4811
|
stableCount++;
|
|
4713
|
-
if (mode === 'no_new_content' && stableCount >= 2) {
|
|
4812
|
+
if (mode === 'no_new_content' && stableCount >= 2) { done('no_new_content', scrolls + 1); return; }
|
|
4714
4813
|
} else { stableCount = 0; lastHeight = h; }
|
|
4715
4814
|
|
|
4716
4815
|
// bottom reached
|
|
4717
|
-
if (
|
|
4816
|
+
if (s.view() + s.pos() >= h - 2) { done('bottom', scrolls + 1); return; }
|
|
4718
4817
|
}
|
|
4719
|
-
|
|
4818
|
+
done('max_scrolls', scrolls);
|
|
4720
4819
|
})();
|
|
4721
4820
|
}),
|
|
4722
|
-
args: [until, selector || null, max_scrolls, step_px || null, settle_ms],
|
|
4821
|
+
args: [until, selector || null, container || null, max_scrolls, step_px || null, settle_ms],
|
|
4723
4822
|
world: 'MAIN',
|
|
4724
|
-
});
|
|
4725
|
-
|
|
4823
|
+
}).then((results) => results?.[0]?.result ?? { stopped_reason: 'error', scrolls: 0 });
|
|
4824
|
+
// Stesse sleep in pagina delle attese: su una scheda nascosta rallentano.
|
|
4825
|
+
const deadline = Math.min(55000, max_scrolls * (settle_ms + 250) + 5000);
|
|
4826
|
+
return withDeadline(tabId, deadline, run,
|
|
4827
|
+
{ stopped_reason: 'deadline', error: `Scrolling did not finish within ${deadline}ms` },
|
|
4828
|
+
(r) => r.stopped_reason === 'deadline');
|
|
4726
4829
|
}
|
|
4727
4830
|
|
|
4728
4831
|
// --- Avvia la connessione + registra l'instrumentation ---
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
3
|
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.19.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/cli.js
CHANGED
|
@@ -119,10 +119,13 @@ function connect(port) {
|
|
|
119
119
|
sendCommand(type, params = {}) {
|
|
120
120
|
return new Promise((res, rej) => {
|
|
121
121
|
const command = createCommand(type, params);
|
|
122
|
+
// Come ws-manager: un timeout chiesto dal comando alza quello di trasporto.
|
|
123
|
+
const asked = Number(params?.timeout);
|
|
124
|
+
const timeout = Number.isFinite(asked) && asked > 0 ? Math.max(getTimeout(type), asked + 5000) : getTimeout(type);
|
|
122
125
|
const timer = setTimeout(() => {
|
|
123
126
|
pending.delete(command.id);
|
|
124
|
-
rej(new Error(`Command ${type} timed out after ${
|
|
125
|
-
},
|
|
127
|
+
rej(new Error(`Command ${type} timed out after ${timeout}ms`));
|
|
128
|
+
}, timeout);
|
|
126
129
|
pending.set(command.id, { res, rej, timer });
|
|
127
130
|
ws.send(JSON.stringify(command));
|
|
128
131
|
});
|
package/server/index.js
CHANGED
|
@@ -64,6 +64,10 @@ async function main() {
|
|
|
64
64
|
'For more than one field use fill_form once (with submit_selector to submit in the same call) instead of repeated type_text: one turn instead of N.',
|
|
65
65
|
'For tables use extract_table (server-side where/columns filtering) or extract, never read_page: read_page on a big table costs tens of thousands of tokens for data you filter anyway.',
|
|
66
66
|
'To read detail in a screenshot, crop and zoom with element_screenshot (selector or region, scale) instead of taking another full one; to check an outcome, assert or wait_for poll for you — one call, no screenshot.',
|
|
67
|
+
// Il blocco più frequente sul campo (Meta Ads Manager, 23/09/2026): tre
|
|
68
|
+
// attese scadute e due screenshot falliti prima di capire che la
|
|
69
|
+
// finestra era dietro un'altra.
|
|
70
|
+
'A tab in a minimized, covered or background window does not render: screenshots fail and page timers slow down. get_page_info reports visibility; page_hidden in a result means the same. Bring the window on screen, or create_tab new_window with bounds.',
|
|
67
71
|
].join(' '),
|
|
68
72
|
});
|
|
69
73
|
|
package/server/protocol.js
CHANGED
|
@@ -92,12 +92,15 @@ export const MessageType = Object.freeze({
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
// Versione
|
|
95
|
-
export const VERSION = '1.
|
|
95
|
+
export const VERSION = '1.19.0';
|
|
96
96
|
|
|
97
97
|
// Configurazione
|
|
98
98
|
export const DEFAULT_PORT = 8765;
|
|
99
99
|
export const COMMAND_TIMEOUT_MS = 30000; // 30s per comandi normali
|
|
100
|
-
|
|
100
|
+
// Più del timer interno di captureVisible (10 s, dopo il pacer da 520 ms):
|
|
101
|
+
// a parità vinceva il trasporto col messaggio generico «the tab may be busy»,
|
|
102
|
+
// e quello utile («the window is not rendering frames») non arrivava mai.
|
|
103
|
+
export const SCREENSHOT_TIMEOUT_MS = 15000;
|
|
101
104
|
export const PING_INTERVAL_MS = 15000; // 15s heartbeat
|
|
102
105
|
export const IDENT_TIMEOUT_MS = 5000; // tempo max per identificarsi
|
|
103
106
|
export const PENDING_RELAY_TTL_MS = 150000; // deve superare il timeout comando più lungo (120s full_page_screenshot)
|
package/server/tools.js
CHANGED
|
@@ -113,6 +113,34 @@ function fitArray(items, max, serialize) {
|
|
|
113
113
|
return items.slice(0, lo);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Output di execute_js sotto `max` senza rompere il JSON: tagliare la
|
|
118
|
+
* serializzazione a metà stringa dava «Invalid control character» a chi la
|
|
119
|
+
* parsava. Una stringa si accorcia dentro il suo valore, un array per elementi
|
|
120
|
+
* (jsonText), il resto diventa un prefisso dichiarato come tale.
|
|
121
|
+
*/
|
|
122
|
+
export function jsResultText(data, max) {
|
|
123
|
+
const text = JSON.stringify(data);
|
|
124
|
+
if (text == null || text.length <= max) return text;
|
|
125
|
+
const hint = 'raise max_length, or save_to for the whole result';
|
|
126
|
+
const value = data?.result;
|
|
127
|
+
const wrap = (field, cut, total) => JSON.stringify({ [field]: cut, truncated: true, total_chars: total, hint });
|
|
128
|
+
const fitString = (field, full) => {
|
|
129
|
+
// Gli escape JSON allungano la stringa: il prefisso più lungo che sta nel
|
|
130
|
+
// tetto si cerca sulla serializzazione, non sui caratteri grezzi.
|
|
131
|
+
let lo = 0;
|
|
132
|
+
let hi = Math.min(full.length, max);
|
|
133
|
+
while (lo < hi) {
|
|
134
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
135
|
+
if (wrap(field, full.slice(0, mid), full.length).length <= max) lo = mid; else hi = mid - 1;
|
|
136
|
+
}
|
|
137
|
+
return wrap(field, full.slice(0, lo), full.length);
|
|
138
|
+
};
|
|
139
|
+
if (typeof value === 'string') return fitString('result', value);
|
|
140
|
+
if (Array.isArray(value)) return jsonText(data, max, hint);
|
|
141
|
+
return fitString('result_json_prefix', JSON.stringify(value));
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
/** Una riga (oggetto colonna→valore o array di celle) soddisfa il filtro where. */
|
|
117
145
|
function tableRowMatches(row, where) {
|
|
118
146
|
return Object.entries(where).every(([col, needle]) => {
|
|
@@ -444,17 +472,73 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
444
472
|
}
|
|
445
473
|
};
|
|
446
474
|
|
|
447
|
-
//
|
|
448
|
-
//
|
|
475
|
+
// Registro ref → selector per tab, popolato da get_interactives, dalla
|
|
476
|
+
// preview di navigate e dai vicini di find_text. Permette click/type_text/
|
|
477
|
+
// hover per ref (n1, n2…) senza ripetere selettori lunghi.
|
|
478
|
+
// Un selettore tiene lo stesso ref finché la pagina non cambia, e un numero
|
|
479
|
+
// non viene mai riassegnato: prima ogni chiamata di scoperta ripartiva da n1
|
|
480
|
+
// sulla stessa mappa, e dopo un find_text n3 indicava un altro elemento —
|
|
481
|
+
// il click andava a segno sull'elemento sbagliato senza errori.
|
|
449
482
|
const interactivesRefs = new Map();
|
|
483
|
+
const REFS_MAX = 2000;
|
|
450
484
|
|
|
451
485
|
const refsKey = (tab_id) => tab_id ?? sessionTabId ?? 'active';
|
|
452
486
|
|
|
487
|
+
function refRegistry(tab_id) {
|
|
488
|
+
const key = refsKey(tab_id);
|
|
489
|
+
let reg = interactivesRefs.get(key);
|
|
490
|
+
if (!reg) {
|
|
491
|
+
reg = { next: 1, floor: 1, bySel: new Map(), byRef: new Map() };
|
|
492
|
+
interactivesRefs.set(key, reg);
|
|
493
|
+
}
|
|
494
|
+
return reg;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Assegna i ref agli elementi (in place). Solo gli elementi con selector
|
|
498
|
+
// ricevono un ref: senza, non ci sarebbe nulla da risolvere.
|
|
499
|
+
function assignRefs(tab_id, elements) {
|
|
500
|
+
const reg = refRegistry(tab_id);
|
|
501
|
+
for (const e of elements) {
|
|
502
|
+
if (!e.selector) continue;
|
|
503
|
+
let ref = reg.bySel.get(e.selector);
|
|
504
|
+
if (!ref) {
|
|
505
|
+
ref = `n${reg.next++}`;
|
|
506
|
+
reg.bySel.set(e.selector, ref);
|
|
507
|
+
reg.byRef.set(ref, e.selector);
|
|
508
|
+
}
|
|
509
|
+
e.ref = ref;
|
|
510
|
+
}
|
|
511
|
+
while (reg.byRef.size > REFS_MAX) {
|
|
512
|
+
const [oldRef, oldSel] = reg.byRef.entries().next().value;
|
|
513
|
+
reg.byRef.delete(oldRef);
|
|
514
|
+
reg.bySel.delete(oldSel);
|
|
515
|
+
}
|
|
516
|
+
return reg.byRef.size;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Una navigazione cambia pagina: i selettori vecchi potrebbero trovare altro.
|
|
520
|
+
// La mappa si svuota ma il contatore no, così un ref vecchio dà errore
|
|
521
|
+
// invece di indicare un elemento nuovo con lo stesso numero.
|
|
522
|
+
function resetRefs(tab_id) {
|
|
523
|
+
const reg = refRegistry(tab_id);
|
|
524
|
+
reg.bySel.clear();
|
|
525
|
+
reg.byRef.clear();
|
|
526
|
+
reg.floor = reg.next;
|
|
527
|
+
}
|
|
528
|
+
|
|
453
529
|
function resolveTarget(selector, ref, tab_id) {
|
|
454
530
|
if (selector) return selector;
|
|
455
531
|
if (ref) {
|
|
456
|
-
const
|
|
457
|
-
|
|
532
|
+
const reg = interactivesRefs.get(refsKey(tab_id));
|
|
533
|
+
const sel = reg?.byRef.get(ref);
|
|
534
|
+
if (!sel) {
|
|
535
|
+
const num = Number(String(ref).replace(/^n/, ''));
|
|
536
|
+
const why = !reg || reg.next === 1 ? 'no refs issued for this tab yet'
|
|
537
|
+
: num >= reg.next ? `refs issued so far go up to n${reg.next - 1}`
|
|
538
|
+
: num < reg.floor ? 'it was issued before the last navigate, which resets refs'
|
|
539
|
+
: 'it was evicted from the ref cache';
|
|
540
|
+
throw new Error(`Unknown ref ${ref} (${why}) — run get_interactives or find_text again`);
|
|
541
|
+
}
|
|
458
542
|
return sel;
|
|
459
543
|
}
|
|
460
544
|
throw new Error('Either selector or ref is required');
|
|
@@ -489,13 +573,9 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
489
573
|
async function interactivesPreview(tab_id, limit = 12) {
|
|
490
574
|
try {
|
|
491
575
|
const data = await send(MessageType.GET_INTERACTIVES, { limit, visible_only: true, tab_id });
|
|
492
|
-
const
|
|
493
|
-
(
|
|
494
|
-
|
|
495
|
-
if (e.selector) refMap.set(e.ref, e.selector);
|
|
496
|
-
});
|
|
497
|
-
interactivesRefs.set(refsKey(tab_id), refMap);
|
|
498
|
-
return refMap.size ? truncateText(interactivesLines(data), 1500) : null;
|
|
576
|
+
const elements = data?.elements ?? [];
|
|
577
|
+
assignRefs(tab_id, elements);
|
|
578
|
+
return elements.some((e) => e.ref) ? truncateText(interactivesLines(data), 1500) : null;
|
|
499
579
|
} catch { return null; }
|
|
500
580
|
}
|
|
501
581
|
|
|
@@ -534,21 +614,38 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
534
614
|
server.tool(
|
|
535
615
|
'get_tabs',
|
|
536
616
|
'List every open tab with id, url, title, active flag and mine (created by this session). Read-only. Find a tab_id when the implicit '
|
|
537
|
-
+ 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling.'
|
|
617
|
+
+ 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling. '
|
|
618
|
+
+ 'Session tabs gone since the last call come back once in closed_session_tabs with the reason (closed, window_closed, replaced, closed_by_bridge).',
|
|
538
619
|
{
|
|
539
620
|
include_windows: z.boolean().optional().default(false)
|
|
540
621
|
.describe('Also return the windows with bounds, state, type and tab count'),
|
|
541
622
|
},
|
|
542
623
|
async ({ include_windows }) => {
|
|
543
|
-
|
|
624
|
+
// `ended` = le schede che il server sa sue: l'estensione dà il motivo di
|
|
625
|
+
// quelle che non trova più. Senza schede di sessione la risposta resta
|
|
626
|
+
// quella di sempre.
|
|
627
|
+
const data = await send(MessageType.GET_TABS, {
|
|
628
|
+
include_windows: include_windows === true,
|
|
629
|
+
...(ownedTabs.size ? { ended: [...ownedTabs] } : {}),
|
|
630
|
+
});
|
|
544
631
|
const list = Array.isArray(data) ? data : (data?.tabs ?? []);
|
|
632
|
+
const windows = Array.isArray(data) ? null : data?.windows;
|
|
545
633
|
for (const t of list) if (ownedTabs.has(t.id)) t.mine = true;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
};
|
|
634
|
+
const missing = [...ownedTabs].filter((id) => !list.some((t) => t.id === id));
|
|
635
|
+
const shaped = windows ? { tabs: list, windows } : list;
|
|
636
|
+
if (!missing.length) return { content: [{ type: 'text', text: jsonText(shaped) }] };
|
|
637
|
+
|
|
638
|
+
// Un'estensione precedente ignora `ended` e risponde con l'array: motivo ignoto.
|
|
639
|
+
const ended = (!Array.isArray(data) && data?.ended) || {};
|
|
640
|
+
const closed = missing.map((id) => ({ id, ...(ended[id] ?? { reason: 'unknown' }) }));
|
|
641
|
+
for (const c of closed) {
|
|
642
|
+
ownedTabs.delete(c.id);
|
|
643
|
+
const successor = c.replaced_by != null ? list.find((t) => t.id === c.replaced_by) : null;
|
|
644
|
+
// Scheda sostituita da Chrome: resta della sessione con il nuovo id.
|
|
645
|
+
if (successor) { ownedTabs.add(successor.id); successor.mine = true; }
|
|
646
|
+
if (sessionTabId === c.id) sessionTabId = successor?.id ?? null;
|
|
647
|
+
}
|
|
648
|
+
return { content: [{ type: 'text', text: jsonText({ tabs: list, ...(windows ? { windows } : {}), closed_session_tabs: closed }) }] };
|
|
552
649
|
}
|
|
553
650
|
);
|
|
554
651
|
|
|
@@ -567,6 +664,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
567
664
|
const data = await send(MessageType.NAVIGATE, { url, tab_id });
|
|
568
665
|
// Il tab navigato diventa il default di sessione per i comandi successivi
|
|
569
666
|
if (data?.tabId != null) sessionTabId = data.tabId;
|
|
667
|
+
resetRefs(data?.tabId ?? tab_id);
|
|
570
668
|
const preview = await interactivesPreview(data?.tabId ?? tab_id);
|
|
571
669
|
return {
|
|
572
670
|
content: [{
|
|
@@ -578,6 +676,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
578
676
|
);
|
|
579
677
|
|
|
580
678
|
// --- screenshot ---
|
|
679
|
+
// Larghezze dei preset, le stesse di viewport_resize nell'estensione.
|
|
680
|
+
const PRESET_WIDTHS = { mobile: 375, tablet: 768, desktop: 1440 };
|
|
581
681
|
server.tool(
|
|
582
682
|
'screenshot',
|
|
583
683
|
'Screenshot of the visible viewport only (PNG), at the current scroll position, or one per viewport preset with presets. Read-only. '
|
|
@@ -586,7 +686,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
586
686
|
{
|
|
587
687
|
tab_id: tabId,
|
|
588
688
|
save_to: saveToField('the PNG'),
|
|
589
|
-
presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('
|
|
689
|
+
presets: z.array(z.enum(['mobile', 'tablet', 'desktop'])).optional().describe('Resizes the window per preset, then restores it; not phone emulation. A refused width is reported, not shot. save_to = directory'),
|
|
590
690
|
},
|
|
591
691
|
async ({ tab_id, save_to, presets }) => {
|
|
592
692
|
guardWrite(save_to);
|
|
@@ -598,10 +698,24 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
598
698
|
const notes = [];
|
|
599
699
|
try {
|
|
600
700
|
for (const preset of presets) {
|
|
601
|
-
await send(MessageType.VIEWPORT_RESIZE, { preset, tab_id });
|
|
701
|
+
const resized = await send(MessageType.VIEWPORT_RESIZE, { preset, tab_id });
|
|
602
702
|
await new Promise((r) => setTimeout(r, 400));
|
|
603
703
|
const shot = await send(MessageType.SCREENSHOT, { tab_id });
|
|
604
704
|
if (!shot?.image) { notes.push(`${preset}: no image`); continue; }
|
|
705
|
+
// presets ridimensiona la finestra, non emula un dispositivo: il
|
|
706
|
+
// window manager può imporre una larghezza minima o ignorare la
|
|
707
|
+
// richiesta. Un'immagine desktop etichettata «mobile» porta a
|
|
708
|
+
// conclusioni sbagliate (2226 px su med-systems.it, 23/09/2026):
|
|
709
|
+
// meglio nessuna immagine e una nota esplicita.
|
|
710
|
+
const got = shot.viewport?.width;
|
|
711
|
+
const want = PRESET_WIDTHS[preset];
|
|
712
|
+
if (got && want && Math.abs(got - want) > Math.max(40, want * 0.15)) {
|
|
713
|
+
const zoom = resized?.zoom && resized.zoom !== 1 ? `, page zoom ${Math.round(resized.zoom * 100)}%` : '';
|
|
714
|
+
notes.push(`${preset}: NOT APPLIED — viewport stayed ${got}×${shot.viewport.height} CSS px (asked ~${want}${zoom}); `
|
|
715
|
+
+ 'the window manager refused the width. No capture saved: this would not be a ' + preset + ' rendering. '
|
|
716
|
+
+ 'Device emulation needs a headless browser.');
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
605
719
|
if (save_to) {
|
|
606
720
|
await mkdir(save_to, { recursive: true });
|
|
607
721
|
const path = join(save_to, `${preset}.png`);
|
|
@@ -645,21 +759,25 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
645
759
|
// --- execute_js ---
|
|
646
760
|
server.tool(
|
|
647
761
|
'execute_js',
|
|
648
|
-
'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled.'
|
|
762
|
+
'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled. '
|
|
763
|
+
+ 'A promise is awaited up to timeout. A cut result stays valid JSON and says truncated; save_to writes the whole result to disk.',
|
|
649
764
|
{
|
|
650
765
|
code: z.string().describe('JS evaluated in the page; the value of the last expression is returned'),
|
|
651
766
|
tab_id: tabId,
|
|
652
767
|
frame_id: frameId,
|
|
653
768
|
max_length: z.number().optional().default(20000).describe('Max output chars'),
|
|
769
|
+
timeout: z.number().optional().describe('Max ms to wait for the result (default 30000); raise it for long async work'),
|
|
770
|
+
save_to: saveToField('the result (a string as is, anything else as JSON)'),
|
|
654
771
|
},
|
|
655
|
-
async ({ code, tab_id, frame_id, max_length }) => {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
662
|
-
}
|
|
772
|
+
async ({ code, tab_id, frame_id, max_length, timeout, save_to }) => {
|
|
773
|
+
guardWrite(save_to);
|
|
774
|
+
const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id, ...(timeout ? { timeout } : {}) });
|
|
775
|
+
if (save_to) {
|
|
776
|
+
const value = data?.result;
|
|
777
|
+
const isString = typeof value === 'string';
|
|
778
|
+
return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' });
|
|
779
|
+
}
|
|
780
|
+
return { content: [{ type: 'text', text: jsResultText(data, max_length ?? DEFAULT_MAX_OUTPUT) }] };
|
|
663
781
|
}
|
|
664
782
|
);
|
|
665
783
|
|
|
@@ -1034,12 +1152,13 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1034
1152
|
max_scrolls: z.number().optional().default(20).describe('Cap on scroll steps, so an infinite feed terminates'),
|
|
1035
1153
|
step_px: z.number().optional().describe('px per step, default viewport height'),
|
|
1036
1154
|
settle_ms: z.number().optional().default(400).describe('Pause ms after each step'),
|
|
1155
|
+
container: z.string().optional().describe('until: selector that scrolls; default the document, or the largest scrollable element if the document does not scroll'),
|
|
1037
1156
|
tab_id: tabId,
|
|
1038
1157
|
frame_id: frameId,
|
|
1039
1158
|
},
|
|
1040
|
-
async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, tab_id, frame_id }) => {
|
|
1159
|
+
async ({ action, selector, x, y, behavior, offset_y, until, max_scrolls, step_px, settle_ms, container, tab_id, frame_id }) => {
|
|
1041
1160
|
const data = (action ?? 'to') === 'until'
|
|
1042
|
-
? await send(MessageType.SCROLL_UNTIL, { until, selector, max_scrolls, step_px, settle_ms, tab_id })
|
|
1161
|
+
? await send(MessageType.SCROLL_UNTIL, { until, selector, container, max_scrolls, step_px, settle_ms, tab_id })
|
|
1043
1162
|
: await send(MessageType.SCROLL_TO, { selector, x, y, behavior, offset_y, tab_id, frame_id });
|
|
1044
1163
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1045
1164
|
}
|
|
@@ -1367,6 +1486,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1367
1486
|
return { content: [{ type: 'text', text: jsonText({ closed }) }] };
|
|
1368
1487
|
}
|
|
1369
1488
|
const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
|
|
1489
|
+
if (action === 'close') ownedTabs.delete(data?.closed ?? tab_id ?? sessionTabId);
|
|
1370
1490
|
if (action === 'close' && (tab_id == null || tab_id === sessionTabId)) sessionTabId = null;
|
|
1371
1491
|
if (action === 'activate' && tab_id != null) sessionTabId = tab_id;
|
|
1372
1492
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
@@ -1452,12 +1572,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
1452
1572
|
.slice(0, 5)
|
|
1453
1573
|
.map((c) => c.e);
|
|
1454
1574
|
if (els.length) {
|
|
1455
|
-
|
|
1456
|
-
els.forEach((e, i) => {
|
|
1457
|
-
e.ref = `n${i + 1}`;
|
|
1458
|
-
if (e.selector) refMap.set(e.ref, e.selector);
|
|
1459
|
-
});
|
|
1460
|
-
interactivesRefs.set(refsKey(tab_id), refMap);
|
|
1575
|
+
assignRefs(tab_id, els);
|
|
1461
1576
|
near = truncateText(interactivesLines({ count: els.length, elements: els, note: 'near first match' }), 1200);
|
|
1462
1577
|
}
|
|
1463
1578
|
} catch {}
|
|
@@ -2150,13 +2265,8 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
|
|
|
2150
2265
|
},
|
|
2151
2266
|
async ({ scope, limit, visible_only, format, frame_id, tab_id }) => {
|
|
2152
2267
|
const data = await send(MessageType.GET_INTERACTIVES, { scope, limit, visible_only, frame_id, tab_id });
|
|
2153
|
-
//
|
|
2154
|
-
|
|
2155
|
-
(data?.elements ?? []).forEach((e, i) => {
|
|
2156
|
-
e.ref = `n${i + 1}`;
|
|
2157
|
-
if (e.selector) refMap.set(e.ref, e.selector);
|
|
2158
|
-
});
|
|
2159
|
-
interactivesRefs.set(refsKey(tab_id), refMap);
|
|
2268
|
+
// Ref stabili per selettore, memorizzati per click/type_text/hover
|
|
2269
|
+
assignRefs(tab_id, data?.elements ?? []);
|
|
2160
2270
|
if ((format ?? 'lines') === 'json') {
|
|
2161
2271
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
2162
2272
|
}
|
|
@@ -150,8 +150,10 @@ highlighted diff; zoom into a region with `element_screenshot({region, scale:2})
|
|
|
150
150
|
### Three viewports
|
|
151
151
|
Triggers: "check it on mobile/tablet/desktop", «com'è su telefono?».
|
|
152
152
|
`screenshot({presets:['mobile','tablet','desktop'], save_to:'./shots'})`: one
|
|
153
|
-
call, one file per viewport, window restored.
|
|
154
|
-
|
|
153
|
+
call, one file per viewport, window restored. Presets resize the window and
|
|
154
|
+
do not emulate a phone: a width the window manager refuses comes back as
|
|
155
|
+
`NOT APPLIED`, with no image — say so instead of judging the mobile layout.
|
|
156
|
+
Report overflow, overlapping elements, hidden CTAs. Dark mode: `emulate_media({colorScheme:'dark'})`.
|
|
155
157
|
Print stylesheet: `emulate_media({printMode:true})` then `screenshot`.
|
|
156
158
|
|
|
157
159
|
### Accessibility and keyboard navigation
|
|
@@ -264,6 +266,21 @@ Triggers: "download the orders export and tell me…".
|
|
|
264
266
|
`click` the export button → `manage_downloads({action:'wait_for_complete'})` → read the file
|
|
265
267
|
with Claude Code. Spam in signups: `extract_table({where:{email:'/\\.ru$|xn--/'}})`.
|
|
266
268
|
|
|
269
|
+
### Heavy web apps (Meta Ads Manager, Google Ads, big back offices)
|
|
270
|
+
Triggers: "change the campaign budget", "read the ad set breakdown", «modifica
|
|
271
|
+
la campagna su Ads Manager», «leggi i dati dell'inserzione».
|
|
272
|
+
Keep the tab visible first: `get_page_info` must say `visibility: 'visible'`,
|
|
273
|
+
else `create_tab({url, new_window:true, left, top, width, height})` on screen.
|
|
274
|
+
Menus without ARIA roles: `find_text({text})` on the label, not
|
|
275
|
+
`get_interactives` scoped by role. The app scrolls inside a panel:
|
|
276
|
+
`scroll({action:'until', container})` (it picks the largest scrollable panel
|
|
277
|
+
when the page itself does not scroll). Virtualized lists render the next rows
|
|
278
|
+
only after a frame: scroll in one call, click in the next. Fields that save on
|
|
279
|
+
Enter: `type_text` then `press_key({key:'Enter'})`. Several clicks on a React
|
|
280
|
+
UI: one call per click, or `execute_js` with about 350 ms between them. A
|
|
281
|
+
button that opens `window.open('')` navigates the tab away: override
|
|
282
|
+
`window.open` with `execute_js` before clicking.
|
|
283
|
+
|
|
267
284
|
### Email deliverability
|
|
268
285
|
Triggers: "do the site emails land in spam?". `navigate('https://www.mail-tester.com')`
|
|
269
286
|
→ `clipboard({action:'read'})` after copying the address → send from the site
|