├── .eslintrc.json ├── .github └── workflows │ └── browser.yml ├── .gitignore ├── README.md ├── __test__ └── browser.test.js ├── jsconfig.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── components │ │ ├── Spotlight.jsx │ │ └── github-icon.jsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.js │ ├── page.js │ └── try │ │ └── route.js └── utils │ ├── cfCheck.js │ ├── cn.js │ ├── preload.js │ └── utils.js └── tailwind.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/browser.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Changes 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build-app: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup Bun 17 | uses: oven-sh/setup-bun@v1 18 | with: 19 | bun-version: latest 20 | 21 | - name: Install packages 22 | run: bun i 23 | shell: bash 24 | - name: Create Screenshots Directory 25 | run: mkdir screenshots 26 | - name: Test 27 | run: bun run test 28 | shell: bash 29 | 30 | - name: Upload Browser Screenshots 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: browser-screenshots 34 | path: screenshots/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | bun.lockb -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Deploy puppeteer headless api in vercel

4 |
5 | 6 | ![image](https://github.com/hehehai/h-blog/assets/12692552/712824de-8b97-44f3-a402-bd213def7c63) 7 | 8 |
9 | 10 | ## Getting Started 11 | 12 | First, run the development server: 13 | 14 | ```bash 15 | npm install 16 | npm run dev 17 | ``` 18 | 19 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 20 | 21 | ## Features 22 | 23 | - Support webpage screenshots 24 | - Support cloudflare [nopecha](https://nopecha.com/demo) 25 | 26 | ![image](https://github.com/hehehai/h-blog/assets/12692552/bd4cc26c-2bd4-476f-a7ab-d2b5e3d0ae74) 27 | ![image](https://github.com/hehehai/h-blog/assets/12692552/02a65b4a-2f9b-421a-ba26-0f790b0951be) 28 | 29 | ## Refs 30 | 31 | - [在 Vercel 部署无头浏览器实现网页截图](https://www.hehehai.cn/posts/vercel-deploy-headless) 32 | -------------------------------------------------------------------------------- /__test__/browser.test.js: -------------------------------------------------------------------------------- 1 | const cfCheck = require('../src/utils/cfCheck.js'); 2 | const puppeteer = require("puppeteer-core"); 3 | const chromium = require("@sparticuz/chromium-min"); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const remoteExecutablePath = 7 | "https://github.com/Sparticuz/chromium/releases/download/v123.0.1/chromium-v123.0.1-pack.tar"; 8 | const userAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36"; 9 | const websites = [ 10 | "https://nopecha.com/demo/cloudflare" 11 | , "https://nowsecure.nl/" 12 | , "https://2captcha.com/demo/cloudflare-turnstile" 13 | , "https://infosimples.github.io/detect-headless/" 14 | , "https://arh.antoinevastel.com/bots/areyouheadless" 15 | , "https://bot.sannysoft.com/" 16 | , "https://hmaker.github.io/selenium-detector/" 17 | , "https://kaliiiiiiiiii.github.io/brotector/" 18 | , "https://fingerprintjs.github.io/BotD/main/" 19 | , "https://pixelscan.net/" 20 | ]; 21 | describe("Testing page navigation and title", () => { 22 | let browser; 23 | let page; 24 | 25 | beforeAll(async () => { 26 | browser = await puppeteer.launch({ 27 | ignoreDefaultArgs: ["--enable-automation"], 28 | args: [...chromium.args, "--disable-blink-features=AutomationControlled"], 29 | defaultViewport: { width: 1920, height: 1080 }, 30 | executablePath: await chromium.executablePath(remoteExecutablePath), 31 | headless: "new", 32 | }); 33 | const pages = await browser.pages(); 34 | page = pages[0]; 35 | await page.setUserAgent(userAgent); 36 | const preloadFile = fs.readFileSync(path.join(process.cwd(), '/src/utils/preload.js'), 'utf8'); 37 | await page.evaluateOnNewDocument(preloadFile); 38 | page.on('dialog', async dialog => { 39 | await dialog.accept(); 40 | }); 41 | }, 10000); 42 | afterAll(async () => { 43 | await browser.close(); 44 | }); 45 | 46 | it("should set up the browser", async () => { 47 | expect(browser).toBeDefined(); 48 | expect(page).toBeDefined(); 49 | }); 50 | websites.forEach((website, i) => { 51 | it(`should navigate to '${website}'`, async () => { 52 | try { 53 | await page.goto(website, { waitUntil: "networkidle2" }); 54 | await cfCheck(page); 55 | await page.screenshot({ path: `screenshots/example${i}.png` }); 56 | console.log(website + "\nTest passed!"); 57 | } catch (error) { 58 | console.error(website + "\nTest failed:", error); 59 | throw error; 60 | } 61 | 62 | }, 60000); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverComponentsExternalPackages: ['puppeteer-core', '@sparticuz/chromium-min'] 5 | }, 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headless-try", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@sparticuz/chromium-min": "^122.0.0", 14 | "clsx": "^2.1.1", 15 | "framer-motion": "^11.1.9", 16 | "geist": "^1.3.0", 17 | "mini-svg-data-uri": "^1.4.4", 18 | "next": "14.0.4", 19 | "puppeteer-core": "22.5.0", 20 | "react": "^18", 21 | "react-dom": "^18", 22 | "tailwind-merge": "^2.3.0" 23 | }, 24 | "devDependencies": { 25 | "autoprefixer": "^10.0.1", 26 | "eslint": "^8", 27 | "eslint-config-next": "14.0.4", 28 | "jest": "^29.7.0", 29 | "postcss": "^8", 30 | "tailwindcss": "^3.3.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/Spotlight.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { cn } from "@/utils/cn"; 3 | 4 | export const Spotlight = ({ className, fill }) => { 5 | return ( 6 | 15 | 16 | 25 | 26 | 27 | 36 | 37 | 43 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/app/components/github-icon.jsx: -------------------------------------------------------------------------------- 1 | 2 | export function LineMdGithubLoop(props) { 3 | return ( 4 | 5 | ) 6 | } 7 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hehehai/headless-try/66cfd6294ac93bb1e1d563955582e0af62add48e/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } -------------------------------------------------------------------------------- /src/app/layout.js: -------------------------------------------------------------------------------- 1 | import { GeistSans } from "geist/font/sans"; 2 | import "./globals.css"; 3 | 4 | export const metadata = { 5 | title: "Try screenshot", 6 | description: "Vercel functions run headless browser url screenshot", 7 | }; 8 | 9 | export default function RootLayout({ children }) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/page.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Spotlight } from "@/app/components/Spotlight"; 5 | import { LineMdGithubLoop } from "@/app/components/github-icon"; 6 | 7 | export default function Home() { 8 | const [imgUrl, setImgUrl] = useState(""); 9 | const [loading, setLoading] = useState(false); 10 | const [time, setTime] = useState(0); 11 | const [duration, setDuration] = useState(0); 12 | 13 | const handleSubmit = async (e) => { 14 | e.preventDefault(); 15 | 16 | const formData = new FormData(e.target); 17 | const url = formData.get("url"); 18 | if (!url) { 19 | return; 20 | } 21 | 22 | setDuration(0); 23 | setTime(0); 24 | const timePoint = Date.now(); 25 | const intervalTimer = startDuration(); 26 | try { 27 | setLoading(true); 28 | const res = await fetch(`/try?url=${url}`, { 29 | next: { revalidate: 10 }, 30 | }); 31 | const data = await res.blob(); 32 | setImgUrl(URL.createObjectURL(data)); 33 | } catch (error) { 34 | console.error(error); 35 | alert("Something went wrong"); 36 | } finally { 37 | // 秒 38 | setTime((Date.now() - timePoint) / 1000); 39 | intervalTimer && clearInterval(intervalTimer); 40 | setLoading(false); 41 | } 42 | }; 43 | 44 | function startDuration() { 45 | return setInterval(() => { 46 | setDuration((prev) => Number((prev + 0.2).toFixed(1))); 47 | }, 200); 48 | } 49 | 50 | return ( 51 |
52 | 56 |
57 |
58 |

59 | Try screenshot 60 |

61 |

62 | Thursday, May 9th 2024 Vercel Functions for Hobby can now run up to 63 | 60 seconds{" "} 64 | 68 | detail 69 | 70 |

71 |
72 |
73 |
74 | 78 |
79 | 86 | 111 |
112 |
113 |
114 | {imgUrl && ( 115 |
116 | screenshot 117 |
118 | )} 119 |
120 |
121 |
122 | 123 | 124 | 125 | 126 | Github 127 | 128 | 129 |
130 |
131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /src/app/try/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import cfCheck from "@/utils/cfCheck"; 5 | import { 6 | localExecutablePath, 7 | isDev, 8 | userAgent, 9 | remoteExecutablePath, 10 | } from "@/utils/utils"; 11 | 12 | export const maxDuration = 60; // This function can run for a maximum of 60 seconds (update by 2024-05-10) 13 | export const dynamic = "force-dynamic"; 14 | 15 | const chromium = require("@sparticuz/chromium-min"); 16 | const puppeteer = require("puppeteer-core"); 17 | 18 | export async function GET(request) { 19 | const url = new URL(request.url); 20 | const urlStr = url.searchParams.get("url"); 21 | if (!urlStr) { 22 | return NextResponse.json( 23 | { error: "Missing url parameter" }, 24 | { status: 400 } 25 | ); 26 | } 27 | 28 | let browser = null; 29 | try { 30 | browser = await puppeteer.launch({ 31 | ignoreDefaultArgs: ["--enable-automation"], 32 | args: isDev 33 | ? [ 34 | "--disable-blink-features=AutomationControlled", 35 | "--disable-features=site-per-process", 36 | "-disable-site-isolation-trials", 37 | ] 38 | : [...chromium.args, "--disable-blink-features=AutomationControlled"], 39 | defaultViewport: { width: 1920, height: 1080 }, 40 | executablePath: isDev 41 | ? localExecutablePath 42 | : await chromium.executablePath(remoteExecutablePath), 43 | headless: isDev ? false : "new", 44 | debuggingPort: isDev ? 9222 : undefined, 45 | }); 46 | 47 | const pages = await browser.pages(); 48 | const page = pages[0]; 49 | await page.setUserAgent(userAgent); 50 | await page.setViewport({ width: 1920, height: 1080 }); 51 | const preloadFile = fs.readFileSync( 52 | path.join(process.cwd(), "/src/utils/preload.js"), 53 | "utf8" 54 | ); 55 | await page.evaluateOnNewDocument(preloadFile); 56 | await page.goto(urlStr, { 57 | waitUntil: "networkidle2", 58 | timeout: 60000, 59 | }); 60 | await cfCheck(page); 61 | 62 | console.log("page title", await page.title()); 63 | const blob = await page.screenshot({ type: "png" }); 64 | 65 | const headers = new Headers(); 66 | 67 | headers.set("Content-Type", "image/png"); 68 | headers.set("Content-Length", blob.length.toString()); 69 | 70 | // or just use new Response ❗️ 71 | return new NextResponse(blob, { status: 200, statusText: "OK", headers }); 72 | } catch (err) { 73 | console.log(err); 74 | return NextResponse.json( 75 | { error: "Internal Server Error" }, 76 | { status: 500 } 77 | ); 78 | } finally { 79 | await browser.close(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/cfCheck.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cloudflare Check Tools: 3 | * https://nopecha.com/demo/cloudflare 4 | * https://nowsecure.nl/ 5 | * https://2captcha.com/demo/cloudflare-turnstile 6 | * 7 | * Browser Check Tools: 8 | * https://infosimples.github.io/detect-headless/ 9 | * https://arh.antoinevastel.com/bots/areyouheadless 10 | * https://bot.sannysoft.com/ 11 | * https://hmaker.github.io/selenium-detector/ 12 | * https://kaliiiiiiiiii.github.io/brotector/ 13 | */ 14 | 15 | async function cfCheck(page) { 16 | await page.waitForFunction("window._cf_chl_opt===undefined"); 17 | const frames = await page.frames(); 18 | 19 | for (const frame of frames) { 20 | const frameUrl = frame.url(); 21 | try { 22 | const domain = new URL(frameUrl).hostname; 23 | console.log(domain); 24 | if (domain === "challenges.cloudflare.com") { 25 | const id = await frame.evaluate( 26 | () => window._cf_chl_opt.chlApiWidgetId 27 | ); 28 | await page.waitForFunction( 29 | `document.getElementById("cf-chl-widget-${id}_response").value!==''` 30 | ); 31 | console.log( 32 | await page.evaluate( 33 | () => document.getElementById(`cf-chl-widget-${id}_response`).value 34 | ) 35 | ); 36 | 37 | console.log("CF is loaded."); 38 | } 39 | } catch (error) {} 40 | } 41 | } 42 | module.exports = cfCheck; 43 | -------------------------------------------------------------------------------- /src/utils/cn.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/preload.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const windowsPatch = (w) => { 3 | w.chrome = { 4 | app: { 5 | isInstalled: false, 6 | InstallState: { 7 | DISABLED: "disabled", 8 | INSTALLED: "installed", 9 | NOT_INSTALLED: "not_installed", 10 | }, 11 | RunningState: { 12 | CANNOT_RUN: "cannot_run", 13 | READY_TO_RUN: "ready_to_run", 14 | RUNNING: "running", 15 | }, 16 | }, 17 | loadTimes: () => {}, 18 | csi: () => {}, 19 | }; 20 | w.console.debug = () => {}; 21 | w.console.log = () => {}; 22 | w.console.context = () => {}; 23 | w.navigator.permissions.query = new Proxy(navigator.permissions.query, { 24 | apply: async function (target, thisArg, args) { 25 | try { 26 | const result = await Reflect.apply(target, thisArg, args); 27 | if (result?.state === "prompt") { 28 | Object.defineProperty(result, "state", { value: "denied" }); 29 | } 30 | return Promise.resolve(result); 31 | } catch (error) { 32 | return Promise.reject(error); 33 | } 34 | }, 35 | }); 36 | Element.prototype._addEventListener = Element.prototype.addEventListener; 37 | Element.prototype.addEventListener = function () { 38 | let args = [...arguments]; 39 | let temp = args[1]; 40 | args[1] = function () { 41 | let args2 = [...arguments]; 42 | args2[0] = Object.assign({}, args2[0]); 43 | args2[0].isTrusted = true; 44 | return temp(...args2); 45 | }; 46 | return this._addEventListener(...args); 47 | }; 48 | }; 49 | const cloudflareClicker = (w) => { 50 | if (w?.document && w.location.host === "challenges.cloudflare.com") { 51 | const targetSelector = "input[type=checkbox]"; 52 | const observer = new MutationObserver((mutationsList) => { 53 | for (const mutation of mutationsList) { 54 | if (mutation.type === "childList") { 55 | const addedNodes = Array.from(mutation.addedNodes); 56 | for (const addedNode of addedNodes) { 57 | if (addedNode.nodeType === addedNode.ELEMENT_NODE) { 58 | const node = addedNode?.querySelector(targetSelector); 59 | if (node) { 60 | node.parentElement.click(); 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }); 67 | 68 | const observerOptions = { 69 | childList: true, 70 | subtree: true, 71 | }; 72 | observer.observe( 73 | w.document.documentElement || w.document, 74 | observerOptions 75 | ); 76 | } 77 | }; 78 | windowsPatch(window); 79 | cloudflareClicker(window); 80 | })(); 81 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | export const localExecutablePath = 2 | process.platform === "win32" 3 | ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" 4 | : process.platform === "linux" 5 | ? "/usr/bin/google-chrome" 6 | : "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; 7 | export const remoteExecutablePath = 8 | "https://github.com/Sparticuz/chromium/releases/download/v123.0.1/chromium-v123.0.1-pack.tar"; 9 | 10 | export const isDev = process.env.NODE_ENV === "development"; 11 | 12 | export const userAgent = 13 | "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36"; 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const svgToDataUri = require("mini-svg-data-uri"); 4 | const { 5 | default: flattenColorPalette, 6 | } = require("tailwindcss/lib/util/flattenColorPalette"); 7 | 8 | module.exports = { 9 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ["var(--font-geist-sans)"], 14 | }, 15 | backgroundImage: { 16 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 17 | "gradient-conic": 18 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 19 | }, 20 | animation: { 21 | flip: "flip 6s infinite steps(2, end)", 22 | rotate: "rotate 3s linear infinite both", 23 | spotlight: "spotlight 2s ease .75s 1 forwards", 24 | }, 25 | keyframes: { 26 | flip: { 27 | to: { 28 | transform: "rotate(360deg)", 29 | }, 30 | }, 31 | rotate: { 32 | to: { 33 | transform: "rotate(90deg)", 34 | }, 35 | }, 36 | spotlight: { 37 | "0%": { 38 | opacity: 0, 39 | transform: "translate(-72%, -62%) scale(0.5)", 40 | }, 41 | "100%": { 42 | opacity: 1, 43 | transform: "translate(-50%,-40%) scale(1)", 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | plugins: [ 50 | addVariablesForColors, 51 | function ({ matchUtilities, theme }) { 52 | matchUtilities( 53 | { 54 | "bg-grid": (value) => ({ 55 | backgroundImage: `url("${svgToDataUri( 56 | `` 57 | )}")`, 58 | }), 59 | "bg-grid-small": (value) => ({ 60 | backgroundImage: `url("${svgToDataUri( 61 | `` 62 | )}")`, 63 | }), 64 | "bg-dot": (value) => ({ 65 | backgroundImage: `url("${svgToDataUri( 66 | `` 67 | )}")`, 68 | }), 69 | }, 70 | { values: flattenColorPalette(theme("backgroundColor")), type: "color" } 71 | ); 72 | }, 73 | ], 74 | }; 75 | 76 | function addVariablesForColors({ addBase, theme }) { 77 | let allColors = flattenColorPalette(theme("colors")); 78 | let newVars = Object.fromEntries( 79 | Object.entries(allColors).map(([key, val]) => [`--${key}`, val]) 80 | ); 81 | 82 | addBase({ 83 | ":root": newVars, 84 | }); 85 | } 86 | --------------------------------------------------------------------------------