新增小绿书/图片帖支持(WeChat newspic 格式)

publisher.py 新增 create_image_post():
- article_type="newspic",横滑轮播 3:4 比例
- 1-20 张图片,第一张自动当封面
- 标题 ≤ 32 字,纯文本描述 ~1000 字
- 使用 upload_thumb(永久素材)上传图片

cli.py 新增 image-post 子命令:
  python3 cli.py image-post img1.jpg img2.jpg -t "标题" -c "描述"

SKILL.md Step 8c 新增"做一个小绿书"触发入口。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wangzhuc 2026-03-29 01:33:42 +08:00
parent 9bcb9bdd23
commit 52cff3dd8f
3 changed files with 144 additions and 1 deletions

View file

@ -284,6 +284,7 @@ python3 {skill_dir}/toolkit/cli.py preview {markdown} --theme {theme} --no-open
| 换成 XX 主题 | 重新渲染 | | 换成 XX 主题 | 重新渲染 |
| 看看文章数据 | `读取: {skill_dir}/references/effect-review.md` | | 看看文章数据 | `读取: {skill_dir}/references/effect-review.md` |
| 学习我的修改 | `读取: {skill_dir}/references/learn-edits.md` | | 学习我的修改 | `读取: {skill_dir}/references/learn-edits.md` |
| 做一个小绿书/图片帖 | `python3 {skill_dir}/toolkit/cli.py image-post img1.jpg img2.jpg -t "标题"` |
--- ---

View file

@ -18,7 +18,7 @@ import yaml
from converter import WeChatConverter, preview_html from converter import WeChatConverter, preview_html
from theme import load_theme, list_themes from theme import load_theme, list_themes
from wechat_api import get_access_token, upload_image, upload_thumb 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 file search order
CONFIG_PATHS = [ CONFIG_PATHS = [
@ -142,6 +142,62 @@ def cmd_themes(args):
print(f" {name:24s} {theme.description}") 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): def cmd_gallery(args):
"""Render all themes side by side in a browser gallery.""" """Render all themes side by side in a browser gallery."""
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@ -327,6 +383,14 @@ def main():
# themes # themes
sub.add_parser("themes", help="List available 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 # gallery
p_gallery = sub.add_parser("gallery", help="Open theme gallery in browser") 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)") 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) cmd_publish(args)
elif args.command == "themes": elif args.command == "themes":
cmd_themes(args) cmd_themes(args)
elif args.command == "image-post":
cmd_image_post(args)
elif args.command == "gallery": elif args.command == "gallery":
cmd_gallery(args) cmd_gallery(args)
except Exception as e: except Exception as e:

View file

@ -10,6 +10,12 @@ class DraftResult:
media_id: str media_id: str
@dataclass
class ImagePostResult:
media_id: str
image_count: int
def create_draft( def create_draft(
access_token: str, access_token: str,
title: str, title: str,
@ -60,3 +66,73 @@ def create_draft(
raise ValueError(f"WeChat create_draft error: missing media_id in response: {data}") raise ValueError(f"WeChat create_draft error: missing media_id in response: {data}")
return DraftResult(media_id=data["media_id"]) 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),
)