├── .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 | 
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 | 
27 | 
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 |
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 |
114 | {imgUrl && (
115 |
116 |

117 |
118 | )}
119 |
120 |
121 |
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 |
--------------------------------------------------------------------------------