ghost-bridge 0.2.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.
@@ -0,0 +1,956 @@
1
+ // 使用当月1号0点的时间戳作为 token,确保同月内的服务器和插件自动匹配
2
+ function getMonthlyToken() {
3
+ const now = new Date()
4
+ const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
5
+ return String(firstDayOfMonth.getTime())
6
+ }
7
+
8
+ const CONFIG = {
9
+ basePort: 33333,
10
+ maxPortRetries: 10,
11
+ token: getMonthlyToken(),
12
+ autoDetach: false,
13
+ maxErrors: 40,
14
+ maxStackFrames: 5,
15
+ maxRequestsTracked: 200,
16
+ maxRequestBodySize: 100000,
17
+ }
18
+
19
+ let attachedTabId = null
20
+ let scriptMap = new Map()
21
+ let scriptSourceCache = new Map()
22
+ let lastErrors = []
23
+ let lastErrorLocation = null
24
+ let requestMap = new Map()
25
+ let networkRequests = []
26
+ let state = { enabled: false, connected: false, port: null, currentPort: null, scanRound: 0 }
27
+
28
+ // 待处理的请求(等待 offscreen 响应)
29
+ const pendingRequests = new Map()
30
+
31
+ function setBadgeState(status) {
32
+ const map = {
33
+ connecting: { text: "…", color: "#999" },
34
+ on: { text: "ON", color: "#34c759" },
35
+ off: { text: "OFF", color: "#999" },
36
+ err: { text: "ERR", color: "#ff3b30" },
37
+ att: { text: "ATT", color: "#ff9f0a" },
38
+ }
39
+ const cfg = map[status] || map.off
40
+ chrome.action.setBadgeText({ text: cfg.text })
41
+ chrome.action.setBadgeBackgroundColor({ color: cfg.color })
42
+ }
43
+
44
+ function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms))
46
+ }
47
+
48
+ function log(msg) {
49
+ console.log(`[ghost-bridge] ${msg}`)
50
+ }
51
+
52
+ // ========== Offscreen Document 管理 ==========
53
+
54
+ let offscreenCreating = null
55
+
56
+ async function setupOffscreenDocument() {
57
+ const offscreenUrl = chrome.runtime.getURL('offscreen.html')
58
+
59
+ // 检查是否已存在
60
+ const existingContexts = await chrome.runtime.getContexts({
61
+ contextTypes: ['OFFSCREEN_DOCUMENT'],
62
+ documentUrls: [offscreenUrl]
63
+ }).catch(() => [])
64
+
65
+ if (existingContexts.length > 0) {
66
+ return
67
+ }
68
+
69
+ // 防止并发创建
70
+ if (offscreenCreating) {
71
+ await offscreenCreating
72
+ return
73
+ }
74
+
75
+ offscreenCreating = chrome.offscreen.createDocument({
76
+ url: 'offscreen.html',
77
+ reasons: ['WORKERS'], // 使用 WORKERS 作为理由
78
+ justification: 'Maintain WebSocket connection to ghost-bridge server'
79
+ })
80
+
81
+ await offscreenCreating
82
+ offscreenCreating = null
83
+ log('Offscreen document 已创建')
84
+ }
85
+
86
+ async function closeOffscreenDocument() {
87
+ try {
88
+ await chrome.offscreen.closeDocument()
89
+ log('Offscreen document 已关闭')
90
+ } catch {
91
+ // 可能已关闭
92
+ }
93
+ }
94
+
95
+ // ========== Chrome Debugger 事件处理 ==========
96
+
97
+ chrome.debugger.onEvent.addListener((source, method, params) => {
98
+ if (source.tabId !== attachedTabId) return
99
+ if (!state.enabled) return
100
+
101
+ if (method === "Debugger.scriptParsed") {
102
+ scriptMap.set(params.scriptId, { url: params.url || "(inline)" })
103
+ }
104
+
105
+ if (method === "Runtime.exceptionThrown") {
106
+ const detail = params?.exceptionDetails || {}
107
+ const topFrame = detail.stackTrace?.callFrames?.[0]
108
+ const entry = {
109
+ type: "exception",
110
+ severity: "error",
111
+ url: topFrame?.url || detail.url,
112
+ line: topFrame?.lineNumber,
113
+ column: topFrame?.columnNumber,
114
+ text: detail.text,
115
+ scriptId: topFrame?.scriptId,
116
+ stack: compactStack(detail.stackTrace),
117
+ timestamp: Date.now(),
118
+ }
119
+ lastErrorLocation = {
120
+ url: entry.url,
121
+ line: entry.line,
122
+ column: entry.column,
123
+ scriptId: entry.scriptId,
124
+ }
125
+ pushError(entry)
126
+ }
127
+
128
+ if (method === "Log.entryAdded") {
129
+ const entry = params?.entry || {}
130
+ pushError({
131
+ type: entry.level || "log",
132
+ severity: entry.level === "warning" ? "warn" : entry.level === "error" ? "error" : "info",
133
+ url: entry.source || entry.url,
134
+ line: entry.lineNumber,
135
+ text: entry.text,
136
+ stack: compactStack(entry.stackTrace),
137
+ timestamp: Date.now(),
138
+ })
139
+ }
140
+
141
+ if (method === "Runtime.consoleAPICalled") {
142
+ const args = (params.args || []).map((a) => a.value).filter(Boolean)
143
+ pushError({
144
+ type: params.type || "console",
145
+ severity: params.type === "error" ? "error" : params.type === "warning" ? "warn" : "info",
146
+ url: params.stackTrace?.callFrames?.[0]?.url,
147
+ line: params.stackTrace?.callFrames?.[0]?.lineNumber,
148
+ text: args.join(" "),
149
+ stack: compactStack(params.stackTrace),
150
+ timestamp: Date.now(),
151
+ })
152
+ }
153
+
154
+ // 网络事件处理
155
+ if (method === "Network.requestWillBeSent") {
156
+ const req = params.request || {}
157
+ const entry = {
158
+ requestId: params.requestId,
159
+ url: req.url,
160
+ method: req.method || "GET",
161
+ requestHeaders: req.headers || {},
162
+ postData: req.postData,
163
+ initiator: params.initiator?.type,
164
+ resourceType: params.type,
165
+ startTime: params.timestamp,
166
+ timestamp: Date.now(),
167
+ status: "pending",
168
+ }
169
+ requestMap.set(params.requestId, entry)
170
+ if (requestMap.size > CONFIG.maxRequestsTracked * 2) {
171
+ const firstKey = requestMap.keys().next().value
172
+ requestMap.delete(firstKey)
173
+ }
174
+ }
175
+
176
+ if (method === "Network.responseReceived") {
177
+ const res = params.response || {}
178
+ const entry = requestMap.get(params.requestId)
179
+ if (entry) {
180
+ entry.status = res.status >= 400 ? "error" : "success"
181
+ entry.statusCode = res.status
182
+ entry.statusText = res.statusText
183
+ entry.mimeType = res.mimeType
184
+ entry.responseHeaders = res.headers || {}
185
+ entry.protocol = res.protocol
186
+ entry.remoteAddress = res.remoteIPAddress
187
+ entry.fromCache = res.fromDiskCache || res.fromServiceWorker
188
+ entry.timing = res.timing
189
+ entry.encodedDataLength = params.encodedDataLength
190
+ if (res.status >= 400) {
191
+ pushError({
192
+ type: "network",
193
+ severity: "error",
194
+ url: res.url || entry.url,
195
+ status: res.status,
196
+ statusText: res.statusText,
197
+ mimeType: res.mimeType,
198
+ requestId: params.requestId,
199
+ method: entry.method,
200
+ timestamp: Date.now(),
201
+ })
202
+ }
203
+ }
204
+ }
205
+
206
+ if (method === "Network.loadingFinished") {
207
+ const entry = requestMap.get(params.requestId)
208
+ if (entry) {
209
+ entry.endTime = params.timestamp
210
+ entry.encodedDataLength = params.encodedDataLength
211
+ entry.duration = entry.endTime && entry.startTime
212
+ ? Math.round((entry.endTime - entry.startTime) * 1000)
213
+ : null
214
+ if (entry.status === "pending") entry.status = "success"
215
+ pushNetworkRequest(entry)
216
+ requestMap.delete(params.requestId)
217
+ }
218
+ }
219
+
220
+ if (method === "Network.loadingFailed") {
221
+ const entry = requestMap.get(params.requestId)
222
+ if (entry) {
223
+ entry.status = "failed"
224
+ entry.errorText = params.errorText
225
+ entry.canceled = params.canceled
226
+ entry.blockedReason = params.blockedReason
227
+ pushError({
228
+ type: "network",
229
+ severity: "error",
230
+ url: entry.url,
231
+ requestId: params.requestId,
232
+ method: entry.method,
233
+ text: params.errorText,
234
+ timestamp: Date.now(),
235
+ })
236
+ pushNetworkRequest(entry)
237
+ requestMap.delete(params.requestId)
238
+ }
239
+ }
240
+ })
241
+
242
+ function pushNetworkRequest(entry) {
243
+ networkRequests.unshift(entry)
244
+ if (networkRequests.length > CONFIG.maxRequestsTracked) {
245
+ networkRequests.pop()
246
+ }
247
+ }
248
+
249
+ chrome.debugger.onDetach.addListener((source, reason) => {
250
+ if (source.tabId && source.tabId === attachedTabId) {
251
+ attachedTabId = null
252
+ scriptMap = new Map()
253
+ scriptSourceCache = new Map()
254
+ networkRequests = []
255
+ requestMap = new Map()
256
+ }
257
+ if (!state.enabled) return
258
+ if (reason === "canceled_by_user") {
259
+ log("调试被用户取消,已关闭")
260
+ state.enabled = false
261
+ setBadgeState("off")
262
+ } else {
263
+ log(`调试已断开:${reason}`)
264
+ setBadgeState("att")
265
+ }
266
+ })
267
+
268
+ function pushError(entry) {
269
+ lastErrors.unshift(entry)
270
+ if (lastErrors.length > CONFIG.maxErrors) {
271
+ const dropIdx = lastErrors
272
+ .map((e, i) => ({ sev: e.severity || "info", i }))
273
+ .reverse()
274
+ .find((e) => e.sev !== "error")?.i
275
+ if (dropIdx !== undefined) lastErrors.splice(dropIdx, 1)
276
+ else lastErrors.pop()
277
+ }
278
+ }
279
+
280
+ function compactStack(stackTrace) {
281
+ const frames = stackTrace?.callFrames || []
282
+ return frames.slice(0, CONFIG.maxStackFrames).map((f) => ({
283
+ functionName: f.functionName || "",
284
+ url: f.url || "(inline)",
285
+ line: f.lineNumber,
286
+ column: f.columnNumber,
287
+ }))
288
+ }
289
+
290
+ // ========== Debugger 操作 ==========
291
+
292
+ async function ensureAttached() {
293
+ if (!state.enabled) throw new Error("扩展已暂停,点击图标开启后再试")
294
+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
295
+ if (!tab) throw new Error("没有激活的标签页")
296
+ if (attachedTabId !== tab.id) {
297
+ try {
298
+ await chrome.debugger.attach({ tabId: tab.id }, "1.3")
299
+ setBadgeState("on")
300
+ } catch (e) {
301
+ setBadgeState("att")
302
+ throw e
303
+ }
304
+ attachedTabId = tab.id
305
+ scriptMap = new Map()
306
+ scriptSourceCache = new Map()
307
+ networkRequests = []
308
+ requestMap = new Map()
309
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Runtime.enable")
310
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Log.enable")
311
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Console.enable").catch(() => {})
312
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Debugger.enable")
313
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Profiler.enable")
314
+ await chrome.debugger.sendCommand({ tabId: attachedTabId }, "Network.enable").catch(() => {})
315
+ }
316
+ return { tabId: attachedTabId }
317
+ }
318
+
319
+ async function maybeDetach(force = false) {
320
+ if ((CONFIG.autoDetach || force) && attachedTabId) {
321
+ try {
322
+ await chrome.debugger.detach({ tabId: attachedTabId })
323
+ } catch (e) {
324
+ log(`detach 失败:${e.message}`)
325
+ } finally {
326
+ attachedTabId = null
327
+ }
328
+ }
329
+ }
330
+
331
+ async function detachAllTargets() {
332
+ try {
333
+ const targets = await chrome.debugger.getTargets()
334
+ for (const t of targets) {
335
+ if (!t.attached) continue
336
+ try {
337
+ if (t.tabId !== undefined) {
338
+ await chrome.debugger.detach({ tabId: t.tabId })
339
+ } else {
340
+ await chrome.debugger.detach({ targetId: t.id })
341
+ }
342
+ } catch {}
343
+ }
344
+ const tabs = await chrome.tabs.query({})
345
+ for (const tab of tabs) {
346
+ if (!tab.id) continue
347
+ try {
348
+ await chrome.debugger.detach({ tabId: tab.id })
349
+ } catch {}
350
+ }
351
+ } catch {}
352
+ attachedTabId = null
353
+ }
354
+
355
+ // ========== 命令处理 ==========
356
+
357
+ async function handleGetLastError() {
358
+ await ensureAttached()
359
+ const events = lastErrors.slice(0, CONFIG.maxErrors)
360
+ const counts = events.reduce(
361
+ (acc, e) => {
362
+ acc.total++
363
+ acc[e.severity || "info"] = (acc[e.severity || "info"] || 0) + 1
364
+ return acc
365
+ },
366
+ { total: 0 }
367
+ )
368
+ return {
369
+ lastErrorLocation,
370
+ summary: {
371
+ count: events.length,
372
+ severityCount: counts,
373
+ lastTimestamp: events[0]?.timestamp,
374
+ },
375
+ recent: events,
376
+ }
377
+ }
378
+
379
+ async function pickScriptId(preferUrlContains) {
380
+ if (preferUrlContains) {
381
+ for (const [id, meta] of scriptMap.entries()) {
382
+ if (meta.url && meta.url.includes(preferUrlContains)) return { id, url: meta.url }
383
+ }
384
+ }
385
+ if (lastErrorLocation?.scriptId && scriptMap.has(lastErrorLocation.scriptId)) {
386
+ const meta = scriptMap.get(lastErrorLocation.scriptId)
387
+ return { id: lastErrorLocation.scriptId, url: meta.url }
388
+ }
389
+ const first = scriptMap.entries().next().value
390
+ if (first) {
391
+ return { id: first[0], url: first[1].url }
392
+ }
393
+ throw new Error("未找到可用脚本,确认页面已加载脚本")
394
+ }
395
+
396
+ async function handleGetScriptSource(params = {}) {
397
+ const target = await ensureAttached()
398
+ const chosen = await pickScriptId(params.scriptUrlContains)
399
+ const { scriptSource } = await chrome.debugger.sendCommand(target, "Debugger.getScriptSource", {
400
+ scriptId: chosen.id,
401
+ })
402
+ scriptSourceCache.set(chosen.id, scriptSource)
403
+ const location = {
404
+ line: params.line ?? lastErrorLocation?.line ?? null,
405
+ column: params.column ?? lastErrorLocation?.column ?? null,
406
+ }
407
+ return {
408
+ url: chosen.url,
409
+ scriptId: chosen.id,
410
+ location,
411
+ source: scriptSource,
412
+ note: "若为单行压缩脚本,可结合 column 提取片段",
413
+ }
414
+ }
415
+
416
+ async function handleCoverageSnapshot(params = {}) {
417
+ const target = await ensureAttached()
418
+ const durationMs = params.durationMs || 1500
419
+ await chrome.debugger.sendCommand(target, "Profiler.startPreciseCoverage", {
420
+ callCount: true,
421
+ detailed: true,
422
+ })
423
+ await sleep(durationMs)
424
+ const { result } = await chrome.debugger.sendCommand(target, "Profiler.takePreciseCoverage")
425
+ await chrome.debugger.sendCommand(target, "Profiler.stopPreciseCoverage")
426
+
427
+ const simplified = result
428
+ .map((item) => {
429
+ const totalCount = item.functions.reduce((sum, f) => sum + (f.callCount || 0), 0)
430
+ return { url: item.url || "(inline)", scriptId: item.scriptId, totalCount }
431
+ })
432
+ .sort((a, b) => b.totalCount - a.totalCount)
433
+ .slice(0, 20)
434
+
435
+ return { topScripts: simplified, rawCount: result.length }
436
+ }
437
+
438
+ function findContexts(source, query, maxMatches) {
439
+ const lower = source.toLowerCase()
440
+ const q = query.toLowerCase()
441
+ const matches = []
442
+ let idx = lower.indexOf(q)
443
+ while (idx !== -1 && matches.length < maxMatches) {
444
+ const start = Math.max(0, idx - 200)
445
+ const end = Math.min(source.length, idx + q.length + 200)
446
+ matches.push({ start, end, context: source.slice(start, end) })
447
+ idx = lower.indexOf(q, idx + q.length)
448
+ }
449
+ return matches
450
+ }
451
+
452
+ async function handleFindByString(params = {}) {
453
+ const target = await ensureAttached()
454
+ const query = params.query
455
+ const maxMatches = params.maxMatches || 5
456
+ const preferred = params.scriptUrlContains
457
+
458
+ const results = []
459
+ const entries = [...scriptMap.entries()]
460
+ for (const [id, meta] of entries) {
461
+ if (preferred && (!meta.url || !meta.url.includes(preferred))) continue
462
+ if (!scriptSourceCache.has(id)) {
463
+ const { scriptSource } = await chrome.debugger.sendCommand(target, "Debugger.getScriptSource", { scriptId: id })
464
+ scriptSourceCache.set(id, scriptSource)
465
+ }
466
+ const source = scriptSourceCache.get(id)
467
+ const matches = findContexts(source, query, maxMatches - results.length)
468
+ if (matches.length) {
469
+ results.push({ url: meta.url, scriptId: id, matches })
470
+ }
471
+ if (results.length >= maxMatches) break
472
+ }
473
+
474
+ return { query, results }
475
+ }
476
+
477
+ async function handleSymbolicHints() {
478
+ const target = await ensureAttached()
479
+ const expression = `(function(){
480
+ try {
481
+ const resources = performance.getEntriesByType('resource').slice(-20).map(e => ({
482
+ name: e.name, type: e.initiatorType || '', size: e.transferSize || 0
483
+ }));
484
+ const globals = Object.keys(window).filter(k => k.length < 30).slice(0, 60);
485
+ const ls = Object.keys(localStorage || {}).slice(0, 20);
486
+ return {
487
+ location: window.location.href,
488
+ ua: navigator.userAgent,
489
+ resources, globals, localStorageKeys: ls
490
+ };
491
+ } catch (e) { return { error: e.message }; }
492
+ })()`
493
+ const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
494
+ expression,
495
+ returnByValue: true,
496
+ })
497
+ return result?.value
498
+ }
499
+
500
+ async function handleEval(params = {}) {
501
+ const target = await ensureAttached()
502
+ const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
503
+ expression: params.code,
504
+ returnByValue: true,
505
+ })
506
+ return result?.value
507
+ }
508
+
509
+ async function handleListNetworkRequests(params = {}) {
510
+ await ensureAttached()
511
+ const { filter, method, status, resourceType, limit = 50 } = params
512
+
513
+ let results = [...networkRequests]
514
+ const pending = [...requestMap.values()].map(r => ({ ...r, status: "pending" }))
515
+ results = [...pending, ...results]
516
+
517
+ if (filter) {
518
+ const lowerFilter = filter.toLowerCase()
519
+ results = results.filter(r => r.url?.toLowerCase().includes(lowerFilter))
520
+ }
521
+ if (method) results = results.filter(r => r.method?.toUpperCase() === method.toUpperCase())
522
+ if (status) results = results.filter(r => r.status === status)
523
+ if (resourceType) {
524
+ const lowerType = resourceType.toLowerCase()
525
+ results = results.filter(r => r.resourceType?.toLowerCase() === lowerType)
526
+ }
527
+
528
+ results = results.slice(0, limit)
529
+
530
+ return {
531
+ total: networkRequests.length + requestMap.size,
532
+ filtered: results.length,
533
+ requests: results.map(r => ({
534
+ requestId: r.requestId,
535
+ url: r.url,
536
+ method: r.method,
537
+ status: r.status,
538
+ statusCode: r.statusCode,
539
+ resourceType: r.resourceType,
540
+ mimeType: r.mimeType,
541
+ duration: r.duration,
542
+ encodedDataLength: r.encodedDataLength,
543
+ fromCache: r.fromCache,
544
+ timestamp: r.timestamp,
545
+ errorText: r.errorText,
546
+ })),
547
+ }
548
+ }
549
+
550
+ async function handleGetNetworkDetail(params = {}) {
551
+ const target = await ensureAttached()
552
+ const { requestId, includeBody = false } = params
553
+ if (!requestId) throw new Error("需要提供 requestId")
554
+
555
+ let entry = requestMap.get(requestId)
556
+ if (!entry) entry = networkRequests.find(r => r.requestId === requestId)
557
+ if (!entry) throw new Error(`未找到请求: ${requestId}`)
558
+
559
+ const result = { ...entry }
560
+
561
+ if (includeBody && entry.status !== "pending" && entry.status !== "failed") {
562
+ try {
563
+ const { body, base64Encoded } = await chrome.debugger.sendCommand(
564
+ target, "Network.getResponseBody", { requestId }
565
+ )
566
+ if (base64Encoded) {
567
+ result.bodyInfo = { type: "binary", base64Length: body.length, note: "二进制内容,已 base64 编码" }
568
+ if (body.length < CONFIG.maxRequestBodySize) result.bodyBase64 = body
569
+ } else {
570
+ if (body.length > CONFIG.maxRequestBodySize) {
571
+ result.body = body.slice(0, CONFIG.maxRequestBodySize)
572
+ result.bodyTruncated = true
573
+ result.bodyTotalLength = body.length
574
+ } else {
575
+ result.body = body
576
+ }
577
+ }
578
+ } catch (e) {
579
+ result.bodyError = e.message
580
+ }
581
+ }
582
+
583
+ return result
584
+ }
585
+
586
+ async function handleClearNetworkRequests() {
587
+ await ensureAttached()
588
+ const count = networkRequests.length
589
+ networkRequests = []
590
+ return { cleared: count }
591
+ }
592
+
593
+ async function handleCaptureScreenshot(params = {}) {
594
+ const target = await ensureAttached()
595
+ const { format = 'png', quality, fullPage = false, clip } = params
596
+
597
+ await chrome.debugger.sendCommand(target, 'Page.enable')
598
+
599
+ let captureParams = {
600
+ format,
601
+ ...(quality !== undefined && format === 'jpeg' ? { quality } : {}),
602
+ }
603
+
604
+ if (clip) {
605
+ captureParams.clip = { x: clip.x || 0, y: clip.y || 0, width: clip.width, height: clip.height, scale: clip.scale || 1 }
606
+ } else if (fullPage) {
607
+ const { result } = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
608
+ expression: `(function() {
609
+ return {
610
+ width: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.body.clientWidth, document.documentElement.clientWidth),
611
+ height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight)
612
+ };
613
+ })()`,
614
+ returnByValue: true,
615
+ })
616
+
617
+ const pageSize = result?.value
618
+ if (pageSize && pageSize.width && pageSize.height) {
619
+ const maxWidth = Math.min(pageSize.width, 4096)
620
+ const maxHeight = Math.min(pageSize.height, 16384)
621
+
622
+ captureParams.clip = { x: 0, y: 0, width: maxWidth, height: maxHeight, scale: 1 }
623
+
624
+ const { result: viewportResult } = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
625
+ expression: `({ width: window.innerWidth, height: window.innerHeight })`,
626
+ returnByValue: true,
627
+ })
628
+ const originalViewport = viewportResult?.value
629
+
630
+ await chrome.debugger.sendCommand(target, 'Emulation.setDeviceMetricsOverride', {
631
+ width: maxWidth, height: maxHeight, deviceScaleFactor: 1, mobile: false,
632
+ })
633
+
634
+ try {
635
+ const { data } = await chrome.debugger.sendCommand(target, 'Page.captureScreenshot', captureParams)
636
+ if (originalViewport) {
637
+ await chrome.debugger.sendCommand(target, 'Emulation.setDeviceMetricsOverride', {
638
+ width: originalViewport.width, height: originalViewport.height, deviceScaleFactor: 1, mobile: false,
639
+ })
640
+ }
641
+ await chrome.debugger.sendCommand(target, 'Emulation.clearDeviceMetricsOverride').catch(() => {})
642
+ return {
643
+ imageData: data, format, fullPage: true, width: maxWidth, height: maxHeight,
644
+ note: pageSize.height > maxHeight ? `页面高度 ${pageSize.height}px 超过限制,已截取前 ${maxHeight}px` : undefined,
645
+ }
646
+ } catch (e) {
647
+ await chrome.debugger.sendCommand(target, 'Emulation.clearDeviceMetricsOverride').catch(() => {})
648
+ throw e
649
+ }
650
+ }
651
+ }
652
+
653
+ const { data } = await chrome.debugger.sendCommand(target, 'Page.captureScreenshot', captureParams)
654
+ const { result: sizeResult } = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
655
+ expression: `({ width: window.innerWidth, height: window.innerHeight })`,
656
+ returnByValue: true,
657
+ })
658
+
659
+ return { imageData: data, format, fullPage: false, width: sizeResult?.value?.width, height: sizeResult?.value?.height }
660
+ }
661
+
662
+ async function handleGetPageContent(params = {}) {
663
+ const target = await ensureAttached()
664
+ const { mode = "text", selector, maxLength = 50000, includeMetadata = true } = params
665
+
666
+ const selectorStr = selector ? JSON.stringify(selector) : 'null'
667
+ const modeStr = JSON.stringify(mode)
668
+
669
+ const expression = `(function() {
670
+ try {
671
+ const result = {};
672
+ if (document.readyState === 'loading') {
673
+ return { error: '页面尚未加载完成,请稍后重试', readyState: document.readyState };
674
+ }
675
+ let targetElement = document.body;
676
+ const selector = ${selectorStr};
677
+ if (selector) {
678
+ try {
679
+ targetElement = document.querySelector(selector);
680
+ if (!targetElement) {
681
+ return { error: '选择器未匹配到任何元素', selector: selector, suggestion: '请检查选择器是否正确' };
682
+ }
683
+ result.selector = selector;
684
+ result.matchedTag = targetElement.tagName.toLowerCase();
685
+ } catch (e) {
686
+ return { error: '无效的 CSS 选择器: ' + e.message, selector: selector };
687
+ }
688
+ }
689
+ const includeMetadata = ${includeMetadata};
690
+ if (includeMetadata) {
691
+ result.metadata = {
692
+ title: document.title || '',
693
+ url: window.location.href,
694
+ description: document.querySelector('meta[name="description"]')?.content || '',
695
+ keywords: document.querySelector('meta[name="keywords"]')?.content || '',
696
+ charset: document.characterSet,
697
+ language: document.documentElement.lang || '',
698
+ };
699
+ }
700
+ const mode = ${modeStr};
701
+ const maxLength = ${maxLength};
702
+ if (mode === 'text') {
703
+ let text = targetElement.innerText || targetElement.textContent || '';
704
+ text = text.replace(/\\n{3,}/g, '\\n\\n').trim();
705
+ result.contentLength = text.length;
706
+ if (text.length > maxLength) {
707
+ result.content = text.slice(0, maxLength);
708
+ result.truncated = true;
709
+ } else {
710
+ result.content = text;
711
+ result.truncated = false;
712
+ }
713
+ } else if (mode === 'html') {
714
+ let html = targetElement.outerHTML || '';
715
+ result.contentLength = html.length;
716
+ if (html.length > maxLength) {
717
+ result.content = html.slice(0, maxLength);
718
+ result.truncated = true;
719
+ result.note = 'HTML 已截断,可能不完整';
720
+ } else {
721
+ result.content = html;
722
+ result.truncated = false;
723
+ }
724
+ } else if (mode === 'structured') {
725
+ const structured = {};
726
+ const headings = targetElement.querySelectorAll('h1,h2,h3,h4,h5,h6');
727
+ structured.headings = Array.from(headings).slice(0, 50).map(h => ({ level: parseInt(h.tagName[1]), text: h.innerText.trim().slice(0, 200) }));
728
+ const links = targetElement.querySelectorAll('a[href]');
729
+ structured.links = Array.from(links).slice(0, 100).map(a => ({ text: (a.innerText || '').trim().slice(0, 100), href: a.href })).filter(l => l.href && !l.href.startsWith('javascript:'));
730
+ const buttons = targetElement.querySelectorAll('button, input[type="button"], input[type="submit"], [role="button"]');
731
+ structured.buttons = Array.from(buttons).slice(0, 50).map(b => ({ text: (b.innerText || b.value || b.getAttribute('aria-label') || '').trim().slice(0, 100), type: b.type || 'button', disabled: b.disabled || false }));
732
+ const forms = targetElement.querySelectorAll('form');
733
+ structured.forms = Array.from(forms).slice(0, 20).map(f => {
734
+ const fields = Array.from(f.querySelectorAll('input, select, textarea')).slice(0, 30);
735
+ return { action: f.action || '', method: (f.method || 'GET').toUpperCase(), fieldCount: fields.length, fields: fields.map(field => ({ tag: field.tagName.toLowerCase(), type: field.type || '', name: field.name || '', placeholder: field.placeholder || '', required: field.required || false })) };
736
+ });
737
+ const images = targetElement.querySelectorAll('img');
738
+ structured.images = Array.from(images).slice(0, 50).map(img => ({ alt: img.alt || '', src: img.src ? img.src.slice(0, 200) : '' })).filter(img => img.src);
739
+ const tables = targetElement.querySelectorAll('table');
740
+ structured.tables = Array.from(tables).slice(0, 10).map(table => {
741
+ const headers = Array.from(table.querySelectorAll('th')).map(th => th.innerText.trim().slice(0, 50));
742
+ const rows = table.querySelectorAll('tr');
743
+ return { headers: headers.slice(0, 20), rowCount: rows.length };
744
+ });
745
+ result.structured = structured;
746
+ result.counts = { headings: structured.headings.length, links: structured.links.length, buttons: structured.buttons.length, forms: structured.forms.length, images: structured.images.length, tables: structured.tables.length };
747
+ }
748
+ result.mode = mode;
749
+ return result;
750
+ } catch (e) {
751
+ return { error: e.message };
752
+ }
753
+ })()`
754
+
755
+ const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
756
+ expression,
757
+ returnByValue: true,
758
+ })
759
+
760
+ if (result?.value?.error) throw new Error(result.value.error)
761
+ return result?.value
762
+ }
763
+
764
+ // 处理来自服务器的命令
765
+ async function handleCommand(message) {
766
+ const { id, command, params, token } = message
767
+ if (!id || !command) return
768
+ if (!state.enabled) {
769
+ sendToServer({ id, error: "扩展已暂停,点击图标重新开启" })
770
+ return
771
+ }
772
+ if (CONFIG.token && CONFIG.token !== token) {
773
+ sendToServer({ id, error: "token 校验失败" })
774
+ return
775
+ }
776
+ try {
777
+ let result
778
+ if (command === "getLastError") result = await handleGetLastError()
779
+ else if (command === "getScriptSource") result = await handleGetScriptSource(params)
780
+ else if (command === "coverageSnapshot") result = await handleCoverageSnapshot(params)
781
+ else if (command === "findByString") result = await handleFindByString(params)
782
+ else if (command === "symbolicHints") result = await handleSymbolicHints()
783
+ else if (command === "eval") result = await handleEval(params)
784
+ else if (command === "listNetworkRequests") result = await handleListNetworkRequests(params)
785
+ else if (command === "getNetworkDetail") result = await handleGetNetworkDetail(params)
786
+ else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests()
787
+ else if (command === "captureScreenshot") result = await handleCaptureScreenshot(params)
788
+ else if (command === "getPageContent") result = await handleGetPageContent(params)
789
+ else throw new Error(`未知指令 ${command}`)
790
+
791
+ sendToServer({ id, result })
792
+ } catch (e) {
793
+ sendToServer({ id, error: e.message })
794
+ } finally {
795
+ await maybeDetach()
796
+ }
797
+ }
798
+
799
+ // 发送消息到服务器(通过 offscreen)
800
+ function sendToServer(data) {
801
+ chrome.runtime.sendMessage({ type: 'send', data }).catch(() => {})
802
+ }
803
+
804
+ // ========== 消息监听 ==========
805
+
806
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
807
+ // 判断消息来源
808
+ const senderUrl = sender.url || ''
809
+ const isFromOffscreen = senderUrl.includes('offscreen.html')
810
+ const isFromBackground = !sender.url // background 发的消息没有 url
811
+
812
+ // background 自己发出的消息不处理(避免循环)
813
+ if (isFromBackground) {
814
+ return
815
+ }
816
+
817
+ // 主动推送状态给 popup
818
+ function broadcastStatus() {
819
+ let status
820
+ if (!state.enabled) {
821
+ status = 'disconnected'
822
+ } else if (state.connected) {
823
+ status = 'connected'
824
+ } else if (state.scanRound >= 2) {
825
+ status = 'not_found'
826
+ } else {
827
+ status = 'scanning'
828
+ }
829
+ chrome.runtime.sendMessage({
830
+ type: 'statusUpdate',
831
+ state: {
832
+ status,
833
+ enabled: state.enabled,
834
+ port: state.port,
835
+ currentPort: state.currentPort,
836
+ basePort: CONFIG.basePort,
837
+ scanRound: state.scanRound,
838
+ }
839
+ }).catch(() => {}) // popup 可能未打开,忽略错误
840
+ }
841
+
842
+ // 来自 offscreen 的状态更新
843
+ if (message.type === 'status' && isFromOffscreen) {
844
+ if (message.status === 'connected') {
845
+ state.connected = true
846
+ state.port = message.port
847
+ setBadgeState('on')
848
+ log(`✅ 已连接到 ghost-bridge 服务 (端口 ${message.port})`)
849
+ ensureAttached().catch((e) => log(`attach 失败:${e.message}`))
850
+ } else if (message.status === 'disconnected') {
851
+ state.connected = false
852
+ state.port = null
853
+ if (state.enabled) setBadgeState('connecting')
854
+ } else if (message.status === 'scanning') {
855
+ state.currentPort = message.currentPort
856
+ setBadgeState('connecting')
857
+ } else if (message.status === 'not_found') {
858
+ state.scanRound++
859
+ setBadgeState('connecting')
860
+ }
861
+ broadcastStatus() // 状态变化时主动推送
862
+ return
863
+ }
864
+
865
+ // 来自 offscreen 的日志
866
+ if (message.type === 'log' && isFromOffscreen) {
867
+ console.log(`[offscreen] ${message.msg}`)
868
+ return
869
+ }
870
+
871
+ // 来自 offscreen 的命令(从服务器转发)
872
+ if (message.type === 'command' && isFromOffscreen) {
873
+ handleCommand(message.data)
874
+ return
875
+ }
876
+
877
+ // send 消息是 background 发给 offscreen 的,这里不处理
878
+ if (message.type === 'send') {
879
+ return
880
+ }
881
+
882
+ // 来自 popup 的状态查询
883
+ if (message.type === 'getStatus') {
884
+ let status
885
+ if (!state.enabled) {
886
+ status = 'disconnected'
887
+ } else if (state.connected) {
888
+ status = 'connected'
889
+ } else if (state.scanRound >= 2) {
890
+ status = 'not_found'
891
+ } else {
892
+ status = 'scanning'
893
+ }
894
+ sendResponse({
895
+ status,
896
+ enabled: state.enabled,
897
+ port: state.port,
898
+ currentPort: state.currentPort,
899
+ basePort: CONFIG.basePort,
900
+ scanRound: state.scanRound,
901
+ })
902
+ return true
903
+ }
904
+
905
+ // 来自 popup 的连接请求
906
+ if (message.type === 'connect') {
907
+ if (message.port) {
908
+ CONFIG.basePort = message.port
909
+ chrome.storage.local.set({ basePort: message.port })
910
+ }
911
+ state.enabled = true
912
+ state.scanRound = 0
913
+ setBadgeState('connecting')
914
+
915
+ // 启动 offscreen 并开始连接
916
+ setupOffscreenDocument().then(() => {
917
+ chrome.runtime.sendMessage({
918
+ type: 'connect',
919
+ basePort: CONFIG.basePort,
920
+ token: CONFIG.token,
921
+ maxPortRetries: CONFIG.maxPortRetries,
922
+ }).catch(() => {})
923
+ })
924
+
925
+ sendResponse({ ok: true })
926
+ return true
927
+ }
928
+
929
+ // 来自 popup 的断开请求
930
+ if (message.type === 'disconnect') {
931
+ state.enabled = false
932
+ state.connected = false
933
+ state.scanRound = 0
934
+ setBadgeState('off')
935
+ detachAllTargets().catch(() => {})
936
+
937
+ // 通知 offscreen 断开
938
+ chrome.runtime.sendMessage({ type: 'disconnect' }).catch(() => {})
939
+
940
+ sendResponse({ ok: true })
941
+ return true
942
+ }
943
+
944
+ return false
945
+ })
946
+
947
+ // 启动时从 storage 加载端口配置
948
+ chrome.storage.local.get(['basePort'], (result) => {
949
+ if (result.basePort) {
950
+ CONFIG.basePort = result.basePort
951
+ }
952
+ })
953
+
954
+ // 默认暂停
955
+ setBadgeState("off")
956
+ log("Ghost Bridge background 已加载")