From 8db2c1994a080191e4e803364cf9d1763c062c87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 04:38:01 +0000 Subject: [PATCH] chore: rebuild dist/openclaw from source --- dist/openclaw/SKILL.md | 2 + dist/openclaw/scripts/learn_theme.py | 647 +++++++++++++++++++++++++++ dist/openclaw/toolkit/cli.py | 16 + 3 files changed, 665 insertions(+) create mode 100644 dist/openclaw/scripts/learn_theme.py diff --git a/dist/openclaw/SKILL.md b/dist/openclaw/SKILL.md index cc6ff9d..6c3918b 100644 --- a/dist/openclaw/SKILL.md +++ b/dist/openclaw/SKILL.md @@ -39,6 +39,7 @@ description: | - 用户说"学习我的修改" → `读取: {baseDir}/references/learn-edits.md`。支持两种来源: - **本地修改**(默认):用户在 `output/` 的 markdown 文件中修改 - **微信草稿箱同步**:`python3 {baseDir}/scripts/learn_edits.py --from-wechat`,自动从草稿箱拉回最新内容,与本地原文做纯文本 diff +- 用户说"学习排版"/"学排版" → `python3 {baseDir}/scripts/learn_theme.py --name `,用户需提供一个公众号文章 URL 和主题名称。提取完成后提示用户设置 `style.yaml` 的 `theme` 字段。 - 用户说"看看文章数据" → `读取: {baseDir}/references/effect-review.md` - 用户说"检查一下"/"自检"/"这篇文章怎么样" → 对最近一篇生成的文章(或用户指定的文章)执行自检,输出生成报告: @@ -459,6 +460,7 @@ python3 {baseDir}/toolkit/cli.py preview {markdown} --theme {theme} --no-open -o | 换成 XX 主题 | 重新渲染 | | 看看文章数据 | `读取: {baseDir}/references/effect-review.md` | | 学习我的修改 | `读取: {baseDir}/references/learn-edits.md`。支持本地 markdown 修改和微信草稿箱同步(`--from-wechat`) | +| 学习排版 / 学排版 | `python3 {baseDir}/scripts/learn_theme.py --name ` | | 做一个小绿书/图片帖 | `python3 {baseDir}/toolkit/cli.py image-post img1.jpg img2.jpg -t "标题"` | | 检查一下 / 自检 / 这篇文章怎么样 | 生成报告(生成档案 + 质量检查,见辅助功能) | | 导入范文 / 建范文库 | `python3 {baseDir}/scripts/extract_exemplar.py article.md` | diff --git a/dist/openclaw/scripts/learn_theme.py b/dist/openclaw/scripts/learn_theme.py new file mode 100644 index 0000000..e0ef85d --- /dev/null +++ b/dist/openclaw/scripts/learn_theme.py @@ -0,0 +1,647 @@ +"""learn_theme.py — extract a WeWrite-compatible theme from a WeChat article URL. + +Usage: + python3 scripts/learn_theme.py # fetch + analyse live article + python3 scripts/learn_theme.py --file # analyse a saved HTML file +""" + +import argparse +import colorsys +import re +import sys +from collections import Counter +from pathlib import Path + +import requests +import yaml +from bs4 import BeautifulSoup + +# --------------------------------------------------------------------------- +# 1. Color utilities +# --------------------------------------------------------------------------- + +def rgb_to_hex(rgb_str: str) -> str: + """Convert ``rgb(r,g,b)`` or ``rgba(r,g,b,a)`` to ``#rrggbb``. + + Pass-through for values that already look like hex (lowercased). + Return the original string unchanged if no pattern matches. + """ + if not isinstance(rgb_str, str): + return rgb_str + s = rgb_str.strip() + # Already hex + if re.match(r"^#[0-9a-fA-F]{3,8}$", s): + return s.lower() + m = re.match( + r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*[\d.]+)?\s*\)", + s, + re.IGNORECASE, + ) + if m: + r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3)) + return "#{:02x}{:02x}{:02x}".format(r, g, b) + return s + + +def lightness(hex_color: str) -> float: + """Return HLS lightness (0.0–1.0) for a hex colour string. + + Returns 0.5 for any invalid / non-hex input. + """ + s = hex_color.strip().lstrip("#") + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6: + return 0.5 + try: + r = int(s[0:2], 16) / 255.0 + g = int(s[2:4], 16) / 255.0 + b = int(s[4:6], 16) / 255.0 + except ValueError: + return 0.5 + _h, l, _s = colorsys.rgb_to_hls(r, g, b) + return l + + +def is_gray(hex_color: str, threshold: int = 30) -> bool: + """Return True if R, G, B values are all within *threshold* of each other.""" + s = hex_color.strip().lstrip("#") + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6: + return False + try: + r = int(s[0:2], 16) + g = int(s[2:4], 16) + b = int(s[4:6], 16) + except ValueError: + return False + return max(r, g, b) - min(r, g, b) <= threshold + + +def adjust_lightness(hex_color: str, target_l: float) -> str: + """Return a new hex colour with lightness set to *target_l* (0.0–1.0).""" + s = hex_color.strip().lstrip("#") + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6: + return hex_color + try: + r = int(s[0:2], 16) / 255.0 + g = int(s[2:4], 16) / 255.0 + b = int(s[4:6], 16) / 255.0 + except ValueError: + return hex_color + h, _l, sat = colorsys.rgb_to_hls(r, g, b) + nr, ng, nb = colorsys.hls_to_rgb(h, max(0.0, min(1.0, target_l)), sat) + return "#{:02x}{:02x}{:02x}".format( + int(nr * 255), int(ng * 255), int(nb * 255) + ) + + +def derive_darkmode(colors: dict) -> dict: + """Derive a dark-mode colour dict from a light-mode *colors* dict. + + Rules + ----- + background → #1e1e1e + text → lightness set to 0.80 + text_light → lightness set to 0.60 + primary → lightness + 0.15, capped at 0.85 + code_bg → #2d2d2d + code_color → #d4d4d4 + quote_bg → #252525 + quote_border → dark-mode primary + """ + primary = colors.get("primary", "#2563eb") + primary_l = lightness(primary) + dm_primary = adjust_lightness(primary, min(primary_l + 0.15, 0.85)) + + dm = { + "background": "#1e1e1e", + "text": adjust_lightness(colors.get("text", "#333333"), 0.80), + "text_light": adjust_lightness(colors.get("text_light", "#666666"), 0.60), + "primary": dm_primary, + "code_bg": "#2d2d2d", + "code_color": "#d4d4d4", + "quote_bg": "#252525", + "quote_border": dm_primary, + } + return dm + + +# --------------------------------------------------------------------------- +# 2. HTML fetch and style extraction +# --------------------------------------------------------------------------- + +def parse_inline_style(style_str: str) -> dict: + """Parse ``"color: red; font-size: 16px"`` into ``{"color": "red", "font-size": "16px"}``.""" + result = {} + if not style_str: + return result + for declaration in style_str.split(";"): + declaration = declaration.strip() + if ":" not in declaration: + continue + prop, _, val = declaration.partition(":") + result[prop.strip().lower()] = val.strip() + return result + + +_TARGET_TAGS = { + "p", "section", "span", "strong", "em", + "h1", "h2", "h3", "h4", + "blockquote", "code", "pre", "img", "a", +} + +_BROWSER_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" +) + +TEMPLATE_THEME = "professional-clean" +THEMES_DIR = Path(__file__).resolve().parent.parent / "toolkit" / "themes" + + +def _attach_title(soup, content) -> None: + """Find the article title in *soup* and stash it on *content*.""" + title_tag = soup.find("h1", class_="rich_media_title") or soup.find( + "h1", id="activity-name" + ) + content._wewrite_title = title_tag.get_text(strip=True) if title_tag else "" + + +def fetch_article(url: str, timeout: int = 20) -> "BeautifulSoup tag": + """Fetch a WeChat article, return the ``#js_content`` element. + + The article title is attached as ``content._wewrite_title`` (empty string + if not found). Exits with code 1 on network errors or missing content. + + Parameters + ---------- + url: WeChat article URL (mp.weixin.qq.com/…) + timeout: HTTP request timeout in seconds (default 20). + """ + try: + resp = requests.get(url, headers={"User-Agent": _BROWSER_UA}, timeout=timeout) + resp.raise_for_status() + except requests.exceptions.RequestException as exc: + print(f"Error: failed to fetch URL: {exc}", file=sys.stderr) + sys.exit(1) + resp.encoding = "utf-8" + soup = BeautifulSoup(resp.text, "html.parser") + + content = soup.find(id="js_content") + if content is None: + print("Error: #js_content not found — the page may require verification.", file=sys.stderr) + sys.exit(1) + + _attach_title(soup, content) + return content + + +def extract_styles(content) -> dict: + """Iterate all elements in *content*, group inline styles by tag name. + + Returns ``{tag_name: [style_dict, ...], ...}`` for the target tags. + Only elements that have a non-empty ``style`` attribute are included. + """ + grouped: dict[str, list[dict]] = {tag: [] for tag in _TARGET_TAGS} + for elem in content.find_all(True): + tag = elem.name + if tag not in _TARGET_TAGS: + continue + raw_style = elem.get("style", "") + if not raw_style: + continue + parsed = parse_inline_style(raw_style) + if parsed: + grouped[tag].append(parsed) + return grouped + + +# --------------------------------------------------------------------------- +# 3. Style analysis +# --------------------------------------------------------------------------- + +DEFAULTS = { + "primary": "#2563eb", + "secondary": "#3b82f6", + "text": "#333333", + "text_light": "#666666", + "background": "#ffffff", + "code_bg": "#1e293b", + "code_color": "#e2e8f0", + "quote_border": "#2563eb", + "quote_bg": "#eff6ff", + "border_radius": "8px", + "font_size": "16px", + "line_height": "1.8", + "letter_spacing": "0px", + "font_family": ( + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ' + '"Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", ' + '"Microsoft YaHei", sans-serif' + ), + "p_margin": "0 0 16px 0", +} + + +def most_common_value(style_list: list, prop: str): + """Return the most common value of CSS *prop* across *style_list*. + + Returns ``None`` if the property does not appear in any dict. + """ + values = [d[prop] for d in style_list if prop in d and d[prop]] + if not values: + return None + return Counter(values).most_common(1)[0][0] + + +def _parse_px(value: str) -> float | None: + """Parse a CSS pixel value like ``"16px"`` → 16.0, or return None.""" + if not value: + return None + m = re.match(r"([\d.]+)\s*px", value.strip(), re.IGNORECASE) + return float(m.group(1)) if m else None + + +def analyze_styles(grouped: dict) -> dict: + """Analyse the output of :func:`extract_styles` and return a flat theme dict. + + Inferred properties (falling back to DEFAULTS when not found): + text, text_light, primary, secondary, background, + font_size, line_height, letter_spacing, font_family, p_margin, + quote_border, quote_bg, code_bg, code_color, border_radius. + """ + result = dict(DEFAULTS) # start with all defaults + + # --- text ------------------------------------------------------------------ + p_styles = grouped.get("p", []) + raw_text = most_common_value(p_styles, "color") + if raw_text: + result["text"] = rgb_to_hex(raw_text) + + # --- text_light ------------------------------------------------------------ + # Collect foreground colours only (not backgrounds) for text_light candidates + all_colors = [] + for tag_styles in grouped.values(): + for d in tag_styles: + val = d.get("color") + if val: + all_colors.append(rgb_to_hex(val)) + + text_light_candidates = [ + c for c in all_colors + if is_gray(c) and 0.15 < lightness(c) < 0.85 and c != result["text"] + ] + if text_light_candidates: + # Pick the one with the highest lightness + result["text_light"] = max(text_light_candidates, key=lightness) + + # --- primary (accent color) ------------------------------------------------ + # Collect non-gray colors from strong/section/h1-h3/span; boost colors from + # elements whose font-size is ≥ 20 px (weight × 5). + # Exclude the dominant text color so near-black body text never wins. + accent_tags = {"strong", "section", "h1", "h2", "h3", "span"} + accent_counter: Counter = Counter() + for tag in accent_tags: + for d in grouped.get(tag, []): + color_val = d.get("color") + if not color_val: + continue + hex_c = rgb_to_hex(color_val) + if is_gray(hex_c): + continue + if hex_c == result["text"]: + continue + # Check font-size for boost + fs = d.get("font-size") + fs_px = _parse_px(fs) if fs else None + weight = 5 if (fs_px is not None and fs_px >= 20) else 1 + accent_counter[hex_c] += weight + + if accent_counter: + sorted_accents = accent_counter.most_common() + result["primary"] = sorted_accents[0][0] + # --- secondary --------------------------------------------------------- + if len(sorted_accents) >= 2: + result["secondary"] = sorted_accents[1][0] + else: + # Derive: primary + 10% lightness, cap 0.90 + primary_l = lightness(result["primary"]) + result["secondary"] = adjust_lightness( + result["primary"], min(primary_l + 0.10, 0.90) + ) + else: + # No accent found — derive secondary from default primary + primary_l = lightness(result["primary"]) + result["secondary"] = adjust_lightness( + result["primary"], min(primary_l + 0.10, 0.90) + ) + + # --- background ------------------------------------------------------------ + # Check background-color of the first few
elements for high lightness + for d in (grouped.get("section", []))[:10]: + bg = d.get("background-color") or d.get("background") + if bg: + hex_bg = rgb_to_hex(bg) + if lightness(hex_bg) > 0.85: + result["background"] = hex_bg + break + + # --- typography (from

) ------------------------------------------------- + if p_styles: + fs = most_common_value(p_styles, "font-size") + if fs: + result["font_size"] = fs + lh = most_common_value(p_styles, "line-height") + if lh: + result["line_height"] = lh + ls = most_common_value(p_styles, "letter-spacing") + if ls: + result["letter_spacing"] = ls + margin = most_common_value(p_styles, "margin") + if margin: + result["p_margin"] = margin + + # font-family from + span_styles = grouped.get("span", []) + ff = most_common_value(span_styles, "font-family") + if ff: + result["font_family"] = ff + + # --- quote_border / quote_bg ----------------------------------------------- + # Priority: actual

elements first. + # For section/p: only use a background when a border-left is also present on + # that element (avoids picking up decorative divider colors). + bq_border = None + bq_bg = None + + # Pass 1: blockquote (highest confidence) + for d in grouped.get("blockquote", []): + bl = d.get("border-left") or d.get("border-left-color") + if bl and not bq_border: + color_match = re.search(r"(rgb[a]?\([^)]+\)|#[0-9a-fA-F]{3,8})", bl) + if color_match: + bq_border = rgb_to_hex(color_match.group(1)) + bg = d.get("background-color") or d.get("background") + if bg and not bq_bg: + hex_bg = rgb_to_hex(bg) + if hex_bg not in ("#ffffff", "#000000") and not is_gray(hex_bg, threshold=10): + bq_bg = hex_bg + + # Pass 2: section/p — only trust backgrounds that co-occur with border-left + if not bq_border: + for tag in ("section", "p"): + for d in grouped.get(tag, []): + bl = d.get("border-left") or d.get("border-left-color") + if bl: + color_match = re.search(r"(rgb[a]?\([^)]+\)|#[0-9a-fA-F]{3,8})", bl) + if color_match and not bq_border: + bq_border = rgb_to_hex(color_match.group(1)) + bg = d.get("background-color") or d.get("background") + if bg and not bq_bg: + hex_bg = rgb_to_hex(bg) + if hex_bg not in ("#ffffff", "#000000") and not is_gray( + hex_bg, threshold=10 + ): + bq_bg = hex_bg + + if bq_border: + result["quote_border"] = bq_border + else: + result["quote_border"] = result["primary"] + + if bq_bg: + result["quote_bg"] = bq_bg + else: + # Derive a light tint of primary + primary_l = lightness(result["primary"]) + result["quote_bg"] = adjust_lightness(result["primary"], min(primary_l + 0.35, 0.95)) + + # --- code_bg / code_color -------------------------------------------------- + for tag in ("pre", "code"): + tag_styles = grouped.get(tag, []) + bg = most_common_value(tag_styles, "background-color") or most_common_value( + tag_styles, "background" + ) + if bg: + result["code_bg"] = rgb_to_hex(bg) + color = most_common_value(tag_styles, "color") + if color: + result["code_color"] = rgb_to_hex(color) + + # --- border_radius --------------------------------------------------------- + all_radii = [] + for tag_styles in grouped.values(): + for d in tag_styles: + br = d.get("border-radius") + if br: + all_radii.append(br) + if all_radii: + result["border_radius"] = Counter(all_radii).most_common(1)[0][0] + + return result + + +# --------------------------------------------------------------------------- +# 4. Theme YAML generation +# --------------------------------------------------------------------------- + +def _load_template_css() -> str: + """Load the base_css from the professional-clean template theme.""" + template_path = THEMES_DIR / f"{TEMPLATE_THEME}.yaml" + with open(template_path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) + return data["base_css"] + + +def generate_theme_yaml(name: str, title: str, analyzed: dict) -> str: + """Generate a complete theme YAML string from analyzed style properties. + + Parameters + ---------- + name: Theme identifier (used as filename and --theme reference). + title: Article title (used in description). + analyzed: Dict returned by :func:`analyze_styles`. + """ + css = _load_template_css() + + # Build colors dict + colors = { + "primary": analyzed.get("primary", "#2563eb"), + "secondary": analyzed.get("secondary", "#3b82f6"), + "text": analyzed.get("text", "#333333"), + "text_light": analyzed.get("text_light", "#666666"), + "background": analyzed.get("background", "#ffffff"), + "code_bg": analyzed.get("code_bg", "#1e293b"), + "code_color": analyzed.get("code_color", "#e2e8f0"), + "quote_border": analyzed.get("quote_border", "#2563eb"), + "quote_bg": analyzed.get("quote_bg", "#eff6ff"), + "border_radius": analyzed.get("border_radius", "8px"), + } + + # Replace template colors in CSS + css = css.replace("#2563eb", colors["primary"]) + css = css.replace("#3b82f6", colors["secondary"]) + css = css.replace("#333333", colors["text"]) + css = css.replace("#666666", colors["text_light"]) + css = css.replace("#1e293b", colors["code_bg"]) + css = css.replace("#e2e8f0", colors["code_color"]) + css = css.replace("#eff6ff", colors["quote_bg"]) + + # Replace typography via regex + font_size = analyzed.get("font_size", "16px") + line_height = analyzed.get("line_height", "1.8") + font_family = analyzed.get("font_family", + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ' + '"Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", ' + '"Microsoft YaHei", sans-serif') + p_margin = analyzed.get("p_margin", "12px 0") + border_radius = colors["border_radius"] + + # body font-size + css = re.sub( + r"(body\s*\{[^}]*font-size:\s*)[\d.]+px", + lambda m: m.group(1) + font_size, + css, + flags=re.DOTALL, + ) + # body line-height + css = re.sub( + r"(body\s*\{[^}]*line-height:\s*)[\d.]+", + lambda m: m.group(1) + line_height, + css, + flags=re.DOTALL, + ) + # body font-family (replace the quoted chain) + css = re.sub( + r'(body\s*\{[^}]*font-family:\s*)[^;]+;', + lambda m: m.group(1) + font_family + ";", + css, + flags=re.DOTALL, + ) + # p line-height + css = re.sub( + r'(p\s*\{[^}]*line-height:\s*)[\d.]+', + lambda m: m.group(1) + line_height, + css, + flags=re.DOTALL, + ) + # p margin + css = re.sub( + r'(p\s*\{[^}]*margin:\s*)[^;]+;', + lambda m: m.group(1) + p_margin + ";", + css, + flags=re.DOTALL, + ) + # li line-height (match p line-height) + css = re.sub( + r'(li\s*\{[^}]*line-height:\s*)[\d.]+', + lambda m: m.group(1) + line_height, + css, + flags=re.DOTALL, + ) + # border-radius: 8px and border-radius: 4px + css = css.replace("border-radius: 8px", f"border-radius: {border_radius}") + css = css.replace("border-radius: 4px", f"border-radius: {border_radius}") + + # Derive dark mode + darkmode = derive_darkmode(colors) + + # Description + if title: + description = f"从「{title}」学习的排版主题" + else: + description = f"Learned theme: {name}" + + # Build theme data + colors_with_dark = dict(colors) + colors_with_dark["darkmode"] = darkmode + + theme_data = { + "name": name, + "description": description, + "colors": colors_with_dark, + "base_css": css, + } + + return yaml.dump(theme_data, allow_unicode=True, default_flow_style=False, sort_keys=False) + + +# --------------------------------------------------------------------------- +# CLI entry point / smoke test +# --------------------------------------------------------------------------- + +def _load_from_file(path: str): + """Load #js_content from a local HTML file (for smoke testing).""" + with open(path, encoding="utf-8") as fh: + soup = BeautifulSoup(fh.read(), "html.parser") + content = soup.find(id="js_content") + if content is None: + print(f"Error: #js_content not found in {path}", file=sys.stderr) + sys.exit(1) + _attach_title(soup, content) + return content + + +def main(): + parser = argparse.ArgumentParser( + description="Learn a WeChat formatting theme from an article URL.", + ) + parser.add_argument("url", help="WeChat article URL (https://mp.weixin.qq.com/s/...)") + parser.add_argument("--name", required=True, help="Theme name (used as filename and reference)") + parser.add_argument("--output-dir", default=None, help="Output directory (default: toolkit/themes/)") + args = parser.parse_args() + + # Validate name: only letters, digits, hyphens, underscores + if not re.match(r"^[a-zA-Z0-9_-]+$", args.name): + print("Error: --name must contain only letters, digits, hyphens, and underscores.", file=sys.stderr) + raise SystemExit(1) + + output_dir = Path(args.output_dir) if args.output_dir else THEMES_DIR + output_path = output_dir / f"{args.name}.yaml" + + if output_path.exists(): + print(f"Warning: {output_path} already exists, will be overwritten.", file=sys.stderr) + + # Fetch + print("Fetching article...") + content = fetch_article(args.url) + title = getattr(content, "_wewrite_title", "") + if title: + print(f"Title: {title}") + + # Extract + grouped = extract_styles(content) + styled_count = sum(len(v) for v in grouped.values()) + print(f"Extracted {styled_count} styled elements.") + + # Analyze + analyzed = analyze_styles(grouped) + + # Generate & write + theme_yaml = generate_theme_yaml(args.name, title, analyzed) + output_dir.mkdir(parents=True, exist_ok=True) + output_path.write_text(theme_yaml, encoding="utf-8") + + # Report + print() + print(f"Learned theme from: {title or args.url}") + print(f" text: {analyzed['text']}") + print(f" text_light: {analyzed['text_light']}") + print(f" primary: {analyzed['primary']}") + print(f" secondary: {analyzed['secondary']}") + print(f" background: {analyzed['background']}") + print(f" font: {analyzed['font_family'][:50]}") + print(f" size: {analyzed['font_size']} / line-height {analyzed['line_height']} / spacing {analyzed['letter_spacing']}") + print() + print(f"Theme saved → {output_path}") + print(f"Use it: python3 toolkit/cli.py preview article.md --theme {args.name}") + print(f"Or set: theme: {args.name} in style.yaml") + + +if __name__ == "__main__": + main() diff --git a/dist/openclaw/toolkit/cli.py b/dist/openclaw/toolkit/cli.py index 86f6301..f8f5742 100644 --- a/dist/openclaw/toolkit/cli.py +++ b/dist/openclaw/toolkit/cli.py @@ -232,6 +232,15 @@ def cmd_gallery(args): webbrowser.open(f"file://{Path(output).absolute()}") +def cmd_learn_theme(args): + """Learn a theme from a WeChat article URL.""" + import subprocess + script = Path(__file__).parent.parent / "scripts" / "learn_theme.py" + cmd = [sys.executable, str(script), args.url, "--name", args.name] + result = subprocess.run(cmd) + sys.exit(result.returncode) + + def _gallery_sample_markdown(): return """# 示例文章标题 @@ -397,6 +406,11 @@ def main(): p_gallery.add_argument("-o", "--output", help="Output HTML file path") p_gallery.add_argument("--no-open", action="store_true", help="Don't open browser") + # learn-theme + p_learn = sub.add_parser("learn-theme", help="Learn formatting theme from a WeChat article URL") + p_learn.add_argument("url", help="WeChat article URL") + p_learn.add_argument("--name", required=True, help="Theme name") + args = parser.parse_args() try: @@ -410,6 +424,8 @@ def main(): cmd_image_post(args) elif args.command == "gallery": cmd_gallery(args) + elif args.command == "learn-theme": + cmd_learn_theme(args) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1)