#!/usr/bin/env python3 """ CLI entry point for WeWrite. Usage: python cli.py preview article.md --theme professional-clean python cli.py publish article.md --appid wx123 --secret abc123 python cli.py themes """ import argparse import sys import webbrowser from pathlib import Path import yaml from converter import WeChatConverter, preview_html from theme import load_theme, list_themes from wechat_api import get_access_token, upload_image, upload_thumb from publisher import create_draft, create_image_post # Config file search order CONFIG_PATHS = [ Path.cwd() / "config.yaml", Path(__file__).parent.parent / "config.yaml", # skill root Path(__file__).parent / "config.yaml", # toolkit dir Path.home() / ".config" / "wewrite" / "config.yaml", ] def load_config() -> dict: """Load config from first found config.yaml.""" for p in CONFIG_PATHS: if p.exists(): with open(p, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} return {} def cmd_preview(args): """Generate HTML preview and open in browser.""" theme = load_theme(args.theme) converter = WeChatConverter(theme=theme) result = converter.convert_file(args.input) # Wrap in full HTML for browser preview full_html = preview_html(result.html, theme) # Write to temp file input_path = Path(args.input) output = args.output or str(input_path.with_suffix(".html")) Path(output).write_text(full_html, encoding="utf-8") print(f"Title: {result.title}") print(f"Digest: {result.digest}") print(f"Images: {len(result.images)}") print(f"Output: {output}") if not args.no_open: webbrowser.open(f"file://{Path(output).absolute()}") print("Opened in browser.") def cmd_publish(args): """Convert, upload images, and create WeChat draft.""" cfg = load_config() wechat_cfg = cfg.get("wechat", {}) # Resolve from CLI args → config.yaml fallback appid = args.appid or wechat_cfg.get("appid") secret = args.secret or wechat_cfg.get("secret") theme_name = args.theme or cfg.get("theme", "professional-clean") author = args.author or wechat_cfg.get("author") if not appid or not secret: print("Error: --appid and --secret required (or set in config.yaml)", file=sys.stderr) sys.exit(1) theme = load_theme(theme_name) converter = WeChatConverter(theme=theme) result = converter.convert_file(args.input) print(f"Title: {result.title}") print(f"Digest: {result.digest}") print(f"Images found: {len(result.images)}") # Get access token token = get_access_token(appid, secret) print("Access token obtained.") # Upload images referenced in article and replace src # Resolve relative paths against the markdown file's directory md_dir = Path(args.input).resolve().parent html = result.html for img_src in result.images: if img_src.startswith(("http://", "https://")): print(f"Skipping remote image: {img_src}") continue # Try: absolute → relative to CWD → relative to markdown file img_path = Path(img_src) if not img_path.is_absolute(): if not img_path.exists(): img_path = md_dir / img_src if img_path.exists(): print(f"Uploading image: {img_src}") wechat_url = upload_image(token, str(img_path)) html = html.replace(img_src, wechat_url) print(f" -> {wechat_url}") else: print(f"Warning: image not found: {img_src} (searched {md_dir})") # Upload cover image if provided thumb_media_id = None if args.cover: print(f"Uploading cover: {args.cover}") thumb_media_id = upload_thumb(token, args.cover) print(f" -> media_id: {thumb_media_id}") # Create draft title = args.title or result.title or Path(args.input).stem digest = args.digest or result.digest draft = create_draft( access_token=token, title=title, html=html, digest=digest, thumb_media_id=thumb_media_id, author=author, ) print(f"\nDraft created! media_id: {draft.media_id}") def cmd_themes(args): """List available themes.""" names = list_themes() for name in names: theme = load_theme(name) print(f" {name:24s} {theme.description}") def cmd_image_post(args): """Create a WeChat image post (小绿书) from image files.""" cfg = load_config() wechat_cfg = cfg.get("wechat", {}) appid = args.appid or wechat_cfg.get("appid") secret = args.secret or wechat_cfg.get("secret") if not appid or not secret: print("Error: --appid and --secret required (or set in config.yaml)", file=sys.stderr) sys.exit(1) images = args.images if not images: print("Error: at least 1 image required", file=sys.stderr) sys.exit(1) if len(images) > 20: print(f"Error: max 20 images, got {len(images)}", file=sys.stderr) sys.exit(1) token = get_access_token(appid, secret) print(f"Uploading {len(images)} images as permanent materials...") media_ids = [] for img_path in images: p = Path(img_path) if not p.exists(): print(f"Error: image not found: {img_path}", file=sys.stderr) sys.exit(1) print(f" Uploading: {p.name}") mid = upload_thumb(token, str(p)) media_ids.append(mid) print(f" -> {mid}") title = args.title if len(title) > 32: print(f"Warning: title truncated to 32 chars (was {len(title)})") title = title[:32] content = args.content or "" result = create_image_post( access_token=token, title=title, image_media_ids=media_ids, content=content, open_comment=True, ) print(f"\nImage post draft created!") print(f" media_id: {result.media_id}") print(f" images: {result.image_count}") print(f" title: {title}") print(f" 请到公众号后台草稿箱检查并发布") def cmd_gallery(args): """Render all themes side by side in a browser gallery.""" from concurrent.futures import ThreadPoolExecutor # Use provided markdown or a built-in sample if args.input: md_text = Path(args.input).read_text(encoding="utf-8") else: md_text = _gallery_sample_markdown() names = list_themes() results = {} def render_theme(name): theme = load_theme(name) converter = WeChatConverter(theme=theme) result = converter.convert(md_text) return name, theme.description, result.html # Parallel rendering with ThreadPoolExecutor(max_workers=8) as pool: for name, desc, html in pool.map(lambda n: render_theme(n), names): results[name] = (desc, html) # Build gallery HTML gallery_html = _build_gallery_html(results, names) output = args.output or "/tmp/wewrite-gallery.html" Path(output).write_text(gallery_html, encoding="utf-8") print(f"Gallery: {output} ({len(names)} themes)") if not args.no_open: 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 """# 示例文章标题 ## 第一部分 这是一段正常的文章内容,用来展示不同主题的排版效果。WeWrite 支持多种排版主题,每种都有独特的视觉风格。 说实话,选主题这件事——看截图永远不如看实际渲染效果。 ## 关键数据 | 指标 | 数值 | 变化 | |------|------|------| | 阅读量 | 12,580 | +23% | | 分享率 | 4.7% | +0.8% | | 完读率 | 68% | -2% | ## 代码示例 ```python def hello(): print("Hello, WeWrite!") ``` > 好的排版不是让读者注意到设计,而是让读者忘记设计,只记住内容。 ## 列表展示 - 第一个要点:简洁是设计的灵魂 - 第二个要点:一致性比创意更重要 - 第三个要点:移动端体验优先 **加粗文本**和*斜体文本*的样式也需要关注。 最后这段用来展示文章结尾的留白和间距效果。一篇好文章的结尾,应该像一首好歌的最后一个音符——恰到好处地收束。 """ def _join_newline(items): """Join items with comma + newline (workaround for f-string limitation).""" return ",\n".join(items) def _build_gallery_html(results, names): cards = [] for name in names: desc, html = results[name] # Escape for embedding in JS escaped_html = html.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$') cards.append(f"""
{len(names)} 个主题 · 点击卡片查看大图 · 点击「复制 HTML」直接粘贴到公众号编辑器