@mehmoodqureshi/chrome-mcp 0.9.2 → 0.9.4
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 +31 -2
- package/dist/shared/page-fns.js +208 -182
- package/dist/shared/screenshot.d.ts +14 -0
- package/dist/shared/screenshot.js +19 -4
- package/dist/shared/snapshot.d.ts +24 -2
- package/dist/shared/snapshot.js +117 -33
- package/dist/src/bridge/connection.d.ts +13 -0
- package/dist/src/bridge/connection.js +55 -1
- package/dist/src/bridge/server.d.ts +2 -0
- package/dist/src/bridge/server.js +5 -0
- package/dist/src/bridge/workspace.d.ts +1 -1
- package/dist/src/bridge/workspace.js +2 -2
- package/dist/src/cli.js +24 -3
- package/dist/src/config.d.ts +6 -0
- package/dist/src/config.js +32 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -2
- package/dist/src/executor/cdp-executor.js +21 -6
- package/dist/src/executor/extension-executor.d.ts +6 -2
- package/dist/src/executor/extension-executor.js +18 -5
- package/dist/src/executor/types.d.ts +25 -2
- package/dist/src/mcp/locate.d.ts +7 -4
- package/dist/src/mcp/locate.js +15 -8
- package/dist/src/mcp/tools.d.ts +21 -0
- package/dist/src/mcp/tools.js +114 -38
- package/docs/BLUEPRINT.md +1 -1
- package/extension-dist/background.js +494 -263
- package/extension-dist/manifest.json +4 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -383,6 +383,33 @@ Renders through Chrome's own print pipeline and saves to the task's `results/`
|
|
|
383
383
|
dir, returning the path and size. The bytes themselves are never returned — a
|
|
384
384
|
PDF is megabytes of base64 no model can read.
|
|
385
385
|
|
|
386
|
+
### Paying less per turn — `--tools`
|
|
387
|
+
|
|
388
|
+
Every MCP server you connect costs context before you ask it anything: the host
|
|
389
|
+
sends the whole tool catalog to the model on **every** turn. chrome-mcp's 39
|
|
390
|
+
tools are 27 KB of JSON Schema, about 6.9k tokens, on each one.
|
|
391
|
+
|
|
392
|
+
Most runs need a handful of them. `--tools` advertises only those:
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
npx -y @mehmoodqureshi/chrome-mcp \
|
|
396
|
+
--allow-domain app.example.com --enable-mutations \
|
|
397
|
+
--tools tabs_list,tab_new,navigate,snapshot,click,type,get_text
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
That surface is **6.0 KB, ~1.5k tokens** — an 82% cut against the full catalog,
|
|
401
|
+
for a run that was never going to print a PDF or upload a file.
|
|
402
|
+
|
|
403
|
+
- Comma-separated and repeatable: `--tools navigate,get_text --tools click`.
|
|
404
|
+
- A tool left out is hidden from `tools/list` **and refused if called** — a
|
|
405
|
+
`batch` op naming it fails the same way an unknown tool does. Hiding a tool is
|
|
406
|
+
a real restriction, not a display filter. It is not a substitute for the
|
|
407
|
+
policy gate, though: `--tools eval` still does nothing without
|
|
408
|
+
`--unsafe-enable-eval`.
|
|
409
|
+
- An unknown name fails at startup and prints the catalog, so a typo can never
|
|
410
|
+
quietly drop `click` from the surface.
|
|
411
|
+
- `chrome-mcp --help` prints the full catalog of 39 names to pick from.
|
|
412
|
+
|
|
386
413
|
## Status
|
|
387
414
|
|
|
388
415
|
v0.5.0 — **safe multi-tab concurrency.** Adds the `batch` fan-out tool, makes
|
|
@@ -502,5 +529,7 @@ the fallback.
|
|
|
502
529
|
> `chrome.debugger` is used only where it's needed and worth it: `trusted: true`
|
|
503
530
|
> input (real OS-level events on React/Vue inputs) and `screenshot` (captures a
|
|
504
531
|
> specific tab **without** activating it — safe under parallel `batch` — with
|
|
505
|
-
> true full-page and element capture). Those ops
|
|
506
|
-
>
|
|
532
|
+
> true full-page and element capture). Those ops show the debug banner while
|
|
533
|
+
> attached; the session lingers 1.5s after the last op so a burst of them
|
|
534
|
+
> attaches once. Screenshots are JPEG (quality 70) at CSS-pixel size by
|
|
535
|
+
> default — pass `format: "png"`, `quality`, or `scale` to change that.
|
package/dist/shared/page-fns.js
CHANGED
|
@@ -87,8 +87,6 @@ function pageOp(a) {
|
|
|
87
87
|
return { dx, dy, exact: true, scrollX: win.scrollX, scrollY: win.scrollY };
|
|
88
88
|
};
|
|
89
89
|
const sel = typeof a.selector === 'string' && a.selector.length > 0 ? a.selector : null;
|
|
90
|
-
const el = sel ? deepQuery(sel) : null;
|
|
91
|
-
const missing = sel !== null && el === null;
|
|
92
90
|
/** Set a value the way React/Vue see it (they patch the instance setter). */
|
|
93
91
|
const setValue = (node, next) => {
|
|
94
92
|
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'value')?.set;
|
|
@@ -98,195 +96,223 @@ function pageOp(a) {
|
|
|
98
96
|
node.value = next;
|
|
99
97
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
100
98
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return { found:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return { found: false };
|
|
113
|
-
const root = el ?? document.documentElement;
|
|
114
|
-
if (!root)
|
|
115
|
-
return { found: true, html: '' };
|
|
116
|
-
const outer = a.outer === true || !sel;
|
|
117
|
-
return { found: true, html: outer ? root.outerHTML : root.innerHTML };
|
|
118
|
-
}
|
|
119
|
-
case 'click': {
|
|
120
|
-
if (!el)
|
|
121
|
-
return { found: false };
|
|
122
|
-
el.scrollIntoView({ block: 'center' });
|
|
123
|
-
el.click();
|
|
124
|
-
return { found: true };
|
|
125
|
-
}
|
|
126
|
-
case 'type': {
|
|
127
|
-
const node = el;
|
|
128
|
-
if (!node)
|
|
129
|
-
return { found: false };
|
|
130
|
-
node.focus();
|
|
131
|
-
const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
|
|
132
|
-
setValue(node, next);
|
|
133
|
-
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
134
|
-
return { found: true };
|
|
135
|
-
}
|
|
136
|
-
case 'focus': {
|
|
137
|
-
const node = el;
|
|
138
|
-
if (!node)
|
|
139
|
-
return { found: false };
|
|
140
|
-
node.focus();
|
|
141
|
-
if (a.clear)
|
|
142
|
-
setValue(node, '');
|
|
143
|
-
return { found: true };
|
|
144
|
-
}
|
|
145
|
-
case 'point': {
|
|
146
|
-
if (!el)
|
|
147
|
-
return { found: false };
|
|
148
|
-
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
149
|
-
const r = el.getBoundingClientRect();
|
|
150
|
-
const off = frameOffset();
|
|
151
|
-
return {
|
|
152
|
-
found: true,
|
|
153
|
-
x: r.left + r.width / 2 + off.dx,
|
|
154
|
-
y: r.top + r.height / 2 + off.dy,
|
|
155
|
-
// false => this frame's coordinates cannot be mapped to the top viewport.
|
|
156
|
-
exact: off.exact,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
case 'hover': {
|
|
160
|
-
if (!el)
|
|
161
|
-
return { found: false };
|
|
162
|
-
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
163
|
-
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
164
|
-
return { found: true };
|
|
165
|
-
}
|
|
166
|
-
case 'select': {
|
|
167
|
-
const node = el;
|
|
168
|
-
if (!node || !node.options)
|
|
169
|
-
return { found: false };
|
|
170
|
-
const want = new Set(a.values ?? []);
|
|
171
|
-
let matched = false;
|
|
172
|
-
for (const opt of Array.from(node.options)) {
|
|
173
|
-
const on = want.has(opt.value) || want.has(opt.label) || want.has(opt.text);
|
|
174
|
-
opt.selected = on;
|
|
175
|
-
if (on)
|
|
176
|
-
matched = true;
|
|
99
|
+
/** The op proper, against an element already resolved (or null). */
|
|
100
|
+
const perform = (el) => {
|
|
101
|
+
const missing = sel !== null && el === null;
|
|
102
|
+
switch (a.op) {
|
|
103
|
+
case 'probe':
|
|
104
|
+
return { found: true, url: location.href, title: document.title };
|
|
105
|
+
case 'text': {
|
|
106
|
+
if (missing)
|
|
107
|
+
return { found: false };
|
|
108
|
+
const root = el ?? document.body;
|
|
109
|
+
return { found: true, text: root ? root.innerText ?? '' : '' };
|
|
177
110
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
h: window.innerHeight,
|
|
187
|
-
fullW: Math.max(d.scrollWidth, d.clientWidth),
|
|
188
|
-
fullH: Math.max(d.scrollHeight, d.clientHeight),
|
|
189
|
-
};
|
|
190
|
-
if (!sel)
|
|
191
|
-
return { found: true, dims, element: null, missing: false };
|
|
192
|
-
if (!el)
|
|
193
|
-
return { found: true, dims, element: null, missing: true };
|
|
194
|
-
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
195
|
-
const r = el.getBoundingClientRect();
|
|
196
|
-
const off = frameOffset();
|
|
197
|
-
// viewport rect + this frame's offset + the TOP document's scroll ->
|
|
198
|
-
// document coordinates of the page the screenshot actually captures.
|
|
199
|
-
return {
|
|
200
|
-
found: true,
|
|
201
|
-
dims,
|
|
202
|
-
element: {
|
|
203
|
-
x: r.left + off.dx + off.scrollX,
|
|
204
|
-
y: r.top + off.dy + off.scrollY,
|
|
205
|
-
w: r.width,
|
|
206
|
-
h: r.height,
|
|
207
|
-
},
|
|
208
|
-
missing: false,
|
|
209
|
-
exact: off.exact,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
case 'scroll': {
|
|
213
|
-
if (el)
|
|
214
|
-
el.scrollIntoView({ block: 'center' });
|
|
215
|
-
else if (a.x != null || a.y != null)
|
|
216
|
-
window.scrollTo(a.x ?? 0, a.y ?? 0);
|
|
217
|
-
else
|
|
218
|
-
window.scrollBy(a.deltaX ?? 0, a.deltaY ?? 0);
|
|
219
|
-
return { found: !missing };
|
|
220
|
-
}
|
|
221
|
-
case 'storage': {
|
|
222
|
-
const store = a.session ? window.sessionStorage : window.localStorage;
|
|
223
|
-
const op = a.storageOp;
|
|
224
|
-
if (op === 'set') {
|
|
225
|
-
store.setItem(String(a.key), String(a.value ?? ''));
|
|
226
|
-
return { found: true, ok: true };
|
|
111
|
+
case 'html': {
|
|
112
|
+
if (missing)
|
|
113
|
+
return { found: false };
|
|
114
|
+
const root = el ?? document.documentElement;
|
|
115
|
+
if (!root)
|
|
116
|
+
return { found: true, html: '' };
|
|
117
|
+
const outer = a.outer === true || !sel;
|
|
118
|
+
return { found: true, html: outer ? root.outerHTML : root.innerHTML };
|
|
227
119
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
120
|
+
case 'click': {
|
|
121
|
+
if (!el)
|
|
122
|
+
return { found: false };
|
|
123
|
+
el.scrollIntoView({ block: 'center' });
|
|
124
|
+
el.click();
|
|
125
|
+
return { found: true };
|
|
231
126
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
127
|
+
case 'type': {
|
|
128
|
+
const node = el;
|
|
129
|
+
if (!node)
|
|
130
|
+
return { found: false };
|
|
131
|
+
node.focus();
|
|
132
|
+
const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
|
|
133
|
+
setValue(node, next);
|
|
134
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
135
|
+
return { found: true };
|
|
235
136
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
137
|
+
case 'focus': {
|
|
138
|
+
const node = el;
|
|
139
|
+
if (!node)
|
|
140
|
+
return { found: false };
|
|
141
|
+
node.focus();
|
|
142
|
+
if (a.clear)
|
|
143
|
+
setValue(node, '');
|
|
144
|
+
return { found: true };
|
|
243
145
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return resolve({ found: false });
|
|
257
|
-
setTimeout(tick, every);
|
|
146
|
+
case 'point': {
|
|
147
|
+
if (!el)
|
|
148
|
+
return { found: false };
|
|
149
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
150
|
+
const r = el.getBoundingClientRect();
|
|
151
|
+
const off = frameOffset();
|
|
152
|
+
return {
|
|
153
|
+
found: true,
|
|
154
|
+
x: r.left + r.width / 2 + off.dx,
|
|
155
|
+
y: r.top + r.height / 2 + off.dy,
|
|
156
|
+
// false => this frame's coordinates cannot be mapped to the top viewport.
|
|
157
|
+
exact: off.exact,
|
|
258
158
|
};
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
159
|
+
}
|
|
160
|
+
case 'hover': {
|
|
161
|
+
if (!el)
|
|
162
|
+
return { found: false };
|
|
163
|
+
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
164
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
165
|
+
return { found: true };
|
|
166
|
+
}
|
|
167
|
+
case 'select': {
|
|
168
|
+
const node = el;
|
|
169
|
+
if (!node || !node.options)
|
|
170
|
+
return { found: false };
|
|
171
|
+
const want = new Set(a.values ?? []);
|
|
172
|
+
let matched = false;
|
|
173
|
+
for (const opt of Array.from(node.options)) {
|
|
174
|
+
const on = want.has(opt.value) || want.has(opt.label) || want.has(opt.text);
|
|
175
|
+
opt.selected = on;
|
|
176
|
+
if (on)
|
|
177
|
+
matched = true;
|
|
178
|
+
}
|
|
179
|
+
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
180
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
181
|
+
return { found: true, matched };
|
|
182
|
+
}
|
|
183
|
+
case 'measure': {
|
|
184
|
+
const d = document.documentElement;
|
|
185
|
+
const dims = {
|
|
186
|
+
w: window.innerWidth,
|
|
187
|
+
h: window.innerHeight,
|
|
188
|
+
fullW: Math.max(d.scrollWidth, d.clientWidth),
|
|
189
|
+
fullH: Math.max(d.scrollHeight, d.clientHeight),
|
|
190
|
+
dpr: window.devicePixelRatio || 1,
|
|
191
|
+
scrollX: window.scrollX,
|
|
192
|
+
scrollY: window.scrollY,
|
|
277
193
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
194
|
+
if (!sel)
|
|
195
|
+
return { found: true, dims, element: null, missing: false };
|
|
196
|
+
if (!el)
|
|
197
|
+
return { found: true, dims, element: null, missing: true };
|
|
198
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
199
|
+
const r = el.getBoundingClientRect();
|
|
200
|
+
const off = frameOffset();
|
|
201
|
+
// viewport rect + this frame's offset + the TOP document's scroll ->
|
|
202
|
+
// document coordinates of the page the screenshot actually captures.
|
|
203
|
+
return {
|
|
204
|
+
found: true,
|
|
205
|
+
dims,
|
|
206
|
+
element: {
|
|
207
|
+
x: r.left + off.dx + off.scrollX,
|
|
208
|
+
y: r.top + off.dy + off.scrollY,
|
|
209
|
+
w: r.width,
|
|
210
|
+
h: r.height,
|
|
211
|
+
},
|
|
212
|
+
missing: false,
|
|
213
|
+
exact: off.exact,
|
|
284
214
|
};
|
|
285
|
-
|
|
286
|
-
|
|
215
|
+
}
|
|
216
|
+
case 'scroll': {
|
|
217
|
+
if (el)
|
|
218
|
+
el.scrollIntoView({ block: 'center' });
|
|
219
|
+
else if (a.x != null || a.y != null)
|
|
220
|
+
window.scrollTo(a.x ?? 0, a.y ?? 0);
|
|
221
|
+
else
|
|
222
|
+
window.scrollBy(a.deltaX ?? 0, a.deltaY ?? 0);
|
|
223
|
+
return { found: !missing };
|
|
224
|
+
}
|
|
225
|
+
case 'storage': {
|
|
226
|
+
const store = a.session ? window.sessionStorage : window.localStorage;
|
|
227
|
+
const op = a.storageOp;
|
|
228
|
+
if (op === 'set') {
|
|
229
|
+
store.setItem(String(a.key), String(a.value ?? ''));
|
|
230
|
+
return { found: true, ok: true };
|
|
231
|
+
}
|
|
232
|
+
if (op === 'remove') {
|
|
233
|
+
store.removeItem(String(a.key));
|
|
234
|
+
return { found: true, ok: true };
|
|
235
|
+
}
|
|
236
|
+
if (op === 'clear') {
|
|
237
|
+
store.clear();
|
|
238
|
+
return { found: true, ok: true };
|
|
239
|
+
}
|
|
240
|
+
if (a.key)
|
|
241
|
+
return { found: true, ok: true, value: store.getItem(a.key) };
|
|
242
|
+
const entries = {};
|
|
243
|
+
for (let i = 0; i < store.length; i++) {
|
|
244
|
+
const k = store.key(i);
|
|
245
|
+
if (k)
|
|
246
|
+
entries[k] = store.getItem(k) ?? '';
|
|
247
|
+
}
|
|
248
|
+
return { found: true, ok: true, entries };
|
|
249
|
+
}
|
|
250
|
+
// -- the two polling ops: one injection that resolves in-page, rather than
|
|
251
|
+
// one executeScript round-trip per tick --
|
|
252
|
+
case 'waitSelector': {
|
|
253
|
+
const deadline = Date.now() + (a.timeoutMs ?? 5_000);
|
|
254
|
+
const every = a.interval ?? 120;
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
const tick = () => {
|
|
257
|
+
if (sel && deepQuery(sel))
|
|
258
|
+
return resolve({ found: true });
|
|
259
|
+
if (Date.now() > deadline)
|
|
260
|
+
return resolve({ found: false });
|
|
261
|
+
setTimeout(tick, every);
|
|
262
|
+
};
|
|
263
|
+
tick();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
case 'waitFor': {
|
|
267
|
+
const deadline = Date.now() + (a.timeoutMs ?? 30_000);
|
|
268
|
+
const every = a.interval ?? 150;
|
|
269
|
+
const want = typeof a.textContains === 'string' && a.textContains.length > 0 ? a.textContains : null;
|
|
270
|
+
const gone = a.gone === true;
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const hit = () => {
|
|
273
|
+
let present;
|
|
274
|
+
if (sel)
|
|
275
|
+
present = !!deepQuery(sel);
|
|
276
|
+
else if (want)
|
|
277
|
+
present = (document.body?.innerText ?? '').includes(want);
|
|
278
|
+
else
|
|
279
|
+
present = true;
|
|
280
|
+
return gone ? !present : present;
|
|
281
|
+
};
|
|
282
|
+
const tick = () => {
|
|
283
|
+
if (hit())
|
|
284
|
+
return resolve({ found: true, matched: true });
|
|
285
|
+
if (Date.now() > deadline)
|
|
286
|
+
return resolve({ found: true, matched: false });
|
|
287
|
+
setTimeout(tick, every);
|
|
288
|
+
};
|
|
289
|
+
tick();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
default:
|
|
293
|
+
return { found: false, error: `unknown page op: ${String(a.op)}` };
|
|
287
294
|
}
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
};
|
|
296
|
+
const el = sel ? deepQuery(sel) : null;
|
|
297
|
+
// Element ops wait for their target IN THIS SAME injection when `timeoutMs`
|
|
298
|
+
// is set: a not-yet-rendered button costs a few in-page ticks, not a second
|
|
299
|
+
// executeScript round-trip (the old waitSelector-then-act pair).
|
|
300
|
+
const waits = a.op === 'click' || a.op === 'type' || a.op === 'focus' || a.op === 'point' || a.op === 'hover' || a.op === 'select';
|
|
301
|
+
if (waits && sel && !el && a.timeoutMs && a.timeoutMs > 0) {
|
|
302
|
+
const deadline = Date.now() + a.timeoutMs;
|
|
303
|
+
const every = a.interval ?? 120;
|
|
304
|
+
return new Promise((resolve) => {
|
|
305
|
+
const tick = () => {
|
|
306
|
+
const hit = deepQuery(sel);
|
|
307
|
+
if (hit)
|
|
308
|
+
return resolve(perform(hit));
|
|
309
|
+
if (Date.now() > deadline)
|
|
310
|
+
return resolve({ found: false });
|
|
311
|
+
setTimeout(tick, every);
|
|
312
|
+
};
|
|
313
|
+
setTimeout(tick, every);
|
|
314
|
+
});
|
|
290
315
|
}
|
|
316
|
+
return perform(el);
|
|
291
317
|
}
|
|
292
318
|
//# sourceMappingURL=page-fns.js.map
|
|
@@ -14,7 +14,20 @@ export interface PageDims {
|
|
|
14
14
|
/** Full content box (document) width/height. */
|
|
15
15
|
fullW: number;
|
|
16
16
|
fullH: number;
|
|
17
|
+
/** window.devicePixelRatio (default 1). Output pixels = CSS px * dpr * clip.scale. */
|
|
18
|
+
dpr?: number;
|
|
19
|
+
/** Current scroll offset of the top document (default 0,0); a viewport clip starts here. */
|
|
20
|
+
scrollX?: number;
|
|
21
|
+
scrollY?: number;
|
|
17
22
|
}
|
|
23
|
+
export type ScreenshotFormat = 'png' | 'jpeg';
|
|
24
|
+
/** Default encoding: JPEG at this quality is ~5-10x smaller than PNG on a
|
|
25
|
+
* typical page and still perfectly legible to a vision model. */
|
|
26
|
+
export declare const DEFAULT_SCREENSHOT_FORMAT: ScreenshotFormat;
|
|
27
|
+
export declare const DEFAULT_JPEG_QUALITY = 70;
|
|
28
|
+
/** Output size multiplier relative to CSS pixels. 1 = CSS px (so a Retina
|
|
29
|
+
* viewport is NOT captured at 2x); 2 = device px on a 2x display. */
|
|
30
|
+
export declare const DEFAULT_SCREENSHOT_SCALE = 1;
|
|
18
31
|
/** An element's box in DOCUMENT coordinates (viewport rect + scroll offset), CSS px. */
|
|
19
32
|
export interface ElementRect {
|
|
20
33
|
x: number;
|
|
@@ -55,4 +68,5 @@ export declare const MAX_CAPTURE_PX = 16384;
|
|
|
55
68
|
export declare function planScreenshot(dims: PageDims, opts?: {
|
|
56
69
|
fullPage?: boolean;
|
|
57
70
|
element?: ElementRect | null;
|
|
71
|
+
scale?: number;
|
|
58
72
|
}): ScreenshotPlan;
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.MAX_CAPTURE_PX = void 0;
|
|
11
|
+
exports.MAX_CAPTURE_PX = exports.DEFAULT_SCREENSHOT_SCALE = exports.DEFAULT_JPEG_QUALITY = exports.DEFAULT_SCREENSHOT_FORMAT = void 0;
|
|
12
12
|
exports.planScreenshot = planScreenshot;
|
|
13
|
+
/** Default encoding: JPEG at this quality is ~5-10x smaller than PNG on a
|
|
14
|
+
* typical page and still perfectly legible to a vision model. */
|
|
15
|
+
exports.DEFAULT_SCREENSHOT_FORMAT = 'jpeg';
|
|
16
|
+
exports.DEFAULT_JPEG_QUALITY = 70;
|
|
17
|
+
/** Output size multiplier relative to CSS pixels. 1 = CSS px (so a Retina
|
|
18
|
+
* viewport is NOT captured at 2x); 2 = device px on a 2x display. */
|
|
19
|
+
exports.DEFAULT_SCREENSHOT_SCALE = 1;
|
|
13
20
|
/**
|
|
14
21
|
* Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
|
|
15
22
|
* tall images; beyond this we clamp the clip and flag `truncated`.
|
|
@@ -20,11 +27,15 @@ exports.MAX_CAPTURE_PX = 16384;
|
|
|
20
27
|
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
21
28
|
*/
|
|
22
29
|
function planScreenshot(dims, opts = {}) {
|
|
30
|
+
// CDP's clip.scale multiplies on top of the device scale factor, so dividing
|
|
31
|
+
// by the DPR yields exactly `opts.scale` output pixels per CSS pixel.
|
|
32
|
+
const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
|
|
33
|
+
const scale = (opts.scale && opts.scale > 0 ? opts.scale : exports.DEFAULT_SCREENSHOT_SCALE) / dpr;
|
|
23
34
|
if (opts.element) {
|
|
24
35
|
const realH = Math.max(1, Math.round(opts.element.h));
|
|
25
36
|
const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
|
|
26
37
|
return {
|
|
27
|
-
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale
|
|
38
|
+
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
|
|
28
39
|
captureBeyondViewport: true,
|
|
29
40
|
width: Math.max(1, Math.round(opts.element.w)),
|
|
30
41
|
height: Math.min(realH, exports.MAX_CAPTURE_PX),
|
|
@@ -35,7 +46,7 @@ function planScreenshot(dims, opts = {}) {
|
|
|
35
46
|
if (opts.fullPage) {
|
|
36
47
|
const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
|
|
37
48
|
return {
|
|
38
|
-
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale
|
|
49
|
+
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
|
|
39
50
|
captureBeyondViewport: true,
|
|
40
51
|
width: dims.fullW,
|
|
41
52
|
height: clipH,
|
|
@@ -43,8 +54,12 @@ function planScreenshot(dims, opts = {}) {
|
|
|
43
54
|
fullHeight: dims.fullH,
|
|
44
55
|
};
|
|
45
56
|
}
|
|
46
|
-
// Plain viewport:
|
|
57
|
+
// Plain viewport: capture what's visible. A clip is only needed to apply a
|
|
58
|
+
// scale; at scale 1 on a 1x display the bare capture is identical and cheaper.
|
|
47
59
|
return {
|
|
60
|
+
...(scale !== 1
|
|
61
|
+
? { clip: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } }
|
|
62
|
+
: {}),
|
|
48
63
|
captureBeyondViewport: false,
|
|
49
64
|
width: dims.w,
|
|
50
65
|
height: dims.h,
|
|
@@ -23,6 +23,28 @@ export interface RawSnapshot {
|
|
|
23
23
|
title: string;
|
|
24
24
|
nodes: RawSnapshotNode[];
|
|
25
25
|
truncated: boolean;
|
|
26
|
+
/** Locator mode, nothing matched: up to 8 "role \"name\"" strings of the
|
|
27
|
+
* same role, so the caller can say what WAS there without a second read. */
|
|
28
|
+
nearby?: string[];
|
|
26
29
|
}
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
30
|
+
/** A role/name query resolved IN the page (see `collectSnapshot`). */
|
|
31
|
+
export interface SnapshotLocator {
|
|
32
|
+
role?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with
|
|
37
|
+
* fresh refs.
|
|
38
|
+
*
|
|
39
|
+
* With a `locator`, the walk is the same but the SCORING happens here: only the
|
|
40
|
+
* strongest-tier matches come back (and only they get a ref), so resolving
|
|
41
|
+
* "the Sign in button" ships a handful of nodes instead of the whole tree and
|
|
42
|
+
* touches one or two DOM attributes instead of hundreds. The tiers mirror
|
|
43
|
+
* `src/mcp/locate.ts` exactly (exact, case-insensitive, prefix, contains), and
|
|
44
|
+
* the server re-scores what it receives, so both ends always agree.
|
|
45
|
+
*
|
|
46
|
+
* Reads (visibility, names, values) all happen BEFORE the ref attributes are
|
|
47
|
+
* written: interleaving them made every `innerText` after a `setAttribute`
|
|
48
|
+
* re-run style, which on a big page is most of the snapshot's cost.
|
|
49
|
+
*/
|
|
50
|
+
export declare function collectSnapshot(interactiveOnly?: boolean, max?: number, locator?: SnapshotLocator | null): RawSnapshot;
|