
Market Breadth Heatmap
- 6 installs
- 46 repo stars
- Updated April 9, 2026
- cyhzzz/market-breadth-heatmap-skill
Fetches industry MA20 above-rate data and generates a light-toned market-breadth heatmap PNG aggregated to 26 primary industry groups.
About
This skill pulls MA20 above-rate data from dapanyuntu.com, aggregates 86 sub-industries into 26 primary groups, and renders a light-palette market-breadth heatmap PNG. A trader or analyst uses it to track market breadth over recent trading days.
- 26 primary industries across 31 trading-day columns
- Playwright Chromium capture into a 3:4 PNG
Market Breadth Heatmap by the numbers
- 6 all-time installs (skills.sh)
- Ranked #836 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cyhzzz/market-breadth-heatmap-skill --skill market-breadth-heatmapAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 46 |
| Last updated | April 9, 2026 |
| Repository | cyhzzz/market-breadth-heatmap-skill ↗ |
What it does
Fetches industry MA20 above-rate data and generates a light-toned market-breadth heatmap PNG aggregated to 26 primary industry groups.
Files
市场宽度热力图
大盘云图数据 → 浅色系热力图 PNG。一级行业 MA20 站上率变化追踪。
规则
- 数据来源:
https://sckd.dapanyuntu.com/,每日 16:00 更新 - 输出格式:3:4 比例(690×920px)浅色系 PNG 图片
- 行业层级:86 个二级行业 → 26 个一级行业大类聚合(取均值)
- 色阶:0-100,浅色系渐变(淡紫 → 浅蓝 → 浅绿 → 浅黄 → 浅橙)
- 时间范围:默认最近 31 个交易日(约 6 周)
- 数据聚合:过滤 0 值(无数据),对有效值取算术平均
- 截图引擎:Playwright Chromium(来自 ljg-card 的 capture.js)
- 质控标准:
- 26 个一级行业全部显示
- 31 个日期列全部对齐
- 颜色与数值对应准确
- 底部统计正确(最强/最弱行业、全市场均值)
- PNG 文件清晰、无截断、无渲染残留
工作流程
Step 1:获取原始数据
读取 <skill-base>/references/data-fetch.md,按其步骤获取 API 数据。
数据结构:
{
"data": [[date_idx, industry_idx, value], ...],
"dates": ["2026-02-24", ...],
"industries": ["专业服务", "专用设备", ...],
"page": 0,
"start_date": "2026-02-23",
"end_date": "2026-04-09"
}Step 2:按一级行业聚合
读取 <skill-base>/references/industry-mapping.md,将 86 个二级行业映射到 26 个一级行业大类。
聚合逻辑:
- 对每个
(category, date)组合,收集所有子行业的有效值(>0) - 计算算术平均值,保留 1 位小数
- 按最新日期的值降序排列行业
执行:
cd "<skill-base>" && python3 scripts/generate.py --mode aggregate --input <raw_data.json> --output aggregated_data.jsonStep 3:生成浅色系热力图 HTML
使用 <skill-base>/assets/heatmap_template.html 作为模板,注入聚合后的数据。
模板特点:
- 浅色背景:
#ffffff - 浅色系色阶:
#e0e7ff→#bae6fd→#bbf7d0→#fef08a→#fdba74 - 3:4 比例:690×920px
- 响应式布局:flexbox + gap
执行:
cd "<skill-base>" && python3 scripts/generate.py --mode render --input aggregated_data.json --template assets/heatmap_template.html --output heatmap.htmlStep 4:HTML → PNG 截图
使用 Playwright 将 HTML 渲染为 PNG 图片。
截图工具:<skill-base>/assets/capture.js(来自 ljg-card)
cd "<skill-base>" && python3 scripts/generate.py --mode capture --input heatmap.html --output market_breadth_heatmap.png依赖安装(首次使用):
cd "<skill-base>" && npm install && npx playwright install chromiumStep 5:质控检查
验证 PNG 输出: 1. 打开 PNG 文件,检查:
- 26 个行业标签全部可见
- 31 个日期列全部对齐
- 颜色渐变平滑,数值与颜色对应
- 底部统计正确
- 图片清晰、无截断
2. 检查文件大小:约 50-200 KB 3. 确认 3:4 比例:690×920px
Step 6:交付
报告文件路径:
[查看热力图](computer:///workspace/market_breadth_heatmap.png)输出格式
最终交付物为单个 PNG 图片文件(690×920px),通过 HTML 中间产物渲染生成。
文件命名:market_breadth_heatmap.png
依赖
- Python 3(数据获取 + 聚合)
- Node.js + Playwright Chromium(HTML → PNG 截图)
- 依赖声明:
<skill-base>/package.json
#!/usr/bin/env node
const path = require('path');
async function main() {
const args = process.argv.slice(2);
const htmlPath = args[0];
const outputPath = args[1];
const width = parseInt(args[2]) || 1080;
const height = parseInt(args[3]) || 1440;
const dpr = parseInt(args[4]) || 2;
if (!htmlPath || !outputPath) {
console.error('Usage: node capture.js <html> <png> [width] [height] [dpr]');
process.exit(1);
}
let chromium;
try {
chromium = require('playwright').chromium;
} catch {
console.error('Playwright not found. Run: npx playwright install chromium');
process.exit(1);
}
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: dpr,
});
const page = await context.newPage();
const fileUrl = 'file://' + path.resolve(htmlPath);
await page.goto(fileUrl, { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
await page.screenshot({
path: path.resolve(outputPath),
type: 'png',
});
await browser.close();
console.log('OK: ' + path.resolve(outputPath) + ` (${width * dpr}x${height * dpr} actual pixels, ${dpr}x DPR)`);
}
main().catch(err => {
console.error(err.message);
process.exit(1);
});
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080">
<title>市场宽度热力图</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1080px;
height: 1440px;
overflow: hidden;
background: #f0f2f5;
font-family: 'Noto Sans SC', sans-serif;
}
.card {
width: 1080px;
height: 1440px;
background: #ffffff;
padding: 44px 38px 32px;
position: relative;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, #818cf8, #38bdf8, #34d399, #fbbf24, #fb923c);
}
.header { flex-shrink: 0; margin-bottom: 16px; }
.header-top { display: flex; justify-content: space-between; align-items: flex-start; }
.title {
font-size: 32px; font-weight: 900; color: #1e293b;
letter-spacing: 0.5px; line-height: 1.3;
}
.title em {
font-style: normal;
background: linear-gradient(135deg, #6366f1, #0ea5e9);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.meta-row { display: flex; align-items: center; gap: 16px; margin-top: 8px; }
.subtitle { font-size: 16px; color: #94a3b8; font-weight: 400; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
background: #f0fdf4; border: 1px solid #bbf7d0;
border-radius: 16px; padding: 4px 14px;
font-size: 14px; color: #16a34a; font-weight: 500;
font-family: 'JetBrains Mono', monospace;
}
.badge-dot {
width: 7px; height: 7px; border-radius: 50%; background: #22c55e;
}
.divider {
height: 1px; background: linear-gradient(90deg, #e2e8f0, transparent 80%);
margin-bottom: 14px; flex-shrink: 0;
}
.heatmap-area {
flex: 1; display: flex; flex-direction: column;
min-height: 0; position: relative; z-index: 1;
}
.date-row { display: flex; align-items: center; margin-bottom: 4px; padding-left: 96px; }
.date-cell {
flex: 1; text-align: center; font-family: 'JetBrains Mono', monospace;
font-size: 12px; color: #94a3b8; line-height: 1;
}
.heatmap-body {
flex: 1; display: flex; flex-direction: column;
gap: 2px; justify-content: space-between;
}
.h-row { display: flex; align-items: center; gap: 0; flex: 1; min-height: 0; }
.ind-label {
width: 88px; flex-shrink: 0; font-size: 14px; font-weight: 500;
color: #64748b; text-align: right; padding-right: 10px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
line-height: 1;
}
.cells { flex: 1; display: flex; gap: 2px; }
.cell {
flex: 1; border-radius: 3px; display: flex; align-items: center;
justify-content: center; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-weight: 500;
min-width: 0; position: relative;
}
.cell.empty { background: #f8fafc !important; }
.legend { flex-shrink: 0; margin-top: 16px; display: flex; align-items: center; gap: 14px; }
.legend-text { font-size: 13px; color: #94a3b8; font-weight: 500; }
.legend-bar-wrap { flex: 1; }
.legend-bar { height: 10px; border-radius: 5px; width: 100%; }
.legend-ticks { display: flex; justify-content: space-between; margin-top: 4px; }
.legend-tick { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: #cbd5e1; }
.footer {
flex-shrink: 0; margin-top: 16px; display: flex;
justify-content: space-between; align-items: flex-end;
padding-top: 14px; border-top: 1px solid #f1f5f9;
}
.stats { display: flex; gap: 32px; }
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-lbl { font-size: 12px; color: #94a3b8; letter-spacing: 0.5px; }
.stat-val { font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; line-height: 1.2; }
.stat-val.g { color: #16a34a; }
.stat-val.r { color: #dc2626; }
.stat-val.y { color: #d97706; }
.src { font-size: 12px; color: #cbd5e1; text-align: right; line-height: 1.6; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="header-top">
<div>
<div class="title">市场宽度 · <em>鱼盆热力图</em></div>
<div class="meta-row">
<span class="subtitle">一级行业 MA20 站上率变化</span>
<span class="badge"><span class="badge-dot"></span><span id="badgeDate"></span></span>
</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="heatmap-area">
<div class="date-row" id="dateRow"></div>
<div class="heatmap-body" id="heatBody"></div>
</div>
<div class="legend">
<span class="legend-text">极弱</span>
<div class="legend-bar-wrap">
<div class="legend-bar" id="legendBar"></div>
<div class="legend-ticks">
<span class="legend-tick">0</span>
<span class="legend-tick">25</span>
<span class="legend-tick">50</span>
<span class="legend-tick">75</span>
<span class="legend-tick">100</span>
</div>
</div>
<span class="legend-text">极强</span>
</div>
<div class="footer">
<div class="stats">
<div class="stat">
<span class="stat-lbl">最强行业</span>
<span class="stat-val g" id="sBest"></span>
</div>
<div class="stat">
<span class="stat-lbl">最弱行业</span>
<span class="stat-val r" id="sWorst"></span>
</div>
<div class="stat">
<span class="stat-lbl">全市场均值</span>
<span class="stat-val y" id="sAvg"></span>
</div>
</div>
<div class="src">
数据来源:大盘云图 dapanyuntu.com<br>
每日16:00更新 · 仅供参考
</div>
</div>
</div>
<script>
const D = __DATA__;
function color(v) {
if (v == null) return { bg: 'transparent', fg: 'transparent' };
v = Math.max(0, Math.min(100, v));
let r, g, b;
if (v <= 20) {
const t = v / 20;
r = 224 - t * 38 | 0; g = 231 - t * 1 | 0; b = 255 - t * 6 | 0;
} else if (v <= 40) {
const t = (v - 20) / 20;
r = 186 - t * 5 | 0; g = 230 - t * 17 | 0; b = 249 - t * 36 | 0;
} else if (v <= 60) {
const t = (v - 40) / 20;
r = 181 + t * 72 | 0; g = 237 - t * 3 | 0; b = 208 - t * 69 | 0;
} else if (v <= 80) {
const t = (v - 60) / 20;
r = 253 + t * 0 | 0; g = 240 - t * 62 | 0; b = 138 - t * 50 | 0;
} else {
const t = (v - 80) / 20;
r = 253 - t * 2 | 0; g = 178 - t * 36 | 0; b = 88 - t * 28 | 0;
}
const bg = `rgb(${r},${g},${b})`;
const lum = (r * 299 + g * 587 + b * 114) / 1000;
const fg = lum > 180 ? 'rgba(0,0,0,0.55)' : 'rgba(0,0,0,0.7)';
return { bg, fg };
}
(function render() {
const { categories: cats, dates, data } = D;
const n = dates.length;
document.getElementById('badgeDate').textContent = dates[n - 1] + ' 收盘';
const dr = document.getElementById('dateRow');
dr.innerHTML = '';
for (let i = 0; i < n; i++) {
const dc = document.createElement('div');
dc.className = 'date-cell';
const show = i % 5 === 0 || i === n - 1;
dc.textContent = show ? dates[i].slice(5).replace('-', '/') : '';
dc.style.visibility = show ? 'visible' : 'hidden';
dr.appendChild(dc);
}
const body = document.getElementById('heatBody');
body.innerHTML = '';
cats.forEach(cat => {
const row = document.createElement('div');
row.className = 'h-row';
const lbl = document.createElement('div');
lbl.className = 'ind-label'; lbl.textContent = cat;
row.appendChild(lbl);
const cells = document.createElement('div');
cells.className = 'cells';
const vals = data[cat];
for (let i = 0; i < n; i++) {
const c = document.createElement('div');
const v = vals[i];
if (v == null) { c.className = 'cell empty'; }
else {
const { bg, fg } = color(v);
c.className = 'cell'; c.style.background = bg; c.style.color = fg;
c.textContent = Math.round(v);
}
cells.appendChild(c);
}
row.appendChild(cells);
body.appendChild(row);
});
const stops = [];
for (let i = 0; i <= 20; i++) { const { bg } = color(i * 5); stops.push(`${bg} ${(i / 20 * 100).toFixed(1)}%`); }
document.getElementById('legendBar').style.background = `linear-gradient(90deg, ${stops.join(',')})`;
const last = cats.map(c => ({ c, v: data[c][n - 1] || 0 }));
last.sort((a, b) => b.v - a.v);
document.getElementById('sBest').textContent = last[0].c + ' ' + last[0].v.toFixed(1);
document.getElementById('sWorst').textContent = last[last.length - 1].c + ' ' + last[last.length - 1].v.toFixed(1);
const avg = (last.reduce((s, x) => s + x.v, 0) / last.length).toFixed(1);
document.getElementById('sAvg').textContent = avg + '%';
})();
</script>
</body>
</html>
{
"dependencies": {
"playwright": "^1.58.2"
}
}
Market Breadth Heatmap Skill
大盘云图市场宽度热力图生成工具。将 sckd.dapanyuntu.com 的一级行业 MA20 站上率数据聚合为浅色系热力图 PNG。
功能特性
- 数据获取:从大盘云图 API 抓取原始行业 MA20 站上率数据
- 行业聚合:86 个二级行业 → 26 个一级行业大类(算术平均)
- 热力图渲染:浅色系渐变(淡紫 → 浅蓝 → 浅绿 → 浅黄 → 浅橙)
- 高清输出:3:4 比例(690×920px @2x),PNG 格式
目录结构
market-breadth-heatmap-skill/
├── SKILL.md # 技能定义
├── README.md # 本文件
├── package.json # Node.js 依赖(Playwright)
├── assets/
│ ├── heatmap_template.html # 热力图 HTML 模板
│ └── capture.js # Playwright 截图工具
├── references/
│ ├── data-fetch.md # 数据获取指南
│ ├── industry-mapping.md # 行业映射表
│ └── taste.md # 设计品味准则
└── scripts/
└── generate.py # Python 生成脚本快速开始
安装依赖
cd market-breadth-heatmap-skill
pip install -r requirements.txt # Python 依赖(如有)
npm install # Playwright 依赖
npx playwright install chromium # 安装 Chromium 浏览器一键生成热力图
cd market-breadth-heatmap-skill
python3 scripts/generate.py --mode all --output-dir ./分步执行
# Step 1: 获取原始数据
python3 scripts/generate.py --mode fetch --output raw_data.json
# Step 2: 按一级行业聚合
python3 scripts/generate.py --mode aggregate --input raw_data.json --output aggregated_data.json
# Step 3: 生成 HTML
python3 scripts/generate.py --mode render \
--input aggregated_data.json \
--template assets/heatmap_template.html \
--output heatmap.html
# Step 4: HTML → PNG 截图
python3 scripts/generate.py --mode capture --input heatmap.html --output heatmap.png输出示例
生成的 PNG 热力图包含:
- 26 个一级行业:按最新交易日 MA20 站上率降序排列
- 31 个交易日:约 6 周历史数据
- 浅色系色阶:0-100 数值映射到淡紫 → 浅蓝 → 浅绿 → 浅黄 → 浅橙
- 底部统计:最强行业、最弱行业、全市场均值

数据说明
数据来源
- API:
https://sckd.dapanyuntu.com/api/api/industry_ma20_analysis_page?page=0 - 更新频率:每日 16:00(收盘后)
- Headers 要求:
Referer: https://sckd.dapanyuntu.com/
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36行业聚合
| 二级行业数 | 一级行业数 | 聚合方法 |
|---|---|---|
| 86 | 26 | 算术平均(过滤 0 值) |
色阶定义
| 数值范围 | 颜色 |
|---|---|
| 0-20 | 淡紫 #e0e7ff |
| 20-40 | 浅蓝 #bae6fd |
| 40-60 | 浅绿 #bbf7d0 |
| 60-80 | 浅黄 #fef08a |
| 80-100 | 浅橙 #fdba74 |
技术栈
- Python 3:数据获取与聚合
- Node.js + Playwright:HTML → PNG 截图
- HTML/CSS:热力图模板渲染
使用场景
- 投顾服务:为客户生成市场宽度可视化报告
- 交易策略:追踪行业轮动与市场情绪变化
- 内容创作:小红书、公众号等平台的财经图表素材
注意事项
- 0 值表示无数据(非真正的 0%),聚合时会自动过滤
- 色阶采用浅色系设计,适合深色/浅色背景皆可使用
- PNG 输出约 50-200 KB,适合直接嵌入文档或社交媒体
License
MIT
数据获取方式
从大盘云图网站获取市场宽度原始数据。
API 端点
GET https://sckd.dapanyuntu.com/api/api/industry_ma20_analysis_page?page=0请求要求
必须携带以下 Headers,否则返回 403:
Referer: https://sckd.dapanyuntu.com/
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36获取方式
方式一:curl(推荐)
curl -s \
-H "Referer: https://sckd.dapanyuntu.com/" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
"https://sckd.dapanyuntu.com/api/api/industry_ma20_analysis_page?page=0" \
-o raw_data.json方式二:浏览器内 fetch
如果 curl 被限制,可通过浏览器自动化工具在页面内执行:
const r = await fetch('/api/api/industry_ma20_analysis_page?page=0');
const d = await r.json();返回数据结构
{
"data": [[date_idx, industry_idx, value], ...],
"dates": ["2026-02-24", "2026-02-25", ...],
"industries": ["专业服务", "专用设备", "中药", ...],
"page": 0,
"start_date": "2026-02-23",
"end_date": "2026-04-09"
}关键说明
data数组中每个元素为[date_idx, industry_idx, value]date_idx:日期索引(0-30),对应dates数组industry_idx:行业索引(0-85),对应industries数组value:MA20 站上率(0-100),0 表示无数据dates:交易日列表,通常 31 个交易日industries:86 个二级行业名称- 总数据点:86 × 31 = 2666
数据更新频率
每日 16:00(收盘后)更新。end_date 通常为当天或前一交易日。
注意事项
- 直接用 WebFetch 访问 API 会返回 403,必须用 curl 或浏览器内 fetch
- 如果数据量增大超过单页,可能需要翻页(
page=1等),但目前 page=0 即可覆盖全部 - 0 值表示该行业在该日期无数据(非真正的 0%),聚合时应过滤
行业映射表
86 个二级行业 → 26 个一级行业大类映射。
映射来源:大盘云图网页源码中的 industryCategoryMap 对象。
完整映射
INDUSTRY_CATEGORY_MAP = {
# 有色金属 (4个子行业)
"有色金属": "有色金属",
"小金属": "有色金属",
"能源金属": "有色金属",
"贵金属": "有色金属",
# 医药 (6个子行业)
"中药": "医药",
"化学制药": "医药",
"医疗器械": "医药",
"医疗服务": "医药",
"医药商业": "医药",
"生物制品": "医药",
# 传媒 (2个子行业)
"文化传媒": "传媒",
"游戏": "传媒",
# 电子 (4个子行业)
"半导体": "电子",
"消费电子": "电子",
"光学光电子": "电子",
"电子元件": "电子",
# 机械 (5个子行业)
"专用设备": "机械",
"仪器仪表": "机械",
"工程机械": "机械",
"电机": "机械",
"通用设备": "机械",
# 通信 (2个子行业)
"通信设备": "通信",
"通信服务": "通信",
# 轻工制造 (4个子行业)
"包装材料": "轻工制造",
"家用轻工": "轻工制造",
"家电行业": "轻工制造",
"造纸印刷": "轻工制造",
# 商贸零售 (5个子行业)
"商业百货": "商贸零售",
"旅游酒店": "商贸零售",
"珠宝首饰": "商贸零售",
"美容护理": "商贸零售",
"贸易行业": "商贸零售",
# 汽车 (3个子行业)
"汽车整车": "汽车",
"汽车服务": "汽车",
"汽车零部件": "汽车",
# 交通运输 (5个子行业)
"交运设备": "交通运输",
"物流行业": "交通运输",
"航空机场": "交通运输",
"航运港口": "交通运输",
"铁路公路": "交通运输",
# 银行 (1个子行业)
"银行": "银行",
# 化工 (9个子行业)
"农药兽药": "化工",
"化学制品": "化工",
"化学原料": "化工",
"化纤行业": "化工",
"化肥行业": "化工",
"塑料制品": "化工",
"橡胶制品": "化工",
"电子化学品": "化工",
"非金属材料": "化工",
# 纺织服装 (1个子行业)
"纺织服装": "纺织服装",
# 计算机 (3个子行业)
"互联网服务": "计算机",
"计算机设备": "计算机",
"软件开发": "计算机",
# 建筑 (6个子行业)
"工程咨询服务": "建筑",
"工程建设": "建筑",
"水泥建材": "建筑",
"玻璃玻纤": "建筑",
"装修建材": "建筑",
"装修装饰": "建筑",
# 食品饮料 (2个子行业)
"酿酒行业": "食品饮料",
"食品饮料": "食品饮料",
# 钢铁 (1个子行业)
"钢铁行业": "钢铁",
# 综合 (3个子行业)
"专业服务": "综合",
"综合行业": "综合",
"教育": "综合",
# 农林牧渔 (1个子行业)
"农牧饲渔": "农林牧渔",
# 金融 (3个子行业)
"证券": "金融",
"保险": "金融",
"多元金融": "金融",
# 石油 (1个子行业)
"石油行业": "石油",
# 国防军工 (2个子行业)
"航天航空": "国防军工",
"船舶制造": "国防军工",
# 电力 (6个子行业)
"光伏设备": "电力",
"电池": "电力",
"电源设备": "电力",
"电网设备": "电力",
"风电设备": "电力",
"电力行业": "电力",
# 房地产 (2个子行业)
"房地产开发": "房地产",
"房地产服务": "房地产",
# 公用事业 (3个子行业)
"公用事业": "公用事业",
"燃气": "公用事业",
"环保行业": "公用事业",
# 煤炭 (2个子行业)
"煤炭行业": "煤炭",
"采掘行业": "煤炭",
}聚合方法
对每个一级行业大类,在每个日期上:
1. 收集所有子行业的值 2. 过滤掉 0 值(无数据) 3. 对剩余有效值取算术平均 4. 保留 1 位小数
排序规则
按最新日期(最后一个交易日)的聚合值降序排列行业,最强行业在上。
设计品味准则(全模具通用)
所有模具生成 HTML 前,必须经过本准则校验。这是视觉质量的底线。
1. 基线参数
| 维度 | 默认值 | 含义 |
|---|---|---|
| DESIGN_VARIANCE | 8 | 1=完美对称,10=艺术混沌 |
| VISUAL_DENSITY | 4 | 1=画廊留白,10=驾驶舱信息密度 |
根据模具自动调整:
-l长图:DESIGN_VARIANCE=5, VISUAL_DENSITY=3(阅读舒适优先)。变化通过色调感知实现——不同内容气质对应不同背景底色和强调色(见 mode-long.md 步骤 2.5)-i信息图:DESIGN_VARIANCE=7, VISUAL_DENSITY=8(数据密度优先)。变化通过动态 REF 编码和内容驱动的自定义布局实现-c海报:DESIGN_VARIANCE=9, VISUAL_DENSITY=2(视觉冲击优先)。与长图共享色调系统,结尾标记仅在末页出现
2. 排版工程
标题
- 大标题:
tracking-tighter(字间距紧凑),leading-none(行高极小) - 禁用 Inter 字体。长图/海报用衬线体(Noto Serif SC),信息图用等宽+无衬线混排
- 仪表盘/技术类场景严禁衬线体——只用高端无衬线(Geist、Satoshi、Cabinet Grotesk)
正文
- 默认:
text-base、leading-relaxed、最大行宽65ch -i信息图:正文 ≥36px、行高 ≥1.6、标注 ≥24px(手机端 1080px→390px 缩放 2.8 倍后需可读)- 段落文本颜色避免纯黑,用
#333或#4a4a4a等深灰
数字
- 当 VISUAL_DENSITY > 7(信息图模式),所有数字用等宽字体(
font-family: monospace)
3. 色彩校准
硬性规则
- 最多 1 个强调色,饱和度 < 80%
- 禁止「AI 紫蓝」:紫色按钮光晕、霓虹渐变一律禁止
- 同一张图内严格统一冷暖调——不在暖灰和冷灰之间摇摆
- 禁止纯黑
#000000:用 Off-Black(#1a1a1a)、Zinc-950 或炭灰
渐变约束
- 不要对大标题使用渐变填充文字
- 背景渐变仅限微妙过渡,避免色彩跳跃
4. 布局多样化
DESIGN_VARIANCE > 4 时
- 禁止居中 Hero:标题不要默认居中。用左对齐、分屏、非对称留白
- 禁止「三等分卡片」:3 列等宽并排是 AI 生成的头号标志。用 2 列锯齿、非对称网格、或横向滚动替代
DESIGN_VARIANCE ≥ 8 时
- 使用 CSS Grid 分数单位(如
grid-template-columns: 2fr 1fr 1fr) - 允许大面积留白(
padding-left: 20vw级别的空间感) - 允许 Masonry 式错落布局
卡片与容器
- 卡片仅在层级关系(elevation)有功能需求时使用
- 数据指标让它们「呼吸」——用
border-top、divide-y或纯留白分组,而非一个个方盒子 - 阴影必须染色(与背景色调一致),不要灰色默认阴影
5. AI 生成禁忌清单
生成任何视觉内容前,逐项排查以下 AI 典型痕迹:
视觉 & CSS
- 禁止外发光:不要
box-shadow默认光晕。用内边框或染色阴影 - 禁止过饱和强调色:强调色必须与中性色优雅融合
- 禁止自定义鼠标指针(静态图不涉及,但生成 HTML 时也不要加)
排版
- 禁止 Inter 字体:用 Geist、Outfit、Cabinet Grotesk 或 Satoshi
- 禁止 H1 尖叫:标题不要靠单纯放大来建立层级。用字重和颜色控制
内容 & 数据(「Jane Doe 效应」)
- 禁止通用人名:John Doe、Sarah Chan、Jack Su 禁止出现。用有创意的真实名字
- 禁止假数据:不要
99.99%、50%、1234567。用有机的「脏」数据(47.2%、+1 (312) 847-1928) - 禁止创业烂名:Acme、Nexus、SmartFlow 禁止。发明有品味的品牌名
- 禁止 AI 文案腔:「赋能」「无缝」「释放」「下一代」禁止。用具体动词
- 禁止 Unsplash 链接:如需占位图,用
https://picsum.photos/seed/{随机字符串}/800/600或 SVG
间距 & 对齐
- padding 和 margin 必须数学精确,不留尴尬间隙
- 相邻元素严格对齐,视觉线条贯通
6. 材质与表面
玻璃态(Glassmorphism)
如需毛玻璃效果,不要只用 backdrop-blur。必须叠加:
- 1px 内边框:
border: 1px solid rgba(255,255,255,0.1) - 微妙内阴影:
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1)
模拟物理边缘折射。
圆角
- 主容器用大圆角(
border-radius: 2.5rem) - 扩散阴影(极淡、大范围):
box-shadow: 0 20px 40px -15px rgba(0,0,0,0.05)
7. 出厂自检
生成 HTML 后、截图前,逐项确认:
- [ ] 是否避免了居中 Hero(DESIGN_VARIANCE > 4 时)?
- [ ] 是否避免了三等分等宽卡片?
- [ ] 标题是否用了非 Inter 字体?
- [ ] 颜色是否统一冷暖调,无纯黑?
- [ ] 强调色是否 ≤ 1 个且饱和度 < 80%?
- [ ] 数据是否真实感(非 99.99% 式假数据)?
- [ ] 文案是否去除了 AI 腔(赋能/无缝/释放)?
- [ ] 间距是否数学精确,无尴尬留白?
- [ ] 阴影是否染色(非灰色默认)?
#!/usr/bin/env python3
"""
市场宽度热力图生成脚本
用法:
python3 generate.py --mode fetch [--output raw_data.json]
python3 generate.py --mode aggregate --input raw_data.json --output aggregated_data.json
python3 generate.py --mode render --input aggregated_data.json --template heatmap_template.html --output result.html
python3 generate.py --mode all [--output-dir ./]
"""
import argparse
import json
import os
import sys
import urllib.request
from collections import defaultdict
# ── 行业映射表 ──────────────────────────────────────────────
INDUSTRY_CATEGORY_MAP = {
"有色金属": "有色金属", "小金属": "有色金属", "能源金属": "有色金属", "贵金属": "有色金属",
"中药": "医药", "化学制药": "医药", "医疗器械": "医药", "医疗服务": "医药",
"医药商业": "医药", "生物制品": "医药",
"文化传媒": "传媒", "游戏": "传媒",
"半导体": "电子", "消费电子": "电子", "光学光电子": "电子", "电子元件": "电子",
"专用设备": "机械", "仪器仪表": "机械", "工程机械": "机械", "电机": "机械", "通用设备": "机械",
"通信设备": "通信", "通信服务": "通信",
"包装材料": "轻工制造", "家用轻工": "轻工制造", "家电行业": "轻工制造", "造纸印刷": "轻工制造",
"商业百货": "商贸零售", "旅游酒店": "商贸零售", "珠宝首饰": "商贸零售",
"美容护理": "商贸零售", "贸易行业": "商贸零售",
"汽车整车": "汽车", "汽车服务": "汽车", "汽车零部件": "汽车",
"交运设备": "交通运输", "物流行业": "交通运输", "航空机场": "交通运输",
"航运港口": "交通运输", "铁路公路": "交通运输",
"银行": "银行",
"农药兽药": "化工", "化学制品": "化工", "化学原料": "化工", "化纤行业": "化工",
"化肥行业": "化工", "塑料制品": "化工", "橡胶制品": "化工",
"电子化学品": "化工", "非金属材料": "化工",
"纺织服装": "纺织服装",
"互联网服务": "计算机", "计算机设备": "计算机", "软件开发": "计算机",
"工程咨询服务": "建筑", "工程建设": "建筑", "水泥建材": "建筑",
"玻璃玻纤": "建筑", "装修建材": "建筑", "装修装饰": "建筑",
"酿酒行业": "食品饮料", "食品饮料": "食品饮料",
"钢铁行业": "钢铁",
"专业服务": "综合", "综合行业": "综合", "教育": "综合",
"农牧饲渔": "农林牧渔",
"证券": "金融", "保险": "金融", "多元金融": "金融",
"石油行业": "石油",
"航天航空": "国防军工", "船舶制造": "国防军工",
"光伏设备": "电力", "电池": "电力", "电源设备": "电力",
"电网设备": "电力", "风电设备": "电力", "电力行业": "电力",
"房地产开发": "房地产", "房地产服务": "房地产",
"公用事业": "公用事业", "燃气": "公用事业", "环保行业": "公用事业",
"煤炭行业": "煤炭", "采掘行业": "煤炭",
}
API_URL = "https://sckd.dapanyuntu.com/api/api/industry_ma20_analysis_page?page=0"
def fetch_data(output_path):
"""Step 1: 从 API 获取原始数据"""
print(f"正在获取数据: {API_URL}")
req = urllib.request.Request(API_URL, headers={
"Referer": "https://sckd.dapanyuntu.com/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"已保存原始数据: {output_path}")
print(f" 行业数: {len(data['industries'])}, 日期数: {len(data['dates'])}, 数据点: {len(data['data'])}")
return data
def aggregate_data(input_path, output_path):
"""Step 2: 按一级行业聚合"""
with open(input_path, encoding="utf-8") as f:
raw = json.load(f)
industries = raw["industries"]
dates = raw["dates"]
data = raw["data"]
unmapped = [ind for ind in industries if ind not in INDUSTRY_CATEGORY_MAP]
if unmapped:
print(f"警告: {len(unmapped)} 个行业未映射: {unmapped}")
cat_date_vals = defaultdict(lambda: defaultdict(list))
for date_idx, ind_idx, val in data:
ind = industries[ind_idx]
cat = INDUSTRY_CATEGORY_MAP.get(ind)
if cat:
cat_date_vals[cat][date_idx].append(val)
cat_avg = {}
for cat, date_vals in cat_date_vals.items():
cat_avg[cat] = {}
for date_idx, vals in date_vals.items():
valid = [v for v in vals if v > 0]
cat_avg[cat][date_idx] = round(sum(valid) / len(valid), 1) if valid else None
latest_col = len(dates) - 1
sorted_cats = sorted(cat_avg.keys(), key=lambda c: cat_avg[c].get(latest_col, 0) or 0, reverse=True)
output = {
"categories": sorted_cats,
"dates": dates,
"data": {cat: [cat_avg[cat].get(i) for i in range(len(dates))] for cat in sorted_cats}
}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"已保存聚合数据: {output_path}")
print(f" 一级行业: {len(sorted_cats)}, 日期: {len(dates)}")
return output
def render_html(input_path, template_path, output_path):
"""Step 3: 注入数据到模板生成 HTML"""
with open(input_path, encoding="utf-8") as f:
data = json.load(f)
with open(template_path, encoding="utf-8") as f:
template = f.read()
data_str = json.dumps(data, ensure_ascii=False)
html = template.replace("__DATA__", data_str)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"已生成热力图: {output_path}")
print(f" 文件大小: {len(html):,} bytes")
return output_path
def capture_html(html_path, output_path, width=1080, height=1440, dpr=2):
"""Step 4: 使用 Playwright 将 HTML 转换为 PNG(2x 高清)"""
import subprocess
skill_base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
capture_js = os.path.join(skill_base, "assets", "capture.js")
if not os.path.exists(capture_js):
print(f"错误: capture.js 不存在 {capture_js}")
print("请运行: npm install && npx playwright install chromium")
return None
print(f"正在截图: {html_path} -> {output_path} ({width}x{height}, {dpr}x DPR)")
cmd = [
"node", capture_js,
html_path, output_path, str(width), str(height), str(dpr)
]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=skill_base)
if result.returncode != 0:
print(f"截图失败: {result.stderr}")
return None
print(f"已生成 PNG: {output_path}")
print(f" 尺寸: {width}x{height}")
return output_path
def main():
parser = argparse.ArgumentParser(description="市场宽度热力图生成工具")
parser.add_argument("--mode", required=True, choices=["fetch", "aggregate", "render", "capture", "all"])
parser.add_argument("--input", help="输入文件路径")
parser.add_argument("--output", help="输出文件路径")
parser.add_argument("--template", help="HTML 模板路径")
parser.add_argument("--output-dir", default="./", help="all 模式的输出目录")
parser.add_argument("--width", type=int, default=1080, help="PNG 输出宽度(默认 1080)")
parser.add_argument("--height", type=int, default=1440, help="PNG 输出高度(默认 1440)")
args = parser.parse_args()
if args.mode == "fetch":
output = args.output or os.path.join(args.output_dir, "raw_data.json")
fetch_data(output)
elif args.mode == "aggregate":
if not args.input:
print("错误: --aggregate 模式需要 --input 参数"); sys.exit(1)
output = args.output or os.path.join(args.output_dir, "aggregated_data.json")
aggregate_data(args.input, output)
elif args.mode == "render":
if not args.input or not args.template:
print("错误: --render 模式需要 --input 和 --template 参数"); sys.exit(1)
output = args.output or os.path.join(args.output_dir, "market_breadth_heatmap.html")
render_html(args.input, args.template, output)
elif args.mode == "capture":
if not args.input:
print("错误: --capture 模式需要 --input 参数"); sys.exit(1)
output = args.output or os.path.join(args.output_dir, "market_breadth_heatmap.png")
capture_html(args.input, output, args.width, args.height)
elif args.mode == "all":
skill_base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template = os.path.join(skill_base, "assets", "heatmap_template.html")
raw_path = os.path.join(args.output_dir, "raw_data.json")
agg_path = os.path.join(args.output_dir, "aggregated_data.json")
html_path = os.path.join(args.output_dir, "market_breadth_heatmap.html")
png_path = os.path.join(args.output_dir, "market_breadth_heatmap.png")
if not os.path.exists(template):
print(f"错误: 模板不存在 {template}"); sys.exit(1)
fetch_data(raw_path)
aggregate_data(raw_path, agg_path)
render_html(agg_path, template, html_path)
capture_html(html_path, png_path, args.width, args.height)
print(f"\n完成! PNG 热力图已生成: {png_path}")
if __name__ == "__main__":
main()