diff --git a/SKILL.md b/SKILL.md index a9f581b..41f1110 100644 --- a/SKILL.md +++ b/SKILL.md @@ -284,6 +284,7 @@ python3 {skill_dir}/toolkit/cli.py preview {markdown} --theme {theme} --no-open | 换成 XX 主题 | 重新渲染 | | 看看文章数据 | `读取: {skill_dir}/references/effect-review.md` | | 学习我的修改 | `读取: {skill_dir}/references/learn-edits.md` | +| 做一个小绿书/图片帖 | `python3 {skill_dir}/toolkit/cli.py image-post img1.jpg img2.jpg -t "标题"` | --- diff --git a/toolkit/cli.py b/toolkit/cli.py index 225d540..86f6301 100644 --- a/toolkit/cli.py +++ b/toolkit/cli.py @@ -18,7 +18,7 @@ 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 +from publisher import create_draft, create_image_post # Config file search order CONFIG_PATHS = [ @@ -142,6 +142,62 @@ def cmd_themes(args): 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 @@ -327,6 +383,14 @@ def main(): # themes sub.add_parser("themes", help="List available themes") + # image-post (小绿书) + p_imgpost = sub.add_parser("image-post", help="Create WeChat image post (小绿书)") + p_imgpost.add_argument("images", nargs="+", help="Image file paths (1-20, first = cover)") + p_imgpost.add_argument("-t", "--title", required=True, help="Post title (max 32 chars)") + p_imgpost.add_argument("-c", "--content", default="", help="Plain text description (max ~1000 chars)") + p_imgpost.add_argument("--appid", default=None, help="WeChat AppID") + p_imgpost.add_argument("--secret", default=None, help="WeChat AppSecret") + # gallery p_gallery = sub.add_parser("gallery", help="Open theme gallery in browser") p_gallery.add_argument("input", nargs="?", default=None, help="Markdown file (optional, uses sample if omitted)") @@ -342,6 +406,8 @@ def main(): cmd_publish(args) elif args.command == "themes": cmd_themes(args) + elif args.command == "image-post": + cmd_image_post(args) elif args.command == "gallery": cmd_gallery(args) except Exception as e: diff --git a/toolkit/publisher.py b/toolkit/publisher.py index 952287a..28fc596 100644 --- a/toolkit/publisher.py +++ b/toolkit/publisher.py @@ -10,6 +10,12 @@ class DraftResult: media_id: str +@dataclass +class ImagePostResult: + media_id: str + image_count: int + + def create_draft( access_token: str, title: str, @@ -60,3 +66,73 @@ def create_draft( raise ValueError(f"WeChat create_draft error: missing media_id in response: {data}") return DraftResult(media_id=data["media_id"]) + + +def create_image_post( + access_token: str, + title: str, + image_media_ids: list[str], + content: str = "", + open_comment: bool = False, + fans_only_comment: bool = False, +) -> ImagePostResult: + """ + Create a WeChat image post (小绿书/图片帖) draft. + + This uses article_type="newspic" which displays as a horizontal + swipe carousel (3:4 ratio), similar to Xiaohongshu. + + Args: + access_token: WeChat access token. + title: Post title, max 32 characters. + image_media_ids: List of permanent media_ids from upload_thumb(). + Min 1, max 20. First image becomes the cover. + content: Plain text description, max ~1000 chars. No HTML. + open_comment: Allow comments. + fans_only_comment: Only followers can comment. + + Returns ImagePostResult with media_id of created draft. + """ + if not image_media_ids: + raise ValueError("At least 1 image is required for image post") + if len(image_media_ids) > 20: + raise ValueError(f"Max 20 images allowed, got {len(image_media_ids)}") + if len(title) > 32: + raise ValueError(f"Title max 32 chars for image post, got {len(title)}") + + article = { + "article_type": "newspic", + "title": title, + "content": content, + "image_info": { + "image_list": [ + {"image_media_id": mid} for mid in image_media_ids + ] + }, + "need_open_comment": 1 if open_comment else 0, + "only_fans_can_comment": 1 if fans_only_comment else 0, + } + + body = {"articles": [article]} + + resp = requests.post( + "https://api.weixin.qq.com/cgi-bin/draft/add", + params={"access_token": access_token}, + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + ) + + data = resp.json() + + errcode = data.get("errcode", 0) + if errcode != 0: + errmsg = data.get("errmsg", "unknown error") + raise ValueError(f"WeChat create_image_post error: errcode={errcode}, errmsg={errmsg}") + + if "media_id" not in data: + raise ValueError(f"WeChat create_image_post: missing media_id in response: {data}") + + return ImagePostResult( + media_id=data["media_id"], + image_count=len(image_media_ids), + )