Extract a brand kit from any website·续

var entries = Object.keys(scored).map(function (h) { return { hex: h, score: scored[h].score, c: scored[h].c }; }); entries.sort(function (a, b) { return b.score - a.score; }); var picked = []; for (var e = 0; e < entries.length && picked.length < 5; e++) { var ok = true; for (var p = 0; p < picked.length; p++) { if (dist(entries[e].c, picked[p].c) < 40) { ok = false; break; } } if (ok) picked.push(entries[e]); } var roles = ['primary', 'secondary', 'accent', 'color-4', 'color-5']; var colors = picked.map(function (c, idx) { return { name: roles[idx], hex: c.hex }; });

// ---- color scheme ---- var bgEl = parseColor(getComputedStyle(document.body).backgroundColor); var colorScheme = (bgEl && lightness(bgEl) < 0.45) ? 'dark' : 'light';

// ---- fonts: headings, body, mono ---- var headingEl = document.querySelector('h1, h2, h3'); var headingFont = headingEl ? cleanFont(getComputedStyle(headingEl).fontFamily) : null; var bodyFont = cleanFont(getComputedStyle(document.body).fontFamily); var monoEl = document.querySelector('code, pre, kbd'); var monoFont = monoEl ? cleanFont(getComputedStyle(monoEl).fontFamily) : null; var fonts = []; if (headingFont) fonts.push({ name: headingFont, usage: 'headings' }); if (bodyFont && bodyFont.toLowerCase() !== (headingFont || '').toLowerCase()) fonts.push({ name: bodyFont, usage: 'body' }); if (monoFont && monoFont.toLowerCase() !== (bodyFont || '').toLowerCase()) fonts.push({ name: monoFont, usage: 'mono' });

// ---- logo: inline SVG (as a data URI) → <img> logo → declared icon → Open Graph image ---- function serializeSvg(svg) { try { var clone = svg.cloneNode(true); if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); var str = new XMLSerializer().serializeToString(clone); if (str.length > 30000) return null; return 'data:image/svg+xml,' + encodeURIComponent(str); } catch (e) { return null; } } function pickLogo() { var svgSels = ['a[href="/"] svg', '[class="logo" i] svg', 'header svg', 'nav svg']; for (var s = 0; s < svgSels.length; s++) { var svg = document.querySelector(svgSels[s]); if (svg) { var rc = svg.getBoundingClientRect(); if (rc.width >= 16 && rc.width <= 640 && rc.height >= 8) { var data = serializeSvg(svg); if (data) return data; } } } var imgSels = ['[class="logo" i] img', 'img[alt*="logo" i]', 'header a[href="/"] img', 'header img']; for (var i2 = 0; i2 < imgSels.length; i2++) { var img = document.querySelector(imgSels[i2]); // Guard against a full-width banner image matching a "logo" selector by // rejecting anything outside a plausible logo size, the same way the // SVG branch above does with getBoundingClientRect(). if (img && img.naturalWidth >= 16 && img.naturalWidth <= 640 && img.naturalHeight >= 8) { var src = img.currentSrc || img.getAttribute('src'); if (src) return abs(src); } } var icon = document.querySelector('link[rel="apple-touch-icon"], link[rel~="icon"]'); if (icon && icon.href) return abs(icon.href); var ogImage = getMetaContent('meta[property="og:image"]'); if (ogImage) return abs(ogImage); return ''; }

var name = getMetaContent('meta[property="og:site_name"]') || (document.title ? document.title.split(/[-|–—·•:]/)[0].trim() : '') || location.hostname.replace(/^www\./, ''); var description = getMetaContent('meta[name="description"]') || getMetaContent('meta[property="og:description"]') || '';

return JSON.stringify({ name: name, description: description, logoUrl: pickLogo(), colorScheme: colorScheme, colors: colors, fonts: fonts }); })(); `;


## 6. Drive the browser and return the brand kit

Now write the Worker that accepts a `url` query parameter, loads the page in Browser Run, runs the script, and returns the result. Two details matter:

- `page.setBypassCSP(true)` lets the in-page script run even on sites with a strict `Content-Security-Policy` (such as GitHub or Stripe), which would otherwise block injected scripts.
- `waitUntil: "networkidle2"` waits until the page has settled, so client-rendered styles are in place before you read them.

Replace the contents of `src/index.ts` with the following Worker:

```js
import puppeteer from "@cloudflare/puppeteer";
import { BRAND_SCRIPT } from "./extract";

const CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days

// Normalize a URL into a stable cache key. This intentionally drops the
// query string and hash, since most sites don't change their brand kit
// (colors, fonts, logo) based on query parameters. If you're extracting
// brand kits for sites that do (for example, a `?theme=dark` toggle),
// include the relevant search params in the key below.
const cacheKeyFor = (url) => {
    const path = url.pathname.replace(/\/+$/, "");
    return `brand:${url.host.toLowerCase()}${path}`;
};

const getTargetUrl = (request) => {
    const target = new URL(request.url).searchParams.get("url");
    if (!target) {
        throw new Error("Missing url query parameter");
    }
    const url = new URL(target);
    // Only render real, public HTTP(S) pages.
    if (!["http:", "https:"].includes(url.protocol)) {
        throw new Error("Only HTTP and HTTPS URLs are allowed");
    }
    return url;
};

const extractBrand = async (env, url) => {
    const browser = await puppeteer.launch(env.BROWSER);
    try {
        const page = await browser.newPage();
        // Allow the in-page script to run even on strict-CSP sites.
        await page.setBypassCSP(true);
        await page.goto(url.toString(), {
            waitUntil: "networkidle2",
            timeout: 30000,
        });

// Read the real computed styles, meta tags, and logo from the DOM.
        const raw = await page.evaluate(BRAND_SCRIPT);
        return JSON.parse(raw);
    } finally {
        await browser.close();
    }
};

export default {
    async fetch(request, env) {
        try {
            const url = getTargetUrl(request);
            const key = cacheKeyFor(url);

// 1. Cache hit → return instantly without driving the browser.
            const cached = await env.CACHE.get(key, "json");
            if (cached) {
                return Response.json({
                    cached: true,
                    url: url.toString(),
                    brand: cached,
                });
            }

// 2. Cache miss → run the browser, then cache the result.
            const brand = await extractBrand(env, url);
            await env.CACHE.put(key, JSON.stringify(brand), {
                expirationTtl: CACHE_TTL_SECONDS,
            });

return Response.json({ cached: false, url: url.toString(), brand });
        } catch (error) {
            return Response.json(
                { error: error instanceof Error ? error.message : "Unknown error" },
                { status: 400 },
            );
        }
    },
};
import puppeteer from "@cloudflare/puppeteer";
import { BRAND_SCRIPT, type BrandKit } from "./extract";

interface Env {
    BROWSER: Fetcher;
    CACHE: KVNamespace;
}

const CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days

// Normalize a URL into a stable cache key. This intentionally drops the
// query string and hash, since most sites don't change their brand kit
// (colors, fonts, logo) based on query parameters. If you're extracting
// brand kits for sites that do (for example, a `?theme=dark` toggle),
// include the relevant search params in the key below.
const cacheKeyFor = (url: URL) => {
    const path = url.pathname.replace(/\/+$/, "");
    return `brand:${url.host.toLowerCase()}${path}`;
};

const getTargetUrl = (request: Request) => {
    const target = new URL(request.url).searchParams.get("url");
    if (!target) {
        throw new Error("Missing url query parameter");
    }
    const url = new URL(target);
    // Only render real, public HTTP(S) pages.
    if (!["http:", "https:"].includes(url.protocol)) {
        throw new Error("Only HTTP and HTTPS URLs are allowed");
    }
    return url;
};

const extractBrand = async (env: Env, url: URL): Promise&lt;BrandKit> => {
    const browser = await puppeteer.launch(env.BROWSER);
    try {
        const page = await browser.newPage();
        // Allow the in-page script to run even on strict-CSP sites.
        await page.setBypassCSP(true);
        await page.goto(url.toString(), {
            waitUntil: "networkidle2",
            timeout: 30000,
        });

// Read the real computed styles, meta tags, and logo from the DOM.
        const raw = (await page.evaluate(BRAND_SCRIPT)) as string;
        return JSON.parse(raw) as BrandKit;
    } finally {
        await browser.close();
    }
};

export default {
    async fetch(request, env): Promise&lt;Response> {
        try {
            const url = getTargetUrl(request);
            const key = cacheKeyFor(url);

// 1. Cache hit → return instantly without driving the browser.
            const cached = await env.CACHE.get&lt;BrandKit>(key, "json");
            if (cached) {
                return Response.json({ cached: true, url: url.toString(), brand: cached });
            }

// 2. Cache miss → run the browser, then cache the result.
            const brand = await extractBrand(env, url);
            await env.CACHE.put(key, JSON.stringify(brand), {
                expirationTtl: CACHE_TTL_SECONDS,
            });

return Response.json({ cached: false, url: url.toString(), brand });
        } catch (error) {
            return Response.json(
                { error: error instanceof Error ? error.message : "Unknown error" },
                { status: 400 },
            );
        }
    },
} satisfies ExportedHandler&lt;Env>;

The Worker checks KV first. On a cache miss, it launches a browser, loads the page, runs the extraction script, caches the result for seven days, and returns the brand kit as JSON.

7. Test the extractor

  1. Start your Worker in remote mode so Browser Run runs against managed headless Chrome:npmyarnpnpm
   npx wrangler dev --remote
   ```

yarn wrangler dev --remote ```

   pnpm wrangler dev --remote
   ```

2. In another terminal, request a brand kit:

```bash
   curl "http://localhost:8787/?url=https://www.cloudflare.com/"
   ```

The response is a JSON object with the extracted `name`, `description`, `logoUrl`, `colorScheme`, `colors`, and `fonts`. Request the same URL again and `cached` will be `true`.

## 8. Deploy

1. Deploy the Worker after local validation:npmyarnpnpm

npx wrangler deploy ```

   yarn wrangler deploy
   ```

pnpm wrangler deploy ```

  1. After deployment, request a brand kit from your Worker URL:
   curl "https://&lt;YOUR_WORKER_HOSTNAME>/?url=https://www.cloudflare.com/"
   ```

## Extend the extractor

The same `page.evaluate()` pattern can read any computed style on the page. From here you can capture more of the design system:

- **Type scale** — read `font-size`, `line-height`, `font-weight`, and `letter-spacing` from `h1`– `h6`, `p`, and `small`.
- **Border radii** — sample `border-radius` from buttons, inputs, and cards to build a radius scale.
- **Shadows** — collect `box-shadow` values from cards and modals to build an elevation scale.
- **Component tokens** — find the page's primary button and capture its background, text color, radius, and padding.

Because everything is read from real computed styles, the values reflect what the site actually renders rather than what a model guesses.

## Production considerations

- **Guard user-supplied URLs.** If the URL comes from untrusted input, block private and link-local addresses ( `localhost`, `10.0.0.0/8`, `192.168.0.0/16`, `169.254.0.0/16`) to avoid [server-side request forgery ↗︎](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery), and only allow `http:` and `https:` schemes.
- **Reuse browser sessions under load.** Instead of `puppeteer.launch()` on every request, reuse idle sessions with [`puppeteer.sessions()`](https://developers.cloudflare.com/browser-run/puppeteer/) and `puppeteer.connect()`, and call `disconnect()` (not `close()`) to keep the session warm for the next request. This avoids cold-start time and helps you stay within [concurrency limits](https://developers.cloudflare.com/browser-run/limits/).
- **Cache aggressively.** Extraction is the expensive step. Keying the KV cache by normalized URL with the `cacheKeyFor` function makes repeat lookups instant and keeps you within Browser Run limits.

Note

`wrangler dev --remote` runs Browser Run against Cloudflare's network. Browser Run is not available in fully local development mode, so use `--remote` when testing locally.

## Related resources

- [Cloudflare's Puppeteer fork](https://developers.cloudflare.com/browser-run/puppeteer/)
- [Browser Run limits](https://developers.cloudflare.com/browser-run/limits/)
- [Workers KV](https://developers.cloudflare.com/kv/)
- [Other Puppeteer examples ↗︎](https://github.com/cloudflare/puppeteer/tree/main/examples)

Was this helpful?

YesNo

## On this page

[![](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NiIgaGVpZ2h0PSIzMCIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDY2IDMwIj48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNzQ2XzIpIj48cGF0aCBmaWxsPSIjZmY5OTEwIiBkPSJNNTMuMTIgMTMuMTI1YzYuOTk3IDAgMTIuNjY4IDUuNzA4IDEyLjY2OCAxMi43NSAwIDEuMjU1LS4xOCAyLjQ2OC0uNTE2IDMuNjE0YS43Mi43MiAwIDAgMS0uNjk0LjUxMUg0Ni45NTNhLjM3NS4zNzUgMCAwIDEtLjM0OC0uNTFsLjMzNy0uODgyYzEuMDc2LTIuODMzIDQuMTQ2LTUuMTU0IDYuOTg3LTUuMjlsNS4zODQtLjI3N2EuNTQzLjU0MyAwIDAgMCAuNTE0LS41NDQuNTQzLjU0MyAwIDAgMC0uNTEtLjU0NGwtNS4wNTYtLjI5OWMtMi43MTgtLjE0LTQuMDQ2LTIuNTYtMy4zMTUtNS4wODFsLjg0Mi0yLjkwMmEuNzIuNzIgMCAwIDEgLjY0Ni0uNTI3cS4zNC0uMDIuNjg2LS4wMTkiLz48cGF0aCBmaWxsPSIjZmY1ZjA4IiBkPSJNMzUuNjA5IDBjNy4yOTUgMCAxMy40MjQgNS4wMjUgMTUuMTU3IDExLjgyNC4wNzMuMjg1LjA1LjU4Ni0uMDU0Ljg2MmwtMS4zODggMy42NzdjLTEuMDc2IDIuODMzLTMuODQyIDUuMjktNi45NyA1LjI5bC0yNC4xMDUuMjc4YS41MzMuNTMzIDAgMCAwLS41MjUuNTM1YzAgLjI5My4yMzQuNTMxLjUyNC41MzVsMjMuNzY1LjMxNmMyLjcyNCAwIDQuMDQ2IDIuNTYgMy4zMTYgNS4wODFsLS4zMDcgMS4wNjFhLjc1Ljc1IDAgMCAxLS43MTYuNTQxSC45NjRhLjcxLjcxIDAgMCAxLS43MTgtLjY0NiAxMSAxMSAwIDAgMS0uMDM0LS44NTRjMC01LjQ3NiA0LjE2Ni05Ljk3MyA5LjQ4Mi0xMC40NTZhNy4yIDcuMiAwIDAgMS0uMTY3LTEuNTQ0YzAtMy45MzUgMy4xNy03LjEyNSA3LjA4LTcuMTI1IDEuNTU1IDAgMi45OTQuNTA2IDQuMTYyIDEuMzYyQzIyLjg0OSA0LjQ5NyAyOC43MDcgMCAzNS42MDkgMCIvPjxwYXRoIGZpbGw9IiNmZjk5MTAiIGQ9Ik01My4xMiAxMy4xMjVjNi45OTcgMCAxMi42NjggNS43MDggMTIuNjY4IDEyLjc1IDAgMS4yNTUtLjE4IDIuNDY4LS41MTYgMy42MTRhLjcyLjcyIDAgMCAxLS42OTQuNTExSDQ2Ljk1M2EuMzc1LjM3NSAwIDAgMS0uMzQ4LS41MWwuMzM3LS44ODJjMS4wNzYtMi44MzMgNC4xNDYtNS4xNTQgNi45ODctNS4yOWw1LjM4NC0uMjc3YS41NDMuNTQzIDAgMCAwIC41MTQtLjU0NC41NDMuNTQzIDAgMCAwLS41MS0uNTQ0bC01LjA1Ni0uMjk5Yy0yLjcxOC0uMTQtNC4wNDYtMi41Ni0zLjMxNS01LjA4MWwuODQyLTIuOTAyYS43Mi43MiAwIDAgMSAuNjQ2LS41MjdxLjM0LS4wMi42ODYtLjAxOSIvPjxwYXRoIGZpbGw9IiNmZjVmMDgiIGQ9Ik0zNS42MDkgMGM3LjI5NSAwIDEzLjQyNCA1LjAyNSAxNS4xNTcgMTEuODI0LjA3My4yODUuMDUuNTg2LS4wNTQuODYybC0xLjM4OCAzLjY3N2MtMS4wNzYgMi44MzMtMy44NDIgNS4yOS02Ljk3IDUuMjlsLTI0LjEwNS4yNzhhLjUzMy41MzMgMCAwIDAtLjUyNS41MzVjMCAuMjkzLjIzNC41MzEuNTI0LjUzNWwyMy43NjUuMzE2YzIuNzI0IDAgNC4wNDYgMi41NiAzLjMxNiA1LjA4MWwtLjMwNyAxLjA2MWEuNzUuNzUgMCAwIDEtLjcxNi41NDFILjk2NGEuNzEuNzEgMCAwIDEtLjcxOC0uNjQ2IDExIDExIDAgMCAxLS4wMzQtLjg1NGMwLTUuNDc2IDQuMTY2LTkuOTczIDkuNDgyLTEwLjQ1NmE3LjIgNy4yIDAgMCAxLS4xNjctMS41NDRjMC0zLjkzNSAzLjE3LTcuMTI1IDcuMDgtNy4xMjUgMS41NTUgMCAyLjk5NC41MDYgNC4xNjIgMS4zNjJDMjIuODQ5IDQuNDk3IDI4LjcwNyAwIDM1LjYwOSAwIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfNzQ2XzIiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBoNjZ2MzBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/browser-run/how-to/extract-brand-kit/#page","headline":"Extract a brand kit from any website","description":"Use Browser Run and Puppeteer to read a site's real computed styles, colors, fonts, and logo, then return a structured brand kit from a Worker.","url":"https://developers.cloudflare.com/browser-run/how-to/extract-brand-kit/","inLanguage":"en","image":"https://developers.cloudflare.com/browser-run/how-to/extract-brand-kit/og.png?v=caff3fbc452d084f","dateModified":"2026-10-12","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}


description: Use Browser Run to automatically generate Open Graph social preview images for your Astro site pages. title: Generate OG images for Astro sites image: https://developers.cloudflare.com/browser-run/how-to/og-images-astro/og.png?v=c3129c9e736d15ea


Skip to content

Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/browser-run/llms.txt
Use this file to discover all available pages before exploring further.