webpeel 0.20.2 → 0.20.3
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/dist/server/app.d.ts +14 -0
- package/dist/server/app.js +384 -0
- package/dist/server/auth-store.d.ts +27 -0
- package/dist/server/auth-store.js +88 -0
- package/dist/server/email-service.d.ts +21 -0
- package/dist/server/email-service.js +79 -0
- package/dist/server/job-queue.d.ts +100 -0
- package/dist/server/job-queue.js +145 -0
- package/dist/server/logger.d.ts +10 -0
- package/dist/server/logger.js +37 -0
- package/dist/server/middleware/auth.d.ts +28 -0
- package/dist/server/middleware/auth.js +221 -0
- package/dist/server/middleware/rate-limit.d.ts +24 -0
- package/dist/server/middleware/rate-limit.js +167 -0
- package/dist/server/middleware/url-validator.d.ts +15 -0
- package/dist/server/middleware/url-validator.js +186 -0
- package/dist/server/openapi.yaml +6418 -0
- package/dist/server/pg-auth-store.d.ts +132 -0
- package/dist/server/pg-auth-store.js +472 -0
- package/dist/server/pg-job-queue.d.ts +59 -0
- package/dist/server/pg-job-queue.js +375 -0
- package/dist/server/premium/domain-intel.d.ts +16 -0
- package/dist/server/premium/domain-intel.js +133 -0
- package/dist/server/premium/index.d.ts +17 -0
- package/dist/server/premium/index.js +35 -0
- package/dist/server/premium/swr-cache.d.ts +14 -0
- package/dist/server/premium/swr-cache.js +34 -0
- package/dist/server/routes/activity.d.ts +6 -0
- package/dist/server/routes/activity.js +74 -0
- package/dist/server/routes/answer.d.ts +5 -0
- package/dist/server/routes/answer.js +125 -0
- package/dist/server/routes/ask.d.ts +28 -0
- package/dist/server/routes/ask.js +229 -0
- package/dist/server/routes/batch.d.ts +6 -0
- package/dist/server/routes/batch.js +493 -0
- package/dist/server/routes/cli-usage.d.ts +6 -0
- package/dist/server/routes/cli-usage.js +127 -0
- package/dist/server/routes/compat.d.ts +23 -0
- package/dist/server/routes/compat.js +652 -0
- package/dist/server/routes/deep-fetch.d.ts +8 -0
- package/dist/server/routes/deep-fetch.js +57 -0
- package/dist/server/routes/demo.d.ts +24 -0
- package/dist/server/routes/demo.js +517 -0
- package/dist/server/routes/do.d.ts +8 -0
- package/dist/server/routes/do.js +72 -0
- package/dist/server/routes/extract.d.ts +8 -0
- package/dist/server/routes/extract.js +235 -0
- package/dist/server/routes/fetch.d.ts +7 -0
- package/dist/server/routes/fetch.js +999 -0
- package/dist/server/routes/health.d.ts +7 -0
- package/dist/server/routes/health.js +19 -0
- package/dist/server/routes/jobs.d.ts +7 -0
- package/dist/server/routes/jobs.js +573 -0
- package/dist/server/routes/mcp.d.ts +14 -0
- package/dist/server/routes/mcp.js +141 -0
- package/dist/server/routes/oauth.d.ts +9 -0
- package/dist/server/routes/oauth.js +396 -0
- package/dist/server/routes/playground.d.ts +17 -0
- package/dist/server/routes/playground.js +283 -0
- package/dist/server/routes/screenshot.d.ts +22 -0
- package/dist/server/routes/screenshot.js +816 -0
- package/dist/server/routes/search.d.ts +6 -0
- package/dist/server/routes/search.js +303 -0
- package/dist/server/routes/session.d.ts +15 -0
- package/dist/server/routes/session.js +397 -0
- package/dist/server/routes/stats.d.ts +6 -0
- package/dist/server/routes/stats.js +71 -0
- package/dist/server/routes/stripe.d.ts +15 -0
- package/dist/server/routes/stripe.js +294 -0
- package/dist/server/routes/users.d.ts +8 -0
- package/dist/server/routes/users.js +1671 -0
- package/dist/server/routes/watch.d.ts +15 -0
- package/dist/server/routes/watch.js +309 -0
- package/dist/server/routes/webhooks.d.ts +26 -0
- package/dist/server/routes/webhooks.js +170 -0
- package/dist/server/routes/youtube.d.ts +6 -0
- package/dist/server/routes/youtube.js +130 -0
- package/dist/server/sentry.d.ts +13 -0
- package/dist/server/sentry.js +38 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.js +7 -0
- package/dist/server/utils/response.d.ts +44 -0
- package/dist/server/utils/response.js +69 -0
- package/dist/server/utils/sse.d.ts +22 -0
- package/dist/server/utils/sse.js +38 -0
- package/package.json +2 -1
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot endpoint — POST /v1/screenshot
|
|
3
|
+
*
|
|
4
|
+
* Takes a screenshot of a URL and returns base64-encoded image data.
|
|
5
|
+
* Uses the same rate limiting / credit system as the fetch endpoint (1 credit).
|
|
6
|
+
*
|
|
7
|
+
* The main endpoint accepts an optional `mode` parameter to select behaviour:
|
|
8
|
+
* - "screenshot" (default) — basic screenshot
|
|
9
|
+
* - "filmstrip" — multiple frames over time
|
|
10
|
+
* - "audit" — accessibility / section audit
|
|
11
|
+
* - "viewports" — multi-viewport screenshots
|
|
12
|
+
* - "design" — design analysis (audit + tokens merged)
|
|
13
|
+
* - "diff" — visual diff between url and compareUrl
|
|
14
|
+
* - "compare" — design comparison between url and compareUrl/ref
|
|
15
|
+
*
|
|
16
|
+
* All legacy sub-endpoints (/filmstrip, /audit, /viewports, …) are kept as
|
|
17
|
+
* thin wrappers that delegate to the same named handler functions.
|
|
18
|
+
* /animation is deprecated and returns 410 Gone.
|
|
19
|
+
*/
|
|
20
|
+
import { Router } from 'express';
|
|
21
|
+
import { takeScreenshot, takeFilmstrip, takeAuditScreenshots, takeViewportsBatch, takeDesignAudit, takeScreenshotDiff, takeDesignAnalysis, takeDesignComparison, } from '../../core/screenshot.js';
|
|
22
|
+
import { validateUrlForSSRF, SSRFError } from '../middleware/url-validator.js';
|
|
23
|
+
import { normalizeActions } from '../../core/actions.js';
|
|
24
|
+
// ── Module-level helpers ──────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Validate a URL from a request body. Sends a 400 response and returns false on failure.
|
|
27
|
+
* Returns true when the URL is valid and safe to use.
|
|
28
|
+
*/
|
|
29
|
+
function validateRequestUrl(url, res) {
|
|
30
|
+
if (!url || typeof url !== 'string') {
|
|
31
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing or invalid "url" parameter' } });
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (url.length > 2048) {
|
|
35
|
+
res.status(400).json({ success: false, error: { type: 'invalid_url', message: 'URL too long (max 2048 characters)' } });
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const parsed = new URL(url);
|
|
40
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
41
|
+
res.status(400).json({ success: false, error: { type: 'invalid_url', message: 'Only HTTP and HTTPS protocols are allowed' } });
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
res.status(400).json({ success: false, error: { type: 'invalid_url', message: 'Invalid URL format' } });
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
validateUrlForSSRF(url);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof SSRFError) {
|
|
54
|
+
res.status(400).json({ success: false, error: { type: 'ssrf_blocked', message: 'Cannot fetch localhost, private networks, or non-HTTP URLs' } });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fire-and-forget usage tracking + DB logging for screenshot endpoints.
|
|
63
|
+
*/
|
|
64
|
+
function trackUsageAndLog(req, res, authStore, endpoint, url, elapsed) {
|
|
65
|
+
const isSoftLimited = req.auth?.softLimited === true;
|
|
66
|
+
const hasExtraUsage = req.auth?.extraUsageAvailable === true;
|
|
67
|
+
const pgStore = authStore;
|
|
68
|
+
if (req.auth?.keyInfo?.key && typeof pgStore.trackBurstUsage === 'function') {
|
|
69
|
+
pgStore.trackBurstUsage(req.auth.keyInfo.key).then(async () => {
|
|
70
|
+
if (isSoftLimited && hasExtraUsage) {
|
|
71
|
+
const extraResult = await pgStore.trackExtraUsage(req.auth.keyInfo.key, 'stealth', url, elapsed, 200);
|
|
72
|
+
if (extraResult.success) {
|
|
73
|
+
res.setHeader('X-Extra-Usage-Charged', `$${extraResult.cost.toFixed(4)}`);
|
|
74
|
+
res.setHeader('X-Extra-Usage-New-Balance', extraResult.newBalance.toFixed(2));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (!isSoftLimited) {
|
|
78
|
+
await pgStore.trackUsage(req.auth.keyInfo.key, 'stealth');
|
|
79
|
+
}
|
|
80
|
+
}).catch(() => { });
|
|
81
|
+
}
|
|
82
|
+
if (req.auth?.keyInfo?.accountId && typeof pgStore.pool !== 'undefined') {
|
|
83
|
+
pgStore.pool.query(`INSERT INTO usage_logs (user_id, endpoint, url, method, processing_time_ms, status_code, ip_address, user_agent)
|
|
84
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [req.auth.keyInfo.accountId, endpoint, url, 'stealth', elapsed, 200,
|
|
85
|
+
req.ip || req.socket.remoteAddress, req.get('user-agent')]).catch(() => { });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── Named handler functions ───────────────────────────────────────────────────
|
|
89
|
+
// Each accepts (req, res, authStore) and handles a specific screenshot mode.
|
|
90
|
+
// Both the main /v1/screenshot?mode=X dispatcher AND the legacy sub-endpoints
|
|
91
|
+
// call the same function — no logic duplication.
|
|
92
|
+
async function handleFilmstrip(req, res, authStore) {
|
|
93
|
+
try {
|
|
94
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
95
|
+
if (!ssUserId) {
|
|
96
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const { url, frames = 6, width, height, format = 'png', quality, waitFor, timeout, headers, cookies, stealth, } = req.body;
|
|
100
|
+
if (!validateRequestUrl(url, res))
|
|
101
|
+
return;
|
|
102
|
+
if (frames !== undefined && (typeof frames !== 'number' || frames < 2 || frames > 12)) {
|
|
103
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid frames: must be between 2 and 12' }, requestId: req.requestId });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (format !== undefined && !['png', 'jpeg', 'jpg'].includes(format)) {
|
|
107
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid format: must be "png", "jpeg", or "jpg"' }, requestId: req.requestId });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (width !== undefined && (typeof width !== 'number' || width < 100 || width > 5000)) {
|
|
111
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid width: must be between 100 and 5000' }, requestId: req.requestId });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (height !== undefined && (typeof height !== 'number' || height < 100 || height > 5000)) {
|
|
115
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid height: must be between 100 and 5000' }, requestId: req.requestId });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (quality !== undefined && (typeof quality !== 'number' || quality < 1 || quality > 100)) {
|
|
119
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid quality: must be between 1 and 100' }, requestId: req.requestId });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (waitFor !== undefined && (typeof waitFor !== 'number' || waitFor < 0 || waitFor > 60000)) {
|
|
123
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid waitFor: must be between 0 and 60000ms' }, requestId: req.requestId });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const startTime = Date.now();
|
|
127
|
+
const result = await takeFilmstrip(url, {
|
|
128
|
+
frames,
|
|
129
|
+
width,
|
|
130
|
+
height,
|
|
131
|
+
format,
|
|
132
|
+
quality,
|
|
133
|
+
waitFor,
|
|
134
|
+
timeout: timeout || 30000,
|
|
135
|
+
headers,
|
|
136
|
+
cookies,
|
|
137
|
+
stealth: stealth === true,
|
|
138
|
+
});
|
|
139
|
+
const elapsed = Date.now() - startTime;
|
|
140
|
+
const isSoftLimited = req.auth?.softLimited === true;
|
|
141
|
+
const hasExtraUsage = req.auth?.extraUsageAvailable === true;
|
|
142
|
+
const pgStore = authStore;
|
|
143
|
+
if (req.auth?.keyInfo?.key && typeof pgStore.trackBurstUsage === 'function') {
|
|
144
|
+
await pgStore.trackBurstUsage(req.auth.keyInfo.key);
|
|
145
|
+
if (isSoftLimited && hasExtraUsage) {
|
|
146
|
+
const extraResult = await pgStore.trackExtraUsage(req.auth.keyInfo.key, 'stealth', url, elapsed, 200);
|
|
147
|
+
if (extraResult.success) {
|
|
148
|
+
res.setHeader('X-Extra-Usage-Charged', `$${extraResult.cost.toFixed(4)}`);
|
|
149
|
+
res.setHeader('X-Extra-Usage-New-Balance', extraResult.newBalance.toFixed(2));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else if (!isSoftLimited) {
|
|
153
|
+
await pgStore.trackUsage(req.auth.keyInfo.key, 'stealth');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (req.auth?.keyInfo?.accountId && typeof pgStore.pool !== 'undefined') {
|
|
157
|
+
pgStore.pool.query(`INSERT INTO usage_logs (user_id, endpoint, url, method, processing_time_ms, status_code, ip_address, user_agent)
|
|
158
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [req.auth.keyInfo.accountId, 'screenshot_filmstrip', url, 'stealth', elapsed, 200,
|
|
159
|
+
req.ip || req.socket.remoteAddress, req.get('user-agent')]).catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
res.setHeader('X-Credits-Used', '1');
|
|
162
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
163
|
+
res.setHeader('X-Fetch-Type', 'filmstrip');
|
|
164
|
+
res.json({
|
|
165
|
+
success: true,
|
|
166
|
+
data: {
|
|
167
|
+
url: result.url,
|
|
168
|
+
format: result.format,
|
|
169
|
+
frameCount: result.frameCount,
|
|
170
|
+
frames: result.frames,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const pgStore = authStore;
|
|
176
|
+
if (req.auth?.keyInfo?.accountId && typeof pgStore.pool !== 'undefined') {
|
|
177
|
+
pgStore.pool.query(`INSERT INTO usage_logs (user_id, endpoint, url, method, status_code, error, ip_address, user_agent)
|
|
178
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [req.auth.keyInfo.accountId, 'screenshot_filmstrip', req.body?.url, 'stealth', 500,
|
|
179
|
+
error.message || 'Unknown error', req.ip || req.socket.remoteAddress, req.get('user-agent')]).catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
if (error.code) {
|
|
182
|
+
res.status(500).json({ success: false, error: { type: 'filmstrip_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#filmstrip_error' }, requestId: req.requestId });
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred while taking the filmstrip', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function handleAudit(req, res, authStore) {
|
|
190
|
+
try {
|
|
191
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
192
|
+
if (!ssUserId) {
|
|
193
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const { url, width, height, format = 'jpeg', quality, selector, scrollThrough = false, waitFor, timeout } = req.body;
|
|
197
|
+
if (!validateRequestUrl(url, res))
|
|
198
|
+
return;
|
|
199
|
+
if (format !== undefined && !['png', 'jpeg', 'jpg'].includes(format)) {
|
|
200
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid format: must be "png", "jpeg", or "jpg"' }, requestId: req.requestId });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const startTime = Date.now();
|
|
204
|
+
const result = await takeAuditScreenshots(url, {
|
|
205
|
+
width, height, format, quality,
|
|
206
|
+
selector: typeof selector === 'string' ? selector : 'section',
|
|
207
|
+
scrollThrough: scrollThrough === true,
|
|
208
|
+
waitFor, timeout: timeout || 60000,
|
|
209
|
+
});
|
|
210
|
+
const elapsed = Date.now() - startTime;
|
|
211
|
+
trackUsageAndLog(req, res, authStore, 'screenshot_audit', url, elapsed);
|
|
212
|
+
res.setHeader('X-Credits-Used', String(result.sections.length || 1));
|
|
213
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
214
|
+
res.setHeader('X-Fetch-Type', 'audit');
|
|
215
|
+
res.json({
|
|
216
|
+
success: true,
|
|
217
|
+
data: {
|
|
218
|
+
url: result.url,
|
|
219
|
+
format: result.format,
|
|
220
|
+
sections: result.sections.map(s => ({
|
|
221
|
+
index: s.index,
|
|
222
|
+
tag: s.tag,
|
|
223
|
+
id: s.id,
|
|
224
|
+
className: s.className,
|
|
225
|
+
top: s.top,
|
|
226
|
+
height: s.height,
|
|
227
|
+
screenshot: s.screenshot,
|
|
228
|
+
})),
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code) {
|
|
234
|
+
res.status(500).json({ success: false, error: { type: 'audit_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#audit_error' }, requestId: req.requestId });
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during audit screenshots', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function handleViewports(req, res, authStore) {
|
|
242
|
+
try {
|
|
243
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
244
|
+
if (!ssUserId) {
|
|
245
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const { url, viewports, fullPage = false, format = 'jpeg', quality, scrollThrough = false, waitFor, timeout } = req.body;
|
|
249
|
+
if (!validateRequestUrl(url, res))
|
|
250
|
+
return;
|
|
251
|
+
if (format !== undefined && !['png', 'jpeg', 'jpg'].includes(format)) {
|
|
252
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid format: must be "png", "jpeg", or "jpg"' }, requestId: req.requestId });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!Array.isArray(viewports) || viewports.length === 0) {
|
|
256
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing or invalid "viewports" array' }, requestId: req.requestId });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (viewports.length > 6) {
|
|
260
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Maximum 6 viewports per request' }, requestId: req.requestId });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
for (const vp of viewports) {
|
|
264
|
+
if (!vp || typeof vp.width !== 'number' || typeof vp.height !== 'number') {
|
|
265
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Each viewport must have numeric width and height' }, requestId: req.requestId });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (vp.width < 100 || vp.width > 5000 || vp.height < 100 || vp.height > 5000) {
|
|
269
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Viewport dimensions must be between 100 and 5000' }, requestId: req.requestId });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const startTime = Date.now();
|
|
274
|
+
const result = await takeViewportsBatch(url, {
|
|
275
|
+
viewports, fullPage: fullPage === true,
|
|
276
|
+
format, quality,
|
|
277
|
+
scrollThrough: scrollThrough === true,
|
|
278
|
+
waitFor, timeout: timeout || 90000,
|
|
279
|
+
});
|
|
280
|
+
const elapsed = Date.now() - startTime;
|
|
281
|
+
trackUsageAndLog(req, res, authStore, 'screenshot_viewports', url, elapsed);
|
|
282
|
+
res.setHeader('X-Credits-Used', String(viewports.length));
|
|
283
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
284
|
+
res.setHeader('X-Fetch-Type', 'viewports');
|
|
285
|
+
res.json({
|
|
286
|
+
success: true,
|
|
287
|
+
data: {
|
|
288
|
+
url: result.url,
|
|
289
|
+
format: result.format,
|
|
290
|
+
viewports: result.viewports,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error.code) {
|
|
296
|
+
res.status(500).json({ success: false, error: { type: 'viewports_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#viewports_error' }, requestId: req.requestId });
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during viewport screenshots', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function handleDesignAuditHandler(req, res, authStore) {
|
|
304
|
+
try {
|
|
305
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
306
|
+
if (!ssUserId) {
|
|
307
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const { url, rules, selector, width, height, waitFor, timeout } = req.body;
|
|
311
|
+
if (!validateRequestUrl(url, res))
|
|
312
|
+
return;
|
|
313
|
+
if (rules !== undefined && typeof rules !== 'object') {
|
|
314
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid "rules": must be an object' }, requestId: req.requestId });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const startTime = Date.now();
|
|
318
|
+
const result = await takeDesignAudit(url, {
|
|
319
|
+
rules: typeof rules === 'object' ? rules : undefined,
|
|
320
|
+
selector: typeof selector === 'string' ? selector : undefined,
|
|
321
|
+
width, height,
|
|
322
|
+
waitFor, timeout: timeout || 60000,
|
|
323
|
+
});
|
|
324
|
+
const elapsed = Date.now() - startTime;
|
|
325
|
+
trackUsageAndLog(req, res, authStore, 'design_audit', url, elapsed);
|
|
326
|
+
res.setHeader('X-Credits-Used', '1');
|
|
327
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
328
|
+
res.setHeader('X-Fetch-Type', 'design-audit');
|
|
329
|
+
res.json({
|
|
330
|
+
success: true,
|
|
331
|
+
data: {
|
|
332
|
+
url: result.url,
|
|
333
|
+
audit: result.audit,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error.code) {
|
|
339
|
+
res.status(500).json({ success: false, error: { type: 'design_audit_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#design_audit_error' }, requestId: req.requestId });
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during design audit', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function handleDesignAnalysisHandler(req, res, authStore) {
|
|
347
|
+
try {
|
|
348
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
349
|
+
if (!ssUserId) {
|
|
350
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const { url, selector, width, height, waitFor, timeout, stealth } = req.body;
|
|
354
|
+
if (!validateRequestUrl(url, res))
|
|
355
|
+
return;
|
|
356
|
+
const startTime = Date.now();
|
|
357
|
+
const result = await takeDesignAnalysis(url, {
|
|
358
|
+
selector: typeof selector === 'string' ? selector : undefined,
|
|
359
|
+
width, height,
|
|
360
|
+
waitFor, timeout: timeout || 60000,
|
|
361
|
+
stealth: typeof stealth === 'boolean' ? stealth : undefined,
|
|
362
|
+
});
|
|
363
|
+
const elapsed = Date.now() - startTime;
|
|
364
|
+
trackUsageAndLog(req, res, authStore, 'design_analysis', url, elapsed);
|
|
365
|
+
res.setHeader('X-Credits-Used', '1');
|
|
366
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
367
|
+
res.setHeader('X-Fetch-Type', 'design-analysis');
|
|
368
|
+
res.json({
|
|
369
|
+
success: true,
|
|
370
|
+
data: {
|
|
371
|
+
url: result.url,
|
|
372
|
+
analysis: result.analysis,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (error.code) {
|
|
378
|
+
res.status(500).json({ success: false, error: { type: 'design_analysis_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#design_analysis_error' }, requestId: req.requestId });
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during design analysis', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* mode: "design" — merges design-audit and design-analysis into one response.
|
|
387
|
+
*/
|
|
388
|
+
async function handleDesignMerged(req, res, authStore) {
|
|
389
|
+
try {
|
|
390
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
391
|
+
if (!ssUserId) {
|
|
392
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const { url, rules, selector, width, height, waitFor, timeout, stealth } = req.body;
|
|
396
|
+
if (!validateRequestUrl(url, res))
|
|
397
|
+
return;
|
|
398
|
+
if (rules !== undefined && typeof rules !== 'object') {
|
|
399
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid "rules": must be an object' }, requestId: req.requestId });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const startTime = Date.now();
|
|
403
|
+
const [auditResult, analysisResult] = await Promise.all([
|
|
404
|
+
takeDesignAudit(url, {
|
|
405
|
+
rules: typeof rules === 'object' ? rules : undefined,
|
|
406
|
+
selector: typeof selector === 'string' ? selector : undefined,
|
|
407
|
+
width, height,
|
|
408
|
+
waitFor, timeout: timeout || 60000,
|
|
409
|
+
}),
|
|
410
|
+
takeDesignAnalysis(url, {
|
|
411
|
+
selector: typeof selector === 'string' ? selector : undefined,
|
|
412
|
+
width, height,
|
|
413
|
+
waitFor, timeout: timeout || 60000,
|
|
414
|
+
stealth: typeof stealth === 'boolean' ? stealth : undefined,
|
|
415
|
+
}),
|
|
416
|
+
]);
|
|
417
|
+
const elapsed = Date.now() - startTime;
|
|
418
|
+
trackUsageAndLog(req, res, authStore, 'design', url, elapsed);
|
|
419
|
+
res.setHeader('X-Credits-Used', '2');
|
|
420
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
421
|
+
res.setHeader('X-Fetch-Type', 'design');
|
|
422
|
+
res.json({
|
|
423
|
+
success: true,
|
|
424
|
+
data: {
|
|
425
|
+
url: auditResult.url,
|
|
426
|
+
audit: auditResult.audit,
|
|
427
|
+
analysis: analysisResult.analysis,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
if (error.code) {
|
|
433
|
+
res.status(500).json({ success: false, error: { type: 'design_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#design_error' }, requestId: req.requestId });
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during design analysis', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Handles both mode:"diff" (uses url + compareUrl) and the legacy /diff endpoint (url1 + url2).
|
|
442
|
+
*/
|
|
443
|
+
async function handleDiff(req, res, authStore) {
|
|
444
|
+
try {
|
|
445
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
446
|
+
if (!ssUserId) {
|
|
447
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// Accept both { url1, url2 } (legacy) and { url, compareUrl } (new mode param style)
|
|
451
|
+
const url1 = req.body.url1 ?? req.body.url;
|
|
452
|
+
const url2 = req.body.url2 ?? req.body.compareUrl;
|
|
453
|
+
const { width, height, fullPage = false, threshold, waitFor, timeout } = req.body;
|
|
454
|
+
if (!validateRequestUrl(url1, res))
|
|
455
|
+
return;
|
|
456
|
+
if (!validateRequestUrl(url2, res))
|
|
457
|
+
return;
|
|
458
|
+
if (url1 === url2) {
|
|
459
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'url1 and url2 must be different URLs' }, requestId: req.requestId });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (threshold !== undefined && (typeof threshold !== 'number' || threshold < 0 || threshold > 1)) {
|
|
463
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid threshold: must be a number between 0 and 1' }, requestId: req.requestId });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (width !== undefined && (typeof width !== 'number' || width < 100 || width > 5000)) {
|
|
467
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid width: must be between 100 and 5000' }, requestId: req.requestId });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (height !== undefined && (typeof height !== 'number' || height < 100 || height > 5000)) {
|
|
471
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid height: must be between 100 and 5000' }, requestId: req.requestId });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const startTime = Date.now();
|
|
475
|
+
const result = await takeScreenshotDiff(url1, url2, {
|
|
476
|
+
width,
|
|
477
|
+
height,
|
|
478
|
+
fullPage: fullPage === true,
|
|
479
|
+
threshold: threshold ?? 0.1,
|
|
480
|
+
waitFor,
|
|
481
|
+
timeout: timeout || 60000,
|
|
482
|
+
});
|
|
483
|
+
const elapsed = Date.now() - startTime;
|
|
484
|
+
trackUsageAndLog(req, res, authStore, 'screenshot_diff', url1, elapsed);
|
|
485
|
+
res.setHeader('X-Credits-Used', '2');
|
|
486
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
487
|
+
res.setHeader('X-Fetch-Type', 'diff');
|
|
488
|
+
const responseFormat = req.body.responseFormat || req.query.responseFormat;
|
|
489
|
+
if (responseFormat === 'binary') {
|
|
490
|
+
const imgBuf = Buffer.from(result.diff, 'base64');
|
|
491
|
+
res.setHeader('Content-Type', 'image/png');
|
|
492
|
+
res.send(imgBuf);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
res.json({
|
|
496
|
+
success: true,
|
|
497
|
+
data: {
|
|
498
|
+
diff: result.diff,
|
|
499
|
+
diffPixels: result.diffPixels,
|
|
500
|
+
totalPixels: result.totalPixels,
|
|
501
|
+
diffPercent: result.diffPercent,
|
|
502
|
+
dimensions: result.dimensions,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (error.code) {
|
|
508
|
+
res.status(500).json({ success: false, error: { type: 'diff_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#diff_error' }, requestId: req.requestId });
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during visual diff', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* mode: "compare" — design comparison (same as GET /v1/design-compare but via POST).
|
|
517
|
+
* Accepts { url, compareUrl } or { url, ref } in the body.
|
|
518
|
+
*/
|
|
519
|
+
async function handleDesignCompare(req, res, authStore) {
|
|
520
|
+
try {
|
|
521
|
+
const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
522
|
+
if (!userId) {
|
|
523
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Accept body params (POST mode) or query params (GET /v1/design-compare)
|
|
527
|
+
const url = (req.body.url ?? req.query.url);
|
|
528
|
+
const ref = (req.body.compareUrl ?? req.body.ref ?? req.query.ref);
|
|
529
|
+
const widthParam = req.body.width ?? req.query.width;
|
|
530
|
+
const heightParam = req.body.height ?? req.query.height;
|
|
531
|
+
if (!url || typeof url !== 'string') {
|
|
532
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required parameter "url"' }, requestId: req.requestId });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (!validateRequestUrl(url, res))
|
|
536
|
+
return;
|
|
537
|
+
if (!ref || typeof ref !== 'string') {
|
|
538
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required parameter "compareUrl" (or "ref")' }, requestId: req.requestId });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!validateRequestUrl(ref, res))
|
|
542
|
+
return;
|
|
543
|
+
if (url === ref) {
|
|
544
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: '"url" and "compareUrl" must be different URLs' }, requestId: req.requestId });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const width = widthParam !== undefined ? parseInt(String(widthParam), 10) : undefined;
|
|
548
|
+
const height = heightParam !== undefined ? parseInt(String(heightParam), 10) : undefined;
|
|
549
|
+
if (width !== undefined && (isNaN(width) || width < 100 || width > 5000)) {
|
|
550
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid width: must be between 100 and 5000' }, requestId: req.requestId });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (height !== undefined && (isNaN(height) || height < 100 || height > 5000)) {
|
|
554
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid height: must be between 100 and 5000' }, requestId: req.requestId });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const startTime = Date.now();
|
|
558
|
+
const result = await takeDesignComparison(url, ref, { width, height });
|
|
559
|
+
const elapsed = Date.now() - startTime;
|
|
560
|
+
trackUsageAndLog(req, res, authStore, 'design_compare', url, elapsed);
|
|
561
|
+
res.setHeader('X-Credits-Used', '2');
|
|
562
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
563
|
+
res.setHeader('X-Fetch-Type', 'design-compare');
|
|
564
|
+
res.json({
|
|
565
|
+
success: true,
|
|
566
|
+
data: {
|
|
567
|
+
subjectUrl: result.subjectUrl,
|
|
568
|
+
referenceUrl: result.referenceUrl,
|
|
569
|
+
score: result.comparison.score,
|
|
570
|
+
summary: result.comparison.summary,
|
|
571
|
+
gaps: result.comparison.gaps,
|
|
572
|
+
subjectAnalysis: result.comparison.subjectAnalysis,
|
|
573
|
+
referenceAnalysis: result.comparison.referenceAnalysis,
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
const err = error;
|
|
579
|
+
if (err.code) {
|
|
580
|
+
res.status(500).json({ success: false, error: { type: 'design_compare_error', message: err.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#design_compare_error' }, requestId: req.requestId });
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during design comparison', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// ── Router factory ────────────────────────────────────────────────────────────
|
|
588
|
+
export function createScreenshotRouter(authStore) {
|
|
589
|
+
const router = Router();
|
|
590
|
+
// ── POST /v1/screenshot ────────────────────────────────────────────────────
|
|
591
|
+
// Accepts optional `mode` parameter to dispatch to sub-handlers.
|
|
592
|
+
// Falls through to basic screenshot when mode is "screenshot" or absent.
|
|
593
|
+
router.post('/v1/screenshot', async (req, res) => {
|
|
594
|
+
// Mode-based dispatch
|
|
595
|
+
const mode = req.body.mode;
|
|
596
|
+
if (mode === 'filmstrip')
|
|
597
|
+
return handleFilmstrip(req, res, authStore);
|
|
598
|
+
if (mode === 'audit')
|
|
599
|
+
return handleAudit(req, res, authStore);
|
|
600
|
+
if (mode === 'viewports')
|
|
601
|
+
return handleViewports(req, res, authStore);
|
|
602
|
+
if (mode === 'design')
|
|
603
|
+
return handleDesignMerged(req, res, authStore);
|
|
604
|
+
if (mode === 'diff')
|
|
605
|
+
return handleDiff(req, res, authStore);
|
|
606
|
+
if (mode === 'compare')
|
|
607
|
+
return handleDesignCompare(req, res, authStore);
|
|
608
|
+
// mode === 'screenshot' or absent → basic screenshot below
|
|
609
|
+
try {
|
|
610
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
611
|
+
if (!ssUserId) {
|
|
612
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const { url, fullPage = false, width, height, format = 'png', quality, waitFor, timeout, actions, headers, cookies, stealth, scrollThrough = false, selector, } = req.body;
|
|
616
|
+
if (!validateRequestUrl(url, res))
|
|
617
|
+
return;
|
|
618
|
+
if (format !== undefined && !['png', 'jpeg', 'jpg'].includes(format)) {
|
|
619
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid format: must be "png", "jpeg", or "jpg"' }, requestId: req.requestId });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (width !== undefined && (typeof width !== 'number' || width < 100 || width > 5000)) {
|
|
623
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid width: must be between 100 and 5000' }, requestId: req.requestId });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (height !== undefined && (typeof height !== 'number' || height < 100 || height > 5000)) {
|
|
627
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid height: must be between 100 and 5000' }, requestId: req.requestId });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (quality !== undefined && (typeof quality !== 'number' || quality < 1 || quality > 100)) {
|
|
631
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid quality: must be between 1 and 100' }, requestId: req.requestId });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (waitFor !== undefined && (typeof waitFor !== 'number' || waitFor < 0 || waitFor > 60000)) {
|
|
635
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid waitFor: must be between 0 and 60000ms' }, requestId: req.requestId });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (selector !== undefined && typeof selector !== 'string') {
|
|
639
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Invalid selector: must be a string' }, requestId: req.requestId });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
let normalizedActions;
|
|
643
|
+
if (actions !== undefined) {
|
|
644
|
+
try {
|
|
645
|
+
normalizedActions = normalizeActions(actions);
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: `Invalid actions: ${e.message}` }, requestId: req.requestId });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const startTime = Date.now();
|
|
653
|
+
const result = await takeScreenshot(url, {
|
|
654
|
+
fullPage: fullPage === true,
|
|
655
|
+
width,
|
|
656
|
+
height,
|
|
657
|
+
format,
|
|
658
|
+
quality,
|
|
659
|
+
waitFor,
|
|
660
|
+
timeout: timeout || 30000,
|
|
661
|
+
actions: normalizedActions,
|
|
662
|
+
headers,
|
|
663
|
+
cookies,
|
|
664
|
+
stealth: stealth === true,
|
|
665
|
+
scrollThrough: scrollThrough === true,
|
|
666
|
+
selector: typeof selector === 'string' ? selector : undefined,
|
|
667
|
+
});
|
|
668
|
+
const elapsed = Date.now() - startTime;
|
|
669
|
+
const isSoftLimited = req.auth?.softLimited === true;
|
|
670
|
+
const hasExtraUsage = req.auth?.extraUsageAvailable === true;
|
|
671
|
+
const pgStore = authStore;
|
|
672
|
+
if (req.auth?.keyInfo?.key && typeof pgStore.trackBurstUsage === 'function') {
|
|
673
|
+
await pgStore.trackBurstUsage(req.auth.keyInfo.key);
|
|
674
|
+
if (isSoftLimited && hasExtraUsage) {
|
|
675
|
+
const extraResult = await pgStore.trackExtraUsage(req.auth.keyInfo.key, 'stealth', url, elapsed, 200);
|
|
676
|
+
if (extraResult.success) {
|
|
677
|
+
res.setHeader('X-Extra-Usage-Charged', `$${extraResult.cost.toFixed(4)}`);
|
|
678
|
+
res.setHeader('X-Extra-Usage-New-Balance', extraResult.newBalance.toFixed(2));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else if (!isSoftLimited) {
|
|
682
|
+
await pgStore.trackUsage(req.auth.keyInfo.key, 'stealth');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (req.auth?.keyInfo?.accountId && typeof pgStore.pool !== 'undefined') {
|
|
686
|
+
pgStore.pool.query(`INSERT INTO usage_logs (user_id, endpoint, url, method, processing_time_ms, status_code, ip_address, user_agent)
|
|
687
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [req.auth.keyInfo.accountId, 'screenshot', url, 'stealth', elapsed, 200,
|
|
688
|
+
req.ip || req.socket.remoteAddress, req.get('user-agent')]).catch(() => { });
|
|
689
|
+
}
|
|
690
|
+
res.setHeader('X-Credits-Used', '1');
|
|
691
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
692
|
+
res.setHeader('X-Fetch-Type', 'screenshot');
|
|
693
|
+
const responseFormat = req.body.responseFormat || req.query.responseFormat;
|
|
694
|
+
if (responseFormat === 'binary') {
|
|
695
|
+
const imgBuf = Buffer.from(result.screenshot, 'base64');
|
|
696
|
+
res.setHeader('Content-Type', `image/${result.format}`);
|
|
697
|
+
res.setHeader('X-Final-URL', result.url);
|
|
698
|
+
res.send(imgBuf);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
res.json({
|
|
702
|
+
success: true,
|
|
703
|
+
data: {
|
|
704
|
+
url: result.url,
|
|
705
|
+
screenshot: `data:${result.contentType};base64,${result.screenshot}`,
|
|
706
|
+
metadata: {
|
|
707
|
+
sourceURL: result.url,
|
|
708
|
+
format: result.format,
|
|
709
|
+
width: width || 1280,
|
|
710
|
+
height: height || 720,
|
|
711
|
+
fullPage: fullPage === true,
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
const pgStore = authStore;
|
|
718
|
+
if (req.auth?.keyInfo?.accountId && typeof pgStore.pool !== 'undefined') {
|
|
719
|
+
pgStore.pool.query(`INSERT INTO usage_logs (user_id, endpoint, url, method, status_code, error, ip_address, user_agent)
|
|
720
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [req.auth.keyInfo.accountId, 'screenshot', req.body?.url, 'stealth', 500,
|
|
721
|
+
error.message || 'Unknown error', req.ip || req.socket.remoteAddress, req.get('user-agent')]).catch(() => { });
|
|
722
|
+
}
|
|
723
|
+
if (error.code) {
|
|
724
|
+
res.status(500).json({ success: false, error: { type: 'screenshot_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#screenshot_error' }, requestId: req.requestId });
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred while taking the screenshot', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
// ── POST /v1/screenshot/filmstrip — thin wrapper ───────────────────────────
|
|
732
|
+
router.post('/v1/screenshot/filmstrip', (req, res) => handleFilmstrip(req, res, authStore));
|
|
733
|
+
// ── POST /v1/screenshot/audit — thin wrapper ───────────────────────────────
|
|
734
|
+
router.post('/v1/screenshot/audit', (req, res) => handleAudit(req, res, authStore));
|
|
735
|
+
// ── POST /v1/screenshot/animation — DEPRECATED (410 Gone) ─────────────────
|
|
736
|
+
router.post('/v1/screenshot/animation', (req, res) => {
|
|
737
|
+
res.status(410).json({ success: false, error: { type: 'deprecated', message: "This endpoint has been deprecated. Use POST /v1/screenshot with mode='filmstrip' instead.", hint: "Use POST /v1/screenshot with { mode: 'filmstrip' } instead", docs: 'https://webpeel.dev/docs/errors#deprecated' }, requestId: req.requestId });
|
|
738
|
+
});
|
|
739
|
+
// ── POST /v1/screenshot/viewports — thin wrapper ───────────────────────────
|
|
740
|
+
router.post('/v1/screenshot/viewports', (req, res) => handleViewports(req, res, authStore));
|
|
741
|
+
// ── POST /v1/screenshot/design-audit — thin wrapper ───────────────────────
|
|
742
|
+
router.post('/v1/screenshot/design-audit', (req, res) => handleDesignAuditHandler(req, res, authStore));
|
|
743
|
+
// ── POST /v1/screenshot/design-analysis — thin wrapper ────────────────────
|
|
744
|
+
router.post('/v1/screenshot/design-analysis', (req, res) => handleDesignAnalysisHandler(req, res, authStore));
|
|
745
|
+
// ── POST /v1/screenshot/diff — thin wrapper ────────────────────────────────
|
|
746
|
+
router.post('/v1/screenshot/diff', (req, res) => handleDiff(req, res, authStore));
|
|
747
|
+
// ── POST /v1/review ────────────────────────────────────────────────────────
|
|
748
|
+
router.post('/v1/review', async (req, res) => {
|
|
749
|
+
try {
|
|
750
|
+
const ssUserId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
751
|
+
if (!ssUserId) {
|
|
752
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const { url, rules, selector } = req.body;
|
|
756
|
+
if (!validateRequestUrl(url, res))
|
|
757
|
+
return;
|
|
758
|
+
const startTime = Date.now();
|
|
759
|
+
const [viewportsResult, auditResult] = await Promise.all([
|
|
760
|
+
takeViewportsBatch(url, {
|
|
761
|
+
viewports: [
|
|
762
|
+
{ width: 375, height: 812, label: 'mobile' },
|
|
763
|
+
{ width: 768, height: 1024, label: 'tablet' },
|
|
764
|
+
{ width: 1440, height: 900, label: 'desktop' },
|
|
765
|
+
],
|
|
766
|
+
fullPage: false,
|
|
767
|
+
format: 'jpeg',
|
|
768
|
+
quality: 80,
|
|
769
|
+
timeout: 90000,
|
|
770
|
+
}),
|
|
771
|
+
takeDesignAudit(url, {
|
|
772
|
+
rules: typeof rules === 'object' ? rules : undefined,
|
|
773
|
+
selector: typeof selector === 'string' ? selector : undefined,
|
|
774
|
+
timeout: 60000,
|
|
775
|
+
}),
|
|
776
|
+
]);
|
|
777
|
+
const elapsed = Date.now() - startTime;
|
|
778
|
+
trackUsageAndLog(req, res, authStore, 'review', url, elapsed);
|
|
779
|
+
res.setHeader('X-Credits-Used', '4');
|
|
780
|
+
res.setHeader('X-Processing-Time', elapsed.toString());
|
|
781
|
+
res.setHeader('X-Fetch-Type', 'review');
|
|
782
|
+
res.json({
|
|
783
|
+
success: true,
|
|
784
|
+
data: {
|
|
785
|
+
url: viewportsResult.url,
|
|
786
|
+
viewports: viewportsResult.viewports,
|
|
787
|
+
audit: auditResult.audit,
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
if (error.code) {
|
|
793
|
+
res.status(500).json({ success: false, error: { type: 'review_error', message: error.message.replace(/[<>"']/g, ''), docs: 'https://webpeel.dev/docs/errors#review_error' }, requestId: req.requestId });
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
res.status(500).json({ success: false, error: { type: 'internal_error', message: 'An unexpected error occurred during review', docs: 'https://webpeel.dev/docs/errors#internal_error' }, requestId: req.requestId });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
// ── GET /v1/design-compare — thin wrapper (delegates to handleDesignCompare) ─
|
|
801
|
+
router.get('/v1/design-compare', async (req, res) => {
|
|
802
|
+
const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
|
|
803
|
+
if (!userId) {
|
|
804
|
+
res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'API key required.', hint: 'Get a free API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/authentication' } });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
// Validate ref query param (url is validated inside handleDesignCompare)
|
|
808
|
+
const { ref } = req.query;
|
|
809
|
+
if (!ref || typeof ref !== 'string') {
|
|
810
|
+
res.status(400).json({ success: false, error: { type: 'invalid_request', message: 'Missing required query parameter "ref"' }, requestId: req.requestId });
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
return handleDesignCompare(req, res, authStore);
|
|
814
|
+
});
|
|
815
|
+
return router;
|
|
816
|
+
}
|