├── .gitignore ├── src ├── index.ts ├── app.ts ├── schema.ts └── screenshots.ts ├── tsconfig.json ├── README.md ├── package.json └── Dockerfile /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | import app from "./app"; 3 | 4 | const port = 3000; 5 | console.log(`Server is running on port ${port}`); 6 | 7 | serve({ 8 | fetch: app.fetch, 9 | port, 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "strict": true, 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "types": ["node"], 8 | "outDir": "./build" 9 | }, 10 | "exclude": ["node_modules"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | learnshot 2 | ========= 3 | 4 | `learnshot` is a screenshot API built for fun and [sharing my experience](https://screenshotone.com/blog/building-screenshot-api/) of building [ScreenshotOne](https://screenshotone.com/). 5 | 6 | ## Play with it 7 | 8 | ``` 9 | npm install 10 | npm run dev 11 | ``` 12 | 13 | ``` 14 | open http://localhost:3000/screenshot?url=https://example.com 15 | ``` 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "tsx watch src/index.ts", 4 | "build": "tsc" 5 | }, 6 | "dependencies": { 7 | "@aws-sdk/client-s3": "^3.540.0", 8 | "@aws-sdk/lib-storage": "^3.540.0", 9 | "@cliqz/adblocker-puppeteer": "^1.26.16", 10 | "@hono/node-server": "^1.8.2", 11 | "@hono/zod-openapi": "^0.9.8", 12 | "aws-sdk": "^2.1584.0", 13 | "cross-fetch": "^4.0.0", 14 | "hono": "^4.1.3", 15 | "puppeteer": "^22.6.0", 16 | "zod": "^3.22.4" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20.11.17", 20 | "tsx": "^3.12.2", 21 | "typescript": "^5.4.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from "@hono/zod-openapi"; 2 | import { createRoute } from "@hono/zod-openapi"; 3 | 4 | import { ScreenshotOptionsSchema, ScreenshotResultSchema } from "./schema"; 5 | import { render } from "./screenshots"; 6 | 7 | const app = new OpenAPIHono(); 8 | 9 | app.openapi( 10 | createRoute({ 11 | method: "get", 12 | path: "/screenshot", 13 | request: { 14 | query: ScreenshotOptionsSchema, 15 | }, 16 | responses: { 17 | 200: { 18 | content: { 19 | "application/json": { 20 | schema: ScreenshotResultSchema, 21 | }, 22 | }, 23 | description: "Render a Screenshot", 24 | }, 25 | }, 26 | }), 27 | async (c) => { 28 | const screenshotOptions = c.req.valid("query"); 29 | 30 | const result = await render(screenshotOptions); 31 | 32 | return c.json({ screenshot_url: result.url }); 33 | } 34 | ); 35 | 36 | app.doc("/openapi.json", { 37 | openapi: "3.0.0", 38 | info: { 39 | version: "1.0.0", 40 | title: "The Screenshot API", 41 | }, 42 | }); 43 | 44 | export default app; 45 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "@hono/zod-openapi"; 2 | 3 | export const ScreenshotOptionsSchema = z.object({ 4 | url: z.string().url().openapi({ 5 | description: "A website URL.", 6 | example: "https://example.com", 7 | }), 8 | block_cookie_canners: z.boolean().default(true).openapi({ 9 | description: "Render clean screenshots.", 10 | example: false, 11 | }), 12 | viewport_width: z.coerce.number().int().min(1).default(1920).openapi({ 13 | description: "Change the viewport width.", 14 | example: 1920, 15 | }), 16 | viewport_height: z.coerce.number().int().min(1).default(1080).openapi({ 17 | description: "Change the viewport height.", 18 | example: 600, 19 | }), 20 | device_scale_factor: z.coerce.number().int().min(1).default(1).openapi({ 21 | description: "Change the device scale factor.", 22 | example: 2, 23 | }), 24 | full_page: z.boolean().default(false).openapi({ 25 | description: "Render the full page screenshot.", 26 | example: false, 27 | }), 28 | }); 29 | 30 | export type ScreenshotOptions = z.infer; 31 | 32 | export const ScreenshotResultSchema = z 33 | .object({ 34 | screenshot_url: z.string().url(), 35 | }) 36 | .openapi("Screenshot"); 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20@sha256:cb7cd40ba6483f37f791e1aace576df449fc5f75332c19ff59e2c6064797160e 2 | 3 | # Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others) 4 | # Note: this installs the necessary libs to make the bundled version of Chrome that Puppeteer 5 | # installs, work. 6 | RUN apt-get update \ 7 | && apt-get install -y wget gnupg \ 8 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg \ 9 | && sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 10 | && apt-get update \ 11 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-freefont-ttf libxss1 dbus dbus-x11 \ 12 | --no-install-recommends \ 13 | && service dbus start \ 14 | && rm -rf /var/lib/apt/lists/* \ 15 | && groupadd -r apiuser && useradd -rm -g apiuser -G audio,video apiuser 16 | 17 | USER apiuser 18 | 19 | WORKDIR /home/apiuser/app 20 | 21 | COPY --chown=apiuser:apiuser package*json tsconfig.json ./ 22 | COPY --chown=apiuser:apiuser src ./src 23 | 24 | RUN npm ci && \ 25 | npm run build && \ 26 | npm prune --production 27 | 28 | CMD ["node", "/home/apiuser/app/build/index.js"] -------------------------------------------------------------------------------- /src/screenshots.ts: -------------------------------------------------------------------------------- 1 | import { ScreenshotOptions } from "./schema"; 2 | 3 | import puppeteer, { Page } from "puppeteer"; 4 | import { PuppeteerBlocker } from "@cliqz/adblocker-puppeteer"; 5 | import fetch from "cross-fetch"; 6 | 7 | import { 8 | CompleteMultipartUploadCommandOutput, 9 | S3Client, 10 | S3ClientConfig, 11 | } from "@aws-sdk/client-s3"; 12 | import { Upload } from "@aws-sdk/lib-storage"; 13 | 14 | let blocker: PuppeteerBlocker | null = null; 15 | async function blockCookieBanners(page: Page) { 16 | if (!blocker) { 17 | blocker = await PuppeteerBlocker.fromLists(fetch, [ 18 | // the list of the cookie banners to block from the https://easylist.to/ website 19 | "https://secure.fanboy.co.nz/fanboy-cookiemonster.txt", 20 | ]); 21 | } 22 | 23 | await blocker.enableBlockingInPage(page); 24 | } 25 | 26 | async function scroll(page: Page) { 27 | return await page.evaluate(async () => { 28 | return await new Promise((resolve, reject) => { 29 | var i = setInterval(() => { 30 | window.scrollBy(0, window.innerHeight); 31 | if ( 32 | document.scrollingElement && 33 | document.scrollingElement.scrollTop + window.innerHeight >= 34 | document.scrollingElement.scrollHeight 35 | ) { 36 | window.scrollTo(0, 0); 37 | clearInterval(i); 38 | resolve(null); 39 | } 40 | }, 100); 41 | }); 42 | }); 43 | } 44 | 45 | const cfg: S3ClientConfig = { 46 | region: process.env.S3_REGION ?? "us-west-2", 47 | maxAttempts: 5, 48 | retryMode: "standard", 49 | credentials: { 50 | accessKeyId: process.env.ACCESS_KEY_ID ?? "test", 51 | secretAccessKey: process.env.SECRET_ACCESS_KEY ?? "test", 52 | }, 53 | endpoint: process.env.S3_ENDPOINT ?? "http://localhost:4566", 54 | forcePathStyle: true, 55 | }; 56 | 57 | const client = new S3Client(cfg); 58 | 59 | export async function uploadToS3Storage( 60 | screenshot: Buffer, 61 | key: string, 62 | contentType: string 63 | ) { 64 | const bucket = "screenshots"; 65 | 66 | const upload = new Upload({ 67 | client: client, 68 | params: { 69 | Bucket: bucket, 70 | Key: key, 71 | Body: screenshot, 72 | ContentType: contentType, 73 | StorageClass: "STANDARD", 74 | }, 75 | queueSize: 4, 76 | partSize: 1024 * 1024 * 5, 77 | leavePartsOnError: false, 78 | }); 79 | 80 | const result: CompleteMultipartUploadCommandOutput = await upload.done(); 81 | if (!result.Location) { 82 | throw new Error("Failed to upload"); 83 | } 84 | 85 | return result.Location; 86 | } 87 | 88 | export async function render( 89 | options: ScreenshotOptions 90 | ): Promise<{ url: string }> { 91 | const browser = await puppeteer.launch({ 92 | args: [ 93 | "--disable-setuid-sandbox", 94 | "--disable-dev-shm-usage", 95 | "--disable-accelerated-2d-canvas", 96 | "--no-first-run", 97 | "--single-process", 98 | "--disable-gpu", 99 | ], 100 | headless: true, 101 | }); 102 | 103 | const page = await browser.newPage(); 104 | 105 | if (options.block_cookie_canners) { 106 | await blockCookieBanners(page); 107 | } 108 | 109 | await page.setViewport({ 110 | width: options.viewport_width, 111 | height: options.viewport_height, 112 | deviceScaleFactor: options.device_scale_factor, 113 | }); 114 | 115 | await page.goto(options.url); 116 | 117 | if (options.full_page) { 118 | await scroll(page); 119 | } 120 | 121 | const screenshot = await page.screenshot({ 122 | type: "jpeg", 123 | encoding: "binary", 124 | fullPage: options.full_page, 125 | }); 126 | 127 | await browser.close(); 128 | 129 | const location = await uploadToS3Storage( 130 | screenshot, 131 | "example.jpeg", 132 | "image/jpeg" 133 | ); 134 | 135 | return { url: location }; 136 | } 137 | --------------------------------------------------------------------------------