le-wechat-article v1.0:Leroy 专属公众号写作工坊(类型路由 / 分层审读 / 排版转换 / 草稿箱同步)
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Markdown → 微信公众号可粘贴 HTML(全内联样式,零依赖)。
|
||||
|
||||
排版 = 骨架(结构装饰)× 色板(主题色)两层:
|
||||
骨架 工程 色条标题 + 主题色加粗 + 灰底金句卡——教程、技术、工程实践(默认)
|
||||
杂志 深色大标题 + 深灰粗体 + 装饰线金句卡——叙事、访谈、生活方式
|
||||
简报 紧凑节奏 + 主题色小标题——更新简报、快讯、清单
|
||||
色板 靛蓝 / 松绿 / 黛紫 / 绛红(--color 可自定义 #RRGGBB)
|
||||
|
||||
用法:
|
||||
python3 md2wechat.py article.md -o article.html # 工程骨架 + 靛蓝
|
||||
python3 md2wechat.py article.md --skeleton 杂志 --palette 松绿 # 指定骨架与色板
|
||||
python3 md2wechat.py article.md --list-themes # 看全部组合与适用场景
|
||||
python3 md2wechat.py article.md --preview # 三骨架对照页(375px 栏宽)
|
||||
输出用浏览器打开,全选复制 → 粘贴进公众号编辑器。
|
||||
支持的语法子集:#(跳过)/ ### / 段落 / **加粗** / `行内代码` / [链接] /  /
|
||||
> 引用(含「—— 出处」识别为金句卡)/ - 与 1. 列表 / --- 分隔 / ```代码块``` / ≤3 列表格。
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
|
||||
PALETTES = {"靛蓝": "#1A5CFF", "松绿": "#0F7B6C", "黛紫": "#6B4FBB", "绛红": "#B03A2E"}
|
||||
SKELETONS = {
|
||||
"工程": {"alias": "eng", "fit": "教程、技术解析、工程实践"},
|
||||
"杂志": {"alias": "mag", "fit": "叙事、访谈、生活方式、品牌"},
|
||||
"简报": {"alias": "brief", "fit": "更新简报、快讯、清单文"},
|
||||
}
|
||||
|
||||
|
||||
def esc(t):
|
||||
return html.escape(t, quote=False)
|
||||
|
||||
|
||||
def darken(hexc, f=0.72):
|
||||
hexc = hexc.lstrip("#")
|
||||
r, g, b = (int(hexc[i:i + 2], 16) for i in (0, 2, 4))
|
||||
m = lambda v: max(0, int(v * f))
|
||||
return f"#{m(r):02x}{m(g):02x}{m(b):02x}"
|
||||
|
||||
|
||||
def styles(sk, color, px):
|
||||
"""骨架差异全部参数化在这里:返回各元素的内联样式串。"""
|
||||
n = int(px[:-2])
|
||||
dark = darken(color)
|
||||
if sk == "杂志":
|
||||
r, g, b = color.lstrip("#")[0:2], color.lstrip("#")[2:4], color.lstrip("#")[4:6]
|
||||
hl = f"rgba({int(r,16)},{int(g,16)},{int(b,16)},0.12)"
|
||||
return {
|
||||
"p": f"font-size:{px};line-height:1.8;color:#333;letter-spacing:.5px;margin:0 0 22px;",
|
||||
"strong": f"color:#111;font-weight:700;background:{hl};",
|
||||
"link": f"color:{dark};text-decoration:none;border-bottom:1px solid {hl};",
|
||||
"h3": (f"margin:34px 0 16px;font-size:{n + 1}px;font-weight:700;color:#111;"
|
||||
f"border-bottom:2px solid #e8e8e8;padding-bottom:10px;line-height:1.5;"),
|
||||
"quote": (f"margin:26px 0;padding:4px 16px;border-left:2px solid #ccc;"
|
||||
f"font-size:{n - 1}px;line-height:1.8;color:#888;"),
|
||||
"golden": (f"margin:30px 0;padding:20px 10px;text-align:center;"
|
||||
f"border-top:1px solid {color};border-bottom:1px solid {color};"
|
||||
f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"),
|
||||
"li": "margin:0 0 12px;line-height:1.8;", "li_ul": f"padding-left:1.4em;margin:0 0 22px;font-size:{px};line-height:1.8;color:#333;",
|
||||
}
|
||||
if sk == "简报":
|
||||
return {
|
||||
"p": f"font-size:{px};line-height:1.7;color:#3a3a3a;margin:0 0 16px;",
|
||||
"strong": f"color:{color};font-weight:600;",
|
||||
"link": f"color:{color};text-decoration:none;",
|
||||
"h3": (f"margin:26px 0 12px;font-size:{n + 1}px;font-weight:600;color:{dark};"
|
||||
f"line-height:1.5;"),
|
||||
"quote": (f"margin:20px 0;padding:2px 14px;border-left:2px solid {color};"
|
||||
f"font-size:{n - 1}px;line-height:1.7;color:#777;"),
|
||||
"golden": (f"margin:24px 0;padding:12px 8px;text-align:center;"
|
||||
f"font-size:{n + 1}px;line-height:1.8;color:{dark};font-weight:500;"
|
||||
f"letter-spacing:1px;"),
|
||||
"li": "margin:0 0 8px;line-height:1.7;", "li_ul": f"padding-left:1.4em;margin:0 0 16px;font-size:{px};line-height:1.7;color:#3a3a3a;",
|
||||
}
|
||||
# 工程(默认)
|
||||
return {
|
||||
"p": f"font-size:{px};line-height:1.75;color:#3f3f3f;letter-spacing:.4px;margin:0 0 20px;",
|
||||
"strong": f"color:{color};font-weight:600;",
|
||||
"link": f"color:{color};text-decoration:none;",
|
||||
"h3": (f"margin:30px 0 16px;font-size:{n + 1}px;font-weight:600;color:{dark};"
|
||||
f"border-left:4px solid {color};padding-left:10px;line-height:1.5;"),
|
||||
"quote": (f"margin:24px 0;padding:12px 16px;border-left:3px solid #ddd;background:#f7f8fa;"
|
||||
f"font-size:{n - 1}px;line-height:1.8;color:#888;"),
|
||||
"golden": (f"margin:28px 0;padding:20px 24px;background:#f7f8fa;text-align:center;"
|
||||
f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"),
|
||||
"li": "margin:0 0 10px;line-height:1.75;", "li_ul": f"padding-left:1.4em;margin:0 0 20px;font-size:{px};line-height:1.75;color:#3f3f3f;",
|
||||
}
|
||||
|
||||
|
||||
def inline(text, st):
|
||||
"""行内元素转换:先保护行内代码,再处理加粗/链接/图片。"""
|
||||
codes = []
|
||||
|
||||
def stash(m):
|
||||
codes.append(m.group(1))
|
||||
return f"\x00{len(codes) - 1}\x00"
|
||||
|
||||
t = re.sub(r"`([^`]+)`", stash, text)
|
||||
t = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)",
|
||||
lambda m: (f'<img src="{esc(m.group(2))}" style="max-width:100%;display:block;margin:16px auto;'
|
||||
f'border-radius:6px;"'
|
||||
+ (f'<figcaption style="text-align:center;font-size:13px;color:#999;'
|
||||
f'margin:8px 0 20px;">{esc(m.group(3))}</figcaption>' if m.group(3) else "")),
|
||||
t)
|
||||
t = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
|
||||
rf'<a href="\2" style="{st["link"]}">\1</a>', t)
|
||||
t = re.sub(r"\*\*([^*]+)\*\*", rf'<strong style="{st["strong"]}">\1</strong>', t)
|
||||
t = re.sub(r"\x00(\d+)\x00",
|
||||
lambda m: f'<code style="background:#f2f3f5;color:#c7254e;border-radius:3px;'
|
||||
f'padding:2px 5px;font-size:14px;font-family:Menlo,Consolas,monospace;">'
|
||||
f'{esc(codes[int(m.group(1))])}</code>', t)
|
||||
return t
|
||||
|
||||
|
||||
def convert(md, sk, color, px):
|
||||
st = styles(sk, color, px)
|
||||
dark = darken(color)
|
||||
lines = md.split("\n")
|
||||
out, i, n = [], 0, len(lines)
|
||||
in_front = lines and lines[0].strip() == "---"
|
||||
front_done = not in_front
|
||||
|
||||
def flush_list(items, ordered):
|
||||
tag = "ol" if ordered else "ul"
|
||||
bullet = re.compile(r"^\s*(?:\d+\.|-)\s+")
|
||||
lis = "".join(
|
||||
f'<li style="{st["li"]}">' + inline(bullet.sub("", it), st) + "</li>"
|
||||
for it in items)
|
||||
out.append(f'<{tag} style="{st["li_ul"]}">{lis}</{tag}>')
|
||||
|
||||
while i < n:
|
||||
ln = lines[i]
|
||||
if not front_done:
|
||||
if i > 0 and ln.strip() == "---":
|
||||
front_done = True
|
||||
i += 1
|
||||
continue
|
||||
s = ln.strip()
|
||||
if not s:
|
||||
i += 1
|
||||
continue
|
||||
if s.startswith("```"):
|
||||
i += 1
|
||||
buf = []
|
||||
while i < n and not lines[i].strip().startswith("```"):
|
||||
buf.append(lines[i])
|
||||
i += 1
|
||||
i += 1
|
||||
code = esc("\n".join(buf))
|
||||
out.append(f'<pre style="background:#f6f8fa;border-radius:8px;padding:14px 16px;'
|
||||
f'overflow-x:auto;font-size:13.5px;line-height:1.6;margin:0 0 20px;'
|
||||
f'font-family:Menlo,Consolas,monospace;color:#24292e;">{code}</pre>')
|
||||
continue
|
||||
if s.startswith("# ") and not s.startswith("##"):
|
||||
i += 1 # 主标题不进正文(填在公众号后台)
|
||||
continue
|
||||
if s.startswith("###") and not s.startswith("####"):
|
||||
out.append(f'<h3 style="{st["h3"]}">{inline(s.lstrip("#").strip(), st)}</h3>')
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^####+\s", s):
|
||||
out.append(f'<p style="margin:24px 0 12px;font-weight:600;color:{dark};'
|
||||
f'font-size:{px};">{inline(s.lstrip("#").strip(), st)}</p>')
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^-{3,}$", s):
|
||||
out.append('<hr style="border:none;border-top:1px solid #eaeaea;margin:28px 0;"/>')
|
||||
i += 1
|
||||
continue
|
||||
if s.startswith(">"):
|
||||
buf = []
|
||||
while i < n and lines[i].strip().startswith(">"):
|
||||
buf.append(lines[i].strip().lstrip(">").strip())
|
||||
i += 1
|
||||
text = "<br/>".join(inline(b, st) for b in buf if b)
|
||||
key = "golden" if "——" in text else "quote"
|
||||
out.append(f'<blockquote style="{st[key]}">{text}</blockquote>')
|
||||
continue
|
||||
if s.startswith("|"):
|
||||
tbl = []
|
||||
while i < n and lines[i].strip().startswith("|"):
|
||||
tbl.append(lines[i].strip())
|
||||
i += 1
|
||||
rows = [r for r in tbl if not re.match(r"^\|[\s:|-]+\|$", r)]
|
||||
if rows:
|
||||
cols = max(r.count("|") - 1 for r in rows)
|
||||
if cols > 3:
|
||||
print(f"⚠️ 表格超过 3 列({cols} 列),已跳过渲染——请先改写为列表", file=sys.stderr)
|
||||
else:
|
||||
trs = []
|
||||
for ri, r in enumerate(rows):
|
||||
cells = [c.strip() for c in r.strip("|").split("|")]
|
||||
head = (f"background:#f7f8fa;font-weight:600;color:{dark};" if ri == 0 else "")
|
||||
tds = "".join(
|
||||
f'<td style="border:1px solid #eaeaea;padding:8px 10px;'
|
||||
f'font-size:{int(px[:-2]) - 1}px;{head}">{inline(c, st)}</td>'
|
||||
for c in cells)
|
||||
trs.append(f"<tr>{tds}</tr>")
|
||||
out.append('<table style="border-collapse:collapse;margin:0 auto 20px;">'
|
||||
+ "".join(trs) + "</table>")
|
||||
continue
|
||||
if re.match(r"^\s*-\s+", ln):
|
||||
items = []
|
||||
while i < n and re.match(r"^\s*-\s+", lines[i]):
|
||||
items.append(lines[i])
|
||||
i += 1
|
||||
flush_list(items, ordered=False)
|
||||
continue
|
||||
if re.match(r"^\s*\d+\.\s+", ln):
|
||||
items = []
|
||||
while i < n and re.match(r"^\s*\d+\.\s+", lines[i]):
|
||||
items.append(lines[i])
|
||||
i += 1
|
||||
flush_list(items, ordered=True)
|
||||
continue
|
||||
out.append(f'<p style="{st["p"]}">{inline(s, st)}</p>')
|
||||
i += 1
|
||||
|
||||
return (f'<section style="max-width:578px;margin:0 auto;font-family:'
|
||||
f'-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Hiragino Sans GB\','
|
||||
f'\'Microsoft YaHei\',sans-serif;">' + "".join(out) + "</section>")
|
||||
|
||||
|
||||
SAMPLE = """### 小节标题示例
|
||||
|
||||
正文段落:这里是**主题加粗落点**,后面是[链接示例](https://example.com)与行内代码 `npm install`。
|
||||
|
||||
- **标签一**:说明文字
|
||||
- **标签二**:说明文字
|
||||
|
||||
> 值得截图转发的一句金句。 —— 出处
|
||||
|
||||
> 普通引用:补充说明的引文。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
结尾段落,含**收束落点**。
|
||||
"""
|
||||
|
||||
|
||||
def list_themes():
|
||||
print("骨架 × 色板(--skeleton 骨架 --palette 色板,或 --color '#RRGGBB' 自定义)\n")
|
||||
for sk, meta in SKELETONS.items():
|
||||
print(f" {sk}(--skeleton {sk} 或 {meta['alias']})—— {meta['fit']}")
|
||||
print(f" 色板:{' / '.join(f'{k} {v}' for k, v in PALETTES.items())}\n")
|
||||
|
||||
|
||||
def resolve_skeleton(name):
|
||||
if not name:
|
||||
return "工程"
|
||||
for sk, meta in SKELETONS.items():
|
||||
if name in (sk, meta["alias"]):
|
||||
return sk
|
||||
sys.exit(f"未知骨架 {name},可选:{' / '.join(SKELETONS)}(或其英文别名)")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Markdown → 微信公众号 HTML(骨架×色板,全内联样式)")
|
||||
ap.add_argument("article", nargs="?")
|
||||
ap.add_argument("-o", "--output")
|
||||
ap.add_argument("--skeleton", help="骨架:工程 / 杂志 / 简报(默认 工程)")
|
||||
ap.add_argument("--palette", choices=list(PALETTES))
|
||||
ap.add_argument("--theme", choices=list(PALETTES), help="兼容旧参数,等同 --palette")
|
||||
ap.add_argument("--color", help="自定义主题色 #RRGGBB,覆盖色板")
|
||||
ap.add_argument("--font-size", default="16px")
|
||||
ap.add_argument("--list-themes", action="store_true", help="列出全部骨架与色板")
|
||||
ap.add_argument("--preview", action="store_true", help="生成三骨架对照页(用内置样文)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.list_themes:
|
||||
list_themes()
|
||||
return
|
||||
sk = resolve_skeleton(args.skeleton)
|
||||
color = args.color or PALETTES.get(args.palette or args.theme or "靛蓝")
|
||||
if not re.match(r"^#[0-9A-Fa-f]{6}$", color):
|
||||
sys.exit(f"颜色格式应为 #RRGGBB,收到 {color}")
|
||||
|
||||
if args.preview:
|
||||
blocks = []
|
||||
for sk_name in SKELETONS:
|
||||
doc = convert(SAMPLE, sk_name, color, args.font_size)
|
||||
blocks.append(f'<h2 style="text-align:center;font-size:15px;color:#999;'
|
||||
f'margin:40px 0 12px;">骨架:{sk_name} · 色板 {color}</h2>'
|
||||
f'<div style="max-width:375px;margin:0 auto;border:1px solid #ddd;'
|
||||
f'padding:16px 14px;">{doc}</div>')
|
||||
page = ("<!DOCTYPE html><html><head><meta charset='utf-8'><title>主题预览</title></head>"
|
||||
f"<body>{''.join(blocks)}</body></html>")
|
||||
out = args.output or "theme-preview.html"
|
||||
open(out, "w", encoding="utf-8").write(page)
|
||||
print(f"✔ {out}(骨架对照页,375px 栏宽 ≈ 手机宽度)——浏览器打开选一个顺眼的")
|
||||
return
|
||||
|
||||
if not args.article:
|
||||
sys.exit("请提供 markdown 文件路径(或用 --list-themes / --preview)")
|
||||
md = open(args.article, encoding="utf-8").read()
|
||||
doc = convert(md, sk, color, args.font_size)
|
||||
full = ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||
"<title>公众号文章</title></head>"
|
||||
f"<body style='background:#fff;'>{doc}</body></html>")
|
||||
out = args.output or re.sub(r"\.md$", ".html", args.article)
|
||||
open(out, "w", encoding="utf-8").write(full)
|
||||
print(f"✔ {out}(骨架 {sk} · 主题色 {color})——浏览器打开后全选复制,粘贴进公众号编辑器")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user