├── .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 |

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 |
--------------------------------------------------------------------------------