mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
feat(baoyu-post-to-wechat): add API publishing and external theme support
- Add wechat-api.ts for direct API-based article publishing - Support plain text, HTML, and markdown inputs with auto-conversion - Add external theme discovery from MD_THEME_DIR environment variable - Improve CSS inlining with juice library integration - Support WeChat API credentials via .baoyu-skills/.env
This commit is contained in:
@@ -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/<name>.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 <chosen-theme>
|
||||
npx -y bun ${MD_TO_HTML_SKILL_DIR}/scripts/main.ts <markdown_file> --theme <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 <chosen-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=<user_input>
|
||||
WECHAT_APP_SECRET=<user_input>
|
||||
```
|
||||
|
||||
### Step 6: Publish to WeChat
|
||||
|
||||
**API method**:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/wechat-api.ts <html_file> [--title <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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user