品牌套件是让网站可识别的视觉设计决策集合:颜色、排版、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 的基本知识
创建名为 brand-kit 的新 Worker 项目:
npm create cloudflare@latest -- brand-kityarn create cloudflare brand-kitpnpm create cloudflare@latest brand-kit出现提示时,选择使用 TypeScript 的 Hello World Worker。
在 brand-kit 目录中,安装 Cloudflare 的 Puppeteer fork:
npm i -D @cloudflare/puppeteeryarn add -D @cloudflare/puppeteerpnpm add -D @cloudflare/puppeteerbun add -d @cloudflare/puppeteer提取品牌套件会驱动完整的浏览器会话,因此你需要缓存结果,避免对同一 URL 的重复查找再次运行浏览器。为缓存创建 KV 命名空间:
npx wrangler kv namespace create CACHEyarn wrangler kv namespace create CACHEpnpm wrangler kv namespace create CACHE从命令输出中复制命名空间 id。你将在下一步将其添加到 Wrangler 配置中。
向 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>"提取器的核心是通过 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
});
})();
`;现在编写接受 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 返回品牌套件。
-
以 remote 模式启动 Worker,以便 Browser Run 针对托管的无头 Chrome 运行:
npx wrangler dev --remoteyarn wrangler dev --remotepnpm wrangler dev --remote -
在另一个终端中请求品牌套件:
curl "http://localhost:8787/?url=https://www.cloudflare.com/"响应是包含提取的
name、description、logoUrl、colorScheme、colors和fonts的 JSON 对象。再次请求同一 URL 时,cached将为true。
-
本地验证后部署 Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
部署后,从 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 限制之内。