├── .gitignore ├── README.md ├── api └── screenshot.js ├── package.json ├── screenshot-options.js ├── screenshot.js ├── test └── sample.js └── vercel.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | tmp 4 | .vercel 5 | .cache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

11ty Logo

2 | 3 | # Screenshot API 4 | 5 | A runtime service to use live website screenshots on your site. 6 | 7 | Read the [Blog post: Building an Automated Screenshot Service on Netlify in ~140 Lines of Code](https://www.zachleat.com/web/screenshots/). 8 | 9 | ## Usage 10 | 11 | Image URLs have the formats: 12 | 13 | ``` 14 | /:url/ 15 | /:url/:size/ 16 | /:url/:size/:aspectratio/ 17 | /:url/:size/:aspectratio/:zoom/ 18 | ``` 19 | 20 | * `url` must be URI encoded. 21 | * Valid `size` values: 22 | * `small`: 375×___ (default) 23 | * `medium`: 650×___ 24 | * `large`: 1024×___ 25 | * `aspectratio` of `9:16` is not supported (throws an error) 26 | * `opengraph`: always 1200×630, works with `zoom` 27 | * `aspectratio` is ignored (no errors thrown) 28 | * Valid `aspectratio` values: 29 | * `1:1` (default) 30 | * `9:16` 31 | * Valid `zoom` values: 32 | * `bigger` (1.4 `devicePixelRatio`) 33 | * `smaller` (0.71 `devicePixelRatio`) 34 | 35 | ### Advanced Options 36 | 37 | #### Custom Wait Conditions 38 | 39 | You can customize the conditions with which the headless browser will wait to take the screenshot. At a low level, this controls the [`waitUntil` property in Puppeteer’s `goto` call](https://pptr.dev/#?product=Puppeteer&version=v13.3.1&show=api-pagegotourl-options). The options are: 40 | 41 | * DOMContentLoaded `wait:0` 42 | * Load event `wait:1` (default) 43 | * Load event and there have been no network connections for 500ms: `wait:2` 44 | * Load event and there are fewer than two network connections for 500ms: `wait:3` 45 | 46 | ``` 47 | /:url/_wait:0/ 48 | /:url/_wait:1/ 49 | /:url/_wait:2/ 50 | /:url/_wait:3/ 51 | ``` 52 | 53 | #### Custom Timeout 54 | 55 | Number of seconds to wait before the request times out. We will attempt to simulate the stop button and return the screenshot that exists up to that point. Worst case, a default Eleventy logo is returned. 56 | 57 | * Minimum: `3` 58 | * Maximum: `9` 59 | 60 | ``` 61 | /:url/_timeout:3/ 62 | /:url/_timeout:9/ 63 | ``` 64 | 65 | #### Combine these options 66 | 67 | You can use any of these advanced options together, like `/:url/_wait:0_timeout:2/`. Order only matters to the uniqueness of the URL caching on the CDN: `/:url/_wait:0/` and `/:url/_wait:0/` will be functionally equivalent but make two different screenshot requests. 68 | -------------------------------------------------------------------------------- /api/screenshot.js: -------------------------------------------------------------------------------- 1 | import screenshot from "../screenshot.js"; 2 | import screenshotOptions from "../screenshot-options.js"; 3 | 4 | const ONE_MINUTE = 60; 5 | const ONE_HOUR = ONE_MINUTE*60; 6 | const ONE_DAY = ONE_HOUR*24; 7 | const ONE_WEEK = ONE_DAY*7; 8 | const ONE_YEAR = ONE_DAY*365; // maximum s-maxage 9 | 10 | const VALID_UNDERSCORE_OPTIONS = ["timeout", "wait"]; 11 | const VALID_PARAMS = ["url", "size", "ratio", "zoom", "options"]; 12 | 13 | function isFullUrl(url) { 14 | try { 15 | new URL(url); 16 | return true; 17 | } catch(e) { 18 | // invalid url OR local path 19 | return false; 20 | } 21 | } 22 | 23 | function getBlankImageResponse(width, height, errorMessage) { 24 | return new Response(``, { 25 | // We need to return 200 here or Firefox won’t display the image 26 | // HOWEVER a 200 means that if it times out on the first attempt it will stay the default image until the next build. 27 | status: 200, 28 | headers: { 29 | "content-type": "image/svg+xml", 30 | "x-11ty-error-message": errorMessage, 31 | // HOWEVER HOWEVER, we can set a ttl of 3600 which means that the image will be re-requested in an hour. 32 | "cache-control": `public, s-maxage=${ONE_DAY}, stale-while-revalidate=${ONE_HOUR}` 33 | } 34 | }); 35 | } 36 | 37 | function getRedirectUrl(url, size, aspectratio, zoom, pathOptions = {}) { 38 | let optionsStr = Object.entries(pathOptions).map(([key, value]) => `_${key}:${value}`).join(""); 39 | return "/" + [url, size, aspectratio, zoom, optionsStr].filter(Boolean).join("/") + "/"; 40 | } 41 | 42 | export async function GET(request, context) { 43 | // e.g. /https%3A%2F%2Fwww.11ty.dev%2F/small/1:1/smaller/ 44 | let requestUrl = new URL(request.url); 45 | let pathSplit = requestUrl.pathname.split("/").filter(entry => !!entry); 46 | let [url, size, aspectratio, zoom, optionsString] = pathSplit; 47 | let viewport = []; 48 | let forceDedupeRedirect = false; 49 | let hasInvalidQueryParams = false; 50 | 51 | // Manage your own frequency by using a _ prefix and then a hash buster string after your URL 52 | // e.g. /https%3A%2F%2Fwww.11ty.dev%2F/_20210802/ and set this to today’s date when you deploy 53 | if(size && size.startsWith("_")) { 54 | optionsString = size; 55 | size = undefined; 56 | } 57 | if(aspectratio && aspectratio.startsWith("_")) { 58 | optionsString = aspectratio; 59 | aspectratio = undefined; 60 | } 61 | if(zoom && zoom.startsWith("_")) { 62 | optionsString = zoom; 63 | zoom = undefined; 64 | } 65 | 66 | // Options 67 | let pathOptions = {}; 68 | let optionsMatch = (optionsString || "").split("_").filter(entry => !!entry); 69 | for(let o of optionsMatch) { 70 | let [key, value] = o.split(":"); 71 | if(!VALID_UNDERSCORE_OPTIONS.includes(key)) { 72 | // don’t add to pathOptions 73 | console.log( "Invalid underscore option key", key, value ); 74 | forceDedupeRedirect = true; 75 | } else { 76 | pathOptions[key.toLowerCase()] = parseInt(value, 10); 77 | } 78 | } 79 | 80 | for(let [key, value] of requestUrl.searchParams.entries()) { 81 | if(!VALID_PARAMS.includes(key)) { 82 | console.log( "Invalid query param", key, value ); 83 | forceDedupeRedirect = true; 84 | hasInvalidQueryParams = true; 85 | } 86 | } 87 | 88 | let wait = ["load"]; 89 | if(pathOptions.wait === 0) { 90 | wait = ["domcontentloaded"]; 91 | } else if(!pathOptions.wait || pathOptions.wait === 1) { 92 | wait = ["load"]; 93 | } else if(pathOptions.wait === 2) { 94 | wait = ["load", "networkidle0"]; 95 | } else if(pathOptions.wait === 3) { 96 | wait = ["load", "networkidle2"]; 97 | } else { 98 | console.log( "Invalid wait value", pathOptions.wait ); 99 | delete pathOptions.wait; 100 | forceDedupeRedirect = true; 101 | } 102 | 103 | let timeout; 104 | if(pathOptions.timeout) { 105 | timeout = pathOptions.timeout * 1000; 106 | } 107 | 108 | let dpr; 109 | if(zoom === "bigger") { 110 | dpr = 1.4; 111 | } else if(zoom === "smaller") { 112 | dpr = 0.71428571; 113 | } else if(!zoom || zoom === "standard") { 114 | dpr = 1; 115 | } else { 116 | console.log( "Invalid zoom", zoom ); 117 | zoom = undefined; 118 | forceDedupeRedirect = true; 119 | } 120 | 121 | if(!size || size === "small") { 122 | if(!aspectratio || aspectratio === "1:1") { 123 | viewport = [375, 375]; 124 | } else if(aspectratio === "9:16") { 125 | viewport = [375, 667]; 126 | } else { 127 | console.log( "Invalid aspect ratio for small size", aspectratio ); 128 | aspectratio = undefined; 129 | forceDedupeRedirect = true; 130 | } 131 | } else if(size === "medium") { 132 | if(!aspectratio || aspectratio === "1:1") { 133 | viewport = [650, 650]; 134 | } else if(aspectratio === "9:16") { 135 | viewport = [650, 1156]; 136 | } else { 137 | console.log( "Invalid aspect ratio for medium size", aspectratio ); 138 | aspectratio = undefined; 139 | forceDedupeRedirect = true; 140 | } 141 | } else if(size === "large") { 142 | // 0.5625 aspect ratio not supported on large 143 | if(!aspectratio || aspectratio === "1:1") { 144 | viewport = [1024, 1024]; 145 | } else { 146 | console.log( "Invalid aspect ratio for large size", aspectratio ); 147 | aspectratio = undefined; 148 | forceDedupeRedirect = true; 149 | } 150 | } else if(size === "opengraph") { 151 | // de-dupe to ignore aspectratio 152 | if(aspectratio) { 153 | // do nothing 154 | console.log( "Ignoring aspect ratio for opengraph size", aspectratio ); 155 | aspectratio = undefined; 156 | } 157 | 158 | // always maintain a 1200×630 output image 159 | if(zoom === "bigger") { // dpr = 1.4 160 | viewport = [857, 450]; 161 | } else if(zoom === "smaller") { // dpr = 0.714 162 | viewport = [1680, 882]; 163 | } else if(!zoom) { 164 | viewport = [1200, 630]; 165 | } else { 166 | console.log( "Invalid zoom for opengraph size", zoom ); 167 | aspectratio = undefined; 168 | zoom = undefined; 169 | forceDedupeRedirect = true; 170 | } 171 | } else { 172 | console.log( "Invalid size", size ); 173 | size = undefined; 174 | forceDedupeRedirect = true; 175 | } 176 | 177 | if(forceDedupeRedirect) { 178 | let redirectUrl = getRedirectUrl(url, size, aspectratio, zoom, pathOptions); 179 | if(requestUrl.pathname !== redirectUrl || hasInvalidQueryParams) { 180 | console.log( "Hard de-dupe redirect from", requestUrl.pathname, "to", redirectUrl ); 181 | return new Response(null, { 182 | status: 302, 183 | headers: { 184 | "location": redirectUrl, 185 | } 186 | }) 187 | } 188 | } 189 | 190 | url = decodeURIComponent(url); 191 | 192 | try { 193 | if(!isFullUrl(url)) { 194 | throw new Error(`Invalid \`url\`: ${url}`); 195 | } 196 | 197 | if(!viewport || viewport.length !== 2) { 198 | throw new Error("Incorrect API usage. Expects one of: /:url/ or /:url/:size/ or /:url/:size/:aspectratio/") 199 | } 200 | 201 | let deniedHostnames = screenshotOptions?.deniedHostnames || []; 202 | for(let deniedHost of deniedHostnames) { 203 | if((new URL(url)).hostname === deniedHost) { 204 | console.log( "Denied request to", url ); 205 | return getBlankImageResponse(viewport[0], viewport[1], "Too expensive"); 206 | } 207 | } 208 | 209 | let startTime = Date.now(); 210 | let format = "jpeg"; // hardcoded for now, but png and webp are supported! 211 | let withJs = true; 212 | let output = await screenshot(url, { 213 | format, 214 | viewport, 215 | dpr, 216 | wait, 217 | timeout, 218 | withJs, 219 | }); 220 | 221 | // output to Function logs 222 | console.log("Success:", url, `${Date.now() - startTime}ms`, JSON.stringify({ viewport, size, dpr, aspectratio, withJs })); 223 | 224 | return new Response(output, { 225 | status: 200, 226 | headers: { 227 | "content-type": `image/${format}`, 228 | "cache-control": `public, s-maxage=${ONE_YEAR}, stale-while-revalidate=${ONE_WEEK}` 229 | } 230 | }); 231 | } catch (error) { 232 | console.log("Error from", requestUrl.pathname, error); 233 | 234 | let width = viewport?.[0] || 1200; 235 | let height = viewport?.[1] || 630; 236 | 237 | return getBlankImageResponse(width, height, error.message); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-api-screenshot", 3 | "version": "1.0.0", 4 | "description": "A service to add web page screenshots to your Eleventy sites.", 5 | "type": "module", 6 | "main": "screenshot.js", 7 | "scripts": { 8 | "start": "vercel dev" 9 | }, 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/11ty/api-screenshot.git" 14 | }, 15 | "author": { 16 | "name": "Zach Leatherman", 17 | "email": "zachleatherman@gmail.com", 18 | "url": "https://zachleat.com/" 19 | }, 20 | "keywords": [], 21 | "bugs": { 22 | "url": "https://github.com/11ty/api-screenshot/issues" 23 | }, 24 | "homepage": "https://github.com/11ty/api-screenshot#readme", 25 | "dependencies": { 26 | "@sparticuz/chromium": "^126.0.0", 27 | "puppeteer-core": "^22.13.1" 28 | }, 29 | "devDependencies": { 30 | "vercel": "^35.0.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /screenshot-options.js: -------------------------------------------------------------------------------- 1 | export default { 2 | deniedHostnames: ["x2mint.vercel.app", "tiennhm.github.io"] 3 | };; -------------------------------------------------------------------------------- /screenshot.js: -------------------------------------------------------------------------------- 1 | import chromium from "@sparticuz/chromium"; 2 | import puppeteer from "puppeteer-core"; 3 | 4 | chromium.setHeadlessMode = true; 5 | 6 | chromium.setGraphicsMode = false; 7 | 8 | async function screenshot(url, options = {}) { 9 | let { format = "jpeg", viewport = [375, 375], dpr = 1, withJs = true, wait, timeout = 8000 } = options; 10 | 11 | // Must be between 500 and 8000 12 | timeout = Math.min(Math.max(timeout, 500), 8000); 13 | 14 | const browser = await puppeteer.launch({ 15 | executablePath: await chromium.executablePath(), 16 | args: chromium.args, 17 | defaultViewport: { 18 | width: viewport[0], 19 | height: viewport[1], 20 | deviceScaleFactor: parseFloat(dpr), 21 | }, 22 | headless: chromium.headless, 23 | ignoreHTTPSErrors: true, 24 | }); 25 | 26 | const page = await browser.newPage(); 27 | 28 | if(!withJs) { 29 | page.setJavaScriptEnabled(false); 30 | } 31 | 32 | let response = await Promise.race([ 33 | page.goto(url, { 34 | waitUntil: wait || ["load"], 35 | timeout, 36 | }), 37 | new Promise(resolve => { 38 | setTimeout(() => { 39 | resolve(false); // false is expected below 40 | }, Math.max(timeout, 0)); // we need time to execute the window.stop before the top level timeout hits 41 | }), 42 | ]); 43 | 44 | if(response === false) { // timed out, resolved false 45 | await page.evaluate(() => window.stop()); 46 | } 47 | 48 | // let statusCode = response.status(); 49 | // TODO handle 4xx/5xx status codes better 50 | 51 | let screenshotOptions = { 52 | type: format, 53 | encoding: "binary", 54 | fullPage: false, 55 | captureBeyondViewport: false, 56 | clip: { 57 | x: 0, 58 | y: 0, 59 | width: viewport[0], 60 | height: viewport[1], 61 | } 62 | }; 63 | 64 | if(format === "jpeg") { 65 | screenshotOptions.quality = 80; 66 | } 67 | 68 | let output = await page.screenshot(screenshotOptions); 69 | 70 | await browser.close(); 71 | 72 | return output; 73 | } 74 | 75 | export default screenshot; -------------------------------------------------------------------------------- /test/sample.js: -------------------------------------------------------------------------------- 1 | import screenshot from "../screenshot.js"; 2 | 3 | let before = Date.now() 4 | console.log( await screenshot("https://zachleat.com/"), `${Date.now() - before}ms` ); 5 | // v1.screenshot.11ty.dev/https%3A%2F%2Ftiennhm.github.io%2FOGGY%2F/opengraph/ 6 | // v1.screenshot.11ty.dev/https%3A%2F%2Ftiennhm.github.io%2FOGGY%2F/opengraph/smaller/_20240718/ -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/screenshot.js": { 4 | "includeFiles": "" 5 | } 6 | }, 7 | "rewrites": [ 8 | { "source": "/", "destination": "/api/screenshot/" }, 9 | { "source": "/:url/", "destination": "/api/screenshot/" }, 10 | { "source": "/:url/:size/", "destination": "/api/screenshot/" }, 11 | { "source": "/:url/:size/:ratio/", "destination": "/api/screenshot/" }, 12 | { "source": "/:url/:size/:ratio/:zoom/", "destination": "/api/screenshot/" }, 13 | { "source": "/:url/:size/:ratio/:zoom/:options/", "destination": "/api/screenshot/" } 14 | ], 15 | "trailingSlash": true 16 | } 17 | --------------------------------------------------------------------------------