ghost-bridge 0.5.1 → 0.6.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 CHANGED
@@ -1,183 +1,151 @@
1
1
  # 👻 Ghost Bridge
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/ghost-bridge.svg?style=flat-square)](https://www.npmjs.com/package/ghost-bridge)
4
+ [![npm total downloads](https://img.shields.io/npm/dt/ghost-bridge.svg?style=flat-square&label=downloads)](https://www.npmjs.com/package/ghost-bridge)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
5
6
 
6
- > **Zero-restart Chrome AI Copilot** Subvert your workflow. Allow AI to seamlessly take over the browser you're already using, enabling real-time debugging, visual observation, and interactive manipulation without launching a new Chrome instance.
7
+ > Zero-restart Chrome bridge for MCP clients. Let AI inspect, debug, and operate the browser session you are already using.
7
8
 
8
- ---
9
+ ## Why
9
10
 
10
- ## Why Ghost Bridge?
11
+ Most browser-capable AI tools start a separate browser. Ghost Bridge connects AI to your existing Chrome session instead, so it can work with the page state you already have: logged-in accounts, reproduced bugs, in-progress flows, network failures, and real UI state.
11
12
 
12
- - 🔌 **Zero-Config Attach** — Bypasses the need for `--remote-debugging-port`. Captures Chrome's native DevTools Protocol directly via an extension.
13
- - 🔍 **No-Sourcemap Debugging** — Slice code fragments, perform string searches, and analyze coverage to pinpoint bugs straight in minified production code.
14
- - 🌐 **Deep Network Analysis** — Comprehensive capture of requests/responses with multi-dimensional filtering and response body inspection.
15
- - 📸 **Visual & Structural Perception** — Full-page or clipped high-fidelity screenshots paired with structural data extraction (titles, links, forms, buttons).
16
- - 🎯 **DOM Physical Manipulation** — Empowers AI to click, type, and form-submit with CDP-level physical simulation. Fully compatible with complex SPAs (React/Vue/Angular/Svelte).
17
- - 📊 **Performance Diagnostics** — Get granular engine metrics: JS Heap, Layout recalculations, Web Vitals (TTFB/FCP/LCP), and resource loading speeds.
18
- - 🔄 **Multi-Client Mastery** — Built-in singleton manager automatically coordinates multiple MCP clients sharing a single Chrome transport.
13
+ ## What It Does
19
14
 
20
- ---
15
+ - Attach to Chrome without `--remote-debugging-port`
16
+ - Inspect page structure, text, screenshots, errors, and network traffic
17
+ - Search and extract script sources, even in production bundles
18
+ - Click, type, scroll, and submit forms on the current page
19
+ - Share one Chrome transport across multiple MCP clients
21
20
 
22
- ## 🚀 Quick Start
21
+ ## What's New in 0.6.0
23
22
 
24
- ### 1. Install & Initialize
23
+ - `inspect_page` now collects structured page data and interactive elements in one browser-side snapshot, reducing duplicate DOM scans and cutting one round-trip from the hot path
24
+ - `capture_screenshot` now defaults to JPEG for better transfer efficiency: visible viewport screenshots default to `quality: 80`, and `fullPage` screenshots default to `quality: 70`
25
+ - Use `format: "png"` when you need high-fidelity text rendering, 1px lines, icon edges, transparency, or pixel-level UI inspection
26
+ - Attachment and request cleanup paths are more robust under concurrent usage and multi-client reconnect scenarios
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Install
25
31
 
26
32
  ```bash
27
- # Install globally
28
33
  npm install -g ghost-bridge
29
-
30
- # Auto-configure MCP (Claude Code, Codex, Cursor, Antigravity) and prepare the extension directory
31
34
  ghost-bridge init
32
35
  ```
33
36
 
34
- > `ghost-bridge init` currently writes:
35
- > - Claude Code: `~/.claude/settings.json` or legacy `~/.claude.json`
36
- > - Codex: `~/.codex/config.toml`
37
- > - Cursor: `~/.cursor/mcp.json`
38
- > - Antigravity: `~/.gemini/antigravity/mcp.json`
39
- >
40
- > **Note on other MCP clients (Windsurf, Roo, others):**
41
- > `ghost-bridge init` attempts to auto-configure supported clients. If your client isn't auto-detected, add one of the following snippets to the appropriate MCP configuration file:
42
- >
43
- > JSON-style MCP configs (`mcp.json`, Claude JSON config, Cursor):
44
- > ```json
45
- > {
46
- > "mcpServers": {
47
- > "ghost-bridge": {
48
- > "command": "/absolute/path/to/node",
49
- > "args": ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
50
- > }
51
- > }
52
- > }
53
- > ```
54
- >
55
- > Codex TOML config (`~/.codex/config.toml`):
56
- > ```toml
57
- > [mcp_servers.ghost-bridge]
58
- > type = "stdio"
59
- > command = "/absolute/path/to/node"
60
- > args = ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
61
- > ```
62
-
63
- ### 2. Load the Chrome Extension
64
-
65
- 1. Open Chrome and navigate to `chrome://extensions`
66
- 2. Toggle **Developer mode** in the top right corner.
67
- 3. Click **Load unpacked**
68
- 4. Select the directory: `~/.ghost-bridge/extension`
69
-
70
- > 💡 *Tip: Run `ghost-bridge extension --open` to reveal the directory directly.*
71
-
72
- ### 3. Connect & Command
73
-
74
- 1. Click the **Ghost Bridge** ghost icon in your browser toolbar.
75
- 2. Click **Connect** and wait for the status to turn to ✅ **ON**.
76
- 3. Open Claude Desktop or your Claude CLI. All tools are now primed and ready!
77
-
78
- **Prompting tips:**
79
- - You usually do not need to say "please use ghost-bridge". Direct requests like `分析当前页面`、`看看这个网站的 DOM 结构`、`帮我检查这个页面为什么布局错了`、`帮我点击登录按钮并提交表单` should be enough.
80
- - `inspect_page` is the default entry tool for generic page analysis.
81
- - For visual/UI issues, the model should prefer `capture_screenshot`.
82
- - For text/DOM extraction, the model should prefer `get_page_content`.
83
- - For page actions, the model should start with `get_interactive_snapshot` and then call `dispatch_action`.
84
-
85
- ---
86
-
87
- ## 🛠️ Tool Arsenal
88
-
89
- ### 🔍 Core Debugging
90
-
91
- | Tool | Capability |
92
- |------|------------|
93
- | `inspect_page` | Best default entry for page analysis. Returns metadata, structured content, and an interactive-elements overview. |
94
- | `get_server_info` | Retrieves server instance status, WebSocket ports, and client roles. |
95
- | `get_last_error` | Aggregates recent exceptions, console errors, and failed network requests with mapped locators. |
96
- | `get_script_source` | Pulls raw script definitions. Supports URL-fragment filtering, specific line targeting, and beautification. |
97
- | `coverage_snapshot` | Triggers a quick coverage trace (1.5s default) to identify the most active scripts on the page. |
98
- | `find_by_string` | Scans page script sources for keywords, returning a 200-character context window. |
99
- | `symbolic_hints` | Gathers context clues: Resource lists, Global Variable keys, LocalStorage schema, and UA strings. |
100
- | `eval_script` | Executes raw JavaScript expressions in the page context. *(Use with caution)* |
101
-
102
- ### 🌍 Network Intelligence
103
-
104
- | Tool | Capability |
105
- |------|------------|
106
- | `list_network_requests` | Lists captured network traffic. Supports filtering by URL, Method, Status Code, or Resource Type. |
107
- | `get_network_detail` | Dives deep into a specific request's Headers, Timing, and optional Response Body extraction. |
108
- | `clear_network_requests` | Wipes the current network capture buffer. |
109
-
110
- ### 📸 Page Perception
111
-
112
- | Tool | Capability |
113
- |------|------------|
114
- | `capture_screenshot` | Captures the viewport or emulates full-page scrolling screenshots. |
115
- | `get_page_content` | Extracts raw text, sanitized HTML, or structured actionable data representations. |
116
-
117
- ### 🎯 Interactive Automation (DOM)
118
-
119
- | Tool | Capability |
120
- |------|------------|
121
- | `get_interactive_snapshot` | Scans for visible interactive elements, returning a concise map `[e1, e2...]`. Pierces open Shadow DOMs. |
122
- | `dispatch_action` | Dispatches physical UI actions (click, fill, press, hover) against targeted element references (e.g., `e1`). |
123
-
124
- **Example Agent Workflow:**
125
- 1. AI: `get_interactive_snapshot` ➝ `[{ref:"e1", tag:"input", placeholder:"Search..."}, {ref:"e2", tag:"button", text:"Login"}]`
126
- 2. AI: `dispatch_action({ref: "e1", action: "fill", value: "hello"})`
127
- 3. AI: `dispatch_action({ref: "e2", action: "click"})`
128
- 4. AI: `capture_screenshot` to verify state changes.
129
-
130
- ### 📊 Performance Profiling
131
-
132
- | Tool | Capability |
133
- |------|------------|
134
- | `perf_metrics` | Collects layered performance data (Engine Metrics, Web Vitals, and Resource Load Summaries). |
135
-
136
- ---
137
-
138
- ## ⚙️ Configuration
139
-
140
- | Setting | Default | Description |
141
- |---------|---------|-------------|
142
- | **Base Port** | `33333` | WS port. Auto-increments if occupied. |
143
- | **Token** | *Monthly UUID* | Local WS auth token, auto-rotates on the 1st of every month. |
144
- | **Auto Detach** | `false` | Keeps debugger attached to actively buffer invisible exceptions and network calls. |
145
-
146
- **Environment Variables:**
147
- - `GHOST_BRIDGE_PORT` — Override base port.
148
- - `GHOST_BRIDGE_TOKEN` — Override connection token.
37
+ `ghost-bridge init` currently writes config for:
149
38
 
150
- ---
39
+ - Claude Code: `~/.claude/settings.json` or `~/.claude.json`
40
+ - Codex: `~/.codex/config.toml`
41
+ - Cursor: `~/.cursor/mcp.json`
42
+ - Antigravity: `~/.gemini/antigravity/mcp.json`
151
43
 
152
- ## 🏗️ Architecture
44
+ If your MCP client is not auto-detected, add one of these manually.
153
45
 
154
- ```mermaid
155
- graph LR
156
- A[Claude CLI/Desktop] <-->|stdio| B(MCP Server\nserver.js)
157
- B <-->|WebSocket| C(Chrome Extension\nbackground.js)
158
- C <-->|CDP| D[Browser Tab\nTarget Context]
46
+ JSON config:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "ghost-bridge": {
52
+ "command": "/absolute/path/to/node",
53
+ "args": ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Codex TOML:
60
+
61
+ ```toml
62
+ [mcp_servers.ghost-bridge]
63
+ type = "stdio"
64
+ command = "/absolute/path/to/node"
65
+ args = ["/absolute/path/to/global/node_modules/ghost-bridge/dist/server.js"]
159
66
  ```
160
67
 
161
- - **MCP Server**: Spawned by Claude via standard I/O streams. Orchestrates WS connections.
162
- - **Chrome Extension (MV3)**: Taps into `chrome.debugger` API. Utilizes an Offscreen Document to prevent WS hibernation.
163
- - **Singleton Design**: If multiple agents spawn servers, the first becomes the master bridge while subsequent instances chain transparently as clients.
68
+ ### 2. Load the Extension
69
+
70
+ 1. Open `chrome://extensions`
71
+ 2. Enable Developer mode
72
+ 3. Click `Load unpacked`
73
+ 4. Select `~/.ghost-bridge/extension`
164
74
 
165
- ---
75
+ You can also run:
76
+
77
+ ```bash
78
+ ghost-bridge extension --open
79
+ ```
166
80
 
167
- ## ⚠️ Known Limitations
81
+ ### 3. Connect
168
82
 
169
- - **Service Workers Suspending**: MV3 background workers may suspend. We've built robust auto-reconnection logic, but prolonged inactivity might require re-toggling.
170
- - **DevTools Conflict**: If you manually open Chrome DevTools (F12) on the target tab, `chrome.debugger.attach` may be rejected.
171
- - **Beautify Overhead**: Beautifying massive single-line bundles is expensive; the server will auto-truncate overly large scripts.
172
- - **Cross-Origin OOPIF**: Elements and errors deeply embedded in strict Cross-Origin Iframes might evade the primary debugger hook without further multi-target attach logic.
83
+ 1. Click the Ghost Bridge extension icon
84
+ 2. Click `Connect`
85
+ 3. Wait until the status becomes `ON`
86
+ 4. Open your MCP client and start working on the current page
173
87
 
174
- ---
88
+ Typical prompts:
89
+
90
+ - `Analyze the current page`
91
+ - `Check why this layout is broken`
92
+ - `Inspect the DOM structure`
93
+ - `Click the login button and submit the form`
94
+
95
+ ## Tools
96
+
97
+ | Tool | Purpose |
98
+ |------|---------|
99
+ | `inspect_page` | Default entry point for page analysis |
100
+ | `capture_screenshot` | Visual inspection and UI debugging |
101
+ | `get_page_content` | Text, HTML, and structured DOM extraction |
102
+ | `get_interactive_snapshot` | Find clickable and editable elements |
103
+ | `dispatch_action` | Click, fill, press, scroll, hover, select |
104
+ | `list_network_requests` | Inspect captured network traffic |
105
+ | `get_network_detail` | Read one request in detail |
106
+ | `get_last_error` | Inspect recent errors and exceptions |
107
+ | `get_script_source` | Extract page scripts |
108
+ | `find_by_string` | Search within bundled script content |
109
+ | `coverage_snapshot` | Identify active scripts quickly |
110
+ | `perf_metrics` | Collect Web Vitals and engine metrics |
111
+
112
+ Recommended flow:
113
+
114
+ 1. Start with `inspect_page`
115
+ 2. Use `capture_screenshot` for visual issues
116
+ Default is optimized for transfer with JPEG; switch to `png` for pixel-level checks
117
+ 3. Use `get_page_content` for DOM or text extraction
118
+ 4. Use `get_interactive_snapshot` before `dispatch_action`
119
+
120
+ ## Configuration
121
+
122
+ | Setting | Default | Notes |
123
+ |---------|---------|-------|
124
+ | Port | `33333` | Set `GHOST_BRIDGE_PORT` to override |
125
+ | Token | Monthly UUID | Set `GHOST_BRIDGE_TOKEN` to override |
126
+ | Auto detach | `false` | Keeps debugger attached for ongoing capture |
127
+
128
+ ## Architecture
129
+
130
+ ```mermaid
131
+ flowchart LR
132
+ A["AI Client<br/>Claude / Codex / Cursor"]
133
+ B["Ghost Bridge MCP Server<br/>server.js"]
134
+ C["Chrome Extension<br/>background.js"]
135
+ D["Browser Tab<br/>Target Context"]
136
+
137
+ A <-->|"stdio"| B
138
+ B <-->|"WebSocket"| C
139
+ C <-->|"CDP"| D
140
+ ```
175
141
 
176
- ## 🤝 Contributing
142
+ ## Limitations
177
143
 
178
- Contributions, issues, and feature requests are welcome!
179
- Check out our [Contributing Guide](CONTRIBUTING.md) to get started building tools or improving the bridge.
144
+ - Chrome DevTools on the target tab can conflict with `chrome.debugger.attach`
145
+ - MV3 background lifecycle can still cause reconnect scenarios after long idle periods
146
+ - Very large minified bundles may be truncated during beautify or extraction
147
+ - Deep cross-origin iframe cases are not fully covered yet
180
148
 
181
- ## 📄 License
149
+ ## License
182
150
 
183
- This project is [MIT](LICENSE) licensed.
151
+ [MIT](LICENSE)
package/dist/server.js CHANGED
@@ -28536,7 +28536,6 @@ var GHOST_BRIDGE_VERSION = packageJson.version;
28536
28536
 
28537
28537
  // src/server.js
28538
28538
  var BASE_PORT = Number(process.env.GHOST_BRIDGE_PORT || 33333);
28539
- var MAX_PORT_RETRIES = 10;
28540
28539
  function getMonthlyToken() {
28541
28540
  const now = /* @__PURE__ */ new Date();
28542
28541
  const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
@@ -28567,6 +28566,10 @@ function getExistingService() {
28567
28566
  if (!fs2.existsSync(PORT_INFO_FILE)) return null;
28568
28567
  const info = JSON.parse(fs2.readFileSync(PORT_INFO_FILE, "utf-8"));
28569
28568
  if (!info.pid || !info.port) return null;
28569
+ if (info.port !== BASE_PORT) {
28570
+ fs2.unlinkSync(PORT_INFO_FILE);
28571
+ return null;
28572
+ }
28570
28573
  if (!isProcessRunning(info.pid)) {
28571
28574
  log(`\u65E7\u670D\u52A1 PID ${info.pid} \u5DF2\u4E0D\u5B58\u5728\uFF0C\u6E05\u7406\u65E7\u4FE1\u606F`);
28572
28575
  fs2.unlinkSync(PORT_INFO_FILE);
@@ -28618,22 +28621,14 @@ function isPortAvailable(port) {
28618
28621
  });
28619
28622
  }
28620
28623
  async function startWebSocketServer() {
28621
- for (let i = 0; i < MAX_PORT_RETRIES; i++) {
28622
- const port = BASE_PORT + i;
28623
- const available = await isPortAvailable(port);
28624
- if (available) {
28625
- actualPort = port;
28626
- const wss2 = new import_websocket_server.default({ port });
28627
- if (port !== BASE_PORT) {
28628
- log(`\u26A0\uFE0F \u7AEF\u53E3 ${BASE_PORT} \u88AB\u5360\u7528\uFF0C\u5DF2\u5207\u6362\u5230\u7AEF\u53E3 ${port}`);
28629
- }
28630
- log(`\u{1F680} WebSocket \u670D\u52A1\u5DF2\u542F\u52A8\uFF0C\u7AEF\u53E3 ${port}${WS_TOKEN ? "\uFF08\u542F\u7528 token \u6821\u9A8C\uFF09" : ""}`);
28631
- return wss2;
28632
- } else {
28633
- log(`\u7AEF\u53E3 ${port} \u88AB\u5360\u7528\uFF0C\u5C1D\u8BD5\u4E0B\u4E00\u4E2A...`);
28634
- }
28624
+ const available = await isPortAvailable(BASE_PORT);
28625
+ if (!available) {
28626
+ throw new Error(`\u56FA\u5B9A\u7AEF\u53E3 ${BASE_PORT} \u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CA\u653E\u8BE5\u7AEF\u53E3\u6216\u901A\u8FC7 GHOST_BRIDGE_PORT \u6307\u5B9A\u5176\u4ED6\u7AEF\u53E3`);
28635
28627
  }
28636
- throw new Error(`\u65E0\u6CD5\u627E\u5230\u53EF\u7528\u7AEF\u53E3\uFF08\u5DF2\u5C1D\u8BD5 ${BASE_PORT} - ${BASE_PORT + MAX_PORT_RETRIES - 1}\uFF09`);
28628
+ actualPort = BASE_PORT;
28629
+ const wss2 = new import_websocket_server.default({ port: BASE_PORT });
28630
+ log(`\u{1F680} WebSocket \u670D\u52A1\u5DF2\u542F\u52A8\uFF0C\u7AEF\u53E3 ${BASE_PORT}${WS_TOKEN ? "\uFF08\u542F\u7528 token \u6821\u9A8C\uFF09" : ""}`);
28631
+ return wss2;
28637
28632
  }
28638
28633
  async function initWebSocketService() {
28639
28634
  const existing = getExistingService();
@@ -28653,6 +28648,16 @@ async function initWebSocketService() {
28653
28648
  }
28654
28649
  }
28655
28650
  }
28651
+ if (!await isPortAvailable(BASE_PORT)) {
28652
+ const valid = await verifyExistingService(BASE_PORT);
28653
+ if (valid) {
28654
+ actualPort = BASE_PORT;
28655
+ isMainInstance = false;
28656
+ log(`\u2705 \u590D\u7528\u56FA\u5B9A\u7AEF\u53E3\u4E0A\u7684\u73B0\u6709\u670D\u52A1\uFF0C\u7AEF\u53E3 ${actualPort}`);
28657
+ return null;
28658
+ }
28659
+ throw new Error(`\u56FA\u5B9A\u7AEF\u53E3 ${BASE_PORT} \u5DF2\u88AB\u5176\u4ED6\u8FDB\u7A0B\u5360\u7528\uFF0C\u8BF7\u91CA\u653E\u8BE5\u7AEF\u53E3\u6216\u901A\u8FC7 GHOST_BRIDGE_PORT \u6307\u5B9A\u5176\u4ED6\u7AEF\u53E3`);
28660
+ }
28656
28661
  const wss2 = await startWebSocketServer();
28657
28662
  isMainInstance = true;
28658
28663
  fs2.writeFileSync(
@@ -28808,9 +28813,18 @@ function connectToMainInstance() {
28808
28813
  });
28809
28814
  }
28810
28815
  function failAllPending(message) {
28811
- pendingRequests.forEach(({ reject, timer }) => {
28812
- clearTimeout(timer);
28813
- reject(new Error(message));
28816
+ pendingRequests.forEach((pending, id) => {
28817
+ if (pending.reject) {
28818
+ clearTimeout(pending.timer);
28819
+ pending.reject(new Error(message));
28820
+ } else if (pending.source) {
28821
+ try {
28822
+ if (pending.source.readyState === import_websocket.default.OPEN) {
28823
+ pending.source.send(JSON.stringify({ id, error: message }));
28824
+ }
28825
+ } catch {
28826
+ }
28827
+ }
28814
28828
  });
28815
28829
  pendingRequests.clear();
28816
28830
  }
@@ -29063,18 +29077,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
29063
29077
  },
29064
29078
  {
29065
29079
  name: "capture_screenshot",
29066
- description: "\u3010\u63A8\u8350\u7528\u4E8E\u89C6\u89C9\u5206\u6790\u3011\u622A\u53D6\u5F53\u524D\u9875\u9762\u7684\u622A\u56FE\uFF0C\u8FD4\u56DE base64 \u56FE\u7247\u3002\u9002\u7528\u4E8E\uFF1A1) \u67E5\u770B\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u6548\u679C 2) \u6392\u67E5 UI/\u6837\u5F0F/\u5E03\u5C40/\u989C\u8272\u95EE\u9898 3) \u9A8C\u8BC1\u9875\u9762\u6E32\u67D3 4) \u5206\u6790\u5143\u7D20\u4F4D\u7F6E\u548C\u95F4\u8DDD 5) \u67E5\u770B\u56FE\u7247/\u56FE\u6807\u7B49\u89C6\u89C9\u5185\u5BB9\u3002\u5F53\u7528\u6237\u8BF4\u201C\u770B\u770B\u8FD9\u4E2A\u9875\u9762\u957F\u4EC0\u4E48\u6837\u201D\u201C\u5E2E\u6211\u5206\u6790\u754C\u9762/\u5E03\u5C40/\u6837\u5F0F\u201D\u65F6\uFF0C\u5E94\u4F18\u5148\u4F7F\u7528\u6B64\u5DE5\u5177\uFF0C\u65E0\u9700\u7528\u6237\u663E\u5F0F\u63D0\u5230 ghost-bridge\u3002\u5F53\u9700\u8981\u770B\u5230\u9875\u9762\u300C\u957F\u4EC0\u4E48\u6837\u300D\u65F6\u4F7F\u7528\u6B64\u5DE5\u5177\u3002\u5982\u4EC5\u9700\u6587\u672C/\u94FE\u63A5\u7B49\u4FE1\u606F\uFF0C\u5EFA\u8BAE\u4F7F\u7528\u66F4\u5FEB\u7684 get_page_content\u3002",
29080
+ description: "\u3010\u63A8\u8350\u7528\u4E8E\u89C6\u89C9\u5206\u6790\u3011\u622A\u53D6\u5F53\u524D\u9875\u9762\u7684\u622A\u56FE\uFF0C\u8FD4\u56DE base64 \u56FE\u7247\u3002\u9002\u7528\u4E8E\uFF1A1) \u67E5\u770B\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u6548\u679C 2) \u6392\u67E5 UI/\u6837\u5F0F/\u5E03\u5C40/\u989C\u8272\u95EE\u9898 3) \u9A8C\u8BC1\u9875\u9762\u6E32\u67D3 4) \u5206\u6790\u5143\u7D20\u4F4D\u7F6E\u548C\u95F4\u8DDD 5) \u67E5\u770B\u56FE\u7247/\u56FE\u6807\u7B49\u89C6\u89C9\u5185\u5BB9\u3002\u5F53\u7528\u6237\u8BF4\u201C\u770B\u770B\u8FD9\u4E2A\u9875\u9762\u957F\u4EC0\u4E48\u6837\u201D\u201C\u5E2E\u6211\u5206\u6790\u754C\u9762/\u5E03\u5C40/\u6837\u5F0F\u201D\u65F6\uFF0C\u5E94\u4F18\u5148\u4F7F\u7528\u6B64\u5DE5\u5177\uFF0C\u65E0\u9700\u7528\u6237\u663E\u5F0F\u63D0\u5230 ghost-bridge\u3002\u5F53\u9700\u8981\u770B\u5230\u9875\u9762\u300C\u957F\u4EC0\u4E48\u6837\u300D\u65F6\u4F7F\u7528\u6B64\u5DE5\u5177\u3002\u9ED8\u8BA4\u4F18\u5148\u4F7F\u7528\u66F4\u7701\u4F20\u8F93\u7684 JPEG\uFF1A\u666E\u901A\u622A\u56FE\u9ED8\u8BA4 quality 80\uFF0C\u5B8C\u6574\u957F\u622A\u56FE\u9ED8\u8BA4 quality 70\u3002\u5F53\u9700\u8981\u68C0\u67E5\u6587\u5B57\u6E05\u6670\u5EA6\u30011px \u7EC6\u7EBF\u3001\u56FE\u6807\u8FB9\u7F18\u3001\u900F\u660E\u80CC\u666F\u6216\u50CF\u7D20\u7EA7\u7EC6\u8282\u65F6\uFF0C\u5E94\u4F18\u5148\u4F7F\u7528 PNG\u3002\u5982\u4EC5\u9700\u6587\u672C/\u94FE\u63A5\u7B49\u4FE1\u606F\uFF0C\u5EFA\u8BAE\u4F7F\u7528\u66F4\u5FEB\u7684 get_page_content\u3002",
29067
29081
  inputSchema: {
29068
29082
  type: "object",
29069
29083
  properties: {
29070
29084
  format: {
29071
29085
  type: "string",
29072
29086
  enum: ["png", "jpeg"],
29073
- description: "\u56FE\u7247\u683C\u5F0F\uFF0C\u9ED8\u8BA4 png\uFF08\u65E0\u635F\uFF09\uFF0Cjpeg \u66F4\u5C0F"
29087
+ description: "\u56FE\u7247\u683C\u5F0F\u3002\u9ED8\u8BA4\u4F7F\u7528 jpeg\uFF1B\u9700\u8981\u9AD8\u4FDD\u771F\u6587\u5B57\u3001\u7EC6\u7EBF\u3001\u900F\u660E\u80CC\u666F\u65F6\u7528 png"
29074
29088
  },
29075
29089
  quality: {
29076
29090
  type: "number",
29077
- description: "JPEG \u8D28\u91CF (0-100)\uFF0C\u4EC5\u5F53 format \u4E3A jpeg \u65F6\u6709\u6548\uFF0C\u5EFA\u8BAE 80"
29091
+ description: "JPEG \u8D28\u91CF (0-100)\uFF0C\u4EC5\u5F53 format \u4E3A jpeg \u65F6\u6709\u6548\u3002\u9ED8\u8BA4\u666E\u901A\u622A\u56FE 80\uFF0C\u5B8C\u6574\u957F\u622A\u56FE 70"
29078
29092
  },
29079
29093
  fullPage: {
29080
29094
  type: "boolean",
@@ -29187,20 +29201,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
29187
29201
  try {
29188
29202
  if (name === "inspect_page") {
29189
29203
  const { selector, includeInteractive = true, maxElements = 30 } = args;
29190
- const page = await askChrome("getPageContent", {
29191
- mode: "structured",
29204
+ const snapshot = await askChrome("inspectPageSnapshot", {
29192
29205
  selector,
29193
- maxLength: 2e4,
29194
- includeMetadata: true
29206
+ includeInteractive,
29207
+ maxElements
29195
29208
  });
29196
- let interactive = null;
29197
- if (includeInteractive) {
29198
- interactive = await askChrome("getInteractiveSnapshot", {
29199
- selector,
29200
- includeText: true,
29201
- maxElements
29202
- });
29203
- }
29209
+ const page = snapshot?.page;
29210
+ const interactive = snapshot?.interactive ?? null;
29204
29211
  const links = page?.counts?.links;
29205
29212
  const buttons = page?.counts?.buttons;
29206
29213
  const forms = page?.counts?.forms;
@@ -29352,6 +29359,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
29352
29359
  }
29353
29360
  const metadata = {
29354
29361
  format: res.format,
29362
+ ...res.quality !== void 0 ? { quality: res.quality } : {},
29355
29363
  fullPage: res.fullPage,
29356
29364
  width: res.width,
29357
29365
  height: res.height,
@@ -7,7 +7,6 @@ function getMonthlyToken() {
7
7
 
8
8
  const CONFIG = {
9
9
  basePort: 33333,
10
- maxPortRetries: 10,
11
10
  token: getMonthlyToken(),
12
11
  autoDetach: false,
13
12
  maxErrors: 100,
@@ -23,7 +22,7 @@ let lastErrors = []
23
22
  let lastErrorLocation = null
24
23
  let requestMap = new Map()
25
24
  let networkRequests = []
26
- let state = { enabled: false, connected: false, port: null, currentPort: null, scanRound: 0 }
25
+ let state = { enabled: false, connected: false, port: null, currentPort: null, connectionStatus: 'disconnected', connectionError: '' }
27
26
 
28
27
  // 待处理的请求(等待 offscreen 响应)
29
28
  const pendingRequests = new Map()
@@ -399,46 +398,57 @@ function compactStack(stackTrace) {
399
398
 
400
399
  // ========== Debugger 操作 ==========
401
400
 
401
+ // attach 互斥锁:防止并发调用 ensureAttached 导致重复 attach / 状态竞态
402
+ let _attachLock = Promise.resolve()
403
+
402
404
  async function ensureAttached() {
403
- if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
404
- const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
405
- if (!tab) throw new Error("没有激活的标签页")
406
- if (attachedTabId !== tab.id) {
407
- if (attachedTabId) {
408
- try { await chrome.debugger.detach({ tabId: attachedTabId }) } catch (e) {}
409
- }
410
- try {
411
- await chrome.debugger.attach({ tabId: tab.id }, "1.3")
412
- setBadgeState("on")
413
- } catch (e) {
414
- attachedTabId = null
415
- if (state.connected) {
405
+ let _release
406
+ const _prev = _attachLock
407
+ _attachLock = new Promise(r => _release = r)
408
+ await _prev
409
+ try {
410
+ if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
411
+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
412
+ if (!tab) throw new Error("没有激活的标签页")
413
+ if (attachedTabId !== tab.id) {
414
+ if (attachedTabId) {
415
+ try { await chrome.debugger.detach({ tabId: attachedTabId }) } catch (e) {}
416
+ }
417
+ try {
418
+ await chrome.debugger.attach({ tabId: tab.id }, "1.3")
416
419
  setBadgeState("on")
417
- } else {
418
- setBadgeState("att")
420
+ } catch (e) {
421
+ attachedTabId = null
422
+ if (state.connected) {
423
+ setBadgeState("on")
424
+ } else {
425
+ setBadgeState("att")
426
+ }
427
+ throw e
419
428
  }
420
- throw e
429
+ attachedTabId = tab.id
430
+ scriptMap = new Map()
431
+ scriptSourceCache = new Map()
432
+ networkRequests = []
433
+ requestMap = new Map()
434
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
435
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
436
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
437
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
438
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
439
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Network.enable").catch(() => {})
440
+
441
+ // Enable auto-attach to sub-targets (iframes, workers) for comprehensive capture
442
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Target.setAutoAttach", {
443
+ autoAttach: true,
444
+ waitForDebuggerOnStart: false,
445
+ flatten: true,
446
+ }).catch(() => {})
421
447
  }
422
- attachedTabId = tab.id
423
- scriptMap = new Map()
424
- scriptSourceCache = new Map()
425
- networkRequests = []
426
- requestMap = new Map()
427
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
428
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
429
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
430
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
431
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
432
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Network.enable").catch(() => {})
433
-
434
- // Enable auto-attach to sub-targets (iframes, workers) for comprehensive capture
435
- await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Target.setAutoAttach", {
436
- autoAttach: true,
437
- waitForDebuggerOnStart: false,
438
- flatten: true,
439
- }).catch(() => {})
448
+ return { tabId: attachedTabId }
449
+ } finally {
450
+ _release()
440
451
  }
441
- return { tabId: attachedTabId }
442
452
  }
443
453
 
444
454
  async function maybeDetach(force = false) {
@@ -880,13 +890,17 @@ function roundMs(seconds) {
880
890
 
881
891
  async function handleCaptureScreenshot(params = {}) {
882
892
  const target = await ensureAttached()
883
- const { format = 'png', quality, fullPage = false, clip } = params
893
+ const { format: requestedFormat, quality: requestedQuality, fullPage = false, clip } = params
894
+ const format = requestedFormat || 'jpeg'
895
+ const quality = format === 'jpeg'
896
+ ? (requestedQuality ?? (fullPage ? 70 : 80))
897
+ : undefined
884
898
 
885
899
  await chrome.debugger.sendCommand(target, 'Page.enable')
886
900
 
887
901
  let captureParams = {
888
902
  format,
889
- ...(quality !== undefined && format === 'jpeg' ? { quality } : {}),
903
+ ...(format === 'jpeg' ? { quality } : {}),
890
904
  }
891
905
 
892
906
  if (clip) {
@@ -928,7 +942,7 @@ async function handleCaptureScreenshot(params = {}) {
928
942
  }
929
943
  await chrome.debugger.sendCommand(target, 'Emulation.clearDeviceMetricsOverride').catch(() => {})
930
944
  return {
931
- imageData: data, format, fullPage: true, width: maxWidth, height: maxHeight,
945
+ imageData: data, format, quality, fullPage: true, width: maxWidth, height: maxHeight,
932
946
  note: pageSize.height > maxHeight ? `页面高度 ${pageSize.height}px 超过限制,已截取前 ${maxHeight}px` : undefined,
933
947
  }
934
948
  } catch (e) {
@@ -944,7 +958,207 @@ async function handleCaptureScreenshot(params = {}) {
944
958
  returnByValue: true,
945
959
  })
946
960
 
947
- return { imageData: data, format, fullPage: false, width: sizeResult?.value?.width, height: sizeResult?.value?.height }
961
+ return {
962
+ imageData: data,
963
+ format,
964
+ quality,
965
+ fullPage: false,
966
+ width: sizeResult?.value?.width,
967
+ height: sizeResult?.value?.height
968
+ }
969
+ }
970
+
971
+ async function handleInspectPageSnapshot(params = {}) {
972
+ const target = await ensureAttached()
973
+ const { selector, includeInteractive = true, maxElements = 30 } = params
974
+
975
+ const selectorStr = selector ? JSON.stringify(selector) : 'null'
976
+
977
+ const expression = `(function() {
978
+ try {
979
+ if (document.readyState === 'loading') {
980
+ return { error: '页面尚未加载完成,请稍后重试', readyState: document.readyState };
981
+ }
982
+
983
+ const includeInteractive = ${includeInteractive};
984
+ const maxEls = ${maxElements};
985
+ const selector = ${selectorStr};
986
+ const result = {};
987
+ let targetElement = document.body;
988
+
989
+ if (selector) {
990
+ try {
991
+ targetElement = document.querySelector(selector);
992
+ if (!targetElement) {
993
+ return { error: '选择器未匹配到任何元素', selector: selector, suggestion: '请检查选择器是否正确' };
994
+ }
995
+ result.selector = selector;
996
+ result.matchedTag = targetElement.tagName.toLowerCase();
997
+ } catch (e) {
998
+ return { error: '无效的 CSS 选择器: ' + e.message, selector: selector };
999
+ }
1000
+ }
1001
+
1002
+ result.metadata = {
1003
+ title: document.title || '',
1004
+ url: window.location.href,
1005
+ description: document.querySelector('meta[name="description"]')?.content || '',
1006
+ keywords: document.querySelector('meta[name="keywords"]')?.content || '',
1007
+ charset: document.characterSet,
1008
+ language: document.documentElement.lang || '',
1009
+ };
1010
+
1011
+ const structured = {};
1012
+ const headings = targetElement.querySelectorAll('h1,h2,h3,h4,h5,h6');
1013
+ structured.headings = Array.from(headings).slice(0, 50).map(h => ({
1014
+ level: parseInt(h.tagName[1]),
1015
+ text: h.innerText.trim().slice(0, 200)
1016
+ }));
1017
+ const links = targetElement.querySelectorAll('a[href]');
1018
+ structured.links = Array.from(links).slice(0, 100).map(a => ({
1019
+ text: (a.innerText || '').trim().slice(0, 100),
1020
+ href: a.href
1021
+ })).filter(l => l.href && !l.href.startsWith('javascript:'));
1022
+ const buttons = targetElement.querySelectorAll('button, input[type="button"], input[type="submit"], [role="button"]');
1023
+ structured.buttons = Array.from(buttons).slice(0, 50).map(b => ({
1024
+ text: (b.innerText || b.value || b.getAttribute('aria-label') || '').trim().slice(0, 100),
1025
+ type: b.type || 'button',
1026
+ disabled: b.disabled || false
1027
+ }));
1028
+ const forms = targetElement.querySelectorAll('form');
1029
+ structured.forms = Array.from(forms).slice(0, 20).map(f => {
1030
+ const fields = Array.from(f.querySelectorAll('input, select, textarea')).slice(0, 30);
1031
+ return {
1032
+ action: f.action || '',
1033
+ method: (f.method || 'GET').toUpperCase(),
1034
+ fieldCount: fields.length,
1035
+ fields: fields.map(field => ({
1036
+ tag: field.tagName.toLowerCase(),
1037
+ type: field.type || '',
1038
+ name: field.name || '',
1039
+ placeholder: field.placeholder || '',
1040
+ required: field.required || false
1041
+ }))
1042
+ };
1043
+ });
1044
+ const images = targetElement.querySelectorAll('img');
1045
+ structured.images = Array.from(images).slice(0, 50).map(img => ({
1046
+ alt: img.alt || '',
1047
+ src: img.src ? img.src.slice(0, 200) : ''
1048
+ })).filter(img => img.src);
1049
+ const tables = targetElement.querySelectorAll('table');
1050
+ structured.tables = Array.from(tables).slice(0, 10).map(table => {
1051
+ const headers = Array.from(table.querySelectorAll('th')).map(th => th.innerText.trim().slice(0, 50));
1052
+ const rows = table.querySelectorAll('tr');
1053
+ return { headers: headers.slice(0, 20), rowCount: rows.length };
1054
+ });
1055
+
1056
+ result.page = {
1057
+ metadata: result.metadata,
1058
+ ...(result.selector ? { selector: result.selector, matchedTag: result.matchedTag } : {}),
1059
+ structured,
1060
+ counts: {
1061
+ headings: structured.headings.length,
1062
+ links: structured.links.length,
1063
+ buttons: structured.buttons.length,
1064
+ forms: structured.forms.length,
1065
+ images: structured.images.length,
1066
+ tables: structured.tables.length
1067
+ },
1068
+ mode: 'structured'
1069
+ };
1070
+
1071
+ if (!includeInteractive) {
1072
+ result.interactive = null;
1073
+ return result;
1074
+ }
1075
+
1076
+ let refCounter = 0;
1077
+ const elements = [];
1078
+ const INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[tabindex]:not([tabindex="-1"]),[contenteditable="true"],[onclick]';
1079
+
1080
+ function isVisible(el) {
1081
+ const style = window.getComputedStyle(el);
1082
+ if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return null;
1083
+ if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
1084
+ style.position !== 'fixed' && style.position !== 'sticky') return null;
1085
+ const rect = el.getBoundingClientRect();
1086
+ if (rect.width === 0 && rect.height === 0) return null;
1087
+ return rect;
1088
+ }
1089
+
1090
+ function buildEntry(el, rect) {
1091
+ refCounter++;
1092
+ const ref = 'e' + refCounter;
1093
+ el.setAttribute('data-ghost-ref', ref);
1094
+ const tag = el.tagName.toLowerCase();
1095
+ const entry = { ref, tag, cx: Math.round(rect.left + rect.width / 2), cy: Math.round(rect.top + rect.height / 2) };
1096
+ if (el.type) entry.type = el.type;
1097
+ if (el.name) entry.name = el.name;
1098
+ if (el.getAttribute('role')) entry.role = el.getAttribute('role');
1099
+ if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
1100
+ if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
1101
+ if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
1102
+ if (tag === 'select') {
1103
+ entry.options = Array.from(el.options).slice(0, 10).map(o => ({
1104
+ value: o.value, text: o.text.slice(0, 50), selected: o.selected
1105
+ }));
1106
+ }
1107
+ const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
1108
+ if (text && text.length <= 100) entry.text = text;
1109
+ else if (text) entry.text = text.slice(0, 97) + '...';
1110
+ if (el.disabled) entry.disabled = true;
1111
+ return entry;
1112
+ }
1113
+
1114
+ function scanRoot(root) {
1115
+ const candidates = root.querySelectorAll(INTERACTIVE_SELECTOR);
1116
+ for (let i = 0; i < candidates.length && elements.length < maxEls; i++) {
1117
+ const rect = isVisible(candidates[i]);
1118
+ if (rect) elements.push(buildEntry(candidates[i], rect));
1119
+ }
1120
+ if (elements.length < maxEls) {
1121
+ const all = root.querySelectorAll('*');
1122
+ for (let i = 0; i < all.length && elements.length < maxEls; i++) {
1123
+ const el = all[i];
1124
+ if (el.shadowRoot) scanRoot(el.shadowRoot);
1125
+ if (el.onclick && !el.hasAttribute('data-ghost-ref')) {
1126
+ const rect = isVisible(el);
1127
+ if (rect) elements.push(buildEntry(el, rect));
1128
+ }
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
1134
+ scanRoot(targetElement);
1135
+
1136
+ result.interactive = {
1137
+ url: window.location.href,
1138
+ title: document.title,
1139
+ elementCount: elements.length,
1140
+ viewport: {
1141
+ width: window.innerWidth,
1142
+ height: window.innerHeight,
1143
+ scrollX: Math.round(window.scrollX),
1144
+ scrollY: Math.round(window.scrollY),
1145
+ },
1146
+ elements
1147
+ };
1148
+
1149
+ return result;
1150
+ } catch (e) {
1151
+ return { error: e.message };
1152
+ }
1153
+ })()`
1154
+
1155
+ const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1156
+ expression,
1157
+ returnByValue: true,
1158
+ })
1159
+
1160
+ if (result?.value?.error) throw new Error(result.value.error)
1161
+ return result?.value
948
1162
  }
949
1163
 
950
1164
  async function handleGetPageContent(params = {}) {
@@ -1062,78 +1276,65 @@ async function handleGetInteractiveSnapshot(params = {}) {
1062
1276
  let refCounter = 0;
1063
1277
  const elements = [];
1064
1278
 
1065
- // 判断元素是否可见
1279
+ const maxEls = ${maxElements};
1280
+ // 候选集选择器——用浏览器原生选择器引擎代替全树 JS 递归
1281
+ const INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="combobox"],[tabindex]:not([tabindex="-1"]),[contenteditable="true"],[onclick]';
1282
+
1283
+ // 可见性检测(单次 getComputedStyle,返回 rect 复用)
1066
1284
  function isVisible(el) {
1067
- if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
1068
- window.getComputedStyle(el).position !== 'fixed' &&
1069
- window.getComputedStyle(el).position !== 'sticky') return false;
1070
1285
  const style = window.getComputedStyle(el);
1071
- if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return false;
1286
+ if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return null;
1287
+ if (!el.offsetParent && el.tagName !== 'HTML' && el.tagName !== 'BODY' &&
1288
+ style.position !== 'fixed' && style.position !== 'sticky') return null;
1072
1289
  const rect = el.getBoundingClientRect();
1073
- if (rect.width === 0 && rect.height === 0) return false;
1074
- return true;
1290
+ if (rect.width === 0 && rect.height === 0) return null;
1291
+ return rect;
1075
1292
  }
1076
1293
 
1077
- // 判断元素是否可交互
1078
- function isInteractive(el) {
1294
+ function buildEntry(el, rect) {
1295
+ refCounter++;
1296
+ const ref = 'e' + refCounter;
1297
+ el.setAttribute('data-ghost-ref', ref);
1079
1298
  const tag = el.tagName.toLowerCase();
1080
- if (['a', 'button', 'input', 'select', 'textarea'].includes(tag)) return true;
1081
- if (el.getAttribute('role') === 'button' || el.getAttribute('role') === 'link' ||
1082
- el.getAttribute('role') === 'tab' || el.getAttribute('role') === 'menuitem' ||
1083
- el.getAttribute('role') === 'checkbox' || el.getAttribute('role') === 'radio' ||
1084
- el.getAttribute('role') === 'switch' || el.getAttribute('role') === 'combobox') return true;
1085
- if (el.getAttribute('tabindex') && parseInt(el.getAttribute('tabindex')) >= 0) return true;
1086
- if (el.getAttribute('contenteditable') === 'true') return true;
1087
- if (el.onclick || el.getAttribute('onclick')) return true;
1088
- return false;
1299
+ const entry = { ref, tag, cx: Math.round(rect.left + rect.width / 2), cy: Math.round(rect.top + rect.height / 2) };
1300
+ if (el.type) entry.type = el.type;
1301
+ if (el.name) entry.name = el.name;
1302
+ if (el.getAttribute('role')) entry.role = el.getAttribute('role');
1303
+ if (${includeText}) {
1304
+ if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
1305
+ if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
1306
+ if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
1307
+ if (tag === 'select') {
1308
+ entry.options = Array.from(el.options).slice(0, 10).map(o => ({
1309
+ value: o.value, text: o.text.slice(0, 50), selected: o.selected
1310
+ }));
1311
+ }
1312
+ const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
1313
+ if (text && text.length <= 100) entry.text = text;
1314
+ else if (text) entry.text = text.slice(0, 97) + '...';
1315
+ }
1316
+ if (el.disabled) entry.disabled = true;
1317
+ return entry;
1089
1318
  }
1090
1319
 
1091
- // 递归遍历 DOM 树(含 Shadow DOM
1092
- function walkDOM(node, root) {
1093
- if (elements.length >= ${maxElements}) return;
1094
- const children = node.children || [];
1095
- for (let i = 0; i < children.length; i++) {
1096
- if (elements.length >= ${maxElements}) return;
1097
- const el = children[i];
1098
- if (isInteractive(el) && isVisible(el)) {
1099
- refCounter++;
1100
- const ref = 'e' + refCounter;
1101
- el.setAttribute('data-ghost-ref', ref);
1102
- const tag = el.tagName.toLowerCase();
1103
- const rect = el.getBoundingClientRect();
1104
- const entry = {
1105
- ref: ref,
1106
- tag: tag,
1107
- cx: Math.round(rect.left + rect.width / 2),
1108
- cy: Math.round(rect.top + rect.height / 2),
1109
- };
1110
- // 类型信息
1111
- if (el.type) entry.type = el.type;
1112
- if (el.name) entry.name = el.name;
1113
- if (el.getAttribute('role')) entry.role = el.getAttribute('role');
1114
- // 文本信息
1115
- if (${includeText}) {
1116
- if (el.placeholder) entry.placeholder = el.placeholder.slice(0, 80);
1117
- if (el.value && tag !== 'textarea') entry.value = el.value.slice(0, 80);
1118
- if (tag === 'a') entry.href = (el.href || '').slice(0, 150);
1119
- if (tag === 'select') {
1120
- entry.options = Array.from(el.options).slice(0, 10).map(o => ({
1121
- value: o.value, text: o.text.slice(0, 50), selected: o.selected
1122
- }));
1123
- }
1124
- const text = (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
1125
- if (text && text.length <= 100) entry.text = text;
1126
- else if (text) entry.text = text.slice(0, 97) + '...';
1320
+ // 候选集扫描(含 Shadow DOM 穿透)
1321
+ function scanRoot(root) {
1322
+ const candidates = root.querySelectorAll(INTERACTIVE_SELECTOR);
1323
+ for (let i = 0; i < candidates.length && elements.length < maxEls; i++) {
1324
+ const rect = isVisible(candidates[i]);
1325
+ if (rect) elements.push(buildEntry(candidates[i], rect));
1326
+ }
1327
+ // 穿透 Shadow DOM + 兜底检测 el.onclick = fn 形式的 JS 属性绑定
1328
+ if (elements.length < maxEls) {
1329
+ const all = root.querySelectorAll('*');
1330
+ for (let i = 0; i < all.length && elements.length < maxEls; i++) {
1331
+ const el = all[i];
1332
+ if (el.shadowRoot) scanRoot(el.shadowRoot);
1333
+ // CSS 选择器只能匹配 [onclick] 属性,这里兜住 el.onclick = fn 的情况
1334
+ if (el.onclick && !el.hasAttribute('data-ghost-ref')) {
1335
+ const rect = isVisible(el);
1336
+ if (rect) elements.push(buildEntry(el, rect));
1127
1337
  }
1128
- // disabled 状态
1129
- if (el.disabled) entry.disabled = true;
1130
- elements.push(entry);
1131
- }
1132
- // 递归子节点
1133
- walkDOM(el, root);
1134
- // 穿透 Shadow DOM
1135
- if (el.shadowRoot) {
1136
- walkDOM(el.shadowRoot, root);
1137
1338
  }
1138
1339
  }
1139
1340
  }
@@ -1141,7 +1342,6 @@ async function handleGetInteractiveSnapshot(params = {}) {
1141
1342
  // 清理旧的 ref 标记
1142
1343
  document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
1143
1344
 
1144
- // 确定扫描根节点
1145
1345
  let rootEl = document.body;
1146
1346
  const sel = ${selectorStr};
1147
1347
  if (sel) {
@@ -1149,7 +1349,7 @@ async function handleGetInteractiveSnapshot(params = {}) {
1149
1349
  if (!rootEl) return { error: '选择器未匹配到任何元素', selector: sel };
1150
1350
  }
1151
1351
 
1152
- walkDOM(rootEl, rootEl);
1352
+ scanRoot(rootEl);
1153
1353
 
1154
1354
  return {
1155
1355
  url: window.location.href,
@@ -1369,6 +1569,7 @@ async function handleCommand(message) {
1369
1569
  else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests()
1370
1570
  else if (command === "perfMetrics") result = await handlePerfMetrics(params)
1371
1571
  else if (command === "captureScreenshot") result = await handleCaptureScreenshot(params)
1572
+ else if (command === "inspectPageSnapshot") result = await handleInspectPageSnapshot(params)
1372
1573
  else if (command === "getPageContent") result = await handleGetPageContent(params)
1373
1574
  else if (command === "getInteractiveSnapshot") result = await handleGetInteractiveSnapshot(params)
1374
1575
  else if (command === "dispatchAction") result = await handleDispatchAction(params)
@@ -1396,10 +1597,8 @@ function broadcastStatus() {
1396
1597
  status = 'disconnected'
1397
1598
  } else if (state.connected) {
1398
1599
  status = 'connected'
1399
- } else if (state.scanRound >= 4) {
1400
- status = 'not_found'
1401
1600
  } else {
1402
- status = 'scanning'
1601
+ status = state.connectionStatus || 'connecting'
1403
1602
  }
1404
1603
 
1405
1604
  let tabUrl = ''
@@ -1425,7 +1624,7 @@ function broadcastStatus() {
1425
1624
  port: state.port,
1426
1625
  currentPort: state.currentPort,
1427
1626
  basePort: CONFIG.basePort,
1428
- scanRound: state.scanRound,
1627
+ connectionError: state.connectionError,
1429
1628
  errorCount: actualErrors.length,
1430
1629
  recentErrors: actualErrors.slice(0, 5),
1431
1630
  tabTitle,
@@ -1474,18 +1673,32 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1474
1673
  if (message.status === 'connected') {
1475
1674
  state.connected = true
1476
1675
  state.port = message.port
1676
+ state.currentPort = message.port
1677
+ state.connectionStatus = 'connected'
1678
+ state.connectionError = ''
1477
1679
  setBadgeState('on')
1478
1680
  log(`✅ 已连接到 ghost-bridge 服务 (端口 ${message.port})`)
1479
1681
  ensureAttached().catch((e) => log(`attach 失败:${e.message}`))
1480
1682
  } else if (message.status === 'disconnected') {
1481
1683
  state.connected = false
1482
1684
  state.port = null
1685
+ state.connectionStatus = 'connecting'
1686
+ state.connectionError = ''
1483
1687
  if (state.enabled) setBadgeState('connecting')
1484
- } else if (message.status === 'scanning') {
1688
+ } else if (message.status === 'connecting') {
1485
1689
  state.currentPort = message.currentPort
1690
+ state.connectionStatus = 'connecting'
1691
+ state.connectionError = ''
1486
1692
  setBadgeState('connecting')
1693
+ } else if (message.status === 'error') {
1694
+ state.currentPort = message.currentPort
1695
+ state.connectionStatus = 'error'
1696
+ state.connectionError = message.errorMessage || ''
1697
+ setBadgeState('err')
1487
1698
  } else if (message.status === 'not_found') {
1488
- state.scanRound++
1699
+ state.currentPort = message.currentPort
1700
+ state.connectionStatus = 'not_found'
1701
+ state.connectionError = ''
1489
1702
  setBadgeState('connecting')
1490
1703
  }
1491
1704
  broadcastStatus() // 状态变化时主动推送
@@ -1516,10 +1729,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1516
1729
  status = 'disconnected'
1517
1730
  } else if (state.connected) {
1518
1731
  status = 'connected'
1519
- } else if (state.scanRound >= 4) {
1520
- status = 'not_found'
1521
1732
  } else {
1522
- status = 'scanning'
1733
+ status = state.connectionStatus || 'connecting'
1523
1734
  }
1524
1735
 
1525
1736
  let tabUrl = ''
@@ -1544,7 +1755,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1544
1755
  port: state.port,
1545
1756
  currentPort: state.currentPort,
1546
1757
  basePort: CONFIG.basePort,
1547
- scanRound: state.scanRound,
1758
+ connectionError: state.connectionError,
1548
1759
  errorCount: actualErrors.length,
1549
1760
  recentErrors: actualErrors.slice(0, 5),
1550
1761
  tabTitle,
@@ -1561,7 +1772,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1561
1772
  chrome.storage.local.set({ basePort: message.port })
1562
1773
  }
1563
1774
  state.enabled = true
1564
- state.scanRound = 0
1775
+ state.connected = false
1776
+ state.port = null
1777
+ state.currentPort = CONFIG.basePort
1778
+ state.connectionStatus = 'connecting'
1779
+ state.connectionError = ''
1565
1780
  setBadgeState('connecting')
1566
1781
 
1567
1782
  // 启动 offscreen 并开始连接
@@ -1570,7 +1785,6 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1570
1785
  type: 'connect',
1571
1786
  basePort: CONFIG.basePort,
1572
1787
  token: CONFIG.token,
1573
- maxPortRetries: CONFIG.maxPortRetries,
1574
1788
  }).catch(() => {})
1575
1789
  })
1576
1790
 
@@ -1582,7 +1796,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1582
1796
  if (message.type === 'disconnect') {
1583
1797
  state.enabled = false
1584
1798
  state.connected = false
1585
- state.scanRound = 0
1799
+ state.port = null
1800
+ state.currentPort = null
1801
+ state.connectionStatus = 'disconnected'
1802
+ state.connectionError = ''
1586
1803
  setBadgeState('off')
1587
1804
  detachAllTargets().catch(() => {})
1588
1805
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Ghost Bridge",
4
- "version": "0.5.1",
4
+ "version": "0.6.0",
5
5
  "description": "Zero-restart Chrome debugger bridge for Claude MCP, optimized for no-sourcemap production debugging.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -7,7 +7,6 @@ let manualDisconnect = false // 用户主动断开标志,防止 onclose 触
7
7
  let config = {
8
8
  basePort: 33333,
9
9
  token: '',
10
- maxPortRetries: 10,
11
10
  }
12
11
 
13
12
  function log(msg) {
@@ -23,29 +22,17 @@ function getMonthlyToken() {
23
22
  }
24
23
 
25
24
  // 连接到服务器
26
- function connect(portIndex = 0, isNewRound = false) {
25
+ function connect() {
27
26
  // 如果已手动断开,不再尝试连接
28
27
  if (manualDisconnect) return
29
28
 
30
- if (portIndex >= config.maxPortRetries) {
31
- log(`扫描完毕,未找到服务,2秒后重试...`)
32
- reconnectTimer = setTimeout(() => connect(0, true), 2000)
33
- chrome.runtime.sendMessage({ type: 'status', status: 'not_found' }).catch(() => {})
34
- return
35
- }
36
-
37
- const port = config.basePort + portIndex
29
+ const port = config.basePort
38
30
  const url = new URL(`ws://localhost:${port}`)
39
31
  url.searchParams.set('token', config.token)
40
-
41
- if (portIndex === 0 && isNewRound) {
42
- log(`开始扫描端口 ${config.basePort}-${config.basePort + config.maxPortRetries - 1}`)
43
- }
44
-
45
- log(`尝试连接端口 ${port}...`)
32
+ log(`尝试连接固定端口 ${port}...`)
46
33
  chrome.runtime.sendMessage({
47
34
  type: 'status',
48
- status: 'scanning',
35
+ status: 'connecting',
49
36
  currentPort: port,
50
37
  }).catch(() => {})
51
38
 
@@ -59,8 +46,11 @@ function connect(portIndex = 0, isNewRound = false) {
59
46
  }, 2000) // 增加到 2 秒
60
47
 
61
48
  let identityVerified = false
49
+ let socketOpened = false
50
+ let terminalErrorMessage = ''
62
51
 
63
52
  ws.onopen = () => {
53
+ socketOpened = true
64
54
  clearTimeout(connectionTimeout)
65
55
  log(`WebSocket 已连接端口 ${port},等待身份验证...`)
66
56
  }
@@ -84,9 +74,15 @@ function connect(portIndex = 0, isNewRound = false) {
84
74
  port: port,
85
75
  }).catch(() => {})
86
76
  } else {
87
- log(`身份验证失败,尝试下一个端口...`)
77
+ terminalErrorMessage = `Port ${port} is occupied by a non-matching service, or the token does not match.`
78
+ log('身份验证失败,将在固定端口上重试...')
79
+ chrome.runtime.sendMessage({
80
+ type: 'status',
81
+ status: 'error',
82
+ currentPort: port,
83
+ errorMessage: terminalErrorMessage,
84
+ }).catch(() => {})
88
85
  ws.close()
89
- setTimeout(() => connect(portIndex + 1), 50)
90
86
  }
91
87
  return
92
88
  }
@@ -107,15 +103,28 @@ function connect(portIndex = 0, isNewRound = false) {
107
103
  if (manualDisconnect) return
108
104
 
109
105
  if (!identityVerified) {
110
- // 连接失败,尝试下一个端口
111
- setTimeout(() => connect(portIndex + 1), 50)
106
+ if (terminalErrorMessage || socketOpened) {
107
+ const errorMessage = terminalErrorMessage || `Port ${port} is occupied or responding with a non-ghost-bridge protocol.`
108
+ log(`${errorMessage} 2秒后重试...`)
109
+ chrome.runtime.sendMessage({
110
+ type: 'status',
111
+ status: 'error',
112
+ currentPort: port,
113
+ errorMessage,
114
+ }).catch(() => {})
115
+ reconnectTimer = setTimeout(() => connect(), 2000)
116
+ return
117
+ }
118
+ log(`固定端口 ${port} 未发现可用服务,2秒后重试...`)
119
+ chrome.runtime.sendMessage({ type: 'status', status: 'not_found', currentPort: port }).catch(() => {})
120
+ reconnectTimer = setTimeout(() => connect(), 2000)
112
121
  return
113
122
  }
114
123
 
115
124
  // 连接断开,重试
116
- log('连接断开,尝试重连...')
125
+ log(`端口 ${port} 连接断开,尝试重连...`)
117
126
  chrome.runtime.sendMessage({ type: 'status', status: 'disconnected' }).catch(() => {})
118
- reconnectTimer = setTimeout(() => connect(0, true), 1000)
127
+ reconnectTimer = setTimeout(() => connect(), 1000)
119
128
  }
120
129
 
121
130
  ws.onerror = () => {
@@ -151,10 +160,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
151
160
  if (message.type === 'connect') {
152
161
  config.basePort = message.basePort || 33333
153
162
  config.token = message.token || getMonthlyToken()
154
- config.maxPortRetries = message.maxPortRetries || 10
155
163
  disconnect()
156
164
  manualDisconnect = false // 用户重新连接,清除断开标志
157
- connect(0, true)
165
+ connect()
158
166
  sendResponse({ ok: true })
159
167
  return true
160
168
  }
@@ -121,6 +121,15 @@
121
121
  }
122
122
  body.connected-state .bg-ripple-2 { animation-delay: 1.25s; }
123
123
 
124
+ .ghost-error .ghost-body { fill: #ef4444; }
125
+ .ghost-error .ghost-eyes-normal { opacity: 1; }
126
+
127
+ body.error-state .bg-ripple {
128
+ stroke: #ef4444;
129
+ animation: bg-ripple-scan 1.8s infinite cubic-bezier(0, 0, 0.2, 1);
130
+ }
131
+ body.error-state .bg-ripple-2 { animation-delay: 0.9s; }
132
+
124
133
  @keyframes bg-ripple-scan {
125
134
  0% { r: 10%; opacity: 0.8; stroke-width: 3; }
126
135
  100% { r: 80%; opacity: 0; stroke-width: 0; }
@@ -53,7 +53,7 @@ const STATUS_MAP = {
53
53
  }
54
54
 
55
55
  function renderUI(state) {
56
- const { status, port, scanRound, enabled, currentPort, basePort, errorCount, recentErrors, tabTitle } = state
56
+ const { status, port, enabled, currentPort, basePort, connectionError, errorCount, recentErrors, tabTitle } = state
57
57
  const config = STATUS_MAP[status] || STATUS_MAP.disconnected
58
58
 
59
59
  // Update classes for color & animations
@@ -66,6 +66,9 @@ function renderUI(state) {
66
66
  } else if (config.statusClass === 'connecting') {
67
67
  headerGhost.className = 'ghost-wrapper ghost-connecting'
68
68
  document.body.className = 'connecting-state'
69
+ } else if (config.statusClass === 'error') {
70
+ headerGhost.className = 'ghost-wrapper ghost-error'
71
+ document.body.className = 'error-state'
69
72
  } else {
70
73
  headerGhost.className = 'ghost-wrapper ghost-disconnected'
71
74
  document.body.className = 'disconnected-state'
@@ -117,8 +120,7 @@ function renderUI(state) {
117
120
 
118
121
  detailContainer.classList.remove('collapsed')
119
122
  } else if ((status === 'connecting' || status === 'verifying' || status === 'scanning') && currentPort) {
120
- const roundText = scanRound > 0 ? ` [R${scanRound + 1}]` : ''
121
- portVal.textContent = `Scanning: ${currentPort}${roundText}`
123
+ portVal.textContent = `Connecting: ${currentPort}`
122
124
  portVal.className = 'detail-value highlight'
123
125
  tabRow.classList.add('hidden')
124
126
  errorRow.classList.add('hidden')
@@ -131,6 +133,12 @@ function renderUI(state) {
131
133
  tabRow.classList.add('hidden')
132
134
  errorRow.classList.add('hidden')
133
135
  detailContainer.classList.remove('collapsed')
136
+ } else if (status === 'error') {
137
+ portVal.textContent = `Port ${currentPort || basePort || '-'} blocked`
138
+ portVal.className = 'detail-value warning'
139
+ tabRow.classList.add('hidden')
140
+ errorRow.classList.add('hidden')
141
+ detailContainer.classList.remove('collapsed')
134
142
  } else {
135
143
  detailContainer.classList.add('collapsed')
136
144
  }
@@ -145,8 +153,11 @@ function renderUI(state) {
145
153
  }
146
154
 
147
155
  // Scan info text
148
- if ((status === 'connecting' || status === 'scanning') && scanRound > 2) {
149
- scanInfo.textContent = `Round ${scanRound}: Is your MCP Server running?`
156
+ if (status === 'error' && connectionError) {
157
+ scanInfo.textContent = connectionError
158
+ scanInfo.classList.remove('collapsed')
159
+ } else if (status === 'not_found' && basePort) {
160
+ scanInfo.textContent = `Port ${basePort} unavailable. Is your MCP server running on this port?`
150
161
  scanInfo.classList.remove('collapsed')
151
162
  } else {
152
163
  scanInfo.classList.add('collapsed')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost-bridge",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Ghost Bridge: Zero-restart Chrome debugger bridge for Claude MCP. Includes CLI for easy setup.",
@@ -48,5 +48,8 @@
48
48
  "fs-extra": "^11.1.1",
49
49
  "js-beautify": "^1.14.11",
50
50
  "ws": "^8.14.2"
51
+ },
52
+ "dependencies": {
53
+ "ghost-bridge": "^0.5.2"
51
54
  }
52
55
  }