Files
jiamu 5579c7e70a 🎉 gzh-design-skill · 甲木 × 摸鱼小李 联名首发
微信公众号排版技能:把 Markdown 一键排成可直接粘进公众号编辑器的精致 HTML。
- 6 套精选厚主题(摸鱼绿/红白/石墨极简/留白禅意/摸鱼票据/橄榄手记)+ 主题生成器
- 文章类型→配方表装配、关键词下划线、全角标点、图片自适应
- 双关卡质量校验(component_lint + validate_gzh_html)
- 一键复制预览页,样式粘贴不掉
- AGPL-3.0 · 欢迎 AI Agent / 模型厂商共创

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:41:17 +08:00

170 lines
7.0 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""微信公众号 HTML 合规校验器。
把 SKILL.md 里"必须遵守的平台限制"从模型自觉变成确定性兜底。
排版生成后必跑:检查禁用标签/属性/样式,并核查文字节点是否用
<span leaf=""> 包裹(公众号编辑器粘贴后保持样式的关键)。
用法:
validate_gzh_html.py <file.html>
validate_gzh_html.py --stdin < file.html
退出码: 1 = 有 ERROR(会被公众号过滤或粘贴后样式丢失); 0 = 通过。
"""
import argparse
import re
import sys
from html.parser import HTMLParser
# (正则, 级别, 说明) —— ERROR 会被公众号编辑器过滤掉或导致样式失效
FORBIDDEN = [
(re.compile(r"<style[\s>]", re.I), "ERROR", "<style> 标签会被过滤,样式必须内联"),
(re.compile(r"<script[\s>]", re.I), "ERROR", "<script> 标签会被过滤"),
(re.compile(r"</?div[\s>]", re.I), "ERROR", "<div> 会被改写,请用 <section>"),
(re.compile(r"<link[\s>]", re.I), "ERROR", "外部 <link>(CSS/字体)会被过滤"),
(re.compile(r"\sclass\s*=", re.I), "ERROR", "class 属性会被剥离,请用内联 style"),
(re.compile(r"\sid\s*=", re.I), "ERROR", "id 属性会被剥离"),
(re.compile(r"position\s*:\s*(fixed|absolute|sticky)", re.I), "ERROR",
"position fixed/absolute/sticky 不被支持"),
(re.compile(r"float\s*:", re.I), "ERROR", "float 不被支持"),
(re.compile(r"@media", re.I), "ERROR", "@media 媒体查询不被支持"),
(re.compile(r"@keyframes", re.I), "ERROR", "@keyframes 动画不被支持"),
(re.compile(r"@import", re.I), "ERROR", "@import 不被支持"),
(re.compile(r"display\s*:\s*grid", re.I), "ERROR", "display:grid 不被支持,请用 flex"),
(re.compile(r"var\s*\(\s*--", re.I), "ERROR", "CSS 变量 var(--x) 不被支持,请写死值"),
(re.compile(r"url\s*\(\s*['\"]?https?://[^)]*\.(woff2?|ttf|otf|eot)", re.I),
"ERROR", "外部字体不被支持"),
]
CJK = re.compile(r"[一-鿿㐀-䶿]")
SKIP_TAGS = {"head", "title", "style", "script"} # 不参与公众号正文粘贴的区域
# 中文字后紧跟半角逗号/分号/叹号/问号(应改全角);只查"中文在前"避免中英混排误伤
HALF_PUNCT = re.compile(r"[一-鿿㐀-䶿][,;!?]")
ASCII_QUOTE = re.compile(r"[\"']")
# 代码区特征:等宽字体或 white-space:pre —— 其内半角符号是正常的
CODE_STYLE = re.compile(r"monospace|white-space\s*:\s*pre|courier|consolas|sf mono", re.I)
class LeafChecker(HTMLParser):
"""检查每个非空文本节点是否处于 <span leaf> 内。"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.stack = [] # [(tag, is_leaf, is_code)]
self.leaf_depth = 0 # 处于 span[leaf] 内的嵌套计数
self.code_depth = 0 # 处于代码区(等宽/pre)内的嵌套计数
self.span_leaf_count = 0 # 全文 span leaf 总数
self.unwrapped = [] # (文本片段, 父标签) —— 未被 leaf 包裹的中文文本
self.half_punct = [] # 正文里疑似半角标点的片段
def handle_starttag(self, tag, attrs):
ad = dict(attrs)
is_leaf = tag == "span" and "leaf" in ad
is_code = bool(CODE_STYLE.search(ad.get("style", "") or ""))
if is_leaf:
self.span_leaf_count += 1
self.leaf_depth += 1
if is_code:
self.code_depth += 1
self.stack.append((tag, is_leaf, is_code))
def handle_endtag(self, tag):
for i in range(len(self.stack) - 1, -1, -1):
if self.stack[i][0] == tag:
for _, was_leaf, was_code in self.stack[i:]:
if was_leaf:
self.leaf_depth -= 1
if was_code:
self.code_depth -= 1
del self.stack[i:]
break
def handle_data(self, data):
text = data.strip()
if not text or not CJK.search(text):
return
if any(t in SKIP_TAGS for t, _, _ in self.stack):
return # <head>/<title>/<style>/<script> 内文字不进公众号正文
if self.leaf_depth == 0:
parent = self.stack[-1][0] if self.stack else "(root)"
snippet = text[:24] + ("…" if len(text) > 24 else "")
self.unwrapped.append((snippet, parent))
if self.code_depth == 0 and (HALF_PUNCT.search(text)
or ASCII_QUOTE.search(text)):
snippet = text[:24] + ("…" if len(text) > 24 else "")
self.half_punct.append(snippet)
def validate(html, name="<input>"):
errors, warnings = [], []
for rx, level, msg in FORBIDDEN:
hits = len(rx.findall(html))
if hits:
(errors if level == "ERROR" else warnings).append(
f"{msg}(命中 {hits} 处)")
checker = LeafChecker()
try:
checker.feed(html)
except Exception as e: # 容错:解析失败不致命,只提示
warnings.append(f"HTML 解析中断: {e}")
has_cjk = bool(CJK.search(html))
if has_cjk and checker.span_leaf_count == 0:
errors.append("全文没有任何 <span leaf=\"\"> 包裹——"
"粘贴到公众号后样式会大面积丢失")
elif checker.unwrapped:
sample = ";".join(f"「{s}」(在 <{p}> 内)"
for s, p in checker.unwrapped[:5])
warnings.append(
f"{len(checker.unwrapped)} 处中文文本未被 <span leaf> 包裹,"
f"样式可能丢失。例:{sample}")
if checker.half_punct:
sample = ";".join(f"「{s}」" for s in checker.half_punct[:5])
warnings.append(
f"{len(checker.half_punct)} 处正文疑似半角标点/英文引号,应改中文全角"
f"(代码块内不计)。例:{sample}")
return errors, warnings, checker.span_leaf_count
def main():
ap = argparse.ArgumentParser()
ap.add_argument("file", nargs="?", help="HTML 文件路径")
ap.add_argument("--stdin", action="store_true", help="从标准输入读取")
args = ap.parse_args()
if args.stdin or not args.file:
html = sys.stdin.read()
name = "<stdin>"
else:
with open(args.file, encoding="utf-8", errors="replace") as f:
html = f.read()
name = args.file
errors, warnings, leaf_n = validate(html, name)
print(f"📋 公众号 HTML 合规校验: {name}")
print(f" span leaf 包裹: {leaf_n} 处")
if errors:
print(f"\n❌ ERROR ×{len(errors)}(必须修复,否则粘贴后失效):")
for e in errors:
print(f" • {e}")
if warnings:
print(f"\n⚠️ WARNING ×{len(warnings)}(建议检查):")
for w in warnings:
print(f" • {w}")
if not errors and not warnings:
print("\n✅ 完全合规,可直接粘贴到公众号编辑器")
elif not errors:
print("\n✅ 无致命问题,可粘贴(warning 请人工确认)")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()