├── .github └── FUNDING.yml ├── .gitignore ├── public ├── error-400.png ├── error-500.png ├── screenshot-pdf.png ├── screenshot-desktop.png └── screenshot-mobile.png ├── .prettierrc ├── Dockerfile ├── vercel.json ├── config └── index.js ├── validator.js ├── package.json ├── index.js ├── LICENSE ├── browser.js ├── README.md └── screenshot.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: fransallen -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | node_modules -------------------------------------------------------------------------------- /public/error-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticallyio/screenshot/HEAD/public/error-400.png -------------------------------------------------------------------------------- /public/error-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticallyio/screenshot/HEAD/public/error-500.png -------------------------------------------------------------------------------- /public/screenshot-pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticallyio/screenshot/HEAD/public/screenshot-pdf.png -------------------------------------------------------------------------------- /public/screenshot-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticallyio/screenshot/HEAD/public/screenshot-desktop.png -------------------------------------------------------------------------------- /public/screenshot-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staticallyio/screenshot/HEAD/public/screenshot-mobile.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /usr/src/app 4 | COPY . . 5 | 6 | RUN npm install --only=production 7 | EXPOSE 5000 8 | CMD [ "npm", "start" ] 9 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "regions": ["sin1"], 3 | "builds": [ 4 | { 5 | "src": "index.js", 6 | "use": "@now/node" 7 | } 8 | ], 9 | "routes": [{ "src": "/(.*)", "dest": "/index.js" }] 10 | } 11 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | // Public directory containing static files 7 | publicDir: path.join(__dirname, '..', 'public'), 8 | 9 | // Remote browser endpoint 10 | browserWSEndpoint: process.env.REMOTE_BROWSER || 'ws://localhost:3000', 11 | }; 12 | -------------------------------------------------------------------------------- /validator.js: -------------------------------------------------------------------------------- 1 | const { URL } = require('url'); 2 | 3 | function getInt(str) { 4 | return /[0-9]+/.test(str) ? parseInt(str) : undefined; 5 | } 6 | 7 | function isValidUrl(str) { 8 | try { 9 | const url = new URL(str); 10 | return url.hostname.includes('.'); 11 | } catch (e) { 12 | console.error(e.message); 13 | return false; 14 | } 15 | } 16 | 17 | module.exports = { getInt, isValidUrl }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screenshot", 3 | "description": "A simple screenshot API.", 4 | "private": true, 5 | "dependencies": { 6 | "etag": "1.8.1", 7 | "express": "4.17.1", 8 | "puppeteer-core": "10.1.0" 9 | }, 10 | "devDependencies": { 11 | "prettier": "1.18.2" 12 | }, 13 | "scripts": { 14 | "dev": "nodemon", 15 | "format": "prettier --write '**/*.{js,json,css}'", 16 | "start": "node index" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const express = require('express'); 6 | 7 | const { getMobile, getDesktop, getPdf } = require('./screenshot'); 8 | 9 | const app = express(); 10 | 11 | // Disable `X-Powered-By` header 12 | app.disable('x-powered-by'); 13 | 14 | app.get('/screenshot/pdf/*', getPdf); 15 | app.get('/screenshot/mobile/*', getMobile); 16 | app.get('/*', getDesktop); 17 | 18 | const port = process.env.PORT_SCREENSHOT || 5000; 19 | app.listen(port, () => 20 | console.log(`Screenshot listen on http://localhost:${port}`), 21 | ); 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Statically 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer-core'); 2 | const config = require('./config'); 3 | 4 | const disableTransitionDelayCSS = `*,::after,::before{transition-delay:0s!important;transition-duration:0s!important;animation-delay:-.1ms!important;animation-duration:0s!important;animation-play-state:paused!important;caret-color:transparent!important;color-adjust:exact!important}`; 5 | 6 | /* Set browser for desktop */ 7 | async function getScreenshot(url, type, quality, fullPage) { 8 | const browser = await puppeteer.connect({ 9 | browserWSEndpoint: config.browserWSEndpoint, 10 | }); 11 | const page = await browser.newPage(); 12 | 13 | await page.emulate({ 14 | userAgent: 15 | 'Mozilla/5.0 (Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36 Statically-Screenshot/1.0 (+https://statically.io/screenshot/)', 16 | viewport: { 17 | width: 1280, 18 | height: 960, 19 | deviceScaleFactor: 1, 20 | isMobile: false, 21 | hasTouch: false, 22 | isLandscape: true, 23 | }, 24 | }); 25 | 26 | await page.goto(url /*{ waitUntil: 'networkidle0' }*/); 27 | await page.addStyleTag({ content: disableTransitionDelayCSS }); 28 | 29 | const file = await page.screenshot({ type, quality, fullPage }); 30 | await browser.close(); 31 | console.log('HTTP ' + url); 32 | return file; 33 | } 34 | 35 | /* Set browser for mobile */ 36 | async function getScreenshotMobile(url, type, quality, fullPage) { 37 | const browser = await puppeteer.connect({ 38 | browserWSEndpoint: config.browserWSEndpoint, 39 | }); 40 | const page = await browser.newPage(); 41 | 42 | await page.emulate({ 43 | userAgent: 44 | 'Mozilla/5.0 (Linux Android 5.0 SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Mobile Safari/537.36 Statically-Screenshot-Mobile/1.0 (+https://statically.io/screenshot/)', 45 | viewport: { 46 | width: 360, 47 | height: 640, 48 | deviceScaleFactor: 1, 49 | isMobile: true, 50 | hasTouch: true, 51 | isLandscape: false, 52 | }, 53 | }); 54 | 55 | await page.goto(url /*{ waitUntil: 'networkidle0' }*/); 56 | await page.addStyleTag({ content: disableTransitionDelayCSS }); 57 | 58 | console.log('HTTP ' + url); 59 | const file = await page.screenshot({ type, quality, fullPage }); 60 | await browser.close(); 61 | return file; 62 | } 63 | 64 | /* Set browser for PDF */ 65 | async function generatePdf(url) { 66 | const browser = await puppeteer.connect({ 67 | browserWSEndpoint: config.browserWSEndpoint, 68 | }); 69 | const page = await browser.newPage(); 70 | 71 | await page.emulate({ 72 | userAgent: 73 | 'Mozilla/5.0 (Linux Android 5.0 SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Mobile Safari/537.36 Statically-Screenshot-PDF/1.0 (+https://statically.io/screenshot/)', 74 | viewport: { 75 | width: 360, 76 | height: 640, 77 | deviceScaleFactor: 1, 78 | isMobile: true, 79 | hasTouch: true, 80 | isLandscape: false, 81 | }, 82 | }); 83 | 84 | await page.goto(url /*{ waitUntil: 'networkidle0' }*/); 85 | const file = await page.pdf({ format: 'A4' }); 86 | await browser.close(); 87 | console.log('HTTP ' + url); 88 | return file; 89 | } 90 | 91 | module.exports = { getScreenshot, getScreenshotMobile, generatePdf }; 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Screenshot 4 | 5 |

6 | 7 |

Screenshot

8 | 9 |

Automagically converts URLs into images and PDFs.

10 | 11 |

12 | statically.io | 13 | Twitter | 14 | Community | 15 | Become A Backer 16 |

17 | 18 | Docker Cloud Build Status 19 | 20 | 21 | Docker Pulls 22 | 23 | 24 | Docker Image Size 25 | 26 |

27 | 28 | ## :sparkles: Overview 29 | 30 | **Screenshot** is a simple application to automagically convert URLs into images and PDFs. It's designed to be simple and easy to install anywhere. This application is split into two distinct components, the core service to process the URL and then send it to a remote browser (Chrome). The remote browser to screenshot and generate PDF from a URL. 31 | 32 | ## :bulb: Features 33 | 34 | - Screenshot website in desktop view. 35 | - Screenshot website in mobile view. 36 | - Converts URL into PDF. 37 | 38 | ## :zap: Installation 39 | 40 | We'll be using Docker as an easy way to install. However since this is a Node.js application, you can run it with your current setup, if you choose this method, please see [config/index.js](config/index.js) to change the remote browser endpoint. 41 | 42 | ### Run the browser 43 | 44 | We'll be using the **browserless/chrome** Docker image to do most of this work. 45 | 46 | ```bash 47 | docker run -d \ 48 | --name chrome \ 49 | -e "ENABLE_DEBUGGER=false" \ 50 | -e "DISABLE_AUTO_SET_DOWNLOAD_BEHAVIOR=true" \ 51 | -e "DEFAULT_BLOCK_ADS=true" \ 52 | -p 3000:3000 \ 53 | browserless/chrome:latest 54 | ``` 55 | 56 | To see more options, you can check [full documentation](https://docs.browserless.io/docs/docker.html). 57 | 58 | ### Run the app 59 | 60 | ```bash 61 | docker run -d \ 62 | --name screenshot \ 63 | -e "REMOTE_BROWSER=ws://172.17.0.1:3000" \ 64 | -p 5000:5000 \ 65 | statically/screenshot:latest 66 | ``` 67 | 68 | Replace the `REMOTE_BROWSER` variable with the remote browser endpoint that you set above. In this example we are using Docker's internal IP address, it should work if you are running both components on one machine. 69 | 70 | ## :fire: Fire it up 71 | 72 | The application avalaible through `/screenshot/` path. 73 | 74 | - Visit http://localhost:5000/screenshot/github.com for desktop view. 75 | 76 | ![Screenshot Desktop](https://cdn.statically.io/gh/staticallyio/screenshot/master/public/screenshot-desktop.png) 77 | 78 | - Visit http://localhost:5000/screenshot/mobile/github.com for mobile view. 79 | 80 | ![Screenshot Mobile](https://cdn.statically.io/gh/staticallyio/screenshot/master/public/screenshot-mobile.png) 81 | 82 | - Visit http://localhost:5000/screenshot/pdf/news.ycombinator.com for PDF. 83 | 84 | ![Screenshot PDF](https://cdn.statically.io/gh/staticallyio/screenshot/master/public/screenshot-pdf.png) -------------------------------------------------------------------------------- /screenshot.js: -------------------------------------------------------------------------------- 1 | const etag = require('etag'); 2 | const config = require('./config'); 3 | const { parse } = require('url'); 4 | const { 5 | getScreenshot, 6 | getScreenshotMobile, 7 | generatePdf, 8 | } = require('./browser'); 9 | const { getInt, isValidUrl } = require('./validator'); 10 | 11 | /* Get desktop view */ 12 | async function getDesktop(req, res) { 13 | try { 14 | const { pathname = '/', query = {} } = parse(req.url, true); 15 | const { type = 'png', quality, fullPage } = query; 16 | const base = '/screenshot/'; 17 | const target = pathname.replace(base, ''); 18 | const url = 'https://' + target; 19 | const qual = getInt(quality); 20 | if (!isValidUrl(url)) { 21 | res.statusCode = 200; 22 | res.setHeader('Content-Type', 'image/png'); 23 | res.setHeader('Cache-Control', 'public, max-age=5'); 24 | res.sendFile(config.publicDir + '/error-400.png'); 25 | } else { 26 | const file = await getScreenshot(url, type, qual, fullPage); 27 | const filename = 'statically_' + target + `.${type}`; 28 | res.statusCode = 200; 29 | res.setHeader('Content-Disposition', `filename="` + filename + `"`); 30 | res.setHeader('Content-Type', `image/${type}`); 31 | res.setHeader('Cache-Control', 'public, max-age=2678400, immutable'); // 1 month CDN cache to save resources 32 | res.setHeader('ETag', etag(file)); 33 | res.end(file); 34 | } 35 | } catch (e) { 36 | res.statusCode = 200; 37 | res.setHeader('Content-Type', 'image/png'); 38 | res.setHeader('Cache-Control', 'public, max-age=5'); 39 | res.sendFile(config.publicDir + '/error-500.png'); 40 | console.error(e.message); 41 | } 42 | } 43 | 44 | /* Get mobile view */ 45 | async function getMobile(req, res) { 46 | try { 47 | const { pathname = '/', query = {} } = parse(req.url, true); 48 | const { type = 'png', quality, fullPage } = query; 49 | const base = '/screenshot/mobile/'; 50 | const target = pathname.replace(base, ''); 51 | const url = 'https://' + target; 52 | const qual = getInt(quality); 53 | if (!isValidUrl(url)) { 54 | res.statusCode = 200; 55 | res.setHeader('Content-Type', 'image/png'); 56 | res.setHeader('Cache-Control', 'public, max-age=5'); 57 | res.sendFile(config.publicDir + '/error-400.png'); 58 | } else { 59 | const file = await getScreenshotMobile(url, type, qual, fullPage); 60 | const filename = 'statically_' + target + `.${type}`; 61 | res.statusCode = 200; 62 | res.setHeader('Content-Disposition', `filename="` + filename + `"`); 63 | res.setHeader('Content-Type', `image/${type}`); 64 | res.setHeader('Cache-Control', 'public, max-age=2678400, immutable'); // 1 month CDN cache to save resources 65 | res.setHeader('ETag', etag(file)); 66 | res.end(file); 67 | } 68 | } catch (e) { 69 | res.statusCode = 200; 70 | res.setHeader('Content-Type', 'image/png'); 71 | res.setHeader('Cache-Control', 'public, max-age=5'); 72 | res.sendFile(config.publicDir + '/error-500.png'); 73 | console.error(e.message); 74 | } 75 | } 76 | 77 | /* Get PDF */ 78 | async function getPdf(req, res) { 79 | try { 80 | const base = '/screenshot/pdf/'; 81 | const target = req.url.replace(base, ''); 82 | const url = 'https://' + target; 83 | if (!isValidUrl(url)) { 84 | res.statusCode = 200; 85 | res.setHeader('Content-Type', 'image/png'); 86 | res.setHeader('Cache-Control', 'public, max-age=5'); 87 | res.sendFile(config.publicDir + '/error-400.png'); 88 | } else { 89 | const file = await generatePdf(url); 90 | const filename = 'statically_' + target + '.pdf'; 91 | res.statusCode = 200; 92 | res.setHeader('Content-Disposition', `filename="` + filename + `"`); 93 | res.setHeader('Content-Type', 'application/pdf'); 94 | res.setHeader('Cache-Control', 'public, max-age=2678400, immutable'); // 1 month CDN cache to save resources 95 | res.setHeader('ETag', etag(file)); 96 | res.end(file); 97 | } 98 | } catch (e) { 99 | res.statusCode = 200; 100 | res.setHeader('Content-Type', 'image/png'); 101 | res.setHeader('Cache-Control', 'public, max-age=5'); 102 | res.sendFile(config.publicDir + '/error-500.png'); 103 | console.error(e.message); 104 | } 105 | } 106 | 107 | module.exports = { getDesktop, getMobile, getPdf }; 108 | --------------------------------------------------------------------------------