chrome-bridge-mcp 1.10.1 → 1.11.1
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 +3 -3
- package/extension/lib/page-markdown.js +151 -0
- package/extension/manifest.json +1 -1
- package/extension/service-worker.js +93 -15
- package/package.json +1 -1
- package/server/protocol.js +3 -1
- package/server/tools.js +43 -13
- package/tools/cws-upload.mjs +197 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Chrome Bridge
|
|
2
2
|
|
|
3
|
-
       [](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
4
4
|
|
|
5
5
|
**Chrome Bridge is an MCP server that connects Claude Code to your real, logged-in Chrome browser — measured 2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome" extension on a form-filling task, with ~3× the toolset and no paid plan.**
|
|
6
6
|
|
|
@@ -63,10 +63,10 @@ Claude Code calls `navigate` (returning clickable element refs), `accessibility_
|
|
|
63
63
|
|
|
64
64
|
## Token-Efficient Design
|
|
65
65
|
|
|
66
|
-
- **Opt-in Surface**: 31 core tools cost ≈7.
|
|
66
|
+
- **Opt-in Surface**: 31 core tools cost ≈7.9k tokens of `tools/list`, all 60 cost ≈14.9k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
|
|
67
67
|
- **Act-from-Result**: Tools attach a capped preview of interactive elements with short refs. Actions only report a `page_changed` delta when the URL or title actually changes.
|
|
68
68
|
- **Optimized Media**: Screenshots are downscaled to ≤1568px. Full-page captures are sliced into readable segments.
|
|
69
|
-
- **
|
|
69
|
+
- **Payload Escape Hatches**: `save_to` on `read_page`, `extract`, `screenshot` and `http_request` writes the result to a file and returns the path — the bytes never enter the context unless the agent decides to read them. `read_page(mode="markdown")` keeps headings, links and tables at a fraction of the HTML cost. The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
|
|
70
70
|
|
|
71
71
|
## Highlights
|
|
72
72
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rende una pagina in Markdown per il consumo del modello: struttura preservata
|
|
3
|
+
* a una frazione del costo dell'HTML.
|
|
4
|
+
*
|
|
5
|
+
* La funzione viene serializzata e iniettata nella pagina con
|
|
6
|
+
* chrome.scripting.executeScript, quindi deve essere autocontenuta: niente
|
|
7
|
+
* riferimenti a variabili esterne, niente import. Per lo stesso motivo è testata
|
|
8
|
+
* a parte contro un DOM finto (`test/unit/page-markdown.test.js`) invece che dal
|
|
9
|
+
* vivo.
|
|
10
|
+
*
|
|
11
|
+
* Cammina l'albero per blocchi invece di usare un querySelectorAll piatto: un
|
|
12
|
+
* `<li><a>x</a></li>` con il selettore piatto verrebbe emesso due volte, una
|
|
13
|
+
* come voce di lista e una come link.
|
|
14
|
+
*/
|
|
15
|
+
export function buildMarkdown(doc) {
|
|
16
|
+
const d = doc || document;
|
|
17
|
+
const out = [];
|
|
18
|
+
|
|
19
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME']);
|
|
20
|
+
const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
|
21
|
+
const BLOCKS = new Set(['P', 'LI', 'BLOCKQUOTE', 'PRE', 'DT', 'DD', 'FIGCAPTION']);
|
|
22
|
+
const MAX_TABLE_ROWS = 50;
|
|
23
|
+
|
|
24
|
+
const attr = (el, name) => (el.getAttribute ? el.getAttribute(name) : null);
|
|
25
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
26
|
+
|
|
27
|
+
// Dimensioni: il rect è la verità quando c'è un layout, ma su un'immagine non
|
|
28
|
+
// ancora disposta (o in test) restano solo gli attributi.
|
|
29
|
+
const sizeOf = (el) => {
|
|
30
|
+
let w = 0;
|
|
31
|
+
let h = 0;
|
|
32
|
+
if (typeof el.getBoundingClientRect === 'function') {
|
|
33
|
+
const r = el.getBoundingClientRect();
|
|
34
|
+
w = r.width; h = r.height;
|
|
35
|
+
}
|
|
36
|
+
if (!w) w = Number(attr(el, 'width')) || 0;
|
|
37
|
+
if (!h) h = Number(attr(el, 'height')) || 0;
|
|
38
|
+
return { w, h };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Testo inline di un blocco: i link diventano [testo](href) senza che il
|
|
42
|
+
// testo venga emesso una seconda volta dal walker.
|
|
43
|
+
const inline = (node) => {
|
|
44
|
+
if (node.nodeType === 3) return node.textContent || '';
|
|
45
|
+
if (node.nodeType !== 1) return '';
|
|
46
|
+
const tag = node.tagName;
|
|
47
|
+
if (SKIP.has(tag)) return '';
|
|
48
|
+
if (tag === 'BR') return ' ';
|
|
49
|
+
if (tag === 'IMG') {
|
|
50
|
+
const { w, h } = sizeOf(node);
|
|
51
|
+
if (w >= 50 && h >= 50) {
|
|
52
|
+
const alt = clean(attr(node, 'alt')) || 'no alt text';
|
|
53
|
+
return `  || ''}) `;
|
|
54
|
+
}
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
const kids = (node.childNodes || []).map(inline).join('');
|
|
58
|
+
if (tag === 'A') {
|
|
59
|
+
const href = attr(node, 'href');
|
|
60
|
+
const label = clean(kids);
|
|
61
|
+
if (!label) return '';
|
|
62
|
+
return href ? `[${label}](${href})` : label;
|
|
63
|
+
}
|
|
64
|
+
if (tag === 'CODE' || tag === 'KBD') {
|
|
65
|
+
const label = clean(kids);
|
|
66
|
+
return label ? '`' + label + '`' : '';
|
|
67
|
+
}
|
|
68
|
+
if (tag === 'STRONG' || tag === 'B') { const l = clean(kids); return l ? `**${l}**` : ''; }
|
|
69
|
+
if (tag === 'EM' || tag === 'I') { const l = clean(kids); return l ? `*${l}*` : ''; }
|
|
70
|
+
return kids;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const cellsOf = (row) => (row.childNodes || [])
|
|
74
|
+
.filter((n) => n.nodeType === 1 && (n.tagName === 'TD' || n.tagName === 'TH'))
|
|
75
|
+
.map((c) => clean(c.textContent).replace(/\|/g, '\\|'));
|
|
76
|
+
|
|
77
|
+
const renderTable = (table) => {
|
|
78
|
+
const rows = (table.querySelectorAll ? table.querySelectorAll('tr') : []) || [];
|
|
79
|
+
if (!rows.length) return;
|
|
80
|
+
const all = rows.map(cellsOf).filter((r) => r.length);
|
|
81
|
+
if (!all.length) return;
|
|
82
|
+
|
|
83
|
+
// Gli header veri, non un segnaposto: la prima riga se contiene th,
|
|
84
|
+
// altrimenti colonne numerate.
|
|
85
|
+
const firstHasTh = (rows[0].childNodes || []).some((n) => n.nodeType === 1 && n.tagName === 'TH');
|
|
86
|
+
const header = firstHasTh ? all[0] : all[0].map((_, i) => `col${i + 1}`);
|
|
87
|
+
const body = firstHasTh ? all.slice(1) : all;
|
|
88
|
+
const width = header.length;
|
|
89
|
+
|
|
90
|
+
out.push('');
|
|
91
|
+
out.push(`| ${header.join(' | ')} |`);
|
|
92
|
+
out.push(`|${' --- |'.repeat(width)}`);
|
|
93
|
+
for (const r of body.slice(0, MAX_TABLE_ROWS)) {
|
|
94
|
+
const padded = r.slice(0, width);
|
|
95
|
+
while (padded.length < width) padded.push('');
|
|
96
|
+
out.push(`| ${padded.join(' | ')} |`);
|
|
97
|
+
}
|
|
98
|
+
if (body.length > MAX_TABLE_ROWS) {
|
|
99
|
+
out.push(`_${body.length} rows total, ${body.length - MAX_TABLE_ROWS} omitted — use extract_table with \`where\` to filter server-side instead of reading them here._`);
|
|
100
|
+
}
|
|
101
|
+
out.push('');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const walk = (node) => {
|
|
105
|
+
if (!node || node.nodeType !== 1) return;
|
|
106
|
+
const tag = node.tagName;
|
|
107
|
+
if (SKIP.has(tag)) return;
|
|
108
|
+
|
|
109
|
+
if (tag === 'TABLE') { renderTable(node); return; }
|
|
110
|
+
|
|
111
|
+
if (HEADINGS.has(tag)) {
|
|
112
|
+
const text = clean(inline(node));
|
|
113
|
+
if (text) out.push(`${'#'.repeat(Number(tag[1]))} ${text}`, '');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (BLOCKS.has(tag)) {
|
|
118
|
+
const text = clean(inline(node));
|
|
119
|
+
if (!text) return;
|
|
120
|
+
if (tag === 'LI') out.push(`- ${text}`);
|
|
121
|
+
else if (tag === 'BLOCKQUOTE') out.push(`> ${text}`, '');
|
|
122
|
+
else if (tag === 'PRE') out.push('```', text, '```', '');
|
|
123
|
+
else if (tag === 'FIGCAPTION') out.push(`*${text}*`, '');
|
|
124
|
+
else out.push(text, '');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (tag === 'IMG') {
|
|
129
|
+
const rendered = clean(inline(node));
|
|
130
|
+
if (rendered) out.push(rendered, '');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const child of node.childNodes || []) walk(child);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const title = clean(d.title);
|
|
138
|
+
if (title) out.push(`# ${title}`, '');
|
|
139
|
+
|
|
140
|
+
// Sommario delle immagini grandi: dice al modello se vale la pena chiedere
|
|
141
|
+
// uno screenshot, al costo di una riga.
|
|
142
|
+
const imgs = (d.body?.querySelectorAll ? d.body.querySelectorAll('img') : []) || [];
|
|
143
|
+
const significant = imgs.filter((img) => { const { w, h } = sizeOf(img); return w >= 100 && h >= 100; });
|
|
144
|
+
if (significant.length) {
|
|
145
|
+
out.push(`_${significant.length} image(s) at least 100x100 on this page — take a screenshot if the visual matters._`, '');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
walk(d.body);
|
|
149
|
+
|
|
150
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
151
|
+
}
|
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.11.1",
|
|
5
5
|
"description": "Bridge your browser to Claude Code: 60 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",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import './telemetry.js';
|
|
9
|
+
import { buildMarkdown } from './lib/page-markdown.js';
|
|
9
10
|
const { pushError } = globalThis.__cbTelemetry;
|
|
10
11
|
|
|
11
12
|
const DEFAULT_PORT = 8765;
|
|
@@ -423,6 +424,8 @@ async function executeCommand(msg) {
|
|
|
423
424
|
return await cmdManageDownloads(params);
|
|
424
425
|
case 'save_page':
|
|
425
426
|
return await cmdSavePage(params);
|
|
427
|
+
case 'wait_for_text':
|
|
428
|
+
return await cmdWaitForText(params);
|
|
426
429
|
case 'http_request':
|
|
427
430
|
return await cmdHttpRequest(params);
|
|
428
431
|
case 'set_zoom':
|
|
@@ -722,13 +725,13 @@ async function cmdExecuteJs({ code, tab_id, frame_id }) {
|
|
|
722
725
|
}
|
|
723
726
|
}
|
|
724
727
|
|
|
725
|
-
async function cmdClick({ selector, tab_id, frame_id, force = false }) {
|
|
728
|
+
async function cmdClick({ selector, tab_id, frame_id, force = false, button = 'left', count = 1 }) {
|
|
726
729
|
if (!selector) throw new Error('Missing required parameter: selector');
|
|
727
730
|
const tabId = await resolveTabId(tab_id);
|
|
728
731
|
|
|
729
732
|
const results = await chrome.scripting.executeScript({
|
|
730
733
|
target: scriptTarget(tabId, frame_id),
|
|
731
|
-
func: (sel, forceClick) => {
|
|
734
|
+
func: (sel, forceClick, btn, times) => {
|
|
732
735
|
function deepQuery(sel) {
|
|
733
736
|
if (!sel.includes('>>>')) return document.querySelector(sel);
|
|
734
737
|
const parts = sel.split('>>>').map((s) => s.trim());
|
|
@@ -755,15 +758,29 @@ async function cmdClick({ selector, tab_id, frame_id, force = false }) {
|
|
|
755
758
|
return { clicked: false, occluded: true, occluder: { selector: occluderSel, tag: top.tagName.toLowerCase(), text: (top.textContent || '').trim().substring(0, 80) } };
|
|
756
759
|
}
|
|
757
760
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
761
|
+
// button 2 = destro: il browser non apre il suo menu nativo da un evento
|
|
762
|
+
// sintetico, ma le pagine che ne implementano uno proprio ascoltano
|
|
763
|
+
// 'contextmenu', ed è quello il caso d'uso reale.
|
|
764
|
+
const right = btn === 'right';
|
|
765
|
+
const clickCount = Math.max(1, Number(times) || 1);
|
|
766
|
+
const base = { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: right ? 2 : 0, buttons: right ? 2 : 1 };
|
|
767
|
+
|
|
768
|
+
for (let i = 0; i < clickCount; i++) {
|
|
769
|
+
const opts = { ...base, detail: i + 1 };
|
|
770
|
+
el.dispatchEvent(new PointerEvent('pointerdown', opts));
|
|
771
|
+
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
772
|
+
el.dispatchEvent(new PointerEvent('pointerup', opts));
|
|
773
|
+
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
774
|
+
if (right) el.dispatchEvent(new MouseEvent('contextmenu', opts));
|
|
775
|
+
else el.click();
|
|
776
|
+
}
|
|
777
|
+
// dblclick non è implicito nei due click: va emesso a parte, ed è quello
|
|
778
|
+
// che seleziona una parola o apre un editor inline.
|
|
779
|
+
if (!right && clickCount >= 2) el.dispatchEvent(new MouseEvent('dblclick', { ...base, detail: 2 }));
|
|
780
|
+
|
|
781
|
+
return { clicked: true, button: right ? 'right' : 'left', count: clickCount, tagName: el.tagName, text: el.textContent?.substring(0, 100) };
|
|
765
782
|
},
|
|
766
|
-
args: [selector, force],
|
|
783
|
+
args: [selector, force, button, count],
|
|
767
784
|
world: 'MAIN',
|
|
768
785
|
});
|
|
769
786
|
|
|
@@ -853,6 +870,18 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
|
|
|
853
870
|
return results?.[0]?.result ?? '';
|
|
854
871
|
}
|
|
855
872
|
|
|
873
|
+
if (mode === 'markdown') {
|
|
874
|
+
// buildMarkdown è autocontenuta apposta: chrome.scripting ne serializza il
|
|
875
|
+
// sorgente e la esegue nella pagina, dove non esiste il modulo.
|
|
876
|
+
const res = await chrome.scripting.executeScript({
|
|
877
|
+
target: scriptTarget(tabId, frame_id),
|
|
878
|
+
func: buildMarkdown,
|
|
879
|
+
args: [null],
|
|
880
|
+
world: 'MAIN',
|
|
881
|
+
});
|
|
882
|
+
return res?.[0]?.result ?? '';
|
|
883
|
+
}
|
|
884
|
+
|
|
856
885
|
if (mode === 'accessibility') {
|
|
857
886
|
// Ritorna una struttura semplificata dell'a11y tree
|
|
858
887
|
const results = await chrome.scripting.executeScript({
|
|
@@ -1639,7 +1668,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
|
|
|
1639
1668
|
|
|
1640
1669
|
// --- viewport_resize ---
|
|
1641
1670
|
|
|
1642
|
-
async function cmdViewportResize({ preset, width, height, tab_id }) {
|
|
1671
|
+
async function cmdViewportResize({ preset, width, height, read_only = false, tab_id }) {
|
|
1643
1672
|
const tabId = await resolveTabId(tab_id);
|
|
1644
1673
|
const tab = await chrome.tabs.get(tabId);
|
|
1645
1674
|
|
|
@@ -1656,10 +1685,13 @@ async function cmdViewportResize({ preset, width, height, tab_id }) {
|
|
|
1656
1685
|
if (targetW) updateOpts.width = targetW;
|
|
1657
1686
|
if (targetH) updateOpts.height = targetH;
|
|
1658
1687
|
|
|
1659
|
-
if (
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1688
|
+
if (!read_only) {
|
|
1689
|
+
if (Object.keys(updateOpts).length === 0) throw new Error('Provide preset, width, or height');
|
|
1690
|
+
await chrome.windows.update(tab.windowId, updateOpts);
|
|
1691
|
+
// La finestra si ridisegna in modo asincrono: senza la pausa si leggono le
|
|
1692
|
+
// dimensioni vecchie e si riporta un risultato falso.
|
|
1693
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1694
|
+
}
|
|
1663
1695
|
|
|
1664
1696
|
const results = await chrome.scripting.executeScript({
|
|
1665
1697
|
target: { tabId },
|
|
@@ -1671,6 +1703,7 @@ async function cmdViewportResize({ preset, width, height, tab_id }) {
|
|
|
1671
1703
|
world: 'MAIN',
|
|
1672
1704
|
});
|
|
1673
1705
|
const actual = results?.[0]?.result ?? {};
|
|
1706
|
+
if (read_only) return { actual };
|
|
1674
1707
|
return { requested: { width: targetW, height: targetH, preset: preset || null }, actual };
|
|
1675
1708
|
}
|
|
1676
1709
|
|
|
@@ -3508,6 +3541,51 @@ async function cmdHttpRequest({ url, method = 'GET', headers, body, binary = fal
|
|
|
3508
3541
|
return { ...common, body: text, size: text.length };
|
|
3509
3542
|
}
|
|
3510
3543
|
|
|
3544
|
+
// --- wait_for_text ---
|
|
3545
|
+
|
|
3546
|
+
/**
|
|
3547
|
+
* Attende che un testo compaia. Si poteva già fare con wait_for_function, ma
|
|
3548
|
+
* quella strada richiede il toggle "Allow user scripts" e un'espressione scritta
|
|
3549
|
+
* a mano: questa no.
|
|
3550
|
+
*/
|
|
3551
|
+
async function cmdWaitForText({ text, selector, timeout = 10000, interval = 200, tab_id, frame_id }) {
|
|
3552
|
+
if (!text) throw new Error('Missing required parameter: text');
|
|
3553
|
+
const tabId = await resolveTabId(tab_id);
|
|
3554
|
+
|
|
3555
|
+
const results = await chrome.scripting.executeScript({
|
|
3556
|
+
target: scriptTarget(tabId, frame_id),
|
|
3557
|
+
func: (needle, scopeSel, tout, intv) => new Promise((resolve) => {
|
|
3558
|
+
const started = Date.now();
|
|
3559
|
+
const wanted = String(needle).toLowerCase();
|
|
3560
|
+
const check = () => {
|
|
3561
|
+
const root = scopeSel ? document.querySelector(scopeSel) : document.body;
|
|
3562
|
+
const haystack = (root?.innerText || '').toLowerCase();
|
|
3563
|
+
const at = haystack.indexOf(wanted);
|
|
3564
|
+
if (at !== -1) {
|
|
3565
|
+
resolve({
|
|
3566
|
+
found: true,
|
|
3567
|
+
waited_ms: Date.now() - started,
|
|
3568
|
+
// Contesto attorno alla prima occorrenza: conferma di aver trovato
|
|
3569
|
+
// la cosa giusta senza restituire l'intera pagina.
|
|
3570
|
+
context: (root?.innerText || '').slice(Math.max(0, at - 60), at + wanted.length + 60).trim(),
|
|
3571
|
+
});
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
if (Date.now() - started >= tout) {
|
|
3575
|
+
resolve({ found: false, error: `Text not found within ${tout}ms: ${needle}` });
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
setTimeout(check, intv);
|
|
3579
|
+
};
|
|
3580
|
+
check();
|
|
3581
|
+
}),
|
|
3582
|
+
args: [text, selector || null, timeout, Math.max(50, interval)],
|
|
3583
|
+
world: 'MAIN',
|
|
3584
|
+
});
|
|
3585
|
+
|
|
3586
|
+
return results?.[0]?.result ?? { found: false, error: 'No result' };
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3511
3589
|
async function cmdSavePage({ tab_id }) {
|
|
3512
3590
|
const tabId = await resolveTabId(tab_id);
|
|
3513
3591
|
const blob = await new Promise((resolve, reject) => {
|
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.11.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server with 60 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/protocol.js
CHANGED
|
@@ -43,6 +43,7 @@ export const MessageType = Object.freeze({
|
|
|
43
43
|
UPLOAD_FILE: 'upload_file',
|
|
44
44
|
WAIT_FOR_NAVIGATION: 'wait_for_navigation',
|
|
45
45
|
WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
|
|
46
|
+
WAIT_FOR_TEXT: 'wait_for_text',
|
|
46
47
|
HANDLE_DIALOGS: 'handle_dialogs',
|
|
47
48
|
FIND_TEXT: 'find_text',
|
|
48
49
|
NETWORK_RULES: 'network_rules',
|
|
@@ -81,7 +82,7 @@ export const MessageType = Object.freeze({
|
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
// Versione
|
|
84
|
-
export const VERSION = '1.
|
|
85
|
+
export const VERSION = '1.11.1';
|
|
85
86
|
|
|
86
87
|
// Configurazione
|
|
87
88
|
export const DEFAULT_PORT = 8765;
|
|
@@ -127,6 +128,7 @@ export function getTimeout(type) {
|
|
|
127
128
|
if (type === MessageType.WAIT_FOR_ELEMENT
|
|
128
129
|
|| type === MessageType.WAIT_FOR_NAVIGATION
|
|
129
130
|
|| type === MessageType.WAIT_FOR_NETWORK_IDLE
|
|
131
|
+
|| type === MessageType.WAIT_FOR_TEXT
|
|
130
132
|
|| type === MessageType.UPLOAD_FILE
|
|
131
133
|
|| type === MessageType.MANAGE_DOWNLOADS
|
|
132
134
|
|| type === MessageType.SAVE_PAGE
|
package/server/tools.js
CHANGED
|
@@ -187,6 +187,18 @@ export const TOOL_CAPS = {
|
|
|
187
187
|
|
|
188
188
|
// Parametri ubiqui: un solo testo, così `tab_id` non significa una cosa in un
|
|
189
189
|
// tool e un'altra nel gemello. test/unit/tool-parameters.test.js lo verifica.
|
|
190
|
+
// save_to: il payload va su disco e nel contesto resta solo il percorso più un
|
|
191
|
+
// sommario. Deliberatamente opt-in per chiamata e non automatico dopo ogni
|
|
192
|
+
// azione: scrivere file per chi non li leggerà è una tassa, e il server non
|
|
193
|
+
// controlla la directory di lavoro del client.
|
|
194
|
+
const saveToField = (what) => z.string().optional()
|
|
195
|
+
.describe(`Absolute path: write ${what} there and return the path instead of the content`);
|
|
196
|
+
|
|
197
|
+
async function savedSummary(path, bytes, extra = {}) {
|
|
198
|
+
await writeFile(path, bytes);
|
|
199
|
+
return { content: [{ type: 'text', text: jsonText({ saved: path, bytes: bytes.length, ...extra }) }] };
|
|
200
|
+
}
|
|
201
|
+
|
|
190
202
|
const selectorField = (extra = '') => z.string()
|
|
191
203
|
.describe(('CSS selector; ">>>" pierces shadow DOM. ' + extra).trim());
|
|
192
204
|
const waitAfter = z.enum(['none', 'navigation', 'networkidle']).optional().default('none')
|
|
@@ -509,9 +521,12 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
509
521
|
+ 'Downscaled to ≤1568px, so fine print may not survive.',
|
|
510
522
|
{
|
|
511
523
|
tab_id: tabId,
|
|
524
|
+
save_to: saveToField('the PNG'),
|
|
512
525
|
},
|
|
513
|
-
async ({ tab_id }) => {
|
|
526
|
+
async ({ tab_id, save_to }) => {
|
|
514
527
|
const data = await send(MessageType.SCREENSHOT, { tab_id });
|
|
528
|
+
const b64 = data?.image ?? data?.data;
|
|
529
|
+
if (save_to && b64) return savedSummary(save_to, Buffer.from(b64, 'base64'), { mimeType: 'image/png' });
|
|
515
530
|
// data.image è base64 PNG
|
|
516
531
|
if (data && data.image) {
|
|
517
532
|
return {
|
|
@@ -562,14 +577,16 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
562
577
|
selector: z.string().optional().describe('CSS selector; ">>>" pierces shadow DOM. Ignored when ref is given'),
|
|
563
578
|
ref: z.string().optional().describe('From get_interactives, e.g. "n3"'),
|
|
564
579
|
force: z.boolean().optional().default(false).describe('Click even if occluded'),
|
|
580
|
+
button: z.enum(['left', 'right']).optional().default('left').describe('right opens the page context menu instead of activating the element'),
|
|
581
|
+
count: z.number().optional().default(1).describe('2 emits dblclick after the two clicks, which is what selects a word or opens an editor'),
|
|
565
582
|
wait_after: waitAfter,
|
|
566
583
|
tab_id: tabId,
|
|
567
584
|
frame_id: frameId,
|
|
568
585
|
},
|
|
569
|
-
async ({ selector, ref, force, wait_after, tab_id, frame_id }) => {
|
|
586
|
+
async ({ selector, ref, force, button, count, wait_after, tab_id, frame_id }) => {
|
|
570
587
|
const target = resolveTarget(selector, ref, tab_id);
|
|
571
588
|
const before = await tabSnapshot(tab_id);
|
|
572
|
-
const data = await send(MessageType.CLICK, { selector: target, force, frame_id, tab_id });
|
|
589
|
+
const data = await send(MessageType.CLICK, { selector: target, force, button: button ?? 'left', count: count ?? 1, frame_id, tab_id });
|
|
573
590
|
// Niente attesa se il click non è andato a buon fine (es. elemento occluso)
|
|
574
591
|
const waited = data?.occluded ? null : await applyWaitAfter(send, wait_after, tab_id);
|
|
575
592
|
const changed = data?.occluded ? null : pageDelta(before, await tabSnapshot(tab_id));
|
|
@@ -617,21 +634,26 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
617
634
|
// --- read_page ---
|
|
618
635
|
server.tool(
|
|
619
636
|
'read_page',
|
|
620
|
-
'Read the page as text (default), raw HTML, or accessibility tree. Read-only.
|
|
637
|
+
'Read the page as text (default), markdown, raw HTML, or accessibility tree. Read-only. markdown keeps '
|
|
638
|
+
+ 'headings, links and tables at a fraction of the HTML cost, and is usually the right middle ground. '
|
|
639
|
+
+ 'Expensive on large pages: '
|
|
621
640
|
+ 'HTML on a big table costs tens of thousands of tokens for data you then filter anyway — prefer '
|
|
622
641
|
+ 'extract_table or extract for tabular and repeated content, and get_interactives to find click targets.',
|
|
623
642
|
{
|
|
624
|
-
mode: z.enum(['text', 'html', 'accessibility']).default('text').describe('text strips markup,
|
|
643
|
+
mode: z.enum(['text', 'markdown', 'html', 'accessibility']).default('text').describe('text strips markup, markdown keeps headings/links/tables far cheaper than html, accessibility returns the a11y tree'),
|
|
625
644
|
tab_id: tabId,
|
|
626
645
|
frame_id: frameId,
|
|
627
646
|
max_length: z.number().optional().default(50000).describe('Max output chars'),
|
|
647
|
+
save_to: saveToField('the page'),
|
|
628
648
|
},
|
|
629
|
-
async ({ mode, tab_id, frame_id, max_length }) => {
|
|
649
|
+
async ({ mode, tab_id, frame_id, max_length, save_to }) => {
|
|
630
650
|
const data = await send(MessageType.READ_PAGE, { mode, tab_id, frame_id });
|
|
651
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data);
|
|
652
|
+
if (save_to) return savedSummary(save_to, Buffer.from(text, 'utf8'), { mode: mode ?? 'text' });
|
|
631
653
|
return {
|
|
632
654
|
content: [{
|
|
633
655
|
type: 'text',
|
|
634
|
-
text: truncateText(
|
|
656
|
+
text: truncateText(text, max_length ?? 50000, 'max_length'),
|
|
635
657
|
}],
|
|
636
658
|
};
|
|
637
659
|
}
|
|
@@ -869,8 +891,9 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
869
891
|
+ '**returns `found: false` with a reason instead of raising**, so a caller that ignores the result '
|
|
870
892
|
+ 'silently proceeds as if the wait had succeeded. Read-only: waiting changes nothing on the page.',
|
|
871
893
|
{
|
|
872
|
-
condition: z.enum(['element', 'function', 'navigation', 'network_idle']).describe('
|
|
873
|
-
selector: z.string().optional().describe('condition=element'),
|
|
894
|
+
condition: z.enum(['element', 'text', 'function', 'navigation', 'network_idle']).describe('element and text need selector or text; function needs expression'),
|
|
895
|
+
selector: z.string().optional().describe('condition=element; with condition=text it narrows the search to that subtree'),
|
|
896
|
+
text: z.string().optional().describe('Literal text to wait for (condition=text), matched case-insensitively'),
|
|
874
897
|
expression: z.string().optional().describe('JS expression (condition=function)'),
|
|
875
898
|
visible: z.boolean().optional().default(false).describe('Element must also be visible'),
|
|
876
899
|
mode: z.enum(['load', 'spa']).optional().default('load').describe('spa = pushState/popstate/hashchange'),
|
|
@@ -880,12 +903,16 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
880
903
|
tab_id: tabId,
|
|
881
904
|
frame_id: frameId,
|
|
882
905
|
},
|
|
883
|
-
async ({ condition, selector, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
|
|
906
|
+
async ({ condition, selector, text, expression, visible, mode, idle_ms, timeout, interval, tab_id, frame_id }) => {
|
|
884
907
|
let data;
|
|
885
908
|
if (condition === 'element') {
|
|
886
909
|
data = await send(MessageType.WAIT_FOR_ELEMENT, {
|
|
887
910
|
selector, timeout: timeout ?? 10000, interval: interval ?? 200, visible: visible ?? false, tab_id, frame_id,
|
|
888
911
|
});
|
|
912
|
+
} else if (condition === 'text') {
|
|
913
|
+
data = await send(MessageType.WAIT_FOR_TEXT, {
|
|
914
|
+
text, selector, timeout: timeout ?? 10000, interval: interval ?? 200, tab_id, frame_id,
|
|
915
|
+
});
|
|
889
916
|
} else if (condition === 'function') {
|
|
890
917
|
data = await send(MessageType.WAIT_FOR_FUNCTION, {
|
|
891
918
|
expression, timeout: timeout ?? 10000, polling_ms: interval ?? 100, tab_id, frame_id,
|
|
@@ -997,13 +1024,14 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
997
1024
|
+ 'corresponding half of the preset, so preset plus width gives a custom width at the preset height. '
|
|
998
1025
|
+ 'A maximized window on ChromeOS ignores the request.',
|
|
999
1026
|
{
|
|
1027
|
+
action: z.enum(['set', 'get']).optional().default('set').describe('get reports the current viewport without resizing anything'),
|
|
1000
1028
|
preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
|
|
1001
1029
|
width: z.number().optional().describe('Overrides preset'),
|
|
1002
1030
|
height: z.number().optional().describe('Overrides preset'),
|
|
1003
1031
|
tab_id: tabId,
|
|
1004
1032
|
},
|
|
1005
|
-
async ({ preset, width, height, tab_id }) => {
|
|
1006
|
-
const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, tab_id });
|
|
1033
|
+
async ({ action, preset, width, height, tab_id }) => {
|
|
1034
|
+
const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, read_only: (action ?? 'set') === 'get', tab_id });
|
|
1007
1035
|
return {
|
|
1008
1036
|
content: [{
|
|
1009
1037
|
type: 'text',
|
|
@@ -1877,8 +1905,9 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1877
1905
|
format: z.enum(['lines', 'json']).optional().default('lines').describe('lines is compact; json keeps one object per record'),
|
|
1878
1906
|
tab_id: tabId,
|
|
1879
1907
|
max_length: z.number().optional().default(20000).describe('Max output chars'),
|
|
1908
|
+
save_to: saveToField('the records as JSON'),
|
|
1880
1909
|
},
|
|
1881
|
-
async ({ item_selector, fields, max_items, format, tab_id, max_length }) => {
|
|
1910
|
+
async ({ item_selector, fields, max_items, format, tab_id, max_length , save_to }) => {
|
|
1882
1911
|
max_length = max_length ?? DEFAULT_MAX_OUTPUT;
|
|
1883
1912
|
const html = await send(MessageType.READ_PAGE, { mode: 'html', tab_id });
|
|
1884
1913
|
if (typeof html !== 'string') throw new Error('Could not read page HTML');
|
|
@@ -1895,6 +1924,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1895
1924
|
}
|
|
1896
1925
|
return row;
|
|
1897
1926
|
});
|
|
1927
|
+
if (save_to) return savedSummary(save_to, Buffer.from(JSON.stringify({ total: nodes.length, shown: items.length, items }), 'utf8'), { total: nodes.length, shown: items.length });
|
|
1898
1928
|
if ((format ?? 'lines') === 'json') {
|
|
1899
1929
|
return { content: [{ type: 'text', text: truncateText(JSON.stringify({ total: nodes.length, shown: items.length, items }), max_length, 'max_items or max_length') }] };
|
|
1900
1930
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Carica e pubblica lo zip dell'estensione sul Chrome Web Store via API.
|
|
4
|
+
*
|
|
5
|
+
* Esiste per togliere di mezzo il TOTP: la dev console non è automatizzabile
|
|
6
|
+
* (la gallery non è scriptabile da un'estensione, e via CDP serve comunque il
|
|
7
|
+
* login Google completo), mentre l'API vuole solo un refresh token revocabile.
|
|
8
|
+
*
|
|
9
|
+
* Credenziali in ~/.config/chrome-bridge/cws.json (chmod 600), MAI nel repo:
|
|
10
|
+
* { "client_id": "...", "client_secret": "...", "refresh_token": "...", "item_id": "..." }
|
|
11
|
+
* Sovrascrivibili da CWS_CLIENT_ID / CWS_CLIENT_SECRET / CWS_REFRESH_TOKEN / CWS_ITEM_ID.
|
|
12
|
+
*
|
|
13
|
+
* Uso:
|
|
14
|
+
* node tools/cws-upload.mjs dist/chrome-bridge-extension-1.11.0.zip # carica come bozza
|
|
15
|
+
* node tools/cws-upload.mjs dist/....zip --publish # carica e pubblica
|
|
16
|
+
* node tools/cws-upload.mjs --auth [porta] # URL di consenso + cattura del codice sul loopback
|
|
17
|
+
* node tools/cws-upload.mjs --exchange <code> [--redirect <uri>] # code → refresh token
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from 'node:http';
|
|
20
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { join, dirname } from 'node:path';
|
|
23
|
+
|
|
24
|
+
const CONFIG = join(homedir(), '.config', 'chrome-bridge', 'cws.json');
|
|
25
|
+
const SCOPE = 'https://www.googleapis.com/auth/chromewebstore';
|
|
26
|
+
// Google ha dismesso il redirect fuori banda (urn:ietf:wg:oauth:2.0:oob): un
|
|
27
|
+
// client nuovo che lo usa riceve "Errore 400: invalid_request". Per i client
|
|
28
|
+
// desktop la strada supportata è il loopback su una porta qualsiasi, che qui
|
|
29
|
+
// viene aperta al volo e chiusa appena arriva il codice.
|
|
30
|
+
function buildAuthUrl(clientId, redirectUri) {
|
|
31
|
+
return 'https://accounts.google.com/o/oauth2/auth?' + new URLSearchParams({
|
|
32
|
+
client_id: clientId,
|
|
33
|
+
redirect_uri: redirectUri,
|
|
34
|
+
response_type: 'code',
|
|
35
|
+
scope: SCOPE,
|
|
36
|
+
access_type: 'offline',
|
|
37
|
+
prompt: 'consent',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Attende un solo codice sul loopback e poi chiude: nessun server che resta su.
|
|
42
|
+
function waitForCode(port) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const server = createServer((req, res) => {
|
|
45
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
46
|
+
const code = url.searchParams.get('code');
|
|
47
|
+
const err = url.searchParams.get('error');
|
|
48
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
49
|
+
res.end(`<h2>${code ? 'Autorizzato. Puoi chiudere questa scheda.' : 'Errore: ' + (err || 'nessun codice')}</h2>`);
|
|
50
|
+
server.close();
|
|
51
|
+
if (code) resolve(code); else reject(new Error(err || 'nessun codice nella risposta'));
|
|
52
|
+
});
|
|
53
|
+
server.on('error', reject);
|
|
54
|
+
server.listen(port, '127.0.0.1');
|
|
55
|
+
setTimeout(() => { server.close(); reject(new Error('nessuna autorizzazione entro 5 minuti')); }, 300000);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadConfig(env = process.env) {
|
|
60
|
+
let file = {};
|
|
61
|
+
try { file = JSON.parse(await readFile(CONFIG, 'utf8')); } catch { /* prima esecuzione */ }
|
|
62
|
+
return {
|
|
63
|
+
client_id: env.CWS_CLIENT_ID || file.client_id,
|
|
64
|
+
client_secret: env.CWS_CLIENT_SECRET || file.client_secret,
|
|
65
|
+
refresh_token: env.CWS_REFRESH_TOKEN || file.refresh_token,
|
|
66
|
+
item_id: env.CWS_ITEM_ID || file.item_id,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function requireFields(cfg, fields) {
|
|
71
|
+
const missing = fields.filter((f) => !cfg[f]);
|
|
72
|
+
if (missing.length) {
|
|
73
|
+
throw new Error(`Manca ${missing.join(', ')} — mettili in ${CONFIG} o nelle variabili CWS_*`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Il Web Store risponde 200 anche quando il caricamento è fallito: l'esito vero
|
|
79
|
+
* è in uploadState, e itemError porta il motivo. Trattare 200 come successo è
|
|
80
|
+
* il modo di credere di aver pubblicato senza averlo fatto.
|
|
81
|
+
*/
|
|
82
|
+
export function interpretUpload(body) {
|
|
83
|
+
const state = body?.uploadState;
|
|
84
|
+
if (state === 'SUCCESS') return { ok: true, state };
|
|
85
|
+
const reasons = (body?.itemError || []).map((e) => e.error_detail || e.errorDetail || JSON.stringify(e));
|
|
86
|
+
return { ok: false, state: state ?? 'UNKNOWN', reasons };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function interpretPublish(body) {
|
|
90
|
+
const status = body?.status || [];
|
|
91
|
+
const ok = status.includes('OK') || status.includes('PUBLISHED_WITH_FRICTION_WARNING');
|
|
92
|
+
return { ok, status, detail: body?.statusDetail || [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function exchange(cfg, code, redirectUri) {
|
|
96
|
+
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
99
|
+
body: new URLSearchParams({
|
|
100
|
+
client_id: cfg.client_id, client_secret: cfg.client_secret,
|
|
101
|
+
code, grant_type: 'authorization_code', redirect_uri: redirectUri,
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const body = await res.json();
|
|
105
|
+
if (!body.refresh_token) throw new Error(`Nessun refresh_token: ${JSON.stringify(body)}`);
|
|
106
|
+
return body.refresh_token;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function saveRefreshToken(cfg, refresh_token) {
|
|
110
|
+
await mkdir(dirname(CONFIG), { recursive: true });
|
|
111
|
+
await writeFile(CONFIG, JSON.stringify({ ...cfg, refresh_token }, null, 2), { mode: 0o600 });
|
|
112
|
+
console.log(`refresh_token salvato in ${CONFIG} (chmod 600)`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function accessToken(cfg) {
|
|
116
|
+
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
119
|
+
body: new URLSearchParams({
|
|
120
|
+
client_id: cfg.client_id,
|
|
121
|
+
client_secret: cfg.client_secret,
|
|
122
|
+
refresh_token: cfg.refresh_token,
|
|
123
|
+
grant_type: 'refresh_token',
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
if (!res.ok || !body.access_token) {
|
|
128
|
+
// invalid_grant qui significa quasi sempre consent screen in "Testing":
|
|
129
|
+
// in quello stato Google scade i refresh token dopo 7 giorni.
|
|
130
|
+
throw new Error(`Token refresh fallito (${res.status}): ${JSON.stringify(body)}`);
|
|
131
|
+
}
|
|
132
|
+
return body.access_token;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function main() {
|
|
136
|
+
const argv = process.argv.slice(2);
|
|
137
|
+
const cfg = await loadConfig();
|
|
138
|
+
|
|
139
|
+
if (argv.includes('--auth')) {
|
|
140
|
+
requireFields(cfg, ['client_id', 'client_secret']);
|
|
141
|
+
const portArg = argv[argv.indexOf('--auth') + 1];
|
|
142
|
+
const port = Number(portArg) > 0 ? Number(portArg) : 8899;
|
|
143
|
+
const redirect = `http://localhost:${port}`;
|
|
144
|
+
console.log(buildAuthUrl(cfg.client_id, redirect));
|
|
145
|
+
console.error(`in attesa del codice su ${redirect} …`);
|
|
146
|
+
|
|
147
|
+
const code = await waitForCode(port);
|
|
148
|
+
const token = await exchange(cfg, code, redirect);
|
|
149
|
+
await saveRefreshToken(cfg, token);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const exIdx = argv.indexOf('--exchange');
|
|
154
|
+
if (exIdx !== -1) {
|
|
155
|
+
requireFields(cfg, ['client_id', 'client_secret']);
|
|
156
|
+
const code = argv[exIdx + 1];
|
|
157
|
+
if (!code) throw new Error('Uso: --exchange <codice> [--redirect <uri>]');
|
|
158
|
+
const rIdx = argv.indexOf('--redirect');
|
|
159
|
+
const redirect = rIdx !== -1 ? argv[rIdx + 1] : 'http://localhost:8899';
|
|
160
|
+
await saveRefreshToken(cfg, await exchange(cfg, code, redirect));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const zip = argv.find((a) => a.endsWith('.zip'));
|
|
165
|
+
if (!zip) throw new Error('Indica lo zip da caricare, o usa --auth / --exchange');
|
|
166
|
+
requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
|
|
167
|
+
|
|
168
|
+
const size = (await stat(zip)).size;
|
|
169
|
+
const token = await accessToken(cfg);
|
|
170
|
+
|
|
171
|
+
const up = await fetch(`https://www.googleapis.com/upload/chromewebstore/v1.1/items/${cfg.item_id}`, {
|
|
172
|
+
method: 'PUT',
|
|
173
|
+
headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2' },
|
|
174
|
+
body: await readFile(zip),
|
|
175
|
+
});
|
|
176
|
+
const upBody = await up.json();
|
|
177
|
+
const upRes = interpretUpload(upBody);
|
|
178
|
+
console.log(`upload ${zip} (${size} B): ${upRes.ok ? 'SUCCESS' : `FALLITO [${upRes.state}] ${upRes.reasons.join(' | ')}`}`);
|
|
179
|
+
if (!upRes.ok) process.exit(1);
|
|
180
|
+
|
|
181
|
+
if (!argv.includes('--publish')) {
|
|
182
|
+
console.log('bozza caricata; ripassa con --publish per mandarla in review');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pub = await fetch(`https://www.googleapis.com/chromewebstore/v1.1/items/${cfg.item_id}/publish`, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2', 'Content-Length': '0' },
|
|
189
|
+
});
|
|
190
|
+
const pubRes = interpretPublish(await pub.json());
|
|
191
|
+
console.log(`publish: ${pubRes.ok ? 'OK' : 'FALLITO'} ${pubRes.status.join(',')} ${pubRes.detail.join(' | ')}`);
|
|
192
|
+
if (!pubRes.ok) process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
196
|
+
main().catch((e) => { console.error(String(e.message || e)); process.exit(1); });
|
|
197
|
+
}
|