@pyrokine/mcp-chrome 2.0.1 → 2.1.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.
Files changed (75) hide show
  1. package/README.md +87 -43
  2. package/dist/core/browser-driver.d.ts +118 -1
  3. package/dist/core/browser-driver.d.ts.map +1 -1
  4. package/dist/core/browser-driver.js +11 -0
  5. package/dist/core/browser-driver.js.map +1 -1
  6. package/dist/core/error-sanitizer.js +2 -2
  7. package/dist/core/error-sanitizer.js.map +1 -1
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +17 -4
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +1 -0
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +1 -0
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/locator.d.ts.map +1 -1
  16. package/dist/core/locator.js +4 -3
  17. package/dist/core/locator.js.map +1 -1
  18. package/dist/core/session.d.ts +5 -2
  19. package/dist/core/session.d.ts.map +1 -1
  20. package/dist/core/session.js +107 -4
  21. package/dist/core/session.js.map +1 -1
  22. package/dist/core/types.d.ts +17 -1
  23. package/dist/core/types.d.ts.map +1 -1
  24. package/dist/core/types.js +57 -4
  25. package/dist/core/types.js.map +1 -1
  26. package/dist/core/unified-session.d.ts +29 -9
  27. package/dist/core/unified-session.d.ts.map +1 -1
  28. package/dist/core/unified-session.js +226 -18
  29. package/dist/core/unified-session.js.map +1 -1
  30. package/dist/core/utils.d.ts +13 -0
  31. package/dist/core/utils.d.ts.map +1 -1
  32. package/dist/core/utils.js +121 -0
  33. package/dist/core/utils.js.map +1 -1
  34. package/dist/extension/bridge.d.ts +37 -22
  35. package/dist/extension/bridge.d.ts.map +1 -1
  36. package/dist/extension/bridge.js +256 -96
  37. package/dist/extension/bridge.js.map +1 -1
  38. package/dist/extension/http-server.d.ts.map +1 -1
  39. package/dist/extension/http-server.js +39 -10
  40. package/dist/extension/http-server.js.map +1 -1
  41. package/dist/tools/browse.d.ts.map +1 -1
  42. package/dist/tools/browse.js +160 -55
  43. package/dist/tools/browse.js.map +1 -1
  44. package/dist/tools/cookies.d.ts.map +1 -1
  45. package/dist/tools/cookies.js +8 -11
  46. package/dist/tools/cookies.js.map +1 -1
  47. package/dist/tools/evaluate.d.ts.map +1 -1
  48. package/dist/tools/evaluate.js +53 -30
  49. package/dist/tools/evaluate.js.map +1 -1
  50. package/dist/tools/extract.d.ts +1 -0
  51. package/dist/tools/extract.d.ts.map +1 -1
  52. package/dist/tools/extract.js +476 -244
  53. package/dist/tools/extract.js.map +1 -1
  54. package/dist/tools/input.d.ts.map +1 -1
  55. package/dist/tools/input.js +798 -441
  56. package/dist/tools/input.js.map +1 -1
  57. package/dist/tools/logs.d.ts.map +1 -1
  58. package/dist/tools/logs.js +79 -31
  59. package/dist/tools/logs.js.map +1 -1
  60. package/dist/tools/manage.d.ts +0 -3
  61. package/dist/tools/manage.d.ts.map +1 -1
  62. package/dist/tools/manage.js +339 -285
  63. package/dist/tools/manage.js.map +1 -1
  64. package/dist/tools/png.d.ts +16 -0
  65. package/dist/tools/png.d.ts.map +1 -0
  66. package/dist/tools/png.js +197 -0
  67. package/dist/tools/png.js.map +1 -0
  68. package/dist/tools/schema.d.ts +14 -112
  69. package/dist/tools/schema.d.ts.map +1 -1
  70. package/dist/tools/schema.js +21 -12
  71. package/dist/tools/schema.js.map +1 -1
  72. package/dist/tools/wait.d.ts.map +1 -1
  73. package/dist/tools/wait.js +73 -13
  74. package/dist/tools/wait.js.map +1 -1
  75. package/package.json +11 -11
@@ -4,15 +4,17 @@
4
4
  * 提取页面内容:
5
5
  * - text: 文本内容
6
6
  * - html: HTML 源码(可选附带图片元信息或图片数据)
7
+ * - frameHtml: iframe HTML 源码(Extension 模式,支持跨域 iframe)
7
8
  * - attribute: 元素属性
8
9
  * - screenshot: 截图
9
10
  * - state: 页面状态(精简的可交互元素列表)
10
11
  * - metadata: 页面元信息(title/og/jsonLd 等)
11
12
  */
12
- import { mkdir, writeFile } from 'fs/promises';
13
- import { basename, dirname, extname, join, resolve, sep } from 'path';
13
+ import { readFile } from 'fs/promises';
14
+ import { basename, extname, join } from 'path';
14
15
  import { z } from 'zod';
15
- import { formatErrorResponse, formatResponse, getSession, getUnifiedSession } from '../core/index.js';
16
+ import { CWD_PATH_PREFIX, formatErrorResponse, formatResponse, getSession, getUnifiedSession, resolveScopedOutputPath, TMP_PATH_PREFIX, writePrivateFile, } from '../core/index.js';
17
+ import { comparePngImages, MAX_COMPARE_PIXELS, MAX_COMPARE_PNG_BYTES, readPngHeader } from './png.js';
16
18
  import { targetToFindParams, targetZodSchema } from './schema.js';
17
19
  /** 无 output 时附录返回的最大图片数 */
18
20
  const MAX_APPENDIX_IMAGES = 20;
@@ -20,7 +22,9 @@ const MAX_APPENDIX_IMAGES = 20;
20
22
  * extract 参数 schema
21
23
  */
22
24
  const extractSchema = z.object({
23
- type: z.enum(['text', 'html', 'attribute', 'screenshot', 'state', 'metadata']).describe('提取类型'),
25
+ type: z
26
+ .enum(['text', 'html', 'frameHtml', 'attribute', 'screenshot', 'state', 'metadata', 'diagnosticBundle'])
27
+ .describe('提取类型'),
24
28
  target: targetZodSchema
25
29
  .optional()
26
30
  .describe('目标元素(attribute 必填;text/html 可选,省略则提取整个页面;screenshot 可选用于元素截图;state 可选(仅 Extension)用于返回目标子树;metadata 不需要)'),
@@ -44,10 +48,21 @@ const extractSchema = z.object({
44
48
  .max(100)
45
49
  .optional()
46
50
  .describe('截图质量(screenshot,仅 jpeg/webp 有效),0-100,推荐 80'),
51
+ clip: z
52
+ .object({
53
+ x: z.number(),
54
+ y: z.number(),
55
+ width: z.number().positive(),
56
+ height: z.number().positive(),
57
+ })
58
+ .optional()
59
+ .describe('坐标区域截图(screenshot),单位为 CSS 像素'),
60
+ compareWith: z.string().optional().describe('截图对比基准 PNG 路径,遵循 output 相同的 tmp:/cwd: 路径规则'),
61
+ diffOutput: z.string().optional().describe('截图对比差异图输出路径,遵循 output 相同的 tmp:/cwd: 路径规则'),
47
62
  output: z
48
63
  .string()
49
64
  .optional()
50
- .describe('输出文件路径(可选),若指定结果写入文件,否则返回内容,images=data 时作为输出目录路径'),
65
+ .describe(`输出文件路径(可选),相对路径默认写入 ${TMP_PATH_PREFIX},持久化到仓库请显式写 ${CWD_PATH_PREFIX},images=data 时作为输出目录路径`),
51
66
  tabId: z
52
67
  .string()
53
68
  .optional()
@@ -63,14 +78,15 @@ const extractSchema = z.object({
63
78
  .optional()
64
79
  .describe('iframe 定位(可选,仅 Extension 模式),CSS 选择器(如 "iframe#main")或索引(如 0),不指定则在主框架操作'),
65
80
  });
66
- /**
67
- * extract 工具处理器
68
- */
69
81
  async function handleExtract(args) {
70
82
  try {
71
83
  const unifiedSession = getUnifiedSession();
72
84
  const useExtension = unifiedSession.isExtensionConnected();
73
85
  const session = getSession();
86
+ const frameHtmlError = validateFrameHtmlArgs(args, useExtension);
87
+ if (frameHtmlError) {
88
+ return frameHtmlError;
89
+ }
74
90
  // 多 tab 并行:临时切换到指定 tab
75
91
  return await unifiedSession.withTabId(args.tabId, async () => {
76
92
  return await unifiedSession.withFrame(args.frame, async () => {
@@ -78,219 +94,7 @@ async function handleExtract(args) {
78
94
  if (useExtension && args.target && args.timeout !== undefined) {
79
95
  await waitForTargetExtension(unifiedSession, args.target, args.timeout);
80
96
  }
81
- switch (args.type) {
82
- case 'text': {
83
- const text = useExtension
84
- ? await extractTextExtension(unifiedSession, args.target)
85
- : await extractText(session, args.target, args.timeout);
86
- if (args.output) {
87
- await writeOutputFile(args.output, text, 'utf-8');
88
- return formatResponse({
89
- success: true,
90
- type: 'text',
91
- output: args.output,
92
- size: text.length,
93
- });
94
- }
95
- return formatResponse({
96
- success: true,
97
- type: 'text',
98
- content: text,
99
- });
100
- }
101
- case 'html': {
102
- // 带图片提取的增强路径
103
- if (args.images) {
104
- return await handleHtmlWithImages(unifiedSession, session, useExtension, args);
105
- }
106
- // 原有路径:纯 HTML
107
- const html = useExtension
108
- ? await extractHtmlExtension(unifiedSession, args.target)
109
- : await extractHTML(session, args.target, args.timeout);
110
- if (args.output) {
111
- await writeOutputFile(args.output, html, 'utf-8');
112
- return formatResponse({
113
- success: true,
114
- type: 'html',
115
- output: args.output,
116
- size: html.length,
117
- });
118
- }
119
- return formatResponse({
120
- success: true,
121
- type: 'html',
122
- content: html,
123
- });
124
- }
125
- case 'attribute': {
126
- if (!args.target) {
127
- return formatErrorResponse(new Error('attribute 提取需要 target 参数'));
128
- }
129
- if (!args.attribute) {
130
- return formatErrorResponse(new Error('attribute 提取需要 attribute 参数'));
131
- }
132
- let value;
133
- if (useExtension) {
134
- value = await extractAttributeExtension(unifiedSession, args.target, args.attribute);
135
- }
136
- else {
137
- value = await extractAttribute(session, args.target, args.attribute, args.timeout);
138
- }
139
- return formatResponse({
140
- success: true,
141
- type: 'attribute',
142
- attribute: args.attribute,
143
- value,
144
- });
145
- }
146
- case 'screenshot': {
147
- // 有 target 时获取元素区域用于裁剪(支持所有 target 类型)
148
- let clip;
149
- if (args.target) {
150
- const { selector, text, xpath, nth: nthParam, } = targetToFindParams(args.target);
151
- const nth = nthParam ?? 0;
152
- // unified.find 内部根据 Extension 连接状态自动路由到 Extension/CDP 路径,
153
- // 返回视口绝对坐标(含 iframe 坐标修正)
154
- const found = await unifiedSession.find(selector, text, xpath);
155
- if (found.length > nth) {
156
- const rect = found[nth].rect;
157
- if (rect.width > 0 && rect.height > 0) {
158
- clip = rect;
159
- }
160
- }
161
- }
162
- const base64 = await unifiedSession.screenshot({
163
- fullPage: clip ? false : (args.fullPage ?? false),
164
- scale: args.scale,
165
- format: args.format,
166
- quality: args.quality,
167
- clip,
168
- });
169
- if (args.output) {
170
- // 写入文件
171
- await writeOutputFile(args.output, Buffer.from(base64, 'base64'));
172
- return formatResponse({
173
- success: true,
174
- type: 'screenshot',
175
- output: args.output,
176
- });
177
- }
178
- // 返回 base64 图片
179
- return {
180
- content: [
181
- {
182
- type: 'image',
183
- data: base64,
184
- mimeType: `image/${args.format === 'jpeg' ? 'jpeg' : (args.format ?? 'png')}`,
185
- },
186
- ],
187
- };
188
- }
189
- case 'state': {
190
- // mode=domsnapshot:用 CDP DOMSnapshot.captureSnapshot 取全量快照(仅 CDP 模式)
191
- if (args.mode === 'domsnapshot') {
192
- if (useExtension) {
193
- return {
194
- content: [
195
- {
196
- type: 'text',
197
- text: JSON.stringify({
198
- error: {
199
- code: 'INVALID_ARGUMENT',
200
- message: 'mode=domsnapshot 仅 CDP 模式支持,Extension 模式请用默认 accessibility',
201
- },
202
- }),
203
- },
204
- ],
205
- isError: true,
206
- };
207
- }
208
- const snapshot = await unifiedSession.sendCdpCommand('DOMSnapshot.captureSnapshot', {
209
- computedStyles: ['display', 'visibility', 'opacity'],
210
- includePaintOrder: false,
211
- includeDOMRects: true,
212
- });
213
- if (args.output) {
214
- await writeOutputFile(args.output, JSON.stringify(snapshot, null, 2), 'utf-8');
215
- return formatResponse({
216
- success: true,
217
- type: 'state',
218
- mode: 'domsnapshot',
219
- output: args.output,
220
- });
221
- }
222
- return formatResponse({
223
- success: true,
224
- type: 'state',
225
- mode: 'domsnapshot',
226
- snapshot,
227
- });
228
- }
229
- // 默认:accessibility 树(原行为)
230
- // 有 target 时获取子树的无障碍状态
231
- let refId;
232
- if (args.target && useExtension) {
233
- const { selector, text, xpath, nth: nthParam, } = targetToFindParams(args.target);
234
- const nth = nthParam ?? 0;
235
- const elements = await unifiedSession.find(selector, text, xpath);
236
- if (elements.length > 0 && nth < elements.length) {
237
- refId = elements[nth].refId;
238
- }
239
- }
240
- const readPageOptions = {};
241
- if (refId) {
242
- readPageOptions.refId = refId;
243
- }
244
- if (args.depth !== undefined) {
245
- readPageOptions.depth = args.depth;
246
- }
247
- const state = await unifiedSession.readPage(Object.keys(readPageOptions).length > 0 ? readPageOptions : undefined);
248
- if (args.output) {
249
- await writeOutputFile(args.output, JSON.stringify(state, null, 2), 'utf-8');
250
- return formatResponse({
251
- success: true,
252
- type: 'state',
253
- output: args.output,
254
- });
255
- }
256
- return formatResponse({
257
- success: true,
258
- type: 'state',
259
- state,
260
- });
261
- }
262
- case 'metadata': {
263
- const metadata = await unifiedSession.getMetadata();
264
- if (args.output) {
265
- await writeOutputFile(args.output, JSON.stringify(metadata, null, 2), 'utf-8');
266
- return formatResponse({
267
- success: true,
268
- type: 'metadata',
269
- output: args.output,
270
- });
271
- }
272
- return formatResponse({
273
- success: true,
274
- type: 'metadata',
275
- ...metadata,
276
- });
277
- }
278
- default:
279
- return {
280
- content: [
281
- {
282
- type: 'text',
283
- text: JSON.stringify({
284
- error: {
285
- code: 'INVALID_ARGUMENT',
286
- message: `未知提取类型: ${args.type}`,
287
- },
288
- }),
289
- },
290
- ],
291
- isError: true,
292
- };
293
- }
97
+ return handleExtractInFrame({ unifiedSession, session, useExtension }, args);
294
98
  }); // withFrame
295
99
  }); // withTabId
296
100
  }
@@ -298,16 +102,445 @@ async function handleExtract(args) {
298
102
  return formatErrorResponse(error);
299
103
  }
300
104
  }
105
+ function validateFrameHtmlArgs(args, useExtension) {
106
+ if (args.type !== 'frameHtml') {
107
+ return undefined;
108
+ }
109
+ if (!useExtension) {
110
+ return {
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: JSON.stringify({
115
+ error: {
116
+ code: 'UNSUPPORTED_MODE',
117
+ message: 'frameHtml 需要 Extension 模式',
118
+ suggestion: '请连接 MCP Chrome Extension,并通过 frame 参数指定 iframe selector 或 index',
119
+ },
120
+ }),
121
+ },
122
+ ],
123
+ isError: true,
124
+ };
125
+ }
126
+ if (args.frame === undefined) {
127
+ return formatErrorResponse(new Error('frameHtml 提取需要 frame 参数,例如 frame="iframe#main" 或 frame=0'));
128
+ }
129
+ return undefined;
130
+ }
131
+ async function handleExtractInFrame(context, args) {
132
+ switch (args.type) {
133
+ case 'text':
134
+ return handleTextExtract(context, args);
135
+ case 'html':
136
+ return handleHtmlExtract(context, args);
137
+ case 'frameHtml':
138
+ return handleFrameHtmlExtract(context, args);
139
+ case 'attribute':
140
+ return handleAttributeExtract(context, args);
141
+ case 'screenshot':
142
+ return handleScreenshotExtract(context, args);
143
+ case 'state':
144
+ return handleStateExtract(context, args);
145
+ case 'diagnosticBundle':
146
+ return handleDiagnosticBundleExtract(context, args);
147
+ case 'metadata':
148
+ return handleMetadataExtract(context, args);
149
+ default:
150
+ return invalidExtractTypeResponse(args);
151
+ }
152
+ }
153
+ async function handleTextExtract({ unifiedSession, session, useExtension }, args) {
154
+ const text = useExtension
155
+ ? await extractTextExtension(unifiedSession, args.target)
156
+ : await extractText(session, args.target, args.timeout);
157
+ if (args.output) {
158
+ const outputPath = await writeOutputFile(args.output, text, 'utf-8');
159
+ return formatResponse({ success: true, type: 'text', output: outputPath, size: text.length });
160
+ }
161
+ return formatResponse({ success: true, type: 'text', content: text });
162
+ }
163
+ async function handleHtmlExtract(context, args) {
164
+ const { unifiedSession, session, useExtension } = context;
165
+ if (args.images) {
166
+ return handleHtmlWithImages(unifiedSession, session, useExtension, args);
167
+ }
168
+ const html = useExtension
169
+ ? await extractHtmlExtension(unifiedSession, args.target)
170
+ : await extractHTML(session, args.target, args.timeout);
171
+ if (args.output) {
172
+ const outputPath = await writeOutputFile(args.output, html, 'utf-8');
173
+ return formatResponse({ success: true, type: 'html', output: outputPath, size: html.length });
174
+ }
175
+ return formatResponse({ success: true, type: 'html', content: html });
176
+ }
177
+ async function handleFrameHtmlExtract({ unifiedSession }, args) {
178
+ const html = await extractHtmlExtension(unifiedSession, args.target);
179
+ if (args.output) {
180
+ const outputPath = await writeOutputFile(args.output, html, 'utf-8');
181
+ return formatResponse({
182
+ success: true,
183
+ type: 'frameHtml',
184
+ frame: args.frame,
185
+ output: outputPath,
186
+ size: html.length,
187
+ });
188
+ }
189
+ return formatResponse({ success: true, type: 'frameHtml', frame: args.frame, content: html });
190
+ }
191
+ async function handleAttributeExtract({ unifiedSession, session, useExtension }, args) {
192
+ if (!args.target) {
193
+ return formatErrorResponse(new Error('attribute 提取需要 target 参数'));
194
+ }
195
+ if (!args.attribute) {
196
+ return formatErrorResponse(new Error('attribute 提取需要 attribute 参数'));
197
+ }
198
+ const value = useExtension
199
+ ? await extractAttributeExtension(unifiedSession, args.target, args.attribute)
200
+ : await extractAttribute(session, args.target, args.attribute, args.timeout);
201
+ return formatResponse({ success: true, type: 'attribute', attribute: args.attribute, value });
202
+ }
203
+ async function handleScreenshotExtract({ unifiedSession, useExtension }, args) {
204
+ let clip;
205
+ if (args.target) {
206
+ const { selector, text, xpath, nth: nthParam } = targetToFindParams(args.target);
207
+ const nth = nthParam ?? 0;
208
+ const found = await unifiedSession.find(selector, text, xpath);
209
+ if (found.length > nth) {
210
+ const rect = found[nth].rect;
211
+ if (rect.width > 0 && rect.height > 0) {
212
+ const scrollOffset = args.frame === undefined ? await getPageScrollOffset(unifiedSession, args.timeout) : { x: 0, y: 0 };
213
+ clip = {
214
+ x: rect.x + scrollOffset.x,
215
+ y: rect.y + scrollOffset.y,
216
+ width: rect.width,
217
+ height: rect.height,
218
+ };
219
+ }
220
+ }
221
+ }
222
+ clip = args.clip ?? clip;
223
+ const fullPage = clip ? false : (args.fullPage ?? false);
224
+ const scale = args.scale ?? 1;
225
+ const screenshot = await unifiedSession.screenshot({
226
+ fullPage,
227
+ scale: args.scale,
228
+ format: args.format,
229
+ quality: args.quality,
230
+ clip,
231
+ });
232
+ const screenshotBuffer = Buffer.from(screenshot.data, 'base64');
233
+ const fallbackDimensions = await getScreenshotFallbackDimensions(unifiedSession, clip, fullPage, scale, args.timeout);
234
+ const dimensions = readImageDimensions(screenshotBuffer, screenshot.format, fallbackDimensions);
235
+ const metadata = {
236
+ format: screenshot.format,
237
+ width: dimensions?.width,
238
+ height: dimensions?.height,
239
+ dimensionSource: dimensions?.source,
240
+ scale,
241
+ fullPage,
242
+ clip,
243
+ size: screenshotBuffer.length,
244
+ byteSize: screenshotBuffer.length,
245
+ capabilities: screenshotCapabilities(useExtension),
246
+ };
247
+ if (args.compareWith) {
248
+ if (screenshot.format !== 'png') {
249
+ throw new Error('screenshot compare 仅支持 png 格式');
250
+ }
251
+ const baselinePath = await resolveScopedOutputPath(args.compareWith, 'mcp-chrome');
252
+ const baseline = await readFile(baselinePath.absolutePath);
253
+ const comparison = comparePngImages(baseline, screenshotBuffer);
254
+ metadata.comparison = {
255
+ pixelDiffRatio: comparison.pixelDiffRatio,
256
+ differentPixels: comparison.differentPixels,
257
+ totalPixels: comparison.totalPixels,
258
+ width: comparison.width,
259
+ height: comparison.height,
260
+ };
261
+ if (args.diffOutput) {
262
+ const diffPath = await writeOutputFile(args.diffOutput, comparison.diffPng);
263
+ metadata.comparison = { ...metadata.comparison, diffOutput: diffPath };
264
+ }
265
+ }
266
+ if (args.output) {
267
+ const outputPath = await writeOutputFile(args.output, screenshotBuffer);
268
+ return formatResponse({ success: true, type: 'screenshot', output: outputPath, metadata });
269
+ }
270
+ return {
271
+ content: [
272
+ { type: 'text', text: JSON.stringify({ success: true, type: 'screenshot', metadata }, null, 2) },
273
+ {
274
+ type: 'image',
275
+ data: screenshot.data,
276
+ mimeType: `image/${screenshot.format === 'jpeg' ? 'jpeg' : screenshot.format}`,
277
+ },
278
+ ],
279
+ };
280
+ }
281
+ async function handleStateExtract({ unifiedSession, useExtension }, args) {
282
+ if (args.mode === 'domsnapshot') {
283
+ return handleDomSnapshotState(unifiedSession, useExtension, args);
284
+ }
285
+ let refId;
286
+ if (args.target && useExtension) {
287
+ const { selector, text, xpath, nth: nthParam } = targetToFindParams(args.target);
288
+ const nth = nthParam ?? 0;
289
+ const elements = await unifiedSession.find(selector, text, xpath);
290
+ if (elements.length > 0 && nth < elements.length) {
291
+ refId = elements[nth].refId;
292
+ }
293
+ }
294
+ const readPageOptions = {};
295
+ if (refId) {
296
+ readPageOptions.refId = refId;
297
+ }
298
+ if (args.depth !== undefined) {
299
+ readPageOptions.depth = args.depth;
300
+ }
301
+ const state = await unifiedSession.readPage(Object.keys(readPageOptions).length > 0 ? readPageOptions : undefined);
302
+ if (args.output) {
303
+ const outputPath = await writeOutputFile(args.output, JSON.stringify(state, null, 2), 'utf-8');
304
+ return formatResponse({ success: true, type: 'state', output: outputPath });
305
+ }
306
+ return formatResponse({ success: true, type: 'state', state });
307
+ }
308
+ async function handleDomSnapshotState(unifiedSession, useExtension, args) {
309
+ if (useExtension) {
310
+ return {
311
+ content: [
312
+ {
313
+ type: 'text',
314
+ text: JSON.stringify({
315
+ error: {
316
+ code: 'INVALID_ARGUMENT',
317
+ message: 'mode=domsnapshot 仅 CDP 模式支持,Extension 模式请用默认 accessibility',
318
+ },
319
+ }),
320
+ },
321
+ ],
322
+ isError: true,
323
+ };
324
+ }
325
+ const snapshot = await unifiedSession.sendCdpCommand('DOMSnapshot.captureSnapshot', {
326
+ computedStyles: ['display', 'visibility', 'opacity'],
327
+ includePaintOrder: false,
328
+ includeDOMRects: true,
329
+ });
330
+ if (args.output) {
331
+ const outputPath = await writeOutputFile(args.output, JSON.stringify(snapshot, null, 2), 'utf-8');
332
+ return formatResponse({ success: true, type: 'state', mode: 'domsnapshot', output: outputPath });
333
+ }
334
+ return formatResponse({ success: true, type: 'state', mode: 'domsnapshot', snapshot });
335
+ }
336
+ async function handleDiagnosticBundleExtract({ unifiedSession, useExtension }, args) {
337
+ const state = unifiedSession.getState();
338
+ const metadata = await unifiedSession.getMetadata();
339
+ const frames = useExtension ? await unifiedSession.getFrames() : { frames: [] };
340
+ await unifiedSession.enableConsole();
341
+ await unifiedSession.enableNetwork();
342
+ const consoleLogs = await unifiedSession.getConsoleLogs();
343
+ const networkRequests = await unifiedSession.getNetworkRequests();
344
+ const bundle = {
345
+ schema: 'mcp-chrome.diagnosticBundle.v1',
346
+ url: state?.url,
347
+ title: state?.title,
348
+ managed: state?.managed ?? false,
349
+ mode: unifiedSession.getMode(),
350
+ metadata,
351
+ frames,
352
+ consoleSummary: {
353
+ total: consoleLogs.length,
354
+ recentErrors: consoleLogs.filter((item) => ['error', 'warning', 'warn'].includes(item.level)).slice(-20),
355
+ },
356
+ networkSummary: {
357
+ total: networkRequests.length,
358
+ recentFailures: networkRequests
359
+ .filter((item) => item.errorText || (item.status !== undefined && item.status >= 400))
360
+ .slice(-20),
361
+ },
362
+ capabilities: {
363
+ screenshot: true,
364
+ hiddenTabScreenshot: useExtension,
365
+ frames: useExtension,
366
+ },
367
+ };
368
+ const summary = {
369
+ url: bundle.url,
370
+ title: bundle.title,
371
+ managed: bundle.managed,
372
+ mode: bundle.mode,
373
+ frameCount: Array.isArray(frames.frames) ? frames.frames.length : 0,
374
+ consoleErrors: bundle.consoleSummary.recentErrors.length,
375
+ networkFailures: bundle.networkSummary.recentFailures.length,
376
+ };
377
+ if (args.output) {
378
+ const outputPath = await writeOutputFile(args.output, JSON.stringify(bundle, null, 2), 'utf-8');
379
+ return formatResponse({ success: true, type: 'diagnosticBundle', output: outputPath, summary });
380
+ }
381
+ return formatResponse({
382
+ success: true,
383
+ type: 'diagnosticBundle',
384
+ summary: { ...summary, capabilities: bundle.capabilities },
385
+ frames: bundle.frames,
386
+ console: bundle.consoleSummary.recentErrors,
387
+ failedRequests: bundle.networkSummary.recentFailures,
388
+ });
389
+ }
390
+ async function handleMetadataExtract({ unifiedSession, useExtension }, args) {
391
+ const metadata = await unifiedSession.getMetadata();
392
+ const frames = useExtension ? await unifiedSession.getFrames() : { frames: [] };
393
+ if (args.output) {
394
+ const outputPath = await writeOutputFile(args.output, JSON.stringify({ ...metadata, ...frames }, null, 2), 'utf-8');
395
+ return formatResponse({ success: true, type: 'metadata', output: outputPath });
396
+ }
397
+ return formatResponse({ success: true, type: 'metadata', ...metadata, ...frames });
398
+ }
399
+ function invalidExtractTypeResponse(args) {
400
+ return {
401
+ content: [
402
+ {
403
+ type: 'text',
404
+ text: JSON.stringify({
405
+ error: { code: 'INVALID_ARGUMENT', message: `未知提取类型: ${args.type}` },
406
+ }),
407
+ },
408
+ ],
409
+ isError: true,
410
+ };
411
+ }
301
412
  // ==================== HTML + 图片提取 ====================
302
- /** 写入文件前自动创建父目录(验证路径在 cwd 范围内)*/
413
+ /** 写入文件前自动创建父目录,并收敛到受控范围 */
303
414
  async function writeOutputFile(path, data, encoding) {
304
- const cwd = process.cwd();
305
- const safePath = resolve(cwd, path);
306
- if (!safePath.startsWith(cwd + sep) && safePath !== cwd) {
307
- throw new Error(`output 路径超出工作目录范围: ${path}`);
415
+ const resolvedPath = await resolveScopedOutputPath(path, 'mcp-chrome');
416
+ await writePrivateFile(resolvedPath.absolutePath, data, encoding);
417
+ return resolvedPath.absolutePath;
418
+ }
419
+ async function getPageScrollOffset(unifiedSession, timeout) {
420
+ return unifiedSession.evaluate('(() => ({ x: window.scrollX || 0, y: window.scrollY || 0 }))()', undefined, timeout);
421
+ }
422
+ async function getScreenshotFallbackDimensions(unifiedSession, clip, fullPage, scale, timeout) {
423
+ if (clip) {
424
+ return {
425
+ width: Math.round(clip.width * scale),
426
+ height: Math.round(clip.height * scale),
427
+ source: 'captureArea',
428
+ };
308
429
  }
309
- await mkdir(dirname(safePath), { recursive: true });
310
- await writeFile(safePath, data, encoding);
430
+ const size = await unifiedSession.evaluate(fullPage
431
+ ? '(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }))()'
432
+ : '(() => ({ width: window.innerWidth, height: window.innerHeight }))()', undefined, timeout);
433
+ return {
434
+ width: Math.round(size.width * scale),
435
+ height: Math.round(size.height * scale),
436
+ source: 'captureArea',
437
+ };
438
+ }
439
+ function screenshotCapabilities(useExtension) {
440
+ return {
441
+ formats: ['png', 'jpeg', 'webp'],
442
+ clip: true,
443
+ fullPage: true,
444
+ scale: true,
445
+ hiddenTabScreenshot: useExtension,
446
+ compare: {
447
+ formats: ['png'],
448
+ maxBytes: MAX_COMPARE_PNG_BYTES,
449
+ maxPixels: MAX_COMPARE_PIXELS,
450
+ },
451
+ };
452
+ }
453
+ function readImageDimensions(buffer, format, fallback) {
454
+ try {
455
+ if (format === 'png') {
456
+ return { ...readPngHeader(buffer), source: 'encoded' };
457
+ }
458
+ if (format === 'jpeg') {
459
+ const size = readJpegDimensions(buffer);
460
+ return size ? { ...size, source: 'encoded' } : fallback;
461
+ }
462
+ if (format === 'webp') {
463
+ const size = readWebpDimensions(buffer);
464
+ return size ? { ...size, source: 'encoded' } : fallback;
465
+ }
466
+ }
467
+ catch {
468
+ return fallback;
469
+ }
470
+ return fallback;
471
+ }
472
+ function readJpegDimensions(buffer) {
473
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
474
+ return undefined;
475
+ }
476
+ let offset = 2;
477
+ while (offset + 9 < buffer.length) {
478
+ if (buffer[offset] !== 0xff) {
479
+ offset++;
480
+ continue;
481
+ }
482
+ while (buffer[offset] === 0xff) {
483
+ offset++;
484
+ }
485
+ const marker = buffer[offset++];
486
+ if (marker === 0xd9 || marker === 0xda) {
487
+ return undefined;
488
+ }
489
+ const segmentLength = buffer.readUInt16BE(offset);
490
+ if (segmentLength < 2 || offset + segmentLength > buffer.length) {
491
+ return undefined;
492
+ }
493
+ if ((marker >= 0xc0 && marker <= 0xc3) ||
494
+ (marker >= 0xc5 && marker <= 0xc7) ||
495
+ (marker >= 0xc9 && marker <= 0xcb) ||
496
+ (marker >= 0xcd && marker <= 0xcf)) {
497
+ return {
498
+ height: buffer.readUInt16BE(offset + 3),
499
+ width: buffer.readUInt16BE(offset + 5),
500
+ };
501
+ }
502
+ offset += segmentLength;
503
+ }
504
+ return undefined;
505
+ }
506
+ function readWebpDimensions(buffer) {
507
+ if (buffer.length < 30 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP') {
508
+ return undefined;
509
+ }
510
+ let offset = 12;
511
+ while (offset + 8 <= buffer.length) {
512
+ const chunkType = buffer.toString('ascii', offset, offset + 4);
513
+ const chunkSize = buffer.readUInt32LE(offset + 4);
514
+ const data = offset + 8;
515
+ if (data + chunkSize > buffer.length) {
516
+ return undefined;
517
+ }
518
+ if (chunkType === 'VP8X' && chunkSize >= 10) {
519
+ return {
520
+ width: 1 + buffer.readUIntLE(data + 4, 3),
521
+ height: 1 + buffer.readUIntLE(data + 7, 3),
522
+ };
523
+ }
524
+ if (chunkType === 'VP8L' && chunkSize >= 5 && buffer[data] === 0x2f) {
525
+ const bits = buffer.readUInt32LE(data + 1);
526
+ return {
527
+ width: (bits & 0x3fff) + 1,
528
+ height: ((bits >> 14) & 0x3fff) + 1,
529
+ };
530
+ }
531
+ if (chunkType === 'VP8 ' &&
532
+ chunkSize >= 10 &&
533
+ buffer[data + 3] === 0x9d &&
534
+ buffer[data + 4] === 0x01 &&
535
+ buffer[data + 5] === 0x2a) {
536
+ return {
537
+ width: buffer.readUInt16LE(data + 6) & 0x3fff,
538
+ height: buffer.readUInt16LE(data + 8) & 0x3fff,
539
+ };
540
+ }
541
+ offset += 8 + chunkSize + (chunkSize % 2);
542
+ }
543
+ return undefined;
311
544
  }
312
545
  /**
313
546
  * 处理 html + images 提取
@@ -349,11 +582,11 @@ async function handleHtmlWithImages(unifiedSession, session, useExtension, args)
349
582
  // info 模式:HTML + 图片元信息
350
583
  const payload = { type: 'html', content: result.html, images: result.images };
351
584
  if (args.output) {
352
- await writeOutputFile(args.output, JSON.stringify(payload, null, 2), 'utf-8');
585
+ const outputPath = await writeOutputFile(args.output, JSON.stringify(payload, null, 2), 'utf-8');
353
586
  return formatResponse({
354
587
  success: true,
355
588
  type: 'html',
356
- output: args.output,
589
+ output: outputPath,
357
590
  imageCount: result.images.length,
358
591
  });
359
592
  }
@@ -366,19 +599,15 @@ async function handleHtmlWithImages(unifiedSession, session, useExtension, args)
366
599
  const appendixMode = !args.output;
367
600
  const imageDataList = await fetchImageData(unifiedSession, result.images, appendixMode ? MAX_APPENDIX_IMAGES : undefined);
368
601
  if (args.output) {
369
- const cwd2 = process.cwd();
370
- const safeOutputDir = resolve(cwd2, args.output);
371
- if (!safeOutputDir.startsWith(cwd2 + sep) && safeOutputDir !== cwd2) {
372
- return formatErrorResponse(new Error(`output 路径超出工作目录范围: ${args.output}`));
373
- }
602
+ const outputDir = (await resolveScopedOutputPath(args.output, 'mcp-chrome')).absolutePath;
374
603
  // 写入目录
375
- await writeImageDirectory(safeOutputDir, result.html, result.images, imageDataList);
604
+ await writeImageDirectory(outputDir, result.html, result.images, imageDataList);
376
605
  return formatResponse({
377
606
  success: true,
378
607
  type: 'html',
379
- output: safeOutputDir,
608
+ output: outputDir,
380
609
  imageCount: result.images.length,
381
- index: join(safeOutputDir, 'index.json'),
610
+ index: join(outputDir, 'index.json'),
382
611
  });
383
612
  }
384
613
  // 无 output:MCP 附录方式返回
@@ -503,9 +732,8 @@ async function fetchImageData(unifiedSession, images, limit) {
503
732
  */
504
733
  async function writeImageDirectory(outputDir, html, images, imageDataList) {
505
734
  const imagesDir = join(outputDir, 'images');
506
- await mkdir(imagesDir, { recursive: true });
507
735
  // 写入 HTML
508
- await writeFile(join(outputDir, 'content.html'), html, 'utf-8');
736
+ await writePrivateFile(join(outputDir, 'content.html'), html, 'utf-8');
509
737
  // 写入图片文件 + 构建索引(相同 src 去重)
510
738
  const indexEntries = [];
511
739
  const writtenFiles = new Map(); // src → file path
@@ -525,7 +753,7 @@ async function writeImageDirectory(outputDir, html, images, imageDataList) {
525
753
  const safeName = sanitizeFilename(src);
526
754
  const filename = `${i}-${safeName}${ext}`;
527
755
  file = `images/${filename}`;
528
- await writeFile(join(imagesDir, filename), Buffer.from(data.base64, 'base64'));
756
+ await writePrivateFile(join(imagesDir, filename), Buffer.from(data.base64, 'base64'));
529
757
  writtenFiles.set(src, file);
530
758
  }
531
759
  }
@@ -539,7 +767,7 @@ async function writeImageDirectory(outputDir, html, images, imageDataList) {
539
767
  });
540
768
  }
541
769
  // 写入索引
542
- await writeFile(join(outputDir, 'index.json'), JSON.stringify({
770
+ await writePrivateFile(join(outputDir, 'index.json'), JSON.stringify({
543
771
  html: 'content.html',
544
772
  images: indexEntries,
545
773
  }, null, 2), 'utf-8');
@@ -825,8 +1053,9 @@ async function extractAttributeExtension(unifiedSession, target, attribute) {
825
1053
  if (text) {
826
1054
  // selector + text 组合:find 已实现 AND 过滤
827
1055
  const elements = await unifiedSession.find(selector, text, undefined);
828
- if (nth >= elements.length)
1056
+ if (nth >= elements.length) {
829
1057
  return null;
1058
+ }
830
1059
  return unifiedSession.getAttribute(undefined, elements[nth].refId, attribute);
831
1060
  }
832
1061
  if (nth > 0) {
@@ -857,6 +1086,9 @@ async function extractComputedStyleExtension(unifiedSession, selector, text, xpa
857
1086
  * 用于实现 extract 的 timeout 参数语义
858
1087
  */
859
1088
  async function waitForTargetExtension(unifiedSession, target, timeout) {
1089
+ if ('x' in target && 'y' in target) {
1090
+ return;
1091
+ }
860
1092
  const startTime = Date.now();
861
1093
  const retryDelay = 100;
862
1094
  const { selector, text, xpath, nth: nthParam } = targetToFindParams(target);
@@ -898,7 +1130,7 @@ async function waitForTargetExtension(unifiedSession, target, timeout) {
898
1130
  */
899
1131
  export function registerExtractTool(server) {
900
1132
  server.registerTool('extract', {
901
- description: `提取页面内容:文本、HTML(可附带图片)、属性、截图、状态、页面元信息`,
1133
+ description: `提取页面内容:文本、HTML(可附带图片)、iframe HTML、属性、截图、状态、页面元信息`,
902
1134
  inputSchema: extractSchema,
903
1135
  }, (args) => handleExtract(args));
904
1136
  }