feat: 初始化混沌学园微信公众号 HTML 排版 Skill

- SKILL.md: 完整设计规范(色彩体系、微信公众号兼容规则、图片排版方案)
- scripts/extract_wechat.py: 微信公众号文章抓取脚本
- scripts/build_hundun_html.py: 混沌风格 HTML 生成脚本(组件化)
- examples/hundun-lead-article.html: 领教营二期排版示例
- resources/color-palette.md: 混沌品牌色彩参考
This commit is contained in:
2026-07-22 12:06:55 +08:00
commit a777665775
7 changed files with 1114 additions and 0 deletions
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
混沌学园风格微信公众号 HTML 生成脚本
用途:将提取的图片列表与文字内容,生成符合混沌品牌视觉体系的微信公众号 HTML 排版。
使用方法:
python3 build_hundun_html.py <extracted_content.txt> <输出HTML路径>
设计规范:
- 白底 (#FFFFFF) + 混沌黄 (#F5B700) + 石墨黑 (#181818)
- Hero 大图 + 双列瀑布流副图
- 100% 内联样式,微信公众号编辑器兼容
- 所有图片加 referrerpolicy="no-referrer"
详细规范请参阅 SKILL.md
"""
import sys
import re
import html as html_lib
from pathlib import Path
# ============================================================
# 混沌色彩体系常量
# ============================================================
COLOR_PRIMARY = "#F5B700" # 混沌黄
COLOR_SECONDARY = "#D99B00" # 深金(正文强调)
COLOR_DARK = "#181818" # 石墨黑
COLOR_DARK_SUB = "#242424" # 辅助深色(深色卡片子容器)
COLOR_BG = "#FFFFFF" # 原生白底
COLOR_CARD = "#F8F9FA" # 极浅灰(内容卡片)
COLOR_MODULE = "#FFFDF5" # 微黄底(课程模块卡片)
COLOR_TEXT = "#2B2B2B" # 主正文
COLOR_TEXT_ALT = "#333333" # 辅助正文
COLOR_TEXT_SUB = "#4E5969" # 二级正文
COLOR_BORDER = "#E5E6EB" # 边框灰
COLOR_DIVIDER = "#D9D9D9" # 虚线分割
FONT_STACK = (
"-apple-system, BlinkMacSystemFont, 'PingFang SC', "
"'Hiragino Sans GB', 'Microsoft YaHei', sans-serif"
)
def loadImages(filePath: str) -> dict:
"""
从 extracted_content.txt 加载图片索引映射。
:param filePath: 提取结果文件路径
:return: {索引: URL} 字典
"""
imgDict = {}
with open(filePath, 'r', encoding='utf-8') as f:
for line in f:
match = re.match(r'\[(\d+)\]\s+(https?://\S+)', line.strip())
if match:
idx = int(match.group(1))
url = html_lib.unescape(match.group(2))
imgDict[idx] = url
return imgDict
def renderHeroMasonry(imgDict: dict, imgIndices: list, maxSub: int = 6) -> str:
"""
渲染 Hero 大图 + 双列瀑布流副图。
:param imgDict: 全局图片索引字典
:param imgIndices: 本组图片索引列表
:param maxSub: 最大副图数量
:return: HTML 字符串
"""
urls = [imgDict[i] for i in imgIndices if i in imgDict]
if not urls:
return ""
heroUrl = urls[0]
subUrls = urls[1:1 + maxSub]
heroHtml = f'''
<section style="margin: 12px 0 10px 0;">
<img src="{heroUrl}" referrerpolicy="no-referrer"
style="width: 100%; height: auto; display: block; border-radius: 10px;
border: 1px solid {COLOR_BORDER};
box-shadow: 0 4px 12px rgba(0,0,0,0.08);" />
</section>
'''
if not subUrls:
return heroHtml
leftUrls = subUrls[0::2]
rightUrls = subUrls[1::2]
def buildCol(colUrls):
imgs = []
for u in colUrls:
imgs.append(
f'<img src="{u}" referrerpolicy="no-referrer"'
f' style="width: 100%; height: auto; display: block;'
f' border-radius: 6px; border: 1px solid {COLOR_BORDER};'
f' box-shadow: 0 2px 6px rgba(0,0,0,0.05);'
f' margin-bottom: 8px;" />'
)
return "\n".join(imgs)
leftHtml = buildCol(leftUrls)
rightHtml = buildCol(rightUrls)
masonryHtml = f'''
<section style="display: flex; justify-content: space-between;
align-items: flex-start; width: 100%;
box-sizing: border-box; margin-bottom: 10px;">
<section style="width: 48.5%; box-sizing: border-box;">
{leftHtml}
</section>
<section style="width: 48.5%; box-sizing: border-box;">
{rightHtml}
</section>
</section>
'''
return heroHtml + masonryHtml
def renderYearCard(year: str, title: str, desc: str, imgHtml: str,
highlight: bool = False) -> str:
"""
渲染时间线年份卡片。
:param year: 年份文字(如"2018年冬"
:param title: 小标题
:param desc: 描述段落
:param imgHtml: 内部图片 HTML(由 renderHeroMasonry 生成)
:param highlight: 是否用混沌黄边框高亮(用于最新年份)
:return: HTML 字符串
"""
borderStyle = f"2px solid {COLOR_PRIMARY}" if highlight else f"1px solid {COLOR_BORDER}"
badgeBg = COLOR_PRIMARY if highlight else COLOR_DARK
badgeColor = "#000000" if highlight else COLOR_PRIMARY
titleColor = COLOR_DARK
return f'''
<section style="margin-bottom: 24px; background-color: {COLOR_CARD};
padding: 16px; border-radius: 12px; border: {borderStyle};">
<section style="display: flex; align-items: center; margin-bottom: 8px;">
<span style="background-color: {badgeBg}; color: {badgeColor};
font-size: 12px; font-weight: 800; padding: 2px 8px;
border-radius: 4px; margin-right: 8px;">{year}</span>
<h3 style="font-size: 16px; font-weight: 700; color: {titleColor}; margin: 0;">{title}</h3>
</section>
<p style="font-size: 14px; line-height: 1.7; color: {COLOR_TEXT_SUB}; margin: 0 0 10px 0;">
{desc}
</p>
{imgHtml}
</section>
'''
def renderModuleCard(name: str, dateRange: str, theme: str, desc: str) -> str:
"""
渲染课程模块卡片(微黄底)。
:param name: 模块名称(如"一模块 · 亮剑"
:param dateRange: 时间(如"2026.8.29 - 8.30"
:param theme: 主题文字
:param desc: 描述文字
:return: HTML 字符串
"""
return f'''
<section style="margin-bottom: 14px; background-color: {COLOR_MODULE};
padding: 16px; border-radius: 12px;
border: 1px solid {COLOR_PRIMARY};">
<section style="display: flex; justify-content: space-between;
align-items: center; margin-bottom: 8px;">
<h3 style="font-size: 16px; font-weight: 800; color: {COLOR_DARK}; margin: 0;">{name}</h3>
<span style="font-size: 12px; color: {COLOR_DARK}; background: {COLOR_PRIMARY};
font-weight: 700; padding: 2px 8px; border-radius: 4px;">{dateRange}</span>
</section>
<p style="font-size: 13px; color: {COLOR_SECONDARY}; margin: 0 0 6px 0; font-weight: 700;">{theme}</p>
<p style="font-size: 14px; color: {COLOR_TEXT_SUB}; margin: 0; line-height: 1.6;">{desc}</p>
</section>
'''
def renderRequirementCard(icon: str, title: str, items: list) -> str:
"""
渲染招募要求卡片。
:param icon: emoji 图标
:param title: 卡片标题(如"1. 产业研究能力"
:param items: 列表项文字列表
:return: HTML 字符串
"""
liHtml = "\n".join(f" <li>{item}</li>" for item in items)
return f'''
<section style="margin-bottom: 14px; background-color: {COLOR_CARD};
padding: 16px; border-radius: 12px;
border-left: 4px solid {COLOR_DARK};
border-top: 1px solid {COLOR_BORDER};
border-right: 1px solid {COLOR_BORDER};
border-bottom: 1px solid {COLOR_BORDER};">
<h3 style="font-size: 15px; font-weight: 700; color: {COLOR_DARK};
margin: 0 0 10px 0;">{icon} {title}</h3>
<ul style="margin: 0; padding-left: 18px; color: {COLOR_TEXT_SUB};
font-size: 14px; line-height: 1.7;">
{liHtml}
</ul>
</section>
'''
def wrapPage(innerHtml: str) -> str:
"""
将内部 HTML 包裹为完整的微信公众号页面容器。
:param innerHtml: 内部所有模块的 HTML
:return: 完整的 HTML 片段
"""
return f'''<section style="max-width: 667px; margin: 0 auto; box-sizing: border-box;
background-color: {COLOR_BG}; color: {COLOR_TEXT};
font-family: {FONT_STACK}; padding: 12px 4px;">
{innerHtml}
</section>'''
# ============================================================
# 示例用法(可按需修改 main 逻辑适配不同文章)
# ============================================================
if __name__ == "__main__":
if len(sys.argv) < 3:
print("用法: python3 build_hundun_html.py <extracted_content.txt> <输出HTML路径>")
print("示例: python3 build_hundun_html.py ./output/extracted_content.txt ./output/article.html")
sys.exit(1)
inputFile = sys.argv[1]
outputFile = sys.argv[2]
imgDict = loadImages(inputFile)
print(f"📦 已加载 {len(imgDict)} 张图片索引")
# 示例:生成一个简单的年份卡片
imgHtml = renderHeroMasonry(imgDict, [1, 2, 3, 4, 5])
yearCard = renderYearCard("2024年", "示例年份", "这是一段示例描述文字。", imgHtml)
fullHtml = wrapPage(yearCard)
Path(outputFile).write_text(fullHtml, encoding='utf-8')
print(f"✅ HTML 已生成: {outputFile}")
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
微信公众号文章抓取脚本
用途:抓取微信公众号文章原始 HTML,提取文字内容与图片 URL 列表。
使用方法:
python3 extract_wechat.py <微信文章URL> [输出目录]
示例:
python3 extract_wechat.py "https://mp.weixin.qq.com/s/xxxx" ./output
输出文件:
- raw_article.html 原始 HTML 源码
- extracted_content.txt 提取的图片列表与纯文本摘要
"""
import sys
import os
import re
import urllib.request
import html as html_lib
from pathlib import Path
def fetch_wechat_html(url: str) -> str:
"""
使用桌面微信 User-Agent 抓取微信公众号文章原始 HTML。
:param url: 微信公众号文章 URL
:return: 原始 HTML 字符串
:raises: urllib.error.URLError 如果请求失败
"""
headers = {
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36 '
'MicroMessenger/8.0.0'
),
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode('utf-8', errors='replace')
def extractTitle(htmlContent: str) -> str:
"""从 HTML 中提取文章标题。"""
match = re.search(r'<title[^>]*>(.*?)</title>', htmlContent, re.DOTALL | re.IGNORECASE)
if match:
title = re.sub(r'<[^>]+>', '', match.group(1)).strip()
return html_lib.unescape(title)
return "未知标题"
def extractImages(htmlContent: str) -> list:
"""
从微信文章 HTML 中提取所有图片 URL。
优先提取 data-src 属性(微信懒加载),其次 src 属性。
:param htmlContent: 原始 HTML 字符串
:return: 去重后的图片 URL 列表
"""
imgUrls = []
seen = set()
# 优先匹配 data-src(微信懒加载图片)
for match in re.finditer(r'data-src=["\']([^"\']+mmbiz[^"\']+)["\']', htmlContent):
url = html_lib.unescape(match.group(1))
if url not in seen:
imgUrls.append(url)
seen.add(url)
# 补充匹配 src(部分图片无 data-src
for match in re.finditer(r'<img[^>]+src=["\']([^"\']+mmbiz[^"\']+)["\']', htmlContent):
url = html_lib.unescape(match.group(1))
if url not in seen:
imgUrls.append(url)
seen.add(url)
return imgUrls
def extractText(htmlContent: str) -> str:
"""
提取文章正文区域的纯文本(js_content 内部)。
:param htmlContent: 原始 HTML 字符串
:return: 清理后的纯文本
"""
contentMatch = re.search(
r'id=["\']js_content["\'][^>]*>(.*?)(?=<div\s+class=["\']ct_mpda_wrp|$)',
htmlContent,
re.DOTALL | re.IGNORECASE
)
if not contentMatch:
return ""
text = contentMatch.group(1)
# 移除所有 HTML 标签
text = re.sub(r'<[^>]+>', ' ', text)
# 解码 HTML 实体
text = html_lib.unescape(text)
# 清理多余空白
text = re.sub(r'\s+', ' ', text).strip()
return text
def main():
"""主入口函数。"""
if len(sys.argv) < 2:
print("用法: python3 extract_wechat.py <微信文章URL> [输出目录]")
print("示例: python3 extract_wechat.py 'https://mp.weixin.qq.com/s/xxxx' ./output")
sys.exit(1)
url = sys.argv[1]
outputDir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("./output")
outputDir.mkdir(parents=True, exist_ok=True)
print(f"📡 正在抓取: {url}")
htmlContent = fetch_wechat_html(url)
# 保存原始 HTML
rawPath = outputDir / "raw_article.html"
rawPath.write_text(htmlContent, encoding='utf-8')
print(f"💾 原始 HTML 已保存: {rawPath} ({len(htmlContent):,} 字节)")
# 提取标题
title = extractTitle(htmlContent)
print(f"📰 标题: {title}")
# 提取图片
imgUrls = extractImages(htmlContent)
print(f"🖼️ 发现 {len(imgUrls)} 张图片")
# 提取文本
textContent = extractText(htmlContent)
# 写入提取结果
extractPath = outputDir / "extracted_content.txt"
with open(extractPath, 'w', encoding='utf-8') as f:
f.write(f"Title: {title}\n\n")
f.write(f"Images ({len(imgUrls)}):\n")
for i, imgUrl in enumerate(imgUrls, 1):
f.write(f"[{i}] {imgUrl}\n")
f.write(f"\n--- 正文摘要 (前 2000 字) ---\n\n")
f.write(textContent[:2000])
print(f"📄 提取结果已保存: {extractPath}")
print(f"\n✅ 完成!共提取 {len(imgUrls)} 张图片,正文约 {len(textContent)} 字。")
if __name__ == "__main__":
main()