Skill: web3aivc/wechat-style-publisher (version 1.0.0)

--- name: wechat-style-publisher description: 支持多账号微信公众号发布，提供自定义文章主题、文章开头/结尾模板。当用户需要将带样式的文章发布到一个或多个微信公众号时使用此工具。 --- 当任务是将一篇文章发布到**一个或多个微信公众号**，并需要使用**账号独立凭据、主题化 HTML、以及可选的开头/结尾引导模板**时，请使用此技能。 ## 主要入口 - Node.js 发布脚本：`{baseDir}/scripts/publish-node.mjs` - Python 发布脚本：`{baseDir}/scripts/publish-python.py` - 仅应用样式（Node.js）：`{baseDir}/scripts/apply-style.mjs` - 模板导入工具： - `{baseDir}/scripts/import-template-node.mjs` - `{baseDir}/scripts/import-template-python.py` - 配置编辑器：`{baseDir}/scripts/set-config.mjs` --- # 功能说明 - 将草稿文章发布到一个或多个已配置的微信公众号 - 支持通过 **账号 ID 列表** 或 **所有已启用账号**进行发布 - 支持内置主题以及自定义 CSS 覆盖 - 在文章正文前后自动插入 **intro / outro HTML 模板** - 上传文章中的本地图片以及可选的封面图片 - 在 Node.js 流程中自动 **内联 CSS** 以兼容微信公众号 - 可从现有微信公众号文章 **HTML 文件或文章 URL** 中导入 intro/outro 模板和文章布局样式 - 将提取出的模板存储为 **命名变量**，方便后续复用 - 发布配置优先读取 `wechat.accounts.<accountId>.publishing` 若不存在则回退到顶层 `publishing` - 支持兼容参数别名，例如 `--article-url`、`--link`、`--template-name`、`--template-registry` - 支持 `--extract-mode ai` 用于保留更多候选内容与分析 JSON，以便 AI 进一步优化模板 --- # 输入内容 - **HTML 内容** 如果原始文章是 Markdown，需要先转换为 HTML。 - **多账号配置 JSON** 示例文件： `{baseDir}/assets/config.example.json` - **可选的文章开头/结尾模板** 示例文件：

path: requirements.txt
content: httpx>=0.27,<1


path: package.json
content: {
  "name": "wechat-style-publisher",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "style": "node scripts/apply-style.mjs",
    "publish:node": "node scripts/publish-node.mjs",
    "import-template:node": "node scripts/import-template-node.mjs",
    "set-config": "node scripts/set-config.mjs"
  },
  "engines": {
    "node": ">=18"
  },
  "dependencies": {
    "highlight.js": "^11.11.1",
    "juice": "^11.0.3"
  }
}


path: assets/template-variables.example.json
content: {
  "templates": {}
}


path: assets/custom-overrides.css
content: /*
  Custom overrides for WeChat article output.
  Keep selectors under .wechat-content so the overrides stay scoped.
*/

.wechat-content {
  color: #2f2f2f;
}

.wechat-content h1 {
  letter-spacing: 0.08em;
}

.wechat-content h2 {
  border-radius: 6px;
}

.wechat-content blockquote {
  border-left-width: 5px;
}

.wechat-content img {
  border-radius: 8px;
}

.wechat-content a {
  font-weight: 600;
}

.wechat-content .wechat-intro,
.wechat-content .wechat-outro {
  padding: 16px 18px;
  margin: 20px 0;
  background: #f7f7f7;
  border-radius: 10px;
}


path: assets/config.example.json
content: {
  "wechat": {
    "defaultAccount": "default",
    "apiBaseUrl": "https://api.weixin.qq.com",
    "tokenCacheDir": "./.tokens",
    "accounts": {
      "default": {
        "name": "我的公众号",
        "positioning": "AI 工具与效率提升",
        "topics": ["AI", "工具", "效率"],
        "excludeTopics": ["娱乐八卦"],
        "appId": "your_app_id_here",
        "appSecret": "your_app_secret_here",
        "type": "subscription",
        "enabled": true,
        "author": "OpenClaw",
        "digestSuffix": "关注账号获取更多实战内容",
        "publishing": {
          "defaultTheme": "fresh-green",
          "introTemplate": "./assets/templates/intro.html",
          "outroTemplate": "./assets/templates/outro.html",
          "customCssFile": "./assets/custom-overrides.css",
          "templateVariablesFile": "./assets/template-variables.example.json",
          "showCoverPic": true,
          "needOpenComment": 1,
          "onlyFansCanComment": 0
        }
      },
      "brand-b": {
        "name": "品牌 B 公众号",
        "positioning": "品牌案例与增长",
        "topics": ["品牌", "增长"],
        "excludeTopics": [],
        "appId": "your_second_app_id_here",
        "appSecret": "your_second_app_secret_here",
        "type": "service",
        "enabled": true,
        "author": "品牌 B 编辑部",
        "digestSuffix": "欢迎转发给团队成员"
      }
    }
  },
  "publishing": {
    "defaultTheme": "modern-minimal",
    "introTemplate": "./assets/templates/intro.html",
    "outroTemplate": "./assets/templates/outro.html",
    "customCssFile": "./assets/custom-overrides.css",
    "templateVariablesFile": "./assets/template-variables.example.json",
    "showCoverPic": true,
    "needOpenComment": 1,
    "onlyFansCanComment": 0
  }
}


path: assets/templates/intro.html
content: <section class="wechat-intro">
  <p><strong>导读</strong></p>
  <p>这篇文章会先给出核心结论，再展开关键步骤和实操细节。</p>
</section>


path: assets/templates/outro.html
content: <section class="wechat-outro">
  <p><strong>结尾引导</strong></p>
  <p>如果这篇内容对你有帮助，可以转发给同事或朋友，也可以持续关注后续更新。</p>
</section>


path: lib/style.mjs
content: import fs from "node:fs/promises";
import path from "node:path";
import hljs from "highlight.js";
import juice from "juice";

export const THEMES = {
  "modern-minimal": `
.wechat-content {
  font-size: 16px;
  color: #3f3f3f;
  line-height: 1.75;
  letter-spacing: 0.05em;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; padding: 0; font-weight: bold; color: #2b2b2b; }
.wechat-content h1 { font-size: 24px; padding-bottom: 8px; border-bottom: 1px solid #3eaf7c; color: #2b2b2b; }
.wechat-content h2 { font-size: 22px; padding: 8px 12px; background-color: #f8f8f8; color: #3eaf7c; border: none; }
.wechat-content h3 { font-size: 20px; padding-left: 10px; border-left: 2px solid #3eaf7c; }
.wechat-content h4 { font-size: 18px; }
.wechat-content h5 { font-size: 16px; }
.wechat-content h6 { font-size: 16px; color: #777; }
.wechat-content p { margin: 15px 0; line-height: 1.75; }
.wechat-content a { color: #3eaf7c; text-decoration: none; border-bottom: 1px solid #3eaf7c; }
.wechat-content ul, .wechat-content ol { margin: 15px 0; padding-left: 30px; }
.wechat-content li { margin: 8px 0; line-height: 1.75; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #f8f8f8; border-left: 3px solid #3eaf7c; color: #666; font-style: italic; }
.wechat-content blockquote p { margin: 0; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #f5f5f5; border-radius: 3px; overflow-x: auto; border: 1px solid #e0e0e0; }
.wechat-content code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background-color: #f0f0f0; padding: 2px 6px; border-radius: 3px; color: #d73a49; }
.wechat-content pre code { display: block; padding: 0; background-color: transparent; color: #333; line-height: 1.5; }
.wechat-content table { margin: 20px 0; border-collapse: collapse; width: 100%; font-size: 14px; }
.wechat-content table th, .wechat-content table td { padding: 10px 15px; border: 1px solid #dfe2e5; text-align: left; }
.wechat-content table th { background-color: #f6f8fa; font-weight: bold; color: #2b2b2b; }
.wechat-content table tr:nth-child(even) { background-color: #f8f8f8; }
.wechat-content hr { margin: 30px 0; border: none; border-top: 1px solid #eee; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 3px; }
.wechat-content strong { font-weight: bold; color: #2b2b2b; }
.wechat-content em { font-style: italic; }
.wechat-content del { text-decoration: line-through; color: #999; }
.wechat-content mark { background-color: #3eaf7c; color: #ffffff; padding: 2px 4px; border-radius: 2px; }
`,
  "tech-future": `
.wechat-content {
  font-size: 16px; color: #1a202c; line-height: 1.75; letter-spacing: 0.05em;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
  background: linear-gradient(180deg, #f7fafc 0%, #edf2f7 100%);
}
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; padding: 0; font-weight: bold; color: #0f172a; }
.wechat-content h1 { font-size: 24px; padding: 15px 25px; background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 100%); color: #ffffff; border: none; border-radius: 8px; box-shadow: 0 4px 20px rgba(139, 92, 246, 0.5), 0 0 30px rgba(6, 182, 212, 0.4); }
.wechat-content h2 { font-size: 22px; padding: 12px 20px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(6, 182, 212, 0.1) 100%); border-left: 5px solid; border-image: linear-gradient(to bottom, #06b6d4, #8b5cf6) 1; border-radius: 0 6px 6px 0; }
.wechat-content h3 { font-size: 20px; padding-left: 15px; border-left: 4px solid #06b6d4; }
.wechat-content h4 { font-size: 18px; color: #0891b2; }
.wechat-content h5 { font-size: 16px; }
.wechat-content h6 { font-size: 16px; color: #777; }
.wechat-content p { margin: 15px 0; line-height: 1.75; }
.wechat-content a { color: #0891b2; text-decoration: none; border-bottom: 2px solid #06b6d4; }
.wechat-content ul, .wechat-content ol { margin: 15px 0; padding-left: 30px; }
.wechat-content li { margin: 8px 0; line-height: 1.75; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background: linear-gradient(135deg, rgba(6, 182, 212, 0.1) 0%, rgba(139, 92, 246, 0.05) 100%); border-left: 5px solid; border-image: linear-gradient(to bottom, #06b6d4, #8b5cf6) 1; color: #475569; font-style: italic; border-radius: 0 8px 8px 0; }
.wechat-content blockquote p { margin: 0; }
.wechat-content pre { margin: 20px 0; padding: 20px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); border-radius: 8px; overflow-x: auto; border: 1px solid #06b6d4; }
.wechat-content code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(6, 182, 212, 0.15)); padding: 3px 8px; border-radius: 4px; color: #0891b2; border: 1px solid rgba(6, 182, 212, 0.3); }
.wechat-content pre code { display: block; padding: 0; background-color: transparent; color: #cbd5e1; line-height: 1.6; border: none; }
.wechat-content table { margin: 20px 0; border-collapse: collapse; width: 100%; font-size: 14px; }
.wechat-content table th, .wechat-content table td { padding: 10px 15px; border: 1px solid #dfe2e5; text-align: left; }
.wechat-content table th { background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 100%); font-weight: bold; color: #ffffff; }
.wechat-content table tr:nth-child(even) { background-color: #f8f8f8; }
.wechat-content hr { margin: 30px 0; border: none; height: 2px; background: linear-gradient(90deg, transparent, #06b6d4, #8b5cf6, transparent); }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 8px; }
.wechat-content strong { font-weight: bold; color: #0f172a; background: linear-gradient(135deg, rgba(6, 182, 212, 0.1), rgba(139, 92, 246, 0.1)); padding: 0 4px; border-radius: 2px; }
.wechat-content em { font-style: italic; }
.wechat-content del { text-decoration: line-through; color: #999; }
.wechat-content mark { background: linear-gradient(135deg, #06b6d4, #8b5cf6); color: #ffffff; padding: 3px 8px; border-radius: 4px; }
`,
  "warm-orange": `
.wechat-content {
  font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; padding: 0; font-weight: bold; color: #2b2b2b; }
.wechat-content h1 { font-size: 24px; padding: 12px 20px; background-color: #ff6b35; color: #ffffff; border: none; }
.wechat-content h2 { font-size: 22px; padding: 10px 16px 10px 20px; background-color: #fff3ed; border-left: 4px solid #ff6b35; color: #ff6b35; }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 4px solid #ff6b35; }
.wechat-content h4 { font-size: 18px; }
.wechat-content h5 { font-size: 16px; }
.wechat-content h6 { font-size: 16px; color: #777; }
.wechat-content p { margin: 15px 0; line-height: 1.75; }
.wechat-content a { color: #ff6b35; text-decoration: none; border-bottom: 1px solid #ff6b35; }
.wechat-content ul, .wechat-content ol { margin: 15px 0; padding-left: 30px; }
.wechat-content li { margin: 8px 0; line-height: 1.75; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #fff3ed; border-left: 4px solid #ff6b35; color: #666; font-style: italic; }
.wechat-content blockquote p { margin: 0; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2c2c2c; border-radius: 5px; overflow-x: auto; border: none; }
.wechat-content code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background-color: #fff3ed; padding: 2px 6px; border-radius: 3px; color: #ff6b35; }
.wechat-content pre code { display: block; padding: 0; background-color: transparent; color: #abb2bf; line-height: 1.5; }
.wechat-content table { margin: 20px 0; border-collapse: collapse; width: 100%; font-size: 14px; }
.wechat-content table th, .wechat-content table td { padding: 10px 15px; border: 1px solid #dfe2e5; text-align: left; }
.wechat-content table th { background-color: #ff6b35; font-weight: bold; color: #ffffff; }
.wechat-content table tr:nth-child(even) { background-color: #fff3ed; }
.wechat-content hr { margin: 30px 0; border: none; border-top: 1px solid #eee; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 5px; }
.wechat-content strong { font-weight: bold; color: #2b2b2b; }
.wechat-content em { font-style: italic; }
.wechat-content del { text-decoration: line-through; color: #999; }
.wechat-content mark { background-color: #ff6b35; color: #ffffff; padding: 2px 4px; border-radius: 2px; }
`,
  "fresh-green": `
.wechat-content {
  font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; padding: 0; font-weight: bold; color: #2b2b2b; }
.wechat-content h1 { font-size: 24px; padding-bottom: 12px; border-bottom: 3px solid #42b983; color: #2b2b2b; text-align: center; }
.wechat-content h2 { font-size: 22px; padding: 10px 16px; background: linear-gradient(to right, #42b983 0%, #85d7b3 100%); color: #ffffff; border: none; border-radius: 4px; }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 4px solid #42b983; }
.wechat-content h4 { font-size: 18px; }
.wechat-content h5 { font-size: 16px; }
.wechat-content h6 { font-size: 16px; color: #777; }
.wechat-content p { margin: 15px 0; line-height: 1.75; }
.wechat-content a { color: #42b983; text-decoration: none; border-bottom: 1px solid #42b983; }
.wechat-content ul, .wechat-content ol { margin: 15px 0; padding-left: 30px; }
.wechat-content li { margin: 8px 0; line-height: 1.75; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #f0faf6; border-left: 4px solid #42b983; color: #666; font-style: italic; }
.wechat-content blockquote p { margin: 0; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2c2c2c; border-radius: 5px; overflow-x: auto; border: none; }
.wechat-content code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background-color: #f0faf6; padding: 2px 6px; border-radius: 3px; color: #42b983; }
.wechat-content pre code { display: block; padding: 0; background-color: transparent; color: #abb2bf; line-height: 1.5; }
.wechat-content table { margin: 20px 0; border-collapse: collapse; width: 100%; font-size: 14px; }
.wechat-content table th, .wechat-content table td { padding: 10px 15px; border: 1px solid #dfe2e5; text-align: left; }
.wechat-content table th { background-color: #42b983; font-weight: bold; color: #ffffff; }
.wechat-content table tr:nth-child(even) { background-color: #f0faf6; }
.wechat-content hr { margin: 30px 0; border: none; border-top: 1px solid #eee; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 5px; }
.wechat-content strong { font-weight: bold; color: #2b2b2b; }
.wechat-content em { font-style: italic; }
.wechat-content del { text-decoration: line-through; color: #999; }
.wechat-content mark { background-color: #42b983; color: #ffffff; padding: 2px 4px; border-radius: 2px; }
`,
  "elegant-violet": `
.wechat-content {
  font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; padding: 0; font-weight: bold; color: #2b2b2b; }
.wechat-content h1 { font-size: 24px; padding-bottom: 12px; border-bottom: 3px solid #9b59b6; color: #2b2b2b; text-align: center; font-weight: 600; }
.wechat-content h2 { font-size: 22px; padding: 10px 16px; background: linear-gradient(135deg, #9b59b6 0%, #c39bd3 100%); color: #ffffff; border: none; border-radius: 4px; box-shadow: 0 2px 8px rgba(155, 89, 182, 0.3); }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 4px solid #9b59b6; }
.wechat-content h4 { font-size: 18px; }
.wechat-content h5 { font-size: 16px; }
.wechat-content h6 { font-size: 16px; color: #777; }
.wechat-content p { margin: 15px 0; line-height: 1.75; }
.wechat-content a { color: #9b59b6; text-decoration: none; border-bottom: 1px solid #9b59b6; }
.wechat-content ul, .wechat-content ol { margin: 15px 0; padding-left: 30px; }
.wechat-content li { margin: 8px 0; line-height: 1.75; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #f8f5fb; border-left: 4px solid #9b59b6; color: #666; font-style: italic; }
.wechat-content blockquote p { margin: 0; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2d2438; border-radius: 5px; overflow-x: auto; border: none; }
.wechat-content code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background-color: #f8f5fb; padding: 2px 6px; border-radius: 3px; color: #9b59b6; }
.wechat-content pre code { display: block; padding: 0; background-color: transparent; color: #c9a7d8; line-height: 1.5; }
.wechat-content table { margin: 20px 0; border-collapse: collapse; width: 100%; font-size: 14px; }
.wechat-content table th, .wechat-content table td { padding: 10px 15px; border: 1px solid #dfe2e5; text-align: left; }
.wechat-content table th { background-color: #9b59b6; font-weight: bold; color: #ffffff; }
.wechat-content table tr:nth-child(even) { background-color: #f8f5fb; }
.wechat-content hr { margin: 30px 0; border: none; border-top: 1px solid #eee; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 5px; }
.wechat-content strong { font-weight: bold; color: #2b2b2b; }
.wechat-content em { font-style: italic; }
.wechat-content del { text-decoration: line-through; color: #999; }
.wechat-content mark { background-color: #9b59b6; color: #ffffff; padding: 2px 4px; border-radius: 2px; }
`,
  "chinese-style": `
.wechat-content {
  font-size: 16px; color: #2c2c2c; line-height: 1.8; letter-spacing: 0.05em;
  font-family: "STSong", "SimSun", "Songti SC", "NSimSun", serif, -apple-system, BlinkMacSystemFont;
}
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; padding: 0; font-weight: bold; color: #2c2c2c; }
.wechat-content h1 { font-size: 24px; padding: 16px 30px; background: linear-gradient(to bottom, #f5e6d3 0%, #efe0c8 50%, #f5e6d3 100%); color: #c8161d; border: none; text-align: center; letter-spacing: 0.15em; border-top: 2px solid #c8161d; border-bottom: 2px solid #c8161d; }
.wechat-content h2 { font-size: 22px; padding: 10px 20px; border-left: 4px solid #c8161d; color: #c8161d; background: linear-gradient(to right, rgba(200, 22, 29, 0.05) 0%, transparent 100%); }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 3px solid #c8161d; }
.wechat-content h4 { font-size: 18px; }
.wechat-content h5 { font-size: 16px; }
.wechat-content h6 { font-size: 16px; color: #777; }
.wechat-content p { margin: 15px 0; line-height: 1.8; }
.wechat-content a { color: #c8161d; text-decoration: none; border-bottom: 1px solid #c8161d; }
.wechat-content ul, .wechat-content ol { margin: 15px 0; padding-left: 30px; }
.wechat-content li { margin: 8px 0; line-height: 1.8; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background: linear-gradient(to right, #faf8f3 0%, #f5f0e8 100%); border-left: 4px solid #c8161d; border-right: 4px solid #c8161d; color: #666; }
.wechat-content blockquote p { margin: 0; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2c2c2c; border-radius: 3px; overflow-x: auto; border: 1px solid #c8161d; }
.wechat-content code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; background-color: #faf8f3; padding: 2px 6px; border-radius: 3px; color: #c8161d; }
.wechat-content pre code { display: block; padding: 0; background-color: transparent; color: #abb2bf; line-height: 1.5; }
.wechat-content table { margin: 20px 0; border-collapse: collapse; width: 100%; font-size: 14px; }
.wechat-content table th, .wechat-content table td { padding: 10px 15px; border: 1px solid #dfe2e5; text-align: left; }
.wechat-content table th { background-color: #c8161d; font-weight: bold; color: #ffffff; }
.wechat-content table tr:nth-child(even) { background-color: #faf8f3; }
.wechat-content hr { margin: 30px 0; border: none; height: 1px; background: linear-gradient(to right, transparent, #c8161d, transparent); }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 3px; }
.wechat-content strong { font-weight: bold; color: #c8161d; }
.wechat-content em { font-style: italic; }
.wechat-content del { text-decoration: line-through; color: #999; }
.wechat-content mark { background-color: #c8161d; color: #ffffff; padding: 2px 4px; border-radius: 2px; }
`
};

const HIGHLIGHT_CSS = `
.hljs { display: block; overflow-x: auto; }
.hljs-comment, .hljs-quote { color: #5c6370; font-style: italic; }
.hljs-doctag, .hljs-keyword, .hljs-formula { color: #c678dd; }
.hljs-section, .hljs-name, .hljs-selector-tag, .hljs-deletion, .hljs-subst { color: #e06c75; }
.hljs-literal { color: #56b6c2; }
.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta-string { color: #98c379; }
.hljs-built_in, .hljs-class .hljs-title { color: #e6c07b; }
.hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-type, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-number { color: #d19a66; }
.hljs-symbol, .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-title { color: #61aeee; }
.hljs-emphasis { font-style: italic; }
.hljs-strong { font-weight: bold; }
.hljs-link { text-decoration: underline; }
`;

export function decodeHtmlEntities(input) {
  return input
    .replaceAll("&lt;", "<")
    .replaceAll("&gt;", ">")
    .replaceAll("&quot;", "\"")
    .replaceAll("&#39;", "'")
    .replaceAll("&amp;", "&");
}

export function highlightCodeBlocks(html) {
  return html.replace(/<pre>\s*<code([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/gi, (_, attrs, code) => {
    const classMatch = attrs.match(/class=["'][^"']*language-([a-z0-9#+-]+)[^"']*["']/i);
    const source = decodeHtmlEntities(code);
    let highlighted = "";

    if (classMatch) {
      const language = classMatch[1].toLowerCase();
      if (hljs.getLanguage(language)) {
        highlighted = hljs.highlight(source, { language }).value;
      }
    }

    if (!highlighted) {
      highlighted = hljs.highlightAuto(source).value;
    }

    return `<pre><code class="hljs">${highlighted}</code></pre>`;
  });
}

export async function readOptionalText(filePath) {
  if (!filePath) {
    return "";
  }
  return fs.readFile(path.resolve(filePath), "utf8");
}

export async function buildArticleHtml({
  html,
  theme = "modern-minimal",
  cssFile = "",
  customCss = "",
  introHtml = "",
  outroHtml = ""
}) {
  const themeCss = THEMES[theme];
  if (!themeCss) {
    throw new Error(`Unknown theme: ${theme}`);
  }

  const segments = [introHtml, html, outroHtml].filter(Boolean).join("\n");
  const wrapped = `<div class="wechat-content">${segments}</div>`;
  const highlighted = highlightCodeBlocks(wrapped);
  const fileCss = await readOptionalText(cssFile);
  const css = [themeCss, HIGHLIGHT_CSS, fileCss, customCss].filter(Boolean).join("\n");

  return juice.inlineContent(highlighted, css, {
    applyStyleTags: true,
    removeStyleTags: true,
    preserveMediaQueries: false,
    preserveFontFaces: false
  });
}


path: scripts/set-config.mjs
content: #!/usr/bin/env node

import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i += 1) {
    const arg = argv[i];
    const next = argv[i + 1];
    if (!arg.startsWith("--")) {
      continue;
    }
    if (!next || next.startsWith("--")) {
      args[arg.slice(2)] = true;
    } else {
      args[arg.slice(2)] = next;
      i += 1;
    }
  }
  return args;
}

function coerceValue(raw, typeHint) {
  if (typeHint === "json") {
    return JSON.parse(raw);
  }
  if (typeHint === "number") {
    return Number(raw);
  }
  if (typeHint === "boolean") {
    return raw === "true";
  }
  if (raw === "true") {
    return true;
  }
  if (raw === "false") {
    return false;
  }
  if (raw === "null") {
    return null;
  }
  return raw;
}

function ensurePath(root, dottedPath) {
  const parts = dottedPath.split(".").filter(Boolean);
  let current = root;
  for (let index = 0; index < parts.length - 1; index += 1) {
    const key = parts[index];
    if (typeof current[key] !== "object" || current[key] === null || Array.isArray(current[key])) {
      current[key] = {};
    }
    current = current[key];
  }
  return { parent: current, key: parts[parts.length - 1] };
}

async function main() {
  const args = parseArgs(process.argv);
  if (!args.config || !args.path) {
    throw new Error("Usage: node scripts/set-config.mjs --config config.json --path publishing.defaultTheme --value fresh-green");
  }

  const configPath = path.resolve(args.config);
  const raw = await fs.readFile(configPath, "utf8");
  const config = JSON.parse(raw);

  if (args.delete) {
    const { parent, key } = ensurePath(config, args.path);
    delete parent[key];
  } else {
    if (typeof args.value === "undefined") {
      throw new Error("Provide --value when not using --delete");
    }
    const { parent, key } = ensurePath(config, args.path);
    parent[key] = coerceValue(args.value, args.type || "");
  }

  await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
  process.stdout.write(`${args.path}\n`);
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/import-template-python.py
content: #!/usr/bin/env python3
"""Import intro/outro templates and layout styles from an existing WeChat article."""

import argparse
import json
import re
from datetime import datetime, UTC
from pathlib import Path

import httpx


def get_arg(namespace, *names):
    for name in names:
        value = getattr(namespace, name, None)
        if value not in [None, ""]:
            return value
    return None


def clean_html(html: str) -> str:
    return re.sub(r"<script\b[\s\S]*?</script>", "", html, flags=re.IGNORECASE)


def extract_style_blocks(html: str) -> str:
    matches = re.findall(r"<style\b[^>]*>([\s\S]*?)</style>", html, flags=re.IGNORECASE)
    return "\n".join(part.strip() for part in matches if part.strip())


def extract_article_html(html: str) -> str:
    patterns = [
        r'<div[^>]+id=["\']js_content["\'][^>]*>([\s\S]*?)</div>',
        r'<section[^>]+id=["\']js_content["\'][^>]*>([\s\S]*?)</section>',
        r'<div[^>]+class=["\'][^"\']*rich_media_content[^"\']*["\'][^>]*>([\s\S]*?)</div>',
        r"<article[^>]*>([\s\S]*?)</article>",
        r"<body[^>]*>([\s\S]*?)</body>",
    ]
    for pattern in patterns:
        match = re.search(pattern, html, flags=re.IGNORECASE)
        if match:
            return match.group(1)
    return html


def extract_blocks(html: str) -> list[str]:
    pattern = re.compile(
        r"<(p|section|blockquote|h1|h2|h3|h4|h5|h6|ul|ol|pre|table|figure|div)\b[^>]*>[\s\S]*?</\1>",
        flags=re.IGNORECASE,
    )
    blocks = []
    for match in pattern.finditer(html):
        block = match.group(0).strip()
        text_only = re.sub(r"<[^>]+>", " ", block)
        text_only = re.sub(r"\s+", " ", text_only).strip()
        if text_only:
            blocks.append(block)
    return blocks


def get_attribute_style(opening_tag: str) -> str:
    match = re.search(r'style=["\']([^"\']+)["\']', opening_tag, flags=re.IGNORECASE)
    return match.group(1).strip() if match else ""


def selector_rule(selector: str, style_text: str) -> str:
    return f"{selector} {{ {style_text} }}\n" if style_text else ""


def extract_style_profile(article_html: str, style_blocks: str) -> dict:
    title_tag = re.search(r"<h1\b[^>]*>", article_html, flags=re.IGNORECASE)
    h2_tag = re.search(r"<h2\b[^>]*>", article_html, flags=re.IGNORECASE)
    paragraph_tag = re.search(r"<p\b[^>]*>", article_html, flags=re.IGNORECASE)
    title_style = get_attribute_style(title_tag.group(0) if title_tag else "")
    heading_style = get_attribute_style(h2_tag.group(0) if h2_tag else "")
    paragraph_style = get_attribute_style(paragraph_tag.group(0) if paragraph_tag else "")
    extracted_css = "".join(
        [
            selector_rule(".wechat-content h1", title_style),
            selector_rule(".wechat-content h2", heading_style),
            selector_rule(".wechat-content p", paragraph_style),
        ]
    ).strip()
    custom_css = "\n".join(part for part in [extracted_css, style_blocks.strip()] if part).strip()
    return {
        "titleStyle": title_style,
        "headingStyle": heading_style,
        "paragraphStyle": paragraph_style,
        "customCss": custom_css,
    }


def find_edge_image(blocks: list[str], side: str) -> dict | None:
    window = blocks[:5] if side == "start" else blocks[-5:]
    for block in window:
        match = re.search(r'<img[^>]*src=["\']([^"\']+)["\'][^>]*>', block, flags=re.IGNORECASE)
        if match:
            return {"src": match.group(1), "html": block}
    return None


def wrap_template(name: str, blocks: list[str]) -> str:
    klass = "wechat-intro" if name == "intro" else "wechat-outro"
    return f'<section class="{klass}">\n' + "\n".join(blocks) + "\n</section>\n"


def load_html(input_path: str, url: str) -> str:
    if input_path:
        return Path(input_path).read_text(encoding="utf-8")
    if url:
        response = httpx.get(url, timeout=30)
        response.raise_for_status()
        return response.text
    raise ValueError("必须提供 --input 或 --url")


def load_registry(registry_path: str) -> dict:
    if not registry_path:
        return {"templates": {}}
    try:
        raw = Path(registry_path).read_text(encoding="utf-8")
        parsed = json.loads(raw)
        if "templates" in parsed:
            return parsed
    except Exception:
        pass
    return {"templates": {}}


def save_registry(registry_path: str, registry: dict) -> None:
    if registry_path:
        Path(registry_path).write_text(json.dumps(registry, ensure_ascii=False, indent=2), encoding="utf-8")


def main():
    parser = argparse.ArgumentParser(description="从现有公众号文章导入模板变量")
    parser.add_argument("--input", default="", help="本地 HTML 文件")
    parser.add_argument("--html-file", default="", help="本地 HTML 文件，兼容别名")
    parser.add_argument("--file", default="", help="本地 HTML 文件，兼容别名")
    parser.add_argument("--url", default="", help="公众号文章 URL")
    parser.add_argument("--article-url", default="", help="公众号文章 URL，兼容别名")
    parser.add_argument("--link", default="", help="公众号文章 URL，兼容别名")
    parser.add_argument("--name", default="", help="模板变量名")
    parser.add_argument("--template-name", default="", help="模板变量名，兼容别名")
    parser.add_argument("--save-name", default="", help="模板变量名，兼容别名")
    parser.add_argument("--intro-count", type=int, default=3, help="头模板提取区块数")
    parser.add_argument("--header-blocks", type=int, default=0, help="头模板提取区块数，兼容别名")
    parser.add_argument("--outro-count", type=int, default=3, help="尾模板提取区块数")
    parser.add_argument("--footer-blocks", type=int, default=0, help="尾模板提取区块数，兼容别名")
    parser.add_argument("--intro-output", default="", help="头模板输出路径")
    parser.add_argument("--header-template-output", default="", help="头模板输出路径，兼容别名")
    parser.add_argument("--outro-output", default="", help="尾模板输出路径")
    parser.add_argument("--footer-template-output", default="", help="尾模板输出路径，兼容别名")
    parser.add_argument("--style-output", default="", help="样式输出路径")
    parser.add_argument("--css-output", default="", help="样式输出路径，兼容别名")
    parser.add_argument("--layout-style-output", default="", help="样式输出路径，兼容别名")
    parser.add_argument("--registry", default="", help="模板变量仓库 JSON")
    parser.add_argument("--template-registry", default="", help="模板变量仓库 JSON，兼容别名")
    parser.add_argument("--extract-mode", default="heuristic", help="heuristic 或 ai")
    parser.add_argument("--mode", default="", help="heuristic 或 ai，兼容别名")
    parser.add_argument("--analysis-output", default="", help="AI 辅助分析输出 JSON")
    parser.add_argument("--ai-analysis-output", default="", help="AI 辅助分析输出 JSON，兼容别名")
    args = parser.parse_args()

    input_path = get_arg(args, "input", "html_file", "file") or ""
    url = get_arg(args, "url", "article_url", "link") or ""
    template_name = get_arg(args, "name", "template_name", "save_name") or ""
    registry_path = get_arg(args, "registry", "template_registry") or ""
    intro_output = get_arg(args, "intro_output", "header_template_output") or ""
    outro_output = get_arg(args, "outro_output", "footer_template_output") or ""
    style_output = get_arg(args, "style_output", "css_output", "layout_style_output") or ""
    analysis_output = get_arg(args, "analysis_output", "ai_analysis_output") or ""
    mode = get_arg(args, "extract_mode", "mode") or "heuristic"
    intro_count = args.header_blocks or args.intro_count or (5 if mode == "ai" else 3)
    outro_count = args.footer_blocks or args.outro_count or (5 if mode == "ai" else 3)

    raw_html = load_html(input_path, url)
    cleaned_html = clean_html(raw_html)
    style_blocks = extract_style_blocks(cleaned_html)
    article_html = extract_article_html(cleaned_html)
    blocks = extract_blocks(article_html)
    if not blocks:
        raise ValueError("未找到可提取的文章区块")

    intro_html = wrap_template("intro", blocks[:intro_count])
    outro_html = wrap_template("outro", blocks[-outro_count:])
    style_profile = extract_style_profile(article_html, style_blocks)
    header_image = find_edge_image(blocks, "start")
    footer_image = find_edge_image(blocks, "end")
    analysis = {
        "mode": mode,
        "source": url or input_path,
        "introCandidateBlocks": blocks[: min(8, len(blocks))],
        "outroCandidateBlocks": blocks[max(0, len(blocks) - 8) :],
        "styleProfile": style_profile,
        "headerImage": header_image,
        "footerImage": footer_image,
    }

    if intro_output:
        Path(intro_output).write_text(intro_html, encoding="utf-8")
    else:
        print(intro_html)

    if outro_output:
        Path(outro_output).write_text(outro_html, encoding="utf-8")
    else:
        print(outro_html)

    if style_output:
        Path(style_output).write_text(style_profile["customCss"], encoding="utf-8")

    if analysis_output:
        Path(analysis_output).write_text(json.dumps(analysis, ensure_ascii=False, indent=2), encoding="utf-8")

    if template_name:
        registry = load_registry(registry_path)
        registry["templates"][template_name] = {
            "name": template_name,
            "importedAt": datetime.now(UTC).isoformat(),
            "source": analysis["source"],
            "introHtml": intro_html,
            "outroHtml": outro_html,
            "customCss": style_profile["customCss"],
            "titleStyle": style_profile["titleStyle"],
            "headingStyle": style_profile["headingStyle"],
            "paragraphStyle": style_profile["paragraphStyle"],
            "headerImage": header_image,
            "footerImage": footer_image,
            "extractionMode": mode,
            "analysis": analysis,
        }
        save_registry(registry_path, registry)


if __name__ == "__main__":
    main()


path: scripts/publish-node.mjs
content: #!/usr/bin/env node

import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { buildArticleHtml, readOptionalText } from "../lib/style.mjs";

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg.startsWith("--")) {
      const next = argv[i + 1];
      if (!next || next.startsWith("--")) {
        args[arg.slice(2)] = true;
      } else {
        args[arg.slice(2)] = next;
        i += 1;
      }
    }
  }
  return args;
}

function stripHtml(html) {
  return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}

function resolveAccounts(config, args) {
  const accounts = config.wechat?.accounts || {};
  if (args["all-enabled"]) {
    return Object.entries(accounts)
      .filter(([, account]) => account.enabled !== false)
      .map(([id]) => id);
  }

  const raw = args.accounts || args.account || config.wechat?.defaultAccount || "default";
  return String(raw)
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

function resolvePublishingConfig(config, account) {
  return {
    ...(config.publishing || {}),
    ...(account?.publishing || {})
  };
}

async function loadConfig(configPath) {
  const resolved = path.resolve(configPath);
  const raw = await fs.readFile(resolved, "utf8");
  return JSON.parse(raw);
}

async function loadTemplateVariable(configDir, publishing, templateName, registryArg) {
  if (!templateName) {
    return null;
  }
  const registryPath = registryArg || publishing.templateVariablesFile || "";
  if (!registryPath) {
    throw new Error("Template variable requested but no registry file configured");
  }
  const resolved = path.resolve(configDir, registryPath);
  const raw = await fs.readFile(resolved, "utf8");
  const parsed = JSON.parse(raw);
  const template = parsed?.templates?.[templateName];
  if (!template) {
    throw new Error(`Template variable not found: ${templateName}`);
  }
  return template;
}

async function getAccessToken(config, accountId, tokenCacheDir) {
  const account = config.wechat.accounts[accountId];
  if (!account) {
    throw new Error(`Unknown account: ${accountId}`);
  }

  await fs.mkdir(tokenCacheDir, { recursive: true });
  const cacheFile = path.join(tokenCacheDir, `token_cache_${accountId.replace(/[^\w\u4e00-\u9fff-]+/g, "_")}.json`);

  try {
    const raw = await fs.readFile(cacheFile, "utf8");
    const cached = JSON.parse(raw);
    if (Date.now() / 1000 < Number(cached.expires_at || 0) - 300) {
      return cached.access_token;
    }
  } catch {}

  const params = new URLSearchParams({
    grant_type: "client_credential",
    appid: account.appId,
    [REDACTED_SECRET]
  });
  const url = `${config.wechat.apiBaseUrl || "https://api.weixin.qq.com"}/cgi-bin/token?${params.toString()}`;
  const response = await fetch(url);
  const result = await response.json();

  if (result.errcode) {
    throw new Error(`Get access token failed for ${accountId}: ${result.errcode} - ${result.errmsg}`);
  }

  const expiresAt = Math.floor(Date.now() / 1000) + Number(result.expires_in || 7200);
  await fs.writeFile(cacheFile, JSON.stringify({ access_token: result.access_token, expires_at: expiresAt }, null, 2), "utf8");
  return result.access_token;
}

async function uploadImage(config, accessToken, imagePath, isThumb = false) {
  const resolved = path.resolve(imagePath);
  const fileName = path.basename(resolved);
  const type = isThumb ? "thumb" : "image";
  const url = `${config.wechat.apiBaseUrl || "https://api.weixin.qq.com"}/cgi-bin/material/add_material?access_token=${accessToken}&type=${type}`;
  const data = await fs.readFile(resolved);
  const form = new FormData();
  form.append("media", new Blob([data]), fileName);

  const response = await fetch(url, {
    method: "POST",
    body: form
  });
  const result = await response.json();
  if (result.errcode && result.errcode !== 0) {
    throw new Error(`Upload image failed: ${result.errcode} - ${result.errmsg}`);
  }
  return result;
}

async function processContentImages(config, accessToken, html, baseDir) {
  const srcMatches = [...html.matchAll(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi)];
  let processed = html;

  for (const match of srcMatches) {
    const src = match[1];
    if (/^https?:\/\//i.test(src) || src.startsWith("data:")) {
      continue;
    }
    const imagePath = path.isAbsolute(src) ? src : path.resolve(baseDir, src);
    const uploaded = await uploadImage(config, accessToken, imagePath, false);
    if (uploaded.url) {
      processed = processed.replaceAll(`src="${src}"`, `src="${uploaded.url}"`);
      processed = processed.replaceAll(`src='${src}'`, `src='${uploaded.url}'`);
    }
  }

  return processed;
}

async function createDraft(config, accessToken, account, title, content, thumbMediaId = "") {
  const url = `${config.wechat.apiBaseUrl || "https://api.weixin.qq.com"}/cgi-bin/draft/add?access_token=${accessToken}`;
  const publishing = resolvePublishingConfig(config, account);
  const plainText = stripHtml(content);
  const digestBase = plainText.slice(0, 120).trim();
  const digestSuffix = account.digestSuffix ? ` ${account.digestSuffix}` : "";
  const payload = {
    articles: [
      {
        title,
        author: account.author || "OpenClaw",
        digest: `${digestBase}${digestSuffix}`.trim(),
        content,
        content_source_url: "",
        thumb_media_id: thumbMediaId,
        need_open_comment: publishing.needOpenComment ?? 1,
        only_fans_can_comment: publishing.onlyFansCanComment ?? 0,
        show_cover_pic: thumbMediaId && publishing.showCoverPic !== false ? 1 : 0
      }
    ]
  };

  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
  const result = await response.json();
  if (result.errcode && result.errcode !== 0) {
    throw new Error(`Create draft failed: ${result.errcode} - ${result.errmsg}`);
  }
  return result.media_id;
}

async function main() {
  const args = parseArgs(process.argv);
  if (!args.config || !args.title || (!args["content-file"] && !args.content)) {
    throw new Error("Usage: node scripts/publish-node.mjs --config config.json --title 标题 --content-file article.html [--accounts a,b]");
  }

  const config = await loadConfig(args.config);
  const configDir = path.dirname(path.resolve(args.config));
  const accounts = resolveAccounts(config, args);
  const html = args["content-file"]
    ? await fs.readFile(path.resolve(args["content-file"]), "utf8")
    : String(args.content || "");

  const results = [];
  const tokenCacheDir = path.resolve(configDir, config.wechat?.tokenCacheDir || "./.tokens");
  for (const accountId of accounts) {
    const account = config.wechat?.accounts?.[accountId];
    if (!account) {
      throw new Error(`Account not found: ${accountId}`);
    }
    const publishing = resolvePublishingConfig(config, account);
    const templateVariable = await loadTemplateVariable(configDir, publishing, args["template-name"], args.registry);
    const introHtml = templateVariable?.introHtml
      || await readOptionalText(args["intro-template"] || publishing.introTemplate ? path.resolve(configDir, args["intro-template"] || publishing.introTemplate || "") : "");
    const outroHtml = templateVariable?.outroHtml
      || await readOptionalText(args["outro-template"] || publishing.outroTemplate ? path.resolve(configDir, args["outro-template"] || publishing.outroTemplate || "") : "");
    const cssFile = templateVariable?.customCss ? "" : (args["css-file"] || publishing.customCssFile ? path.resolve(configDir, args["css-file"] || publishing.customCssFile || "") : "");
    const styledHtml = await buildArticleHtml({
      html,
      theme: args.theme || publishing.defaultTheme || "modern-minimal",
      cssFile,
      customCss: [templateVariable?.customCss || "", args["custom-css"] || ""].filter(Boolean).join("\n"),
      introHtml,
      outroHtml
    });
    const accessToken = await getAccessToken(config, accountId, tokenCacheDir);
    const contentBaseDir = args["content-dir"] ? path.resolve(args["content-dir"]) : path.dirname(path.resolve(args["content-file"] || "."));
    const processedContent = await processContentImages(config, accessToken, styledHtml, contentBaseDir);
    let thumbMediaId = "";
    if (args.thumb) {
      const thumb = await uploadImage(config, accessToken, args.thumb, false);
      thumbMediaId = thumb.media_id || "";
    }
    const mediaId = await createDraft(config, accessToken, account, args.title, processedContent, thumbMediaId);
    const result = { accountId, accountName: account.name || accountId, mediaId };
    results.push(result);
    process.stdout.write(`${result.accountId}\t${result.accountName}\t${result.mediaId}\n`);
  }

  if (args["output-json"]) {
    await fs.writeFile(path.resolve(args["output-json"]), JSON.stringify(results, null, 2), "utf8");
  }
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/apply-style.mjs
content: #!/usr/bin/env node

import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { buildArticleHtml } from "../lib/style.mjs";

function printHelp() {
  process.stdout.write(`Usage:
  node scripts/apply-style.mjs --input article.html [--output out.html] [--theme modern-minimal]
  cat article.html | node scripts/apply-style.mjs --stdin [--theme chinese-style]
  node scripts/apply-style.mjs --input article.html --template-name growth-weekly --registry assets/template-variables.example.json
`);
}

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === "--stdin" || arg === "--help") {
      args[arg.slice(2)] = true;
      continue;
    }
    args[arg.slice(2)] = argv[i + 1];
    i += 1;
  }
  return args;
}

async function readStdin() {
  const chunks = [];
  for await (const chunk of process.stdin) {
    chunks.push(chunk);
  }
  return Buffer.concat(chunks).toString("utf8");
}

async function main() {
  const args = parseArgs(process.argv);
  if (args.help) {
    printHelp();
    return;
  }

  let templateVariable = null;
  if (args["template-name"] && args.registry) {
    const raw = await fs.readFile(path.resolve(args.registry), "utf8");
    templateVariable = JSON.parse(raw)?.templates?.[args["template-name"]] || null;
    if (!templateVariable) {
      throw new Error(`Template variable not found: ${args["template-name"]}`);
    }
  }

  const html = args.stdin
    ? await readStdin()
    : await fs.readFile(path.resolve(args.input), "utf8");

  const output = await buildArticleHtml({
    html,
    theme: args.theme || "modern-minimal",
    cssFile: templateVariable?.customCss ? "" : (args["css-file"] || ""),
    customCss: [templateVariable?.customCss || "", args["custom-css"] || ""].filter(Boolean).join("\n"),
    introHtml: templateVariable?.introHtml || (args["intro-template"] ? await fs.readFile(path.resolve(args["intro-template"]), "utf8") : ""),
    outroHtml: templateVariable?.outroHtml || (args["outro-template"] ? await fs.readFile(path.resolve(args["outro-template"]), "utf8") : "")
  });

  if (args.output) {
    await fs.writeFile(path.resolve(args.output), output, "utf8");
    process.stdout.write(`${path.resolve(args.output)}\n`);
    return;
  }

  process.stdout.write(output);
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/import-template-node.mjs
content: #!/usr/bin/env node

import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i += 1) {
    const arg = argv[i];
    const next = argv[i + 1];
    if (!arg.startsWith("--")) {
      continue;
    }
    if (!next || next.startsWith("--")) {
      args[arg.slice(2)] = true;
    } else {
      args[arg.slice(2)] = next;
      i += 1;
    }
  }
  return args;
}

function getArg(args, ...names) {
  for (const name of names) {
    if (typeof args[name] !== "undefined") {
      return args[name];
    }
  }
  return undefined;
}

function cleanHtml(html) {
  return html.replace(/<script\b[\s\S]*?<\/script>/gi, "");
}

function extractStyleBlocks(html) {
  return [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)]
    .map((match) => match[1].trim())
    .filter(Boolean)
    .join("\n");
}

function extractArticleHtml(html) {
  const patterns = [
    /<div[^>]+id=["']js_content["'][^>]*>([\s\S]*?)<\/div>/i,
    /<section[^>]+id=["']js_content["'][^>]*>([\s\S]*?)<\/section>/i,
    /<div[^>]+class=["'][^"']*rich_media_content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i,
    /<article[^>]*>([\s\S]*?)<\/article>/i,
    /<body[^>]*>([\s\S]*?)<\/body>/i
  ];

  for (const pattern of patterns) {
    const match = html.match(pattern);
    if (match?.[1]) {
      return match[1];
    }
  }
  return html;
}

function extractBlocks(html) {
  const blockPattern = /<(p|section|blockquote|h1|h2|h3|h4|h5|h6|ul|ol|pre|table|figure|div)[^>]*>[\s\S]*?<\/\1>/gi;
  const blocks = [];
  for (const match of html.matchAll(blockPattern)) {
    const block = match[0].trim();
    const textOnly = block.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
    if (textOnly) {
      blocks.push(block);
    }
  }
  return blocks;
}

function getAttributeStyle(htmlTag) {
  const match = htmlTag.match(/style=["']([^"']+)["']/i);
  return match?.[1]?.trim() || "";
}

function selectorRule(selector, style) {
  return style ? `${selector} { ${style} }\n` : "";
}

function extractStyleProfile(articleHtml, styleBlocks) {
  const titleTag = articleHtml.match(/<h1\b[^>]*>/i)?.[0] || "";
  const h2Tag = articleHtml.match(/<h2\b[^>]*>/i)?.[0] || "";
  const paragraphTag = articleHtml.match(/<p\b[^>]*>/i)?.[0] || "";
  const titleStyle = getAttributeStyle(titleTag);
  const h2Style = getAttributeStyle(h2Tag);
  const paragraphStyle = getAttributeStyle(paragraphTag);

  const extractedCss = [
    selectorRule(".wechat-content h1", titleStyle),
    selectorRule(".wechat-content h2", h2Style),
    selectorRule(".wechat-content p", paragraphStyle)
  ].join("");

  return {
    titleStyle,
    headingStyle: h2Style,
    paragraphStyle,
    customCss: [extractedCss.trim(), styleBlocks.trim()].filter(Boolean).join("\n")
  };
}

function findEdgeImage(blocks, side) {
  const windowBlocks = side === "start" ? blocks.slice(0, 5) : blocks.slice(Math.max(0, blocks.length - 5));
  for (const block of windowBlocks) {
    const match = block.match(/<img[^>]*src=["']([^"']+)["'][^>]*>/i);
    if (match?.[1]) {
      return {
        src: match[1],
        html: block
      };
    }
  }
  return null;
}

function wrapTemplate(name, blocks) {
  const klass = name === "intro" ? "wechat-intro" : "wechat-outro";
  return `<section class="${klass}">\n${blocks.join("\n")}\n</section>\n`;
}

async function loadHtml(args) {
  const input = getArg(args, "input", "html-file", "file");
  const url = getArg(args, "url", "article-url", "link");
  if (input) {
    return fs.readFile(path.resolve(input), "utf8");
  }
  if (url) {
    const response = await fetch(String(url));
    if (!response.ok) {
      throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
    }
    return response.text();
  }
  throw new Error("Provide --input <html-file> or --url <wechat-article-url>");
}

async function loadRegistry(registryPath) {
  if (!registryPath) {
    return { templates: {} };
  }
  try {
    const raw = await fs.readFile(path.resolve(registryPath), "utf8");
    const parsed = JSON.parse(raw);
    return parsed?.templates ? parsed : { templates: {} };
  } catch {
    return { templates: {} };
  }
}

async function saveRegistry(registryPath, registry) {
  if (!registryPath) {
    return;
  }
  await fs.writeFile(path.resolve(registryPath), JSON.stringify(registry, null, 2), "utf8");
}

async function main() {
  const args = parseArgs(process.argv);
  const mode = getArg(args, "extract-mode", "mode") || "heuristic";
  const introCount = Number(getArg(args, "intro-count", "header-blocks") || (mode === "ai" ? 5 : 3));
  const outroCount = Number(getArg(args, "outro-count", "footer-blocks") || (mode === "ai" ? 5 : 3));
  const rawHtml = await loadHtml(args);
  const cleanedHtml = cleanHtml(rawHtml);
  const styleBlocks = extractStyleBlocks(cleanedHtml);
  const articleHtml = extractArticleHtml(cleanedHtml);
  const blocks = extractBlocks(articleHtml);

  if (blocks.length === 0) {
    throw new Error("No article blocks found");
  }

  const introBlocks = blocks.slice(0, introCount);
  const outroBlocks = blocks.slice(Math.max(0, blocks.length - outroCount));
  const introHtml = wrapTemplate("intro", introBlocks);
  const outroHtml = wrapTemplate("outro", outroBlocks);
  const styleProfile = extractStyleProfile(articleHtml, styleBlocks);
  const headerImage = findEdgeImage(blocks, "start");
  const footerImage = findEdgeImage(blocks, "end");
  const styleOutput = styleProfile.customCss || "";

  const introOutput = getArg(args, "intro-output", "header-template-output");
  const outroOutput = getArg(args, "outro-output", "footer-template-output");
  const styleOutputPath = getArg(args, "style-output", "css-output", "layout-style-output");
  const registryPath = getArg(args, "registry", "template-registry");
  const templateName = getArg(args, "name", "template-name", "save-name");
  const analysisOutput = getArg(args, "analysis-output", "ai-analysis-output");

  if (introOutput) {
    await fs.writeFile(path.resolve(introOutput), introHtml, "utf8");
  } else {
    process.stdout.write(introHtml);
  }

  if (outroOutput) {
    await fs.writeFile(path.resolve(outroOutput), outroHtml, "utf8");
  } else {
    process.stdout.write(`\n${outroHtml}`);
  }

  if (styleOutputPath) {
    await fs.writeFile(path.resolve(styleOutputPath), styleOutput, "utf8");
  }

  const analysis = {
    mode,
    source: getArg(args, "url", "article-url", "link") || getArg(args, "input", "html-file", "file") || "",
    introCandidateBlocks: blocks.slice(0, Math.min(8, blocks.length)),
    outroCandidateBlocks: blocks.slice(Math.max(0, blocks.length - 8)),
    styleProfile,
    headerImage,
    footerImage
  };

  if (analysisOutput) {
    await fs.writeFile(path.resolve(analysisOutput), JSON.stringify(analysis, null, 2), "utf8");
  }

  if (templateName) {
    const registry = await loadRegistry(registryPath);
    registry.templates[templateName] = {
      name: templateName,
      importedAt: new Date().toISOString(),
      source: analysis.source,
      introHtml,
      outroHtml,
      customCss: styleOutput,
      titleStyle: styleProfile.titleStyle,
      headingStyle: styleProfile.headingStyle,
      paragraphStyle: styleProfile.paragraphStyle,
      headerImage,
      footerImage,
      extractionMode: mode,
      analysis
    };
    await saveRegistry(registryPath, registry);
  }
}

main().catch((error) => {
  process.stderr.write(`${error.message}\n`);
  process.exitCode = 1;
});


path: scripts/publish-python.py
content: #!/usr/bin/env python3
"""Publish themed draft articles to one or more WeChat Official Accounts."""

import argparse
import asyncio
import json
import os
import re
import time
from pathlib import Path

import httpx


THEMES = {
    "modern-minimal": """
.wechat-content { font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.wechat-content h1, .wechat-content h2, .wechat-content h3, .wechat-content h4, .wechat-content h5, .wechat-content h6 { margin-top: 30px; margin-bottom: 15px; font-weight: bold; color: #2b2b2b; }
.wechat-content h1 { font-size: 24px; padding-bottom: 8px; border-bottom: 1px solid #3eaf7c; }
.wechat-content h2 { font-size: 22px; padding: 8px 12px; background-color: #f8f8f8; color: #3eaf7c; }
.wechat-content h3 { font-size: 20px; padding-left: 10px; border-left: 2px solid #3eaf7c; }
.wechat-content p { margin: 15px 0; }
.wechat-content a { color: #3eaf7c; text-decoration: none; border-bottom: 1px solid #3eaf7c; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #f8f8f8; border-left: 3px solid #3eaf7c; color: #666; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #f5f5f5; border-radius: 3px; overflow-x: auto; border: 1px solid #e0e0e0; }
.wechat-content code { font-family: Consolas, monospace; font-size: 14px; background-color: #f0f0f0; padding: 2px 6px; border-radius: 3px; color: #d73a49; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 3px; }
""",
    "tech-future": """
.wechat-content { font-size: 16px; color: #1a202c; line-height: 1.75; letter-spacing: 0.05em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: linear-gradient(180deg, #f7fafc 0%, #edf2f7 100%); }
.wechat-content h1 { font-size: 24px; padding: 15px 25px; background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 100%); color: #ffffff; border-radius: 8px; }
.wechat-content h2 { font-size: 22px; padding: 12px 20px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(6, 182, 212, 0.1) 100%); border-left: 5px solid #06b6d4; }
.wechat-content h3 { font-size: 20px; padding-left: 15px; border-left: 4px solid #06b6d4; }
.wechat-content p { margin: 15px 0; }
.wechat-content a { color: #0891b2; text-decoration: none; border-bottom: 2px solid #06b6d4; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background: linear-gradient(135deg, rgba(6, 182, 212, 0.1) 0%, rgba(139, 92, 246, 0.05) 100%); border-left: 5px solid #06b6d4; color: #475569; }
.wechat-content pre { margin: 20px 0; padding: 20px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); color: #cbd5e1; border-radius: 8px; overflow-x: auto; }
.wechat-content code { font-family: Consolas, monospace; font-size: 14px; background: rgba(6, 182, 212, 0.1); padding: 3px 8px; border-radius: 4px; color: #0891b2; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 8px; }
""",
    "warm-orange": """
.wechat-content { font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.wechat-content h1 { font-size: 24px; padding: 12px 20px; background-color: #ff6b35; color: #ffffff; }
.wechat-content h2 { font-size: 22px; padding: 10px 16px 10px 20px; background-color: #fff3ed; border-left: 4px solid #ff6b35; color: #ff6b35; }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 4px solid #ff6b35; }
.wechat-content p { margin: 15px 0; }
.wechat-content a { color: #ff6b35; text-decoration: none; border-bottom: 1px solid #ff6b35; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #fff3ed; border-left: 4px solid #ff6b35; color: #666; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2c2c2c; color: #abb2bf; border-radius: 5px; overflow-x: auto; }
.wechat-content code { font-family: Consolas, monospace; font-size: 14px; background-color: #fff3ed; padding: 2px 6px; border-radius: 3px; color: #ff6b35; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 5px; }
""",
    "fresh-green": """
.wechat-content { font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.wechat-content h1 { font-size: 24px; padding-bottom: 12px; border-bottom: 3px solid #42b983; text-align: center; }
.wechat-content h2 { font-size: 22px; padding: 10px 16px; background: linear-gradient(to right, #42b983 0%, #85d7b3 100%); color: #ffffff; border-radius: 4px; }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 4px solid #42b983; }
.wechat-content p { margin: 15px 0; }
.wechat-content a { color: #42b983; text-decoration: none; border-bottom: 1px solid #42b983; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #f0faf6; border-left: 4px solid #42b983; color: #666; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2c2c2c; color: #abb2bf; border-radius: 5px; overflow-x: auto; }
.wechat-content code { font-family: Consolas, monospace; font-size: 14px; background-color: #f0faf6; padding: 2px 6px; border-radius: 3px; color: #42b983; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 5px; }
""",
    "elegant-violet": """
.wechat-content { font-size: 16px; color: #3f3f3f; line-height: 1.75; letter-spacing: 0.05em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.wechat-content h1 { font-size: 24px; padding-bottom: 12px; border-bottom: 3px solid #9b59b6; text-align: center; font-weight: 600; }
.wechat-content h2 { font-size: 22px; padding: 10px 16px; background: linear-gradient(135deg, #9b59b6 0%, #c39bd3 100%); color: #ffffff; border-radius: 4px; }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 4px solid #9b59b6; }
.wechat-content p { margin: 15px 0; }
.wechat-content a { color: #9b59b6; text-decoration: none; border-bottom: 1px solid #9b59b6; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background-color: #f8f5fb; border-left: 4px solid #9b59b6; color: #666; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2d2438; color: #c9a7d8; border-radius: 5px; overflow-x: auto; }
.wechat-content code { font-family: Consolas, monospace; font-size: 14px; background-color: #f8f5fb; padding: 2px 6px; border-radius: 3px; color: #9b59b6; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 5px; }
""",
    "chinese-style": """
.wechat-content { font-size: 16px; color: #2c2c2c; line-height: 1.8; letter-spacing: 0.05em; font-family: "STSong", "SimSun", "Songti SC", serif; }
.wechat-content h1 { font-size: 24px; padding: 16px 30px; background: linear-gradient(to bottom, #f5e6d3 0%, #efe0c8 50%, #f5e6d3 100%); color: #c8161d; text-align: center; letter-spacing: 0.15em; border-top: 2px solid #c8161d; border-bottom: 2px solid #c8161d; }
.wechat-content h2 { font-size: 22px; padding: 10px 20px; border-left: 4px solid #c8161d; color: #c8161d; background: linear-gradient(to right, rgba(200, 22, 29, 0.05) 0%, transparent 100%); }
.wechat-content h3 { font-size: 20px; padding-left: 12px; border-left: 3px solid #c8161d; }
.wechat-content p { margin: 15px 0; }
.wechat-content a { color: #c8161d; text-decoration: none; border-bottom: 1px solid #c8161d; }
.wechat-content blockquote { margin: 20px 0; padding: 15px 20px; background: linear-gradient(to right, #faf8f3 0%, #f5f0e8 100%); border-left: 4px solid #c8161d; border-right: 4px solid #c8161d; color: #666; }
.wechat-content pre { margin: 20px 0; padding: 15px; background-color: #2c2c2c; color: #abb2bf; border-radius: 3px; overflow-x: auto; border: 1px solid #c8161d; }
.wechat-content code { font-family: Consolas, monospace; font-size: 14px; background-color: #faf8f3; padding: 2px 6px; border-radius: 3px; color: #c8161d; }
.wechat-content img { max-width: 100%; height: auto; display: block; margin: 20px auto; border-radius: 3px; }
""",
}


def load_json(path_str: str) -> dict:
    with open(path_str, "r", encoding="utf-8") as handle:
        return json.load(handle)


def read_optional_text(path_str: str) -> str:
    if not path_str:
        return ""
    with open(path_str, "r", encoding="utf-8") as handle:
        return handle.read()


def build_styled_content(html: str, theme: str, custom_css: str, intro_html: str, outro_html: str) -> str:
    theme_css = THEMES.get(theme)
    if not theme_css:
        raise ValueError(f"未知主题: {theme}")

    parts = [part for part in [intro_html, html, outro_html] if part]
    body = "\n".join(parts)
    css = "\n".join([theme_css, custom_css]).strip()
    return f'<style>{css}</style><div class="wechat-content">{body}</div>'


def strip_html(content: str) -> str:
    text = re.sub(r"<[^>]+>", " ", content)
    return re.sub(r"\s+", " ", text).strip()


def resolve_accounts(config: dict, account_arg: str, all_enabled: bool) -> list[str]:
    accounts = config.get("wechat", {}).get("accounts", {})
    if all_enabled:
        return [account_id for account_id, account in accounts.items() if account.get("enabled", True)]
    raw = account_arg or config.get("wechat", {}).get("defaultAccount", "default")
    return [item.strip() for item in raw.split(",") if item.strip()]


def resolve_publishing_config(config: dict, account: dict) -> dict:
    publishing = dict(config.get("publishing", {}))
    publishing.update(account.get("publishing", {}))
    return publishing


def load_template_variable(config_dir: Path, publishing: dict, template_name: str, registry_path: str) -> dict | None:
    if not template_name:
        return None
    registry_value = registry_path or publishing.get("templateVariablesFile", "")
    if not registry_value:
        raise ValueError("已指定模板变量名，但未配置 registry")
    resolved = (config_dir / registry_value).resolve()
    registry = json.loads(resolved.read_text(encoding="utf-8"))
    template = registry.get("templates", {}).get(template_name)
    if not template:
        raise ValueError(f"模板变量不存在: {template_name}")
    return template


class WeChatPublisher:
    def __init__(self, config: dict, account_id: str, config_dir: Path):
        self.config = config
        self.account_id = account_id
        self.config_dir = config_dir
        self.account = self.config["wechat"]["accounts"][account_id]
        self.base_url = self.config.get("wechat", {}).get("apiBaseUrl", "https://api.weixin.qq.com")
        token_cache_dir = self.config.get("wechat", {}).get("tokenCacheDir", "./.tokens")
        self.token_cache_dir = (config_dir / token_cache_dir).resolve()

    async def get_access_token(self) -> str:
        self.token_cache_dir.mkdir(parents=True, exist_ok=True)
        cache_file = self.token_cache_dir / f"token_cache_{re.sub(r'[^\\w\\u4e00-\\u9fff-]+', '_', self.account_id)}.json"
        if cache_file.exists():
            try:
                cache = json.loads(cache_file.read_text(encoding="utf-8"))
                if time.time() < cache["expires_at"] - 300:
                    return cache["access_token"]
            except Exception:
                pass

        params = {
            "grant_type": "client_credential",
            "appid": self.account["appId"],
            "secret": self.account["appSecret"],
        }
        async with httpx.AsyncClient(timeout=30) as client:
            response = await client.get(f"{self.base_url}/cgi-bin/token", params=params)
            result = response.json()

        if "errcode" in result:
            raise RuntimeError(f"获取 Access Token 失败: {result['errcode']} - {result['errmsg']}")

        expires_at = time.time() + result["expires_in"]
        cache_file.write_text(json.dumps({"access_token": result["access_token"], "expires_at": expires_at}), encoding="utf-8")
        return result["access_token"]

    async def upload_image(self, access_token: str, image_path: str, is_thumb: bool = False) -> dict:
        resolved = Path(image_path).resolve()
        image_type = "thumb" if is_thumb else "image"
        url = f"{self.base_url}/cgi-bin/material/add_material?access_token={access_token}&type={image_type}"
        mime_type = "image/jpeg" if resolved.suffix.lower() in [".jpg", ".jpeg"] else "image/png"
        async with httpx.AsyncClient(timeout=60) as client:
            with open(resolved, "rb") as file_handle:
                response = await client.post(url, files={"media": (resolved.name, file_handle, mime_type)})
                result = response.json()

        if result.get("errcode", 0) not in [0, None]:
            raise RuntimeError(f"上传图片失败: {result['errcode']} - {result['errmsg']}")
        return result

    async def process_content_images(self, access_token: str, content: str, content_dir: Path) -> str:
        processed = content
        matches = re.findall(r'<img[^>]*src=["\']([^"\']+)["\'][^>]*>', content, flags=re.IGNORECASE)
        for src in matches:
            if src.startswith(("http://", "https://", "data:")):
                continue
            image_path = Path(src)
            if not image_path.is_absolute():
                image_path = (content_dir / src).resolve()
            uploaded = await self.upload_image(access_token, str(image_path), is_thumb=False)
            if uploaded.get("url"):
                processed = processed.replace(f'src="{src}"', f'src="{uploaded["url"]}"')
                processed = processed.replace(f"src='{src}'", f"src='{uploaded['url']}'")
        return processed

    async def create_draft(self, access_token: str, title: str, content: str, thumb_media_id: str) -> str:
        publishing = resolve_publishing_config(self.config, self.account)
        digest_base = strip_html(content)[:120].strip()
        digest_suffix = self.account.get("digestSuffix", "")
        digest = f"{digest_base} {digest_suffix}".strip()
        article = {
            "title": title,
            "author": self.account.get("author", "OpenClaw"),
            "digest": digest,
            "content": content,
            "content_source_url": "",
            "thumb_media_id": thumb_media_id,
            "need_open_comment": publishing.get("needOpenComment", 1),
            "only_fans_can_comment": publishing.get("onlyFansCanComment", 0),
            "show_cover_pic": 1 if thumb_media_id and publishing.get("showCoverPic", True) else 0,
        }
        async with httpx.AsyncClient(timeout=30) as client:
            response = await client.post(
                f"{self.base_url}/cgi-bin/draft/add?access_token={access_token}",
                json={"articles": [article]},
            )
            result = response.json()

        if result.get("errcode", 0) not in [0, None]:
            raise RuntimeError(f"创建草稿失败: {result['errcode']} - {result['errmsg']}")
        return result["media_id"]


async def publish_account(args, config: dict, config_dir: Path, account_id: str, html: str, intro_html: str, outro_html: str, custom_css: str) -> dict:
    publisher = WeChatPublisher(config, account_id, config_dir)
    access_token = await publisher.get_access_token()
    publishing = resolve_publishing_config(config, publisher.account)
    styled_html = build_styled_content(
        html=html,
        theme=args.theme or publishing.get("defaultTheme", "modern-minimal"),
        custom_css=custom_css,
        intro_html=intro_html,
        outro_html=outro_html,
    )
    content_dir = Path(args.content_dir).resolve() if args.content_dir else Path(args.content_file).resolve().parent
    processed_html = await publisher.process_content_images(access_token, styled_html, content_dir)
    thumb_media_id = ""
    if args.thumb:
        thumb = await publisher.upload_image(access_token, args.thumb, is_thumb=False)
        thumb_media_id = thumb["media_id"]
    media_id = await publisher.create_draft(access_token, args.title, processed_html, thumb_media_id)
    return {
        "accountId": account_id,
        "accountName": publisher.account.get("name", account_id),
        "mediaId": media_id,
    }


async def async_main():
    parser = argparse.ArgumentParser(description="多账号微信公众号文章发布")
    parser.add_argument("--config", required=True, help="配置文件路径")
    parser.add_argument("--title", required=True, help="文章标题")
    parser.add_argument("--content-file", help="HTML 内容文件")
    parser.add_argument("--content", help="直接传入 HTML 内容")
    parser.add_argument("--account", default="", help="单个账号 ID")
    parser.add_argument("--accounts", default="", help="多个账号 ID，逗号分隔")
    parser.add_argument("--all-enabled", action="store_true", help="发布到所有启用的账号")
    parser.add_argument("--thumb", default="", help="封面图路径")
    parser.add_argument("--theme", default="", help="主题名称")
    parser.add_argument("--css-file", default="", help="自定义 CSS 文件")
    parser.add_argument("--template-name", default="", help="模板变量名")
    parser.add_argument("--registry", default="", help="模板变量仓库 JSON")
    parser.add_argument("--intro-template", default="", help="前导模板路径")
    parser.add_argument("--outro-template", default="", help="后导模板路径")
    parser.add_argument("--content-dir", default="", help="正文图片基础目录")
    args = parser.parse_args()

    if not args.content_file and not args.content:
        raise ValueError("必须提供 --content-file 或 --content")

    config_path = Path(args.config).resolve()
    config_dir = config_path.parent
    config = load_json(str(config_path))
    html = read_optional_text(args.content_file) if args.content_file else args.content
    requested_accounts = args.accounts or args.account
    account_ids = resolve_accounts(config, requested_accounts, args.all_enabled)

    results = []
    for account_id in account_ids:
        account = config.get("wechat", {}).get("accounts", {}).get(account_id, {})
        publishing = resolve_publishing_config(config, account)
        template_variable = load_template_variable(config_dir, publishing, args.template_name, args.registry)
        intro_template = args.intro_template or publishing.get("introTemplate", "")
        outro_template = args.outro_template or publishing.get("outroTemplate", "")
        css_file = args.css_file or publishing.get("customCssFile", "")
        intro_html = template_variable.get("introHtml", "") if template_variable else ""
        outro_html = template_variable.get("outroHtml", "") if template_variable else ""
        if not intro_html and intro_template:
            intro_html = read_optional_text(str((config_dir / intro_template).resolve()))
        if not outro_html and outro_template:
            outro_html = read_optional_text(str((config_dir / outro_template).resolve()))
        custom_css = template_variable.get("customCss", "") if template_variable else ""
        if not custom_css and css_file:
            custom_css = read_optional_text(str((config_dir / css_file).resolve()))
        result = await publish_account(args, config, config_dir, account_id, html, intro_html, outro_html, custom_css)
        results.append(result)
        print(f"{result['accountId']}\t{result['accountName']}\t{result['mediaId']}")


if __name__ == "__main__":
    asyncio.run(async_main())


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