Skill: zhylq/zhy-wechat-publish (version 0.1.0)

--- name: zhy-wechat-publish description: Use when publishing HTML articles to WeChat Official Account drafts, especially when you need cover upload, automatic cover generation, body image rewriting, CSS-variable compatibility, or draft metadata like author and comment settings. argument-hint: [html-file-path] --- # 微信公众号草稿箱发布技能 本技能用于将文章发布到微信公众号草稿箱。脚本仅依赖 Node.js 内置模块；自动封面生成步骤会复用现有 `bun` 生图脚本。 ## 前置条件 1. `Node.js >= 16` 2. 若要自动生成封面，还需本机可用 `bun` 3. 技能目录下 `.env` 至少包含： ```env WECHAT_APP_ID=你的公众号AppID WECHAT_APP_SECRET=你的公众号AppSecret WECHAT_DEFAULT_THUMB_MEDIA_ID=默认封面图media_id（可选） WECHAT_DEFAULT_AUTHOR=AI源来如此（可选） ``` 4. 运行机器公网 IP 已加入公众号后台 IP 白名单 5. 自动生成封面时，需保证 `zhy-article-illustrator` 的生图环境可用 ## 安装说明 本 skill 适合与 `npx skills add ... --skill zhy-wechat-publish` 一起安装到 OpenCode、Claude Code 等支持 `SKILL.md` 的 agent 工具中。 安装后请在本地 skill 目录手动创建 `.env`，不要将真实凭据提交回仓库。 ## 脚本清单 脚本位于 `scripts/`： | 脚本 | 用途 | |---|---| | `wechat_draft.js` | 上传正文到公众号草稿箱 | | `upload_image.js` | 上传本地封面图到永久素材库，获取 `media_id` | | `publish_with_cover.js` | 读取文章、生成封面、上传封面并推送草稿 | ## 核心能力 - 自动处理正文 CSS 变量兼容 - 自动将正文图片上传到微信正文图片接口并替换 URL - 自动将 `ul/ol` 降级为微信公众号编辑态更稳定的段落列表 - 支持默认作者、评论开关、仅粉丝评论设置 - 支持从文章内容生成单张公众号封面，并自动上传为 `thumb_media_id` ## 工作流 ### 方式一：已准备好 HTML 与封面 media_id ```bash node scripts/wechat_draft.js --title "文章标题" --file "post.html" --author "AI源来如此" --thumb "MEDIA_ID" --need-open-comment "1" --only-fans-can-comment "1" ``` 参数说明： | 参数 | 必填 | 说明 | |---|---|---| | `--title` | 是 | 文章标题 | | `--file` | 是 | HTML 正文文件路径 | | `--author` | 否 | 作者，不传时默认 `WECHAT_DEFAULT_AUTHOR` 或 `AI源来如此` | | `--digest` | 否 | 摘要 | | `--thumb` | 否 | 封面 `media_id`，不传时读取 `.env` 默认值 | | `--source-url` | 否 | 原文链接 | | `--need-open-comment` | 否 | 是否开启评论，默认 `1` | | `--only-fans-can-comment` | 否 | 是否仅粉丝可评论，默认 `1` | ### 方式二：自动生成封面并发布 当你已有文章源文件，以及最终用于上传的 HTML 文件时，可直接运行： ```bash node scripts/publish_with_cover.js --article "article.md" --html "post.html" --author "AI源来如此" --source-url "https://example.com" ``` 该脚本会自动完成： 1. 读取文章内容并提取标题/摘要 2. 调用 `zhy-article-illustrator/scripts/image-gen.ts` 生成单张 16:9 封面图 3. 调用 `upload_image.js --json` 上传封面，获得 `thumb_media_id` 4. 调用 `wechat_draft.js` 推送草稿，并自动带上： - `author` - `thumb_media_id` - `need_open_comment=1` - `only_fans_can_comment=1` 可选参数： | 参数 | 说明 | |---|---| | `--article` | 原始文章文件，推荐 Markdown，也可 HTML | | `--html` | 最终上传到公众号的 HTML 文件 | | `--title` | 显式覆盖标题 | | `--author` | 显式覆盖作者 | | `--cover-output` | 自定义封面输出路径 | | `--cover-ar` | 封面宽高比，默认 `16:9` | | `--source-url` | 原文链接 | | `--need-open-comment` | 是否开启评论，默认 `1` | | `--only-fans-can-comment` | 是否仅粉丝可评论，默认 `1` | ## 正文兼容说明 发布前，`wechat_draft.js` 会自动处理： 1. `var(--xxx)` CSS 变量展开 2. 微信 section 背景样式兼容 3. 首个 `h1` 去除，避免与标题重复 4. 正文图片上传到 `cgi-bin/media/uploadimg` 5. 原生列表转换为普通段落列表 ## 错误排查 - `[40013] invalid appid`：检查 `.env` 中 `WECHAT_APP_ID` - `[40164] invalid ip... not in whitelist`：将报错 IP 加入微信后台白名单 - `[40007] invalid media_id`：封面素材无效，重新上传封面图 - `缺少 Xiaomi/Gemini/OpenAI API Key`：自动封面生成所需生图配置缺失 - 正文图片不显示：检查原图路径/URL 是否可访问，以及 `media/uploadimg` 是否成功 ## 快速示例 ```bash # 已有 HTML，直接发草稿 node scripts/wechat_draft.js --title "技术周报" --file "post.html" # 自动生封面并发布 node scripts/publish_with_cover.js --article "article.md" --html "post.html" # 上传封面图并把 media_id 回写 .env node scripts/upload_image.js "cover.png" --write-env --json ```

path: README.md
content: # zhy-wechat-publish

辅助 skill：将 HTML 上传到微信公众号草稿箱，并自动处理 CSS 变量、正文图片和列表兼容性。

## Install

```bash
npx skills add https://github.com/zhylq/yuan-skills --skill zhy-wechat-publish
```

## What It Does

- 上传 HTML 到微信公众号草稿箱
- 自动展开 CSS 变量
- 自动上传正文图片到微信正文图片接口
- 自动去掉首个 `h1`
- 自动把 `ul/ol` 转成公众号编辑态更稳定的段落列表
- 可选自动生成封面并上传

## Environment Setup

请先复制：

```bash
cp .env.example .env
```

### 最小必填

```env
WECHAT_APP_ID=
WECHAT_APP_SECRET=
```

### 可选但常用

```env
WECHAT_DEFAULT_THUMB_MEDIA_ID=
WECHAT_DEFAULT_AUTHOR=
```

### 如果要自动生成封面

还需要可用的生图环境。可填写：

```env
IMAGE_PROVIDER=
IMAGE_MODEL=
IMAGE_BASE_URL=
IMAGE_API_KEY=
IMAGE_SIZE=
XIAOMI_API_KEY=
XIAOMI_BASE_URL=
XIAOMI_IMAGE_SIZE=
GEMINI_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
```

## WeChat Requirements

- 运行机器公网 IP 需要加入微信公众号后台 IP 白名单
- 使用微信官方草稿接口，需要有效的公众号 AppID / AppSecret
- 如果未提供 `--thumb` 且未配置 `WECHAT_DEFAULT_THUMB_MEDIA_ID`，草稿上传可能失败

## Common Inputs

### 直接上传 HTML

- `--title`
- `--file`
- `--author`
- `--digest`
- `--thumb`
- `--source-url`
- `--need-open-comment`
- `--only-fans-can-comment`

### 自动封面上传

- `--article`
- `--html`
- `--title`
- `--author`
- `--cover-output`
- `--cover-ar`
- `--source-url`

## When To Use Which Entry

### 用 `wechat_draft.js`

适合：

- 你已经有最终 HTML
- 你已经有封面 `media_id`
- 你只想稳定上传草稿

### 用 `publish_with_cover.js`

适合：

- 你已经有文章文件和 HTML
- 你希望自动生成封面
- 你本机有可用的 `bun` 和生图环境

## Notes

- 本 skill 不会自动最终发布，只会保存到草稿箱
- 标题长度建议不超过 64 字符
- 不要把真实 `.env`、AppSecret、media_id 提交回仓库


path: scripts/publish_with_cover.js
content: const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const DEFAULT_AUTHOR = 'AI源来如此';
const DEFAULT_NEED_OPEN_COMMENT = '1';
const DEFAULT_ONLY_FANS_CAN_COMMENT = '1';
const DEFAULT_ASPECT_RATIO = '16:9';

function loadEnv() {
    try {
        const skillDir = path.resolve(__dirname, '..');
        const candidates = [
            path.resolve(skillDir, '.env'),
            path.resolve(process.cwd(), '.env'),
            path.resolve(skillDir, '..', '..', '..', '.env'),
        ];
        for (const envPath of candidates) {
            if (!fs.existsSync(envPath)) continue;
            const content = fs.readFileSync(envPath, 'utf8');
            content.split('\n').forEach((line) => {
                const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
                if (!match) return;
                const key = match[1];
                const value = (match[2] || '').trim().replace(/^['"]|['"]$/g, '');
                if (!process.env[key]) {
                    process.env[key] = value;
                }
            });
            break;
        }
    } catch (error) {
        // ignore env loading errors
    }
}

function parseArgs() {
    const argv = process.argv.slice(2);
    const parsed = {};
    for (let i = 0; i < argv.length; i++) {
        const [REDACTED_SECRET];
        if (!token.startsWith('--')) continue;
        const key = token.slice(2);
        const next = argv[i + 1];
        if (!next || next.startsWith('--')) {
            parsed[key] = '1';
            continue;
        }
        parsed[key] = next;
        i += 1;
    }
    return parsed;
}

function stripMarkdown(markdown) {
    return markdown
        .replace(/```[\s\S]*?```/g, ' ')
        .replace(/`([^`]+)`/g, '$1')
        .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
        .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
        .replace(/^#{1,6}\s+/gm, '')
        .replace(/^>\s?/gm, '')
        .replace(/[*_~>-]/g, ' ')
        .replace(/\s+/g, ' ')
        .trim();
}

function stripHtml(html) {
    return html
        .replace(/<style[\s\S]*?<\/style>/gi, ' ')
        .replace(/<script[\s\S]*?<\/script>/gi, ' ')
        .replace(/<[^>]+>/g, ' ')
        .replace(/&nbsp;/g, ' ')
        .replace(/&amp;/g, '&')
        .replace(/\s+/g, ' ')
        .trim();
}

function parseArticleInput(content, filePath) {
    const ext = path.extname(filePath || '').toLowerCase();
    const isHtml = ext === '.html' || ext === '.htm' || /<html|<section|<p\b/i.test(content);
    let title = '';
    let plainText = '';

    if (isHtml) {
        const titleMatch = content.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i) || content.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
        title = titleMatch ? stripHtml(titleMatch[1]) : '';
        plainText = stripHtml(content);
    } else {
        const titleMatch = content.match(/^#\s+(.+)$/m);
        title = titleMatch ? titleMatch[1].trim() : '';
        plainText = stripMarkdown(content);
    }

    const text = plainText.trim();
    const digest = text.slice(0, 120).trim();
    return {
        title,
        digest,
        plainText: text,
        isHtml
    };
}

function buildCoverPrompt(article, options = {}) {
    const author = options.author || DEFAULT_AUTHOR;
    const title = article.title || '未命名文章';
    const summary = article.digest || article.plainText.slice(0, 100) || '生成适合公众号文章的封面图';
    return [
        '请生成一张微信公众号文章封面图。',
        `文章标题：${title}`,
        `作者署名：${author}`,
        `内容摘要：${summary}`,
        '风格要求：现代中文科技编辑风，简洁、可信、高级感，适合公众号首图。',
        '画面要求：16:9 横版，突出标题主题，可包含抽象科技元素与信息感背景。',
        '文字要求：仅保留少量高质量简体中文标题，不要英文乱码，不要水印，不要二维码。'
    ].join('\n');
}

function slugifyFileBase(articlePath) {
    const base = path.basename(articlePath, path.extname(articlePath));
    return base.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'article';
}

function getDefaultCoverPath(articlePath) {
    const dir = path.dirname(path.resolve(articlePath));
    const base = slugifyFileBase(articlePath);
    return path.join(dir, `${base}-cover.png`);
}

function runCommand(command, args, options = {}) {
    const result = spawnSync(command, args, {
        cwd: options.cwd || process.cwd(),
        encoding: 'utf8',
        env: options.env || process.env,
        shell: false
    });

    if (result.error) {
        throw result.error;
    }
    if (result.status !== 0) {
        const stderr = (result.stderr || '').trim();
        const stdout = (result.stdout || '').trim();
        throw new Error(stderr || stdout || `${command} 执行失败`);
    }
    return result;
}

function extractMediaIdFromUploadOutput(output) {
    const trimmed = String(output || '').trim();
    if (!trimmed) {
        throw new Error('上传封面后未获得任何输出');
    }

    const lines = trimmed.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
    for (let index = lines.length - 1; index >= 0; index -= 1) {
        const line = lines[index];
        if (line.startsWith('{') && line.endsWith('}')) {
            const parsed = JSON.parse(line);
            if (parsed && parsed.media_id) return parsed.media_id;
        }
    }

    const match = trimmed.match(/media_id:\s*([\w-]+)/);
    if (match) return match[1];
    throw new Error('无法从上传结果中解析 media_id');
}

function getImageGenScriptPath() {
    return path.resolve(__dirname, '..', '..', 'zhy-article-illustrator', 'scripts', 'image-gen.ts');
}

function ensureFileExists(filePath, label) {
    if (!fs.existsSync(filePath)) {
        throw new Error(`${label}不存在: ${filePath}`);
    }
}

function publishWithCover(args) {
    const articlePath = path.resolve(process.cwd(), args.article || args.file || '');
    const htmlPath = path.resolve(process.cwd(), args.html || args.file || '');
    const titleArg = args.title || '';
    const author = args.author || process.env.WECHAT_DEFAULT_AUTHOR || DEFAULT_AUTHOR;
    const needOpenComment = args['need-open-comment'] || DEFAULT_NEED_OPEN_COMMENT;
    const onlyFansCanComment = args['only-fans-can-comment'] || DEFAULT_ONLY_FANS_CAN_COMMENT;
    const coverPath = path.resolve(process.cwd(), args['cover-output'] || getDefaultCoverPath(articlePath));
    const aspectRatio = args['cover-ar'] || DEFAULT_ASPECT_RATIO;

    if (!args.article && !args.file) {
        throw new Error('必须提供 --article 或 --file');
    }

    ensureFileExists(articlePath, '文章文件');
    ensureFileExists(htmlPath, 'HTML 文件');

    const rawContent = fs.readFileSync(articlePath, 'utf8');
    const article = parseArticleInput(rawContent, articlePath);
    const title = titleArg || article.title;
    if (!title) {
        throw new Error('无法从文章中提取标题，请通过 --title 显式传入');
    }

    const prompt = buildCoverPrompt({ ...article, title }, { author });
    const imageGenScript = getImageGenScriptPath();
    ensureFileExists(imageGenScript, '封面生成脚本');

    console.log('1. 正在生成封面图...');
    const imageResult = runCommand('bun', [imageGenScript, '--prompt', prompt, '--output', coverPath, '--ar', aspectRatio], {
        cwd: path.dirname(imageGenScript)
    });
    if (imageResult.stdout.trim()) {
        console.log(imageResult.stdout.trim());
    }

    console.log('2. 正在上传封面图到微信素材库...');
    const uploadScript = path.resolve(__dirname, 'upload_image.js');
    const uploadResult = runCommand('node', [uploadScript, coverPath, '--json'], {
        cwd: process.cwd()
    });
    const thumbMediaId = extractMediaIdFromUploadOutput(uploadResult.stdout);
    console.log(`   封面 media_id: ${thumbMediaId}`);

    console.log('3. 正在上传公众号草稿...');
    const draftScript = path.resolve(__dirname, 'wechat_draft.js');
    const draftArgs = [
        draftScript,
        '--title', title,
        '--file', htmlPath,
        '--author', author,
        '--thumb', thumbMediaId,
        '--need-open-comment', needOpenComment,
        '--only-fans-can-comment', onlyFansCanComment
    ];

    if (article.digest) {
        draftArgs.push('--digest', article.digest);
    }
    if (args['source-url']) {
        draftArgs.push('--source-url', args['source-url']);
    }

    const draftResult = runCommand('node', draftArgs, {
        cwd: process.cwd()
    });
    if (draftResult.stdout.trim()) {
        console.log(draftResult.stdout.trim());
    }

    return {
        title,
        author,
        coverPath,
        thumbMediaId,
        digest: article.digest
    };
}

function main() {
    loadEnv();
    const args = parseArgs();

    if (!args.article && !args.file) {
        console.error('使用方法:');
        console.error('  node publish_with_cover.js --article "article.md" --html "post.html" [--title "标题"] [--author "作者"] [--source-url "原文链接"]');
        process.exit(1);
    }

    try {
        const result = publishWithCover(args);
        console.log('\n==============================');
        console.log('自动生成封面并推送草稿成功!');
        console.log(`标题: ${result.title}`);
        console.log(`author: ${result.author}`);
        console.log(`thumb_media_id: ${result.thumbMediaId}`);
        console.log(`cover: ${result.coverPath}`);
        console.log('==============================\n');
    } catch (error) {
        console.error('\n--- 发生错误 ---');
        console.error(error.message);
        process.exit(1);
    }
}

module.exports = {
    parseArgs,
    parseArticleInput,
    buildCoverPrompt,
    getDefaultCoverPath,
    extractMediaIdFromUploadOutput,
    publishWithCover,
    main
};

if (require.main === module) {
    main();
}


path: scripts/upload_image.js
content: const fs = require('fs');
const path = require('path');
const https = require('https');

/**
 * 微信公众号零依赖上传永久图片素材脚本
 * 用于获取封面图的 media_id
 */

function loadEnv() {
    try {
        const skillDir = path.resolve(__dirname, '..');  // .claude/skills/wechat-draft/
        const candidates = [
            path.resolve(skillDir, '.env'),               // Skill 目录（最优先）
            path.resolve(process.cwd(), '.env'),          // 当前工作目录
            path.resolve(skillDir, '..', '..', '..', '.env'), // 项目根目录
        ];
        for (const envPath of candidates) {
            if (fs.existsSync(envPath)) {
                const content = fs.readFileSync(envPath, 'utf8');
                content.split('\n').forEach(line => {
                    const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
                    if (match) {
                        const key = match[1];
                        let value = (match[2] || '').trim();
                        value = value.replace(/^['"]|['"]$/g, '');
                        if (!process.env[key]) {
                            process.env[key] = value;
                        }
                    }
                });
                break;
            }
        }
    } catch (e) {
        // 忽略加载错误
    }
}

function requestJson(url, options = {}) {
    return new Promise((resolve, reject) => {
        const req = https.request(url, options, (res) => {
            let data = '';
            res.on('data', chunk => { data += chunk; });
            res.on('end', () => {
                try {
                    resolve(JSON.parse(data));
                } catch (e) {
                    reject(new Error(`解析 JSON 失败: ${e.message}, 返回内容: ${data}`));
                }
            });
        });
        req.on('error', reject);
        if (options.bodyBuffers) {
            options.bodyBuffers.forEach(buf => req.write(buf));
        }
        req.end();
    });
}

function parseCli() {
    return {
        fileToUpload: process.argv[2],
        autoWriteEnv: process.argv.includes('--write-env'),
        json: process.argv.includes('--json')
    };
}

async function getAccessToken(appId, appSecret) {
    const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&[REDACTED_SECRET]`;
    const data = await requestJson(url, { method: 'GET' });
    if (data.errcode) {
        throw new Error(`获取 Access Token 失败: [${data.errcode}] ${data.errmsg}`);
    }
    return data.access_token;
}

async function uploadMaterial(token, filePath) {
    const url = `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=image`;

    if (!fs.existsSync(filePath)) {
        throw new Error(`找不到图片文件: ${filePath}`);
    }

    const fileName = path.basename(filePath);
    const fileData = fs.readFileSync(filePath);
    const boundary = '----WeChatBoundary' + Date.now().toString(16);

    const header = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
    const footer = `\r\n--${boundary}--\r\n`;

    const headerBuffer = Buffer.from(header, 'utf8');
    const footerBuffer = Buffer.from(footer, 'utf8');
    const totalLength = headerBuffer.length + fileData.length + footerBuffer.length;

    const options = {
        method: 'POST',
        headers: {
            'Content-Type': `multipart/form-data; boundary=${boundary}`,
            'Content-Length': totalLength
        },
        bodyBuffers: [headerBuffer, fileData, footerBuffer]
    };

    const data = await requestJson(url, options);
    if (data.errcode) {
        throw new Error(`上传图片失败: [${data.errcode}] ${data.errmsg}`);
    }
    return data;
}

async function main() {
    loadEnv();

    const { fileToUpload, autoWriteEnv, json } = parseCli();

    const appId = process.env.WECHAT_APP_ID;
    const appSecret = process.env.WECHAT_APP_SECRET;

    if (!appId || !appSecret) {
        console.error('错误: 未找到微信 API 凭证。请检查 .env 文件。');
        process.exit(1);
    }

    if (!fileToUpload) {
        console.error('使用方法:');
        console.error('  node upload_image.js <本地图片路径> [--write-env] [--json]');
        console.error('');
        console.error('参数:');
        console.error('  --write-env  上传成功后自动将 media_id 写入 .env 的 WECHAT_DEFAULT_THUMB_MEDIA_ID');
        console.error('  --json       输出 JSON，便于其他脚本解析');
        process.exit(1);
    }

    try {
        console.log('1. 获取 access_token...');
        const token = await getAccessToken(appId, appSecret);
        console.log('   获取成功!');

        const resolvedPath = path.resolve(process.cwd(), fileToUpload);
        console.log(`2. 正在上传图片: [${fileToUpload}] 到永久素材库...`);
        const result = await uploadMaterial(token, resolvedPath);

        console.log('\n==============================');
        console.log('图片上传成功!');
        console.log(`media_id: ${result.media_id}`);
        console.log('==============================\n');

        if (json) {
            console.log(JSON.stringify({
                success: true,
                media_id: result.media_id,
                url: result.url || null,
                name: result.name || path.basename(resolvedPath)
            }));
        }

        if (autoWriteEnv) {
            // 寻找 .env 文件并回写（优先写入 Skill 目录）
            const skillDir = path.resolve(__dirname, '..');
            const candidates = [
                path.resolve(skillDir, '.env'),
                path.resolve(process.cwd(), '.env'),
                path.resolve(skillDir, '..', '..', '..', '.env'),
            ];
            for (const envPath of candidates) {
                if (fs.existsSync(envPath)) {
                    let content = fs.readFileSync(envPath, 'utf8');
                    if (content.includes('WECHAT_DEFAULT_THUMB_MEDIA_ID=')) {
                        content = content.replace(/WECHAT_DEFAULT_THUMB_MEDIA_ID=.*/, `WECHAT_DEFAULT_THUMB_MEDIA_ID=${result.media_id}`);
                    } else {
                        content += `\nWECHAT_DEFAULT_THUMB_MEDIA_ID=${result.media_id}\n`;
                    }
                    fs.writeFileSync(envPath, content, 'utf8');
                    console.log(`已自动将 media_id 写入 ${envPath}`);
                    break;
                }
            }
        }
    } catch (err) {
        console.error('\n--- 发生错误 ---');
        console.error(err.message);
        if (process.argv.includes('--json')) {
            console.log(JSON.stringify({ success: false, error: err.message }));
        }
        process.exit(1);
    }
}

module.exports = {
    loadEnv,
    requestJson,
    getAccessToken,
    uploadMaterial,
    parseCli,
    main
};

if (require.main === module) {
    main();
}


path: scripts/wechat_draft.js
content: const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');

const DEFAULT_AUTHOR = 'AI源来如此';
const DEFAULT_COMMENT_FLAG = 1;

/**
 * 微信公众号零依赖上传草稿脚本
 * 兼容低版本 Node.js (使用原生 http/https 模块)
 */

function loadEnv() {
    try {
        const skillDir = path.resolve(__dirname, '..');
        const candidates = [
            path.resolve(skillDir, '.env'),
            path.resolve(process.cwd(), '.env'),
            path.resolve(skillDir, '..', '..', '..', '.env'),
        ];
        for (const envPath of candidates) {
            if (fs.existsSync(envPath)) {
                const content = fs.readFileSync(envPath, 'utf8');
                content.split('\n').forEach(line => {
                    const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
                    if (match) {
                        const key = match[1];
                        let value = (match[2] || '').trim();
                        value = value.replace(/^['"]|['"]$/g, '');
                        if (!process.env[key]) {
                            process.env[key] = value;
                        }
                    }
                });
                break;
            }
        }
    } catch (e) {
        // 忽略加载错误
    }
}

function getTransport(targetUrl) {
    return targetUrl.startsWith('https:') ? https : http;
}

function requestRaw(url, options = {}) {
    return new Promise((resolve, reject) => {
        const req = getTransport(url).request(url, options, (res) => {
            const chunks = [];
            res.on('data', chunk => { chunks.push(chunk); });
            res.on('end', () => {
                resolve({
                    statusCode: res.statusCode || 0,
                    headers: res.headers || {},
                    body: Buffer.concat(chunks)
                });
            });
        });
        req.on('error', reject);
        if (options.body) {
            req.write(options.body);
        }
        if (options.bodyBuffers) {
            options.bodyBuffers.forEach(buf => req.write(buf));
        }
        req.end();
    });
}

function requestJson(url, options = {}) {
    return new Promise((resolve, reject) => {
        requestRaw(url, options).then(({ body }) => {
            const data = body.toString('utf8');
            try {
                resolve(JSON.parse(data));
            } catch (e) {
                reject(new Error(`解析 JSON 失败: ${e.message}, 返回内容: ${data}`));
            }
        }).catch(reject);
    });
}

function parseArgs() {
    const args = process.argv.slice(2);
    const parsed = {};
    for (let i = 0; i < args.length; i++) {
        if (args[i].startsWith('--')) {
            const key = args[i].substring(2);
            parsed[key] = args[i + 1] || '';
            i++;
        }
    }
    return parsed;
}

function parseCommentFlag(value, fallback) {
    if (value === undefined || value === null || value === '') return fallback;
    if (value === '0' || value === 0) return 0;
    if (value === '1' || value === 1) return 1;
    throw new Error(`评论开关参数只能是 0 或 1，收到: ${value}`);
}

function buildArticlePayload(args, env = process.env) {
    const article = {
        title: args.title,
        content: args.content,
        author: args.author || env.WECHAT_DEFAULT_AUTHOR || DEFAULT_AUTHOR,
        need_open_comment: parseCommentFlag(args['need-open-comment'], DEFAULT_COMMENT_FLAG),
        only_fans_can_comment: parseCommentFlag(args['only-fans-can-comment'], DEFAULT_COMMENT_FLAG)
    };

    if (args.digest) article.digest = args.digest;
    if (args.thumb) article.thumb_media_id = args.thumb;
    if (args['source-url']) article.content_source_url = args['source-url'];

    if (!article.thumb_media_id && env.WECHAT_DEFAULT_THUMB_MEDIA_ID) {
        article.thumb_media_id = env.WECHAT_DEFAULT_THUMB_MEDIA_ID;
    }

    return article;
}

async function getAccessToken(appId, appSecret) {
    const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&[REDACTED_SECRET]`;
    const data = await requestJson(url, { method: 'GET' });
    if (data.errcode) {
        throw new Error(`获取 Access Token 失败: [${data.errcode}] ${data.errmsg}`);
    }
    return data.access_token;
}

async function uploadDraft(token, articleData) {
    const url = `https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`;
    const bodyData = Buffer.from(JSON.stringify(articleData), 'utf8');

    const options = {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json; charset=utf-8',
            'Content-Length': bodyData.length
        },
        body: bodyData
    };

    const data = await requestJson(url, options);
    if (data.errcode) {
        throw new Error(`上传草稿失败: [${data.errcode}] ${data.errmsg}`);
    }
    return data.media_id;
}

function inferMimeType(fileName, fallback) {
    const ext = path.extname(fileName || '').toLowerCase();
    const mimeMap = {
        '.png': 'image/png',
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg',
        '.gif': 'image/gif',
        '.webp': 'image/webp',
        '.bmp': 'image/bmp'
    };
    return mimeMap[ext] || fallback || 'application/octet-stream';
}

async function downloadBinary(url, redirects = 3) {
    const { statusCode, headers, body } = await requestRaw(url, { method: 'GET' });
    if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location && redirects > 0) {
        const nextUrl = new URL(headers.location, url).toString();
        return downloadBinary(nextUrl, redirects - 1);
    }
    if (statusCode < 200 || statusCode >= 300) {
        throw new Error(`下载图片失败: HTTP ${statusCode}`);
    }
    return {
        data: body,
        contentType: headers['content-type'] || 'application/octet-stream'
    };
}

function resolveCssVariables(html) {
    const sectionMatch = html.match(/<section id="MdWechat" style="([^"]*)">/);
    if (!sectionMatch) return html;

    const styleText = sectionMatch[1];
    const vars = {};
    const styleParts = [];

    styleText.split(';').forEach(part => {
        const trimmed = part.trim();
        if (!trimmed) return;
        const idx = trimmed.indexOf(':');
        if (idx < 0) return;
        const key = trimmed.slice(0, idx).trim();
        const value = trimmed.slice(idx + 1).trim();
        if (key.startsWith('--')) {
            vars[key] = value;
        } else {
            styleParts.push(`${key}: ${value}`);
        }
    });

    let output = html.replace(/var\((--[\w-]+)\)/g, (_, name) => vars[name] || _);
    output = output.replace(sectionMatch[0], `<section id="MdWechat" style="${styleParts.join('; ')}">`);
    return output;
}

function getStyleMap(styleText) {
    const map = {};
    styleText.split(';').forEach(part => {
        const trimmed = part.trim();
        if (!trimmed) return;
        const idx = trimmed.indexOf(':');
        if (idx < 0) return;
        const key = trimmed.slice(0, idx).trim().toLowerCase();
        const value = trimmed.slice(idx + 1).trim();
        map[key] = value;
    });
    return map;
}

function styleMapToString(styleMap, preferredOrder = []) {
    const emitted = new Set();
    const parts = [];
    preferredOrder.forEach((key) => {
        if (styleMap[key]) {
            parts.push(`${key}: ${styleMap[key]}`);
            emitted.add(key);
        }
    });
    Object.keys(styleMap).forEach((key) => {
        if (!emitted.has(key) && styleMap[key]) {
            parts.push(`${key}: ${styleMap[key]}`);
        }
    });
    return parts.join('; ');
}

function normalizeWechatSectionBackground(html) {
    const sectionMatch = html.match(/<section id="MdWechat" style="([^"]*)">/);
    if (!sectionMatch) return html;

    const styleText = sectionMatch[1];
    const styleMap = getStyleMap(styleText);
    if (!styleMap['background-image']) return html;

    const normalized = {
        ...styleMap,
        'background-attachment': styleMap['background-attachment'] || 'scroll',
        'background-clip': styleMap['background-clip'] || 'border-box',
        'background-origin': styleMap['background-origin'] || 'padding-box',
        'background-color': styleMap['background-color'] || 'rgba(0, 0, 0, 0)',
        'width': styleMap['width'] || 'auto'
    };

    if (styleMap['background-position']) {
        normalized['background-position-x'] = 'left';
        normalized['background-position-y'] = 'top';
        delete normalized['background-position'];
    }

    const preferredOrder = [
        'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
        'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
        'padding',
        'background-attachment', 'background-clip', 'background-color', 'background-image', 'background-origin',
        'background-position-x', 'background-position-y', 'background-repeat', 'background-size',
        'width', 'font-family', 'font-size', 'color', 'line-height', 'word-spacing', 'letter-spacing',
        'word-break', 'overflow-wrap', 'text-align'
    ];

    const styleString = styleMapToString(normalized, preferredOrder);

    return html.replace(sectionMatch[0], `<section id="MdWechat" style="${styleString}">`);
}

function convertListBlock(listInner, markerFactory) {
        const liRegex = /<li\b([^>]*)>([\s\S]*?)<\/li>/gi;
        const items = [];
        let match;
        let index = 0;
        while ((match = liRegex.exec(listInner)) !== null) {
            const inner = match[2].trim();
            if (!inner) continue;
            index += 1;
            const normalizedInner = inner
                .replace(/^<p\b[^>]*>([\s\S]*?)<\/p>$/i, '$1')
                .replace(/^<section\b[^>]*>([\s\S]*?)<\/section>$/i, '$1')
                .trim();

            items.push(`<p style="color: rgb(89, 89, 89); font-size: 14px; line-height: 1.8em; letter-spacing: 0em; text-align: left; font-weight: normal; margin-top: 5px; margin-bottom: 5px; margin-left: 0px; margin-right: 0px; padding-top: 0px; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; text-indent: 0em;">${markerFactory(index)}&nbsp;&nbsp;${normalizedInner}</p>`);
        }

        if (!items.length) return '';
        return items.join('');
}

function convertListsToWechatParagraphs(html) {
    let output = html.replace(/<ul\b[^>]*>([\s\S]*?)<\/ul>/gi, (full, listInner) => {
        return convertListBlock(listInner, () => '&#9675;');
    });

    output = output.replace(/<ol\b[^>]*>([\s\S]*?)<\/ol>/gi, (full, listInner) => {
        return convertListBlock(listInner, (index) => `${index}.`);
    });

    return output;
}

function stripLeadingH1(html) {
    return html.replace(/^\s*<section id="MdWechat"([^>]*)>\s*<h1\b[^>]*>.*?<\/h1>/is, '<section id="MdWechat"$1>');
}

async function uploadArticleImage(token, fileName, fileData, contentType) {
    const url = `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${token}`;
    const boundary = '----WeChatDraftBoundary' + Date.now().toString(16);
    const header = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`;
    const footer = `\r\n--${boundary}--\r\n`;
    const headerBuffer = Buffer.from(header, 'utf8');
    const footerBuffer = Buffer.from(footer, 'utf8');
    const totalLength = headerBuffer.length + fileData.length + footerBuffer.length;

    const data = await requestJson(url, {
        method: 'POST',
        headers: {
            'Content-Type': `multipart/form-data; boundary=${boundary}`,
            'Content-Length': totalLength
        },
        bodyBuffers: [headerBuffer, fileData, footerBuffer]
    });

    if (data.errcode) {
        throw new Error(`上传正文图片失败: [${data.errcode}] ${data.errmsg}`);
    }
    if (!data.url) {
        throw new Error('上传正文图片失败: 微信未返回图片 URL');
    }
    return data.url;
}

async function rewriteImagesForWechat(token, html, htmlFilePath) {
    const imgRegex = /<img\b([^>]*?)\bsrc="([^"]+)"([^>]*)>/g;
    const matches = [...html.matchAll(imgRegex)];
    if (!matches.length) return html;

    const srcMap = new Map();
    for (const match of matches) {
        const src = match[2];
        if (srcMap.has(src) || src.startsWith('data:')) continue;

        let fileName = 'image.png';
        let fileData;
        let contentType = 'application/octet-stream';

        if (/^https?:\/\//i.test(src)) {
            const downloaded = await downloadBinary(src);
            fileData = downloaded.data;
            const urlObj = new URL(src);
            fileName = path.basename(urlObj.pathname) || fileName;
            contentType = inferMimeType(fileName, downloaded.contentType);
        } else {
            const localPath = path.resolve(path.dirname(htmlFilePath), src);
            if (!fs.existsSync(localPath)) {
                throw new Error(`正文图片不存在: ${localPath}`);
            }
            fileData = fs.readFileSync(localPath);
            fileName = path.basename(localPath);
            contentType = inferMimeType(fileName);
        }

        const wechatUrl = await uploadArticleImage(token, fileName, fileData, contentType);
        srcMap.set(src, wechatUrl);
    }

    let output = html;
    for (const [src, uploaded] of srcMap.entries()) {
        output = output.split(`src="${src}"`).join(`src="${uploaded}"`);
    }
    return output;
}

async function main() {
    loadEnv();
    const args = parseArgs();

    const appId = process.env.WECHAT_APP_ID;
    const appSecret = process.env.WECHAT_APP_SECRET;

    if (!appId || !appSecret) {
        console.error('错误: 未找到微信 API 凭证。');
        console.error('请配置 WECHAT_APP_ID 和 WECHAT_APP_SECRET 环境变量，或者放在当前技能目录的 .env 文件中。');
        process.exit(1);
    }

    if (!args.title || !args.file) {
        console.error('使用方法:');
        console.error('  node wechat_draft.js --title "文章标题" --file "content.html" [--author "作者"] [--digest "摘要"] [--thumb "素材媒体ID"] [--source-url "原文链接"] [--need-open-comment "1"] [--only-fans-can-comment "1"]');
        process.exit(1);
    }

    let content = '';
    let htmlFilePath = '';
    try {
        htmlFilePath = path.resolve(process.cwd(), args.file);
        content = fs.readFileSync(htmlFilePath, 'utf8');
    } catch (e) {
        console.error(`读取文件出错 ${args.file}:`, e.message);
        process.exit(1);
    }

    try {
        console.log('1. 正在获取 access_token...');
        const token = await getAccessToken(appId, appSecret);
        console.log('   获取成功!');

        console.log('2. 正在处理正文样式与图片...');
        content = resolveCssVariables(content);
        content = normalizeWechatSectionBackground(content);
        content = stripLeadingH1(content);
        content = convertListsToWechatParagraphs(content);
        content = await rewriteImagesForWechat(token, content, htmlFilePath);

        const article = buildArticlePayload({
            ...args,
            content
        });

        if (!article.thumb_media_id) {
            console.warn('警告: 未指定封面图(--thumb)且未设置 WECHAT_DEFAULT_THUMB_MEDIA_ID。微信接口可能返回 40007 错误。');
        }

        const payload = { articles: [article] };

        console.log(`3. 正在上传草稿: "${args.title}"...`);
        const mediaId = await uploadDraft(token, payload);
        console.log('\n==============================');
        console.log('上传草稿成功!');
        console.log(`草稿 MEDIA_ID: ${mediaId}`);
        console.log('==============================\n');
    } catch (error) {
        console.error('\n--- 发生错误 ---');
        console.error(error.message);
        process.exit(1);
    }
}

module.exports = {
    loadEnv,
    parseArgs,
    parseCommentFlag,
    buildArticlePayload,
    resolveCssVariables,
    normalizeWechatSectionBackground,
    stripLeadingH1,
    convertListsToWechatParagraphs,
    rewriteImagesForWechat,
    uploadDraft,
    getAccessToken,
    main
};

if (require.main === module) {
    main();
}


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