Skill: diwuwudi123/wechat-article-mulit-publisher (version 1.3.1)

--- name: wechat-article-mulit-publisher description: 从 Markdown 文件或网页链接提取文章并发布到微信公众号。支持多账号管理、两种排版模板（standard/viral）、自动生成封面图和草稿发布。 --- # WeChat Article Mulit Publisher ## 功能特性 - 多账号支持：通过 `--account` 指定发布到哪个公众号 - 两种排版模板：`standard`（科技风格）和 `viral`（高传播风格） - 自动生成封面图（无本地图片时） - 支持 `--dry-run` 预览 - 支持直接发布草稿 `--publish` - 文章内图片自动上传：自动将文章中的图片上传到微信素材库并替换为微信 CDN URL - 草稿管理：支持列出草稿列表、删除草稿 - 素材管理：支持单独上传图片到素材库（本地文件或网络 URL） ## 配置 编辑 `config.json`： ```json { "default_account": "your_account", "accounts": { "your_account": { "name": "公众号名称", "app_id": "wx...", "app_secret": "...", "author": "作者", "default_template": "standard", "topics": ["科技", "AI", "人工智能"] }, "account2": { "name": "第二个公众号", "app_id": "wx...", "app_secret": "...", "author": "作者", "default_template": "viral", "topics": ["汽车", "新能源", "电动车"] } } } ``` | 字段 | 必填 | 说明 | | ----------------------------- | ---- | ---------------------------------- | | `default_account` | 是 | 默认使用的账号名 | | `accounts[].name` | 是 | 公众号名称（显示用） | | `accounts[].app_id` | 是 | 微信公众号 AppID | | `accounts[].app_secret` | 是 | 微信公众号 AppSecret | | `accounts[].author` | 否 | 默认作者，可被 `--author` 覆盖 | | `accounts[].default_template` | 否 | 默认模板，默认 `standard` | | `accounts[].topics` | 否 | 公众号相关领域关键词，用于自动匹配 | ## 安装依赖 ```bash pip install -r scripts/requirements.txt ``` ## 使用方法 ```bash # 列出所有已配置的账号 python scripts/publish_wechat.py --list-accounts # 预览（不调用微信 API） python scripts/publish_wechat.py <文章.md> --dry-run # 发布到默认账号 python scripts/publish_wechat.py <文章.md> # 发布到指定账号 python scripts/publish_wechat.py <文章.md> --account account2 # 直接发布（跳过草稿直接发布） python scripts/publish_wechat.py <文章.md> --publish --status # 列出草稿箱 python scripts/publish_wechat.py --list-drafts # 删除草稿 python scripts/publish_wechat.py --delete-draft <media_id> # 上传本地图片到素材库 python scripts/publish_wechat.py --upload-material /path/to/image.jpg # 上传网络图片到素材库 python scripts/publish_wechat.py --upload-material-url https://example.com/image.jpg ``` ## 命令行参数 | 参数 | 说明 | | ----------------- | ------------------------------- | | `--account` | 指定公众号账号（账号标识名） | | `--list-accounts` | 列出所有已配置的账号 | | `--template` | 覆盖模板：`standard` 或 `viral` | | `--author` | 覆盖作者名 | | `--cover-image` | 指定本地封面图路径 | | `--source-url` | 覆盖原文链接 | | `--dry-run` | 仅渲染预览，不调用微信 API | | `--publish` | 草稿创建后直接提交发布 | | `--status` | 查询发布状态 | | `--list-drafts` | 列出草稿箱中的所有草稿 | | `--delete-draft` | 删除指定 media_id 的草稿 | | `--upload-material` | 上传本地图片到素材库（传入图片路径） | | `--upload-material-url` | 上传网络图片到素材库（传入图片URL） | ## 输出 成功时返回 JSON： ```json { "success": true, "account": "your_account", "title": "文章标题", "draft_media_id": "...", "preview_html": "路径/to-preview.html" } ```

path: config.json
content: {
  "default_account": "your_account",
  "accounts": {
    "your_account": {
      "name": "你的公众号名称",
      "app_id": "wx000000000000000000",
      "app_secret": "your_app_secret_here",
      "author": "作者名",
      "default_template": "standard"
    }
  }
}

path: scripts/publish_wechat.py
content: #!/usr/bin/env python3
"""Publish Markdown/URL articles to WeChat Official Account."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlparse

try:
    import markdown
    import requests
    import yaml
    from bs4 import BeautifulSoup
except Exception:
    markdown = None
    requests = None
    yaml = None
    BeautifulSoup = None

TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
DRAFT_ADD_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"
DRAFT_BATCHGET_URL = "https://api.weixin.qq.com/cgi-bin/draft/batchget"
DRAFT_DELETE_URL = "https://api.weixin.qq.com/cgi-bin/draft/delete"
PUBLISH_SUBMIT_URL = "https://api.weixin.qq.com/cgi-bin/freepublish/submit"
PUBLISH_GET_URL = "https://api.weixin.qq.com/cgi-bin/freepublish/get"
MATERIAL_ADD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"
UPLOAD_IMAGE_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg"


class WeChatPublishError(RuntimeError):
    """Raised when WeChat API reports an error."""


@dataclass
class Article:
    title: str
    content: str
    source_url: str
    first_image_url: str
    digest: str


def ensure_dependencies() -> None:
    if not all([markdown, requests, yaml, BeautifulSoup]):
        raise RuntimeError(
            "依赖未安装。请先运行: python scripts/publish_wechat.py --install"
        )


def install_dependencies() -> None:
    req = Path(__file__).resolve().parent / "requirements.txt"
    if not req.exists():
        raise RuntimeError(f"未找到依赖文件: {req}")
    subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", str(req)])


def slugify(text: str) -> str:
    t = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", text.strip().lower())
    t = re.sub(r"-{2,}", "-", t).strip("-")
    return t or "wechat-article"


def trim_utf8_bytes(text: str, max_bytes: int) -> str:
    raw = (text or "").encode("utf-8")
    if len(raw) <= max_bytes:
        return text or ""
    return raw[:max_bytes].decode("utf-8", errors="ignore")


def decode_escaped_unicode(text: str) -> str:
    """Convert literal \\uXXXX sequences to real Unicode characters."""
    if not text or "\\u" not in text:
        return text or ""
    if not re.search(r"\\u[0-9a-fA-F]{4}", text):
        return text
    try:
        return text.encode("utf-8").decode("unicode_escape")
    except Exception:
        return text


def normalize_wechat_title(text: str) -> str:
    value = decode_escaped_unicode(text or "")
    value = re.sub(r"\s+", " ", value.strip())
    value = re.sub(r"^#+\s*", "", value)
    value = value.replace("“", "\"").replace("”", "\"").replace("—", "-")
    # 标题按字符限制：64 个汉字以内（按 64 个字符处理）。
    value = value[:64].strip()
    return value or "未命名文章"


def is_url(input_value: str) -> bool:
    return bool(re.match(r"^https?://", input_value.strip(), re.I))


def load_config(path: Path) -> dict[str, Any]:
    if not path.exists():
        raise RuntimeError(f"配置文件不存在: {path}")
    data = json.loads(path.read_text(encoding="utf-8"))
    if "accounts" not in data or "default_account" not in data:
        raise RuntimeError("配置文件缺少 accounts 或 default_account 字段")
    if not data["accounts"]:
        raise RuntimeError("配置中 accounts 字典为空")
    return data


def get_account_config(cfg: dict[str, Any], account_name: str | None) -> dict[str, Any]:
    """从配置中获取指定账号的配置信息。"""
    accounts = cfg["accounts"]
    if not account_name:
        account_name = cfg.get("default_account") or list(accounts.keys())[0]
    if account_name not in accounts:
        available = ", ".join(accounts.keys())
        raise RuntimeError(f"账号 '{account_name}' 不在 accounts 列表中。可用账号: {available}")
    return accounts[account_name]


def auto_match_account(cfg: dict[str, Any], content: str) -> dict[str, Any]:
    """根据文章内容自动匹配最合适的账号。"""
    accounts = cfg["accounts"]
    if not content:
        return get_account_config(cfg, None)

    content_lower = content.lower()
    best_match = None
    best_score = 0

    for name, acc in accounts.items():
        topics = acc.get("topics", [])
        if not topics:
            continue
        score = sum(1 for topic in topics if topic.lower() in content_lower)
        if score > best_score:
            best_score = score
            best_match = name

    if best_match:
        return accounts[best_match]
    return get_account_config(cfg, None)


def list_accounts(cfg: dict[str, Any]) -> list[dict[str, str]]:
    """列出所有配置的账号信息。"""
    default = cfg.get("default_account", "")
    return [
        {
            "name": name,
            "is_default": name == default,
            "author": acc.get("author", ""),
            "default_template": acc.get("default_template", "standard"),
            "topics": acc.get("topics", []),
        }
        for name, acc in cfg["accounts"].items()
    ]


def parse_frontmatter(md_text: str) -> tuple[dict[str, Any], str]:
    text = decode_escaped_unicode(md_text).lstrip("\ufeff")
    if text.startswith("---"):
        parts = text.split("---", 2)
        if len(parts) >= 3:
            try:
                frontmatter = yaml.safe_load(parts[1]) or {}
                return frontmatter, parts[2].strip()
            except Exception:
                pass
    return {}, text


def extract_title_from_md(md_text: str) -> str:
    text = decode_escaped_unicode(md_text).lstrip("\ufeff")
    match = re.search(r"^#\s+(.+)$", text, re.M)
    if match:
        return match.group(1).strip()

    for line in text.splitlines():
        s = re.sub(r"^#+\s*", "", line.strip())
        if s:
            return s[:64]
    return "未命名文章"


def first_markdown_image(md_text: str) -> str:
    match = re.search(r"!\[[^\]]*\]\(([^)]+)\)", md_text)
    return match.group(1).strip() if match else ""


def extract_from_markdown(path: Path, source_url_override: str = "") -> Article:
    md_text = decode_escaped_unicode(path.read_text(encoding="utf-8")).lstrip("\ufeff")
    frontmatter, body = parse_frontmatter(md_text)
    title = (frontmatter.get("title") or extract_title_from_md(body)).strip()[:64]
    source_url = (source_url_override or frontmatter.get("source_url") or "").strip()
    html_content = markdown_to_html(body)
    plain = html_to_plain_text(html_content)
    digest = plain[:120]
    return Article(
        title=title or "未命名文章",
        content=html_content,
        source_url=source_url,
        first_image_url=first_markdown_image(body),
        digest=digest,
    )


def extract_from_url(url: str) -> Article:
    resp = requests.get(url, timeout=30)
    resp.raise_for_status()

    soup = BeautifulSoup(resp.text, "html.parser")
    title_node = (
        soup.select_one("#cb_post_title_url")
        or soup.find("h1")
        or soup.find("title")
    )
    title = title_node.get_text(strip=True)[:64] if title_node else "未命名文章"

    body_node = (
        soup.select_one("#cnblogs_post_body")
        or soup.find("article")
        or soup.find("main")
        or soup.find("div", class_=re.compile(r"content|article|post", re.I))
        or soup.body
    )
    if not body_node:
        raise RuntimeError("无法从网页提取正文")

    for tag in body_node.select("script,style,iframe,.ad,.ads,.advertisement,.comment"):
        tag.decompose()
    for img in body_node.find_all("img"):
        src = (img.get("src") or "").strip()
        if src:
            img["src"] = urljoin(url, src)
    for a in body_node.find_all("a"):
        href = (a.get("href") or "").strip()
        if href:
            a["href"] = urljoin(url, href)

    first_image_url = ""
    first_img = body_node.find("img")
    if first_img and first_img.get("src"):
        first_image_url = first_img["src"]

    html_content = str(body_node)
    plain = html_to_plain_text(html_content)
    digest = plain[:120]

    return Article(
        title=title,
        content=html_content,
        source_url=url,
        first_image_url=first_image_url,
        digest=digest,
    )


def is_probably_html(text: str) -> bool:
    sample = (text or "").strip()[:500].lower()
    return bool(
        re.search(r"<(article|section|div|p|h[1-6]|ul|ol|pre|table|img|blockquote)\b", sample)
    )


def markdown_to_html(text: str) -> str:
    text = decode_escaped_unicode(text or "")
    if is_probably_html(text):
        return text
    return markdown.markdown(
        text,
        extensions=["extra", "tables", "nl2br", "codehilite"],
        output_format="html",
    )


def html_to_plain_text(html: str) -> str:
    soup = BeautifulSoup(html, "html.parser")
    txt = soup.get_text("\n", strip=True)
    txt = re.sub(r"\n+", "\n", txt)
    return txt


def _next_element_sibling(node):
    sibling = node.next_sibling
    while sibling is not None:
        name = getattr(sibling, "name", None)
        if name:
            return sibling
        if str(sibling).strip():
            return None
        sibling = sibling.next_sibling
    return None


def _normalize_wechat_dom(soup: BeautifulSoup) -> None:
    for list_node in soup.find_all(["ul", "ol"]):
        for child in list(list_node.contents):
            name = getattr(child, "name", None)
            if name == "li":
                continue
            text = str(child).strip() if not name else ""
            if text and text not in ("\n", "\r\n"):
                li = soup.new_tag("li")
                li.string = text
                child.replace_with(li)
            else:
                try:
                    child.extract()
                except Exception:
                    pass

    for li in soup.find_all("li"):
        txt = li.get_text(" ", strip=True)
        if not txt or txt in {"•", "-", "·"}:
            li.decompose()

    for pre in soup.find_all("pre"):
        code = pre.find("code")
        target = code if code else pre
        text = target.get_text() if target else ""
        if not text:
            continue
        text = text.replace("\r\n", "\n").replace("\r", "\n").replace("\t", "    ")
        lines = [line.replace("\u00a0", " ").replace("\u3000", " ").rstrip() for line in text.split("\n")]
        cleaned = textwrap.dedent("\n".join(lines).strip("\n"))
        target.clear()
        target.append(cleaned)


def _enhance_reference_links(soup: BeautifulSoup) -> None:
    ref_keywords = ("参考资源", "相关链接", "references", "reference")
    for heading in soup.find_all(re.compile(r"^h[1-6]$")):
        title = heading.get_text(" ", strip=True).lower()
        if not any(k in title for k in ref_keywords):
            continue

        current_level = int(heading.name[1])
        node = heading.next_sibling
        while node is not None:
            if getattr(node, "name", None) and re.match(r"^h[1-6]$", node.name):
                next_level = int(node.name[1])
                if next_level <= current_level:
                    break

            anchors = node.find_all("a") if getattr(node, "find_all", None) else []
            for a in anchors:
                href = (a.get("href") or "").strip()
                if not href:
                    continue
                parent_text = a.parent.get_text(" ", strip=True) if a.parent else ""
                if href in parent_text:
                    continue
                url_anchor = soup.new_tag("a", href=href)
                url_anchor["style"] = "color:#576b95;text-decoration:underline;word-break:break-all;"
                url_anchor["target"] = "_blank"
                url_anchor.string = href
                tail = soup.new_tag("span")
                tail["style"] = "display:block;margin-top:2px;font-size:13px;line-height:1.6;color:#7a7a7a;"
                tail.append(url_anchor)
                a.insert_after(tail)
            node = node.next_sibling


def _style_lead_paragraph(soup: BeautifulSoup, template: str) -> None:
    for p in soup.find_all("p"):
        if p.find_parent(["li", "blockquote", "td", "th"]):
            continue
        text = p.get_text(" ", strip=True)
        if not text:
            continue
        if template == "viral":
            p["style"] = (
                "margin:1em 0;padding:0.88em 0.96em;background:#fff7e6;border:1px solid #ffe1a6;"
                "border-radius:8px;line-height:1.9;font-size:16px;color:#2b2f38;text-indent:0;"
                "box-shadow:0 2px 10px rgba(251,146,60,0.12);"
            )
        else:
            p["style"] = (
                "margin:1em 0;padding:0.75em 0.9em;background:#f8fafc;border:1px solid #edf2f7;"
                "border-radius:6px;line-height:1.9;font-size:16px;color:#243044;text-indent:0;"
            )
        break


def _decorate_highlights(soup: BeautifulSoup, template: str) -> None:
    for p in soup.find_all("p"):
        text = p.get_text(" ", strip=True)
        if text not in ("看点：", "看点:"):
            continue
        if template == "viral":
            p["style"] = (
                "margin:0.95em 0 0.35em;display:inline-block;padding:0.24em 0.72em;"
                "font-size:14px;line-height:1.4;color:#b42318;font-weight:700;"
                "background:#fff1f1;border-radius:999px;border:1px solid #ffcece;"
            )
        else:
            p["style"] = (
                "margin:0.95em 0 0.35em;display:inline-block;padding:0.22em 0.65em;"
                "font-size:14px;line-height:1.4;color:#2457c5;font-weight:700;"
                "background:#eef4ff;border-radius:999px;border:1px solid #dce9ff;"
            )

        next_block = _next_element_sibling(p)
        if next_block and next_block.name in ("ul", "ol"):
            if template == "viral":
                next_block["style"] = (
                    "margin:0.45em 0 1em;padding:0.78em 0.98em 0.78em 1.6em;line-height:1.85;"
                    "color:#2d3445;background:#fff7ec;border-radius:8px;"
                    "border:1px solid #ffe3bf;box-shadow:0 2px 12px rgba(251,146,60,0.10);"
                )
            else:
                next_block["style"] = (
                    "margin:0.45em 0 1em;padding:0.75em 0.95em 0.75em 1.55em;line-height:1.82;"
                    "color:#2f3441;background:#f8fafc;border-radius:6px;border:1px solid #e8eef8;"
                )


def optimize_for_wechat_html(content_html: str, template: str = "standard") -> str:
    template = (template or "standard").strip().lower()
    soup = BeautifulSoup(content_html, "html.parser")
    for tag in soup.find_all(["script", "style"]):
        tag.decompose()

    _normalize_wechat_dom(soup)
    _enhance_reference_links(soup)
    _decorate_highlights(soup, template)

    first_h1 = soup.find("h1")
    if first_h1:
        first_h1.decompose()

    for h2 in soup.find_all("h2"):
        if template == "viral":
            h2["style"] = (
                "margin:1.45em 0 0.72em;padding:0.46em 0.72em;font-size:1.2em;line-height:1.45;"
                "color:#7a1f1f;font-weight:800;background:#fff2f2;border-left:4px solid #ef4444;border-radius:4px;"
            )
        else:
            h2["style"] = (
                "margin:1.4em 0 0.7em;padding:0.38em 0.65em;font-size:1.18em;line-height:1.45;"
                "color:#1f2937;font-weight:700;background:#f5f8ff;border-left:4px solid #3b6dd8;border-radius:2px;"
            )

    for p in soup.find_all("p"):
        if p.get_text(" ", strip=True) in ("看点：", "看点:"):
            continue
        p["style"] = (
            "margin:0.95em 0;line-height:1.92;font-size:16px;color:#2b2f38;"
            "text-align:justify;letter-spacing:0.01em;text-indent:0;"
        )

    list_style = (
        "margin:0.98em 0;padding:0.82em 1em 0.82em 1.62em;line-height:1.86;color:#2d3445;"
        "background:#fffdf8;border-radius:8px;border:1px solid #ffe8c4;"
        "box-shadow:0 2px 10px rgba(251,146,60,0.08);list-style-position:outside;"
    ) if template == "viral" else (
        "margin:0.95em 0;padding:0.75em 0.95em 0.75em 1.55em;line-height:1.8;color:#2f3441;"
        "background:#f8fafc;border-radius:6px;border:1px solid #edf2f7;list-style-position:outside;"
    )

    for ul in soup.find_all("ul"):
        ul["style"] = list_style + "list-style-type:disc;"
    for ol in soup.find_all("ol"):
        ol["style"] = list_style + "list-style-type:decimal;"
    for li in soup.find_all("li"):
        li["style"] = "margin:0.28em 0;font-size:16px;line-height:1.82;text-indent:0;"

    for a in soup.find_all("a"):
        a["style"] = (
            "color:#c2410c;text-decoration:underline;word-break:break-all;"
            if template == "viral"
            else "color:#1f57c3;text-decoration:underline;word-break:break-all;"
        )
        if a.get("href"):
            a["target"] = "_blank"

    for strong in soup.find_all("strong"):
        if template == "viral":
            strong["style"] = (
                "font-weight:800;color:#9a3412;background:#fff3d6;padding:0 2px;border-radius:3px;"
            )
        else:
            strong["style"] = "font-weight:700;color:#1f2937;"

    for blockquote in soup.find_all("blockquote"):
        if template == "viral":
            blockquote["style"] = (
                "margin:1.1em 0;padding:0.82em 1.02em;border-left:3px solid #f59e0b;"
                "background:#fff8e6;color:#5f4b20;line-height:1.85;border-radius:6px;"
            )
        else:
            blockquote["style"] = (
                "margin:1.1em 0;padding:0.8em 1em;border-left:3px solid #7aa2ff;"
                "background:#f4f7ff;color:#43506a;line-height:1.8;border-radius:4px;"
            )

    _style_lead_paragraph(soup, template)
    body_html = "".join(str(node) for node in soup.contents)
    wrapper = (
        "<section style=\"margin:0 auto;max-width:690px;padding:0;"
        "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;"
        "color:#1f2937;font-size:16px;line-height:1.9;\">"
        f"{body_html}"
        "</section>"
    )
    return wrapper


def process_content_images(html_content: str, client: WeChatClient, token: str) -> str:
    soup = BeautifulSoup(html_content, "html.parser")
    img_tags = soup.find_all("img")

    if not img_tags:
        return html_content

    for img in list(img_tags):
        src = (img.get("src") or "").strip()
        if not src:
            img.decompose()
            continue

        if src.startswith(("http://", "https://")):
            try:
                new_url = client.upload_content_image_from_url(token, src)
                if new_url:
                    img["src"] = new_url
                else:
                    img.decompose()
            except Exception:
                img.decompose()
        else:
            local_path = Path(src)
            ext = local_path.suffix.lower().lstrip(".")
            if ext in ("jpg", "jpeg", "png") and local_path.exists():
                try:
                    new_url = client.upload_content_image(token, local_path)
                    if new_url:
                        img["src"] = new_url
                    else:
                        img.decompose()
                except Exception:
                    img.decompose()
            else:
                img.decompose()

    return str(soup)


def compress_image_if_needed(image_path: Path, max_size_bytes: int = 1048576) -> Path:
    if image_path.stat().st_size <= max_size_bytes:
        return image_path

    try:
        from PIL import Image
    except Exception:
        return image_path

    ext = image_path.suffix.lower().lstrip(".")
    if ext not in ("jpg", "jpeg", "png"):
        return image_path

    output_path = image_path.with_suffix(".compressed.jpg")

    with Image.open(image_path) as img:
        if img.mode in ("RGBA", "LA"):
            img = img.convert("RGB")

        quality = 95
        while quality >= 50:
            img.save(output_path, "JPEG", quality=quality, optimize=True)
            if output_path.stat().st_size <= max_size_bytes:
                return output_path
            quality -= 5

        if output_path.exists():
            return output_path

    return image_path


def generate_cover_image(title: str, output_path: Path) -> Path | None:
    try:
        from PIL import Image, ImageDraw, ImageFont
    except Exception:
        return None

    width, height = 900, 383
    img = Image.new("RGB", (width, height))
    draw = ImageDraw.Draw(img)
    top = (60, 130, 246)
    bottom = (33, 197, 147)
    for y in range(height):
        ratio = y / max(1, height - 1)
        r = int(top[0] + (bottom[0] - top[0]) * ratio)
        g = int(top[1] + (bottom[1] - top[1]) * ratio)
        b = int(top[2] + (bottom[2] - top[2]) * ratio)
        draw.line([(0, y), (width, y)], fill=(r, g, b))

    font_candidates = [
        "C:\\Windows\\Fonts\\msyhbd.ttc",
        "C:\\Windows\\Fonts\\msyh.ttc",
        "C:\\Windows\\Fonts\\simhei.ttf",
    ]
    font = None
    for c in font_candidates:
        try:
            font = ImageFont.truetype(c, 56)
            break
        except Exception:
            continue
    if font is None:
        font = ImageFont.load_default()

    clean = re.sub(r"\s+", " ", BeautifulSoup(title, "html.parser").get_text(" ", strip=True))
    line = clean[:26] + ("..." if len(clean) > 26 else "")
    draw.text((50, 130), line, fill=(255, 255, 255), font=font)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(output_path, "JPEG", quality=92)
    return output_path


class WeChatClient:
    def __init__(self, app_id: str, app_secret: str, timeout: int = 30):
        self.app_id = app_id
        self.app_secret = app_secret
        self.timeout = timeout

    def get_token(self) -> str:
        resp = requests.get(
            TOKEN_URL,
            params={
                "grant_type": "client_credential",
                "appid": self.app_id,
                "secret": self.app_secret,
            },
            timeout=self.timeout,
        )
        resp.raise_for_status()
        data = resp.json()
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"get_token failed: {data}")
        [REDACTED_SECRET]"access_token", "")
        if not token:
            raise WeChatPublishError("get_token failed: missing access_token")
        return token

    def _post_json_utf8(self, url: str, params: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        resp = requests.post(
            url,
            params=params,
            data=body,
            headers={"Content-Type": "application/json; charset=utf-8"},
            timeout=self.timeout,
        )
        resp.raise_for_status()
        return json.loads(resp.content.decode("utf-8"))

    def upload_image_from_path(self, token: str, image_path: Path) -> str:
        ext = image_path.suffix.lower().lstrip(".")
        if ext not in ("jpg", "jpeg", "png"):
            return ""
        actual_path = compress_image_if_needed(image_path)
        actual_ext = actual_path.suffix.lower().lstrip(".")
        mime_map = {
            "jpg": "image/jpeg",
            "jpeg": "image/jpeg",
            "png": "image/png",
        }
        content_type = mime_map.get(actual_ext, "image/jpeg")
        with actual_path.open("rb") as fh:
            files = {"media": (actual_path.name, fh, content_type)}
            resp = requests.post(
                MATERIAL_ADD_URL,
                params={"access_token": token, "type": "image"},
                files=files,
                timeout=self.timeout,
            )
        resp.raise_for_status()
        data = resp.json()
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"upload image failed: {data}")
        media_id = data.get("media_id", "")
        if not media_id:
            raise WeChatPublishError("upload image failed: missing media_id")
        return media_id

    def upload_image_from_url(self, token: str, image_url: str) -> str:
        img = requests.get(image_url, timeout=self.timeout)
        img.raise_for_status()
        content_type = img.headers.get("Content-Type", "image/jpeg")
        if not content_type.startswith("image/"):
            return ""
        ext = "jpg" if "jpeg" in content_type else content_type.split("/")[-1].lower()
        if ext not in ("jpg", "jpeg", "png"):
            return ""

        import io
        image_data = img.content
        if len(image_data) > 1048576:
            try:
                from PIL import Image
                img_obj = Image.open(io.BytesIO(image_data))
                if img_obj.mode in ("RGBA", "LA"):
                    img_obj = img_obj.convert("RGB")
                output = io.BytesIO()
                quality = 95
                while quality >= 50:
                    output.seek(0)
                    output.truncate()
                    img_obj.save(output, "JPEG", quality=quality, optimize=True)
                    if output.tell() <= 1048576:
                        image_data = output.getvalue()
                        break
                    quality -= 5
            except Exception:
                pass

        files = {"media": ("thumb.jpg", image_data, "image/jpeg")}
        resp = requests.post(
            MATERIAL_ADD_URL,
            params={"access_token": token, "type": "image"},
            files=files,
            timeout=self.timeout,
        )
        resp.raise_for_status()
        data = resp.json()
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"upload image url failed: {data}")
        media_id = data.get("media_id", "")
        if not media_id:
            raise WeChatPublishError("upload image url failed: missing media_id")
        return media_id

    def upload_content_image(self, token: str, image_path: Path) -> str:
        ext = image_path.suffix.lower().lstrip(".")
        if ext not in ("jpg", "jpeg", "png"):
            return ""
        actual_path = compress_image_if_needed(image_path)
        actual_ext = actual_path.suffix.lower().lstrip(".")
        mime_map = {
            "jpg": "image/jpeg",
            "jpeg": "image/jpeg",
            "png": "image/png",
        }
        content_type = mime_map.get(actual_ext, "image/jpeg")
        with actual_path.open("rb") as fh:
            files = {"media": (actual_path.name, fh, content_type)}
            resp = requests.post(
                UPLOAD_IMAGE_URL,
                params={"access_token": token},
                files=files,
                timeout=self.timeout,
            )
        resp.raise_for_status()
        data = resp.json()
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"upload content image failed: {data}")
        image_url = data.get("url", "")
        if not image_url:
            raise WeChatPublishError("upload content image failed: missing url")
        return image_url

    def upload_content_image_from_url(self, token: str, image_url: str) -> str:
        img = requests.get(image_url, timeout=self.timeout)
        img.raise_for_status()
        content_type = img.headers.get("Content-Type", "image/jpeg")
        if not content_type.startswith("image/"):
            return ""
        ext = "jpg" if "jpeg" in content_type else content_type.split("/")[-1].lower()
        if ext not in ("jpg", "jpeg", "png"):
            return ""

        import io
        image_data = img.content
        if len(image_data) > 1048576:
            try:
                from PIL import Image
                img_obj = Image.open(io.BytesIO(image_data))
                if img_obj.mode in ("RGBA", "LA"):
                    img_obj = img_obj.convert("RGB")
                output = io.BytesIO()
                quality = 95
                while quality >= 50:
                    output.seek(0)
                    output.truncate()
                    img_obj.save(output, "JPEG", quality=quality, optimize=True)
                    if output.tell() <= 1048576:
                        image_data = output.getvalue()
                        break
                    quality -= 5
            except Exception:
                pass

        files = {"media": (f"image.jpg", image_data, "image/jpeg")}
        resp = requests.post(
            UPLOAD_IMAGE_URL,
            params={"access_token": token},
            files=files,
            timeout=self.timeout,
        )
        resp.raise_for_status()
        data = resp.json()
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"upload content image from url failed: {data}")
        result_url = data.get("url", "")
        if not result_url:
            raise WeChatPublishError("upload content image from url failed: missing url")
        return result_url

    def add_draft(
        self,
        token: str,
        title: str,
        author: str,
        digest: str,
        content_html: str,
        source_url: str,
        thumb_media_id: str,
    ) -> str:
        safe_title_base = normalize_wechat_title(title)
        safe_author = trim_utf8_bytes(author, 16)
        safe_digest_base = decode_escaped_unicode(digest or "")
        safe_digest_base = re.sub(r"\s+", " ", safe_digest_base).strip()

        title_limits = [64, 48, 36, 28, 20, 12]
        digest_limits = [120, 90, 60, 40, 20]
        title_candidates: list[str] = []
        digest_candidates: list[str] = []

        for limit in title_limits:
            candidate = safe_title_base[:limit].strip()
            if candidate and candidate not in title_candidates:
                title_candidates.append(candidate)
        for limit in digest_limits:
            candidate = safe_digest_base[:limit].strip()
            if candidate and candidate not in digest_candidates:
                digest_candidates.append(candidate)
        if not digest_candidates:
            digest_candidates = ["AI 资讯速览"]

        last_error: dict[str, Any] | None = None
        for safe_title in title_candidates or ["未命名文章"]:
            should_shorten_title = False
            for safe_digest in digest_candidates:
                payload = {
                    "articles": [
                        {
                            "title": safe_title,
                            "author": safe_author,
                            "digest": safe_digest,
                            "content": content_html,
                            "content_source_url": source_url[:200],
                            "thumb_media_id": thumb_media_id,
                            "need_open_comment": 1,
                            "only_fans_can_comment": 0,
                        }
                    ]
                }
                data = self._post_json_utf8(
                    DRAFT_ADD_URL,
                    {"access_token": token},
                    payload,
                )
                if data.get("errcode", 0) == 0:
                    media_id = data.get("media_id", "")
                    if not media_id:
                        raise WeChatPublishError("draft add failed: missing media_id")
                    return media_id

                last_error = data
                errcode = data.get("errcode")
                if errcode == 45003:
                    should_shorten_title = True
                    break
                if errcode == 45004:
                    continue
                raise WeChatPublishError(f"draft add failed: {data}")

            if should_shorten_title:
                continue

        raise WeChatPublishError(f"draft add failed: {last_error}")

    def submit_publish(self, token: str, media_id: str) -> str:
        data = self._post_json_utf8(
            PUBLISH_SUBMIT_URL,
            {"access_token": token},
            {"media_id": media_id},
        )
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"publish submit failed: {data}")
        publish_id = data.get("publish_id", "")
        if not publish_id:
            raise WeChatPublishError("publish submit failed: missing publish_id")
        return publish_id

    def get_publish_status(self, token: str, publish_id: str) -> dict[str, Any]:
        data = self._post_json_utf8(
            PUBLISH_GET_URL,
            {"access_token": token},
            {"publish_id": publish_id},
        )
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"publish get failed: {data}")
        return data

    def get_drafts(self, token: str, offset: int = 0, count: int = 20, no_content: int = 1) -> dict[str, Any]:
        data = self._post_json_utf8(
            DRAFT_BATCHGET_URL,
            {"access_token": token},
            {"offset": offset, "count": count, "no_content": no_content},
        )
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"draft batchget failed: {data}")
        return data

    def delete_draft(self, token: str, media_id: str) -> dict[str, Any]:
        data = self._post_json_utf8(
            DRAFT_DELETE_URL,
            {"access_token": token},
            {"media_id": media_id},
        )
        if data.get("errcode", 0) != 0:
            raise WeChatPublishError(f"draft delete failed: {data}")
        return data


def parse_args() -> argparse.Namespace:
    default_config = Path(__file__).resolve().parent.parent / "config.json"
    parser = argparse.ArgumentParser(description="Publish article to WeChat Official Account")
    parser.add_argument("input", nargs="?", help="Markdown file path or article URL")
    parser.add_argument("--config", default=str(default_config), help="Path to config.json")
    parser.add_argument("--account", default="", help="指定要使用的公众号账号（账号名），默认使用 default_account")
    parser.add_argument("--list-accounts", action="store_true", help="列出配置中的所有公众号账号")
    parser.add_argument("--template", choices=["standard", "viral"], default="", help="Override template")
    parser.add_argument("--author", default="", help="Override author")
    parser.add_argument("--source-url", default="", help="Override source url")
    parser.add_argument("--cover-image", default="", help="Local cover image path")
    parser.add_argument("--timeout", type=int, default=30, help="HTTP timeout seconds")
    parser.add_argument("--dry-run", action="store_true", help="Render only, no WeChat API calls")
    parser.add_argument("--publish", action="store_true", help="Submit draft for publish")
    parser.add_argument("--status", action="store_true", help="Query publish status once")
    parser.add_argument("--install", action="store_true", help="Install Python dependencies")
    parser.add_argument("--list-drafts", action="store_true", help="List all drafts")
    parser.add_argument("--delete-draft", default="", help="Delete a draft by media_id")
    parser.add_argument("--upload-material", default="", help="上传本地图片到素材库（传入图片路径）")
    parser.add_argument("--upload-material-url", default="", help="上传网络图片到素材库（传入图片URL）")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.install:
        install_dependencies()
        print(json.dumps({"success": True, "installed": True}, ensure_ascii=False))
        return

    ensure_dependencies()
    cfg = load_config(Path(args.config))

    # 列出账号
    if args.list_accounts:
        accounts = list_accounts(cfg)
        print(json.dumps({"success": True, "accounts": accounts}, ensure_ascii=False, indent=2))
        return

    # 列出草稿箱
    if args.list_drafts:
        wechat_cfg = get_account_config(cfg, args.account.strip() or None)
        app_id = (wechat_cfg.get("app_id") or "").strip()
        app_secret = (wechat_cfg.get("app_secret") or "").strip()
        if not app_id or not app_secret:
            raise RuntimeError("配置缺少 app_id 或 app_secret")
        client = WeChatClient(app_id=app_id, app_secret=app_secret, timeout=args.timeout)
        [REDACTED_SECRET])
        drafts_data = client.get_drafts(token)
        items = drafts_data.get("item_count", 0)
        draft_list = []
        for item in drafts_data.get("item", []):
            news_item = item.get("content", {}).get("news_item", [])
            for news in news_item:
                draft_list.append({
                    "media_id": item.get("media_id", ""),
                    "title": news.get("title", ""),
                    "author": news.get("author", ""),
                    "digest": news.get("digest", ""),
                    "update_time": item.get("update_time", 0),
                })
        print(json.dumps({
            "success": True,
            "total": drafts_data.get("total_count", 0),
            "items": items,
            "drafts": draft_list,
        }, ensure_ascii=False, indent=2))
        return

    # 删除草稿
    if args.delete_draft:
        wechat_cfg = get_account_config(cfg, args.account.strip() or None)
        app_id = (wechat_cfg.get("app_id") or "").strip()
        app_secret = (wechat_cfg.get("app_secret") or "").strip()
        if not app_id or not app_secret:
            raise RuntimeError("配置缺少 app_id 或 app_secret")
        client = WeChatClient(app_id=app_id, app_secret=app_secret, timeout=args.timeout)
        [REDACTED_SECRET])
        client.delete_draft(token, args.delete_draft.strip())
        print(json.dumps({"success": True, "deleted": args.delete_draft.strip()}, ensure_ascii=False))
        return

    # 上传本地图片到素材库
    if args.upload_material:
        wechat_cfg = get_account_config(cfg, args.account.strip() or None)
        app_id = (wechat_cfg.get("app_id") or "").strip()
        app_secret = (wechat_cfg.get("app_secret") or "").strip()
        if not app_id or not app_secret:
            raise RuntimeError("配置缺少 app_id 或 app_secret")
        client = WeChatClient(app_id=app_id, app_secret=app_secret, timeout=args.timeout)
        [REDACTED_SECRET])
        image_path = Path(args.upload_material).resolve()
        if not image_path.exists():
            raise RuntimeError(f"文件不存在: {image_path}")
        media_id = client.upload_image_from_path(token, image_path)
        print(json.dumps({"success": True, "media_id": media_id, "path": str(image_path)}, ensure_ascii=False))
        return

    # 上传网络图片到素材库
    if args.upload_material_url:
        wechat_cfg = get_account_config(cfg, args.account.strip() or None)
        app_id = (wechat_cfg.get("app_id") or "").strip()
        app_secret = (wechat_cfg.get("app_secret") or "").strip()
        if not app_id or not app_secret:
            raise RuntimeError("配置缺少 app_id 或 app_secret")
        client = WeChatClient(app_id=app_id, app_secret=app_secret, timeout=args.timeout)
        [REDACTED_SECRET])
        media_id = client.upload_image_from_url(token, args.upload_material_url.strip())
        print(json.dumps({"success": True, "media_id": media_id, "url": args.upload_material_url.strip()}, ensure_ascii=False))
        return

    if not args.input:
        raise RuntimeError("缺少输入参数：请传入 Markdown 文件路径或 URL")

    input_value = args.input.strip()

    if is_url(input_value):
        article = extract_from_url(input_value)
        if args.source_url:
            article.source_url = args.source_url.strip()
    else:
        input_path = Path(input_value).resolve()
        if not input_path.exists():
            raise RuntimeError(f"文件不存在: {input_path}")
        article = extract_from_markdown(input_path, source_url_override=args.source_url.strip())

    if args.account.strip():
        wechat_cfg = get_account_config(cfg, args.account.strip())
    else:
        wechat_cfg = auto_match_account(cfg, article.content)
        print(json.dumps({"mode": "auto-match", "account": wechat_cfg.get("name", "unknown")}, ensure_ascii=False), file=sys.stderr)

    template = (args.template or wechat_cfg.get("default_template") or "viral").strip().lower()
    if template not in {"standard", "viral"}:
        template = "standard"
    author = (args.author or wechat_cfg.get("author") or "").strip()

    article.content = optimize_for_wechat_html(article.content, template=template)

    preview_path = Path.cwd() / f"{slugify(article.title)}-wechat-preview.html"
    preview_path.write_text(article.content, encoding="utf-8")

    if args.dry_run:
        print(
            json.dumps(
                {
                    "success": True,
                    "mode": "dry-run",
                    "title": article.title,
                    "digest": article.digest,
                    "template": template,
                    "preview_html": str(preview_path),
                },
                ensure_ascii=False,
            )
        )
        return

    app_id = (wechat_cfg.get("app_id") or "").strip()
    app_secret = (wechat_cfg.get("app_secret") or "").strip()
    if not app_id or not app_secret:
        raise RuntimeError("配置缺少 app_id 或 app_secret")

    account_display = wechat_cfg.get("name", "default")

    client = WeChatClient(app_id=app_id, app_secret=app_secret, timeout=args.timeout)
    [REDACTED_SECRET])

    article.content = process_content_images(article.content, client, token)

    thumb_media_id = ""
    auto_generate_cover = True
    cover_image_path = Path(args.cover_image).resolve() if args.cover_image else None
    if not thumb_media_id:
        if cover_image_path and cover_image_path.exists():
            thumb_media_id = client.upload_image_from_path(token, cover_image_path)
        else:
            generated = None
            if auto_generate_cover:
                generated = generate_cover_image(
                    article.title,
                    Path(__file__).resolve().parent.parent / "assets" / "generated_cover.jpg",
                )
            if generated and generated.exists():
                thumb_media_id = client.upload_image_from_path(token, generated)
            elif article.first_image_url and article.first_image_url.startswith(("http://", "https://")):
                thumb_media_id = client.upload_image_from_url(token, article.first_image_url)

    if not thumb_media_id:
        raise RuntimeError(
            "无法获取封面素材，请提供 --cover-image，或确保正文含可下载图片，或安装 Pillow 以自动生成封面"
        )

    draft_media_id = client.add_draft(
        token=token,
        title=article.title,
        author=author,
        digest=article.digest,
        content_html=article.content,
        source_url=article.source_url,
        thumb_media_id=thumb_media_id,
    )

    result: dict[str, Any] = {
        "success": True,
        "account": account_display,
        "title": article.title,
        "template": template,
        "draft_media_id": draft_media_id,
        "preview_html": str(preview_path),
    }

    if args.publish:
        publish_id = client.submit_publish(token, draft_media_id)
        result["publish_id"] = publish_id
        if args.status:
            result["status"] = client.get_publish_status(token, publish_id)

    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(
            json.dumps(
                {"success": False, "error": str(exc), "type": type(exc).__name__},
                ensure_ascii=False,
            )
        )
        sys.exit(1)


path: scripts/requirements.txt
content: requests>=2.31.0
beautifulsoup4>=4.12.0
markdown>=3.5.0
pyyaml>=6.0.0
Pillow>=10.0.0


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)