cdp-skill 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/SKILL.md +34 -5
- package/package.json +2 -1
- package/src/capture/console-capture.js +241 -0
- package/src/capture/debug-capture.js +144 -0
- package/src/capture/error-aggregator.js +151 -0
- package/src/capture/eval-serializer.js +320 -0
- package/src/capture/index.js +40 -0
- package/src/capture/network-capture.js +211 -0
- package/src/capture/pdf-capture.js +256 -0
- package/src/capture/screenshot-capture.js +325 -0
- package/src/cdp/browser.js +569 -0
- package/src/cdp/connection.js +369 -0
- package/src/cdp/discovery.js +138 -0
- package/src/cdp/index.js +29 -0
- package/src/cdp/target-and-session.js +439 -0
- package/src/cdp-skill.js +25 -11
- package/src/constants.js +79 -0
- package/src/dom/actionability.js +638 -0
- package/src/dom/click-executor.js +923 -0
- package/src/dom/element-handle.js +496 -0
- package/src/dom/element-locator.js +475 -0
- package/src/dom/element-validator.js +120 -0
- package/src/dom/fill-executor.js +489 -0
- package/src/dom/index.js +248 -0
- package/src/dom/input-emulator.js +406 -0
- package/src/dom/keyboard-executor.js +202 -0
- package/src/dom/quad-helpers.js +89 -0
- package/src/dom/react-filler.js +94 -0
- package/src/dom/wait-executor.js +423 -0
- package/src/index.js +6 -6
- package/src/page/cookie-manager.js +202 -0
- package/src/page/dom-stability.js +181 -0
- package/src/page/index.js +36 -0
- package/src/{page.js → page/page-controller.js} +109 -839
- package/src/page/wait-utilities.js +302 -0
- package/src/page/web-storage-manager.js +108 -0
- package/src/runner/context-helpers.js +224 -0
- package/src/runner/execute-browser.js +518 -0
- package/src/runner/execute-form.js +315 -0
- package/src/runner/execute-input.js +308 -0
- package/src/runner/execute-interaction.js +672 -0
- package/src/runner/execute-navigation.js +180 -0
- package/src/runner/execute-query.js +771 -0
- package/src/runner/index.js +51 -0
- package/src/runner/step-executors.js +421 -0
- package/src/runner/step-validator.js +641 -0
- package/src/tests/Actionability.test.js +613 -0
- package/src/tests/BrowserClient.test.js +1 -1
- package/src/tests/ChromeDiscovery.test.js +1 -1
- package/src/tests/ClickExecutor.test.js +554 -0
- package/src/tests/ConsoleCapture.test.js +1 -1
- package/src/tests/ContextHelpers.test.js +453 -0
- package/src/tests/CookieManager.test.js +450 -0
- package/src/tests/DebugCapture.test.js +307 -0
- package/src/tests/ElementHandle.test.js +1 -1
- package/src/tests/ElementLocator.test.js +1 -1
- package/src/tests/ErrorAggregator.test.js +1 -1
- package/src/tests/EvalSerializer.test.js +391 -0
- package/src/tests/FillExecutor.test.js +611 -0
- package/src/tests/InputEmulator.test.js +1 -1
- package/src/tests/KeyboardExecutor.test.js +430 -0
- package/src/tests/NetworkErrorCapture.test.js +1 -1
- package/src/tests/PageController.test.js +1 -1
- package/src/tests/PdfCapture.test.js +333 -0
- package/src/tests/ScreenshotCapture.test.js +1 -1
- package/src/tests/SessionRegistry.test.js +1 -1
- package/src/tests/StepValidator.test.js +527 -0
- package/src/tests/TargetManager.test.js +1 -1
- package/src/tests/TestRunner.test.js +1 -1
- package/src/tests/WaitStrategy.test.js +1 -1
- package/src/tests/WaitUtilities.test.js +508 -0
- package/src/tests/WebStorageManager.test.js +333 -0
- package/src/types.js +309 -0
- package/src/capture.js +0 -1400
- package/src/cdp.js +0 -1286
- package/src/dom.js +0 -4379
- package/src/runner.js +0 -3676
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF Capture Module
|
|
3
|
+
* Generate PDF documents from page content
|
|
4
|
+
*
|
|
5
|
+
* PUBLIC EXPORTS:
|
|
6
|
+
* - createPdfCapture(session) - Factory for PDF capture
|
|
7
|
+
*
|
|
8
|
+
* @module cdp-skill/capture/pdf-capture
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a PDF capture utility
|
|
16
|
+
* @param {import('../types.js').CDPSession} session - CDP session
|
|
17
|
+
* @returns {Object} PDF capture interface
|
|
18
|
+
*/
|
|
19
|
+
export function createPdfCapture(session) {
|
|
20
|
+
/**
|
|
21
|
+
* Generate PDF from current page
|
|
22
|
+
* @param {import('../types.js').PdfOptions} [options] - PDF options
|
|
23
|
+
* @returns {Promise<Buffer>} PDF buffer
|
|
24
|
+
*/
|
|
25
|
+
async function generatePdf(options = {}) {
|
|
26
|
+
const params = {
|
|
27
|
+
landscape: options.landscape || false,
|
|
28
|
+
displayHeaderFooter: options.displayHeaderFooter || false,
|
|
29
|
+
headerTemplate: options.headerTemplate || '',
|
|
30
|
+
footerTemplate: options.footerTemplate || '',
|
|
31
|
+
printBackground: options.printBackground !== false,
|
|
32
|
+
scale: options.scale || 1,
|
|
33
|
+
paperWidth: options.paperWidth || 8.5,
|
|
34
|
+
paperHeight: options.paperHeight || 11,
|
|
35
|
+
marginTop: options.marginTop || 0.4,
|
|
36
|
+
marginBottom: options.marginBottom || 0.4,
|
|
37
|
+
marginLeft: options.marginLeft || 0.4,
|
|
38
|
+
marginRight: options.marginRight || 0.4,
|
|
39
|
+
pageRanges: options.pageRanges || '',
|
|
40
|
+
preferCSSPageSize: options.preferCSSPageSize || false
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const result = await session.send('Page.printToPDF', params);
|
|
44
|
+
return Buffer.from(result.data, 'base64');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatFileSize(bytes) {
|
|
48
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
49
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
50
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract metadata from PDF buffer
|
|
55
|
+
* @param {Buffer} buffer - PDF buffer
|
|
56
|
+
* @returns {{fileSize: number, fileSizeFormatted: string, pageCount: number, dimensions: Object}}
|
|
57
|
+
*/
|
|
58
|
+
function extractPdfMetadata(buffer) {
|
|
59
|
+
const fileSize = buffer.length;
|
|
60
|
+
const content = buffer.toString('binary');
|
|
61
|
+
|
|
62
|
+
// Count pages by looking for /Type /Page entries
|
|
63
|
+
const pageMatches = content.match(/\/Type\s*\/Page[^s]/g);
|
|
64
|
+
const pageCount = pageMatches ? pageMatches.length : 1;
|
|
65
|
+
|
|
66
|
+
// Try to extract media box dimensions (default page size)
|
|
67
|
+
let dimensions = { width: 612, height: 792 }; // Default Letter size in points
|
|
68
|
+
const mediaBoxMatch = content.match(/\/MediaBox\s*\[\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\]/);
|
|
69
|
+
if (mediaBoxMatch) {
|
|
70
|
+
dimensions = {
|
|
71
|
+
width: parseFloat(mediaBoxMatch[3]) - parseFloat(mediaBoxMatch[1]),
|
|
72
|
+
height: parseFloat(mediaBoxMatch[4]) - parseFloat(mediaBoxMatch[2])
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
fileSize,
|
|
78
|
+
fileSizeFormatted: formatFileSize(fileSize),
|
|
79
|
+
pageCount,
|
|
80
|
+
dimensions: {
|
|
81
|
+
width: dimensions.width,
|
|
82
|
+
height: dimensions.height,
|
|
83
|
+
unit: 'points'
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate PDF buffer
|
|
90
|
+
* @param {Buffer} buffer - PDF buffer
|
|
91
|
+
* @returns {{valid: boolean, errors: string[], warnings: string[]}}
|
|
92
|
+
*/
|
|
93
|
+
function validatePdf(buffer) {
|
|
94
|
+
const content = buffer.toString('binary');
|
|
95
|
+
const errors = [];
|
|
96
|
+
const warnings = [];
|
|
97
|
+
|
|
98
|
+
// Check PDF header
|
|
99
|
+
if (!content.startsWith('%PDF-')) {
|
|
100
|
+
errors.push('Invalid PDF: missing PDF header');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check for EOF marker
|
|
104
|
+
if (!content.includes('%%EOF')) {
|
|
105
|
+
warnings.push('PDF may be truncated: missing EOF marker');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for xref table or xref stream
|
|
109
|
+
if (!content.includes('xref') && !content.includes('/XRef')) {
|
|
110
|
+
warnings.push('PDF may have structural issues: no cross-reference found');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check minimum size (a valid PDF should be at least a few hundred bytes)
|
|
114
|
+
if (buffer.length < 100) {
|
|
115
|
+
errors.push('PDF file is too small to be valid');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
valid: errors.length === 0,
|
|
120
|
+
errors,
|
|
121
|
+
warnings
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate PDF of specific element
|
|
127
|
+
* @param {string} selector - Element selector
|
|
128
|
+
* @param {import('../types.js').PdfOptions} [options] - PDF options
|
|
129
|
+
* @param {Object} elementLocator - Element locator instance
|
|
130
|
+
* @returns {Promise<Buffer>} PDF buffer
|
|
131
|
+
*/
|
|
132
|
+
async function generateElementPdf(selector, options = {}, elementLocator) {
|
|
133
|
+
if (!elementLocator) {
|
|
134
|
+
throw new Error('Element locator required for element PDF');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find the element
|
|
138
|
+
const element = await elementLocator.querySelector(selector);
|
|
139
|
+
if (!element) {
|
|
140
|
+
throw new Error(`Element not found: ${selector}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Get the element's HTML and create a print-optimized version
|
|
145
|
+
const elementHtml = await element.evaluate(`function() {
|
|
146
|
+
const clone = this.cloneNode(true);
|
|
147
|
+
// Create a wrapper with print-friendly styles
|
|
148
|
+
const wrapper = document.createElement('div');
|
|
149
|
+
wrapper.style.cssText = 'width: 100%; margin: 0; padding: 0;';
|
|
150
|
+
wrapper.appendChild(clone);
|
|
151
|
+
return wrapper.outerHTML;
|
|
152
|
+
}`);
|
|
153
|
+
|
|
154
|
+
// Store original body content
|
|
155
|
+
await session.send('Runtime.evaluate', {
|
|
156
|
+
expression: `
|
|
157
|
+
window.__originalBody = document.body.innerHTML;
|
|
158
|
+
window.__originalStyles = document.body.style.cssText;
|
|
159
|
+
`
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Replace body with element content for printing
|
|
163
|
+
await session.send('Runtime.evaluate', {
|
|
164
|
+
expression: `
|
|
165
|
+
document.body.innerHTML = ${JSON.stringify(elementHtml)};
|
|
166
|
+
document.body.style.cssText = 'margin: 0; padding: 20px;';
|
|
167
|
+
`
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Generate the PDF
|
|
171
|
+
const buffer = await generatePdf(options);
|
|
172
|
+
|
|
173
|
+
// Restore original body
|
|
174
|
+
await session.send('Runtime.evaluate', {
|
|
175
|
+
expression: `
|
|
176
|
+
document.body.innerHTML = window.__originalBody;
|
|
177
|
+
document.body.style.cssText = window.__originalStyles;
|
|
178
|
+
delete window.__originalBody;
|
|
179
|
+
delete window.__originalStyles;
|
|
180
|
+
`
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return buffer;
|
|
184
|
+
} finally {
|
|
185
|
+
await element.dispose();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Save PDF to file
|
|
191
|
+
* @param {string} filePath - Destination path
|
|
192
|
+
* @param {Object} [options] - PDF options
|
|
193
|
+
* @param {string} [options.selector] - Element selector for element PDF
|
|
194
|
+
* @param {boolean} [options.validate] - Validate PDF structure
|
|
195
|
+
* @param {Object} [elementLocator] - Element locator for selector option
|
|
196
|
+
* @returns {Promise<Object>} Result with path, metadata, and optional validation
|
|
197
|
+
*/
|
|
198
|
+
async function saveToFile(filePath, options = {}, elementLocator = null) {
|
|
199
|
+
let buffer;
|
|
200
|
+
|
|
201
|
+
// Support element PDF via selector
|
|
202
|
+
if (options.selector && elementLocator) {
|
|
203
|
+
buffer = await generateElementPdf(options.selector, options, elementLocator);
|
|
204
|
+
} else {
|
|
205
|
+
buffer = await generatePdf(options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const absolutePath = path.resolve(filePath);
|
|
209
|
+
const dir = path.dirname(absolutePath);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await fs.mkdir(dir, { recursive: true });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
215
|
+
throw new Error(`Permission denied: cannot create directory "${dir}"`);
|
|
216
|
+
}
|
|
217
|
+
if (err.code === 'EROFS') {
|
|
218
|
+
throw new Error(`Read-only filesystem: cannot create directory "${dir}"`);
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`Failed to create directory "${dir}": ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await fs.writeFile(absolutePath, buffer);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err.code === 'ENOSPC') {
|
|
227
|
+
throw new Error(`Disk full: cannot write PDF to "${absolutePath}"`);
|
|
228
|
+
}
|
|
229
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
230
|
+
throw new Error(`Permission denied: cannot write to "${absolutePath}"`);
|
|
231
|
+
}
|
|
232
|
+
if (err.code === 'EROFS') {
|
|
233
|
+
throw new Error(`Read-only filesystem: cannot write to "${absolutePath}"`);
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`Failed to save PDF to "${absolutePath}": ${err.message}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Extract metadata
|
|
239
|
+
const metadata = extractPdfMetadata(buffer);
|
|
240
|
+
|
|
241
|
+
// Optionally validate
|
|
242
|
+
let validation = null;
|
|
243
|
+
if (options.validate) {
|
|
244
|
+
validation = validatePdf(buffer);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
path: absolutePath,
|
|
249
|
+
...metadata,
|
|
250
|
+
validation,
|
|
251
|
+
selector: options.selector || null
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { generatePdf, saveToFile, extractPdfMetadata, validatePdf };
|
|
256
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot Capture Module
|
|
3
|
+
* CDP-based screenshot capture for viewport, full page, and regions
|
|
4
|
+
*
|
|
5
|
+
* PUBLIC EXPORTS:
|
|
6
|
+
* - createScreenshotCapture(session, options?) - Factory for screenshot capture
|
|
7
|
+
* - captureViewport(session, options?) - Capture viewport screenshot
|
|
8
|
+
* - captureFullPage(session, options?) - Capture full page screenshot
|
|
9
|
+
* - captureRegion(session, region, options?) - Capture specific region
|
|
10
|
+
* - saveScreenshot(buffer, filePath) - Save screenshot to file
|
|
11
|
+
*
|
|
12
|
+
* @module cdp-skill/capture/screenshot-capture
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs/promises';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_DIMENSION = 16384;
|
|
19
|
+
const VALID_FORMATS = ['png', 'jpeg', 'webp'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a screenshot capture utility
|
|
23
|
+
* @param {import('../types.js').CDPSession} session - CDP session
|
|
24
|
+
* @param {Object} [options] - Configuration options
|
|
25
|
+
* @param {number} [options.maxDimension=16384] - Maximum dimension for full page captures
|
|
26
|
+
* @returns {Object} Screenshot capture interface
|
|
27
|
+
*/
|
|
28
|
+
export function createScreenshotCapture(session, options = {}) {
|
|
29
|
+
const maxDimension = options.maxDimension || DEFAULT_MAX_DIMENSION;
|
|
30
|
+
|
|
31
|
+
function validateFormat(format) {
|
|
32
|
+
if (!VALID_FORMATS.includes(format)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid screenshot format "${format}". Valid formats are: ${VALID_FORMATS.join(', ')}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateQuality(quality, format) {
|
|
40
|
+
if (quality === undefined) return;
|
|
41
|
+
if (format === 'png') {
|
|
42
|
+
throw new Error('Quality option is only supported for jpeg and webp formats, not png');
|
|
43
|
+
}
|
|
44
|
+
if (typeof quality !== 'number' || quality < 0 || quality > 100) {
|
|
45
|
+
throw new Error('Quality must be a number between 0 and 100');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateOptions(opts = {}) {
|
|
50
|
+
const format = opts.format || 'png';
|
|
51
|
+
validateFormat(format);
|
|
52
|
+
validateQuality(opts.quality, format);
|
|
53
|
+
return { ...opts, format };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Capture viewport screenshot
|
|
58
|
+
* @param {import('../types.js').ScreenshotOptions} [captureOptions] - Screenshot options
|
|
59
|
+
* @returns {Promise<Buffer>} PNG/JPEG/WebP image buffer
|
|
60
|
+
*/
|
|
61
|
+
async function captureViewport(captureOptions = {}) {
|
|
62
|
+
const validated = validateOptions(captureOptions);
|
|
63
|
+
const params = { format: validated.format };
|
|
64
|
+
|
|
65
|
+
if (params.format !== 'png' && validated.quality !== undefined) {
|
|
66
|
+
params.quality = validated.quality;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Support omitBackground option
|
|
70
|
+
if (captureOptions.omitBackground) {
|
|
71
|
+
params.fromSurface = true;
|
|
72
|
+
// Enable transparent background
|
|
73
|
+
await session.send('Emulation.setDefaultBackgroundColorOverride', {
|
|
74
|
+
color: { r: 0, g: 0, b: 0, a: 0 }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Support clip option for region capture
|
|
79
|
+
if (captureOptions.clip) {
|
|
80
|
+
params.clip = {
|
|
81
|
+
x: captureOptions.clip.x,
|
|
82
|
+
y: captureOptions.clip.y,
|
|
83
|
+
width: captureOptions.clip.width,
|
|
84
|
+
height: captureOptions.clip.height,
|
|
85
|
+
scale: captureOptions.clip.scale || 1
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = await session.send('Page.captureScreenshot', params);
|
|
90
|
+
|
|
91
|
+
// Reset background override if we changed it
|
|
92
|
+
if (captureOptions.omitBackground) {
|
|
93
|
+
await session.send('Emulation.setDefaultBackgroundColorOverride');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Buffer.from(result.data, 'base64');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Capture full page screenshot
|
|
101
|
+
* @param {import('../types.js').ScreenshotOptions} [captureOptions] - Screenshot options
|
|
102
|
+
* @returns {Promise<Buffer>} PNG/JPEG/WebP image buffer
|
|
103
|
+
*/
|
|
104
|
+
async function captureFullPage(captureOptions = {}) {
|
|
105
|
+
const validated = validateOptions(captureOptions);
|
|
106
|
+
|
|
107
|
+
const metrics = await session.send('Page.getLayoutMetrics');
|
|
108
|
+
const { contentSize } = metrics;
|
|
109
|
+
|
|
110
|
+
const width = Math.ceil(contentSize.width);
|
|
111
|
+
const height = Math.ceil(contentSize.height);
|
|
112
|
+
|
|
113
|
+
if (width > maxDimension || height > maxDimension) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Page dimensions (${width}x${height}) exceed maximum allowed (${maxDimension}x${maxDimension}). ` +
|
|
116
|
+
`Consider using captureViewport() or captureRegion() instead.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const params = {
|
|
121
|
+
format: validated.format,
|
|
122
|
+
captureBeyondViewport: true,
|
|
123
|
+
clip: { x: 0, y: 0, width, height, scale: 1 }
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (params.format !== 'png' && validated.quality !== undefined) {
|
|
127
|
+
params.quality = validated.quality;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await session.send('Page.captureScreenshot', params);
|
|
131
|
+
return Buffer.from(result.data, 'base64');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Capture specific region
|
|
136
|
+
* @param {import('../types.js').ClipRegion} region - Region to capture
|
|
137
|
+
* @param {import('../types.js').ScreenshotOptions} [captureOptions] - Screenshot options
|
|
138
|
+
* @returns {Promise<Buffer>} PNG/JPEG/WebP image buffer
|
|
139
|
+
*/
|
|
140
|
+
async function captureRegion(region, captureOptions = {}) {
|
|
141
|
+
const validated = validateOptions(captureOptions);
|
|
142
|
+
const params = {
|
|
143
|
+
format: validated.format,
|
|
144
|
+
clip: {
|
|
145
|
+
x: region.x,
|
|
146
|
+
y: region.y,
|
|
147
|
+
width: region.width,
|
|
148
|
+
height: region.height,
|
|
149
|
+
scale: captureOptions.scale || 1
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (params.format !== 'png' && validated.quality !== undefined) {
|
|
154
|
+
params.quality = validated.quality;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result = await session.send('Page.captureScreenshot', params);
|
|
158
|
+
return Buffer.from(result.data, 'base64');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Capture element by bounding box
|
|
163
|
+
* @param {import('../types.js').BoundingBox} boundingBox - Element bounding box
|
|
164
|
+
* @param {Object} [captureOptions] - Screenshot options
|
|
165
|
+
* @param {number} [captureOptions.padding=0] - Padding around element
|
|
166
|
+
* @returns {Promise<Buffer>} PNG/JPEG/WebP image buffer
|
|
167
|
+
*/
|
|
168
|
+
async function captureElement(boundingBox, captureOptions = {}) {
|
|
169
|
+
const padding = captureOptions.padding || 0;
|
|
170
|
+
return captureRegion({
|
|
171
|
+
x: Math.max(0, boundingBox.x - padding),
|
|
172
|
+
y: Math.max(0, boundingBox.y - padding),
|
|
173
|
+
width: boundingBox.width + (padding * 2),
|
|
174
|
+
height: boundingBox.height + (padding * 2)
|
|
175
|
+
}, captureOptions);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Save buffer to file
|
|
180
|
+
* @param {Buffer} buffer - Image buffer
|
|
181
|
+
* @param {string} filePath - Destination path
|
|
182
|
+
* @returns {Promise<string>} Absolute path
|
|
183
|
+
*/
|
|
184
|
+
async function saveToFile(buffer, filePath) {
|
|
185
|
+
const absolutePath = path.resolve(filePath);
|
|
186
|
+
const dir = path.dirname(absolutePath);
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await fs.mkdir(dir, { recursive: true });
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
192
|
+
throw new Error(`Permission denied: cannot create directory "${dir}"`);
|
|
193
|
+
}
|
|
194
|
+
if (err.code === 'EROFS') {
|
|
195
|
+
throw new Error(`Read-only filesystem: cannot create directory "${dir}"`);
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`Failed to create directory "${dir}": ${err.message}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await fs.writeFile(absolutePath, buffer);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err.code === 'ENOSPC') {
|
|
204
|
+
throw new Error(`Disk full: cannot write screenshot to "${absolutePath}"`);
|
|
205
|
+
}
|
|
206
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
207
|
+
throw new Error(`Permission denied: cannot write to "${absolutePath}"`);
|
|
208
|
+
}
|
|
209
|
+
if (err.code === 'EROFS') {
|
|
210
|
+
throw new Error(`Read-only filesystem: cannot write to "${absolutePath}"`);
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`Failed to save screenshot to "${absolutePath}": ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return absolutePath;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Capture and save to file
|
|
220
|
+
* @param {string} filePath - Destination path
|
|
221
|
+
* @param {Object} [captureOptions] - Screenshot options
|
|
222
|
+
* @param {boolean} [captureOptions.fullPage=false] - Capture full page
|
|
223
|
+
* @param {string} [captureOptions.selector] - Element selector to capture
|
|
224
|
+
* @param {Object} [elementLocator] - Element locator for selector capture
|
|
225
|
+
* @returns {Promise<string>} Absolute path
|
|
226
|
+
*/
|
|
227
|
+
async function captureToFile(filePath, captureOptions = {}, elementLocator = null) {
|
|
228
|
+
let buffer;
|
|
229
|
+
let elementBox = null;
|
|
230
|
+
|
|
231
|
+
// Support element screenshot via selector
|
|
232
|
+
if (captureOptions.selector && elementLocator) {
|
|
233
|
+
const element = await elementLocator.querySelector(captureOptions.selector);
|
|
234
|
+
if (!element) {
|
|
235
|
+
throw new Error(`Element not found: ${captureOptions.selector}`);
|
|
236
|
+
}
|
|
237
|
+
const box = await element.getBoundingBox();
|
|
238
|
+
await element.dispose();
|
|
239
|
+
|
|
240
|
+
if (!box || box.width === 0 || box.height === 0) {
|
|
241
|
+
throw new Error(`Element has no visible dimensions: ${captureOptions.selector}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
elementBox = box;
|
|
245
|
+
buffer = await captureElement(box, captureOptions);
|
|
246
|
+
} else if (captureOptions.fullPage) {
|
|
247
|
+
buffer = await captureFullPage(captureOptions);
|
|
248
|
+
} else {
|
|
249
|
+
buffer = await captureViewport(captureOptions);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return saveToFile(buffer, filePath);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get viewport dimensions
|
|
257
|
+
* @returns {Promise<{width: number, height: number}>}
|
|
258
|
+
*/
|
|
259
|
+
async function getViewportDimensions() {
|
|
260
|
+
const result = await session.send('Runtime.evaluate', {
|
|
261
|
+
expression: '({ width: window.innerWidth, height: window.innerHeight })',
|
|
262
|
+
returnByValue: true
|
|
263
|
+
});
|
|
264
|
+
return result.result.value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
captureViewport,
|
|
269
|
+
captureFullPage,
|
|
270
|
+
captureRegion,
|
|
271
|
+
captureElement,
|
|
272
|
+
saveToFile,
|
|
273
|
+
captureToFile,
|
|
274
|
+
getViewportDimensions
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Convenience function to capture viewport
|
|
280
|
+
* @param {import('../types.js').CDPSession} session - CDP session
|
|
281
|
+
* @param {import('../types.js').ScreenshotOptions} [options] - Screenshot options
|
|
282
|
+
* @returns {Promise<Buffer>}
|
|
283
|
+
*/
|
|
284
|
+
export async function captureViewport(session, options = {}) {
|
|
285
|
+
const capture = createScreenshotCapture(session, options);
|
|
286
|
+
return capture.captureViewport(options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Convenience function to capture full page
|
|
291
|
+
* @param {import('../types.js').CDPSession} session - CDP session
|
|
292
|
+
* @param {import('../types.js').ScreenshotOptions} [options] - Screenshot options
|
|
293
|
+
* @returns {Promise<Buffer>}
|
|
294
|
+
*/
|
|
295
|
+
export async function captureFullPage(session, options = {}) {
|
|
296
|
+
const capture = createScreenshotCapture(session, options);
|
|
297
|
+
return capture.captureFullPage(options);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Convenience function to capture a region
|
|
302
|
+
* @param {import('../types.js').CDPSession} session - CDP session
|
|
303
|
+
* @param {import('../types.js').ClipRegion} region - Region to capture
|
|
304
|
+
* @param {import('../types.js').ScreenshotOptions} [options] - Screenshot options
|
|
305
|
+
* @returns {Promise<Buffer>}
|
|
306
|
+
*/
|
|
307
|
+
export async function captureRegion(session, region, options = {}) {
|
|
308
|
+
const capture = createScreenshotCapture(session, options);
|
|
309
|
+
return capture.captureRegion(region, options);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Save a screenshot buffer to file
|
|
314
|
+
* @param {Buffer} buffer - Screenshot buffer
|
|
315
|
+
* @param {string} filePath - Destination path
|
|
316
|
+
* @returns {Promise<string>} Absolute path
|
|
317
|
+
*/
|
|
318
|
+
export async function saveScreenshot(buffer, filePath) {
|
|
319
|
+
const absolutePath = path.resolve(filePath);
|
|
320
|
+
const dir = path.dirname(absolutePath);
|
|
321
|
+
|
|
322
|
+
await fs.mkdir(dir, { recursive: true });
|
|
323
|
+
await fs.writeFile(absolutePath, buffer);
|
|
324
|
+
return absolutePath;
|
|
325
|
+
}
|