跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

从任意网站提取品牌套件

最后更新 查看 MarkdownAgent 设置

品牌套件是让网站可识别的视觉设计决策集合:颜色、排版、logo 和组件样式。自动提取品牌套件有助于构建设计系统工具、生成主题化预览、为编码智能体提供站点的外观与风格,或审核跨页面的品牌一致性。

读取这些值的可靠方式是从真实浏览器中读取。站点的确切十六进制颜色、font-family 名称、圆角半径和 logo 存在于 CSS 和计算样式中,而非页面的可读文本。仅看到文本的大语言模型可能正确得到名称和描述,但会臆造颜色和字体。借助 Cloudflare Browser Run 和 Cloudflare 的 Puppeteer fork,你可以在托管的无头 Chrome 中加载页面,并直接从 DOM 读取真实 计算样式、meta 标签和 logo。

在本教程中,你将:

  • 向 Worker 添加 Browser Run 绑定和 Puppeteer
  • 运行读取计算样式、字体和 logo 的页内脚本
  • 对颜色评分并去重,以找到品牌调色板
  • 以 JSON 返回品牌套件
  • 将结果缓存在 Workers KV 中,使重复查找即时完成

前提条件

要跟随本教程,你需要:

  • 已启用 Browser Run 的 Cloudflare 账户
  • 本地已安装 Node.js 和 npm
  • 具备 TypeScript 和 Cloudflare Workers 的基本知识

1. 创建 Worker 项目

创建名为 brand-kit 的新 Worker 项目:

npm create cloudflare@latest -- brand-kit

出现提示时,选择使用 TypeScript 的 Hello World Worker。

2. 安装 Puppeteer

在 brand-kit 目录中,安装 Cloudflare 的 Puppeteer fork:

npm i -D @cloudflare/puppeteer

3. 创建 KV 命名空间

提取品牌套件会驱动完整的浏览器会话,因此你需要缓存结果,避免对同一 URL 的重复查找再次运行浏览器。为缓存创建 KV 命名空间:

npx wrangler kv namespace create CACHE

从命令输出中复制命名空间 id。你将在下一步将其添加到 Wrangler 配置中。

4. 配置 Browser Run

向 Wrangler 配置添加 Browser Run 绑定、KV 命名空间以及 Puppeteer 所需的 Node.js 兼容性标志:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "brand-kit",
  "main": "src/index.ts",
  "upload_source_maps": true,
  // Set this to today's date
  "compatibility_date": "2026-11-14",
  "compatibility_flags": [
    "nodejs_compat"
  ],
  "browser": {
    "binding": "BROWSER"
  },
  "kv_namespaces": [
    {
      "binding": "CACHE",
      "id": "<YOUR_KV_NAMESPACE_ID>"
    }
  ]
}
name = "brand-kit"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-11-14"
compatibility_flags = ["nodejs_compat"]

# Browser Run binding — driven by Puppeteer.
[browser]
binding = "BROWSER"

# KV cache for extracted brand kits.
[[kv_namespaces]]
binding = "CACHE"
id = "<YOUR_KV_NAMESPACE_ID>"

5. 编写页内提取脚本

提取器的核心是通过 page.evaluate() 在页面内部 运行的脚本。它读取计算样式、meta 标签和 logo,然后将品牌套件作为 JSON 字符串返回。返回原始字符串(而非活动对象)可使跨浏览器边界的序列化更可靠。

此脚本必须是自包含的:它不能引用 Worker 作用域中的任何内容。它做三件事:

  • 读取 <meta> 标签以获取名称和描述。
  • 遍历一次 DOM,从每个元素采样 background-color、color 和 border-color。它跳过灰色和接近白色/黑色的颜色(低饱和度),更重视背景以及品牌或号召性用语元素,然后按颜色距离对顶部颜色去重以生成调色板。
  • 从标题、正文和代码块读取 font-family,并选择 logo(按顺序:内联 SVG、<img>、声明的图标或 Open Graph 图片)。

创建 src/extract.ts:

// This runs in the page (headless Chrome). It must be self-contained and
// return a JSON string, which page.evaluate() serializes reliably.
export const BRAND_SCRIPT = `
(() => {
  var origin = location.href;
  var abs = function (u) { try { return new URL(u, origin).href; } catch (e) { return u; } };
  var getMetaContent = function (sel) { var m = document.querySelector(sel); return m && m.content ? m.content.trim() : null; };

  function parseColor(str) {
    if (!str) return null;
    var m = str.match(/rgba?\\(([^)]+)\\)/);
    if (!m) return null;
    var p = m[1].split(',').map(function (x) { return parseFloat(x.trim()); });
    var a = p.length > 3 ? p[3] : 1;
    if (a === 0) return null;
    return { r: p[0], g: p[1], b: p[2] };
  }
  function toHex(c) {
    var h = function (n) { var s = Math.max(0, Math.min(255, Math.round(n))).toString(16); return s.length < 2 ? '0' + s : s; };
    return ('#' + h(c.r) + h(c.g) + h(c.b)).toUpperCase();
  }
  function saturation(c) {
    var r = c.r / 255, g = c.g / 255, b = c.b / 255;
    var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
    if (d === 0) return 0;
    var l = (max + min) / 2;
    return l > 0.5 ? d / (2 - max - min) : d / (max + min);
  }
  function lightness(c) {
    var max = Math.max(c.r, c.g, c.b) / 255, min = Math.min(c.r, c.g, c.b) / 255;
    return (max + min) / 2;
  }
  function dist(a, b) {
    return Math.sqrt(Math.pow(a.r - b.r, 2) + Math.pow(a.g - b.g, 2) + Math.pow(a.b - b.b, 2));
  }
  function cleanFont(ff) {
    if (!ff) return null;
    var generic = ['sans-serif', 'serif', 'monospace', 'system-ui', '-apple-system', 'ui-sans-serif', 'ui-monospace', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica neue', 'arial'];
    var parts = ff.split(',').map(function (x) { return x.trim().replace(/^["']|["']$/g, ''); });
    for (var k = 0; k < parts.length; k++) { if (parts[k] && generic.indexOf(parts[k].toLowerCase()) < 0) return parts[k]; }
    return parts[0] || null;
  }

  // ---- colors: a single pass over the DOM, scoring brand colors ----
  var scored = {};
  function add(c, weight) {
    var hex = toHex(c);
    if (!scored[hex]) scored[hex] = { score: 0, c: c };
    scored[hex].score += weight;
  }

  // theme-color is an explicit brand signal — weigh it heavily.
  var themeColor = getMetaContent('meta[name="theme-color"]');
  if (themeColor) { var d = document.createElement('div'); d.style.color = themeColor; document.body.appendChild(d); var tc = parseColor(getComputedStyle(d).color); d.remove(); if (tc) add(tc, 1000); }

  var all = document.querySelectorAll('body *');
  var limit = Math.min(all.length, 4000);
  for (var i = 0; i < limit; i++) {
    var el = all[i];
    var cs = getComputedStyle(el);
    var tag = el.tagName.toLowerCase();
    var cls = (el.className && el.className.toString ? el.className.toString() : '').toLowerCase();
    // Buttons, links, and brand/CTA/nav elements are stronger brand signals.
    var emphasis = (tag === 'button' || tag === 'a' || /btn|cta|button|brand|logo|nav|header|primary|hero/.test(cls)) ? 3 : 1;
    var props = [cs.backgroundColor, cs.color, cs.borderColor];
    for (var j = 0; j < props.length; j++) {
      var col = parseColor(props[j]);
      if (!col) continue;
      if (saturation(col) < 0.2) continue;          // skip greys / near-white / near-black
      var lt = lightness(col);
      if (lt < 0.07 || lt > 0.95) continue;
      add(col, (j === 0 ? 2 : 1) * emphasis);        // backgrounds weigh more than text
    }
  }

  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
  });
})();
`;
export type BrandColor = { name: string; hex: string };
export type BrandFont = { name: string; usage: string };

export type BrandKit = {
	name: string;
	description: string;
	logoUrl: string;
	colorScheme: "light" | "dark";
	colors: BrandColor[];
	fonts: BrandFont[];
};

// This runs in the page (headless Chrome). It must be self-contained and
// return a JSON string, which page.evaluate() serializes reliably.
export const BRAND_SCRIPT = `
(() => {
  var origin = location.href;
  var abs = function (u) { try { return new URL(u, origin).href; } catch (e) { return u; } };
  var getMetaContent = function (sel) { var m = document.querySelector(sel); return m && m.content ? m.content.trim() : null; };

  function parseColor(str) {
    if (!str) return null;
    var m = str.match(/rgba?\\(([^)]+)\\)/);
    if (!m) return null;
    var p = m[1].split(',').map(function (x) { return parseFloat(x.trim()); });
    var a = p.length > 3 ? p[3] : 1;
    if (a === 0) return null;
    return { r: p[0], g: p[1], b: p[2] };
  }
  function toHex(c) {
    var h = function (n) { var s = Math.max(0, Math.min(255, Math.round(n))).toString(16); return s.length < 2 ? '0' + s : s; };
    return ('#' + h(c.r) + h(c.g) + h(c.b)).toUpperCase();
  }
  function saturation(c) {
    var r = c.r / 255, g = c.g / 255, b = c.b / 255;
    var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
    if (d === 0) return 0;
    var l = (max + min) / 2;
    return l > 0.5 ? d / (2 - max - min) : d / (max + min);
  }
  function lightness(c) {
    var max = Math.max(c.r, c.g, c.b) / 255, min = Math.min(c.r, c.g, c.b) / 255;
    return (max + min) / 2;
  }
  function dist(a, b) {
    return Math.sqrt(Math.pow(a.r - b.r, 2) + Math.pow(a.g - b.g, 2) + Math.pow(a.b - b.b, 2));
  }
  function cleanFont(ff) {
    if (!ff) return null;
    var generic = ['sans-serif', 'serif', 'monospace', 'system-ui', '-apple-system', 'ui-sans-serif', 'ui-monospace', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica neue', 'arial'];
    var parts = ff.split(',').map(function (x) { return x.trim().replace(/^["']|["']$/g, ''); });
    for (var k = 0; k < parts.length; k++) { if (parts[k] && generic.indexOf(parts[k].toLowerCase()) < 0) return parts[k]; }
    return parts[0] || null;
  }

  // ---- colors: a single pass over the DOM, scoring brand colors ----
  var scored = {};
  function add(c, weight) {
    var hex = toHex(c);
    if (!scored[hex]) scored[hex] = { score: 0, c: c };
    scored[hex].score += weight;
  }

  // theme-color is an explicit brand signal — weigh it heavily.
  var themeColor = getMetaContent('meta[name="theme-color"]');
  if (themeColor) { var d = document.createElement('div'); d.style.color = themeColor; document.body.appendChild(d); var tc = parseColor(getComputedStyle(d).color); d.remove(); if (tc) add(tc, 1000); }

  var all = document.querySelectorAll('body *');
  var limit = Math.min(all.length, 4000);
  for (var i = 0; i < limit; i++) {
    var el = all[i];
    var cs = getComputedStyle(el);
    var tag = el.tagName.toLowerCase();
    var cls = (el.className && el.className.toString ? el.className.toString() : '').toLowerCase();
    // Buttons, links, and brand/CTA/nav elements are stronger brand signals.
    var emphasis = (tag === 'button' || tag === 'a' || /btn|cta|button|brand|logo|nav|header|primary|hero/.test(cls)) ? 3 : 1;
    var props = [cs.backgroundColor, cs.color, cs.borderColor];
    for (var j = 0; j < props.length; j++) {
      var col = parseColor(props[j]);
      if (!col) continue;
      if (saturation(col) < 0.2) continue;          // skip greys / near-white / near-black
      var lt = lightness(col);
      if (lt < 0.07 || lt > 0.95) continue;
      add(col, (j === 0 ? 2 : 1) * emphasis);        // backgrounds weigh more than text
    }
  }

  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. 驱动浏览器并返回品牌套件

现在编写接受 url 查询参数的 Worker:在 Browser Run 中加载页面、运行脚本并返回结果。有两个细节很重要:

  • page.setBypassCSP(true) 让页内脚本即使在具有严格 Content-Security-Policy 的站点(如 GitHub 或 Stripe)上也能运行,否则这些站点会阻止注入的脚本。
  • waitUntil: "networkidle2" 会等待页面稳定,以便在读取前客户端渲染的样式已就绪。

将 src/index.ts 的内容替换为以下 Worker:

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<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<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<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<Env>;

Worker 首先检查 KV。缓存未命中时,它启动浏览器、加载页面、运行提取脚本、将结果缓存七天,并以 JSON 返回品牌套件。

7. 测试提取器

  1. 以 remote 模式启动 Worker,以便 Browser Run 针对托管的无头 Chrome 运行:

    npx wrangler dev --remote
  2. 在另一个终端中请求品牌套件:

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

    响应是包含提取的 name、description、logoUrl、colorScheme、colors 和 fonts 的 JSON 对象。再次请求同一 URL 时,cached 将为 true。

8. 部署

  1. 本地验证后部署 Worker:

    npx wrangler deploy
  2. 部署后,从 Worker URL 请求品牌套件:

    curl "https://<YOUR_WORKER_HOSTNAME>/?url=https://www.cloudflare.com/"

扩展提取器

相同的 page.evaluate() 模式可以读取页面上的任何计算样式。由此你可以捕获更多设计系统信息:

  • 字号阶梯 — 从 h1–h6、p 和 small 读取 font-size、line-height、font-weight 和 letter-spacing。
  • 圆角 — 从按钮、输入和卡片采样 border-radius 以构建半径阶梯。
  • 阴影 — 从卡片和模态框收集 box-shadow 值以构建海拔阶梯。
  • 组件 token — 找到页面的主按钮并捕获其背景、文本颜色、圆角和内边距。

因为所有值都从真实计算样式读取,它们反映站点实际渲染的内容,而非模型猜测的内容。

生产环境注意事项

  • 防护用户提供的 URL。 如果 URL 来自不受信任的输入,请阻止私有和链路本地地址(localhost、10.0.0.0/8、192.168.0.0/16、169.254.0.0/16)以避免服务端请求伪造 ↗,并且仅允许 http: 和 https: scheme。
  • 在负载下复用浏览器会话。 不要在每个请求上都调用 puppeteer.launch(),而是使用 puppeteer.sessions() 和 puppeteer.connect() 复用空闲会话,并调用 disconnect()(而非 close())以保持会话为下一次请求预热。这可避免冷启动时间,并帮助你保持在并发限制之内。
  • 积极缓存。 提取是昂贵的步骤。使用 cacheKeyFor 函数按规范化 URL 作为 KV 缓存键,可使重复查找即时完成,并帮助你保持在 Browser Run 限制之内。

相关资源

这篇文档对您有帮助吗?