
Get Web Design
- 1 installs
- 62 repo stars
- Updated August 1, 2026
- liaocaoxuezhe/get-web-design
Helps with design & ui/ux tasks.
About
get-web-design is a Claude Code skill for design & ui/ux. It helps developers move faster with AI-assisted coding.
- get-web-design
- Design & UI/UX
- AI-coding skill
Get Web Design by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,609 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/liaocaoxuezhe/get-web-design --skill get-web-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 1, 2026 |
| Repository | liaocaoxuezhe/get-web-design ↗ |
What it does
Helps with design & ui/ux tasks.
Files
get-web-design
将任意线上网站的设计风格提取为一份结构化 DESIGN.md。本 skill 是 design-extractor Chrome 扩展的命令行 / Claude Code 移植版。
何时使用
调用本 skill 当且仅当用户希望:
- 从一个 URL 生成一份可用于 AI 编程提示词的
DESIGN.md - 提取某站点的设计 token(颜色 / 字体 / 间距 / 圆角 / 阴影 / 动效 token)
- 让 AI 描述某站点的视觉风格、组件规范、整体氛围
- 识别网页里最值得复刻的特殊元素,并输出 few-shot 模块规范
典型触发语句:
- "生成 https://stripe.com 的 DESIGN.md"
- "分析 linear.app 的设计风格并保存"
- "extract design from <url>"
硬性前置条件
调用本 skill 前必须确认:
1. chrome-devtools MCP 已连接 —— 通过 /mcp 验证是否有 chrome-devtools 服务,没有则参考 references/setup.md 安装。 2. 多模态 LLM 三项配置已就绪(环境变量或 CLI 参数):
WEB_DESIGN_API_KEYWEB_DESIGN_BASE_URL(OpenAI 兼容根路径,必须含/v1或对应路径前缀)WEB_DESIGN_MODEL(必须支持 vision,纯文本模型会失败)
若任一项缺失,必须先向用户询问并由用户自行填写,不要替用户编造任何 key/url/model。 详见 references/setup.md。
总体工作流(5 步)
URL → [chrome-devtools] 3 截图 + collected.json → [Python] CSS 压缩 + LLM 调用 + 拼装 →
output/<hostname>/{shot1,shot2,shot3}.jpg
output/<hostname>/design.md每一步细节见 references/workflow.md。chrome-devtools MCP 的精确调用配方见 references/chrome_devtools_recipes.md。
Step 1 — 创建输出目录
所有截图和最终 design.md 都直接落到 当前工作目录 下的 output/<hostname>/。
mkdir -p output/<hostname><hostname> 即 URL 的 host(如 https://platform.moonshot.cn/... → platform.moonshot.cn)。 collected.json 仍可放到 /tmp/ 等临时位置(它是中间产物,不必随结果发布)。
Step 2 — 用 chrome-devtools 采集
按 references/chrome_devtools_recipes.md 顺序:
1. mcp__chrome-devtools__new_page({ url }),必要时 wait_for 等首屏渲染。 2. 依次滚到 0% / 35% / 70%,每次滚动后 ≥600ms 再 take_screenshot,直接存到 output/<hostname>/shot1.jpg、shot2.jpg、shot3.jpg。 3. 读取 assets/collect_design_data.js 全部内容,包成 () => { …全部代码… } 传给 evaluate_script,把返回值序列化写入 collected.json(建议放 /tmp/get-web-design/<run-id>/collected.json)。
该 JS 文件最后一行是 return collectDesignData({ includeCss: true });,所以包装层只需要把整个文件内容塞进 () => { ... } 里就能得到结构化对象。Step 3 — 调用编排脚本
python3 <skill_dir>/scripts/generate_design_md.py \
--collected /tmp/get-web-design/<run-id>/collected.json \
--screenshots output/<hostname>/shot1.jpg output/<hostname>/shot2.jpg output/<hostname>/shot3.jpg \
--hostname "<hostname>"默认会输出到 output/<hostname>/design.md,并将传入的 3 张截图归位到同一目录(已经在该目录的会跳过复制)。 如需自定义可用 --output-dir <dir> 整体改目录,或 --output <path> 仅改 markdown 路径。 语言默认英文(--language en),不建议改为 zh。
脚本内部完成:
normalize_css_evidence—— 用scripts/css_evidence.py把 280 行 computed-style 压成高频 token;format_css_evidence_markdown—— 渲染成## Engineering CSS Evidence段(英文);build_messages—— DOM JSON + 3 张截图 base64 + 英文 system prompt(assets/system_prompt_en.txt);call_llm—— OpenAI 兼容/chat/completions非流式调用;assemble_design_md—— 按固定顺序拼装:
frontmatter (hostname / version / last_updated)
+ design_thinking (assets/design_thinking.md,Design Thinking 准则)
+ AI 风格分析 (去掉 ``` 围栏)
+ CSS Evidence Markdown
+ core_principles (assets/core_principles.md,Negative Constraints + Performance)Step 4 — 校验输出
应当生成以下 4 个文件:
output/<hostname>/design.md
output/<hostname>/shot1.jpg
output/<hostname>/shot2.jpg
output/<hostname>/shot3.jpg打开 output/<hostname>/design.md,确认:
- 顶部有
---frontmatter; - 包含
# Design Thinking、# Overall Atmosphere、## Engineering CSS Evidence、## Core Principles四个段落; - 包含
## Distinctive Element Few-shot Examples,并为真实页面元素给出 purpose / evidence / visual rules / recreation prompt / structure sketch; ## Engineering CSS Evidence下的 token 不全是 "Not enough evidence";- 全文无中文(frontmatter 及 AI 分析段落均应为英文)。
若 Engineering CSS Evidence 大量为 "Not enough evidence",回到 Step 2 检查 evaluate_script 返回值是否完整。
Step 5 — 关闭页面(可选)
mcp__chrome-devtools__close_page({ pageIdx })文件清单
| 路径 | 用途 |
|---|---|
assets/collect_design_data.js | 注入到目标页面的 DOM+CSS 采集脚本(不依赖任何外部库) |
assets/system_prompt_zh.txt / system_prompt_en.txt | 多模态 LLM 的 system prompt |
assets/design_thinking.md / core_principles.md | DESIGN.md 的固定头尾段,不要修改顺序或内容 |
scripts/css_evidence.py | CSS computed-style → 设计 token 压缩 + Markdown 渲染(Python 库) |
scripts/generate_design_md.py | 主入口 CLI;负责调 LLM 与最终拼装 |
references/setup.md | 安装 chrome-devtools MCP + 配置 LLM 凭据 |
references/workflow.md | 完整数据流详解(与原 design-extractor 对齐) |
references/chrome_devtools_recipes.md | chrome-devtools MCP 的精确调用顺序与故障兜底 |
重要约束
- 不要在 LLM prompt 中加入 CSS 数据。 原始 CSS 由 Python 单独压缩;prompt 中只提供 DOM 文本+截图。这是为了防止模型编造 CSS 细节。
- 特殊元素 few-shot 必须来自真实证据。
domSnapshot.distinctiveCandidates会提供候选模块,但最终仍需结合截图判断,不要为不存在的页面元素编造模块。 - 拼装顺序固定。 frontmatter → design_thinking → AI 分析 → CSS Evidence → core_principles。任何重排都会破坏下游使用本 DESIGN.md 的提示词链。
- 3 张截图是上限。 由 Chrome 速率限制决定;不要尝试加到 5 张以上。
- 模型必须支持 vision。 用纯文本模型会得到无视觉 grounding 的低质量结果。
- API key/url/model 由用户提供。 不要为用户填默认值;缺失时用
AskUserQuestion询问,然后让用户用环境变量或--api-key传入。
/**
* collect_design_data.js
*
* 注入到目标页面运行(通过 chrome-devtools 的 evaluate_script)。
* 返回 { meta, domSnapshot, engineeredCssEvidence } JSON-serializable 对象。
*
* 使用方式(在 chrome-devtools MCP 中):
* 将此文件内容包装成 IIFE,作为 function 字符串传入 evaluate_script:
* `() => { ...本文件全部内容... ; return collectDesignData({ includeCss: true }); }`
* 也可使用 generate_design_md.py 中的 build_evaluate_script() 自动包装。
*
* 注意:此脚本不依赖任何外部库,全部使用浏览器原生 API。
*/
function collectDesignData({ includeCss = true } = {}) {
const result = {
meta: collectPageMeta(),
domSnapshot: collectDomSnapshot(),
};
if (includeCss) {
result.engineeredCssEvidence = collectCssEvidence();
}
return result;
}
function collectPageMeta() {
const metaByName = (name) => document.querySelector(`meta[name="${name}"]`)?.content || '';
const metaByProperty = (property) => document.querySelector(`meta[property="${property}"]`)?.content || '';
return {
title: document.title,
hostname: window.location.hostname,
description: metaByName('description'),
keywords: metaByName('keywords'),
ogType: metaByProperty('og:type'),
ogSiteName: metaByProperty('og:site_name'),
applicationName: metaByName('application-name'),
url: window.location.href,
};
}
function collectDomSnapshot() {
const textOf = (el, limit = 120) =>
(el?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, limit);
const allText = textOf(document.body, 14000);
const ctaSelector = [
'button',
'a[role="button"]',
'a[href]',
'[class*="btn" i]',
'[class*="button" i]',
'[data-testid*="button" i]',
].join(',');
const distinctiveSelector = [
'header',
'nav',
'main > section',
'section',
'article',
'form',
'table',
'[role="banner"]',
'[role="navigation"]',
'[role="main"]',
'[role="region"]',
'[class*="hero" i]',
'[class*="feature" i]',
'[class*="pricing" i]',
'[class*="plan" i]',
'[class*="card" i]',
'[class*="panel" i]',
'[class*="grid" i]',
'[class*="gallery" i]',
'[class*="testimonial" i]',
'[class*="metric" i]',
'[class*="stats" i]',
'[class*="badge" i]',
'[class*="marquee" i]',
].join(',');
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'))
.filter(isVisibleForEvidence)
.slice(0, 24)
.map((el) => ({ level: el.tagName.toLowerCase(), text: textOf(el, 140) }))
.filter((item) => item.text);
const navigation = Array.from(
document.querySelectorAll('nav a, header a, [role="navigation"] a')
)
.filter(isVisibleForEvidence)
.slice(0, 32)
.map((el) => textOf(el, 60))
.filter(Boolean);
const ctas = Array.from(document.querySelectorAll(ctaSelector))
.filter(isVisibleForEvidence)
.slice(0, 36)
.map((el) => ({
tag: el.tagName.toLowerCase(),
text: textOf(el, 80),
href: el.getAttribute('href') || '',
ariaLabel: el.getAttribute('aria-label') || '',
}))
.filter((item) => item.text || item.ariaLabel);
const landmarks = Array.from(
document.querySelectorAll('header,nav,main,section,article,aside,footer,[role]')
)
.filter(isVisibleForEvidence)
.slice(0, 40)
.map((el) => ({
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || '',
id: el.id || '',
className: stringifyClassName(el.className).split(/\s+/).slice(0, 6).join(' '),
text: textOf(el, 180),
}));
const distinctiveCandidates = collectDistinctiveCandidates(distinctiveSelector, textOf);
return {
headings,
navigation,
ctas,
landmarks,
distinctiveCandidates,
bodyTextSample: allText,
counts: {
forms: document.querySelectorAll('form').length,
inputs: document.querySelectorAll('input,textarea,select').length,
tables: document.querySelectorAll('table').length,
codeBlocks: document.querySelectorAll('pre,code').length,
articleContainers: document.querySelectorAll(
'article,[class*="article" i],[class*="post" i],[class*="blog" i]'
).length,
pricingSections: document.querySelectorAll(
'[class*="pricing" i],[id*="pricing" i],[class*="plans" i],[id*="plans" i]'
).length,
},
};
}
function collectDistinctiveCandidates(selector, textOf) {
const seen = new Set();
const viewportHeight = Math.max(window.innerHeight || 0, 1);
return Array.from(document.querySelectorAll(selector))
.filter((el) => {
if (!isVisibleForEvidence(el) || seen.has(el)) return false;
seen.add(el);
const rect = el.getBoundingClientRect();
const text = textOf(el, 220);
const marker = `${stringifyClassName(el.className)} ${el.id || ''}`.toLowerCase();
const hasDesignMarker = /(hero|feature|pricing|plan|card|panel|grid|gallery|testimonial|metric|stats|badge|marquee|cta|banner)/i.test(marker);
const isLargeSection = rect.width >= 240 && rect.height >= 120;
return hasDesignMarker || (isLargeSection && text.length > 20);
})
.map((el) => summarizeDistinctiveElement(el, textOf, viewportHeight))
.sort((a, b) => b.score - a.score || a.position.top - b.position.top)
.slice(0, 12)
.map(({ score, ...item }) => item);
}
function summarizeDistinctiveElement(el, textOf, viewportHeight) {
const rect = el.getBoundingClientRect();
const className = stringifyClassName(el.className);
const childTags = Array.from(el.children)
.slice(0, 8)
.map((child) => child.tagName.toLowerCase());
const mediaCount = el.querySelectorAll('img,video,canvas,svg,picture').length;
const buttonCount = el.querySelectorAll('button,a[role="button"],[class*="btn" i],[class*="button" i]').length;
const heading = textOf(el.querySelector('h1,h2,h3,h4,h5,h6'), 120);
const kind = inferDistinctiveKind(el);
const area = Math.round(rect.width * rect.height);
const foldBoost = rect.top < viewportHeight * 1.2 ? 2 : 0;
const markerBoost = kind === 'section' || kind === 'content' ? 0 : 3;
return {
kind,
selectorHint: buildSelectorHint(el),
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || '',
id: el.id || '',
className: className.split(/\s+/).slice(0, 8).join(' '),
heading,
text: textOf(el, 260),
position: {
top: Math.round(rect.top),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
structure: {
childTags,
mediaCount,
buttonCount,
},
score: area + mediaCount * 24000 + buttonCount * 12000 + markerBoost * 10000 + foldBoost * 10000,
};
}
function inferDistinctiveKind(el) {
const tag = el.tagName.toLowerCase();
const role = (el.getAttribute('role') || '').toLowerCase();
const marker = `${stringifyClassName(el.className)} ${el.id || ''}`.toLowerCase();
if (tag === 'header' || role === 'banner' || marker.includes('hero')) return 'hero/header';
if (tag === 'nav' || role === 'navigation' || marker.includes('nav')) return 'navigation';
if (tag === 'form') return 'form';
if (tag === 'table') return 'table/data';
if (marker.includes('pricing') || marker.includes('plan')) return 'pricing';
if (marker.includes('feature')) return 'feature';
if (marker.includes('testimonial')) return 'testimonial';
if (marker.includes('metric') || marker.includes('stats')) return 'metrics';
if (marker.includes('gallery')) return 'gallery/media';
if (marker.includes('marquee')) return 'marquee';
if (marker.includes('card')) return 'card';
if (marker.includes('panel')) return 'panel';
if (marker.includes('grid')) return 'grid';
if (marker.includes('badge')) return 'badge';
if (tag === 'section' || role === 'region') return 'section';
return 'content';
}
const CSS_EVIDENCE_LIMIT = 280;
function collectCssEvidence() {
const diagnostics = [];
try {
const totalElements = document.querySelectorAll('*').length;
const sampled = collectSampledElements(CSS_EVIDENCE_LIMIT);
const rows = collectComputedStyleRows(sampled);
if (sampled.length < 30) {
diagnostics.push('Low sample size: fewer than 30 visible elements were extracted.');
}
if (!document.fonts) {
diagnostics.push('document.fonts is unavailable in this browser context.');
}
return {
source: {
url: window.location.href,
title: document.title,
hostname: window.location.hostname,
},
sampledAt: new Date().toISOString(),
totalElements,
sampledElements: rows.length,
rows,
diagnostics,
};
} catch (e) {
return {
source: {
url: window.location.href,
title: document.title,
hostname: window.location.hostname,
},
sampledAt: new Date().toISOString(),
totalElements: 0,
sampledElements: 0,
rows: [],
error: e.message,
diagnostics: [`Engineering CSS evidence extraction failed: ${e.message}`],
};
}
}
function collectSampledElements(limit) {
const prioritySelectors = [
'body',
'h1,h2,h3,h4,h5,h6',
'p',
'a',
'button',
'input,textarea,select',
'label',
'nav,header,footer,main,section,article,aside',
'ul li,ol li',
'table,th,td',
'[role="button"]',
'[class*="card" i]',
'[class*="btn" i]',
'[tabindex]',
];
const seen = new Set();
const candidates = [];
const addElement = (el, priority) => {
if (!el || seen.has(el) || !isVisibleForEvidence(el)) return;
seen.add(el);
candidates.push({ el, priority, area: elementArea(el) });
};
prioritySelectors.forEach((selector, index) => {
try {
document.querySelectorAll(selector).forEach((el) => addElement(el, index));
} catch (_) {}
});
return candidates
.sort((a, b) => a.priority - b.priority || b.area - a.area)
.slice(0, limit)
.map((item) => item.el);
}
function isVisibleForEvidence(el) {
if (!(el instanceof Element)) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
return true;
}
function elementArea(el) {
const rect = el.getBoundingClientRect();
return Math.round(rect.width * rect.height);
}
function collectComputedStyleRows(elements) {
const rows = [];
for (const el of elements) {
try {
const cs = getComputedStyle(el);
const rect = el.getBoundingClientRect();
const componentType = inferComponentType(el);
const isLowConfidence = cs.opacity === '0';
rows.push({
selectorHint: buildSelectorHint(el),
componentType,
lowConfidence: isLowConfidence,
tagName: el.tagName.toLowerCase(),
role: el.getAttribute('role'),
id: el.id || null,
className: stringifyClassName(el.className),
textSample: getTextSample(el),
rect: {
width: Math.round(rect.width),
height: Math.round(rect.height),
top: Math.round(rect.top),
left: Math.round(rect.left),
},
typography: {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
lineHeight: cs.lineHeight,
letterSpacing: cs.letterSpacing,
},
color: {
color: cs.color,
backgroundColor: cs.backgroundColor,
borderColor: cs.borderColor,
outlineColor: cs.outlineColor,
},
box: {
margin: cs.margin,
padding: cs.padding,
marginTop: cs.marginTop,
marginRight: cs.marginRight,
marginBottom: cs.marginBottom,
marginLeft: cs.marginLeft,
paddingTop: cs.paddingTop,
paddingRight: cs.paddingRight,
paddingBottom: cs.paddingBottom,
paddingLeft: cs.paddingLeft,
borderRadius: cs.borderRadius,
boxShadow: cs.boxShadow,
},
motion: {
transitionDuration: cs.transitionDuration,
transitionTimingFunction: cs.transitionTimingFunction,
animationDuration: cs.animationDuration,
animationTimingFunction: cs.animationTimingFunction,
},
});
} catch (_) {}
}
return rows;
}
function inferComponentType(el) {
const tag = el.tagName.toLowerCase();
const role = (el.getAttribute('role') || '').toLowerCase();
const className = stringifyClassName(el.className).toLowerCase();
const id = (el.id || '').toLowerCase();
const marker = `${className} ${id}`;
if (/^h[1-6]$/.test(tag)) return 'heading';
if (tag === 'button' || role === 'button' || marker.includes('button') || marker.includes('btn'))
return 'button';
if (tag === 'a') return 'link';
if (['input', 'textarea', 'select'].includes(tag)) return 'input';
if (tag === 'nav' || marker.includes('nav')) return 'navigation';
if (tag === 'section') return 'section';
if (tag === 'ul' || tag === 'ol' || tag === 'li') return 'list';
if (['table', 'thead', 'tbody', 'tr', 'td', 'th'].includes(tag)) return 'table';
if (marker.includes('card')) return 'card';
if (marker.includes('hero')) return 'hero';
if (marker.includes('modal')) return 'modal';
if (marker.includes('panel')) return 'panel';
if (marker.includes('grid')) return 'grid';
if (marker.includes('container')) return 'container';
return 'content';
}
function buildSelectorHint(el) {
const tag = el.tagName.toLowerCase();
if (el.id) return `${tag}#${el.id}`;
const className = stringifyClassName(el.className);
if (className) {
return `${tag}.${className.split(/\s+/).filter(Boolean).slice(0, 3).join('.')}`;
}
const role = el.getAttribute('role');
if (role) return `${tag}[role="${role}"]`;
return tag;
}
function stringifyClassName(value) {
if (!value) return '';
if (typeof value === 'string') return value;
if (typeof value.baseVal === 'string') return value.baseVal;
return String(value);
}
function getTextSample(el) {
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) return '';
return (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80);
}
// 直接调用并返回结果(chrome-devtools evaluate_script 会序列化返回值)
return collectDesignData({ includeCss: true });
Core Principles
1. Firmly Refuse (Negative Constraints):
- Prohibit using purple/blue gradients on white backgrounds
- Prohibit using common fonts (Inter, Roboto, Arial, system-ui)
- Prohibit using predictable hero-CTA-feature-review templates
- Prohibit using common geometric shapes or abstract spots
- Prohibit using visual effects that look like stock images or clichés
Performance Optimization
- Ensure pages load quickly, avoid unnecessary large resources
- Use modern image formats (WebP) and appropriate compression
- Implement lazy loading techniques for long page content
Important Note: The implementation complexity should match the aesthetic vision. Maximalist designs require complex code as well as a lot of animations and effects. Minimalist or refined designs require restraint, precision, and attention to spacing, typography, and subtle details. Elegance comes from a perfect interpretation of the vision.
Design Thinking
Before coding, understand the context and define a bold aesthetic direction:
- Purpose: What problem does this interface solve? Who is using it?
- Style: Choose an extreme style: minimalism, maximalism, retro-futurism, organic/natural, luxury/artisanal, playful/toy-like, magazine/editorial, raw/rough, art deco/geometric, soft pastel, industrial/practical, etc. There are many styles to choose from. You can draw inspiration from them, but ensure your design truly aligns with the selected aesthetic direction.
- Constraints: Technical requirements (frameworks, performance, accessibility).
- Differentiation: What makes it memorable? What one thing will people remember?
Key: Choose a clear conceptual direction and execute it precisely. Both bold maximalism and refined minimalism are viable — the key is intention, not intensity.
Aesthetic Default Settings
- Start with composition, not components.
- Prioritize full-bleed hero images or full-canvas visual anchors.
- Make the brand or product name the most prominent text.
- Keep copy concise for quick scanning.
- Use white space, alignment, scaling, cropping, and contrast before adding borders.
- Default constraint system: no more than two fonts and one accent color.
You are a senior UI/UX designer and front-end architect.
Generate only a style analysis from DOM text/structure and page screenshots. Do not output a full DESIGN.md. Do not analyze or invent precise CSS code values.
You must identify 3-6 distinctive elements worth recreating from the screenshots and distinctiveCandidates, then describe them as few-shot module specifications. Distinctive elements may include hero sections, navigation, CTAs, pricing cards, feature cards, metric blocks, media frames, forms, motion backgrounds, badges, marquees, or any memorable visual device.
Every distinctive element must be grounded in real evidence. If evidence is limited, output fewer items instead of inventing.
Use a structure close to:
# Overall Atmosphere
## Design System
### 1. Core Style
### 2. Color Palette
### 3. Font Stack
### 4. Texture
### 5. Motion Design
## Component Guidelines
### Button / Card / Navigation / Input / Typography
## Distinctive Element Few-shot Examples
### 1. Element Name
**Purpose:**
**Evidence:**
**Visual Rules:**
- Layout:
- Color:
- Typography:
- Spacing:
- Radius / border / shadow:
- Motion:
**Recreation Prompt:**
> A few-shot description that can be given directly to an AI front-end generator.
**Structure Sketch:**
```html
<section>
...
</section>
```
Output ONLY markdown content, no explanations.
你是一名资深 UI/UX 设计师和前端架构师。
请只基于 DOM 文本/结构和页面截图生成风格分析,不要输出完整 DESIGN.md,不要分析或编造精确 CSS 代码值。
你必须从截图和 distinctiveCandidates 中识别 3-6 个网页里最值得复刻的特殊元素,把它们写成 few-shot 模块规范。特殊元素可以是 hero、导航、CTA、价格卡、功能卡、数据块、图文框、表单、动效背景、徽章、marquee 或任何视觉记忆点。
每个特殊元素都必须来自真实证据;如果证据不足,减少数量,不要编造。
输出结构必须接近:
# 整体氛围
## Design System (设计规范)
### 1. 核心风格
### 2. 配色方案
### 3. 字体栈
### 4. 质感
### 5. 动态交互 (Motion Design)
## 组件规范
### Button / Card / Navigation / Input / Typography
## 特殊元素 Few-shot 复刻样例
### 1. 元素名称
**用途:**
**识别依据:**
**视觉规则:**
- 布局:
- 色彩:
- 字体:
- 间距:
- 圆角/边框/阴影:
- 动效:
**复刻提示词:**
> 一段可直接交给 AI 前端生成器的 few-shot 描述。
**结构草图:**
```html
<section>
...
</section>
```
只输出 markdown 内容,不要输出额外解释。
#!/usr/bin/env bash
set -e
SKILL_REPO="https://raw.githubusercontent.com/liaocaoxuezhe/get-web-design/main"
SKILL_NAME="get-web-design"
# ── 检测目标平台 ──────────────────────────────
detect_target() {
if [[ "$1" != "" ]]; then echo "$1"; return; fi
if command -v claude &>/dev/null; then echo "claude-code"; return; fi
if [[ -d "$HOME/.cursor" ]]; then echo "cursor"; return; fi
if command -v codex &>/dev/null; then echo "codex"; return; fi
if command -v gemini &>/dev/null; then echo "gemini-cli"; return; fi
echo "unknown"
}
TARGET=$(detect_target "${1:-}")
# ── 下载核心文件 ──────────────────────────────
TMP=$(mktemp -d)
FILES=(
SKILL.md
skill.yaml
assets/core_principles.md
assets/design_thinking.md
assets/system_prompt_en.txt
assets/system_prompt_zh.txt
assets/collect_design_data.js
scripts/generate_design_md.py
scripts/css_evidence.py
references/setup.md
references/workflow.md
references/chrome_devtools_recipes.md
)
for f in "${FILES[@]}"; do
mkdir -p "$TMP/$(dirname "$f")"
curl -fsSL "$SKILL_REPO/$f" -o "$TMP/$f"
done
# ── 按平台安装 ────────────────────────────────
case "$TARGET" in
claude-code)
DEST="$HOME/.claude/skills/$SKILL_NAME"
mkdir -p "$DEST"
cp -r "$TMP/"* "$DEST/"
# 注入 CLAUDE.md
CLAUDE_MD="$HOME/.claude/CLAUDE.md"
if [[ -f "$CLAUDE_MD" ]]; then
echo -e "\n## Skill: $SKILL_NAME\n@$DEST/SKILL.md" >> "$CLAUDE_MD"
fi
echo "✓ Installed to Claude Code: $DEST"
echo " Skill context files are available in agent conversations."
;;
cursor)
DEST="$HOME/.cursor/skills/$SKILL_NAME"
mkdir -p "$DEST"
cp -r "$TMP/"* "$DEST/"
# 注入 .cursorrules
RULES_DIR="$HOME/.cursor/rules"
mkdir -p "$RULES_DIR"
RULES="$RULES_DIR/${SKILL_NAME}.mdc"
cp "$DEST/SKILL.md" "$RULES"
echo "✓ Installed to Cursor: $RULES"
;;
codex)
DEST="$HOME/.codex/skills/$SKILL_NAME"
mkdir -p "$DEST"
cp -r "$TMP/"* "$DEST/"
echo "✓ Installed to Codex: $DEST"
echo " Add to your AGENTS.md: @$DEST/SKILL.md"
;;
gemini-cli)
DEST="$HOME/.gemini/skills/$SKILL_NAME"
mkdir -p "$DEST"
cp -r "$TMP/"* "$DEST/"
# GEMINI.md 注入
GEMINI_MD="$HOME/.gemini/GEMINI.md"
if [[ -f "$GEMINI_MD" ]]; then
echo -e "\n## Skill: $SKILL_NAME\n@$DEST/SKILL.md" >> "$GEMINI_MD"
fi
echo "✓ Installed to Gemini CLI: $DEST"
;;
*)
echo "⚠ Could not detect target AI agent."
echo " Files downloaded to: $TMP"
echo " Manually copy SKILL.md into your agent's context."
echo ""
echo " Supported targets: claude-code, cursor, codex, gemini-cli"
echo " Try: bash install.sh <target>"
exit 1
;;
esac
rm -rf "$TMP"
echo ""
echo "🎉 $SKILL_NAME installed successfully!"
get-web-design
将任意线上网站的设计风格提取为一份结构化 DESIGN.md。本 skill 是 design-extractor Chrome 扩展的命令行 / AI Agent 移植版。
功能
- 从任意 URL 生成可用于 AI 编程提示词的
DESIGN.md - 提取站点的设计 token(颜色 / 字体 / 间距 / 圆角 / 阴影 / 动效)
- 使用多模态 LLM 分析站点的视觉风格、组件规范与整体氛围
- 识别网页中特别值得复刻的主要元素,并输出 few-shot 模块规范
- 通过 chrome-devtools MCP 采集 DOM、Computed CSS 与 3 视口截图
- 输出包含 frontmatter + design thinking + AI 风格分析 + CSS 证据 + negative constraints 的完整文档
触发场景
- "生成 https://stripe.com 的 DESIGN.md"
- "分析 linear.app 的设计风格并保存"
- "extract design from <url>"
前置依赖
1. chrome-devtools MCP —— 通过 /mcp 验证是否有 chrome-devtools 服务 2. 多模态 LLM 配置(环境变量):
WEB_DESIGN_API_KEYWEB_DESIGN_BASE_URLWEB_DESIGN_MODEL(必须支持 vision)
详见 references/setup.md。
安装
一行命令(自动检测你的 AI 工具)
curl -fsSL https://raw.githubusercontent.com/liaocaoxuezhe/get-web-design/main/install.sh | bash通过 npx skills 安装(推荐)
如果你已经安装了 `skill.sh` 生态,可以直接用以下命令安装:
npx skills add liaocaoxuezhe/get-web-design安装完成后,skill 会自动注入到当前 AI Agent 的 context 中,无需手动复制文件。
指定平台安装
# Claude Code
curl -fsSL https://raw.githubusercontent.com/liaocaoxuezhe/get-web-design/main/install.sh | bash -s -- claude-code
# Cursor
curl -fsSL https://raw.githubusercontent.com/liaocaoxuezhe/get-web-design/main/install.sh | bash -s -- cursor
# OpenAI Codex CLI
curl -fsSL https://raw.githubusercontent.com/liaocaoxuezhe/get-web-design/main/install.sh | bash -s -- codex
# Gemini CLI
curl -fsSL https://raw.githubusercontent.com/liaocaoxuezhe/get-web-design/main/install.sh | bash -s -- gemini-cli手动安装
将 SKILL.md 复制到你的项目根目录或 agent context 目录即可。
工作流
URL → [chrome-devtools] 3 截图 + collected.json
→ [Python] CSS 压缩 + LLM 调用 + 拼装
→ output/<hostname>/design.md
output/<hostname>/shot1.jpg
output/<hostname>/shot2.jpg
output/<hostname>/shot3.jpg每次抽取的所有产物都收敛到一个 output/<hostname>/ 文件夹,便于归档和复用。
详细步骤见 references/workflow.md。chrome-devtools MCP 调用配方见 references/chrome_devtools_recipes.md。
文件结构
| 路径 | 用途 |
|---|---|
SKILL.md | Skill 核心定义与使用指南 |
assets/collect_design_data.js | 注入目标页面的 DOM + CSS 采集脚本 |
assets/system_prompt_en.txt / system_prompt_zh.txt | 多模态 LLM system prompt |
assets/design_thinking.md / core_principles.md | DESIGN.md 固定头尾段落 |
scripts/generate_design_md.py | 主入口 CLI:调 LLM 与最终拼装 |
scripts/css_evidence.py | CSS computed-style → 设计 token 压缩 |
references/setup.md | chrome-devtools MCP + LLM 凭据配置 |
references/workflow.md | 完整数据流详解 |
references/chrome_devtools_recipes.md | MCP 精确调用顺序与故障兜底 |
License
MIT
chrome-devtools MCP 操作配方
本文给出从 URL 到 collected.json + 3 张截图 的精确调用步骤, 所有工具均以 mcp__chrome-devtools__* 前缀提供。
最终产物目录:
output/<hostname>/design.md
output/<hostname>/shot1.jpg
output/<hostname>/shot2.jpg
output/<hostname>/shot3.jpg<hostname> 即 URL 的 host(例如 platform.moonshot.cn)。
步骤 0:准备工作目录
mkdir -p output/<hostname>
mkdir -p /tmp/get-web-design/<run-id> # 仅用于存放中间产物 collected.json- 截图直接落到
output/<hostname>/(最终结果之一)。 collected.json是中间产物,放/tmp/...即可。
步骤 1:打开页面
mcp__chrome-devtools__new_page({ url: "<目标 URL>" })等待页面就绪:
mcp__chrome-devtools__wait_for({ text: "<页面上出现的关键字>" })
# 或退而求其次:sleep 2 秒步骤 2:截 3 张截图
Chrome 限制每秒 ≤2 次截图,所以每次截图后需要 ≥600ms 间隔。
# 截图 1:顶部
mcp__chrome-devtools__evaluate_script({
function: "() => window.scrollTo({ top: 0, behavior: 'instant' })"
})
mcp__chrome-devtools__take_screenshot({
format: "jpeg", quality: 70,
filePath: "output/<hostname>/shot1.jpg"
})
# 截图 2:35%
mcp__chrome-devtools__evaluate_script({
function: "() => window.scrollTo({ top: Math.round((document.body.scrollHeight - window.innerHeight) * 0.35), behavior: 'instant' })"
})
# 等 ~600ms(多数 MCP 实现自带;否则插一个空 evaluate_script 占用时间)
mcp__chrome-devtools__take_screenshot({
format: "jpeg", quality: 70,
filePath: "output/<hostname>/shot2.jpg"
})
# 截图 3:70%
mcp__chrome-devtools__evaluate_script({
function: "() => window.scrollTo({ top: Math.round((document.body.scrollHeight - window.innerHeight) * 0.70), behavior: 'instant' })"
})
mcp__chrome-devtools__take_screenshot({
format: "jpeg", quality: 70,
filePath: "output/<hostname>/shot3.jpg"
})
# 复位
mcp__chrome-devtools__evaluate_script({
function: "() => window.scrollTo({ top: 0, behavior: 'instant' })"
})若take_screenshot不支持filePath参数,使用 base64 返回值由 Bashecho ... | base64 -d > shot1.jpg落盘。
步骤 3:注入采集脚本
读取 assets/collect_design_data.js 全文,将其包装成立即执行函数:
const SCRIPT = readFile('<skill_root>/assets/collect_design_data.js');
mcp__chrome-devtools__evaluate_script({
function: `() => { ${SCRIPT} }`
})collect_design_data.js 末尾自带 return collectDesignData({ includeCss: true });, 所以包装层只需 () => { …全部… } 即可拿到结构化返回值。
将返回值写入:
/tmp/get-web-design/<run-id>/collected.json若返回的 JSON 字符串极大(>1MB),可在 evaluate_script 内自行
JSON.stringify(result).slice(0, MAX) 截断,或省略 bodyTextSample。步骤 4:交给 Python 脚本
python3 <skill_root>/scripts/generate_design_md.py \
--collected /tmp/get-web-design/<run-id>/collected.json \
--screenshots output/<hostname>/shot1.jpg \
output/<hostname>/shot2.jpg \
output/<hostname>/shot3.jpg \
--hostname "<hostname>"默认输出目录是 当前工作目录下的 `output/<hostname>/`:
output/<hostname>/design.md
output/<hostname>/shot1.jpg # 截图复制/直接落盘到此处
output/<hostname>/shot2.jpg
output/<hostname>/shot3.jpg如需自定义可加 --output-dir <dir> 整体改目录,或 --output <path> 仅改 markdown 路径。 脚本会自动把传入的截图复制到输出目录(截图本身已在该目录时则跳过)。
语言固定为英文(默认 --language en),请勿改为 zh,以确保整个 DESIGN.md 为英文。
故障兜底
| 现象 | 处置 |
|---|---|
take_screenshot 失败 with rate limit | sleep 1s 后重试一次 |
evaluate_script 返回 [object Object] | 改用 JSON.stringify(...) 包装返回值 |
| 页面是 SPA 且首屏 loading | navigate_page 后 wait_for 一个稳定文本,再开始截图 |
| 长滚动页面(10k+ px) | 仍按 0/35/70 百分比截,覆盖率足够;若需要更多张,留意速率限制 |
环境准备 / Setup
本 skill 有 2 项硬性外部依赖,必须在使用前由用户完成配置。
1. chrome-devtools MCP(硬依赖)
本 skill 通过 chrome-devtools MCP 完成截图与脚本注入。没有它无法工作。
安装步骤
在 Claude Code 中:
claude mcp add chrome-devtools npx chrome-devtools-mcp@latest或编辑 ~/.claude.json 添加:
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["chrome-devtools-mcp@latest"]
}
}
}验证
启动一个新的 Claude Code 会话,输入 /mcp 应能看到 chrome-devtools 已连接, 并能调用 mcp__chrome-devtools__new_page / take_screenshot / evaluate_script。
备选 MCP
若没有 chrome-devtools,本 skill 也可以改用以下任一替代(需修改调用细节):
mcp__local-browser__*mcp__Claude_in_Chrome__*
但本文档与示例命令默认使用 chrome-devtools。
---
2. 多模态 LLM 凭据(硬依赖)
本 skill 自身不内置任何 API Key —— 用户必须自行准备一个 支持多模态视觉输入 的 OpenAI 兼容接口。
必填 3 项配置
| 环境变量 | 含义 | 示例 |
|---|---|---|
WEB_DESIGN_API_KEY | API Key | sk-xxxxxxxxxxxx |
WEB_DESIGN_BASE_URL | OpenAI 兼容根路径(包含 `/v1`) | https://api.moonshot.cn/v1 |
WEB_DESIGN_MODEL | 模型名(必须支持视觉) | kimi-latest |
设置方式
方式 A — Shell 环境变量(推荐):
export WEB_DESIGN_API_KEY="sk-..."
export WEB_DESIGN_BASE_URL="https://api.moonshot.cn/v1"
export WEB_DESIGN_MODEL="kimi-latest"方式 B — CLI 参数(覆盖环境变量):
python scripts/generate_design_md.py \
--api-key "sk-..." --base-url "https://..." --model "..." \
--collected ... --screenshots ... --output DESIGN.md已知可用的多模态模型
| Provider | base_url | model 示例 |
|---|---|---|
| Moonshot Kimi | https://api.moonshot.cn/v1 | kimi-latest, moonshot-v1-32k-vision-preview |
| OpenAI | https://api.openai.com/v1 | gpt-4o, gpt-4o-mini, gpt-4-turbo |
| Anthropic(兼容代理) | OpenAI-compat 网关 | claude-3-5-sonnet-20241022, claude-opus-4-... |
| 阿里云 DashScope | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen-vl-max, qwen-vl-plus |
| 智谱 GLM | https://open.bigmodel.cn/api/paas/v4 | glm-4v, glm-4v-plus |
| 火山方舟 | https://ark.cn-beijing.volces.com/api/v3 | doubao-vision-pro-32k |
⚠️ 纯文本模型(如gpt-3.5-turbo、deepseek-chat、kimi-k2)不可用 —— skill 会调用失败或得到无视觉理解的低质量结果。
---
Python 环境
skill 仅使用标准库(urllib, json, base64, argparse 等),不依赖任何第三方包。
- Python ≥ 3.9
- 用户全局 Python(
python3)即可,不需要虚拟环境。
完整工作流(DESIGN.md 生成流程详解)
本流程移植自 design-extractor Chrome 扩展的 sidepanel + background + content-script 三方协作逻辑, 在 Claude Code 环境中由 chrome-devtools MCP + 本 skill 的 Python 脚本协作完成。
总览
chrome-devtools MCP 本地脚本 (skill)
───────────────────── ─────────────────────────
new_page(url) │
take_screenshot(top) ─┐ │
scroll → 35%, screenshot ─┼──► ai_screenshots[3]
scroll → 70%, screenshot ─┘ │
evaluate_script(collect_design_data.js) ──► collected.json
│ ├─ meta
│ ├─ domSnapshot
│ └─ engineeredCssEvidence
│
▼
generate_design_md.py
├─ normalize_css_evidence(...)
├─ format_css_evidence_markdown(...)
├─ build_messages(dom + 3 screenshots)
├─ call_llm(vision model)
└─ assemble: frontmatter + FIXED_1
+ AI 分析 + CSS Evidence
+ FIXED_2
│
▼
output/<hostname>/design.md
output/<hostname>/shot{1,2,3}.jpg各步骤细节
1. 截图采集(3 张)
- 顶部(scrollY = 0%)
- 中部偏上(scrollY = 35%)
- 中下部(scrollY = 70%)
设计原则:3 张是 Chrome captureVisibleTab 速率限制(每秒 ≤2 次)下的安全上限,且能覆盖 hero、内容主区、footer 上方过渡区。
chrome-devtools MCP 实现要点:
- 使用
mcp__chrome-devtools__navigate_page打开 URL; - 使用
mcp__chrome-devtools__evaluate_script执行window.scrollTo({top: docHeight*0.35, behavior:'instant'}); - 使用
mcp__chrome-devtools__take_screenshot保存为 PNG/JPEG; - 每次截图后 sleep ~600ms 以避开速率限制。
2. DOM + CSS 采集
通过 evaluate_script 执行 assets/collect_design_data.js,返回单个 JSON 对象:
{
"meta": { "title", "hostname", "description", "url", ... },
"domSnapshot": {
"headings": [{level, text}],
"navigation": ["...nav text..."],
"ctas": [{tag, text, href, ariaLabel}],
"landmarks": [{tag, role, id, className, text}],
"bodyTextSample": "<截断到 14k 字>",
"counts": {forms, inputs, tables, codeBlocks, articleContainers, pricingSections}
},
"engineeredCssEvidence": {
"source": {url, title, hostname},
"totalElements": <int>,
"sampledElements": <int>,
"rows": [
{
"selectorHint", "componentType", "tagName", "role", "id", "className",
"textSample", "rect": {...},
"typography": {fontFamily, fontSize, fontWeight, lineHeight, letterSpacing},
"color": {color, backgroundColor, borderColor, outlineColor},
"box": {margin*, padding*, borderRadius, boxShadow},
"motion": {transition*, animation*}
}, ...
]
}
}采样策略(与原始扩展一致):
- 优先级选择器:
body, 标题,p,a,button, 表单元素, 语义化 landmark,li, 表格,[role=button],[class*=card],[class*=btn],[tabindex] - 命中后按 priority 排序、再按可见面积降序
- 上限 280 个元素
- 过滤不可见(display:none / visibility:hidden / 0 尺寸)
保存到磁盘: chrome-devtools 的 evaluate_script 返回结构化对象,将其 JSON.stringify 后保存为 collected.json。
3. CSS 处理(高频 token 提取)
由 scripts/css_evidence.py 完成,逻辑等价于原扩展 lib/css-evidence.js:
| Token 类别 | 推断方法 |
|---|---|
color.text.primary / secondary | 全部元素 / body 类元素 color 的 top-1 高频值 |
color.surface.base | backgroundColor 的 top-1 |
color.accent | button + link 的 bg/color/border 池子 top-1 |
color.border.default | borderColor top-1 |
mode | 由 background 颜色亮度判定 light/dark/mixed |
font.family.primary/secondary | fontFamily 的 top-2,取栈中第一项 |
font.size.display/body/label | 标题最大值 / body 元素 top-1 / 最小值 |
spacing.baseUnit | 在 4/5/6/8 中选取整除命中率最高者 |
spacing.scale | margin/padding 高频 px 值 top-5 |
radius.sharp/medium/pill | 按 ≤4 / 4-16 / >16 分桶取 top-1 |
shadow.level | 按 boxShadow 出现率分级:layered / subtle elevation / rare accent / none |
motion.level | 由最大 duration 分级:subtle (<300ms) / moderate (≥300) / expressive (≥700) |
噪声值过滤: none / normal / auto / 0 / transparent / rgba(0,0,0,0) 等被剔除。
输出 Markdown 段落标题为 ## Engineering CSS Evidence(中文 ## 工程 CSS 证据),最终拼入 DESIGN.md 第 4 段。
4. AI 风格分析(多模态)
- 输入: system_prompt(中/英文,要求按固定 H1/H2/H3 输出风格分析)+ DOM snapshot JSON + 3 张截图(base64 data URL)+ 收尾指令。
- 关键约束: 提示词明确要求 "不要分析 CSS 原始数据" —— CSS 证据由脚本生成,模型只看截图和 DOM 文本。
- 接口: OpenAI 兼容
/v1/chat/completions;image 通过image_url.url = "data:image/...;base64,..."传入。 - 模型要求: 必须支持多模态视觉输入(如
kimi-latest、gpt-4o、claude-3-5-sonnet-20241022、qwen-vl-max等)。
5. 最终拼装
顺序固定,不可调换:
build_frontmatter(hostname)
↓
FIXED_TEXT_1 (Design Thinking 准则)
↓
strip_markdown_fence(ai_analysis) (AI 风格分析,去掉首尾 ``` 围栏)
↓
formatted CSS Evidence Markdown
↓
FIXED_TEXT_2 (Negative Constraints + Performance)由 assemble_design_md(...) 完成,默认写入 output/<hostname>/design.md, 并把传入的 3 张截图复制到同一目录(已在目录中的会跳过)。可用 --output-dir / --output 自定义。
失败模式与排查
| 现象 | 排查 |
|---|---|
engineeredCssEvidence missing | evaluate_script 未成功;检查页面是否拦截了脚本注入或返回值过大被截断 |
| AI 输出为空 | 检查 WEB_DESIGN_BASE_URL 是否带 /v1 后缀;检查模型是否支持 vision |
| 截图全黑 | navigate_page 后未等待页面加载,加 wait_for 或 sleep 1-2 秒 |
Low sample size 诊断 | 页面元素少(如登录墙、SPA 未加载完毕),可考虑滚动后重采 |
| HTTP 413 | 截图过大,降低截图质量或减小 viewport(可在 chrome-devtools 中 resize_page) |
"""
css_evidence.py — 将原始 computed-style 行压缩为 design tokens 并渲染为 Markdown。
输入:collect_design_data.js 返回的 engineeredCssEvidence 对象(dict)。
输出:
- normalize_css_evidence(raw) -> 归一化后的 tokens 字典
- format_css_evidence_markdown(evidence, language='zh') -> Markdown 字符串
核心思路:保留高频 token,舍弃噪声值;按角色(text/surface/accent/border 等)归类。
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Any, Callable, Dict, Iterable, List, Optional
EMPTY_LABEL_EN = "Not enough evidence"
EMPTY_LABEL_ZH = "证据不足"
LABELS = {
"en": {
"title": "Engineering CSS Evidence",
"intro": (
"Compressed from live DOM computed styles. This section keeps "
"high-frequency tokens and intent, not raw CSS dumps."
),
"tokens": "Compressed Design Tokens",
"colors": "Color Roles",
"typography": "Typography Roles",
"spacing": "Spacing Rhythm",
"radius": "Radius Roles",
"shadow": "Shadow Intent",
"motion": "Motion Intent",
"distinction": "Distinctive Implementation Signals",
"diagnostics": "Extraction Diagnostics",
"sampled": "Sampled {sampled} visible elements from {total} total DOM elements.",
"confidence": "Confidence",
"empty": EMPTY_LABEL_EN,
},
"zh": {
"title": "工程 CSS 证据",
"intro": "由实时 DOM computed styles 压缩生成。这里只保留高频 token 与设计意图,不输出原始 CSS 清单。",
"tokens": "压缩设计 Token",
"colors": "色彩角色",
"typography": "字体角色",
"spacing": "间距节奏",
"radius": "圆角角色",
"shadow": "阴影意图",
"motion": "动效意图",
"distinction": "差异化实现信号",
"diagnostics": "采集诊断",
"sampled": "从 {total} 个 DOM 元素中采样了 {sampled} 个可见元素。",
"confidence": "置信度",
"empty": EMPTY_LABEL_ZH,
},
}
NOISE_VALUES = {
"none", "normal", "auto", "initial", "inherit", "unset",
"0", "0px", "0s", "0ms",
"rgba(0, 0, 0, 0)", "rgba(0,0,0,0)", "transparent",
}
PX_RE = re.compile(r"-?\d*\.?\d+px")
RGBA_RE = re.compile(r"rgba?\(([^)]+)\)")
HEX_RE = re.compile(r"^#[0-9a-f]{3,8}$", re.IGNORECASE)
# ── 通用工具 ─────────────────────────────────────────────────────
def labels_for(language: str) -> Dict[str, str]:
return LABELS.get(language, LABELS["en"])
def empty_label(language: str) -> str:
return labels_for(language)["empty"]
def is_informative(value: Any) -> bool:
if value is None:
return False
s = str(value).strip().lower()
if not s:
return False
return s not in NOISE_VALUES
def count_values(
values: Iterable[Any], transform: Callable[[Any], Any] = lambda v: v
) -> List[Dict[str, Any]]:
counter: Counter = Counter()
for raw in values:
v = transform(raw)
if not is_informative(v):
continue
counter[str(v).strip()] += 1
items = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
return [{"value": v, "count": c} for v, c in items]
def top_rows(
values: Iterable[Any], limit: int = 3, transform: Callable[[Any], Any] = lambda v: v
) -> List[Dict[str, Any]]:
return count_values(values, transform)[:limit]
def px_number(value: Any) -> Optional[float]:
if value is None:
return None
m = PX_RE.search(str(value))
if not m:
return None
try:
return float(m.group(0)[:-2])
except ValueError:
return None
def px_numbers(value: Any) -> List[float]:
if value is None:
return []
nums = []
for m in PX_RE.findall(str(value)):
try:
n = float(m[:-2])
if n > 0:
nums.append(n)
except ValueError:
pass
return nums
def format_px(num: Optional[float]) -> Optional[str]:
if num is None or num != num: # NaN check
return None
if float(num).is_integer():
return f"{int(num)}px"
return f"{round(num, 2)}px"
def parse_duration_ms(value: Any) -> Optional[int]:
if value is None:
return None
raw = str(value).strip()
if not raw or raw in ("0s", "0ms"):
return None
parts = [p.strip() for p in raw.split(",")]
ms_values = []
for part in parts:
try:
if part.endswith("ms"):
ms_values.append(float(part[:-2]))
elif part.endswith("s"):
ms_values.append(float(part[:-1]) * 1000)
except ValueError:
continue
ms_values = [m for m in ms_values if m > 0]
return round(max(ms_values)) if ms_values else None
def canonical_color(value: Any) -> Optional[str]:
if not is_informative(value):
return None
raw = str(value).strip().lower()
if raw == "transparent":
return None
if HEX_RE.match(raw):
if len(raw) == 4:
return f"#{raw[1]*2}{raw[2]*2}{raw[3]*2}"
return raw[:7]
m = RGBA_RE.match(raw)
if not m:
return raw
parts = [p.strip() for p in m.group(1).split(",")]
if len(parts) < 3:
return raw
try:
alpha = float(parts[3]) if len(parts) >= 4 else 1.0
except ValueError:
alpha = 1.0
if alpha == 0:
return None
try:
channels = [max(0, min(255, round(float(p)))) for p in parts[:3]]
except ValueError:
return raw
return "#" + "".join(f"{c:02x}" for c in channels)
def first_font_family(font_family: Any) -> Optional[str]:
if font_family is None:
return None
for item in str(font_family).split(","):
cleaned = item.strip().strip("'\"")
if cleaned:
return cleaned
return None
def color_brightness(hex_value: str) -> Optional[float]:
if not hex_value:
return None
pairs = re.findall(r"[0-9a-f]{2}", hex_value, re.IGNORECASE)[:3]
if len(pairs) < 3:
return None
try:
nums = [int(p, 16) for p in pairs]
except ValueError:
return None
return sum(nums) / 3
# ── Token 推断 ───────────────────────────────────────────────────
def pick_color_role(rows: List[Dict], getter: Callable, limit: int = 1):
values = top_rows([getter(r) for r in rows], limit, canonical_color)
formatted = [{"value": r["value"], "usage": r["count"]} for r in values]
if limit == 1:
return formatted[0] if formatted else None
return formatted
def infer_color_tokens(rows: List[Dict], body_rows: List[Dict]) -> Dict[str, Any]:
button_rows = [r for r in rows if r.get("componentType") in ("button", "link")]
pool_rows = button_rows or rows
accent_pool = []
for r in pool_rows:
c = r.get("color") or {}
accent_pool.extend([c.get("backgroundColor"), c.get("color"), c.get("borderColor")])
text_primary = pick_color_role(rows, lambda r: (r.get("color") or {}).get("color"))
text_secondary = pick_color_role(
body_rows or rows, lambda r: (r.get("color") or {}).get("color")
)
background = pick_color_role(rows, lambda r: (r.get("color") or {}).get("backgroundColor"))
border = pick_color_role(rows, lambda r: (r.get("color") or {}).get("borderColor"))
focus = pick_color_role(rows, lambda r: (r.get("color") or {}).get("outlineColor"))
accent_top = top_rows(accent_pool, 1, canonical_color)
accent = (
{"value": accent_top[0]["value"], "usage": accent_top[0]["count"]} if accent_top else None
)
return {
"color.text.primary": text_primary,
"color.text.secondary": text_secondary,
"color.surface.base": background,
"color.accent": accent,
"color.border.default": border,
"color.focus.ring": focus,
}
def infer_mode_from_colors(colors: Iterable[Any]) -> str:
brightness = [
b for b in (color_brightness(canonical_color(c) or "") for c in colors) if b is not None
]
if not brightness:
return "unknown"
dark = sum(1 for b in brightness if b < 90)
light = sum(1 for b in brightness if b > 180)
if dark and light:
return "mixed"
if dark > light:
return "dark"
if light > dark:
return "light"
return "mixed"
def infer_typography_tokens(
rows: List[Dict], heading_rows: List[Dict], body_rows: List[Dict]
) -> Dict[str, Any]:
family_rows = top_rows([(r.get("typography") or {}).get("fontFamily") for r in rows], 2)
families = []
for row in family_rows:
primary = first_font_family(row["value"])
if primary:
families.append({"value": primary, "stack": row["value"], "usage": row["count"]})
def to_px_label(value):
n = px_number(value)
return format_px(n) if n is not None else None
size_counts = count_values(
[(r.get("typography") or {}).get("fontSize") for r in rows], to_px_label
)
body_size_top = top_rows(
[(r.get("typography") or {}).get("fontSize") for r in body_rows], 1, to_px_label
)
body_size = body_size_top[0] if body_size_top else (size_counts[0] if size_counts else None)
body_size_px = px_number(body_size["value"]) if body_size else None
heading_sizes = []
for r in heading_rows:
n = px_number((r.get("typography") or {}).get("fontSize"))
if n is not None and (body_size_px is None or n >= body_size_px):
heading_sizes.append(n)
if heading_sizes:
display_size = {"value": format_px(max(heading_sizes)), "count": len(heading_sizes)}
else:
display_size = next(
(s for s in size_counts if (px_number(s["value"]) or 0) >= 24), None
) or (size_counts[0] if size_counts else None)
label_candidates = [
s for s in size_counts if not body_size_px or (px_number(s["value"]) or 0) <= body_size_px
]
label_size = (
label_candidates[-1] if label_candidates else (size_counts[0] if size_counts else None)
)
display_px = px_number(display_size["value"]) if display_size else None
def wrap(token):
if not token:
return None
return {"value": token["value"], "usage": token["count"]}
return {
"font.family.primary": families[0] if families else None,
"font.family.secondary": families[1] if len(families) > 1 else None,
"font.size.display": wrap(display_size),
"font.size.body": wrap(body_size),
"font.size.label": wrap(label_size),
"ratio": (
f"display is {round(display_px / body_size_px, 2)}x body"
if body_size_px and display_px
else EMPTY_LABEL_EN
),
}
def infer_base_unit(numbers: List[float]) -> Optional[str]:
if not numbers:
return None
candidates = [4, 5, 6, 8]
best, best_score = candidates[0], -1
for c in candidates:
score = sum(
1
for n in numbers
if abs(n % c) < 0.1 or abs((n % c) - c) < 0.1
)
if score > best_score:
best, best_score = c, score
return f"{best}px"
def infer_spacing_tokens(rows: List[Dict]) -> Dict[str, Any]:
keys = [
"margin", "padding",
"marginTop", "marginRight", "marginBottom", "marginLeft",
"paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
]
numbers: List[float] = []
for r in rows:
box = r.get("box") or {}
for k in keys:
numbers.extend(px_numbers(box.get(k)))
numbers = [n for n in numbers if 0 < n <= 160]
common = top_rows([format_px(n) for n in numbers], 5)
return {
"baseUnit": infer_base_unit(numbers) or EMPTY_LABEL_EN,
"scale": [
{"name": f"space.{i + 1}", "value": row["value"], "usage": row["count"]}
for i, row in enumerate(common)
],
}
def infer_radius_tokens(rows: List[Dict]) -> List[Dict[str, Any]]:
radii: List[float] = []
for r in rows:
radii.extend(px_numbers((r.get("box") or {}).get("borderRadius")))
buckets = {
"sharp": [n for n in radii if n <= 4],
"medium": [n for n in radii if 4 < n <= 16],
"pill": [n for n in radii if n > 16],
}
def token_for(name: str, values: List[float]):
if not values:
return None
top = top_rows([format_px(n) for n in values], 1)
if not top:
return None
return {"name": f"radius.{name}", "value": top[0]["value"], "usage": top[0]["count"]}
return [t for t in (token_for(k, v) for k, v in buckets.items()) if t]
def infer_shadow_intent(rows: List[Dict]) -> Dict[str, Any]:
shadows = [
s for s in ((r.get("box") or {}).get("boxShadow") for r in rows) if is_informative(s)
]
if not shadows:
return {"level": "none", "usage": 0, "note": "flat surfaces dominate"}
ratio = len(shadows) / max(len(rows), 1)
unique_count = len(count_values(shadows))
if ratio > 0.25 or unique_count > 4:
level = "layered"
elif ratio > 0.08:
level = "subtle elevation"
else:
level = "rare accent"
note = (
f"{unique_count} recurring shadow treatments, compressed to intent"
if unique_count > 1
else "single recurring elevation treatment"
)
return {"level": level, "usage": len(shadows), "note": note}
def infer_motion_intent(rows: List[Dict]) -> Dict[str, Any]:
durations = []
for r in rows:
m = r.get("motion") or {}
for v in (m.get("transitionDuration"), m.get("animationDuration")):
d = parse_duration_ms(v)
if d:
durations.append(d)
easing_pool = []
for r in rows:
m = r.get("motion") or {}
easing_pool.extend([m.get("transitionTimingFunction"), m.get("animationTimingFunction")])
easings = top_rows(easing_pool, 3, lambda v: None if str(v or "").strip() == "ease" else v)
if not durations:
return {
"level": "none",
"range": EMPTY_LABEL_EN,
"durations": [],
"easingStyle": EMPTY_LABEL_EN,
}
mn, mx = min(durations), max(durations)
common = top_rows([f"{d}ms" for d in durations], 3)
if mx >= 700:
level = "expressive"
elif mx >= 300:
level = "moderate"
else:
level = "subtle"
return {
"level": level,
"range": f"{mn}-{mx}ms",
"durations": [{"value": r["value"], "usage": r["count"]} for r in common],
"easingStyle": ", ".join(f"{r['value']} ({r['count']})" for r in easings)
or "mostly default easing",
}
def infer_distinctive_signals(tokens: Dict[str, Any]) -> List[str]:
signals = []
pill = next((t for t in tokens["radius"] if t["name"] == "radius.pill"), None)
if pill:
signals.append(
f"Frequent pill radius ({pill['value']}) creates a soft/capsule interaction language."
)
if tokens["shadow"]["level"] == "none":
signals.append("Flat surfaces are preferred over decorative depth.")
if tokens["shadow"]["level"] == "layered":
signals.append(
"Elevation appears as a recurring material cue, not a one-off decoration."
)
if tokens["motion"]["level"] != "none":
signals.append(
f"Motion is {tokens['motion']['level']}, centered around {tokens['motion']['range']}."
)
ratio = tokens["typography"].get("ratio")
if ratio and ratio != EMPTY_LABEL_EN:
signals.append(f"Type hierarchy is ratio-driven: {ratio}.")
return signals[:4]
# ── 主入口 ────────────────────────────────────────────────────────
def normalize_css_evidence(raw_evidence: Optional[Dict[str, Any]]) -> Dict[str, Any]:
raw_evidence = raw_evidence or {}
raw_rows = raw_evidence.get("rows") or []
visible_rows = [r for r in raw_rows if not r.get("lowConfidence")]
rows = visible_rows or raw_rows
heading_rows = [
r for r in rows
if re.match(r"^h[1-6]$", str(r.get("tagName") or ""), re.IGNORECASE)
or r.get("componentType") == "heading"
]
body_tags = {"p", "span", "li", "label", "blockquote", "body"}
body_rows = [r for r in rows if str(r.get("tagName") or "").lower() in body_tags]
diagnostics = list(raw_evidence.get("diagnostics") or [])
sampled_elements = raw_evidence.get("sampledElements") or len(raw_rows)
total_elements = raw_evidence.get("totalElements") or 0
if raw_evidence.get("error"):
diagnostics.append(f"CSS evidence extraction failed: {raw_evidence['error']}")
if sampled_elements < 30:
diagnostics.append(
"Low sample size: fewer than 30 visible elements were extracted."
)
if not rows:
diagnostics.append("No computed style rows were available for normalization.")
color = infer_color_tokens(rows, body_rows)
tokens = {
"color": color,
"mode": infer_mode_from_colors([(r.get("color") or {}).get("backgroundColor") for r in rows]),
"typography": infer_typography_tokens(rows, heading_rows, body_rows),
"spacing": infer_spacing_tokens(rows),
"radius": infer_radius_tokens(rows),
"shadow": infer_shadow_intent(rows),
"motion": infer_motion_intent(rows),
}
tokens["distinctiveSignals"] = infer_distinctive_signals(tokens)
confidence = (
"high" if sampled_elements >= 120 else "medium" if sampled_elements >= 30 else "low"
)
return {
"source": raw_evidence.get("source"),
"sampledAt": raw_evidence.get("sampledAt"),
"tokens": tokens,
"evidenceStats": {
"totalElements": total_elements,
"sampledElements": sampled_elements,
"confidence": confidence,
"diagnostics": diagnostics,
},
}
# ── Markdown 渲染 ────────────────────────────────────────────────
def _render_token(token, language: str) -> str:
if not token:
return empty_label(language)
if token.get("value") and "usage" in token:
return f"{token['value']} ({token['usage']})"
if token.get("value"):
return token["value"]
return empty_label(language)
def _render_color_tokens(color_tokens, language) -> str:
lines = []
for name, token in (color_tokens or {}).items():
if not token:
continue
lines.append(f"- **{name}:** {_render_token(token, language)}")
if len(lines) >= 6:
break
return "\n".join(lines) if lines else f"- {empty_label(language)}"
def _render_typography_tokens(typography, language) -> str:
lines = []
for name in (
"font.family.primary",
"font.family.secondary",
"font.size.display",
"font.size.body",
"font.size.label",
):
token = (typography or {}).get(name)
if not token:
continue
if name.startswith("font.family"):
lines.append(f"- **{name}:** {token['value']} ({token['usage']})")
else:
lines.append(f"- **{name}:** {_render_token(token, language)}")
ratio = (typography or {}).get("ratio")
if ratio and ratio != EMPTY_LABEL_EN:
lines.append(f"- **ratio:** {ratio}")
return "\n".join(lines) if lines else f"- {empty_label(language)}"
def _render_spacing_tokens(spacing, language) -> str:
lines = [f"- **base unit:** {(spacing or {}).get('baseUnit') or empty_label(language)}"]
for token in (spacing or {}).get("scale") or []:
lines.append(f"- **{token['name']}:** {token['value']} ({token['usage']})")
return "\n".join(lines)
def _render_radius_tokens(radius, language) -> str:
if not radius:
return f"- {empty_label(language)}"
return "\n".join(f"- **{t['name']}:** {t['value']} ({t['usage']})" for t in radius)
def _render_motion(motion, language) -> str:
motion = motion or {}
durations = motion.get("durations") or []
duration_text = (
", ".join(_render_token(d, language) for d in durations) if durations else empty_label(language)
)
return "\n".join(
[
f"- **level:** {motion.get('level') or empty_label(language)}",
f"- **range:** {motion.get('range') or empty_label(language)}",
f"- **common durations:** {duration_text}",
f"- **easing style:** {motion.get('easingStyle') or empty_label(language)}",
]
)
def format_css_evidence_markdown(evidence: Dict[str, Any], language: str = "zh") -> str:
labels = labels_for(language)
tokens = (evidence or {}).get("tokens") or {}
diagnostics = ((evidence or {}).get("evidenceStats") or {}).get("diagnostics") or []
signals = tokens.get("distinctiveSignals") or []
stats = (evidence or {}).get("evidenceStats") or {}
body = f"""## {labels['title']}
{labels['intro']}
### {labels['tokens']}
**Mode:** {tokens.get('mode') or 'unknown'}
#### {labels['colors']}
{_render_color_tokens(tokens.get('color'), language)}
#### {labels['typography']}
{_render_typography_tokens(tokens.get('typography'), language)}
#### {labels['spacing']}
{_render_spacing_tokens(tokens.get('spacing'), language)}
#### {labels['radius']}
{_render_radius_tokens(tokens.get('radius'), language)}
#### {labels['shadow']}
- **level:** {(tokens.get('shadow') or {}).get('level') or empty_label(language)}
- **usage:** {(tokens.get('shadow') or {}).get('usage') or 0}
- **note:** {(tokens.get('shadow') or {}).get('note') or empty_label(language)}
#### {labels['motion']}
{_render_motion(tokens.get('motion'), language)}
### {labels['distinction']}
{chr(10).join(f'- {s}' for s in signals) if signals else f'- {empty_label(language)}'}
### {labels['diagnostics']}
- {labels['sampled'].replace('{sampled}', str(stats.get('sampledElements') or 0)).replace('{total}', str(stats.get('totalElements') or 0))}
- {labels['confidence']}: {stats.get('confidence') or 'low'}.
{chr(10).join(f'- {d}' for d in diagnostics) if diagnostics else '- No extraction warnings.'}"""
return body
#!/usr/bin/env python3
"""
generate_design_md.py — get-web-design 主入口脚本。
工作流程:
1. 读取 collected_data.json(由 chrome-devtools 采集,包含 meta + domSnapshot + engineeredCssEvidence)
2. 读取 3 张截图(PNG/JPEG),编码成 base64 data URL
3. 调用用户配置的多模态 LLM(OpenAI 兼容接口),结合 DOM + 截图生成风格分析
4. 将 engineeredCssEvidence 通过 css_evidence.normalize/format 处理为 Markdown
5. 拼装最终 DESIGN.md:frontmatter + design_thinking + AI 风格分析 + CSS Evidence + core_principles
环境变量(用户必填):
WEB_DESIGN_API_KEY - 多模态模型 API Key
WEB_DESIGN_BASE_URL - 多模态模型 base URL(OpenAI 兼容,如 https://api.moonshot.cn/v1)
WEB_DESIGN_MODEL - 模型名(必须支持 vision,如 kimi-latest、gpt-4o、claude-3-5-sonnet-...)
CLI:
python generate_design_md.py \\
--collected /tmp/collected.json \\
--screenshots shot1.png shot2.png shot3.png \\
--hostname example.com \\
[--output-dir output/example.com] \\
[--output output/example.com/design.md] \\
[--language zh|en]
Default output layout:
output/<hostname>/design.md
output/<hostname>/shot1.jpg
output/<hostname>/shot2.jpg
output/<hostname>/shot3.jpg
The script copies screenshots into the output dir if they are not already there.
"""
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
import os
import re
import shutil
import sys
import urllib.error
import urllib.request
from datetime import date
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parent.parent
ASSETS = SKILL_ROOT / "assets"
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
from css_evidence import format_css_evidence_markdown, normalize_css_evidence # noqa: E402
# ── 工具 ─────────────────────────────────────────────────────────
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8").strip()
def encode_image_to_data_url(path: Path) -> str:
mime, _ = mimetypes.guess_type(str(path))
if not mime or not mime.startswith("image/"):
mime = "image/jpeg"
b64 = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{b64}"
def strip_markdown_fence(text: str) -> str:
text = (text or "").strip()
outer_fence = re.fullmatch(r"```(?:markdown)?\s*\n([\s\S]*?)\n```", text, flags=re.IGNORECASE)
return outer_fence.group(1).strip() if outer_fence else text
def close_unbalanced_markdown_fences(text: str) -> str:
text = (text or "").strip()
if not text:
return ""
fence_count = sum(1 for line in text.splitlines() if re.match(r"^\s*```", line))
return f"{text}\n```" if fence_count % 2 == 1 else text
def build_frontmatter(hostname: str) -> str:
name = f"{hostname or 'Unknown'} Design System"
return (
"---\n"
f"name: {name}\n"
"version: 1.0.0\n"
f"last_updated: {date.today().isoformat()}\n"
"author: get-web-design skill\n"
"---"
)
# ── 构建 LLM 消息 ────────────────────────────────────────────────
def build_messages(collected: dict, screenshots: list[Path], language: str) -> list:
meta = collected.get("meta") or {}
dom_snapshot = collected.get("domSnapshot") or {}
hostname = meta.get("hostname") or "unknown"
title = meta.get("title") or ""
if language == "zh":
system_prompt = read_text(ASSETS / "system_prompt_zh.txt")
user_intro = (
f"网站:{hostname}\n"
f"页面标题:{title}\n\n"
f"下面是 DOM 文本与页面结构快照。请结合后续截图分析网站风格,但不要分析 CSS 原始数据。\n"
f"其中 distinctiveCandidates 是从真实 DOM 中挑出的特殊模块候选,请重点参考它们来生成“特殊元素 Few-shot 复刻样例”:\n"
f"{json.dumps({'meta': meta, 'domSnapshot': dom_snapshot}, ensure_ascii=False, indent=2)}"
)
trailing = (
"请只输出风格分析 markdown,不要包含 frontmatter、固定文本、CSS Evidence 或下载说明。"
"必须包含“特殊元素 Few-shot 复刻样例”章节,并为 3-6 个真实页面元素写出用途、"
"识别依据、视觉规则、复刻提示词和结构草图。"
)
else:
system_prompt = read_text(ASSETS / "system_prompt_en.txt")
user_intro = (
f"Website: {hostname}\n"
f"Page title: {title}\n\n"
f"Here is a DOM text and structure snapshot. Analyze the site style with the screenshots below, but do not analyze raw CSS.\n"
f'The distinctiveCandidates field contains real DOM-derived module candidates. Use it heavily when writing "Distinctive Element Few-shot Examples":\n'
f"{json.dumps({'meta': meta, 'domSnapshot': dom_snapshot}, ensure_ascii=False, indent=2)}"
)
trailing = (
"Output only the style analysis markdown. Do not include frontmatter, "
"fixed copy, CSS Evidence, or download instructions. You must include "
'"Distinctive Element Few-shot Examples" with 3-6 real page elements, '
"each containing purpose, evidence, visual rules, recreation prompt, "
"and structure sketch."
)
user_content: list = [{"type": "text", "text": user_intro}]
for shot in screenshots:
user_content.append(
{"type": "image_url", "image_url": {"url": encode_image_to_data_url(shot)}}
)
user_content.append({"type": "text", "text": trailing})
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
]
# ── 调用 LLM ─────────────────────────────────────────────────────
def call_llm(messages: list, *, api_key: str, base_url: str, model: str, timeout: int = 600) -> str:
url = f"{base_url.rstrip('/')}/chat/completions"
payload = {"model": model, "stream": False, "messages": messages}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
err_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
raise RuntimeError(f"LLM HTTP {e.code}: {err_body[:1000]}") from e
parsed = json.loads(body)
try:
choice = parsed["choices"][0]
msg = choice.get("message") or {}
content = msg.get("content")
if isinstance(content, list):
# Anthropic-style content blocks
text_parts = [
blk.get("text", "")
for blk in content
if isinstance(blk, dict) and blk.get("type") in ("text", "output_text")
]
return "".join(text_parts).strip()
if isinstance(content, str):
return content.strip()
return (choice.get("text") or "").strip()
except (KeyError, IndexError, TypeError) as e:
raise RuntimeError(f"无法从 LLM 响应中提取内容: {body[:500]}") from e
# ── 主流程 ───────────────────────────────────────────────────────
def assemble_design_md(
*, hostname: str, ai_analysis: str, css_evidence_md: str
) -> str:
safe_ai_analysis = close_unbalanced_markdown_fences(strip_markdown_fence(ai_analysis))
parts = [
build_frontmatter(hostname),
read_text(ASSETS / "design_thinking.md"),
safe_ai_analysis,
(css_evidence_md or "").strip(),
read_text(ASSETS / "core_principles.md"),
]
return "\n\n".join(p for p in parts if p)
def main() -> int:
parser = argparse.ArgumentParser(description="Generate DESIGN.md from collected web data.")
parser.add_argument("--collected", required=True, type=Path,
help="JSON file containing meta + domSnapshot + engineeredCssEvidence")
parser.add_argument("--screenshots", nargs="+", required=True, type=Path,
help="Up to 3 screenshot image files (top / mid / lower)")
parser.add_argument("--hostname", default="", help="Hostname; defaults to value in meta")
parser.add_argument(
"--output-dir", default=None, type=Path, dest="output_dir",
help="Output directory. Defaults to ./output/<hostname>/. "
"design.md and copies of the screenshots are written here."
)
parser.add_argument(
"--output", default=None, type=Path,
help="Explicit design.md path. Overrides --output-dir for the markdown only. "
"Defaults to <output-dir>/design.md."
)
parser.add_argument("--language", choices=["zh", "en"], default="en")
parser.add_argument("--api-key", default=os.environ.get("WEB_DESIGN_API_KEY", ""))
parser.add_argument("--base-url", default=os.environ.get("WEB_DESIGN_BASE_URL", ""))
parser.add_argument("--model", default=os.environ.get("WEB_DESIGN_MODEL", ""))
args = parser.parse_args()
if not args.api_key or not args.base_url or not args.model:
sys.stderr.write(
"[ERROR] Missing LLM config. Set --api-key / --base-url / --model "
"or env WEB_DESIGN_API_KEY / WEB_DESIGN_BASE_URL / WEB_DESIGN_MODEL.\n"
)
return 2
if not args.collected.exists():
sys.stderr.write(f"[ERROR] collected file not found: {args.collected}\n")
return 2
for shot in args.screenshots:
if not shot.exists():
sys.stderr.write(f"[ERROR] screenshot not found: {shot}\n")
return 2
collected = json.loads(args.collected.read_text(encoding="utf-8"))
hostname = args.hostname or (collected.get("meta") or {}).get("hostname") or "unknown"
output_dir = args.output_dir or Path("output") / hostname
output_dir.mkdir(parents=True, exist_ok=True)
output_path = args.output or (output_dir / "design.md")
output_path.parent.mkdir(parents=True, exist_ok=True)
args.output = output_path
copied: list[Path] = []
for idx, shot in enumerate(args.screenshots, start=1):
src = shot.resolve()
dst = (output_dir / f"shot{idx}{shot.suffix.lower() or '.jpg'}").resolve()
if src == dst:
copied.append(dst)
continue
try:
shutil.copy2(src, dst)
copied.append(dst)
except OSError as e:
sys.stderr.write(f"[WARN] failed to copy screenshot {src} -> {dst}: {e}\n")
if copied:
print(f"[INFO] Screenshots placed in {output_dir} ({len(copied)} files)",
file=sys.stderr)
raw_evidence = collected.get("engineeredCssEvidence") or {
"error": "engineeredCssEvidence missing from collected data",
"diagnostics": ["engineeredCssEvidence missing from collected data"],
}
normalized = normalize_css_evidence(raw_evidence)
css_md = format_css_evidence_markdown(normalized, language=args.language)
print(f"[INFO] Calling LLM model={args.model} screenshots={len(args.screenshots)}",
file=sys.stderr)
messages = build_messages(collected, args.screenshots, args.language)
ai_analysis = call_llm(
messages,
api_key=args.api_key,
base_url=args.base_url,
model=args.model,
)
if not ai_analysis:
sys.stderr.write("[WARN] LLM returned empty content.\n")
final_md = assemble_design_md(
hostname=hostname,
ai_analysis=ai_analysis,
css_evidence_md=css_md,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(final_md, encoding="utf-8")
print(f"[OK] Wrote {args.output} ({len(final_md)} chars)", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
name: get-web-design
version: 1.0.0
description: "Extract a complete DESIGN.md style guide from any live website URL. Captures screenshots, samples DOM/computed CSS via chrome-devtools, uses a multimodal LLM for visual style analysis, and merges everything into a single structured DESIGN.md."
author: liaocaoxuezhe
license: MIT
tags: [design, frontend, css, ai-agent, web-scraping]
# skill.sh 安装时会把哪些文件注入到 agent context
context_files:
- SKILL.md
- assets/core_principles.md
- assets/design_thinking.md
- assets/system_prompt_en.txt
- assets/system_prompt_zh.txt
- references/setup.md
- references/workflow.md
- references/chrome_devtools_recipes.md
# 随 skill 一起安装的可执行脚本
scripts:
- scripts/generate_design_md.py
- scripts/css_evidence.py
- assets/collect_design_data.js