diff --git a/skills/baoyu-post-to-wechat/SKILL.md b/skills/baoyu-post-to-wechat/SKILL.md index 83eb074..3abaf24 100644 --- a/skills/baoyu-post-to-wechat/SKILL.md +++ b/skills/baoyu-post-to-wechat/SKILL.md @@ -1,10 +1,14 @@ --- name: baoyu-post-to-wechat -description: Posts content to WeChat Official Account (微信公众号) via Chrome CDP automation. Supports article posting (文章) with full markdown formatting and image-text posting (图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "图文/文章". +description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (图文) with multiple images. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "图文/文章". --- # Post to WeChat Official Account +## Language + +**Match user's language**: Respond in the same language the user uses. If user writes in Chinese, respond in Chinese. If user writes in English, respond in English. + ## Script Directory **Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/.ts`. @@ -12,8 +16,8 @@ description: Posts content to WeChat Official Account (微信公众号) via Chro | Script | Purpose | |--------|---------| | `scripts/wechat-browser.ts` | Image-text posts (图文) | -| `scripts/wechat-article.ts` | Article posting (文章) | -| `scripts/md-to-wechat.ts` | Markdown → WeChat HTML | +| `scripts/wechat-article.ts` | Article posting via browser (文章) | +| `scripts/wechat-api.ts` | Article posting via API (文章) | ## Preferences (EXTEND.md) @@ -43,38 +47,229 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user" │ Not found │ Use defaults │ └───────────┴───────────────────────────────────────────────────────────────────────────┘ -**EXTEND.md Supports**: Default theme | Auto-submit preference | Chrome profile path +**EXTEND.md Supports**: Default theme | Default publishing method (api/browser) | Default author | Chrome profile path -## Usage +## Image-Text Posting (图文) -### Image-Text (图文) +For short posts with multiple images (up to 9): ```bash npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --markdown article.md --images ./images/ npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --title "标题" --content "内容" --image img.png --submit ``` -### Article (文章) +See [references/image-text-posting.md](references/image-text-posting.md) for details. -Before posting, ask user to choose a theme using AskUserQuestion: +## Article Posting Workflow (文章) + +Copy this checklist and check off items as you complete them: + +``` +Publishing Progress: +- [ ] Step 0: Load preferences (EXTEND.md) +- [ ] Step 1: Determine input type +- [ ] Step 2: Check markdown-to-html skill +- [ ] Step 3: Convert to HTML +- [ ] Step 4: Validate metadata (title, summary) +- [ ] Step 5: Select method and configure credentials +- [ ] Step 6: Publish to WeChat +- [ ] Step 7: Report completion +``` + +### Step 0: Load Preferences + +Check and load EXTEND.md settings (see Preferences section above). + +### Step 1: Determine Input Type + +| Input Type | Detection | Action | +|------------|-----------|--------| +| HTML file | Path ends with `.html`, file exists | Skip to Step 4 | +| Markdown file | Path ends with `.md`, file exists | Continue to Step 2 | +| Plain text | Not a file path, or file doesn't exist | Save to markdown, then Step 2 | + +**Plain Text Handling**: + +1. Generate slug from content (first 2-4 meaningful words, kebab-case) +2. Create directory and save file: + +```bash +mkdir -p "$(pwd)/post-to-wechat/$(date +%Y-%m-%d)" +# Save content to: post-to-wechat/yyyy-MM-dd/[slug].md +``` + +3. Continue processing as markdown file + +**Slug Examples**: +- "Understanding AI Models" → `understanding-ai-models` +- "人工智能的未来" → `ai-future` (translate to English for slug) + +### Step 2: Check Markdown-to-HTML Skill + +**Skip if**: Input is `.html` file + +**Skill Discovery**: + +```bash +# Check if baoyu-markdown-to-html exists +test -f skills/baoyu-markdown-to-html/SKILL.md && echo "found" +``` + +| Result | Action | +|--------|--------| +| Found | Read its SKILL.md, continue to Step 3 | +| Multiple skills | AskUserQuestion to choose | +| Not found | Show installation suggestion | + +**When Not Found**: + +``` +No markdown-to-html skill found. + +Suggested installation: +https://github.com/JimLiu/baoyu-skills/blob/main/skills/baoyu-markdown-to-html/SKILL.md + +Options: +A) Cancel - install the skill first +B) Continue - provide HTML file manually +``` + +### Step 3: Convert Markdown to HTML + +**Skip if**: Input is `.html` file + +1. **Ask theme preference** (unless specified in EXTEND.md or CLI): | Theme | Description | |-------|-------------| | `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 | -| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) | -| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) | +| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 | +| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 | -Default: `default`. If user has already specified a theme, skip the question. +2. **Execute conversion** (using the discovered skill): -**Workflow**: - -1. Generate HTML preview and print the full `htmlPath` from JSON output so user can click to preview: ```bash -npx -y bun ${SKILL_DIR}/scripts/md-to-wechat.ts article.md --theme +npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts --theme ``` -2. Post to WeChat: + +3. **Parse JSON output** to get: `htmlPath`, `title`, `author`, `summary`, `contentImages` + +### Step 4: Validate Metadata + +Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input). + +| Field | If Missing | +|-------|------------| +| Title | Prompt: "Enter title, or press Enter to auto-generate from content" | +| Summary | Prompt: "Enter summary, or press Enter to auto-generate (recommended for SEO)" | + +**Auto-Generation Logic**: +- **Title**: First H1/H2 heading, or first sentence +- **Summary**: First paragraph, truncated to 120 characters + +### Step 5: Select Publishing Method and Configure + +**Ask publishing method** (unless specified in EXTEND.md or CLI): + +| Method | Speed | Requirements | +|--------|-------|--------------| +| `api` (Recommended) | Fast | API credentials | +| `browser` | Slow | Chrome, login session | + +**If API Selected - Check Credentials**: + ```bash -npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme +# Check project-level +test -f .baoyu-skills/.env && grep -q "WECHAT_APP_ID" .baoyu-skills/.env && echo "project" + +# Check user-level +test -f "$HOME/.baoyu-skills/.env" && grep -q "WECHAT_APP_ID" "$HOME/.baoyu-skills/.env" && echo "user" +``` + +**If Credentials Missing - Guide Setup**: + +``` +WeChat API credentials not found. + +To obtain credentials: +1. Visit https://mp.weixin.qq.com +2. Go to: 开发 → 基本配置 +3. Copy AppID and AppSecret + +Where to save? +A) Project-level: .baoyu-skills/.env (this project only) +B) User-level: ~/.baoyu-skills/.env (all projects) +``` + +After location choice, prompt for values and write to `.env`: + +``` +WECHAT_APP_ID= +WECHAT_APP_SECRET= +``` + +### Step 6: Publish to WeChat + +**API method**: + +```bash +npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts [--title ] [--summary <summary>] +``` + +**Browser method**: + +```bash +npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --html <html_file> +``` + +### Step 7: Completion Report + +**For API method**, include draft management link: + +``` +WeChat Publishing Complete! + +Input: [type] - [path] +Method: API +Theme: [theme name] + +Article: +• Title: [title] +• Summary: [summary] +• Images: [N] inline images + +Result: +✓ Draft saved to WeChat Official Account +• media_id: [media_id] + +Next Steps: +→ Manage drafts: https://mp.weixin.qq.com (登录后进入「内容管理」→「草稿箱」) + +Files created: +[• post-to-wechat/yyyy-MM-dd/slug.md (if plain text)] +[• slug.html (converted)] +``` + +**For Browser method**: + +``` +WeChat Publishing Complete! + +Input: [type] - [path] +Method: Browser +Theme: [theme name] + +Article: +• Title: [title] +• Summary: [summary] +• Images: [N] inline images + +Result: +✓ Draft saved to WeChat Official Account + +Files created: +[• post-to-wechat/yyyy-MM-dd/slug.md (if plain text)] +[• slug.html (converted)] ``` ## Detailed References @@ -86,24 +281,47 @@ npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme ## Feature Comparison -| Feature | Image-Text | Article | -|---------|------------|---------| -| Multiple images | ✓ (up to 9) | ✓ (inline) | -| Markdown support | Title/content extraction | Full formatting | -| Auto compression | ✓ (title: 20, content: 1000 chars) | ✗ | -| Themes | ✗ | ✓ (default, grace, simple) | +| Feature | Image-Text | Article (API) | Article (Browser) | +|---------|------------|---------------|-------------------| +| Plain text input | ✗ | ✓ | ✓ | +| HTML input | ✗ | ✓ | ✓ | +| Markdown input | Title/content | ✓ (via skill) | ✓ (via skill) | +| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | +| Themes | ✗ | ✓ | ✓ | +| Auto-generate metadata | ✗ | ✓ | ✓ | +| Requires Chrome | ✓ | ✗ | ✓ | +| Requires API credentials | ✗ | ✓ | ✗ | +| Speed | Medium | Fast | Slow | ## Prerequisites +**For API method**: +- WeChat Official Account API credentials +- Guided setup in Step 5, or manually set in `.baoyu-skills/.env` + +**For Browser method**: - Google Chrome - First run: log in to WeChat Official Account (session preserved) +**For Markdown conversion**: +- A markdown-to-html skill (e.g., `baoyu-markdown-to-html`) +- If not installed, the workflow will suggest installation + +**Config File Locations** (priority order): +1. Environment variables +2. `<cwd>/.baoyu-skills/.env` +3. `~/.baoyu-skills/.env` + ## Troubleshooting | Issue | Solution | |-------|----------| -| Not logged in | First run opens browser - scan QR to log in | +| No markdown-to-html skill | Install `baoyu-markdown-to-html` from suggested URL | +| Missing API credentials | Follow guided setup in Step 5 | +| Access token error | Check if API credentials are valid and not expired | +| Not logged in (browser) | First run opens browser - scan QR to log in | | Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` env var | +| Title/summary missing | Use auto-generation or provide manually | | Paste fails | Check system clipboard permissions | ## Extension Support diff --git a/skills/baoyu-post-to-wechat/scripts/md/render.ts b/skills/baoyu-post-to-wechat/scripts/md/render.ts index 08d173e..69dc1aa 100644 --- a/skills/baoyu-post-to-wechat/scripts/md/render.ts +++ b/skills/baoyu-post-to-wechat/scripts/md/render.ts @@ -24,11 +24,17 @@ import { highlightAndFormatCode, } from "./utils/languages.js"; -type ThemeName = "default" | "grace" | "simple"; +type ThemeName = string; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); -const THEME_NAMES: ThemeName[] = ["default", "grace", "simple"]; +const EXTERNAL_THEME_CONFIG_PATH = + process.env.MD_THEME_CONFIG_PATH + || "/Users/jimliu/GitHub/md/packages/shared/src/configs/theme.ts"; +const EXTERNAL_THEME_DIR = + process.env.MD_THEME_DIR + || path.resolve(path.dirname(EXTERNAL_THEME_CONFIG_PATH), "theme-css"); +const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"]; const DEFAULT_STYLE = { primaryColor: "#0F4C81", @@ -45,6 +51,57 @@ Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => { export { hljs }; +function stripOutputScope(cssContent: string): string { + let css = cssContent; + css = css.replace(/#output\s*\{/g, "body {"); + css = css.replace(/#output\s+/g, ""); + css = css.replace(/^#output\s*/gm, ""); + return css; +} + +function discoverThemesFromDir(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; + } + return fs + .readdirSync(dir) + .filter((name) => name.endsWith(".css")) + .map((name) => name.replace(/\.css$/i, "")) + .filter((name) => name.toLowerCase() !== "base"); +} + +function readThemeNamesFromConfig(configPath: string): string[] { + if (!fs.existsSync(configPath)) { + return []; + } + const content = fs.readFileSync(configPath, "utf-8"); + const match = content.match(/themeOptionsMap\s*=\s*\{([\s\S]*?)\n\}/); + if (!match) { + return []; + } + return Array.from(match[1].matchAll(/^\s*([a-zA-Z0-9_-]+)\s*:/gm)).map( + (item) => item[1]! + ); +} + +function resolveThemeNames(): ThemeName[] { + const localThemes = discoverThemesFromDir(THEME_DIR); + const externalThemes = discoverThemesFromDir(EXTERNAL_THEME_DIR); + const configThemes = readThemeNamesFromConfig(EXTERNAL_THEME_CONFIG_PATH); + const combined = new Set<ThemeName>([ + ...localThemes, + ...externalThemes, + ...configThemes, + ]); + const resolved = Array.from(combined).filter((name) => + fs.existsSync(path.join(THEME_DIR, `${name}.css`)) + || fs.existsSync(path.join(EXTERNAL_THEME_DIR, `${name}.css`)) + ); + return resolved.length ? resolved : FALLBACK_THEMES; +} + +const THEME_NAMES: ThemeName[] = resolveThemeNames(); + marked.setOptions({ breaks: true, }); @@ -542,15 +599,31 @@ function loadThemeCss(theme: ThemeName): { baseCss: string; themeCss: string; } { - const basePath = path.join(THEME_DIR, "base.css"); - const themePath = path.join(THEME_DIR, `${theme}.css`); + const basePathCandidates = [ + path.join(THEME_DIR, "base.css"), + path.join(EXTERNAL_THEME_DIR, "base.css"), + ]; + const themePathCandidates = [ + path.join(THEME_DIR, `${theme}.css`), + path.join(EXTERNAL_THEME_DIR, `${theme}.css`), + ]; + const basePath = basePathCandidates.find((candidate) => + fs.existsSync(candidate) + ); + const themePath = themePathCandidates.find((candidate) => + fs.existsSync(candidate) + ); - if (!fs.existsSync(basePath)) { - throw new Error(`Missing base CSS: ${basePath}`); + if (!basePath) { + throw new Error( + `Missing base CSS. Checked: ${basePathCandidates.join(", ")}` + ); } - if (!fs.existsSync(themePath)) { - throw new Error(`Missing theme CSS: ${themePath}`); + if (!themePath) { + throw new Error( + `Missing theme CSS for "${theme}". Checked: ${themePathCandidates.join(", ")}` + ); } return { @@ -584,6 +657,10 @@ body { return [variables, baseCss, themeCss].join("\n\n"); } +function normalizeThemeCss(css: string): string { + return stripOutputScope(css); +} + function buildHtmlDocument(title: string, css: string, html: string): string { return [ "<!doctype html>", @@ -603,7 +680,65 @@ function buildHtmlDocument(title: string, css: string, html: string): string { ].join("\n"); } -function main(): void { +async function inlineCss(html: string): Promise<string> { + try { + const { default: juice } = await import("juice"); + return juice(html, { + inlinePseudoElements: true, + preserveImportant: true, + resolveCSSVariables: false, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Missing dependency "juice" for CSS inlining. Install it first (e.g. "bun add juice" or "npm add juice"). Original error: ${detail}` + ); + } +} + +function normalizeCssText(cssText: string): string { + return cssText + .replace(/var\(--md-primary-color\)/g, DEFAULT_STYLE.primaryColor) + .replace(/var\(--md-font-family\)/g, DEFAULT_STYLE.fontFamily) + .replace(/var\(--md-font-size\)/g, DEFAULT_STYLE.fontSize) + .replace(/var\(--blockquote-background\)/g, DEFAULT_STYLE.blockquoteBackground) + .replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f") + .replace(/--md-primary-color:\s*[^;"']+;?/g, "") + .replace(/--md-font-family:\s*[^;"']+;?/g, "") + .replace(/--md-font-size:\s*[^;"']+;?/g, "") + .replace(/--blockquote-background:\s*[^;"']+;?/g, "") + .replace(/--foreground:\s*[^;"']+;?/g, ""); +} + +function normalizeInlineCss(html: string): string { + let output = html; + output = output.replace( + /<style([^>]*)>([\s\S]*?)<\/style>/gi, + (_match, attrs: string, cssText: string) => + `<style${attrs}>${normalizeCssText(cssText)}</style>` + ); + output = output.replace( + /style="([^"]*)"/gi, + (_match, cssText: string) => `style="${normalizeCssText(cssText)}"` + ); + output = output.replace( + /style='([^']*)'/gi, + (_match, cssText: string) => `style='${normalizeCssText(cssText)}'` + ); + return output; +} + +function modifyHtmlStructure(htmlString: string): string { + let output = htmlString; + const pattern = + /<li([^>]*)>([\s\S]*?)(<ul[\s\S]*?<\/ul>|<ol[\s\S]*?<\/ol>)<\/li>/i; + while (pattern.test(output)) { + output = output.replace(pattern, "<li$1>$2</li>$3"); + } + return output; +} + +async function main(): Promise<void> { const options = parseArgs(process.argv.slice(2)); if (!options) { printUsage(); @@ -624,7 +759,7 @@ function main(): void { ); const { baseCss, themeCss } = loadThemeCss(options.theme); - const css = buildCss(baseCss, themeCss); + const css = normalizeThemeCss(buildCss(baseCss, themeCss)); const markdown = fs.readFileSync(inputPath, "utf-8"); const renderer = initRenderer({}); @@ -636,6 +771,8 @@ function main(): void { const title = path.basename(outputPath, ".html"); const html = buildHtmlDocument(title, css, content); + const inlinedHtml = normalizeInlineCss(await inlineCss(html)); + const finalHtml = modifyHtmlStructure(inlinedHtml); let backupPath = ""; if (fs.existsSync(outputPath)) { @@ -643,7 +780,7 @@ function main(): void { fs.renameSync(outputPath, backupPath); } - fs.writeFileSync(outputPath, html, "utf-8"); + fs.writeFileSync(outputPath, finalHtml, "utf-8"); if (backupPath) { console.log(`Backup created: ${backupPath}`); @@ -651,4 +788,7 @@ function main(): void { console.log(`HTML written: ${outputPath}`); } -main(); +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts new file mode 100644 index 0000000..82c8e3d --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts @@ -0,0 +1,499 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +interface WechatConfig { + appId: string; + appSecret: string; +} + +interface AccessTokenResponse { + access_token?: string; + errcode?: number; + errmsg?: string; +} + +interface UploadResponse { + media_id: string; + url: string; + errcode?: number; + errmsg?: string; +} + +interface PublishResponse { + media_id?: string; + errcode?: number; + errmsg?: string; +} + +const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; +const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"; +const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"; + +function loadEnvFile(envPath: string): Record<string, string> { + const env: Record<string, string> = {}; + if (!fs.existsSync(envPath)) return env; + + const content = fs.readFileSync(envPath, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx > 0) { + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + } + return env; +} + +function loadConfig(): WechatConfig { + const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env"); + const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env"); + + const cwdEnv = loadEnvFile(cwdEnvPath); + const homeEnv = loadEnvFile(homeEnvPath); + + const appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID; + const appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET; + + if (!appId || !appSecret) { + throw new Error( + "Missing WECHAT_APP_ID or WECHAT_APP_SECRET.\n" + + "Set via environment variables or in .baoyu-skills/.env file." + ); + } + + return { appId, appSecret }; +} + +async function fetchAccessToken(appId: string, appSecret: string): Promise<string> { + const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch access token: ${res.status}`); + } + const data = await res.json() as AccessTokenResponse; + if (data.errcode) { + throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`); + } + if (!data.access_token) { + throw new Error("No access_token in response"); + } + return data.access_token; +} + +async function uploadImage( + imagePath: string, + accessToken: string, + baseDir?: string +): Promise<UploadResponse> { + let fileBuffer: Buffer; + let filename: string; + let contentType: string; + + if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { + const response = await fetch(imagePath); + if (!response.ok) { + throw new Error(`Failed to download image: ${imagePath}`); + } + const buffer = await response.arrayBuffer(); + if (buffer.byteLength === 0) { + throw new Error(`Remote image is empty: ${imagePath}`); + } + fileBuffer = Buffer.from(buffer); + const urlPath = imagePath.split("?")[0]; + filename = path.basename(urlPath) || "image.jpg"; + contentType = response.headers.get("content-type") || "image/jpeg"; + } else { + const resolvedPath = path.isAbsolute(imagePath) + ? imagePath + : path.resolve(baseDir || process.cwd(), imagePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Image not found: ${resolvedPath}`); + } + const stats = fs.statSync(resolvedPath); + if (stats.size === 0) { + throw new Error(`Local image is empty: ${resolvedPath}`); + } + fileBuffer = fs.readFileSync(resolvedPath); + filename = path.basename(resolvedPath); + const ext = path.extname(filename).toLowerCase(); + const mimeTypes: Record<string, string> = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }; + contentType = mimeTypes[ext] || "image/jpeg"; + } + + const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`; + const header = [ + `--${boundary}`, + `Content-Disposition: form-data; name="media"; filename="${filename}"`, + `Content-Type: ${contentType}`, + "", + "", + ].join("\r\n"); + const footer = `\r\n--${boundary}--\r\n`; + + const headerBuffer = Buffer.from(header, "utf-8"); + const footerBuffer = Buffer.from(footer, "utf-8"); + const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]); + + const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }, + body, + }); + + const data = await res.json() as UploadResponse; + if (data.errcode && data.errcode !== 0) { + throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`); + } + + if (data.url?.startsWith("http://")) { + data.url = data.url.replace(/^http:\/\//i, "https://"); + } + + return data; +} + +async function uploadImagesInHtml( + html: string, + accessToken: string, + baseDir: string +): Promise<{ html: string; firstMediaId: string }> { + const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi; + const matches = [...html.matchAll(imgRegex)]; + + if (matches.length === 0) { + return { html, firstMediaId: "" }; + } + + let firstMediaId = ""; + let updatedHtml = html; + + for (const match of matches) { + const [, src] = match; + if (!src) continue; + + if (src.startsWith("https://mmbiz.qpic.cn")) { + if (!firstMediaId) { + firstMediaId = src; + } + continue; + } + + console.error(`[wechat-api] Uploading image: ${src}`); + try { + const resp = await uploadImage(src, accessToken, baseDir); + updatedHtml = updatedHtml.replace(src, resp.url); + if (!firstMediaId) { + firstMediaId = resp.media_id; + } + } catch (err) { + console.error(`[wechat-api] Failed to upload ${src}:`, err); + } + } + + return { html: updatedHtml, firstMediaId }; +} + +async function publishToDraft( + title: string, + html: string, + thumbMediaId: string, + accessToken: string +): Promise<PublishResponse> { + const url = `${DRAFT_URL}?access_token=${accessToken}`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + articles: [{ + title, + content: html, + thumb_media_id: thumbMediaId, + }], + }), + }); + + const data = await res.json() as PublishResponse; + if (data.errcode && data.errcode !== 0) { + throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`); + } + + return data; +} + +function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) return { frontmatter: {}, body: content }; + + const frontmatter: Record<string, string> = {}; + const lines = match[1]!.split("\n"); + for (const line of lines) { + const colonIdx = line.indexOf(":"); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + frontmatter[key] = value; + } + } + + return { frontmatter, body: match[2]! }; +} + +function renderMarkdownToHtml(markdownPath: string, theme: string = "default"): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const renderScript = path.join(__dirname, "md", "render.ts"); + const baseDir = path.dirname(markdownPath); + + console.error(`[wechat-api] Rendering markdown with theme: ${theme}`); + const result = spawnSync("npx", ["-y", "bun", renderScript, markdownPath, "--theme", theme], { + stdio: ["inherit", "pipe", "pipe"], + cwd: baseDir, + }); + + if (result.status !== 0) { + const stderr = result.stderr?.toString() || ""; + throw new Error(`Render failed: ${stderr}`); + } + + const htmlPath = markdownPath.replace(/\.md$/i, ".html"); + if (!fs.existsSync(htmlPath)) { + throw new Error(`HTML file not generated: ${htmlPath}`); + } + + return htmlPath; +} + +function extractHtmlContent(htmlPath: string): string { + const html = fs.readFileSync(htmlPath, "utf-8"); + const match = html.match(/<div id="output">([\s\S]*?)<\/div>\s*<\/body>/); + if (match) { + return match[1]!.trim(); + } + const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i); + return bodyMatch ? bodyMatch[1]!.trim() : html; +} + +function printUsage(): never { + console.log(`Publish article to WeChat Official Account draft using API + +Usage: + npx -y bun wechat-api.ts <file> [options] + +Arguments: + file Markdown (.md) or HTML (.html) file + +Options: + --title <title> Override title + --theme <name> Theme name for markdown (default, grace, simple). Default: default + --cover <path> Cover image path (local or URL) + --dry-run Parse and render only, don't publish + --help Show this help + +Environment Variables: + WECHAT_APP_ID WeChat App ID + WECHAT_APP_SECRET WeChat App Secret + +Config File Locations (in priority order): + 1. Environment variables + 2. <cwd>/.baoyu-skills/.env + 3. ~/.baoyu-skills/.env + +Example: + npx -y bun wechat-api.ts article.md + npx -y bun wechat-api.ts article.md --theme grace --cover cover.png + npx -y bun wechat-api.ts article.html --title "My Article" + npx -y bun wechat-api.ts article.md --dry-run +`); + process.exit(0); +} + +interface CliArgs { + filePath: string; + isHtml: boolean; + title?: string; + theme: string; + cover?: string; + dryRun: boolean; +} + +function parseArgs(argv: string[]): CliArgs { + if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) { + printUsage(); + } + + const args: CliArgs = { + filePath: "", + isHtml: false, + theme: "default", + dryRun: false, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--title" && argv[i + 1]) { + args.title = argv[++i]; + } else if (arg === "--theme" && argv[i + 1]) { + args.theme = argv[++i]!; + } else if (arg === "--cover" && argv[i + 1]) { + args.cover = argv[++i]; + } else if (arg === "--dry-run") { + args.dryRun = true; + } else if (!arg.startsWith("-")) { + args.filePath = arg; + } + } + + if (!args.filePath) { + console.error("Error: File path required"); + process.exit(1); + } + + args.isHtml = args.filePath.toLowerCase().endsWith(".html"); + + return args; +} + +function extractHtmlTitle(html: string): string { + const titleMatch = html.match(/<title>([^<]+)<\/title>/i); + if (titleMatch) return titleMatch[1]!; + const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i); + if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim(); + return ""; +} + +async function main(): Promise<void> { + const args = parseArgs(process.argv.slice(2)); + + const filePath = path.resolve(args.filePath); + if (!fs.existsSync(filePath)) { + console.error(`Error: File not found: ${filePath}`); + process.exit(1); + } + + const baseDir = path.dirname(filePath); + let title = args.title || ""; + let htmlPath: string; + let htmlContent: string; + let frontmatter: Record<string, string> = {}; + + if (args.isHtml) { + htmlPath = filePath; + htmlContent = extractHtmlContent(htmlPath); + if (!title) { + title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8")); + } + console.error(`[wechat-api] Using HTML file: ${htmlPath}`); + } else { + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = parseFrontmatter(content); + frontmatter = parsed.frontmatter; + const body = parsed.body; + + title = title || frontmatter.title || ""; + if (!title) { + const h1Match = body.match(/^#\s+(.+)$/m); + if (h1Match) title = h1Match[1]!; + } + + console.error(`[wechat-api] Theme: ${args.theme}`); + htmlPath = renderMarkdownToHtml(filePath, args.theme); + console.error(`[wechat-api] HTML generated: ${htmlPath}`); + htmlContent = extractHtmlContent(htmlPath); + } + + if (!title) { + console.error("Error: No title found. Provide via --title, frontmatter, or <title> tag."); + process.exit(1); + } + + console.error(`[wechat-api] Title: ${title}`); + + if (args.dryRun) { + console.log(JSON.stringify({ + title, + htmlPath, + contentLength: htmlContent.length, + }, null, 2)); + return; + } + + const config = loadConfig(); + console.error("[wechat-api] Fetching access token..."); + const accessToken = await fetchAccessToken(config.appId, config.appSecret); + + console.error("[wechat-api] Uploading images..."); + const { html: processedHtml, firstMediaId } = await uploadImagesInHtml( + htmlContent, + accessToken, + baseDir + ); + htmlContent = processedHtml; + + let thumbMediaId = ""; + const coverPath = args.cover || + frontmatter.featureImage || + frontmatter.coverImage || + frontmatter.cover || + frontmatter.image; + + if (coverPath) { + console.error(`[wechat-api] Uploading cover: ${coverPath}`); + const coverResp = await uploadImage(coverPath, accessToken, baseDir); + thumbMediaId = coverResp.media_id; + } else if (firstMediaId) { + if (firstMediaId.startsWith("https://")) { + console.error(`[wechat-api] Uploading first image as cover: ${firstMediaId}`); + const coverResp = await uploadImage(firstMediaId, accessToken, baseDir); + thumbMediaId = coverResp.media_id; + } else { + thumbMediaId = firstMediaId; + } + } + + if (!thumbMediaId) { + console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content."); + process.exit(1); + } + + console.error("[wechat-api] Publishing to draft..."); + const result = await publishToDraft(title, htmlContent, thumbMediaId, accessToken); + + console.log(JSON.stringify({ + success: true, + media_id: result.media_id, + title, + }, null, 2)); + + console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`); +} + +await main().catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +});