md2wx-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- md2wx/__init__.py +3 -0
- md2wx/cli.py +299 -0
- md2wx/clipboard.py +169 -0
- md2wx/config.py +83 -0
- md2wx/containers.py +177 -0
- md2wx/converter.py +206 -0
- md2wx/fetcher.py +211 -0
- md2wx/gallery.py +199 -0
- md2wx/publisher.py +136 -0
- md2wx/styler.py +288 -0
- md2wx/theme.py +117 -0
- md2wx/theme_extractor.py +295 -0
- md2wx/themes/bauhaus.yaml +207 -0
- md2wx/themes/bold-green.yaml +198 -0
- md2wx/themes/bold-navy.yaml +197 -0
- md2wx/themes/default.yaml +217 -0
- md2wx/themes/github-tech.yaml +104 -0
- md2wx/validator.py +166 -0
- md2wx/wechat_api.py +141 -0
- md2wx_cli-0.2.0.dist-info/METADATA +184 -0
- md2wx_cli-0.2.0.dist-info/RECORD +25 -0
- md2wx_cli-0.2.0.dist-info/WHEEL +5 -0
- md2wx_cli-0.2.0.dist-info/entry_points.txt +2 -0
- md2wx_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- md2wx_cli-0.2.0.dist-info/top_level.txt +1 -0
md2wx/containers.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Container and rich syntax pre-processors for md2wx (Clean, minimal styling)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
_INLINE_CODE_RE = re.compile(r"`([^`\n]+?)`")
|
|
7
|
+
_INLINE_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
8
|
+
_INLINE_EM_RE = re.compile(r"(?<!\*)\*([^*\n]+?)\*(?!\*)")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inline_md(text: str) -> str:
|
|
12
|
+
"""Render inline Markdown (code/bold/em/links) inside custom container blocks."""
|
|
13
|
+
text = _INLINE_CODE_RE.sub(
|
|
14
|
+
r'<code style="background: rgba(0,0,0,0.06); padding: 2px 6px; border-radius: 4px; font-size: 0.9em">\1</code>',
|
|
15
|
+
text,
|
|
16
|
+
)
|
|
17
|
+
text = _INLINE_BOLD_RE.sub(r'<strong style="font-weight: 600;">\1</strong>', text)
|
|
18
|
+
text = _INLINE_EM_RE.sub(r"<em>\1</em>", text)
|
|
19
|
+
text = re.sub(r"\[([^\]]+)\]\((https?://[^\s)]+)\)", r'<a href="\2" style="color: #2563eb; text-decoration: underline;">\1</a>', text)
|
|
20
|
+
text = re.sub(r'(?<!href=")(https?://[^\s<"]+)', r'<span style="color: #64748b; font-size: 13px; word-break: break-all;">\1</span>', text)
|
|
21
|
+
return text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def process_containers(text: str, primary_color: str = "#2563eb") -> str:
|
|
25
|
+
"""Pre-process rich Markdown extensions into clean, styled HTML."""
|
|
26
|
+
text = _process_banner_container(text, primary_color)
|
|
27
|
+
text = _process_github_alerts(text, primary_color)
|
|
28
|
+
text = _process_admonitions(text, primary_color)
|
|
29
|
+
text = _process_card_container(text, primary_color)
|
|
30
|
+
text = _process_center_container(text, primary_color)
|
|
31
|
+
text = _process_callout(text)
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _process_banner_container(text: str, primary: str) -> str:
|
|
36
|
+
"""Parse ::: banner [title] (solid primary background, pure white text, centered)."""
|
|
37
|
+
pattern = re.compile(r":::\s*(?:banner|cta|action)(?:\s+([^\n]+))?\n(.*?)\n:::", re.DOTALL | re.I)
|
|
38
|
+
|
|
39
|
+
def repl(m):
|
|
40
|
+
title = (m.group(1) or "").strip()
|
|
41
|
+
body = m.group(2).strip()
|
|
42
|
+
lines = body.split("\n")
|
|
43
|
+
|
|
44
|
+
title_html = f'<p style="font-size: 15px; color: #ffffff !important; font-weight: 600; line-height: 1.9; margin: 0 0 8px; text-align: center;">{inline_md(title)}</p>' if title else ""
|
|
45
|
+
clean_lines = []
|
|
46
|
+
for l in lines:
|
|
47
|
+
if not l.strip():
|
|
48
|
+
continue
|
|
49
|
+
l_html = inline_md(l).replace("color: #64748b;", "color: #ffffff;").replace("color: #2563eb;", "color: #ffffff;")
|
|
50
|
+
clean_lines.append(l_html)
|
|
51
|
+
|
|
52
|
+
body_html = "<br/>".join(clean_lines)
|
|
53
|
+
return (
|
|
54
|
+
f'\n<section style="background: {primary}; border-radius: 10px; padding: 20px; margin: 24px 0;">'
|
|
55
|
+
f"{title_html}"
|
|
56
|
+
f'<section style="text-align: center; color: #ffffff !important; font-size: 13px; line-height: 1.8;">{body_html}</section>'
|
|
57
|
+
f"</section>\n"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return pattern.sub(repl, text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _process_github_alerts(text: str, primary: str) -> str:
|
|
64
|
+
"""Parse GitHub Alerts syntax without injecting artificial icons or extra headers."""
|
|
65
|
+
alert_config = {
|
|
66
|
+
"NOTE": ("#2563eb", "#eff6ff"),
|
|
67
|
+
"TIP": ("#2563eb", "#eff6ff"),
|
|
68
|
+
"IMPORTANT": ("#7c3aed", "#f5f3ff"),
|
|
69
|
+
"WARNING": ("#d97706", "#fffbeb"),
|
|
70
|
+
"CAUTION": ("#dc2626", "#fef2f2"),
|
|
71
|
+
}
|
|
72
|
+
pattern = re.compile(r"^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n((?:^>.*$\n?)+)", re.MULTILINE | re.IGNORECASE)
|
|
73
|
+
|
|
74
|
+
def repl(m):
|
|
75
|
+
atype = m.group(1).upper()
|
|
76
|
+
content = m.group(2)
|
|
77
|
+
color, bg = alert_config.get(atype, ("#2563eb", "#eff6ff"))
|
|
78
|
+
cleaned_lines = []
|
|
79
|
+
for line in content.split("\n"):
|
|
80
|
+
line = re.sub(r"^>\s?", "", line).strip()
|
|
81
|
+
if line:
|
|
82
|
+
cleaned_lines.append(line)
|
|
83
|
+
body_text = "<br/>".join(inline_md(l) for l in cleaned_lines)
|
|
84
|
+
return (
|
|
85
|
+
f'\n<section style="background: {bg}; border-radius: 8px; padding: 16px 20px; margin: 20px 0;">'
|
|
86
|
+
f'<p style="font-size: 15px; color: {color}; font-weight: 600; line-height: 1.9; margin: 0; text-align: center">{body_text}</p>'
|
|
87
|
+
f"</section>\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return pattern.sub(repl, text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _process_admonitions(text: str, primary: str) -> str:
|
|
94
|
+
"""Parse MkDocs style admonition syntax."""
|
|
95
|
+
types = {
|
|
96
|
+
"tip": ("#2563eb", "#eff6ff"),
|
|
97
|
+
"note": ("#2563eb", "#eff6ff"),
|
|
98
|
+
"info": ("#2563eb", "#eff6ff"),
|
|
99
|
+
"warning": ("#d97706", "#fffbeb"),
|
|
100
|
+
"danger": ("#dc2626", "#fef2f2"),
|
|
101
|
+
}
|
|
102
|
+
pattern = re.compile(r"^!!!\s*(tip|note|info|warning|danger)(?:\s+\"([^\"]+)\")?\s*\n((?:^(?: |\t).*$\n?)+)", re.MULTILINE | re.IGNORECASE)
|
|
103
|
+
|
|
104
|
+
def repl(m):
|
|
105
|
+
atype = m.group(1).lower()
|
|
106
|
+
title = m.group(2) or ""
|
|
107
|
+
content = m.group(3)
|
|
108
|
+
color, bg = types.get(atype, ("#2563eb", "#eff6ff"))
|
|
109
|
+
cleaned_lines = [re.sub(r"^(?: |\t)", "", l).strip() for l in content.split("\n") if l.strip()]
|
|
110
|
+
body_text = "<br/>".join(inline_md(l) for l in cleaned_lines)
|
|
111
|
+
title_html = f'<p style="font-size: 15px; color: {color}; font-weight: 600; margin: 0 0 6px 0; text-align: center;">{inline_md(title)}</p>' if title else ""
|
|
112
|
+
return (
|
|
113
|
+
f'\n<section style="background: {bg}; border-radius: 8px; padding: 16px 20px; margin: 20px 0;">'
|
|
114
|
+
f"{title_html}"
|
|
115
|
+
f'<p style="font-size: 15px; color: {color}; font-weight: 500; line-height: 1.8; margin: 0; text-align: center">{body_text}</p>'
|
|
116
|
+
f"</section>\n"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return pattern.sub(repl, text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _process_card_container(text: str, primary: str) -> str:
|
|
123
|
+
"""Parse ::: card [title] (white background, 1px primary border, centered link)."""
|
|
124
|
+
pattern = re.compile(r":::\s*card(?:\s+([^\n]+))?\n(.*?)\n:::", re.DOTALL)
|
|
125
|
+
|
|
126
|
+
def repl(m):
|
|
127
|
+
title = (m.group(1) or "").strip()
|
|
128
|
+
body = m.group(2).strip()
|
|
129
|
+
lines = [inline_md(l) for l in body.split("\n") if l.strip()]
|
|
130
|
+
title_html = f'<p style="font-family: \'Noto Serif SC\', \'Songti SC\', STSong, Georgia, serif; font-size: 19px; color: #0f172a; font-weight: 700; line-height: 1.5; margin: 0 0 10px; text-align: center">{inline_md(title)}</p>' if title else ""
|
|
131
|
+
body_html = "<br/>".join(lines)
|
|
132
|
+
return (
|
|
133
|
+
f'\n<section style="background: #ffffff; border: 1px solid {primary}; border-radius: 10px; padding: 20px; margin: 24px 0; box-shadow: 0 4px 16px rgba(15,23,42,0.06);">'
|
|
134
|
+
f"{title_html}"
|
|
135
|
+
f'<section style="text-align: center; color: #475569; font-size: 14px; line-height: 1.8;">{body_html}</section>'
|
|
136
|
+
f"</section>\n"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return pattern.sub(repl, text)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _process_center_container(text: str, primary: str) -> str:
|
|
143
|
+
"""Parse ::: center block."""
|
|
144
|
+
pattern = re.compile(r":::\s*center\n(.*?)\n:::", re.DOTALL)
|
|
145
|
+
|
|
146
|
+
def repl(m):
|
|
147
|
+
body = m.group(1).strip()
|
|
148
|
+
lines = [inline_md(l) for l in body.split("\n") if l.strip()]
|
|
149
|
+
body_html = "<br/>".join(lines)
|
|
150
|
+
return (
|
|
151
|
+
f'\n<section style="background: #eff6ff; border-radius: 8px; padding: 16px 20px; margin: 20px 0; text-align: center;">'
|
|
152
|
+
f'<p style="font-size: 15px; color: {primary}; font-weight: 600; line-height: 1.9; margin: 0; text-align: center;">{body_html}</p>'
|
|
153
|
+
f"</section>\n"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return pattern.sub(repl, text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _process_callout(text: str) -> str:
|
|
160
|
+
"""Parse ::: tip/warning/info/danger block."""
|
|
161
|
+
colors_map = {
|
|
162
|
+
"tip": ("#2563eb", "#eff6ff"),
|
|
163
|
+
"warning": ("#d97706", "#fffbeb"),
|
|
164
|
+
"info": ("#2563eb", "#eff6ff"),
|
|
165
|
+
"danger": ("#dc2626", "#fef2f2"),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def repl(m):
|
|
169
|
+
ctype = m.group(1).strip().lower()
|
|
170
|
+
content = m.group(2).strip()
|
|
171
|
+
color, bg = colors_map.get(ctype, colors_map["info"])
|
|
172
|
+
return (
|
|
173
|
+
f'\n<section style="background: {bg}; border-radius: 8px; padding: 16px 20px; margin: 18px 0; font-size: 15px; line-height: 1.8; text-align: center; color: {color}; font-weight: 600;">'
|
|
174
|
+
f"{inline_md(content)}</section>\n"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return re.sub(r":::(?:callout\s+)?(tip|warning|info|danger)\n(.*?)\n:::", repl, text, flags=re.DOTALL | re.I)
|
md2wx/converter.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Markdown to WeChat-compatible HTML converter pipeline."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import markdown
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
from .containers import process_containers
|
|
12
|
+
from .styler import (
|
|
13
|
+
apply_inline_styles,
|
|
14
|
+
apply_wechat_fixes,
|
|
15
|
+
convert_links_to_footnotes,
|
|
16
|
+
convert_lists_to_sections,
|
|
17
|
+
inject_darkmode,
|
|
18
|
+
make_paste_safe,
|
|
19
|
+
preserve_code_block_newlines,
|
|
20
|
+
sanitize_for_wechat,
|
|
21
|
+
)
|
|
22
|
+
from .theme import Theme, get_inline_css_rules, load_theme
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ConvertResult:
|
|
27
|
+
html: str
|
|
28
|
+
title: str
|
|
29
|
+
digest: str
|
|
30
|
+
images: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WeChatConverter:
|
|
34
|
+
"""Convert Markdown to WeChat-compatible inline-style HTML."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, theme: Optional[Theme] = None, theme_name: str = "default"):
|
|
37
|
+
self._theme = theme if theme is not None else load_theme(theme_name)
|
|
38
|
+
self._css_rules = get_inline_css_rules(self._theme)
|
|
39
|
+
|
|
40
|
+
def convert(self, markdown_text: str, base_dir: Optional[Path] = None) -> ConvertResult:
|
|
41
|
+
title = self._extract_title(markdown_text)
|
|
42
|
+
markdown_text = self._strip_h1(markdown_text)
|
|
43
|
+
accent = self._theme.colors.get("secondary") or self._theme.colors.get("primary", "#2563eb")
|
|
44
|
+
text_color = self._theme.colors.get("text", "#475569")
|
|
45
|
+
primary = self._theme.colors.get("primary", "#2563eb")
|
|
46
|
+
|
|
47
|
+
# 1. 扩展容器语法与中英文空格预处理
|
|
48
|
+
markdown_text = process_containers(markdown_text, accent)
|
|
49
|
+
markdown_text = self._fix_cjk_spacing(markdown_text)
|
|
50
|
+
|
|
51
|
+
# 2. 基础 Markdown → HTML 解析
|
|
52
|
+
raw_html = self._markdown_to_html(markdown_text)
|
|
53
|
+
soup = BeautifulSoup(raw_html, "html.parser")
|
|
54
|
+
|
|
55
|
+
# 3. 本地图片路径转换与外框样式
|
|
56
|
+
images = self._process_images(soup, base_dir)
|
|
57
|
+
|
|
58
|
+
# 4. 原生列表转为稳定的 Section Flex 容器
|
|
59
|
+
convert_lists_to_sections(soup, text_color, primary)
|
|
60
|
+
|
|
61
|
+
# 5. 外链转为文末上标脚注
|
|
62
|
+
convert_links_to_footnotes(soup, primary)
|
|
63
|
+
|
|
64
|
+
# 6. 内联 CSS 样式编译与组件强化
|
|
65
|
+
apply_inline_styles(soup, self._theme, self._css_rules)
|
|
66
|
+
|
|
67
|
+
# 7. 代码块换行保障与微信平台特性修复
|
|
68
|
+
preserve_code_block_newlines(soup)
|
|
69
|
+
apply_wechat_fixes(soup, text_color)
|
|
70
|
+
sanitize_for_wechat(soup)
|
|
71
|
+
inject_darkmode(soup, self._theme.colors.get("darkmode", {}))
|
|
72
|
+
|
|
73
|
+
final_html = self._fix_cjk_bold_punctuation(str(soup))
|
|
74
|
+
digest = self._generate_digest(final_html)
|
|
75
|
+
return ConvertResult(html=final_html, title=title, digest=digest, images=images)
|
|
76
|
+
|
|
77
|
+
def _extract_and_strip_frontmatter(self, text: str) -> Tuple[str, str]:
|
|
78
|
+
m = re.match(r"^---\r?\n(.*?)\r?\n---\r?\n", text, flags=re.DOTALL)
|
|
79
|
+
if not m:
|
|
80
|
+
return "", text
|
|
81
|
+
frontmatter = m.group(1)
|
|
82
|
+
title = ""
|
|
83
|
+
try:
|
|
84
|
+
import yaml
|
|
85
|
+
data = yaml.safe_load(frontmatter)
|
|
86
|
+
if isinstance(data, dict) and data.get("title"):
|
|
87
|
+
title = str(data["title"]).strip().strip("\"'")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
if not title:
|
|
91
|
+
for line in frontmatter.split("\n"):
|
|
92
|
+
if line.strip().lower().startswith("title:"):
|
|
93
|
+
cand = line.split(":", 1)[1].strip().strip("\"'")
|
|
94
|
+
if not cand.startswith(("[", "{")):
|
|
95
|
+
title = cand
|
|
96
|
+
break
|
|
97
|
+
return title, text[m.end():]
|
|
98
|
+
|
|
99
|
+
def _extract_title(self, text: str) -> str:
|
|
100
|
+
fm_title, body = self._extract_and_strip_frontmatter(text)
|
|
101
|
+
if fm_title:
|
|
102
|
+
return fm_title
|
|
103
|
+
in_code = False
|
|
104
|
+
for line in body.split("\n"):
|
|
105
|
+
s = line.strip()
|
|
106
|
+
if s.startswith("```"):
|
|
107
|
+
in_code = not in_code
|
|
108
|
+
continue
|
|
109
|
+
if not in_code and s.startswith("# ") and not s.startswith("## "):
|
|
110
|
+
return s[2:].strip()
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
def _strip_h1(self, text: str) -> str:
|
|
114
|
+
_, body = self._extract_and_strip_frontmatter(text)
|
|
115
|
+
lines, in_code = [], False
|
|
116
|
+
for line in body.split("\n"):
|
|
117
|
+
s = line.strip()
|
|
118
|
+
if s.startswith("```"):
|
|
119
|
+
in_code = not in_code
|
|
120
|
+
lines.append(line)
|
|
121
|
+
continue
|
|
122
|
+
if not in_code and s.startswith("# ") and not s.startswith("## "):
|
|
123
|
+
continue
|
|
124
|
+
lines.append(line)
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
def _markdown_to_html(self, text: str) -> str:
|
|
128
|
+
exts = [
|
|
129
|
+
"markdown.extensions.fenced_code",
|
|
130
|
+
"markdown.extensions.tables",
|
|
131
|
+
"markdown.extensions.nl2br",
|
|
132
|
+
"markdown.extensions.sane_lists",
|
|
133
|
+
"markdown.extensions.codehilite",
|
|
134
|
+
]
|
|
135
|
+
configs = {"codehilite": {"linenums": False, "guess_lang": True, "noclasses": True}}
|
|
136
|
+
return markdown.Markdown(extensions=exts, extension_configs=configs).convert(text)
|
|
137
|
+
|
|
138
|
+
def _process_images(self, soup: BeautifulSoup, base_dir: Optional[Path]) -> list[str]:
|
|
139
|
+
images = []
|
|
140
|
+
for img in soup.find_all("img"):
|
|
141
|
+
src = img.get("src", "")
|
|
142
|
+
if src:
|
|
143
|
+
if base_dir and not src.startswith(("http://", "https://", "data:")):
|
|
144
|
+
local_p = (base_dir / src).resolve()
|
|
145
|
+
if local_p.exists():
|
|
146
|
+
src = str(local_p).replace("\\", "/")
|
|
147
|
+
img["src"] = src
|
|
148
|
+
images.append(src)
|
|
149
|
+
existing = img.get("style", "")
|
|
150
|
+
if "max-width" not in existing:
|
|
151
|
+
img["style"] = f"{existing}; max-width: 100%; height: auto; display: block; margin: 24px auto" if existing else "max-width: 100%; height: auto; display: block; margin: 24px auto"
|
|
152
|
+
return images
|
|
153
|
+
|
|
154
|
+
def _fix_cjk_spacing(self, text: str) -> str:
|
|
155
|
+
cjk, latin = r"[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]", r"[A-Za-z0-9]"
|
|
156
|
+
lines, in_code = [], False
|
|
157
|
+
for line in text.split("\n"):
|
|
158
|
+
if line.strip().startswith("```"):
|
|
159
|
+
in_code = not in_code
|
|
160
|
+
lines.append(line)
|
|
161
|
+
continue
|
|
162
|
+
if in_code:
|
|
163
|
+
lines.append(line)
|
|
164
|
+
continue
|
|
165
|
+
tokens = []
|
|
166
|
+
|
|
167
|
+
def mask_inline(m):
|
|
168
|
+
tokens.append(m.group(0))
|
|
169
|
+
return f"\x00TK{len(tokens)-1}\x00"
|
|
170
|
+
|
|
171
|
+
masked = re.sub(r"`[^`\n]+?`|https?://[^\s)\]]+", mask_inline, line)
|
|
172
|
+
masked = re.sub(f"({cjk})({latin})", r"\1 \2", masked)
|
|
173
|
+
masked = re.sub(f"({latin})({cjk})", r"\1 \2", masked)
|
|
174
|
+
for idx, tok in enumerate(tokens):
|
|
175
|
+
masked = masked.replace(f"\x00TK{idx}\x00", tok)
|
|
176
|
+
lines.append(masked)
|
|
177
|
+
return "\n".join(lines)
|
|
178
|
+
|
|
179
|
+
def _fix_cjk_bold_punctuation(self, html: str) -> str:
|
|
180
|
+
return re.sub(r"(<strong>)(.*?)([,。!?;:、]+)(</strong>)", r"\1\2\4\3", html)
|
|
181
|
+
|
|
182
|
+
def _generate_digest(self, html: str, max_bytes: int = 120) -> str:
|
|
183
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
184
|
+
text = re.sub(r"\s+", " ", soup.get_text(separator=" ", strip=True)).strip()
|
|
185
|
+
encoded = text.encode("utf-8")
|
|
186
|
+
if len(encoded) <= max_bytes:
|
|
187
|
+
return text
|
|
188
|
+
return encoded[: max_bytes - 3].decode("utf-8", errors="ignore").rstrip() + "..."
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def preview_html(body_html: str, theme: Theme) -> str:
|
|
192
|
+
"""生成完整 HTML5 预览页面骨架。"""
|
|
193
|
+
return f"""<!DOCTYPE html>
|
|
194
|
+
<html lang="zh-CN">
|
|
195
|
+
<head>
|
|
196
|
+
<meta charset="UTF-8">
|
|
197
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
198
|
+
<title>Preview</title>
|
|
199
|
+
<style>
|
|
200
|
+
{theme.base_css}
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
{body_html}
|
|
205
|
+
</body>
|
|
206
|
+
</html>"""
|
md2wx/fetcher.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""WeChat article fetcher and Markdown generator for md2wx."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup, NavigableString
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fetch_and_convert_article(url: str, output_file: Optional[Path] = None) -> Tuple[Path, str]:
|
|
14
|
+
"""Fetch a WeChat article, download its assets, and convert it to clean Markdown."""
|
|
15
|
+
req = urllib.request.Request(
|
|
16
|
+
url,
|
|
17
|
+
headers={
|
|
18
|
+
"User-Agent": (
|
|
19
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) "
|
|
20
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 "
|
|
21
|
+
"MicroMessenger/8.0.38(0x1800262c) NetType/WIFI Language/zh_CN"
|
|
22
|
+
),
|
|
23
|
+
"Referer": "https://mp.weixin.qq.com",
|
|
24
|
+
},
|
|
25
|
+
)
|
|
26
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
27
|
+
content_bytes = resp.read()
|
|
28
|
+
try:
|
|
29
|
+
html_raw = content_bytes.decode("utf-8")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
html_raw = content_bytes.decode("gbk", errors="ignore")
|
|
32
|
+
|
|
33
|
+
soup = BeautifulSoup(html_raw, "html.parser")
|
|
34
|
+
|
|
35
|
+
# 1. 提取元数据
|
|
36
|
+
title_el = soup.find(id="activity-name") or soup.find("h1") or soup.find("title")
|
|
37
|
+
title = title_el.get_text(strip=True) if title_el else "wechat_article"
|
|
38
|
+
title = re.sub(r'[\/:*?"<>|]', "_", title)
|
|
39
|
+
|
|
40
|
+
author_el = soup.find(id="js_name") or soup.find(class_="rich_media_meta_text")
|
|
41
|
+
author = author_el.get_text(strip=True) if author_el else ""
|
|
42
|
+
|
|
43
|
+
# 2. 提取正文容器 #js_content
|
|
44
|
+
content_el = soup.find(id="js_content")
|
|
45
|
+
if not content_el:
|
|
46
|
+
raise ValueError("未能解析到文章正文容器 (#js_content),可能链接失效或触发了微信反爬验证。")
|
|
47
|
+
|
|
48
|
+
# 确定输出路径与资源目录
|
|
49
|
+
if output_file is None:
|
|
50
|
+
target_md = Path(f"{title}.md")
|
|
51
|
+
else:
|
|
52
|
+
target_md = output_file
|
|
53
|
+
|
|
54
|
+
assets_dir = target_md.parent / f"{target_md.stem}-assets"
|
|
55
|
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
# 3. 下载图片并替换为本地相对路径
|
|
58
|
+
img_counter = 0
|
|
59
|
+
for img in content_el.find_all("img"):
|
|
60
|
+
src = img.get("data-src") or img.get("src")
|
|
61
|
+
if not src or not src.startswith("http"):
|
|
62
|
+
continue
|
|
63
|
+
img_counter += 1
|
|
64
|
+
fmt = img.get("data-type", "png")
|
|
65
|
+
if fmt not in ("png", "jpg", "jpeg", "gif", "webp"):
|
|
66
|
+
fmt = "png"
|
|
67
|
+
img_name = f"fig-{img_counter:02d}.{fmt}"
|
|
68
|
+
local_img_path = assets_dir / img_name
|
|
69
|
+
try:
|
|
70
|
+
img_req = urllib.request.Request(src, headers={"User-Agent": "Mozilla/5.0", "Referer": "https://mp.weixin.qq.com"})
|
|
71
|
+
with urllib.request.urlopen(img_req, timeout=10) as r:
|
|
72
|
+
local_img_path.write_bytes(r.read())
|
|
73
|
+
img["src"] = f"{target_md.stem}-assets/{img_name}"
|
|
74
|
+
except Exception:
|
|
75
|
+
img["src"] = src
|
|
76
|
+
|
|
77
|
+
# 4. 转换 DOM 为 Markdown
|
|
78
|
+
md_body = _html_node_to_markdown(content_el)
|
|
79
|
+
|
|
80
|
+
frontmatter = f"""---
|
|
81
|
+
title: "{title}"
|
|
82
|
+
author: "{author}"
|
|
83
|
+
source_url: "{url}"
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
# {title}
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
final_md = frontmatter + md_body.strip() + "\n"
|
|
90
|
+
target_md.write_text(final_md, encoding="utf-8")
|
|
91
|
+
return target_md, final_md
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _html_node_to_markdown(node) -> str:
|
|
95
|
+
"""将 HTML DOM 节点树递归转换为干净的结构化 Markdown(支持智能卡片识别)。"""
|
|
96
|
+
pieces = []
|
|
97
|
+
for child in node.children:
|
|
98
|
+
if isinstance(child, NavigableString):
|
|
99
|
+
txt = str(child).strip()
|
|
100
|
+
if txt:
|
|
101
|
+
pieces.append(txt)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
name = child.name.lower() if child.name else ""
|
|
105
|
+
|
|
106
|
+
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
107
|
+
level = int(name[1])
|
|
108
|
+
pieces.append(f"\n{'#' * level} {child.get_text(strip=True)}\n")
|
|
109
|
+
elif name == "p":
|
|
110
|
+
p_txt = _render_inline(child).strip()
|
|
111
|
+
if p_txt:
|
|
112
|
+
pieces.append(f"\n{p_txt}\n")
|
|
113
|
+
elif name == "pre":
|
|
114
|
+
code = child.find("code")
|
|
115
|
+
code_txt = code.get_text() if code else child.get_text()
|
|
116
|
+
pieces.append(f"\n```\n{code_txt.strip()}\n```\n")
|
|
117
|
+
elif name == "blockquote":
|
|
118
|
+
b_txt = _render_inline(child).strip()
|
|
119
|
+
if b_txt:
|
|
120
|
+
quoted = "\n".join(f"> {line}" for line in b_txt.split("\n"))
|
|
121
|
+
pieces.append(f"\n{quoted}\n")
|
|
122
|
+
elif name in ("ul", "ol"):
|
|
123
|
+
for idx, li in enumerate(child.find_all("li", recursive=False), 1):
|
|
124
|
+
li_txt = _render_inline(li).strip()
|
|
125
|
+
if name == "ul":
|
|
126
|
+
pieces.append(f"- {li_txt}")
|
|
127
|
+
else:
|
|
128
|
+
pieces.append(f"{idx}. {li_txt}")
|
|
129
|
+
pieces.append("")
|
|
130
|
+
elif name == "img":
|
|
131
|
+
alt = child.get("alt", "") or "文章配图"
|
|
132
|
+
src = child.get("src", "")
|
|
133
|
+
if src:
|
|
134
|
+
pieces.append(f"\n\n")
|
|
135
|
+
elif name in ("section", "div"):
|
|
136
|
+
st_raw = child.get("style", "")
|
|
137
|
+
st_dict = {}
|
|
138
|
+
for item in st_raw.split(";"):
|
|
139
|
+
if ":" in item:
|
|
140
|
+
k, v = item.split(":", 1)
|
|
141
|
+
st_dict[k.strip().lower()] = v.strip().lower()
|
|
142
|
+
|
|
143
|
+
bg_val = st_dict.get("background", "") or st_dict.get("background-color", "")
|
|
144
|
+
border_val = st_dict.get("border", "") or st_dict.get("border-color", "")
|
|
145
|
+
|
|
146
|
+
# 1. 纯色实心横幅 (背景为深蓝实心 #2563eb / rgb(37, 99, 235))
|
|
147
|
+
if bg_val and any(c in bg_val for c in ("#2563eb", "rgb(37, 99, 235)", "rgb(37,99,235)")):
|
|
148
|
+
p_nodes = child.find_all(["p", "section"])
|
|
149
|
+
lines = [_render_inline(p).strip() for p in p_nodes if _render_inline(p).strip()]
|
|
150
|
+
if lines:
|
|
151
|
+
title_line = lines[0]
|
|
152
|
+
body_lines = "\n".join(lines[1:]) if len(lines) > 1 else ""
|
|
153
|
+
banner_md = f"::: banner {title_line}\n{body_lines}\n:::" if body_lines else f"::: banner\n{title_line}\n:::"
|
|
154
|
+
pieces.append(f"\n{banner_md}\n")
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# 2. 浅色高亮居中框 (背景为浅蓝 #eff6ff)
|
|
158
|
+
if bg_val and any(c in bg_val for c in ("#eff6ff", "rgb(239, 246, 255)", "rgb(239,246,255)")):
|
|
159
|
+
inner_txt = _render_inline(child).strip()
|
|
160
|
+
if inner_txt:
|
|
161
|
+
quoted = "\n".join(f"> {line}" for line in inner_txt.split("\n") if line.strip())
|
|
162
|
+
pieces.append(f"\n{quoted}\n")
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# 3. 白底描边外框卡片 (border 为 1px solid ...)
|
|
166
|
+
if border_val and "solid" in border_val:
|
|
167
|
+
p_nodes = child.find_all(["p", "section"])
|
|
168
|
+
lines = [_render_inline(p).strip() for p in p_nodes if _render_inline(p).strip()]
|
|
169
|
+
if lines:
|
|
170
|
+
title_line = lines[0]
|
|
171
|
+
body_lines = "\n".join(lines[1:]) if len(lines) > 1 else ""
|
|
172
|
+
card_md = f"::: card {title_line}\n{body_lines}\n:::" if body_lines else f"::: card\n{title_line}\n:::"
|
|
173
|
+
pieces.append(f"\n{card_md}\n")
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
inner = _html_node_to_markdown(child).strip()
|
|
177
|
+
if inner:
|
|
178
|
+
pieces.append(inner)
|
|
179
|
+
else:
|
|
180
|
+
txt = _render_inline(child).strip()
|
|
181
|
+
if txt:
|
|
182
|
+
pieces.append(txt)
|
|
183
|
+
|
|
184
|
+
return "\n\n".join(p for p in pieces if p)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _render_inline(node) -> str:
|
|
188
|
+
res = []
|
|
189
|
+
for child in node.children:
|
|
190
|
+
if isinstance(child, NavigableString):
|
|
191
|
+
res.append(str(child))
|
|
192
|
+
continue
|
|
193
|
+
cname = child.name.lower() if child.name else ""
|
|
194
|
+
if cname in ("strong", "b"):
|
|
195
|
+
res.append(f"**{child.get_text()}**")
|
|
196
|
+
elif cname in ("em", "i"):
|
|
197
|
+
res.append(f"*{child.get_text()}*")
|
|
198
|
+
elif cname == "code":
|
|
199
|
+
res.append(f"`{child.get_text()}`")
|
|
200
|
+
elif cname == "a":
|
|
201
|
+
href = child.get("href", "")
|
|
202
|
+
res.append(f"[{child.get_text()}]({href})")
|
|
203
|
+
elif cname == "img":
|
|
204
|
+
src = child.get("src", "")
|
|
205
|
+
alt = child.get("alt", "") or "文章配图"
|
|
206
|
+
res.append(f"")
|
|
207
|
+
elif cname == "br":
|
|
208
|
+
res.append("\n")
|
|
209
|
+
else:
|
|
210
|
+
res.append(_render_inline(child))
|
|
211
|
+
return "".join(res)
|