le-wechat-article v1.2:融合 gzh-design-skill 主题排版引擎(6 主题组件库 / 合规校验 / 复制预览页,AGPL 隔离于 themes/),md2wechat 降为轻稿快档

This commit is contained in:
leroy
2026-10-06 21:12:21 +08:00
parent 0ba7c1f39d
commit 500d8224db
20 changed files with 6039 additions and 12 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/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()