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/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![{alt}]({src})\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"![{alt}]({src})")
207
+ elif cname == "br":
208
+ res.append("\n")
209
+ else:
210
+ res.append(_render_inline(child))
211
+ return "".join(res)