Back to skills
SkillHub ClubShip Full StackFull Stack

wechat-article-forge

Imported from https://github.com/openclaw/skills.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,108
Hot score
99
Updated
March 20, 2026
Overall rating
C0.0
Composite score
0.0
Best-practice grade
F17.6

Install command

npx @skill-hub/cli install openclaw-skills-wechat-article-forge

Repository

openclaw/skills

Skill path: skills/chunhualiao/wechat-article-forge

Imported from https://github.com/openclaw/skills.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install wechat-article-forge into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding wechat-article-forge to shared team environments
  • Use wechat-article-forge for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: wechat-article-writer
description: End-to-end 微信公众号 (WeChat Official Account) article writing and publishing pipeline. 9-step multi-agent workflow: topic research → Chinese-first writing → blind quality review → fact-check → formatting → human preview → scrapbook illustrations → draft box publish. Use when user asks to write, draft, or publish a WeChat article, or says "forge write/draft/publish/topic/voice/status".
---

# wechat-article-writer

> 从选题到发布的公众号一体化写作工作流

Multi-agent pipeline: Orchestrator delegates writing and reviewing to independent subagents. The orchestrator never writes or reviews — it routes, tracks versions, and enforces quality gates.

## Setup

```bash
bash <skill-dir>/scripts/setup.sh <workspace-dir>
```

Installs: bun runtime, bundled baoyu renderer deps, and a persistent preview server (`wechat-preview.service`, port 8898, auto-restart).

## Scope

**Handles:** Topic research → Chinese-first writing → quality review → scrapbook illustrations → WeChat formatting → publishing to WeChat draft box (via Official Account API or CDP browser automation).

**Does NOT handle:** Git/version control, non-WeChat platforms, post-publish analytics, WeChat messaging/customer service.

**Ends at:** Article saved to WeChat draft box. User publishes manually.

---

## Commands

Trigger any command below, or see `skill.yml` for the full trigger pattern list.

| Command | What it does |
|---------|-------------|
| `forge topic X` | Research trending angles, propose 3 options with hooks |
| `forge write X` | Full pipeline: research → publish (9 steps) |
| `forge draft X` | Write + format only, stop before illustrations/publish (steps 1-7) |
| `forge publish <slug>` | Publish an existing draft to WeChat |
| `forge preview <slug>` | Render preview, run format quality checks |
| `forge voice train` | Analyze past articles to extract voice profile |
| `forge status` | Show pipeline status and pending drafts |

If no subject given, loads from `session.json` (set by `forge topic`). See `references/data-layout.md`.

---

## Pipeline (9 Steps)

State persists to `pipeline-state.json` — survives compaction. See `references/pipeline-state.md`.

| # | Step | Who | Details |
|---|------|-----|---------|
| 1 | **Research + Prep** | Orchestrator | (a) `web_search` for topic angles + 5-8 sources. (b) Verify each source exists (fetch title/authors/venue). Save as `sources.json`. (c) Load voice profile. (d) Generate outline (6-8 sections), save `outline.md`. |
| 2 | **Write** | Writer subagent | Chinese-first draft. Writer MUST cite only from `sources.json` or mark `[UNVERIFIED]`. See `references/writer-prompt.md` |
| 3 | **Review** | Reviewer subagent | Blind 8-dimension craft scoring. See `references/reviewer-rubric.md` |
| 4a | **Revise (auto)** | Writer subagent | Max 2 automated cycles. Loop back to Step 3 if score < threshold. |
| 4b | **Revise (human)** | Human-in-the-loop | If still below threshold after 2 auto cycles, user provides direction. Pauses pipeline. |
| 5 | **Fact-check** | Fact-Checker subagent | Verify every claim via web search. Produces corrections + reference list. Max 2 fact-check cycles (corrections → re-verify). See `references/fact-checker-prompt.md` |
| 6 | **Format** | Script | `bash scripts/format.sh <draft-dir> [draft-file] [theme]` — baoyu renderer (default theme: classic WeChat style). Themes: default/grace/simple. If fact-check required >3 text changes, Orchestrator does a **spot re-review** (Reviewer scores only changed paragraphs, not full article). |
| 7 | **Preview** | Human | Open `http://<host>:8898/formatted.html` (persistent preview server, systemd `wechat-preview.service`), await text approval |
| 8 | **Illustrate + Embed** | article-illustrator + script | Generate scrapbook images (AFTER text approval). ~$0.06/article via Z.AI (preferred, ~$0.015/image) or ~$0.50 via OpenRouter. |
| 9 | **Publish** | Orchestrator | **Three paths** — check in order: (C) WeChat Official Account API via appid+appsecret (credentials at `wechat_secrets_path` in config.json) — **preferred, most reliable**; (A) OpenClaw browser tool with base64 chunking for macOS/Titan; or (B) direct CDP WebSocket for Linux/remote. Paths A+B use two-phase injection (text first, then images via clipboard blob paste). See `references/browser-automation.md` |

### Key Rules

- **Writer never self-reviews.** Reviewer is blind — never sees outline or brief.
- **Illustrations LAST.** Most expensive step. Only after user approves text.
- **article-illustrator is the ONLY image method.** Must follow full scrapbook pipeline: read `references/scrapbook-prompt.md` → generate JSON plan with 300-500 char descriptions → call `generate.py`. Never bare prompts. **Prefer Z.AI provider** (~$0.015/image, 97.9% Chinese text accuracy) over OpenRouter (~$0.12/image).
- **Two-phase image injection.** Base64 images are stripped on save. Inject text-only HTML first, then insert each image at the correct position via clipboard blob paste (WeChat auto-uploads to CDN). Verify image count + positions after insertion.
- **Browser tool vs direct CDP.** On macOS/Titan where OpenClaw manages the browser, you MUST use the browser tool (Path A). Playwright isolates page contexts — external CDP connections see zero targets. On Linux with standalone Chrome, use direct CDP (Path B). See `references/browser-automation.md`.
- **Base64 chunking for browser tool.** Raw HTML in the browser tool's `fn` parameter breaks due to escaping conflicts. Always base64-encode HTML, store in chunks via `window._b`, then `atob()` and inject. Track `chunks_stored` in pipeline state for compaction recovery.
- **Always save as draft.** User publishes manually.
- **Check for WeChat API credentials first.** If `wechat_secrets_path` credentials file (see `config.json`) exists, use Path C (API) — no browser required, more reliable. Fall back to Path A/B only if no credentials.
- **`ensure_ascii=False` is mandatory for WeChat API.** `requests(..., json=payload)` escapes Chinese as `\u5199\u4e66`. Always use `data=json.dumps(..., ensure_ascii=False).encode('utf-8')`.
- **Topic fidelity:** Every revision preserves the article's 初心 (purpose statement in pipeline-state.json). Drift = FAIL.

### Image Counts by Type

| Type | Min | Max |
|------|-----|-----|
| 科普 | 3 | 5 |
| 教程 | 3 | 6 |
| 观点 | 2 | 4 |
| 资讯 | 2 | 3 |

---

## Review Dimensions

Reviewer scores 0-10 on **craft-observable** dimensions (not outcome predictions):

| Dimension | Weight |
|-----------|--------|
| Insight Density (洞察密度) | 20% |
| Originality (新鲜感) | 15% |
| Emotional Resonance (情感共鸣) | 15% |
| Completion Power (完读力) | 15% |
| Voice (语感) | 10% |
| Evidence (论据) | 10% |
| Content Timeliness (内容时效性) | 10% |
| Title (标题) | 5% |

**Pass:** weighted_total ≥ 9.0, no dimension below 7, Originality ≥ 8.

**Hard blockers (instant FAIL):** 教材腔, 翻译腔, 鸡汤腔, 灌水, 模板化, 标题党.

Full rubric with scoring criteria: `references/reviewer-rubric.md`

---

## Architecture

```
Orchestrator (Main Agent) — routes, tracks, enforces gates
    ├── Writer Subagent — drafts + revises (Opus model)
    ├── Reviewer Subagent — blind scoring (Sonnet model)
    ├── Fact-Checker Subagent — verifies claims via web search (Sonnet model)
    └── article-illustrator — scrapbook images (after text passes)
```

---

## Configuration

Configure via `~/.wechat-article-writer/config.json` (generated by `scripts/setup.sh`):

| Field | Default | Description |
|-------|---------|-------------|
| `default_article_type` | `"教程"` | Default article type (科普/教程/观点/资讯) |
| `wechat_secrets_path` | `~/.wechat-article-writer/secrets.json` | Path to WeChat API credentials |
| `chrome_debug_port` | `18800` | Chrome CDP port for browser automation (Path B) |
| `wechat_author` | — | Author name shown in WeChat draft |
| `word_count_targets` | See defaults | Min/max word counts per article type |

See `references/data-layout.md` for full config schema.


---

## References

| File | When to load |
|------|-------------|
| `references/writer-prompt.md` | Step 2 (writing) and Step 4 (revision) |
| `references/reviewer-rubric.md` | Step 3 (review) — full 8-dimension scoring criteria |
| `references/fact-checker-prompt.md` | Step 5 — claim extraction, verification, correction protocol |
| `references/viral-article-traits.md` | Step 2 — Writer self-check list |
| `references/pipeline-state.md` | On resume or compaction — state machine schema + protocol |
| `references/browser-automation.md` | Step 9 — Two publishing paths: Path A (OpenClaw browser tool) and Path B (direct CDP). Includes base64 chunking, image insertion, save verification. |
| `references/LESSONS_LEARNED.md` | Hard-won lessons from production publishing sessions (escaping, selectors, mixed content, costs) |
| `references/data-layout.md` | Directory structure, slug generation, config/session schemas |
| `references/agent-config.md` | Setup — Gateway, AGENTS.md, environment config |
| `references/quality-checks.md` | Steps 3, 7 — content/format quality gates |
| `references/figure-generation-guide.md` | Step 8 — illustration placement heuristics |
| `references/wechat-html-rules.md` | Step 6 — what HTML/CSS works in WeChat |
| `references/templates.md` | Step 1 — starting templates by article type |
| `references/voice-profile-schema.json` | Step 1 — voice profile field definitions |
| `references/default-voice-profile.json` | Step 1 — fallback voice profile |


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/data-layout.md

```markdown
# Data Layout & Schemas

## Directory Structure

```
~/.wechat-article-writer/
├── config.json              # User configuration
├── voice-profile.json       # Writing style profile (from forge voice train)
├── session.json             # Current active session (topic handoff)
└── drafts/
    └── <slug-YYYYMMDD>/
        ├── meta.json        # Status, title, type, timestamps
        ├── pipeline-state.json  # Compaction-safe state machine
        ├── outline.md       # Section outline
        ├── sources.json     # Verified source bank from research (Step 1)
        ├── draft.md         # Raw Markdown draft (+ draft-v2.md, v3, ...)
        ├── review-v1.json   # Reviewer scores (+ v2, v3, ...)
        ├── fact-check.json  # Claim verification results (Step 7)
        ├── formatted.html   # WeChat HTML (from wenyan-cli)
        ├── cover.png        # 900×383px cover image
        └── images/          # Inline illustrations
            ├── illustration-plan.json  # Scrapbook JSON plan
            ├── fig1.png
            └── fig2.png
```

## Slug Generation

1. Convert Chinese title to pinyin (first 6 syllables, hyphen-separated, lowercase).
2. Keep ASCII portions as-is (e.g., `AI`, `Rust`).
3. Append `-YYYYMMDD`.
4. Replace non-`[a-z0-9-]` with hyphen. Collapse consecutive hyphens.

**Examples:**
- `本周技术周报` → `ben-zhou-ji-shu-zhou-bao-20260217`
- `AI编程工具深度评测` → `ai-bian-cheng-gong-ju-shen-20260217`

## session.json Schema

```json
{
  "topic": {
    "title": "本周AI工具精选",
    "angle": "news_hook",
    "type": "资讯",
    "hook": "上周有三个AI产品同时发布……",
    "subject": "AI工具"
  },
  "selected_at": "2026-02-17T21:00:00Z",
  "slug": "ben-zhou-ai-gong-ju-20260217"
}
```

Handoff: `forge topic` writes → `forge write`/`forge draft` reads (expires after 24h).

## config.json

```json
{
  "default_theme": "condensed",
  "default_article_type": "教程",
  "auto_publish_types": ["资讯", "周报"],
  "cover_style": "ai_generate",
  "word_count_targets": {
    "资讯": [800, 1500],
    "周报": [1000, 2000],
    "教程": [1500, 3000],
    "观点": [1200, 2500],
    "科普": [1500, 3000]
  },
  "chrome_debug_port": 18800,
  "chrome_display": ":1",
  "chrome_user_data_dir": "/tmp/openclaw-browser2",
  "wechat_author": "你的公众号名称"
}
```

```

### references/pipeline-state.md

```markdown
# Pipeline State Machine (Compaction-Safe)

The orchestrator persists state to `~/.wechat-article-writer/drafts/<slug>/pipeline-state.json` **before every subagent spawn and after every step completion**. This file is the single source of truth for resuming after context compaction or session restart.

## Schema

```json
{
  "slug": "growth-mindset-ai-20260219",
  "step": 5,
  "phase": "reviewing",
  "purpose": "One-sentence 初心 statement",
  "revision_cycle": 2,
  "max_revisions": 3,
  "pass_threshold": 9.5,
  "min_dimension": 8,
  "last_score": 8.60,
  "last_review_file": "review-v2.json",
  "last_draft_file": "draft-v3.md",
  "subagent_label": "reviewer-growth-mindset-v3",
  "subagent_status": "pending",
  "pending_action": "spawn_reviewer",
  "illustrations_done": false,
  "cover_done": false,
  "html_rendered": false,
  "published": false,
  "error": null,
  "updated_at": "2026-02-19T13:50:00Z"
}
```

| Field | Values |
|-------|--------|
| `phase` | `preparing` \| `writing` \| `reviewing` \| `revising` \| `awaiting_human` \| `fact_checking` \| `formatting` \| `previewing` \| `illustrating` \| `publishing` \| `done` \| `blocked` |
| `subagent_status` | `pending` \| `done` \| `failed` \| `null` |
| `publishing_sub` | `preparing` \| `chunking` \| `text_injected` \| `images_inserting` \| `images_done` \| `saving` \| `null` |

### Publishing Sub-Phase Tracking

When `phase` is `publishing`, use `publishing_sub` to track progress within browser injection. This enables resume after session compaction.

```json
{
  "phase": "publishing",
  "publishing_sub": "chunking",
  "publishing_path": "browser_tool",
  "chunks_total": 5,
  "chunks_stored": 3,
  "images_total": 4,
  "images_inserted": 0,
  "wechat_token": "1829963357",
  "editor_target_id": "13142245C7278BD45ECDCE0ED7FF1056"
}
```

| Sub-phase | What happened | Resume action |
|-----------|--------------|---------------|
| `preparing` | Editor page opened, login verified | Verify token still valid |
| `chunking` | Storing base64 chunks in page context | Re-store from `chunks_stored` onward |
| `text_injected` | HTML content pasted into ProseMirror | Skip to image insertion |
| `images_inserting` | Inserting images one by one | Resume from `images_inserted` |
| `images_done` | All images inserted and verified | Proceed to save |
| `saving` | Save button clicked | Check if save succeeded |

**CRITICAL:** If session compacts during `chunking`, the page context (`window._b`) may be lost if the page reloaded. Verify `window._b` length before continuing — if lost, re-store all chunks.

## Resume Protocol (MANDATORY at start of every turn)

1. Check if any `pipeline-state.json` exists with `phase` ≠ `done`/`blocked`.
2. If found, read it — this is your current task.
3. If `subagent_label` is set and `subagent_status` is `pending`:
   - Check via `subagents list`.
   - Done → read result, save output, advance step.
   - Running → report and wait. Do NOT re-spawn.
   - Failed → set `error`, set `phase: "blocked"`, notify user.
4. If no subagent pending → execute `pending_action`.
5. **Before spawning**: write state with label + `pending` FIRST.
6. **After completion**: save output, update state, then proceed.

## State Transitions

```
research+prep (1) → write (2) → review (3)
  ↓ (score < threshold AND cycle < max)
revise-auto (4a) → review (3) [loop, max 2 automated]
  ↓ (score < threshold AND cycle >= 2)
revise-human (4b) → review (3) [pipeline pauses for user direction]
  ↓ (score >= threshold)
fact-check (5) [max 2 cycles: verify → fix → re-verify]
  ↓ (if >3 text changes: spot re-review on changed paragraphs)
format (6) → preview (7)
  ↓ (user approves text)
illustrate+embed (8) → publish+metadata (9) → done
```

## File Naming

- Drafts: `draft.md` (initial), `draft-v2.md`, `draft-v3.md`, ...
- Reviews: `review-v1.json`, `review-v2.json`, ...
- `last_draft_file` and `last_review_file` always point to current version.

```

### references/writer-prompt.md

```markdown
# Writer Agent Prompts

## Initial Draft Prompt

```
You are the WRITER agent for a WeChat 公众号 article.

TASK: Write the full article in Chinese based on the outline below.
Apply the voice profile throughout. Write Chinese-first — do not write
English and then translate. Return only the article body in Markdown.
Do NOT include image references — images will be added later.

VOICE PROFILE:
[insert full JSON contents of voice-profile.json or default-voice-profile.json]

OUTLINE:
[insert full contents of outline.md]

TARGET WORD COUNT: [e.g. 1800 characters]
ARTICLE TYPE: [e.g. 教程]
PURPOSE (初心): [one-sentence statement from pipeline-state.json]

SOURCE BANK:
[insert contents of sources.json — these are pre-verified sources]

SOURCING RULE: You MUST cite only from the source bank above.
If you want to reference a study/fact not in the source bank, mark it
with [UNVERIFIED: brief description] so the Fact-Checker knows to
verify it. Do NOT invent institutions, researcher names, or statistics.
If you're unsure of a detail, leave it vague rather than fabricate it.

LOCALIZATION RULE: 读者只懂中文。所有英文人名、地名、机构名、期刊名
必须翻译为中文,首次出现时括号注明英文原名。例如:
  ✅ 宾大沃顿商学院(Wharton School)的梅因克(Lennart Meincke)团队
  ✅ 发表在《自然·人类行为》(Nature Human Behaviour)上
  ❌ Lennart Meincke团队
  ❌ 发表在Nature Human Behaviour上
后续再次出现时直接用中文,不再重复英文。
专有技术名词(如ChatGPT、AI、LLM)可保留英文。
```

## Originality-First Writing (MANDATORY)

Before writing, answer THREE questions (include at top of draft; orchestrator strips before review):

1. **What is the ONE surprising insight** in this article that readers won't find in 1000 other articles on this topic? If you can't name it, don't start writing — find it first.
2. **Why would someone screenshot a paragraph** and send it to a friend? Which specific paragraph? If none, the article is not ready.
3. **What is the author's LIVED EXPERIENCE** connection to this topic? Generic "I also felt this way" doesn't count. Specific, concrete, personal.

## Anti-灌水 Rule

Every paragraph must pass the deletion test: "If I delete this paragraph, does the article lose something the reader would miss?" If no → delete it. Density > length.

## Self-Check (from viral-article-traits.md)

Before submitting, verify:
- Killer title (≤26 chars, curiosity gap)
- 3-second hook
- Conversational voice (zero 翻译腔/教材腔/鸡汤腔)
- Verifiable real cases
- Reader-first value (what does reader take away?)
- Rhythm & visual breathing (short paragraphs, mobile-friendly)
- Complete emotional arc

## Revision Prompt

```
You are the WRITER agent. Revise this article based on the reviewer's
feedback below. Only modify the sections mentioned. Keep everything
else intact. Return the full revised article in Markdown.

ORIGINAL DRAFT:
[contents of last_draft_file]

REVIEWER FEEDBACK:
[feedback items from last_review_file]

PURPOSE (初心): [one-sentence statement — do not drift from this]
```

```

### references/reviewer-rubric.md

```markdown
# Reviewer Rubric — WeChat 公众号 Article Quality

> v4 — 2026-02-20. Craft-only revision. Reviewers judge TEXT, not outcomes. All dimensions are observable in the article itself — no prediction of reader behavior, shares, or platform metrics. WeChat signals (完读率, 分享, 收藏) are outcomes determined by distribution, timing, and audience size. A reviewer reading a draft cannot predict them. What a reviewer CAN judge: whether the writing is clear, original, compelling, and well-evidenced.

## Scoring Dimensions

### Insight Density (洞察密度) — Weight: 20%

How many genuinely surprising, non-obvious ideas per 1000 characters? This is the core value proposition of any article.

| Score | Criteria |
|-------|----------|
| 9-10 | Multiple "我靠,没想到" moments. Each major section delivers a non-obvious insight backed by evidence. The ideas are SPECIFIC and nameable, not vague gestures at complexity. Reader learns something they couldn't have guessed. |
| 7-8 | 1-2 strong insights, rest is competent but predictable. |
| 5-6 | Rehashes known ideas clearly. Well-organized but no surprises. |
| 3-4 | Obvious points stated with confidence. Reader already knows all of this. |
| 0-2 | Zero insight. Pure filler or platitude. |

**Key test:** For each section, can you state the non-obvious claim in one sentence? If the sentence is something anyone would say, score ≤6.

### Originality (新鲜感) — Weight: 15%

Unique angle is the #6 trait of viral articles (南方传媒书院). In an era of AI-generated content, originality is the only moat.

| Score | Criteria |
|-------|----------|
| 9-10 | Genuinely new insight or framing. Reader thinks "I never thought about it that way." Has a specific, nameable core idea that cannot be found in 1000 other articles on the same topic. The insight is EARNED through research/experience, not manufactured. |
| 7-8 | Fresh angle on a known topic. Not groundbreaking, but not the same take everyone else has. |
| 5-6 | Competent synthesis of existing ideas. Well-written but the reader has seen this before. |
| 3-4 | Rehashed talking points. Could be generated by anyone with access to the same sources. |
| 0-2 | Plagiarism-adjacent. Zero original thought. |

### Emotional Resonance (情感共鸣) — Weight: 15%

共鸣 is the #1 trait of viral articles. The reader must feel "说的就是我" or experience genuine emotional arousal (positive or negative). Low-arousal emotions (contentment, sadness) don't drive shares; high-arousal emotions (awe, anger, anxiety, inspiration) do.

| Score | Criteria |
|-------|----------|
| 9-10 | Hits reader in the gut. They feel genuinely different after reading — moved, fired up, unsettled, or deeply seen. Emotion is EARNED by evidence and storytelling, not manufactured by rhetoric. Complete emotional arc: setup → tension → resolution/revelation. |
| 7-8 | Good emotional moments but some flat stretches. The feeling is real but not sustained. |
| 5-6 | Occasional emotional flickers. Mostly intellectual, not visceral. |
| 3-4 | Flat. Reader feels nothing. Pure information delivery. |
| 0-2 | Actively annoying — condescending, preachy, or manipulatively sentimental. |

### Completion Power (完读力) — Weight: 15%

WeChat platform avg completion rate: 15-25%. Top articles: 45-60%. This dimension predicts whether readers finish or bail. It subsumes old "hook" and "engagement" — because what matters is not just the opening OR the middle, but whether every section earns the next scroll.

| Score | Criteria |
|-------|----------|
| 9-10 | Unputdownable. Every paragraph creates a micro-reason to keep reading: unanswered question, escalating stakes, surprising turn, building pattern. The "3-second test" passes on EVERY screen-scroll, not just the opening. No section where the reader's thumb hovers over the back button. Mobile-optimized: short paragraphs (≤4 lines on phone), visual breathing, varied rhythm. |
| 7-8 | Strong pull with 1-2 flat spots. Reader skims a section but comes back. |
| 5-6 | Starts strong, sags in the middle. Reader finishes out of obligation, not desire. |
| 3-4 | Only the opening is compelling. Most readers bail before halfway. |
| 0-2 | Even the opening fails. Wall of text, no hooks, no reason to continue. |

**Mobile check:** Is there ever a full phone screen (5+ lines) without a visual break, subheading, bold text, or new paragraph? Each occurrence = -1.

### Voice (语感) — Weight: 10%

Natural Chinese that sounds like a specific person thinking out loud, not a committee or algorithm.

| Score | Criteria |
|-------|----------|
| 9-10 | Unmistakably human. Has a personality — you could recognize the author blind. Sentence rhythm varies naturally. Colloquial without being sloppy. Would pass the "read aloud" test — sounds like someone talking, not reciting. |
| 7-8 | Mostly natural with minor stiff patches. |
| 5-6 | Functional but generic. Could be any competent writer. |
| 3-4 | Stiff, formal, or inconsistent. Switches registers awkwardly. |
| 0-2 | Obvious 翻译腔, 教材腔, or 鸡汤腔. |

### Evidence (论据) — Weight: 10%

Verifiable real cases are the #6 trait. Data and specifics build credibility and give readers ammunition for re-sharing ("据xxx研究...").

**HARD RULE — Source Attribution:** Every claim citing a study, experiment, or survey MUST include enough information for readers to verify: researcher/team name, institution, and publication venue or year. "某大学的研究发现" is NOT acceptable — must be "MIT的Ethan Mollick团队发表在《Nature》上的研究发现". The article MUST end with a numbered reference list (参考文献) linking claims to sources. Failure to provide verifiable attribution for key claims = automatic cap at 7.

| Score | Criteria |
|-------|----------|
| 9-10 | Every major claim backed by specific, verifiable evidence: named researchers, institutions, publication venues, concrete numbers. Sources are authoritative and recent. Evidence SURPRISES — it's not the obvious example everyone uses. Article ends with a complete reference list. |
| 7-8 | Good evidence for most claims. 1-2 assertions lack full attribution. Reference list present but incomplete. |
| 5-6 | Some evidence but also hand-waving. "有研究表明" without saying which research. No reference list. |
| 3-4 | Mostly opinion dressed as fact. |
| 0-2 | Pure assertion. No evidence at all. |

### Content Timeliness (内容时效性) — Weight: 10%

Is the article's core argument anchored to universal ideas, or does it depend on this week's news? This is observable in the text — look at what the claims rest on.

| Score | Criteria |
|-------|----------|
| 9-10 | Core argument rests on principles, frameworks, or human nature — not specific tools, versions, or events. Examples are illustrative, not load-bearing (swap them out, argument still holds). Teaches a way of thinking, not just current facts. |
| 7-8 | Mostly enduring. Some references may date but the main argument holds. |
| 5-6 | Mixed. Core idea has value but heavily tied to current context. |
| 3-4 | Mostly ephemeral. Depends on specific products/events that will be irrelevant in a year. |
| 0-2 | Pure news recap. Zero value once the moment passes. |

### Title (标题) — Weight: 5%

Title determines open rate (打开率). Platform avg: 1.9%, good: 4.3%+. But we weight it only 5% because a great title on a bad article is worse than useless — it's a trust violation that loses followers.

| Score | Criteria |
|-------|----------|
| 9-10 | Irresistible curiosity gap. Reader MUST click. ≤26 characters. Specific and concrete, not vague. Promises something the article delivers. Would stop a thumb mid-scroll in a notification list. |
| 7-8 | Good click appeal, slightly generic. |
| 5-6 | Descriptive but not compelling. |
| 3-4 | Weak, too long, or misleading. |
| 0-2 | Terrible or clickbait that article doesn't deliver. |

---

## Weight Summary

| Dimension | Weight | What Reviewer Observes |
|-----------|--------|----------------------|
| Insight Density (洞察密度) | 20% | Non-obvious ideas per section |
| Originality (新鲜感) | 15% | Unique framing vs rehashed takes |
| Emotional Resonance (情感共鸣) | 15% | Earned emotional arc in the text |
| Completion Power (完读力) | 15% | Pacing, hooks, micro-tension per scroll |
| Voice (语感) | 10% | Natural Chinese, personality, rhythm |
| Evidence (论据) | 10% | Specific, named, verifiable sources |
| Content Timeliness (内容时效性) | 10% | Universal vs news-dependent argument |
| Title (标题) | 5% | Clarity, specificity, length |
| **Total** | **100%** | |

## Pass/Fail Criteria

- **Pass:** `weighted_total >= 9.0` AND no single dimension below 7 AND Originality >= 8
- **Fail:** `weighted_total < 9.0` OR any single dimension below 7 OR Originality < 8

**Why Originality >= 8 is mandatory:** A perfectly executed generic article is still a generic article. It won't go viral, won't be remembered, won't build the account's reputation. Originality is the non-negotiable.

### Hard Blockers (automatic FAIL regardless of score)

1. **教材腔 (textbook voice):** Academic phrasing, stiff connectors, impersonal tone.
2. **翻译腔 (translation smell):** Chinese mirroring English syntax.
3. **鸡汤腔 (chicken soup):** Empty motivational platitudes. Poster-ready sentences with zero substance.
4. **灌水 (filler/padding):** Paragraphs that add no new information, insight, or emotion. Deletion test: if you remove it and nobody notices, it's 灌水.
5. **模板化 (template writing):** 问题→理论→案例→建议→鸡汤结尾 without structural surprise.
6. **标题党 (clickbait):** Title promises what article doesn't deliver. This is the fastest way to lose followers.

## Anti-Patterns

- **"翻译腔":** English-mirrored syntax
- **"鸡汤化":** Empty motivational platitudes
- **"教材体":** Academic tone
- **"标题党":** Title promises what article doesn't deliver
- **"流水账":** Listing without insight
- **"万金油文":** Article so generic it could be about any topic with minor word swaps
- **"套路结构":** Predictable template with no structural innovation

## Feedback Format

For each dimension scoring below 8, provide:

```json
{
  "dimension": "shareability",
  "score": 5,
  "issue": "No clear share trigger — reader has no reason to forward this",
  "quote": "relevant passage",
  "suggestion": "specific rewrite direction"
}
```

## Data Sources

- NewRank 2024 Annual Report: 30.78万篇 10W+ articles (7 per 10,000)
- 36kr/NewRank 2026 Study (7,242 accounts): 1.9% open rate, 4.3% headline, 50% completion of openers, 16.1% reads from shares
- 南方传媒书院/澎湃 10W+ Analysis: 10 traits of viral articles
- WeChat platform metrics: 完读率 avg 15-25%, emotional content 45-60%
- Zhang Xiaolong quote: 70-80% of early OA reads from Moments shares (二八定律)
- WeChat recommendation algorithm signals: 完读率, 分享, 点赞/在看, 收藏, 留言

```

### references/fact-checker-prompt.md

```markdown
# Fact-Checker Subagent Protocol

> Step 5 of the pipeline. Runs AFTER the draft passes craft review (Steps 3-4) and BEFORE formatting (Step 6). The Orchestrator applies mechanical corrections from this step's output, then re-runs Fact-Checker once to confirm. Max 2 fact-check cycles total.

## Role

You are a fact-checker. Your job is to verify every factual claim in the article. You have access to `web_search` and `web_fetch`. You do NOT judge writing quality — only factual accuracy.

## Process

### 1. Extract Claims

Read the draft and extract every verifiable factual claim into a structured list:

```json
{
  "claims": [
    {
      "id": 1,
      "text": "宾大沃顿商学院的实验发现94%的想法互相雷同",
      "type": "study",
      "entities": {
        "institution": "宾大沃顿商学院",
        "researchers": null,
        "publication": null,
        "statistic": "94%",
        "year": null
      }
    }
  ]
}
```

**Claim types:** `study` (academic research), `statistic` (specific number), `quote` (attributed quote), `event` (historical event), `product` (company/product claim), `person` (biographical claim).

### 1b. Check Localization

Scan the entire draft for bare English names. Flag any person name, institution, place name, or publication venue that appears in English without a Chinese translation. The rule:

- **First mention:** Chinese translation + (English original). E.g. 沃顿商学院(Wharton School)
- **Subsequent mentions:** Chinese only.
- **Exceptions:** Technical terms that are universally used in English (ChatGPT, AI, LLM, GPT-4, etc.)

Any bare English name = verdict ⚠️ LOCALIZATION with a suggested Chinese translation.

### 2. Verify Each Claim

For each claim, use `web_search` to find the primary source. Check:

- **Institution:** Is it the correct university/organization?
- **Researchers:** Are the names correct? Are they actually affiliated with that institution?
- **Publication:** Is the venue correct (journal name, conference, year)?
- **Statistics:** Are the numbers accurate? (e.g., 3593 vs 3302 — even small errors matter)
- **Quotes:** Is this a real quote, or a paraphrase presented as a quote?
- **Causation vs correlation:** Does the study actually claim what the article says it claims?

### 3. Classify Results

For each claim, assign a verdict:

| Verdict | Meaning |
|---------|---------|
| ✅ VERIFIED | Confirmed by primary source |
| ⚠️ INACCURATE | Partially correct but has errors (wrong institution, wrong numbers, etc.) |
| ⚠️ LOCALIZATION | Bare English name without Chinese translation |
| ❌ UNVERIFIABLE | Cannot find primary source to confirm |
| 🚫 FALSE | Contradicted by primary source |

### 4. Output

Save results to `fact-check.json` in the draft directory:

```json
{
  "draft_version": "v4",
  "checked_at": "2026-02-20T05:30:00Z",
  "total_claims": 8,
  "verified": 5,
  "inaccurate": 2,
  "unverifiable": 1,
  "false": 0,
  "pass": false,
  "claims": [
    {
      "id": 1,
      "original_text": "多伦多大学追踪了3593个创意点子",
      "verdict": "⚠️ INACCURATE",
      "issues": [
        "Institution is Peking University (北京大学), not University of Toronto",
        "Number is 3302 ideas (from 61 students), not 3593"
      ],
      "correction": "北京大学Qinghan Liu团队追踪了61名大学生产生的3302个创意点子",
      "source": "arXiv:2401.06816, Liu et al., Peking University",
      "source_url": "https://arxiv.org/abs/2401.06816"
    }
  ],
  "reference_list": [
    {
      "id": 1,
      "citation": "Lennart Meincke, Gideon Nave & Christian Terwiesch, ...",
      "url": "https://doi.org/..."
    }
  ]
}
```

### 5. Gate Rule

- **All claims must be ✅ VERIFIED and all names localized** to pass.
- Any ⚠️ INACCURATE, ⚠️ LOCALIZATION, or 🚫 FALSE → return corrections to Orchestrator. Orchestrator applies fixes to draft (mechanical, not a rewrite) and re-runs fact-check.
- Any ❌ UNVERIFIABLE → flag to Orchestrator. Options: (a) remove the claim, (b) soften language ("有研究认为" → note it's unverified), (c) user provides source.
- **Maximum 2 fact-check cycles.** If still failing after 2 rounds, escalate to human.

### 6. Reference List Generation

The fact-checker also generates a complete reference list (参考文献) for the article, with:
- Author names
- Paper/article title
- Publication venue + year
- URL where available

This reference list is appended to the draft if not already present.

## Subagent Configuration

- **Model:** Sonnet (cost-effective, web search is the bottleneck not reasoning)
- **Label:** `fact-checker-{slug}-{version}` (e.g., `fact-checker-ai-boring-v4`)
- **Tools needed:** `web_search`, `web_fetch`, file read/write
- **Estimated cost:** ~$0.02-0.05 per run (mostly web search calls)

## What This Step Does NOT Do

- Does not judge writing quality (that's the Reviewer's job)
- Does not rewrite text (that's the Writer's job)
- Does not check grammar or style
- Does not verify opinions or subjective claims — only factual assertions

```

### scripts/format.sh

```bash
#!/usr/bin/env bash
# format.sh — WeChat article formatting (baoyu renderer)
# Usage: bash format.sh <draft-dir> [draft-file] [theme]
# Themes: default (recommended), grace, simple
set -euo pipefail

DRAFT_DIR="${1:?Usage: format.sh <draft-dir> [draft-file] [theme]}"
DRAFT_FILE="${2:-draft.md}"
THEME="${3:-default}"
DRAFT_PATH="$DRAFT_DIR/$DRAFT_FILE"
FORMATTED="$DRAFT_DIR/formatted.html"
DRAFT_HTML="${DRAFT_PATH%.md}.html"

if [[ ! -f "$DRAFT_PATH" ]]; then
  echo "ERROR: Draft not found: $DRAFT_PATH" >&2; exit 1
fi

BUN="$HOME/.bun/bin/bun"
BAOYU="$(cd "$(dirname "$0")" && pwd)/renderer/main.ts"

if [[ ! -f "$BUN" ]]; then
  echo "ERROR: bun not installed" >&2; exit 1
fi
if [[ ! -f "$BAOYU" ]]; then
  echo "ERROR: baoyu skill missing at $BAOYU" >&2; exit 1
fi

echo "Rendering baoyu/$THEME ..."

# Run baoyu in the draft dir so relative image paths resolve correctly
# baoyu outputs <draft-file>.html next to the input file
"$BUN" "$BAOYU" "$DRAFT_PATH" --theme "$THEME" --keep-title 2>&1 | grep -v '^{'

if [[ ! -f "$DRAFT_HTML" ]]; then
  echo "ERROR: baoyu did not produce $DRAFT_HTML" >&2; exit 1
fi

TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S %Z')
{
  printf '<meta charset="utf-8">\n'
  printf '<div style="background:#fff3cd;padding:8px;font-size:12px;color:#856404;border-bottom:1px solid #ffc107;text-align:center;">Preview: %s | baoyu/%s</div>\n' "$TIMESTAMP" "$THEME"
  cat "$DRAFT_HTML"
} > "$FORMATTED"

rm -f "$DRAFT_HTML"
echo "formatted.html: $(wc -c < "$FORMATTED") bytes"
echo "Done: $FORMATTED"
# NOTE: Preview server = systemd wechat-preview.service (port 8898). Do NOT restart from here.

```

### references/browser-automation.md

```markdown
# Browser Automation Publishing (CDP)

For **unverified subscription accounts (未认证订阅号)**, WeChat's content APIs are unavailable. This skill uses browser automation to drive the mp.weixin.qq.com editor directly.

There are **two paths** depending on your setup. Read both and use the one that matches.

---

## Path A: OpenClaw Browser Tool (Recommended for Titan/macOS)

When OpenClaw runs its own Chromium (built-in browser at port 18800), you **must** use the OpenClaw `browser` tool. Direct CDP WebSocket connections do not work because Playwright isolates its page contexts.

### Why You Cannot Bypass the Browser Tool

- Port 18800 IS Chrome's CDP remote debugging port
- But `browser` tool uses Playwright internally; pages live in Playwright's private context
- `/json/list` returns an empty array — pages are invisible to external CDP clients
- Direct WebSocket with `Target.getTargets` returns 0 targets
- Python `playwright.connect_over_cdp()` sees 0 pages (different context)
- **Bottom line:** All JS execution must go through `browser(action=act, request={kind:"evaluate", fn:...})`

### Prerequisites

1. OpenClaw running with built-in browser (default on macOS/Titan)
2. User navigates to `mp.weixin.qq.com` via `browser(action=open, targetUrl="https://mp.weixin.qq.com")`
3. User scans QR code to log in
4. Extract `token` from the URL after login

### HTML Injection via Base64 Chunking

Direct HTML strings in the `fn` parameter fail due to nested JSON/JS escaping issues (quotes, angle brackets, backslashes all conflict). Base64 encoding solves this — only alphanumeric + `/+=` characters, zero escaping issues.

**Confirmed working:** 3500-char base64 chunks pass through the browser tool's `evaluate` fn parameter. No obvious size limit found, but chunk for safety.

#### Step 1: Prepare Base64 Chunks (in shell)

```bash
# Read formatted.html, strip <img> tags, base64 encode
DRAFT_DIR=~/.wechat-article-writer/drafts/<slug>
cat "$DRAFT_DIR/formatted.html" | sed 's/<img[^>]*>//g' > /tmp/text-only.html
B64=$(base64 -i /tmp/text-only.html | tr -d '\n')
echo "Total base64 length: ${#B64}"

# Split into 3500-char chunks
CHUNK_SIZE=3500
i=0
while [ $((i * CHUNK_SIZE)) -lt ${#B64} ]; do
  echo "${B64:$((i * CHUNK_SIZE)):$CHUNK_SIZE}" > "/tmp/chunk_${i}.txt"
  i=$((i + 1))
done
echo "Total chunks: $i"
```

#### Step 2: Store Chunks in Page Context (via browser tool)

For each chunk, call:
```
browser(action=act, request={
  kind: "evaluate",
  fn: "window._b = (window._b || '') + '<chunk_content_here>'; return 'stored ' + window._b.length;"
})
```

Track which chunks have been stored in `pipeline-state.json` (field: `chunks_stored`) for resume after compaction.

#### Step 3: Decode and Inject

```
browser(action=act, request={
  kind: "evaluate",
  fn: "(function(){ var html = atob(window._b); delete window._b; var pm = document.querySelector('.ProseMirror'); if(!pm) return 'NO_PM'; pm.focus(); var range = document.createRange(); range.selectNodeContents(pm); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); var dt = new DataTransfer(); dt.setData('text/html', html); pm.dispatchEvent(new ClipboardEvent('paste', {bubbles:true, cancelable:true, clipboardData:dt})); return 'INJECTED ' + html.length + ' chars'; })()"
})
```

#### Step 4: Set Title

The title field is a `<textarea>` (as of 2026-02), not a standard `<input>` or contenteditable div.

```
browser(action=act, request={
  kind: "evaluate",
  fn: "(function(){ var ta = document.querySelector('textarea'); if(!ta) return 'NO_TEXTAREA'; var setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; setter.call(ta, 'YOUR TITLE HERE'); ta.dispatchEvent(new Event('input', {bubbles:true})); ta.dispatchEvent(new Event('change', {bubbles:true})); return 'TITLE_SET'; })()"
})
```

#### Step 5: Insert Images via Clipboard Blob Paste

For each image, read the file, base64 encode, chunk-store in page context, then paste as blob:

```
browser(action=act, request={
  kind: "evaluate",
  fn: "(function(){ /* 1. Find anchor text */ var pm = document.querySelector('.ProseMirror'); var s0 = pm.querySelector('section'); var kids = Array.from(s0.children); var target = null; for(var i=0;i<kids.length;i++){ if(kids[i].textContent.includes('ANCHOR_TEXT')){ target = kids[i]; }} if(!target) return 'ANCHOR_NOT_FOUND'; /* 2. Position cursor */ var range = document.createRange(); range.selectNodeContents(target); range.collapse(false); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand('insertParagraph'); return 'CURSOR_READY'; })()"
})
```

Then paste the image blob:
```
browser(action=act, request={
  kind: "evaluate",
  fn: "(async function(){ var b64 = window._img; delete window._img; var bin = atob(b64); var bytes = new Uint8Array(bin.length); for(var i=0;i<bin.length;i++) bytes[i]=bin.charCodeAt(i); var blob = new Blob([bytes],{type:'image/png'}); var file = new File([blob],'fig.png',{type:'image/png'}); var dt = new DataTransfer(); dt.items.add(file); document.querySelector('.ProseMirror').dispatchEvent(new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:dt})); return 'PASTED'; })()"
})
```

Wait 3-5 seconds between images for WeChat CDN upload.

#### Step 6: Save as Draft

```
browser(action=act, request={
  kind: "evaluate",
  fn: "(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(b.textContent.includes('保存为草稿')){ b.click(); return 'SAVE_CLICKED'; }} return 'SAVE_BTN_NOT_FOUND'; })()"
})
```

Then poll for save completion (check every 2s, up to 30s):
```
browser(action=act, request={
  kind: "evaluate",
  fn: "(function(){ var text = document.body.innerText; if(text.includes('已保存')) return 'SAVED'; if(text.includes('保存失败')) return 'SAVE_FAILED'; return 'SAVING'; })()"
})
```

### Mixed Content Warning

HTTPS pages (mp.weixin.qq.com) cannot fetch HTTP localhost resources due to mixed content blocking. Do not attempt to serve files from a local HTTP server and inject them via `<img src="http://localhost:...">`. Use base64 blob paste instead.

---

## Path B: Direct CDP via Python WebSocket (Linux/Remote Server)

When Chrome runs as a standalone process (not through OpenClaw's browser tool), you can connect directly via CDP WebSocket. This is the preferred path on any headless Linux server.

### Prerequisites

1. Chrome running with remote debugging:
   ```bash
   DISPLAY=:1 google-chrome-stable \
     --remote-debugging-port=18800 \
     --remote-allow-origins='*' \
     --user-data-dir=/tmp/openclaw-browser2 \
     --no-first-run --disable-default-apps \
     --disable-gpu --disable-software-rasterizer &
   ```
   **`--remote-allow-origins='*'` is REQUIRED** for Python/Node CDP WebSocket access. Without it you get 403.
2. User must scan QR code to log in to mp.weixin.qq.com (session persists in `user-data-dir`).
3. Extract the `token` from the URL: `mp.weixin.qq.com/...?token=XXXXXXXXX`.

### CDP Connection

```python
import json, websocket

# Get tab list
import requests
tabs = requests.get("http://127.0.0.1:18800/json/list").json()
tab_id = tabs[0]["id"]

ws = websocket.create_connection(f"ws://127.0.0.1:18800/devtools/page/{tab_id}")

def evaluate(expr, await_promise=False):
    ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
        "params": {"expression": expr, "returnByValue": True, "awaitPromise": await_promise}}))
    return json.loads(ws.recv()).get("result", {}).get("result", {}).get("value")
```

### Publishing Workflow (Python CDP)

#### Phase 1: Inject Text-Only HTML

Strip all `<img>` tags from `formatted.html` and inject the text-only version via clipboard paste.

```python
import re, json

with open('formatted.html') as f:
    html = f.read()
text_html = re.sub(r'<img[^>]*/?>', '', html)

evaluate(f"""
(function() {{
    var pm = document.querySelector('.ProseMirror');
    pm.focus();
    var range = document.createRange();
    range.selectNodeContents(pm);
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(range);
    var dt = new DataTransfer();
    dt.setData('text/html', {json.dumps(text_html)});
    pm.dispatchEvent(new ClipboardEvent('paste', {{
        bubbles: true, cancelable: true, clipboardData: dt
    }}));
    return 'OK';
}})()
""")
```

#### Phase 2: Insert Images at Correct Positions

**CRITICAL:** Images must be inserted at the correct positions, not appended. Use anchor text to find the right DOM node, position the cursor AFTER it, then paste the image blob.

##### Step 2a: Build Image Position Map

```python
positions = []
for match in re.finditer(r'<img[^>]*src="data:image/[^;]+;base64,([^"]+)"[^>]*/?>',  html):
    before_html = html[:match.start()]
    paras = re.findall(r'>([^<]{20,})<', before_html)
    anchor = paras[-1].strip()[:60] if paras else ''
    positions.append((len(positions), anchor))
```

##### Step 2b: For Each Image, Position Cursor and Paste

```python
import base64, time

for img_index, anchor_text in positions:
    # 1. Find anchor and position cursor
    evaluate(f"""
    (function(){{
        var pm = document.querySelector('.ProseMirror');
        var s0 = pm.querySelector('section');
        var kids = Array.from(s0.children);
        var target = null;
        for (var i = 0; i < kids.length; i++) {{
            if (kids[i].textContent.includes({json.dumps(anchor_text)})) {{
                target = kids[i];
            }}
        }}
        if (!target) return 'ANCHOR NOT FOUND';
        var range = document.createRange();
        range.selectNodeContents(target);
        range.collapse(false);
        var sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
        return 'CURSOR AT: ' + target.textContent.substring(0, 40);
    }})()
    """)

    evaluate("document.execCommand('insertParagraph')")

    # 2. Read and chunk-upload image
    with open(f'images/fig{img_index+1}.png', 'rb') as f:
        b64 = base64.b64encode(f.read()).decode()

    CHUNK = 500000
    chunks = [b64[i:i+CHUNK] for i in range(0, len(b64), CHUNK)]
    evaluate("window._ic = [];")
    for i, chunk in enumerate(chunks):
        evaluate(f"window._ic[{i}] = '{chunk}';")

    # 3. Paste as blob
    evaluate("""
    (async function() {
        var b64 = window._ic.join(''); delete window._ic;
        var bin = atob(b64);
        var bytes = new Uint8Array(bin.length);
        for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
        var blob = new Blob([bytes], {type: 'image/png'});
        var file = new File([blob], 'fig.png', {type: 'image/png'});
        var dt = new DataTransfer();
        dt.items.add(file);
        document.querySelector('.ProseMirror').dispatchEvent(
            new ClipboardEvent('paste', {bubbles:true, cancelable:true, clipboardData:dt}));
        return 'PASTED';
    })()
    """, await_promise=True)

    time.sleep(5)  # Wait for WeChat CDN upload
```

##### Step 2c: Clean Up Empty Placeholders

```python
evaluate("""
(function() {
    var pm = document.querySelector('.ProseMirror');
    var imgs = pm.querySelectorAll('img');
    var removed = 0;
    Array.from(imgs).forEach(function(img) {
        if (!img.src || img.src.length < 10) { img.remove(); removed++; }
    });
    return 'Removed ' + removed + ' empty placeholders';
})()
""")
```

#### Phase 3: Verify Images

**MANDATORY.** Count images in editor and compare to expected count. FAIL if mismatch.

```python
result = evaluate("""
(function() {
    var pm = document.querySelector('.ProseMirror');
    var imgs = pm.querySelectorAll('img[src]');
    var real = Array.from(imgs).filter(function(i) { return i.src.length > 10; });
    var positions = real.map(function(img) {
        var p = img.closest('section, p, div');
        var text_before = '';
        if (p && p.previousElementSibling) {
            text_before = p.previousElementSibling.textContent.substring(0, 40);
        }
        return text_before;
    });
    return JSON.stringify({count: real.length, positions: positions});
})()
""")
```

#### Phase 4: Set Title, Author, Cover, Save

```python
# Title (textarea as of 2026-02)
evaluate("""
(function(){
    var ta = document.querySelector('textarea');
    if(!ta) return 'NO_TEXTAREA';
    var setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
    setter.call(ta, 'Your Title');
    ta.dispatchEvent(new Event('input', {bubbles:true}));
    ta.dispatchEvent(new Event('change', {bubbles:true}));
    return 'TITLE_SET';
})()
""")

# Save as draft
evaluate("""
var btns = document.querySelectorAll('button');
for (var b of btns) {
    if (b.textContent.includes('保存为草稿')) { b.click(); break; }
}
""")

# Wait for save confirmation (CRITICAL)
for i in range(30):
    time.sleep(1)
    status = evaluate("document.body.innerText")
    if "已保存" in status:
        print("Save successful!")
        break
    if "保存失败" in status:
        print("Save failed!")
        break
```

For cover image, use "从正文选择":
1. Click the cover selection link
2. Wait for dialog with thumbnail images
3. Click the first thumbnail
4. Click "下一步" → dismiss warnings → "确认"

---

## Important Notes (Both Paths)

- **ProseMirror, not UEditor:** The visible editor is `.ProseMirror`, not `#edui1_contentplaceholder`.
- **Base64 images DO NOT survive save.** Must use clipboard blob paste — WeChat auto-uploads to CDN (`mmbiz.qpic.cn`).
- **Image position is cursor-determined.** Position cursor at the correct DOM node BEFORE pasting each image. Use anchor text matching.
- **Insert images in REVERSE ORDER** (last image first) to avoid index shifts. Or re-query the DOM after each insertion.
- **Each blob paste may create 1 real + 1 empty img.** Always clean up empty placeholders before saving.
- **Security warning:** Chrome extensions may trigger "浏览器插件存在安全隐患" — dismiss with "我知道了".
- **Session tokens expire:** Token in URL (e.g. `token=1829963357`) expires after ~2 hours of inactivity. Check token validity before starting injection. If expired, user must re-scan QR code.
- **Title field changed:** As of 2026-02, the title field is a `<textarea>`, not an `<input>` or contenteditable div. Use `HTMLTextAreaElement.prototype.value` setter.

### Choosing Your Path

| Condition | Use |
|-----------|-----|
| OpenClaw on macOS with built-in browser | **Path A** (browser tool) |
| Standalone Chrome on Linux/remote | **Path B** (direct CDP) |
| Linux/Ubuntu server | **Path B** |
| Titan (macOS) | **Path A** |
| Need screenshots for user | Both paths support this |

---

## Path C: WeChat Official Account API (推荐 — No Browser Required)

When the WeChat Official Account appid + appsecret are available, **skip the browser entirely**. The `draft/add` API works for both verified and unverified subscription accounts (订阅号). No QR code scan, no browser session, no DOM fragility.

**Check for credentials first:**
```bash
cat ~/.wechat-article-writer/secrets.json  # {"appid": "wx...", "appsecret": "..."}
```
If the file exists, use Path C. Otherwise fall back to Path A or B.

### Step 1: Get Access Token

```python
import json, requests, os
creds = json.load(open(os.path.expanduser("~/.wechat-article-writer/secrets.json")))
resp = requests.get(
    "https://api.weixin.qq.com/cgi-bin/token",
    params={"grant_type": "client_credential", "appid": creds["appid"], "secret": creds["appsecret"]},
    timeout=30
)
access_token = resp.json()["access_token"]  # valid 7200 seconds
```

### Step 2: Upload Images to WeChat CDN

WeChat blocks external image URLs. All `<img>` sources must be WeChat CDN URLs.

```python
# For article body images (temporary URL, use in <img src="...">):
with open("images/img1.png", "rb") as f:
    r = requests.post(
        "https://api.weixin.qq.com/cgi-bin/media/uploadimg",
        params={"access_token": access_token},
        files={"media": ("img1.png", f, "image/png")}, timeout=60
    )
wx_img_url = r.json()["url"]   # http://mmbiz.qpic.cn/...

# For cover (permanent, needed as thumb_media_id):
with open("images/cover.png", "rb") as f:
    r = requests.post(
        "https://api.weixin.qq.com/cgi-bin/material/add_material",
        params={"access_token": access_token, "type": "image"},
        files={"media": ("cover.png", f, "image/png")}, timeout=60
    )
thumb_media_id = r.json()["media_id"]
```

### Step 3: Clean Article HTML

WeChat requires a clean HTML fragment (no `<html>/<head>/<body>` tags):

```python
import re
with open("formatted.html") as f:
    raw = f.read()
body_match = re.search(r'<body[^>]*>(.*)</body>', raw, re.DOTALL | re.IGNORECASE)
content = body_match.group(1).strip() if body_match else raw
content = re.sub(r'<meta[^>]+>', '', content)
content = re.sub(r'<div>Preview build:[^<]+</div>', '', content)
content = re.sub(r' style="[^"]{100,}"', '', content)   # drop verbose inline styles
content = re.sub(r'</?section[^>]*>', '', content)       # unwrap <section> tags
content = re.sub(r'\n\s*\n', '\n', content).strip()
```

### Step 4: Create Draft

> ⚠️ **CRITICAL: Always use `ensure_ascii=False`**
>
> `requests(..., json=payload)` internally calls `json.dumps(ensure_ascii=True)`, which escapes Chinese as `\u5199\u4e66`. The WeChat editor renders these escape sequences **literally** — the article shows gibberish. Always use `data=json.dumps(..., ensure_ascii=False).encode("utf-8")`.

```python
payload = {
    "articles": [{
        "title": "文章标题",          # see field limits below — ≤18 Chinese chars!
        "author": "作者",             # ≤2 Chinese chars (≤8 bytes)
        "digest": "文章摘要",
        "content": content,
        "content_source_url": "https://github.com/...",
        "thumb_media_id": thumb_media_id,
        "need_open_comment": 1,
        "only_fans_can_comment": 0
    }]
}

body = json.dumps(payload, ensure_ascii=False).encode("utf-8")  # ← MUST be this
resp = requests.post(
    "https://api.weixin.qq.com/cgi-bin/draft/add",
    params={"access_token": access_token},
    data=body,
    headers={"Content-Type": "application/json; charset=utf-8"},
    timeout=60
)
media_id = resp.json()["media_id"]
```

### ⚠️ Undocumented Field Limits (discovered 2026-02-28)

Official docs are wrong. Empirically confirmed limits for unverified subscription accounts:

| Field | Documented | **Actual Limit** | Notes |
|-------|-----------|-----------------|-------|
| `title` | 64 chars | **≤18 chars** (≤36 bytes) | 18 works, 19 fails — binary-search confirmed |
| `author` | 8 chars | **≤8 bytes** (≤2 Chinese chars, ~4 ASCII) | `廖春` (6b) ✓, `廖春华` (9b) ✗ |
| `digest` | 120 chars | **~28 Chinese chars** | Test for your account |
| `content` | 20,000 bytes | **~18KB UTF-8** | Chinese = 3 bytes/char; strip styles to reduce |

**Error codes lie.** `errcode 45003` says "title size out of limit" but often means content too large. `errcode 45110` says "author size out of limit" but can be triggered by content size. Test fields independently.

**Title workaround:** The API title is just for draft box display. Edit it to the full version in the WeChat editor UI (no size limit there) before publishing.

### Full Script

```bash
python3 scripts/publish_via_api.py \
  --draft-dir ~/.wechat-article-writer/drafts/<slug> \
  --title "草稿标题(≤18字)" \
  --author "作者" \
  --source-url "https://..."
```

### Path C vs A/B

| | Path A (Browser Tool) | Path B (Direct CDP) | **Path C (API)** |
|-|----------------------|---------------------|-----------------|
| Requires browser session | ✓ | ✓ | **✗** |
| Works headless | ✗ | ✓ | **✓** |
| Needs appid + secret | ✗ | ✗ | **✓** |
| Title limit | No limit | No limit | ≤18 Chinese chars |
| Image workflow | Clipboard blob paste | Clipboard blob paste | Pre-upload to CDN |
| Reliability | Medium (DOM changes) | Medium (session expiry) | **High** |

```

### scripts/setup.sh

```bash
#!/usr/bin/env bash
# wechat-article-writer 一键安装配置脚本
# 用法: bash scripts/setup.sh [workspace_dir]
#
# 自动完成:
# 1. 创建数据目录 (~/.wechat-article-writer/)
# 2. 安装依赖技能 (article-illustrator, wechat-publisher)
# 3. 安装 bun runtime + baoyu renderer deps
# 4. 追加 AGENTS.md 规则到工作区
# 5. 追加 HEARTBEAT.md pipeline 检查
# 6. 验证环境变量
# 7. 生成默认 config.json

set -euo pipefail

WORKSPACE="${1:-$(pwd)}"
DATA_DIR="$HOME/.wechat-article-writer"
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'

ok()   { echo -e "${GREEN}✅ $1${NC}"; }
warn() { echo -e "${YELLOW}⚠️  $1${NC}"; }
fail() { echo -e "${RED}❌ $1${NC}"; }

echo "═══════════════════════════════════════════"
echo "  wechat-article-writer 安装配置"
echo "═══════════════════════════════════════════"
echo ""
echo "工作区: $WORKSPACE"
echo "数据目录: $DATA_DIR"
echo ""

# ─── 1. 数据目录 ────────────────────────────────────────────
echo "── 步骤 1/7: 创建数据目录"
mkdir -p "$DATA_DIR/drafts"
ok "数据目录已创建: $DATA_DIR"

# ─── 2. 依赖技能 ────────────────────────────────────────────
echo ""
echo "── 步骤 2/7: 检查依赖技能"

check_skill() {
  local name="$1"
  local search_dirs=(
    "$HOME/.openclaw/skills/$name"
    "$WORKSPACE/skills/$name"
  )
  for d in "${search_dirs[@]}"; do
    if [ -d "$d" ]; then
      ok "$name 已安装: $d"
      return 0
    fi
  done
  return 1
}

if ! check_skill "article-illustrator"; then
  warn "article-illustrator 未安装 — 尝试安装..."
  if command -v openclaw &>/dev/null; then
    openclaw skill install article-illustrator 2>/dev/null && ok "article-illustrator 已安装" || fail "安装失败,请手动安装: openclaw skill install article-illustrator"
  else
    fail "请手动安装: openclaw skill install article-illustrator"
  fi
fi

if ! check_skill "wechat-publisher"; then
  warn "wechat-publisher 未安装 — 尝试安装..."
  if command -v openclaw &>/dev/null; then
    openclaw skill install wechat-publisher 2>/dev/null && ok "wechat-publisher 已安装" || fail "安装失败,请手动安装: openclaw skill install wechat-publisher"
  else
    fail "请手动安装: openclaw skill install wechat-publisher"
  fi
fi

# ─── 3. bun runtime + baoyu renderer ──────────────────────
echo ""
echo "── 步骤 3/7: 检查 bun runtime"
if [[ -f "$HOME/.bun/bin/bun" ]]; then
  ok "bun already installed: $($HOME/.bun/bin/bun --version)"
else
  warn "bun not found — install from https://bun.sh then re-run setup"
fi
export PATH="$HOME/.bun/bin:$PATH"

RENDERER_MD="$SKILL_DIR/scripts/renderer/md"
if [[ -f "$RENDERER_MD/package.json" ]]; then
  (cd "$RENDERER_MD" && "$HOME/.bun/bin/bun" install --frozen-lockfile --silent 2>/dev/null) && ok "renderer deps installed" || warn "run: cd $RENDERER_MD && bun install"
fi
fi

# ─── 4. AGENTS.md 规则 ──────────────────────────────────────
echo ""
echo "── 步骤 4/7: 配置 AGENTS.md"
AGENTS_FILE="$WORKSPACE/AGENTS.md"
MARKER="## 微信公众号文章写作规则"

if [ -f "$AGENTS_FILE" ] && grep -q "$MARKER" "$AGENTS_FILE" 2>/dev/null; then
  ok "AGENTS.md 已包含写作规则"
else
  cat >> "$AGENTS_FILE" << 'AGENTS_BLOCK'

## 微信公众号文章写作规则

### 自动执行(不问人)
- 选题调研(web_search)
- 撰写大纲
- 生成初稿(spawn Writer 子代理,用 Opus 模型)
- 评审打分(spawn Reviewer 子代理,用 Sonnet 模型)
- 自动修改循环(最多2轮)
- baoyu renderer 排版 (scripts/renderer/, default 主题)
- 启动预览服务器

### 必须等人确认
- **文字预览**(步骤9):发预览链接后等用户反馈,不要自行继续
- **插图生成**(步骤10):用户明确说"可以了"/"好了"/"生成图片"才执行
- **发布**(步骤13):始终保存为草稿,用户手动发布

### 用户反馈处理
- 用户可能通过**语音消息**给反馈 — 直接根据转录文字行动
- 用户说"改一下XX" → 直接改,不要确认
- 用户说"不好"/"重写" → 回到步骤4重写
- 用户说"可以了" → 进入下一步
- 用户沉默超过30分钟 → 发一条提醒,不要反复催

### 成本控制
- 插图是最贵的步骤(~$0.50/篇),放在最后
- 不要在被拒绝的草稿上生成插图
- article-illustrator 原样使用,不修改提示词或添加风格前缀

### Pipeline 状态
- 每个主要步骤完成后更新 pipeline-state.json
- 会话重启后先读 pipeline-state.json 恢复进度
- 不要依赖会话记忆来跟踪进度
AGENTS_BLOCK
  ok "AGENTS.md 已追加写作规则"
fi

# ─── 5. HEARTBEAT.md ────────────────────────────────────────
echo ""
echo "── 步骤 5/7: 配置 HEARTBEAT.md"
HEARTBEAT_FILE="$WORKSPACE/HEARTBEAT.md"
HB_MARKER="## 文章 Pipeline 检查"

if [ -f "$HEARTBEAT_FILE" ] && grep -q "$HB_MARKER" "$HEARTBEAT_FILE" 2>/dev/null; then
  ok "HEARTBEAT.md 已包含 pipeline 检查"
else
  cat >> "$HEARTBEAT_FILE" << 'HB_BLOCK'

## 文章 Pipeline 检查
每次心跳检查 ~/.wechat-article-writer/drafts/*/pipeline-state.json
- 如果有 phase 不是 "done" 且不是等待人工的阶段 → 继续执行
- 如果 phase 是 "previewing_text" 或 "illustrating" → 不要自动继续,等用户
- 如果 pipeline 超过 24 小时没更新 → 提醒用户
HB_BLOCK
  ok "HEARTBEAT.md 已追加 pipeline 检查"
fi

# ─── 6. 环境变量 ────────────────────────────────────────────
echo ""
echo "── 步骤 6/7: 检查环境变量"
if [ -n "${OPENROUTER_API_KEY:-}" ]; then
  ok "OPENROUTER_API_KEY 已设置"
else
  warn "OPENROUTER_API_KEY 未设置 — 插图生成将不可用"
  warn "设置方法: export OPENROUTER_API_KEY=sk-or-..."
fi

# ─── 7. 默认配置 ────────────────────────────────────────────
echo ""
echo "── 步骤 7/7: 生成默认配置"
CONFIG_FILE="$DATA_DIR/config.json"
if [ -f "$CONFIG_FILE" ]; then
  ok "config.json 已存在: $CONFIG_FILE"
else
  cat > "$CONFIG_FILE" << 'CONFIG'
{
  "default_theme": "condensed",
  "default_article_type": "观点",
  "auto_publish_types": [],
  "cover_style": "from_content",
  "chrome_debug_port": 18800,
  "chrome_display": ":1",
  "chrome_user_data_dir": "/tmp/openclaw-browser2",
  "wechat_author": "",
  "word_count_targets": {
    "资讯": [800, 1500],
    "周报": [1000, 2000],
    "教程": [1500, 3000],
    "观点": [1200, 2500],
    "科普": [1500, 3000]
  }
}
CONFIG
  ok "config.json 已创建: $CONFIG_FILE"
  warn "请编辑 config.json 设置 wechat_author(公众号名称)"
fi

# ─── 完成 ───────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo -e "  ${GREEN}安装配置完成!${NC}"
echo "═══════════════════════════════════════════"
echo ""
echo "下一步:"
echo "  1. 确保 Chrome 已启动并登录 mp.weixin.qq.com"
echo "  2. 编辑 $CONFIG_FILE 设置 wechat_author"
echo "  3. 试试: forge write about AI编程工具"
echo ""

```

### references/viral-article-traits.md

```markdown
# 公众号爆款文章十大特征 (Data-Driven)

> 基于行业数据分析、清博大数据研究、头部公众号运营经验总结
> Sources: 澎湃新闻/清博大数据 (2018), 壹伴/木木老贼, 卢松松博客, 西瓜数据, yiban.io, CSDN爆文分析

## 1. 杀手级标题 (Killer Title)

**数据支撑**: 标题决定80%的打开率。公众号文章阅读量公式:`总阅读 = 粉丝量 × 会话打开率 + 分享量 × 好友数 × 朋友圈打开率`。标题同时影响打开率和分享率。

**特征**:
- ≤26字(移动端两行以内)
- 制造好奇缺口(curiosity gap):提出问题但不揭示答案
- 数字具象化("5小时3刷"比"多次观看"有效10倍)
- 戏剧冲突/反常识("每天吃宵夜,我瘦了30斤")
- 对号入座——让特定人群觉得"这是写给我的"

**反面**: 标题党 ≠ 好标题。标题承诺必须被正文兑现,否则用户不会分享。

## 2. 3秒钩子 (3-Second Hook)

**数据支撑**: 微信用户平均在3秒内决定是否继续阅读。朋友圈信息流中,一屏之内看不到吸引力就划走。

**特征**:
- 第一段必须制造悬念、冲突或共鸣
- 开头用场景/故事/问题切入,绝不用"随着…的发展"
- 第一句话的功能 = 让读者读第二句话

## 3. 社交货币 (Social Currency)

**数据支撑**: 朋友圈分享是公众号文章传播的核心引擎。用户分享前会考虑:这篇文章转发后,我在朋友眼中是什么形象?

**特征**:
- 转发后能让分享者显得"有见识、有品味、有关怀"
- 提供谈资("你知道吗?"型内容)
- 提供身份认同("说出了我想说的话")
- 提供实用价值("这个对你有用"型转发动机)

**关键**: 写作时永远问自己——读者为什么愿意把这篇文章转到朋友圈?如果找不到理由,这篇文章不会爆。

## 4. 情绪驱动 (Emotional Engine)

**数据支撑**: 高唤醒情绪(敬畏、愤怒、焦虑、惊讶、开心)比低唤醒情绪(悲伤、满足)的分享率高出数倍。Jonah Berger (Wharton) 研究证实情绪是病毒传播的核心引擎。

**特征**:
- 触发高唤醒情绪:愤怒 > 焦虑 > 敬畏 > 惊喜 > 开心
- 情绪必须真实,来自具体故事/数据,不能靠空洞鸡汤
- 情绪弧线:开头制造张力 → 中间层层递进 → 结尾释放或升华
- 避免低唤醒情绪(纯悲伤、纯满足),读者看完不会行动

## 5. 口语化表达 (Conversational Voice)

**数据支撑**: 公众号阅读场景 = 手机碎片时间。用户在地铁、床上、马桶上看文章。正式书面语 = 阅读阻力。

**特征**:
- 写作像说话,不像写论文
- 短句为主(一句话一个意思)
- 用"你""我"建立对话感
- 适当使用口语词("这事儿""搞定""靠谱")
- 零翻译腔、零教材腔

**测试方法**: 写完后大声朗读一遍——如果听起来别扭,读者看起来也别扭。

## 6. 可验证的真实案例 (Verifiable Real Cases)

**数据支撑**: 头部公众号(人民日报、占豪等)的10w+文章几乎都基于真实事件。虚构"小王""小李"式案例被读者一眼识破,严重损害可信度和分享意愿。

**特征**:
- 有名有姓有出处(人名、机构名、时间、信息源)
- 读者能自行搜索验证
- 案例服务于论点,不是为了凑字数
- 选择读者"没听过但能查到"的案例,新鲜感 + 可信度双杀

## 7. 利他性优先 (Reader-First Value)

**数据支撑**: 知乎、少数派等平台的爆文分析一致指出:用户分享的核心动机是"对别人有用"。干货类、工具类、教程类文章收藏率最高。

**特征**:
- 每篇文章回答一个问题:读完后,读者能拿走什么?
- 具体、可执行的建议(不是"要努力",是"打开XX,点击YY,输入ZZ")
- 信息密度高——每段都有新信息,没有注水段落
- 写作时的核心自问:"这段话删掉,读者会损失什么?"

## 8. 节奏感与视觉呼吸 (Rhythm & Visual Breathing)

**数据支撑**: 手机屏幕窄,大段文字 = 视觉窒息。清博数据分析显示,高分享文章普遍段落短、留白多、视觉节奏快。

**特征**:
- 段落不超过3-4行(手机端)
- 长短句交替制造节奏感
- 善用分隔线、加粗、引用块打破单调
- 图片/插图打断文字墙(每800-1000字至少一张图)
- 关键信息加粗——扫读友好

## 9. 完整的情感弧线 (Complete Emotional Arc)

**数据支撑**: 澎湃新闻/清博大数据对600篇头部爆文分析显示,情感类内容(涉及正能量、家庭、爱情等元素)在微信端表现突出。完整的情感旅程让读者感到"被治愈"或"被点燃",促进分享。

**特征**:
- 结构:困境/痛点 → 认知转变 → 行动/希望
- 读者在结尾时的情绪状态应该和开头不同
- 结尾不能虎头蛇尾——最后一段的力度 ≥ 开头
- 好的结尾让读者想"说点什么"——促进评论和分享

## 10. 热点嫁接 (Hot Topic Grafting)

**数据支撑**: 微信搜一搜、看一看的推荐算法优先推送与当前热点相关的内容。西瓜数据报告显示,公众号推荐流量的爆发往往与热点话题绑定。新号"天降10w+"案例多与热点踩准时机有关。

**特征**:
- 在热点事件24-72小时内发布相关内容
- 不是简单搬运热点新闻,而是"热点 + 独特角度"
- 用热点做入口,用深度做留存
- 标题包含热点关键词(提升搜一搜曝光)
- 常青内容也可以通过"嫁接"当下热点获得二次传播

---

## 写作代理使用指南

Writer Agent 在构思和写作时,必须逐条检查以上10项特征:

1. 标题是否 ≤26字、有好奇缺口、有对号入座感?
2. 开头3秒内是否有钩子?
3. 读者转发到朋友圈的理由是什么?(社交货币)
4. 全文的情绪引擎是什么?是否高唤醒?
5. 是否全程口语化?有没有翻译腔/教材腔?
6. 案例是否真实、有名有姓、可验证?
7. 读者读完能拿走什么具体价值?
8. 视觉节奏是否手机友好?段落是否过长?
9. 情感弧线是否完整?结尾是否有力?
10. 是否与当下热点有关联?

## 评审代理使用指南

Reviewer Agent 在评分时,除原有8维度外,额外检查以下爆款要素:

- **社交货币检查**: 如果找不到读者转发朋友圈的明确理由,Engagement维度扣2分
- **情绪引擎检查**: 如果全文情绪平淡(低唤醒),Emotional Arc维度上限7分
- **口语化检查**: 发现翻译腔/教材腔 = Hard Blocker(已有规则)
- **利他性检查**: 如果读者读完无法带走具体可执行的东西,Actionability维度上限6分
- **视觉节奏检查**: 连续超过5行无断点 = Engagement扣1分(每处)
- **标题检查**: 超过26字 = Title维度上限5分

---

*Last updated: 2026-02-19*
*Sources: 澎湃新闻/清博大数据, 壹伴博客, 卢松松博客, 西瓜数据, Jonah Berger (Contagious), yiban.io*

```

### references/LESSONS_LEARNED.md

```markdown
# WeChat Publishing 经验教训

> **Platform key:** Lessons marked **(macOS only)** apply when OpenClaw manages the browser via Playwright (e.g. Titan). Lessons marked **(Linux/Ubuntu)** apply to standalone Chrome with direct CDP (e.g. headless Ubuntu server). Unmarked lessons apply to **both platforms**.

---

## 2026-02-26: OpenClaw Browser Tool vs Direct CDP **(macOS only)**

### Problem: Cannot bypass OpenClaw's browser tool on Titan

**Symptom:** Python WebSocket, Playwright `connect_over_cdp`, and raw socket CDP connections all fail to see any pages on port 18800.

**Root cause:** OpenClaw's built-in Chromium uses Playwright, which isolates page contexts. The CDP endpoint exists but:
- `/json/list` returns empty array
- `Target.getTargets` returns 0 targets
- `Target.setDiscoverTargets(true)` fires no events
- External Playwright `connect_over_cdp` sees 0 pages (different context)
- WebSocket without Origin header gets 101 handshake but zero targets

**Solution:** Use OpenClaw's `browser(action=act, request={kind:"evaluate", fn:...})` exclusively. All JS execution goes through this tool. See `browser-automation.md` Path A.

**Lesson:** On machines where OpenClaw manages the browser (macOS default), you are locked into the browser tool. Plan your automation around its constraints (string escaping, no persistent variables across calls unless stored in `window`).

**Does NOT apply to:** Ubuntu/Linux with standalone Chrome launched via `google-chrome-stable --remote-debugging-port=18800 --remote-allow-origins='*'`. Direct CDP WebSocket works fine there (Path B).

---

### Problem: HTML injection fails due to escaping hell **(macOS only — browser tool path)**

**Symptom:** `browser(action=act, request={kind:"evaluate", fn:"..."})` breaks when `fn` contains HTML with quotes, angle brackets, or backslashes.

**Root cause:** The `fn` string goes through multiple serialization layers: JSON (tool call) → JSON (browser protocol) → JS evaluation. Nested quotes and special characters break at each layer.

**Solution: Base64 encoding.**
1. Base64-encode the HTML in shell (`base64 -i file.html | tr -d '\n'`)
2. Store base64 string in page context via `window._b` (chunk if >3500 chars)
3. Decode with `atob(window._b)` in the evaluate call
4. Inject decoded HTML via `ClipboardEvent('paste', {clipboardData: dt})`

**Key finding:** `fn` parameter accepts 3500+ chars with no observed limit. Base64 alphabet (A-Z, a-z, 0-9, +, /, =) has zero escaping conflicts.

**Lesson:** For any complex string injection through browser tool, always base64-encode first. Never try to embed raw HTML/JSON in the fn parameter.

**On Ubuntu (Path B):** This is not an issue. Python `evaluate()` passes the expression string directly over WebSocket with `json.dumps()`, which handles escaping correctly in one layer. You can embed raw HTML in `json.dumps(text_html)` without base64.

---

### Problem: Title field selector changed **(both platforms)**

**Symptom:** Title not being set despite code running without errors.

**Root cause:** WeChat editor's title field changed from `<input>` / contenteditable div to `<textarea>` (as of 2026-02).

**Solution:** Use `HTMLTextAreaElement.prototype.value` setter:
```javascript
var setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setter.call(textarea, 'title');
textarea.dispatchEvent(new Event('input', {bubbles: true}));
textarea.dispatchEvent(new Event('change', {bubbles: true}));
```

**Lesson:** WeChat's editor DOM changes without notice. Always verify selectors before assuming they work. Take a snapshot first and inspect the actual DOM structure.

---

### Problem: Mixed content blocking **(both platforms)**

**Symptom:** Attempted to serve images from local HTTP server and load via `<img src="http://localhost:8899/...">` in the HTTPS WeChat editor page. Images silently fail to load.

**Root cause:** Browsers block HTTP resources on HTTPS pages (mixed content policy). mp.weixin.qq.com is HTTPS.

**Solution:** Use clipboard blob paste for images instead of URL references. Read image → base64 encode → create Blob in page context → paste via ClipboardEvent.

**Lesson:** Never attempt to inject local server URLs into HTTPS pages. Always use blob/data methods.

---

## 2026-02-26: Cost Optimization Discovery **(both platforms)**

### Z.AI (GLM-Image) vs OpenRouter GPT-4o for Illustrations

| Provider | Cost per image | 4 images | Chinese text accuracy |
|----------|---------------|----------|----------------------|
| Z.AI (GLM-Image) | ~$0.015 | ~$0.06 | 97.9% |
| OpenRouter (GPT-4o) | ~$0.12 | ~$0.50 | 50-90% (unstable) |

**Result:** Z.AI is ~8x cheaper AND produces better Chinese text. Updated default in SKILL.md and figure-generation-guide.md.

---

## 2026-02-26: Pipeline State Gaps **(both platforms)**

### Problem: No resume granularity within publishing phase

**Symptom:** If session compacts during the multi-step browser injection (chunk storage, decode, inject, images, save), there's no way to resume from mid-injection. The entire publishing phase must restart.

**Solution:** Added sub-phase tracking in `pipeline-state.json`:
- `publishing_sub: "preparing"` — opening editor, verifying login
- `publishing_sub: "chunking"` — storing base64 chunks (`chunks_stored: N`) **(Path A only)**
- `publishing_sub: "text_injected"` — HTML content injected
- `publishing_sub: "images_inserting"` — inserting images (`images_inserted: N`)
- `publishing_sub: "images_done"` — all images inserted and verified
- `publishing_sub: "saving"` — save button clicked, waiting for confirmation

---

## 2026-02-23: Original Lessons

### 1. Chrome CDP needs `--remote-allow-origins=*` **(Linux/Ubuntu only)**

WebSocket connections get 403 without this flag. Required for Path B (direct CDP). Not relevant for Path A (browser tool handles the connection internally).

### 2. Save must wait for confirmation **(both platforms)**

Never navigate away after clicking save. Poll for "已保存" / "保存失败" for up to 30 seconds.

### 3. Remote server browser visibility **(Linux/Ubuntu only)**

Users can't see Chrome on a remote Linux server. Options:
- Take screenshots and send to user for verification
- `--headless=new` mode with screenshot-based workflow
- VNC or X11 forwarding for user to see browser
- User runs Chrome locally and uses Browser Relay

### 4. WeChat session expiry **(both platforms)**

Token expires after ~2 hours of inactivity. Check before starting injection. User must re-scan QR code if expired. On Linux, the QR code appears in the Chrome window (needs DISPLAY or VNC). On macOS, the browser tool can take a screenshot to share.

### 5. Image insertion via clipboard blob paste works reliably **(both platforms)**

Confirmed working pattern: find anchor → position cursor → insertParagraph → paste blob → wait for CDN upload (3-5s).

### 6. `DISPLAY` variable required **(Linux/Ubuntu only)**

Chrome needs `DISPLAY=:1` (or whichever X display is active) on headless Linux. Without it, Chrome won't start. Common setup: `Xvfb :1 -screen 0 1920x1080x24 &` then `export DISPLAY=:1`.

---

## Checklist: Before Publishing

- [ ] Token still valid? (check URL, try loading editor page)
- [ ] ProseMirror editor visible? (`document.querySelector('.ProseMirror')` not null)
- [ ] Which path? (Path A for OpenClaw browser tool on macOS, Path B for direct CDP on Linux)
- [ ] HTML base64 encoded and chunked? **(Path A only)**
- [ ] `--remote-allow-origins='*'` in Chrome launch args? **(Path B only)**
- [ ] DISPLAY variable set? **(Linux only)**
- [ ] All images exist locally? (`ls images/fig*.png`)
- [ ] Anchor texts extracted for image positioning?
- [ ] Title text prepared? (use textarea setter — both platforms)

---

## 2026-02-28: WeChat Official Account API as Path C **(both platforms)**

### Discovery: API Works for Unverified Accounts

**Symptom:** We assumed browser automation (Path A/B) was required because the account is an unverified subscription account (未认证订阅号).

**Finding:** The `cgi-bin/draft/add` API works fine for unverified accounts. The restriction on unverified accounts applies to *publishing* (send to followers) but not to *saving drafts*. The API-based path requires only appid + appsecret, both available in `~/.wechat-article-writer/secrets.json`.

**Lesson:** Always check for WeChat API credentials first. If available, skip the browser entirely.

---

### Problem: Chinese text displays as `\u5199\u4e66` escape sequences in WeChat editor

**Symptom:** Article opens in WeChat editor showing literal text like `\u591a\u667a\u80fd\u4f53\u6846\u67b618\u5c0f\u65f6` instead of `多智能体框架18小时`.

**Root cause:** `requests.post(..., json=payload)` internally uses `json.dumps(ensure_ascii=True)` (Python default). This converts all non-ASCII characters to `\uXXXX`. WeChat's editor renders the raw escape sequences as text, not as Unicode characters.

**Solution:** Encode manually with `ensure_ascii=False`:
```python
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
resp = requests.post(url, data=body, headers={"Content-Type": "application/json; charset=utf-8"})
```

**Lesson:** Never use `requests(..., json=...)` for any WeChat API call containing Chinese text. Always use `data=json.dumps(..., ensure_ascii=False).encode("utf-8")`.

---

### Problem: WeChat API field limits are much stricter than documented

**Symptom:** `draft/add` returns `errcode 45003 "title size out of limit"` even with a 28-character title, and `errcode 45110 "author size out of limit"` even with a 3-character author.

**Finding (empirically confirmed via binary search, 2026-02-28):**

| Field | Documented | **Actual** |
|-------|-----------|-----------|
| `title` | 64 chars | **18 chars** (36 bytes) |
| `author` | 8 chars | **8 bytes** (2 Chinese chars) |
| `digest` | 120 chars | **~28 Chinese chars** |
| `content` | 20,000 bytes | **~18KB UTF-8** |

**Complication:** Error codes are not reliable indicators of which field is the problem. `45003` and `45110` both appeared for content-size issues. Always test fields independently to isolate the real cause.

**Workaround:** Use a short API title (≤18 chars) for the draft box. Edit it to the full title in the WeChat editor UI before publishing — the UI has no such restriction.

**Lesson:** Document these real limits. When any `45xxx` error appears, do a binary-search test on each field independently before concluding which field is at fault.

---

### Problem: Z.AI CDN download returns 404 immediately after generation

**Symptom:** `generate.py` calls Z.AI API → gets a `https://mfile.z.ai/...` URL → requests.get returns 404.

**Root cause:** Z.AI generates the image asynchronously; the CDN URL is valid but the file isn't propagated yet at the moment the download starts.

**Solution:** Retry with delay:
```python
for attempt in range(6):
    time.sleep(4)  # wait before each attempt
    r = requests.get(img_url, timeout=60)
    if r.status_code == 200:
        with open(out_path, "wb") as f:
            f.write(r.content)
        break
```

**Lesson:** Always wrap Z.AI CDN downloads in a retry loop with ≥3 second delay. 4 seconds + 5 retries is reliable in practice.

---

## Checklist Update: Path C Pre-flight

- [ ] `~/.wechat-article-writer/secrets.json` exists with `appid` + `appsecret`?
  - Yes → use Path C
  - No → fall back to Path A (macOS) or Path B (Linux)
- [ ] Access token fetched and valid (not expired)?
- [ ] All images uploaded to WeChat CDN (`media/uploadimg`)? Save URLs in `wechat_image_urls.json`.
- [ ] Cover image uploaded as permanent material? Save `thumb_media_id`.
- [ ] Content cleaned: no `<meta>`, no `<body>`, no `Preview build` banner, no verbose `style="..."`?
- [ ] Content size under ~18KB (UTF-8 bytes)?
- [ ] Title ≤ 18 Chinese chars?
- [ ] Author ≤ 2 Chinese chars (or ≤ 4 ASCII chars)?
- [ ] Using `ensure_ascii=False` in JSON encoding?

---

## 2026-03-01: 预览服务器 (Preview Server) **(both platforms)**

### Problem: 预览链接反复打不开 — 临时服务器反复挂掉

**症状:** 每次调用 `format.sh` 后发一个预览链接,用户反映链接打不开;重启后能用,下次又不行。在整个写作流程里发生了十数次。

**根本原因:** 预览服务器是在 exec 命令里用 `&` 启动的,每次都是重新 kill 然后重启。问题有三层:
1. **不稳定生命周期** — 服务器作为 exec shell 的子进程启动,exec 会话结束后偶尔被杀死
2. **人工介入依赖** — 每次 `format.sh` 之后都要手动重启服务器,忘记或者时序不对就没法访问
3. **端口绑定方式** — 绑到了具体的 Tailscale IP (`<your-tailscale-ip>`) 而非 `0.0.0.0`,IP 变动就挂

**错误做法(不要这样做):**
```bash
# ❌ 每次 format 后 kill + 重启
kill $(lsof -ti:8898) 2>/dev/null
python3 -m http.server 8898 --bind <your-tailscale-ip> &
```

**正确做法:做成 systemd 用户服务,永久运行,自动重启。**

**一次性设置步骤:**

1. 创建服务器脚本 `~/.wechat-article-writer/preview_server.py`:
```python
#!/usr/bin/env python3
import http.server, os

SERVE_DIR = os.path.expanduser("~/.wechat-article-writer/drafts/wechat-article-writer-deep-dive")
PORT = 8898

class NoCacheHandler(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
        self.send_header("Pragma", "no-cache")
        super().end_headers()
    def log_message(self, fmt, *args):
        pass

os.chdir(SERVE_DIR)
http.server.HTTPServer(("0.0.0.0", PORT), NoCacheHandler).serve_forever()
```

2. 创建 systemd 服务 `~/.config/systemd/user/wechat-preview.service`:
```ini
[Unit]
Description=WeChat Article Preview Server (port 8898)
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 $HOME/.wechat-article-writer/preview_server.py
Restart=always
RestartSec=3

[Install]
WantedBy=default.target
```

3. 启用并启动:
```bash
systemctl --user daemon-reload
systemctl --user enable wechat-preview.service
systemctl --user start wechat-preview.service
```

4. 验证:
```bash
systemctl --user status wechat-preview.service
curl -I http://<your-tailscale-ip>:8898/formatted.html
```

**服务特性:**
- 绑定 `0.0.0.0:8898`(所有网口,包括 Tailscale)
- `Cache-Control: no-store, no-cache` — 每次 format 后直接刷新即可,无需 hard refresh
- `Restart=always` — 崩溃自动重启,无需人工干预
- `enabled` — 开机自启

**format.sh 的正确做法:**
- format.sh **不得** kill 或重启预览服务器
- format.sh 输出的 HTML 里有 "Preview build" 时间戳,配合 no-cache 头,浏览器直接刷新即可看到最新内容

**诊断命令(当链接打不开时):**
```bash
# 1. 服务还在吗?
systemctl --user status wechat-preview.service

# 2. 端口在监听吗?
ss -tlnp | grep 8898

# 3. 本地可达吗?
curl -I http://<your-tailscale-ip>:8898/formatted.html

# 4. 如果服务挂了,重启:
systemctl --user restart wechat-preview.service
```

**Lesson:** 任何需要"持续可访问"的 HTTP 服务,必须用 systemd 服务管理,不能用 exec 临时启动。exec 进程生命周期不可预测,不适合做服务基础设施。

---

## 2026-03-01: 排版主题 (wenyan Theme) **(both platforms)**

### Problem: 默认主题太素,公众号效果差

**症状:** format.sh 生成的预览和发布到微信的文章排版很平淡,没有视觉层次感。

**根本原因:** format.sh 调用 `wenyan render` 时没有传 `-t` 参数,永远使用 `default` 主题(最素的)。`skill.yml` 里有 `default_theme: condensed` 配置,但 format.sh 完全没读取它;而且 `condensed` 根本不是合法的 wenyan 主题。

**wenyan 可用主题:**
```
default        — 极简,几乎没有样式(不推荐)
pie            — sspai 风格,现代精致,适合科技/深度内容 ✅ 推荐
lapis          — 蓝调极简,清爽
orangeheart    — 暖橙,适合情感/生活类内容
rainbow        — 彩色活泼
maize          — 柔和麦色
purple         — 紫色简约
phycat         — 薄荷绿,结构清晰
```

**修复:**
```bash
# format.sh 现在默认使用 pie 主题
cat "$DRAFT_PATH" | wenyan render -t pie > "$RAW_HTML"

# 也可以在调用时传主题参数
bash format.sh <draft-dir> draft-v4.md lapis
```

**Lesson:** 排版工具的默认选项往往是最保守的,不代表最佳选择。新部署时应明确测试 2-3 个主题,选出最适合内容调性的,写入 format.sh 默认值。


```

### references/agent-config.md

```markdown
# 智能体配置指南 — Orchestrator Agent Configuration

本文档定义调用 wechat-article-writer 技能的**编排智能体(Orchestrator Agent)**的最佳配置。安装技能后,按此指南配置智能体,实现最大自动化 + 关键人工审核。

---

## 一、核心原则

- **能自动跑的步骤不问人**(调研、初稿、评审、排版、后处理)
- **必须停下来等人的步骤明确标注**(文字预览审核、插图确认、发布确认)
- **语音消息是主要反馈方式** — 智能体必须能处理语音转文字并据此行动
- **状态持久化** — 所有进度写入 `pipeline-state.json`,不依赖会话记忆

---

## 二、Gateway 配置

```yaml
# openclaw config 关键字段
session:
  idleMinutes: 10080        # 7天空闲才重置(文章流程可能跨多天)
  
defaultModel: anthropic/claude-sonnet-4-5   # 日常对话和评审用Sonnet(省钱)
```

**模型策略:**
| 任务 | 模型 | 原因 |
|------|------|------|
| 日常对话、评审 | Sonnet (默认) | 成本低,评审质量够用 |
| 初稿写作、改稿 | Opus (子代理指定) | 创作质量显著更高 |
| 插图生成 | OpenRouter gpt-5-image-mini | article-illustrator 默认 |

---

## 三、AGENTS.md 追加规则

将以下内容追加到编排智能体的 `AGENTS.md`:

```markdown
## 微信公众号文章写作规则

### 自动执行(不问人)
- 选题调研(web_search)
- 撰写大纲
- 生成初稿(spawn Writer 子代理,用 Opus 模型)
- 评审打分(spawn Reviewer 子代理,用 Sonnet 模型)
- 自动修改循环(最多2轮)
- wenyan 排版 + HTML 后处理
- 启动预览服务器

### 必须等人确认
- **文字预览**(步骤9):发预览链接后等用户反馈,不要自行继续
- **插图生成**(步骤10):用户明确说"可以了"/"好了"/"生成图片"才执行
- **发布**(步骤13):始终保存为草稿,用户手动发布

### 用户反馈处理
- 用户可能通过**语音消息**给反馈 — 直接根据转录文字行动
- 用户说"改一下XX" → 直接改,不要确认
- 用户说"不好"/"重写" → 回到步骤4重写
- 用户说"可以了" → 进入下一步
- 用户沉默超过30分钟 → 发一条提醒,不要反复催

### 成本控制
- 插图是最贵的步骤(~$0.50/篇),放在最后
- 不要在被拒绝的草稿上生成插图
- article-illustrator 原样使用,不修改提示词或添加风格前缀

### Pipeline 状态
- 每个主要步骤完成后更新 pipeline-state.json
- 会话重启后先读 pipeline-state.json 恢复进度
- 不要依赖会话记忆来跟踪进度
```

---

## 四、HEARTBEAT.md 追加规则

```markdown
## 文章 Pipeline 检查
每次心跳检查 ~/.wechat-article-writer/drafts/*/pipeline-state.json
- 如果有 phase 不是 "done" 且不是等待人工的阶段 → 继续执行
- 如果 phase 是 "previewing_text" 或 "illustrating" → 不要自动继续,等用户
- 如果 pipeline 超过 24 小时没更新 → 提醒用户
```

---

## 五、SOUL.md 建议追加

```markdown
## 文章写作人格
写文章时切换到"编辑"模式:
- 对文字质量极其严格,不放过任何教材腔/翻译腔/鸡汤腔
- 但对用户反馈高度响应 — 用户说改就改,不要辩解
- 主动提供改进建议,但不强制执行
- 如果用户的意见和评审冲突,以用户意见为准
```

---

## 六、技能依赖清单

安装 wechat-article-writer 前,确保以下技能已安装:

```bash
# 必须
openclaw skill install article-illustrator   # 插图生成
openclaw skill install wechat-publisher      # wenyan-cli 排版

# 推荐
openclaw skill install openai-whisper-api    # 语音转文字(处理用户语音反馈)
```

---

## 七、环境变量

```bash
OPENROUTER_API_KEY=sk-...   # 必须:article-illustrator 图片生成
```

---

## 八、Chrome 浏览器配置(发布用)

```bash
# 启动 Chrome,开启远程调试
DISPLAY=:1 google-chrome-stable \
  --remote-debugging-port=18800 \
  --user-data-dir=/tmp/openclaw-browser2 \
  --no-first-run --disable-default-apps &

# 用户需手动扫码登录 mp.weixin.qq.com
# token 从 URL 中提取,保存到 config.json
```

---

## 九、完整安装检查清单

| # | 检查项 | 命令 |
|---|--------|------|
| 1 | article-illustrator 已安装 | `ls ~/.openclaw/skills/article-illustrator/` |
| 2 | wechat-publisher 已安装 | `which wenyan` |
| 3 | OPENROUTER_API_KEY 已设置 | `echo $OPENROUTER_API_KEY` |
| 4 | Chrome 远程调试可用 | `curl -s http://localhost:18800/json/version` |
| 5 | WeChat 已登录 | 浏览器中访问 mp.weixin.qq.com 检查 |
| 6 | session idle >= 7天 | 检查 OpenClaw gateway 配置 |
| 7 | 默认模型 Sonnet | `openclaw status` |
| 8 | 语音转文字可用 | openai-whisper-api 技能已安装 |

---

## 十、Cron 错误监控

**Cron job 失败是静默的** — 不会主动通知任何人。必须通过以下方式监控:

1. **HEARTBEAT.md 中添加 cron 健康检查**(setup.sh 已自动添加)
2. **Discord delivery target 必须使用 `channel:` 前缀**(e.g. `channel:1234567890`),否则报 "Ambiguous Discord recipient" 并静默失败
3. 如果 `consecutiveErrors >= 2`,禁用该 job 并通知用户

## 十一、常见问题

**Q: 文章流程跑到一半,会话被重置了怎么办?**
A: pipeline-state.json 保存了完整状态。新会话启动后,心跳检查会发现未完成的 pipeline 并恢复。

**Q: 评审分数始终达不到9.5怎么办?**
A: 自动轮次最多2轮,之后转人工。用户的判断优先于自动评分。不要死磕分数。

**Q: 插图风格不满意怎么办?**
A: 重新生成(~$0.50)。但不要修改 article-illustrator 的调用方式,只改内容描述。

**Q: wenyan 输出乱码?**
A: wenyan 输出缺少 `<meta charset="utf-8">`,步骤8会自动注入。如果直接打开 raw.html 会乱码,这是正常的。

```

### references/quality-checks.md

```markdown
# Quality Checks Reference

These are the quality gates applied during the `forge write`, `forge draft`, and `forge preview` pipelines. Every check is listed with its failure criteria and the automatic fix (if any) or the human action required.

Checks are organized into two gates:
- **Gate 1: Content** — run after draft writing, before formatting
- **Gate 2: Format** — run after wenyan-cli conversion, before publishing

A gate failure blocks the pipeline. Up to 2 automatic fix cycles are attempted before the user is notified.

---

## Gate 1: Content Quality

### CQ-01 — Hook Strength

**What it checks:** Does the opening paragraph (first 150 characters) contain a compelling hook?

**Failure criteria (any one):**
- Opens with "本文将介绍…" / "今天我们来聊…" / "随着…的发展" pattern
- First sentence is a definition ("X是一种…")
- No tension, question, surprising fact, or narrative by end of paragraph 1

**Automatic fix:** Prepend a generated hook paragraph that creates tension or poses a question. Original para 1 becomes para 2.

**Prompt injection for fix:**
> 请为以下文章写一个开篇钩子(2-3句话)。钩子必须:提出一个让读者想继续读的问题、揭示一个反直觉的事实、或者讲述一个3秒场景。不要用定义开头,不要用"本文将介绍"。原文:[ARTICLE_EXCERPT]

**Severity:** HIGH — fix before proceeding.

---

### CQ-02 — Word Count

**What it checks:** Article length matches the target range for the declared article type.

**Targets:**
| Type | Min | Max |
|------|-----|-----|
| 资讯 | 800 | 1500 |
| 周报 | 1000 | 2000 |
| 教程 | 1500 | 3000 |
| 观点 | 1200 | 2500 |
| 科普 | 1500 | 3000 |

**Failure criteria:**
- Word count < min → **too short**
- Word count > max → **too long**

**Automatic fix (too short):** Identify the 2 shortest sections. Expand each with a concrete example, data point, or elaboration. Re-check length.

**Automatic fix (too long):** Identify the 2 longest sections. Trim redundant sentences and restate denser. Re-check length.

**Severity:** MEDIUM — attempt fix. If still out of range after 2 cycles, warn user and continue.

---

### CQ-03 — Voice Match Score

**What it checks:** The article's style matches the author's voice profile (if `~/.wechat-article-writer/voice-profile.json` exists).

**How it's scored (0–100):**
- Sentence length within ±30% of profile average: +20 pts
- Opening style matches `structure.opening_style`: +15 pts
- Closing style matches `structure.closing_style`: +15 pts
- At least 2 of the author's `dominant_devices` present: +20 pts
- Formality level consistent with profile: +15 pts
- Emoji usage consistent with profile: +15 pts

**Failure threshold:** Score < 60

**Automatic fix:** Rephrase 3–5 representative paragraphs to match the voice profile. Inject `writing_prompt_injection` from profile and regenerate those paragraphs.

**Severity:** MEDIUM — fix if profile exists. Skip check if no profile found (warn user to run `forge voice train`).

---

### CQ-04 — Section Balance

**What it checks:** No single section is disproportionately long or short relative to others.

**Failure criteria:**
- Any section < 100 characters (stub section)
- Any section > 50% of total article word count
- Standard deviation of section word counts > 2.5× the mean (extreme imbalance)

**Automatic fix:**
- Stub section: expand with 1–2 concrete examples
- Bloated section: split into two sections with a new H2, or trim to key points

**Severity:** MEDIUM

---

### CQ-05 — Chinese-First Rule

**What it checks:** No untranslated English passages longer than a phrase.

**Failure criteria:**
- Any consecutive run of 5+ English words that is not:
  - A proper noun (product name, person name)
  - A code snippet inside backticks
  - A quoted external source

**Automatic fix:** Translate offending passages into Chinese. Keep technical terms in parenthetical form: `术语(Technical Term)`.

**Severity:** HIGH — must fix.

---

### CQ-06 — Link Quality

**What it checks:** Any links included in the article are valid and accessible.

**How it checks:** `curl -s -o /dev/null -w "%{http_code}" <url>` for each link. Expected: 2xx or 3xx.

**Failure criteria:** HTTP 4xx or 5xx response, or timeout after 5 seconds.

**Automatic fix:** None — notify user with list of broken links. User must replace or remove.

**Severity:** HIGH (for 4xx/5xx). LOW (for timeout — may be transient; warn and continue).

---

### CQ-07 — 标题 (Title) Quality

**What it checks:** The article title follows 公众号 best practices.

**Failure criteria (any):**
- Title > 26 characters (WeChat truncates beyond this in feed)
- Title is all lowercase English
- Title contains no noun or active verb (too abstract)
- Title begins with a number that is spelled out (e.g., "三个理由" is fine; "三" alone is too vague)

**Automatic fix:** Generate 3 alternative titles within constraints. Ask user to confirm one.

**Severity:** HIGH — do not publish without confirming title.

---

### CQ-08 — Duplicate Content Check

**What it checks:** The draft is not too similar to an already-published article in `~/.wechat-article-writer/drafts/`.

**How it checks:** Simple trigram overlap against all published `draft.md` files. Similarity threshold: 40%.

**Failure criteria:** Trigram similarity > 40% with any published article.

**Automatic fix:** None — notify user with the similar article slug. User decides whether to proceed or differentiate.

**Severity:** MEDIUM — warn and pause. User must explicitly confirm to proceed.

---

## Gate 2: Format Quality

### FQ-01 — Broken Image References

**What it checks:** All `<img src="...">` tags in the formatted HTML point to valid URLs (WeChat CDN or absolute HTTPS).

**How it checks:** HEAD request to each image URL. Also checks that no `src` value starts with `./`, `../`, or `/` (local paths not supported by WeChat).

**Failure criteria:**
- Any `src` is a local file path
- Any `src` returns non-2xx HTTP response

**Automatic fix:**
- Local images: upload to WeChat CDN using `wenyan-cli --upload-media` and replace src with CDN URL.
- Broken remote URLs: flag to user.

**Severity:** HIGH — WeChat will silently drop broken images.

---

### FQ-02 — HTML File Size

**What it checks:** The formatted HTML file is within WeChat's limits.

**Failure criteria:**
- HTML file > 200KB — WeChat API may reject or truncate

**Automatic fix:** Identify embedded base64 images (if any) and replace with CDN URLs. Remove redundant inline styles. Trim whitespace.

**Severity:** HIGH

---

### FQ-03 — Encoding Check

**What it checks:** The HTML file is valid UTF-8 with correct meta charset declaration.

**How it checks:**
```bash
file --mime-encoding formatted.html   # must be utf-8
grep -c 'charset=utf-8' formatted.html  # must be >= 1
```

**Failure criteria:**
- Encoding is not UTF-8
- No `charset=utf-8` meta tag

**Automatic fix:** Re-save with explicit UTF-8 encoding. Insert `<meta charset="utf-8">` in `<head>`.

**Severity:** HIGH

---

### FQ-04 — Forbidden CSS Properties

**What it checks:** No CSS properties are used that WeChat's renderer strips or breaks.

**Forbidden properties (WeChat strips these):**
```
position: fixed
position: sticky
display: flex          (partially supported — avoid for layout)
display: grid          (not supported)
@media queries         (ignored in article renderer)
JavaScript in <script> (stripped)
<iframe>               (stripped)
external font-family   (only system fonts work)
```

**How it checks:** Grep the formatted HTML for each forbidden pattern.

**Failure criteria:** Any match found outside a `<code>` block.

**Automatic fix:** Remove or replace with WeChat-safe equivalents (see `wechat-html-rules.md`).

**Severity:** HIGH

---

### FQ-05 — Cover Image Dimensions

**What it checks:** The cover image is exactly 900×383px (WeChat official account recommended dimensions).

**How it checks:**
```bash
identify -format "%wx%h" cover.png
```
(requires ImageMagick)

**Failure criteria:** Dimensions ≠ 900×383

**Automatic fix:**
```bash
convert cover.png -resize 900x383^ -gravity center -extent 900x383 cover-fixed.png
```

**Severity:** HIGH — incorrect dimensions cause distorted display in feed.

---

### FQ-06 — Reading Time Estimate

**What it checks:** Estimated reading time is communicated so the user can adjust length if needed.

**How it calculates:** Chinese reading speed ≈ 500 characters/minute.
`reading_time_min = total_chars / 500`

**Failure criteria:** None — this is informational only.

**Output example:**
```
📖 预计阅读时间:4分钟(2,100字)
```

**Severity:** INFO

---

### FQ-07 — Title Tag in HTML

**What it checks:** The formatted HTML contains a correct `<title>` tag matching the article title.

**Failure criteria:** `<title>` tag is missing or contains placeholder text.

**Automatic fix:** Insert/replace `<title>` with article title from `meta.json`.

**Severity:** MEDIUM

---

## Severity Levels

| Level | Meaning | Action |
|-------|---------|--------|
| HIGH | Blocks publish. Auto-fix attempted; if fix fails, pause and notify user. | Must resolve |
| MEDIUM | Should fix; attempt auto-fix. If fix fails after 2 cycles, warn and continue. | Strongly recommended |
| LOW | Informational. | Optional |
| INFO | Always shown; no action required. | Informational |

---

---

## Gate 3: Figure Quality

Checked after step 6 (figure generation), before cover image and formatting.

### FIG-01 — Minimum Figure Count

**What it checks:** Article contains at least 2 inline figure references (`![图`).

**Severity:** HIGH — articles without figures have significantly lower engagement on WeChat.

**Automatic fix:** Re-run figure generation step. If mermaid-cli fails, use ImageMagick fallback.

### FIG-02 — Figure Files Exist

**What it checks:** Every `![...](<path>)` in draft.md points to a file that exists and is ≥10KB.

**Severity:** HIGH — missing or corrupt figures will show as broken images.

### FIG-03 — Figure File Size

**What it checks:** No figure PNG exceeds 2MB (WeChat CDN upload limit).

**Severity:** MEDIUM — large images may fail upload or slow mobile loading.

**Automatic fix:** Compress with `pngquant` or re-generate at lower resolution.

### FIG-04 — Chinese Figure Captions

**What it checks:** All image references use Chinese captions: `![图N:<description>]`.

**Severity:** MEDIUM — uncaptioned figures look unprofessional.

### FIG-05 — Prompt Files Preserved

**What it checks:** Each `.png` in `images/` has a corresponding `.prompt.txt` file (for article-illustrator images) or `.mmd` file (for Mermaid fallback).

**Severity:** LOW — source files enable future edits and reproducibility.

### FIG-06 — Even Distribution

**What it checks:** No two figures appear within 200 characters of each other in `draft.md`.

**Severity:** LOW — clustering figures disrupts reading flow.

### FIG-07 — CDN URLs After Upload

**What it checks:** After the publish step, all `<img src="...">` in the editor point to `mmbiz.qpic.cn` CDN URLs, not local file paths.

**Severity:** HIGH — local paths will show as broken images in published articles.

---

## Running Checks Manually

You can invoke individual checks via shell during development:

```bash
# Word count
wc -m draft.md

# Chinese encoding
file --mime-encoding formatted.html

# Image size check
identify -format "%wx%h\n" cover.png

# Find potential English passages
grep -oE '[A-Za-z ]{20,}' draft.md | grep -v '`'

# HTML file size
du -k formatted.html | awk '{print $1 " KB"}'
```

```

### references/figure-generation-guide.md

```markdown
# Figure Generation Guide

> 公众号文章插图生成规范

## Why Figures Matter

WeChat articles are consumed on mobile phones (~375px wide). Walls of text cause readers to bail. **Every article must contain 2–4 inline figures** that break up text and visualize key concepts.

## Primary Method: Scrapbook-Style Illustrations (article-illustrator)

The **article-illustrator** skill generates colorful, hand-drawn scrapbook-style illustrations using AI image generation (OpenRouter `gpt-5-image-mini`). These produce significantly better visual quality for 公众号 than Mermaid diagrams.

### Why Scrapbook Over Mermaid

- **Visual appeal:** Hand-drawn style catches attention in WeChat's content feed
- **Text readability:** AI-generated images can include Chinese text naturally within illustrations
- **Mobile-friendly:** Portrait orientation (1088×1920) works perfectly on phone screens
- **Engagement:** Illustrated articles get 2-3× more shares than diagram-heavy articles

### Generation Process

1. **Analyze the draft** and identify 2–4 anchor points where a figure adds value
2. **Write prompts** — one per figure, describing a scrapbook-style illustration:
   - Include key concepts, labels, and visual metaphors
   - Specify Chinese text to include in the image
   - Reference the article-illustrator's `references/scrapbook-prompt.md` for style guidelines
3. **Save prompts** as `images/fig<N>-<topic>.prompt.txt`
4. **Generate in parallel** (~10–30s each):

   **Primary: Z.AI / GLM-Image (~$0.015/image, 97.9% Chinese text accuracy)**
   ```bash
   SKILL_DIR=<path-to-article-illustrator-skill>
   IMG_DIR=~/.wechat-article-writer/drafts/<slug>/images

   python3 "$SKILL_DIR/scripts/generate.py" \
     --provider zai \
     --prompt "$(cat $IMG_DIR/fig1-topic.prompt.txt)" \
     --output "$IMG_DIR/fig1-topic.png" \
     --orientation portrait &
   python3 "$SKILL_DIR/scripts/generate.py" \
     --provider zai \
     --prompt "$(cat $IMG_DIR/fig2-topic.prompt.txt)" \
     --output "$IMG_DIR/fig2-topic.png" \
     --orientation portrait &
   wait
   ```

   **Fallback: OpenRouter GPT-4o (~$0.12/image, Chinese text 50-90%)**
   ```bash
   python3 "$SKILL_DIR/scripts/generate.py" \
     --provider openrouter \
     --prompt "$(cat $IMG_DIR/fig1-topic.prompt.txt)" \
     --output "$IMG_DIR/fig1-topic.png" \
     --orientation portrait
   ```

   Z.AI is ~8x cheaper with significantly better Chinese text rendering. Use OpenRouter only if Z.AI is unavailable or quota is exhausted.
5. **Insert into draft.md** with Chinese captions:
   ```markdown
   ![图1:系统架构全景 — 六大核心模块](images/fig1-architecture.png)
   ```

### Prompt Writing Tips

- Describe the visual scene, not just the concept: "A colorful hand-drawn bulletin board with sticky notes showing six modules..."
- Include specific Chinese labels: "标注:任务板、日历、记忆库"
- Mention visual style: "scrapbook style, hand-drawn, colorful, with arrows and doodles"
- Keep it under 200 words — overly detailed prompts reduce quality

## Figure Types (by use case)

| Type | When to Use | Illustration Style |
|------|------------|-------------------|
| Architecture overview | Showing system components | Bulletin board with labeled sticky notes |
| Pipeline / Flowchart | Step-by-step processes | Conveyor belt or road map illustration |
| Timeline | Historical progression | Scroll or path with milestones |
| Comparison | Two approaches, before/after | Split-screen or vs. layout |
| Team/Org structure | Relationships between actors | Family tree or org chart with avatars |
| Concept map | Relationships between ideas | Mind map with branches and icons |

## Placement Heuristics

Insert figures:
- **After the first major section** (not at the very top — hook the reader with text first)
- **Between conceptual shifts** — when the article moves to a new idea
- **Before the conclusion** — a summary/recap figure reinforces the message

**Caption format:** `图N:<描述> — <补充说明>` (Chinese figure numbering, em-dash for subtitle)

**Even distribution:** No two figures should appear within 200 characters of each other.

## Image Upload to WeChat CDN

All local images MUST be uploaded to WeChat's CDN before the article can be saved/published. Local file paths do not work.

**Upload process (via CDP):**
```javascript
// For each image file:
const b64 = fs.readFileSync(imagePath).toString('base64');
// From the page context, POST to:
// /cgi-bin/filetransfer?action=upload_material&f=json&scene=8&writetype=doublewrite&groupid=1&token=<token>
// Response: { "cdn_url": "https://mmbiz.qpic.cn/..." }
```

After upload, replace all `images/fig*.png` paths in `formatted.html` with CDN URLs.

## Quality Checks for Figures

| Check | Rule | Severity |
|-------|------|----------|
| FIG-01 | Article has ≥2 inline figures | HIGH |
| FIG-02 | All figure PNGs exist and are ≥10KB (not corrupt) | HIGH |
| FIG-03 | No figure exceeds 2MB (WeChat CDN upload limit) | MEDIUM |
| FIG-04 | Figure captions present and in Chinese | MEDIUM |
| FIG-05 | Prompt files saved alongside PNGs | LOW |
| FIG-06 | Figures evenly distributed (no 2 figures within 200 chars of each other) | LOW |
| FIG-07 | After upload, all img src attributes point to mmbiz.qpic.cn | HIGH |

## Mermaid Fallback

If article-illustrator is unavailable (no `OPENROUTER_API_KEY`, network issues, etc.), fall back to Mermaid diagrams:

### Mobile-First Mermaid Design Rules

1. **Prefer vertical layouts (`TD`/`TB`)** — horizontal (`LR`) only if ≤5 nodes
2. **Max 6–8 nodes per diagram** — split complex diagrams into 2+ figures
3. **Labels ≤8 Chinese characters per node**
4. **Use color coding** — group related nodes with the same fill color
5. **White background** — render with `-b white`
6. **Render width: 900px**
7. **No emoji in node labels** — inconsistent rendering across Android/iOS

### Mermaid Color Palette

```
Primary:    #3498db (blue)     — main concepts
Secondary:  #2ecc71 (green)    — outputs, results
Accent:     #e74c3c (red)      — highlights, entry points
Neutral:    #95a5a6 (grey)     — background items
Emphasis:   #f39c12 (orange)   — human actions
Purple:     #9b59b6 (purple)   — external systems
```

### Mermaid Rendering

```bash
PUPPET='{"args":["--no-sandbox","--disable-setuid-sandbox"]}'
echo "$PUPPET" > /tmp/mmdc-puppet.json
npx @mermaid-js/mermaid-cli \
  -i <input>.mmd -o <output>.png \
  -w 900 -b white \
  -p /tmp/mmdc-puppet.json -q
```

## ImageMagick Last Resort

If both article-illustrator AND mermaid-cli fail, generate simple text-based covers:

```bash
FONT=$(fc-match --format='%{file}' 'Noto Sans CJK SC' 2>/dev/null || \
       fc-match --format='%{file}' 'WenQuanYi' 2>/dev/null || \
       echo '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')

convert -size 900x600 xc:"#1a1a2e" \
  -font "$FONT" \
  -fill "#3498db" -pointsize 36 -gravity North -annotate +0+30 "系统架构" \
  -fill white -pointsize 24 -gravity Center -annotate +0+0 "网关 → 智能体 → 技能" \
  output.png
```

```

### references/wechat-html-rules.md

```markdown
# WeChat HTML/CSS Rendering Rules

WeChat's article renderer (微信公众号文章渲染器) is not a standard browser. It is an embedded WebView that strips or ignores many common HTML and CSS features. This document describes what works, what doesn't, and the safe alternatives.

These rules apply to content created with `wenyan-cli`. If you write custom HTML fragments, follow this guide.

---

## The Golden Rule

**WeChat renders inline styles only.**

External stylesheets, `<link>` tags, and `<style>` blocks in `<head>` are stripped at upload time. All styling must be in `style=""` attributes on individual elements.

`wenyan-cli` handles this automatically by inlining styles during conversion. If you edit HTML manually, always use inline styles.

---

## Supported HTML Elements

### ✅ Fully Supported

| Element | Notes |
|---------|-------|
| `<p>` | Primary text container. Use for all paragraphs. |
| `<h1>` `<h2>` `<h3>` | Rendered. H1 is typically the title (auto-generated). Use H2 for sections, H3 for subsections. |
| `<strong>` `<b>` | Bold — works reliably. |
| `<em>` `<i>` | Italic — works. Use sparingly; light on mobile screens. |
| `<u>` | Underline — works, but looks like a hyperlink. Avoid. |
| `<br>` | Line break — works. Prefer `<p>` over multiple `<br>`. |
| `<blockquote>` | Renders with indent. Use for quotes and callout boxes (apply colored border via inline style). |
| `<ul>` `<ol>` `<li>` | Lists — work. |
| `<img>` | Works. Must be absolute HTTPS URL or WeChat CDN URL. Local paths fail silently. |
| `<a>` | Works for display only. **Links are not clickable in articles.** WeChat converts URLs to non-clickable text. Only internal WeChat links (via 公众号 link mechanism) work. |
| `<table>` `<tr>` `<td>` `<th>` | Basic tables work on mobile. Avoid complex tables — prefer lists or split into multiple paragraphs. |
| `<code>` `<pre>` | Renders with monospace font. `wenyan-cli` applies syntax highlighting as inline `<span>` elements — safe. |
| `<hr>` | Horizontal rule — works. |
| `<section>` | Acts like `<div>`. |

### ⚠️ Partially Supported / Use with Caution

| Element | Status | Notes |
|---------|--------|-------|
| `<div>` | Partially | Works as a block container. Don't nest more than 3 levels deep. |
| `<span>` | Partially | Inline container — works for colored text. |
| `<figure>` `<figcaption>` | Partially | Renders, but `figcaption` may not display below image on all devices. Use `<p style="text-align:center; color:#999; font-size:14px;">` instead. |
| `<details>` `<summary>` | No | Stripped. |
| `<video>` | Partially | WeChat converts to a WeChat video embed only if uploaded via the WeChat backend. Raw `<video>` tags are stripped. |
| `<audio>` | No | Stripped. |

### ❌ Stripped / Not Supported

| Element | What Happens |
|---------|-------------|
| `<script>` | Completely stripped |
| `<iframe>` | Completely stripped |
| `<form>` `<input>` `<button>` | Stripped |
| `<link>` (stylesheets) | Stripped |
| `<style>` in `<head>` | Stripped on upload |
| `<svg>` (inline) | Stripped. Use `<img src="...svg">` with CDN URL instead, or convert to PNG. |

---

## CSS Properties

### ✅ Reliable Inline CSS

These properties work reliably in WeChat's WebView when applied as inline styles:

```css
/* Typography */
font-size: 16px;          /* Use px, not rem/em */
font-weight: bold;
font-style: italic;
color: #333333;           /* Hex values preferred over named colors */
line-height: 1.8;
text-align: left | center | right;
text-decoration: underline | none;
letter-spacing: 1px;
word-break: break-all;    /* Prevents overflow on long URLs/code */

/* Box model */
margin: 0 auto;
padding: 10px 15px;
border: 1px solid #e0e0e0;
border-left: 4px solid #1AAD19;  /* Classic callout box */
border-radius: 4px;
width: 100%;
max-width: 100%;

/* Background */
background-color: #f5f5f5;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* Works */

/* Display */
display: block;
display: inline;
display: inline-block;

/* Images */
max-width: 100%;          /* ALWAYS set on images */
height: auto;
vertical-align: middle;
```

### ❌ Unreliable / Stripped CSS

```css
/* Layout — don't use for structural layout */
display: flex;            /* Stripped on some older WebView versions */
display: grid;            /* Not supported */
position: fixed;          /* Stripped */
position: sticky;         /* Stripped */
z-index: 999;             /* Irrelevant — no stacking context */
float: left | right;      /* Works but causes layout issues; avoid */

/* Media queries — ignored */
@media (max-width: 768px) { ... }  /* Ignored in article renderer */

/* External fonts */
font-family: 'Inter', sans-serif;   /* External fonts stripped; only system fonts load */
@font-face { ... }                  /* Stripped */

/* Transitions & animations */
transition: all 0.3s ease;   /* Stripped */
animation: ...;              /* Stripped */
transform: rotate(45deg);    /* Partially; avoid for layout */

/* CSS variables */
--my-color: red;             /* Not supported */
color: var(--my-color);      /* Not supported */

/* calc() */
width: calc(100% - 40px);   /* Unreliable; use fixed values or 100% */
```

---

## Safe Font Stack

External fonts are stripped. Use only system fonts:

```css
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
             "Microsoft YaHei", "微软雅黑", sans-serif;
```

For monospace (code):
```css
font-family: "SF Mono", Monaco, Consolas, "Courier New", monospace;
```

---

## Images

### Required Rules

1. **Always use absolute HTTPS URLs** — no local paths, no relative paths.
2. **Upload to WeChat CDN first** using `wenyan-cli --upload-media <file>` or via the WeChat management backend.
3. **Set `max-width: 100%`** on every image to prevent overflow on narrow screens.
4. **Cover image**: 900×383px. WeChat will crop/distort if wrong size.
5. **In-article images**: Any aspect ratio. WeChat does not auto-resize beyond 100% max-width.

### Image Template

```html
<img
  src="https://mmbiz.qpic.cn/your_image_path/0"
  style="max-width:100%; height:auto; display:block; margin:0 auto;"
  alt="图片描述"
/>
<p style="text-align:center; color:#999999; font-size:13px; margin-top:6px;">
  图注:这里写图片说明文字
</p>
```

### SVG

Do not embed SVG inline — it is stripped. Convert to PNG first:

```bash
# Using ImageMagick
convert diagram.svg diagram.png

# Using mmdc (mermaid skill — preferred for diagrams)
mmdc -i diagram.mmd -o diagram.png -w 800
```

---

## Tables

Tables render but are not responsive. Follow these rules:

- Max 4 columns on mobile
- Keep cell content short (< 20 characters)
- Use `width:100%` on `<table>` and `border-collapse:collapse`
- Use `padding:8px` on `<td>` and `<th>`
- Use alternating row colors via inline style (WeChat strips CSS classes)

**Safe table template:**

```html
<table style="width:100%; border-collapse:collapse; font-size:14px;">
  <thead>
    <tr style="background-color:#f0f0f0;">
      <th style="padding:8px 12px; text-align:left; border:1px solid #ddd;">列一</th>
      <th style="padding:8px 12px; text-align:left; border:1px solid #ddd;">列二</th>
    </tr>
  </thead>
  <tbody>
    <tr style="background-color:#ffffff;">
      <td style="padding:8px 12px; border:1px solid #ddd;">数据</td>
      <td style="padding:8px 12px; border:1px solid #ddd;">数据</td>
    </tr>
    <tr style="background-color:#fafafa;">
      <td style="padding:8px 12px; border:1px solid #ddd;">数据</td>
      <td style="padding:8px 12px; border:1px solid #ddd;">数据</td>
    </tr>
  </tbody>
</table>
```

---

## Callout / Highlight Boxes

Use `<blockquote>` or `<section>` with inline border styling:

**Tip / Info box (green left border):**
```html
<blockquote style="border-left:4px solid #1AAD19; padding:10px 16px; background:#f0fff0; margin:16px 0; border-radius:0 4px 4px 0;">
  <p style="margin:0; color:#333; font-size:15px;">💡 <strong>提示:</strong>这里写要强调的内容。</p>
</blockquote>
```

**Warning box (orange left border):**
```html
<blockquote style="border-left:4px solid #F5A623; padding:10px 16px; background:#fffbf0; margin:16px 0; border-radius:0 4px 4px 0;">
  <p style="margin:0; color:#333; font-size:15px;">⚠️ <strong>注意:</strong>这里写警告内容。</p>
</blockquote>
```

**Summary box (blue left border):**
```html
<blockquote style="border-left:4px solid #1989FA; padding:10px 16px; background:#f0f7ff; margin:16px 0; border-radius:0 4px 4px 0;">
  <p style="margin:0; color:#333; font-size:15px;">📌 <strong>核心观点:</strong>这里写关键摘要。</p>
</blockquote>
```

---

## Code Blocks

`wenyan-cli` handles code block conversion with syntax highlighting. The output is `<pre>` with inline `<span>` color styles — safe for WeChat.

If writing manually:

```html
<pre style="background:#1e1e1e; color:#d4d4d4; padding:16px; border-radius:4px; overflow-x:auto; font-size:13px; line-height:1.6; word-break:break-all;">
<code><span style="color:#569cd6;">const</span> <span style="color:#9cdcfe;">greeting</span> = <span style="color:#ce9178;">"你好,世界"</span>;</code>
</pre>
```

---

## Links

**Links are not clickable in WeChat articles for external URLs.** WeChat converts `<a href="...">` to plain text for external links.

Options:
- For important URLs: display the full URL as text so readers can copy it
- For WeChat-internal links: use the `公众号文章` link type via the WeChat editor or API
- For sponsored content: use WeChat's official link card format

Do not rely on `<a>` for navigation.

---

## Article Size Limits

| Item | Limit |
|------|-------|
| HTML content size | ~64KB after upload (API limit is higher but renderer may truncate) |
| Number of images | No hard limit; recommend ≤ 20 |
| Title length (feed) | 26 characters visible; 64 max stored |
| Total article characters | No hard limit; practical max ~10,000 characters for readability |
| Cover image file size | ~2MB |
| In-article image file size | ~10MB per image (via API upload) |

---

## Emoji

Emoji render correctly in WeChat on both iOS and Android (using the device's native emoji font).

Unicode emoji are safe. WeChat's custom emoji (the animated ones in chat) cannot be embedded in articles.

---

## WeChat-Specific Meta Tags

When using the WeChat API to create a draft, these fields are separate from the HTML body:

| API field | Description |
|-----------|-------------|
| `title` | Article title (separate from HTML `<title>`) |
| `thumb_media_id` | Cover image (uploaded separately to WeChat CDN) |
| `author` | Author name displayed below title |
| `digest` | Auto-generated excerpt shown in feed (first 120 chars of body, or custom) |
| `content_source_url` | "阅读原文" link at bottom (optional) |
| `show_cover_pic` | 0 or 1 — whether cover image appears inside article body |

`wenyan-cli` handles populating these from Markdown frontmatter.

---

## Testing Your HTML

Before publishing, test render in WeChat DevTools or view via:

```bash
# Quick sanity check — open in a standard browser first
xdg-open formatted.html

# Check for forbidden patterns
grep -E 'position:\s*fixed|display:\s*(flex|grid)|@media' formatted.html

# Check for local image paths
grep -oE 'src="[^h][^t][^t][^p][^"]*"' formatted.html
```

---

## Sources

- **WeChat Official Account API Documentation:** https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html
- **WeChat Draft Article API (图文消息):** https://developers.weixin.qq.com/doc/offiaccount/Draft_Box/Add_draft.html
- **WeChat Publish API:** https://developers.weixin.qq.com/doc/offiaccount/Publish/Publish.html
- **Cover image dimensions (900×383px):** https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Adding_Permanent_Assets.html
- **Article content size limits:** https://developers.weixin.qq.com/doc/offiaccount/Draft_Box/Add_draft.html (content field docs)
- **Reading speed estimate (500 chars/min):** Based on average Chinese reading speed research; commonly cited in Chinese content strategy guides.

```

### references/templates.md

```markdown
# Article Templates

These templates define the structural skeleton for each article type. Use them as starting outlines for the writing step. Each template includes:

- Frontmatter schema
- Section structure with purpose and word count target
- Writing notes per section
- A filled example outline

---

## How Templates Are Used

During `forge write` or `forge draft`, after topic selection the agent selects the appropriate template based on article type. The template becomes the outline scaffold — each section is then filled in with researched content and shaped to the author's voice profile.

The agent may deviate from the template when content demands it, but section count and purpose should remain close.

---

## Template 1: 资讯 (News / Updates)

**Purpose:** Report on a recent development, product release, or industry event. Fast to write. Auto-publishes.

**Target length:** 800–1500 characters (Chinese)

**Frontmatter:**
```yaml
title: ""          # ≤ 26 chars; include the news subject
type: 资讯
date: YYYY-MM-DD
tags: []
```

### Section Structure

| # | Section | Purpose | Target chars |
|---|---------|---------|-------------|
| 0 | **开篇** (no heading) | Hook + core news in 2–3 sentences. What happened? Why does it matter right now? | 150–200 |
| 1 | **背景** | Brief context: what was the situation before this news? Reader needs ≤ 3 sentences to understand the significance. | 200–300 |
| 2 | **核心内容** | The actual news, broken into 3–5 bullet points. Facts, figures, quotes. Cite sources inline. | 300–500 |
| 3 | **影响与解读** | What does this mean for the reader's work / industry? 2–3 paragraphs of analysis. | 200–350 |
| 4 | **结语** | 1–2 sentences. Either a forward-looking statement ("接下来值得关注…") or a question for the reader. | 80–120 |

### Writing Notes

- Use **past tense** for events, **present tense** for implications
- Lead with the most newsworthy fact — don't bury the lede
- Citations: use format `据[来源]报道` or `根据[来源]数据`
- No H3 subheadings needed — this is a short-form piece

### Example Outline (Filled)

```
标题:OpenAI发布GPT-4 Turbo:上下文扩至128K

开篇:
OpenAI在开发者大会上宣布GPT-4 Turbo,上下文窗口从8K扩展到128K,
相当于一本完整技术书籍。对开发者意味着什么?

背景:
GPT-4自2023年3月发布以来,上下文限制(8192 tokens)是最常见的开发者投诉之一。
长文档分析、代码库问答都受此制约。

核心内容:
• 上下文窗口:128K tokens(约100,000字)
• 定价下调:输入降低3倍,输出降低2倍
• 知识截止日期更新至2023年4月
• 新增JSON模式,保证结构化输出
• 函数调用支持多函数并行

影响与解读:
对RAG架构的冲击:更大上下文窗口意味着部分场景无需向量数据库…
对成本的影响:按照新定价计算,处理一本书只需约…

结语:
128K上下文是迈向"无限上下文"的重要一步,但这真的解决了企业用户的痛点吗?
```

---

## Template 2: 周报 (Weekly Roundup)

**Purpose:** Curate and summarize the week's most important items in a domain. Consistent, repeatable format. Auto-publishes.

**Target length:** 1000–2000 characters

**Frontmatter:**
```yaml
title: ""          # Pattern: "[Domain]周报 | 第X期"  e.g. "AI周报 | 第42期"
type: 周报
date: YYYY-MM-DD
issue_number: 42
tags: []
```

### Section Structure

| # | Section | Purpose | Target chars |
|---|---------|---------|-------------|
| 0 | **卷首语** (no heading) | 1 short paragraph. The week's mood, a key theme, or an editorial take. Personal voice. | 100–150 |
| 1 | **本周要闻** | 3–5 top news items. Each item: bold headline, 2–3 sentences of context + significance. | 400–600 |
| 2 | **工具 & 资源** | 3–5 tools, repos, articles, or resources worth bookmarking. Format: name → one-line description → why it matters. | 300–400 |
| 3 | **深度推荐** (optional) | 1 article, paper, or podcast worth reading in full. 2–3 sentences on why. | 100–150 |
| 4 | **一句话观点** | 3–5 short, punchy takes on the week. Format: `💬 "观点内容"` | 150–200 |
| 5 | **下周关注** | 2–3 things to watch next week (events, launches, deadlines). | 80–120 |

### Writing Notes

- **Consistency is key**: the format should be identical every week — readers learn to navigate it
- Emoji work well as section bullets in roundups (provides visual rhythm without headers)
- Each news item follows: `**[标题]**:事件描述。为什么重要:一句分析。`
- Use `🔗 原文链接:[URL]` format for citations (links aren't clickable but readers can find them)
- First-person plural acceptable ("我们关注到…", "本周值得关注的是…")

### Example Outline (Filled)

```
标题:AI工具周报 | 第18期

卷首语:
这一周,模型越来越便宜,工具越来越多,但真正落地的产品还是那几个。
精心挑了五条值得你花时间的内容。

本周要闻:
**Mistral发布Mixtral 8x7B**:首个开源MoE架构大模型正式发布,
性能对标GPT-3.5,可本地运行。意味着:高质量本地LLM时代真的来了。

**Google Gemini Ultra泄露跑分**:内部跑分显示Gemini Ultra在多个
基准上超过GPT-4。但:跑分和实际体验差距有多大,还待观察。
…

工具 & 资源:
• **Continue** (VS Code插件) → 接入本地LLM的AI编程助手,完全免费
• **LlamaIndex v0.9** → RAG框架重大更新,支持多模态检索
…

下周关注:
• OpenAI DevDay第二季?据传下周有新发布
• Anthropic Claude 3发布计划时间窗口临近
```

---

## Template 3: 教程 (Tutorial / How-To)

**Purpose:** Teach the reader to do something specific, step by step. High value, strong SEO. Requires user review before publish.

**Target length:** 1500–3000 characters

**Frontmatter:**
```yaml
title: ""          # Pattern: "X分钟学会[技能]" or "手把手教你[做Y]"
type: 教程
date: YYYY-MM-DD
difficulty: 入门 | 进阶 | 高级
tags: []
```

### Section Structure

| # | Section | Purpose | Target chars |
|---|---------|---------|-------------|
| 0 | **开篇** (no heading) | Hook: the pain point this tutorial solves. "如果你曾经遇到过X问题…" | 150–200 |
| 1 | **你将学到什么** | Bullet list: 3–5 concrete skills/outcomes. Sets expectations. | 100–150 |
| 2 | **前提条件** | What the reader needs before starting. Keep it minimal. | 80–120 |
| 3 | **[Step 1 名称]** | First major step. Include: explanation, code/commands (if applicable), expected result. | 300–500 |
| 4 | **[Step 2 名称]** | Second step. Same format. | 300–500 |
| 5 | **[Step 3 名称]** | Third step. | 300–500 |
| 6 | **常见问题 & 坑** | 3–5 things that commonly go wrong, with fixes. "踩坑记录" format resonates well. | 200–300 |
| 7 | **总结 & 下一步** | Recap what was learned. Point to related resources or advanced topics. | 100–150 |

### Writing Notes

- Use numbered steps within each H2 section (`1. 安装依赖`, `2. 配置环境变量…`)
- Code blocks: use triple backtick with language identifier — `wenyan-cli` converts to highlighted HTML
- "踩坑" sections are very popular — readers share relatable failure experiences
- Screenshots or diagrams go between steps: `[图:X的示意图]` as placeholder, then generate/insert
- Avoid assuming the reader knows anything beyond the stated prerequisites

### Example Outline (Filled)

```
标题:5分钟在本地运行Llama 3——M1 Mac和Linux通用

开篇:
想用大模型做实验,每次API费用都让你心疼?
Llama 3 8B在本地运行,RTX 3060就够,M1 MacBook也行。这篇教程从零开始。

你将学到什么:
• 用Ollama在本地部署Llama 3 8B/70B
• 通过命令行和Open WebUI界面使用
• 对话速度基准测试方法
• 4-bit量化配置,降低显存需求

前提条件:
• macOS (Apple Silicon) 或 Linux,8GB+ 内存
• 基础命令行操作
• 无需GPU(CPU推理较慢但可用)

Step 1:安装Ollama
安装Ollama是最简单的一步...
```bash
curl -fsSL https://ollama.ai/install.sh | sh
```
…

Step 2:拉取Llama 3模型
…

常见问题 & 坑:
Q:运行时提示"out of memory"怎么办?
A:改用4-bit量化版本:`ollama pull llama3:8b-instruct-q4_0`…
```

---

## Template 4: 观点 (Opinion / Analysis)

**Purpose:** Share a clear stance on a topic, back it with reasoning, and provoke reader thinking. Highest sharing rate when done well. Always requires user review.

**Target length:** 1200–2500 characters

**Frontmatter:**
```yaml
title: ""          # Provocative or unexpected. Pattern: "[反直觉观点]" or "为什么[X]是个错误"
type: 观点
date: YYYY-MM-DD
tags: []
```

### Section Structure

| # | Section | Purpose | Target chars |
|---|---------|---------|-------------|
| 0 | **开篇** (no heading) | The bold claim or uncomfortable truth. State the thesis immediately. Don't hedge. | 150–250 |
| 1 | **为什么这是个重要问题** | Establish stakes. Why should the reader care about this question? | 200–300 |
| 2 | **主流观点是什么** | Steel-man the opposing view fairly. Shows intellectual honesty. | 200–300 |
| 3 | **但是——** | The pivot. Why the mainstream view is incomplete, wrong, or misses something. This is the core of the article. | 400–600 |
| 4 | **证据 & 案例** | Concrete evidence: data, case studies, anecdotes, expert opinions. Min 2 evidence points. | 300–400 |
| 5 | **我的结论** | Restated thesis with more nuance. What the reader should take away. | 150–200 |
| 6 | **留给你的问题** | 1–2 questions for the reader to reflect on. Drives comments. | 80–100 |

### Writing Notes

- **Never hedge in the title** — "也许"、"可能" in a title kills shareability
- The "但是——" section is where the author's true voice must shine — resist the urge to soften
- Use first person liberally in opinion pieces ("我认为", "我的判断是")
- Evidence section: cite with `据[来源]的数据` — don't just assert facts
- Closing question should feel genuinely open, not rhetorical

### Example Outline (Filled)

```
标题:停止用RAG,开始用Fine-tuning——你可能选错了方案

开篇:
过去一年,"RAG"成了AI工程师的口头禅。
几乎每一个企业AI项目,第一反应都是"先搭个RAG"。
这个直觉是错的。

为什么这是个重要问题:
RAG和Fine-tuning的选择决定了项目成本、效果上限和维护负担。
错误的选择意味着三个月后推倒重来……

主流观点是什么:
RAG支持者的逻辑清晰:无需训练、数据随时更新、成本低……
这个逻辑在很多场景下是正确的。

但是——:
RAG本质上是一个检索问题,不是一个理解问题。
如果你的核心需求是"模型用我的方式回答问题"……

证据 & 案例:
• 某金融公司的实验:RAG准确率62%,Fine-tuning后达到91%
• Anthropic 2024年报告中关于Fine-tuning适用场景的分析
…

我的结论:
RAG适合知识检索,Fine-tuning适合风格和能力塑造。
大多数企业需要的是后者,却都在做前者。

留给你的问题:
你的AI项目,核心瓶颈是"找到正确信息"还是"用正确方式表达"?
```

---

## Template 5: 科普 (Explainer / Education)

**Purpose:** Make a complex technical or conceptual topic accessible to a non-expert audience. Evergreen content. Requires user review.

**Target length:** 1500–3000 characters

**Frontmatter:**
```yaml
title: ""          # Pattern: "[复杂概念],其实没那么难" or "一文搞懂[X]"
type: 科普
date: YYYY-MM-DD
tags: []
```

### Section Structure

| # | Section | Purpose | Target chars |
|---|---------|---------|-------------|
| 0 | **开篇** (no heading) | Validate the reader's confusion: "如果你也觉得X很难懂,那这篇文章是为你写的。" | 120–180 |
| 1 | **先说最简单的版本** | Explain the concept in 2–3 sentences, as if to a friend with no background. No jargon. | 150–200 |
| 2 | **为什么它重要** | Real-world context: where does this concept appear? Why does ignoring it cost something? | 200–300 |
| 3 | **拆解核心机制** | The actual explanation, built up step by step. Use analogy as the primary teaching device. | 400–700 |
| 4 | **一个真实例子** | Walk through one complete real-world example that illustrates the concept end-to-end. | 300–400 |
| 5 | **常见误解** | 2–4 things people wrongly believe about this topic, with corrections. | 200–300 |
| 6 | **延伸阅读** | 2–3 resources for readers who want to go deeper. | 80–120 |

### Writing Notes

- **The Feynman Test**: Can you explain it in simple terms? If not, the understanding isn't there yet
- Every H2 section should be self-contained — a reader who skips section 3 can still understand section 4
- Analogies are the primary tool; use 2–3 per article. Make them concrete and from daily life
- Avoid phrases like "众所周知" — they alienate readers who don't know
- A Mermaid diagram or hand-drawn-style illustration goes very well in section 3

### Example Outline (Filled)

```
标题:向量数据库,一文搞懂

开篇:
"我们用向量数据库"——这句话在AI创业公司里被说烂了。
但如果你问"向量数据库和普通数据库有什么区别",很多人答不上来。
这篇文章用一个外卖员的例子说清楚它。

先说最简单的版本:
向量数据库是一种专门存储和搜索"相似性"的数据库。
普通数据库问:"这条记录是否存在?"
向量数据库问:"哪些记录和这条最像?"

为什么它重要:
每次你在某音搜视频、在电商搜"类似这个风格的连衣裙"……

拆解核心机制:
把每个概念想象成地图上的一个点……
[Mermaid图:二维空间中的向量相似度示意]

一个真实例子:
假设你有一个文档问答系统……

常见误解:
❌ 误解1:"向量数据库就是替换MySQL的"
✅ 实际上:它们解决不同问题,通常配合使用……
```

---

## Selecting the Right Template

When the article type is not specified by the user, infer from context:

| Signal | Suggested Type |
|--------|---------------|
| "最新发布"、"刚刚"、"今天"、"宣布" | 资讯 |
| "本周"、"周报"、"roundup"、"盘点本周" | 周报 |
| "怎么做"、"教程"、"步骤"、"手把手" | 教程 |
| "我认为"、"观点"、"为什么X是错的"、"应该" | 观点 |
| "一文搞懂"、"什么是"、"入门"、"解释" | 科普 |

When ambiguous, default to **科普** (evergreen, broadly useful) and note the assumption.

```

### references/voice-profile-schema.json

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://openclaw.dev/schemas/wechat-article-writer/voice-profile.json",
  "title": "WeChatArticleWriter Voice Profile",
  "description": "Captures an author's writing style fingerprint for consistent AI-assisted 公众号 article generation.",
  "type": "object",
  "required": ["meta", "rhythm", "structure", "rhetoric", "vocabulary", "punctuation", "tone"],
  "additionalProperties": false,

  "properties": {

    "meta": {
      "type": "object",
      "description": "Profile metadata",
      "required": ["created_at", "updated_at", "article_count", "version"],
      "properties": {
        "created_at":     { "type": "string", "format": "date-time" },
        "updated_at":     { "type": "string", "format": "date-time" },
        "article_count":  { "type": "integer", "minimum": 1, "description": "Number of articles used to build this profile" },
        "version":        { "type": "string", "description": "Schema version, e.g. '1.0'" },
        "sample_titles":  {
          "type": "array",
          "items": { "type": "string" },
          "maxItems": 10,
          "description": "Representative article titles used during training"
        }
      }
    },

    "rhythm": {
      "type": "object",
      "description": "Sentence and paragraph pacing",
      "required": ["avg_sentence_chars", "avg_paragraph_sentences", "short_sentence_ratio"],
      "properties": {
        "avg_sentence_chars": {
          "type": "number",
          "description": "Average Chinese characters per sentence (punctuation excluded)",
          "examples": [28.5]
        },
        "avg_paragraph_sentences": {
          "type": "number",
          "description": "Average sentences per paragraph",
          "examples": [3.2]
        },
        "short_sentence_ratio": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Fraction of sentences under 15 characters (punchy style indicator)",
          "examples": [0.22]
        },
        "long_sentence_ratio": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Fraction of sentences over 50 characters",
          "examples": [0.08]
        },
        "variation_score": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Sentence length variation — higher means more dynamic rhythm",
          "examples": [0.65]
        }
      }
    },

    "structure": {
      "type": "object",
      "description": "Article-level structural preferences",
      "required": ["preferred_section_count", "uses_subheadings", "opening_style", "closing_style"],
      "properties": {
        "preferred_section_count": {
          "type": "integer",
          "minimum": 2,
          "maximum": 15,
          "description": "Typical number of H2 sections per article",
          "examples": [5]
        },
        "uses_subheadings": {
          "type": "boolean",
          "description": "Does the author use H3 subheadings within sections?"
        },
        "uses_numbered_lists": {
          "type": "boolean",
          "description": "Does the author use numbered lists?"
        },
        "uses_bullet_lists": {
          "type": "boolean",
          "description": "Does the author use bullet (unordered) lists?"
        },
        "uses_summary_box": {
          "type": "boolean",
          "description": "Does the author use a blockquote/callout summary box?"
        },
        "opening_style": {
          "type": "string",
          "enum": [
            "question",
            "story",
            "data_shock",
            "scene_setting",
            "bold_claim",
            "contrast",
            "anecdote"
          ],
          "description": "Dominant opening hook pattern across analyzed articles"
        },
        "closing_style": {
          "type": "string",
          "enum": [
            "call_to_action",
            "open_question",
            "summary",
            "personal_reflection",
            "forward_looking",
            "quote"
          ],
          "description": "Dominant closing pattern"
        },
        "avg_word_count": {
          "type": "integer",
          "description": "Typical article length in Chinese characters",
          "examples": [1800]
        }
      }
    },

    "rhetoric": {
      "type": "object",
      "description": "Rhetorical devices and writing techniques used",
      "required": ["dominant_devices"],
      "properties": {
        "dominant_devices": {
          "type": "array",
          "description": "Top rhetorical devices, ordered by frequency",
          "items": {
            "type": "string",
            "enum": [
              "analogy",
              "metaphor",
              "data_cite",
              "expert_quote",
              "rhetorical_question",
              "contrast",
              "enumeration",
              "storytelling",
              "personal_anecdote",
              "hypothetical"
            ]
          },
          "minItems": 1,
          "maxItems": 5
        },
        "uses_technical_deep_dives": {
          "type": "boolean",
          "description": "Does the author regularly include technical details, code, or specs?"
        },
        "simplification_style": {
          "type": "string",
          "enum": ["analogy_heavy", "step_by_step", "compare_to_known", "visual_description", "minimal"],
          "description": "How the author explains complex concepts to a general audience"
        },
        "self_reference_frequency": {
          "type": "string",
          "enum": ["never", "rare", "occasional", "frequent"],
          "description": "How often the author writes in first person or shares personal experience"
        }
      }
    },

    "vocabulary": {
      "type": "object",
      "description": "Word and terminology preferences",
      "required": ["formality_level", "english_loanword_style"],
      "properties": {
        "formality_level": {
          "type": "string",
          "enum": ["casual", "semi_formal", "formal", "academic"],
          "description": "Overall register of the writing"
        },
        "english_loanword_style": {
          "type": "string",
          "enum": [
            "avoid",
            "parenthetical",
            "mixed_freely",
            "full_english_terms"
          ],
          "description": "How to handle English technical terms — e.g. 'AI' vs '人工智能' vs '人工智能(AI)'"
        },
        "signature_phrases": {
          "type": "array",
          "description": "Phrases the author uses characteristically — include these occasionally",
          "items": { "type": "string" },
          "maxItems": 20,
          "examples": [["说到底", "换句话说", "值得注意的是", "这意味着"]]
        },
        "avoid_phrases": {
          "type": "array",
          "description": "Phrases to avoid (clichés the author never uses)",
          "items": { "type": "string" },
          "maxItems": 20,
          "examples": [["总而言之", "综上所述", "毋庸置疑"]]
        },
        "domain_focus": {
          "type": "array",
          "description": "Primary subject domains (informs vocabulary choices)",
          "items": {
            "type": "string",
            "enum": [
              "technology",
              "ai_ml",
              "product",
              "entrepreneurship",
              "design",
              "science",
              "finance",
              "culture",
              "lifestyle",
              "general"
            ]
          }
        }
      }
    },

    "punctuation": {
      "type": "object",
      "description": "Chinese punctuation and typographic preferences",
      "required": ["prefers_chinese_comma", "uses_em_dash", "uses_ellipsis"],
      "properties": {
        "prefers_chinese_comma": {
          "type": "boolean",
          "description": "Prefers 顿号(、)over comma for list separation"
        },
        "uses_em_dash": {
          "type": "boolean",
          "description": "Uses 破折号(——)for parenthetical asides"
        },
        "uses_ellipsis": {
          "type": "boolean",
          "description": "Uses 省略号(……)for trailing effect"
        },
        "book_title_marks": {
          "type": "boolean",
          "description": "Uses 书名号(《》)for product/article names"
        },
        "emoji_usage": {
          "type": "string",
          "enum": ["none", "section_headers_only", "occasional", "frequent"],
          "description": "How often emoji appear in the article body and headers"
        },
        "emoji_style": {
          "type": "array",
          "description": "Characteristic emoji used by this author",
          "items": { "type": "string" },
          "maxItems": 15,
          "examples": [["🔥", "💡", "📌", "🚀", "✅"]]
        }
      }
    },

    "tone": {
      "type": "object",
      "description": "Overall voice and attitude of the writing",
      "required": ["primary_tone", "reader_relationship"],
      "properties": {
        "primary_tone": {
          "type": "string",
          "enum": [
            "authoritative",
            "conversational",
            "enthusiastic",
            "analytical",
            "skeptical",
            "encouraging",
            "neutral_informative"
          ],
          "description": "The dominant emotional register of the writing"
        },
        "secondary_tone": {
          "type": "string",
          "enum": [
            "authoritative",
            "conversational",
            "enthusiastic",
            "analytical",
            "skeptical",
            "encouraging",
            "neutral_informative",
            "none"
          ]
        },
        "reader_relationship": {
          "type": "string",
          "enum": ["mentor_student", "peer_to_peer", "expert_to_layperson", "friend_to_friend"],
          "description": "How the author positions themselves relative to the reader"
        },
        "humor_level": {
          "type": "string",
          "enum": ["none", "occasional_wit", "light_humor", "playful"],
          "description": "How much humor appears in the writing"
        },
        "opinion_strength": {
          "type": "string",
          "enum": ["factual_only", "mild_opinion", "clear_stance", "provocative"],
          "description": "How strongly the author asserts their own views"
        }
      }
    },

    "writing_prompt_injection": {
      "type": "string",
      "description": "Optional freeform instruction appended to the writing prompt verbatim. Use for nuances not captured by the schema. Max 500 characters.",
      "maxLength": 500,
      "examples": [
        "作者喜欢用具体数字而不是模糊表述,总是在引出话题后先给读者一个「为什么这很重要」的背景,结尾常提出一个开放性问题让读者思考。"
      ]
    }
  },

  "examples": [
    {
      "meta": {
        "created_at": "2024-02-17T12:00:00Z",
        "updated_at": "2024-02-17T12:00:00Z",
        "article_count": 12,
        "version": "1.0",
        "sample_titles": ["2024年AI工具全景图", "为什么程序员应该学习写作", "Rust让我重新思考内存管理"]
      },
      "rhythm": {
        "avg_sentence_chars": 24.3,
        "avg_paragraph_sentences": 3.1,
        "short_sentence_ratio": 0.28,
        "long_sentence_ratio": 0.06,
        "variation_score": 0.71
      },
      "structure": {
        "preferred_section_count": 5,
        "uses_subheadings": false,
        "uses_numbered_lists": true,
        "uses_bullet_lists": false,
        "uses_summary_box": true,
        "opening_style": "bold_claim",
        "closing_style": "open_question",
        "avg_word_count": 2100
      },
      "rhetoric": {
        "dominant_devices": ["analogy", "data_cite", "rhetorical_question"],
        "uses_technical_deep_dives": true,
        "simplification_style": "analogy_heavy",
        "self_reference_frequency": "occasional"
      },
      "vocabulary": {
        "formality_level": "semi_formal",
        "english_loanword_style": "parenthetical",
        "signature_phrases": ["换句话说", "值得注意的是", "说到底"],
        "avoid_phrases": ["总而言之", "综上所述"],
        "domain_focus": ["technology", "ai_ml"]
      },
      "punctuation": {
        "prefers_chinese_comma": false,
        "uses_em_dash": true,
        "uses_ellipsis": false,
        "book_title_marks": true,
        "emoji_usage": "section_headers_only",
        "emoji_style": ["💡", "🔥", "✅", "📌"]
      },
      "tone": {
        "primary_tone": "analytical",
        "secondary_tone": "conversational",
        "reader_relationship": "peer_to_peer",
        "humor_level": "occasional_wit",
        "opinion_strength": "clear_stance"
      },
      "writing_prompt_injection": "作者喜欢在文章开头用一个反直觉的事实抓住读者,中段用类比降低阅读门槛,结尾留开放性问题。避免过于学术的表述。"
    }
  ]
}

```

### references/default-voice-profile.json

```json
{
  "meta": {
    "created_at": "2026-02-17T00:00:00Z",
    "updated_at": "2026-02-17T00:00:00Z",
    "article_count": 1,
    "version": "1.0",
    "sample_titles": []
  },
  "rhythm": {
    "avg_sentence_chars": 25,
    "avg_paragraph_sentences": 3,
    "short_sentence_ratio": 0.2,
    "long_sentence_ratio": 0.1,
    "variation_score": 0.6
  },
  "structure": {
    "preferred_section_count": 5,
    "uses_subheadings": false,
    "uses_numbered_lists": true,
    "uses_bullet_lists": true,
    "uses_summary_box": false,
    "opening_style": "question",
    "closing_style": "summary",
    "avg_word_count": 1800
  },
  "rhetoric": {
    "dominant_devices": [
      "analogy",
      "data_cite",
      "rhetorical_question"
    ],
    "uses_technical_deep_dives": false,
    "simplification_style": "analogy_heavy",
    "self_reference_frequency": "occasional"
  },
  "vocabulary": {
    "formality_level": "semi_formal",
    "english_loanword_style": "parenthetical",
    "signature_phrases": [],
    "avoid_phrases": [
      "总而言之",
      "综上所述",
      "毋庸置疑"
    ],
    "domain_focus": [
      "general"
    ]
  },
  "punctuation": {
    "prefers_chinese_comma": false,
    "uses_em_dash": false,
    "uses_ellipsis": false,
    "book_title_marks": true,
    "emoji_usage": "occasional",
    "emoji_style": [
      "💡",
      "✅",
      "📌"
    ]
  },
  "tone": {
    "primary_tone": "neutral_informative",
    "secondary_tone": "conversational",
    "reader_relationship": "peer_to_peer",
    "humor_level": "occasional_wit",
    "opinion_strength": "mild_opinion"
  },
  "writing_prompt_injection": "写作风格应平实易懂,面向普通读者。开篇提出核心问题,中段用具体例子说明,结尾给出实用建议或行动指引。语气亲切,不过于学术。避免套话开头,如「随着X的发展」或「本文将介绍」。"
}

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### README.md

```markdown
# wechat-article-writer

> 从选题到发布的公众号一体化写作工作流

An OpenClaw skill that automates the full lifecycle of a WeChat Official Account (微信公众号) article: topic research, multi-agent writing with quality gates, scrapbook-style illustrations, formatting, and publishing to the draft box.

**v2.4.0** · Three publishing paths: Official Account API (recommended), browser tool, or direct CDP · Bundled baoyu renderer (no external dependencies)

---

## What It Does

```
forge write 关于AI编程工具的深度评测
```

That single command runs a **9-step pipeline**:

1. **Research + Prep** — topic angles, verified `sources.json` bank, voice profile, outline
2. **Write** via Writer subagent (Chinese-first, constrained to source bank)
3. **Review** via Reviewer subagent (blind 8-dimension craft scoring)
4. **Revise** — 4a: up to 2 automated cycles; 4b: human-in-the-loop if needed
5. **Fact-check** via Fact-Checker subagent (verify every claim, generate reference list)
6. **Format** to WeChat HTML via bundled baoyu renderer (`scripts/format.sh`, default classic theme)
7. **Preview** on persistent HTTP server at port 8898 for human approval
8. **Illustrate + Embed** — scrapbook images via article-illustrator, upload to WeChat CDN
9. **Publish** to WeChat draft box — via API (preferred) or browser automation (fallback)

---

## Architecture

```
Orchestrator (Main Agent) — routes, tracks, enforces gates
    ├── Writer Subagent — drafts + revises (Opus model)
    ├── Reviewer Subagent — blind craft scoring (Sonnet model)
    ├── Fact-Checker Subagent — verifies claims via web search (Sonnet model)
    └── article-illustrator — scrapbook images (after text passes)
```

- **Writer never self-reviews.** Constrained to verified source bank — must mark `[UNVERIFIED]` for anything outside it.
- **Reviewer is blind.** Never sees outline or brief — judges craft as a reader would.
- **Fact-Checker is independent.** Verifies every claim via web search, generates reference list.
- **Illustrations are gated.** No images until text is human-approved (~$0.06/article via Z.AI).

---

## Setup

```bash
bash <skill-dir>/scripts/setup.sh <workspace-dir>
```

Installs: bun runtime, bundled baoyu renderer deps, persistent preview server (`wechat-preview.service`, port 8898, auto-restart), writes `config.json`, appends rules to `AGENTS.md`.

### API Keys

| Key | Purpose | Required? |
|-----|---------|-----------|
| `ZAI_API_KEY` | Image generation via Z.AI ($0.015/image, 97.9% Chinese accuracy) | Preferred |
| `GLM_API_KEY` | Image generation via BigModel China | Alternative |
| `OPENROUTER_API_KEY` | Image generation fallback | Alternative |

At least one image key is required for illustrations.

### Publishing Credentials

**Path C (API — recommended):** Store appid + appsecret in a credentials file (default: `~/.wechat-article-writer/secrets.json`):
```json
{ "appid": "wx...", "appsecret": "..." }
```
No browser needed. Works for unverified subscription accounts.

**Path A/B (browser fallback):** If no API credentials, the skill automates `mp.weixin.qq.com` via Chrome CDP. See `references/browser-automation.md`.

---

## Commands

| Command | Description |
|---------|-------------|
| `forge topic <subject>` | Research + propose 3 topic angles |
| `forge write <subject>` | Full pipeline: research → write → review → illustrate → publish |
| `forge draft <subject>` | Write + format only, stop before illustrations |
| `forge publish <draft-id>` | Push existing draft to WeChat |
| `forge preview <draft-id>` | Format check + preview |
| `forge voice train` | Build voice profile from past articles |
| `forge status` | Show all drafts and their pipeline status |

---

## Publishing (Step 9)

The skill checks for publishing credentials in order and uses the first available method:

### Path C — WeChat Official Account API (recommended)

```bash
python3 scripts/publish_via_api.py \
  --draft-dir ~/.wechat-article-writer/drafts/<slug> \
  --title "草稿标题(≤18字)" \
  --author "作者" \
  --source-url "https://..."
```

**⚠️ Undocumented WeChat API field limits** (confirmed empirically, February 2026):

| Field | Documented | Actual Limit |
|-------|-----------|--------------|
| `title` | 64 chars | **≤18 chars** (36 bytes) |
| `author` | 8 chars | **≤8 bytes** (= 2 Chinese chars) |
| `digest` | 120 chars | **~28 Chinese chars** |
| `content` | 20,000 bytes | **~18KB UTF-8** |

> **Title workaround:** The API title is only for draft box listing. Edit it to the full version in the WeChat editor UI before publishing — the UI has no 18-char limit.

> **Chinese encoding:** Always use `data=json.dumps(..., ensure_ascii=False).encode("utf-8")` — never `requests(..., json=payload)` which escapes Chinese as `\uXXXX`.

### Path A — OpenClaw Browser Tool (macOS)

Uses the `browser` tool to drive `mp.weixin.qq.com` via Playwright. Injects via base64 chunking + clipboard paste. See `references/browser-automation.md` → Path A.

### Path B — Direct CDP WebSocket (Linux)

Connects to Chrome CDP directly from Python. Requires `--remote-debugging-port=18800 --remote-allow-origins='*'`. See `references/browser-automation.md` → Path B.

---

## Quality Gates

### Reviewer Scoring (8 dimensions)

| Dimension | Weight | What it measures |
|-----------|--------|-----------------|
| Insight Density (洞察密度) | 20% | Non-obvious ideas per section |
| Originality (新鲜感) | 15% | Genuinely new framing |
| Emotional Resonance (情感共鸣) | 15% | Earned emotional arc |
| Completion Power (完读力) | 15% | Every section earns the next scroll |
| Voice (语感) | 10% | Natural Chinese, sounds like a person |
| Evidence (论据) | 10% | Named researchers, institutions, venues |
| Content Timeliness (内容时效性) | 10% | Argument rests on principles, not news |
| Title (标题) | 5% | Clear, specific, ≤26 chars |

**Pass:** weighted_total ≥ 9.0, no dimension below 7, Originality ≥ 8.

**Hard blockers (instant FAIL):** 教材腔, 翻译腔, 鸡汤腔, 灌水, 模板化, 标题党.

---

## Formatting (Step 6)

`scripts/format.sh` renders markdown to WeChat-compatible HTML using the bundled baoyu renderer:

```bash
bash scripts/format.sh <draft-dir> <draft-file> [theme]
# Themes: default (recommended), grace, simple
```

The preview is served by a persistent systemd service (`wechat-preview.service`) on port 8898 — no manual server restarts needed.

---

## Illustrations

Uses **article-illustrator** skill's scrapbook pipeline:
- Style: hand-crafted mixed-media collages — torn paper, washi tape, cork boards, hand-drawn markers
- Cost: ~$0.06/article (4 images × $0.015 via Z.AI)
- Z.AI CDN note: URL may return 404 immediately after generation — script retries with 4s delay (up to 5 attempts)

---

## Configuration

`~/.wechat-article-writer/config.json`:

```json
{
  "default_article_type": "教程",
  "chrome_debug_port": 18800,
  "wechat_author": "你的公众号名称",
  "wechat_secrets_path": "~/.wechat-article-writer/secrets.json",
  "word_count_targets": {
    "资讯": [800, 1500],
    "教程": [1500, 3000],
    "观点": [1200, 2500],
    "科普": [1500, 3000]
  }
}
```

---

## Data Layout

```
~/.wechat-article-writer/
├── config.json
├── voice-profile.json
├── preview_server.py          # Persistent preview server (systemd)
└── drafts/
    └── <slug-YYYYMMDD>/
        ├── pipeline-state.json
        ├── outline.md
        ├── sources.json
        ├── draft.md / draft-v2.md / ...
        ├── review-v1.json / ...
        ├── fact-check.json
        ├── formatted.html
        └── images/
            ├── illustration-plan.json
            └── img*.png
```

---

## Scripts

| Script | Description |
|--------|-------------|
| `scripts/setup.sh` | One-click setup: bun, renderer deps, systemd preview service |
| `scripts/format.sh` | Markdown → WeChat HTML (baoyu renderer, default theme) |
| `scripts/publish_via_api.py` | API-based publisher (Path C) |
| `scripts/renderer/` | Bundled baoyu-markdown-to-html renderer |

---

## References

| File | When to load |
|------|-------------|
| `references/writer-prompt.md` | Step 2 (writing) and Step 4 (revision) |
| `references/reviewer-rubric.md` | Step 3 — full 8-dimension scoring criteria |
| `references/fact-checker-prompt.md` | Step 5 — claim verification protocol |
| `references/browser-automation.md` | Step 9 — all three publishing paths |
| `references/pipeline-state.md` | On resume — state machine schema |
| `references/wechat-html-rules.md` | Step 6 — what HTML/CSS works in WeChat |
| `references/LESSONS_LEARNED.md` | Hard-won lessons from production publishing sessions |

---

## License

MIT

```

### _meta.json

```json
{
  "owner": "chunhualiao",
  "slug": "wechat-article-forge",
  "displayName": "WeChat Article Writer",
  "latest": {
    "version": "2.4.1",
    "publishedAt": 1772424240526,
    "commit": "https://github.com/openclaw/skills/commit/c313e438c10944bf33753e0751a6baea88e2e1ca"
  },
  "history": [
    {
      "version": "2.3.3",
      "publishedAt": 1772343737473,
      "commit": "https://github.com/openclaw/skills/commit/ff0f07d492de6eaeb3f5c1ec4103aff0719fd6aa"
    }
  ]
}

```

### scripts/publish_via_api.py

```python
#!/usr/bin/env python3
"""
WeChat Official Account API publisher (Path C).

Uploads images to WeChat CDN, then creates a draft article via the draft/add API.
Works for both verified and unverified subscription accounts.

Usage:
    python3 publish_via_api.py \
        --draft-dir ~/.wechat-article-writer/drafts/<slug> \
        --title "草稿标题(<=18字)" \
        --author "作者" \
        --source-url "https://github.com/..."

Requirements:
    Credentials file at path configured by `wechat_secrets_path` in config.json
    Default: ~/.wechat-article-writer/secrets.json -- {"appid": "wx...", "appsecret": "..."}
    pip install requests

KNOWN FIELD LIMITS (empirically confirmed, unverified accounts, 2026-02-28):
    title   <= 18 Chinese chars (36 bytes)
    author  <= 8 bytes  (2 Chinese chars or ~4 ASCII chars)
    digest  ~  28 Chinese chars
    content ~  18KB UTF-8 bytes
"""

import argparse, json, os, re, sys, time
import requests

def _get_secrets_path(data_dir=None):
    """Read wechat_secrets_path from config.json, fall back to default."""
    if data_dir is None:
        data_dir = os.path.expanduser("~/.wechat-article-writer")
    config_file = os.path.join(data_dir, "config.json")
    if os.path.exists(config_file):
        cfg = json.load(open(config_file))
        if "wechat_secrets_path" in cfg:
            return os.path.expanduser(cfg["wechat_secrets_path"])
    return os.path.expanduser("~/.wechat-article-writer/secrets.json")

SECRETS_PATH = _get_secrets_path()


def load_credentials():
    if not os.path.exists(SECRETS_PATH):
        print(f"ERROR: {SECRETS_PATH} not found", file=sys.stderr)
        sys.exit(1)
    return json.load(open(SECRETS_PATH))


def get_access_token(creds):
    resp = requests.get(
        "https://api.weixin.qq.com/cgi-bin/token",
        params={"grant_type": "client_credential", "appid": creds["appid"], "secret": creds["appsecret"]},
        timeout=30,
    )
    data = resp.json()
    if "access_token" not in data:
        print(f"ERROR: {data}", file=sys.stderr)
        sys.exit(1)
    return data["access_token"]


def upload_image_cdn(token, img_path, retries=5, delay=4):
    """Upload image, return temporary WeChat CDN URL for use in <img src="">."""
    for attempt in range(retries):
        with open(img_path, "rb") as f:
            resp = requests.post(
                "https://api.weixin.qq.com/cgi-bin/media/uploadimg",
                params={"access_token": token},
                files={"media": (os.path.basename(img_path), f, "image/png")},
                timeout=60,
            )
        data = resp.json()
        if "url" in data:
            return data["url"]
        print(f"  Attempt {attempt+1}: {data}", file=sys.stderr)
        time.sleep(delay)
    raise RuntimeError(f"Upload failed: {img_path}")


def upload_cover_permanent(token, img_path):
    """Upload cover as permanent material, return thumb_media_id."""
    with open(img_path, "rb") as f:
        resp = requests.post(
            "https://api.weixin.qq.com/cgi-bin/material/add_material",
            params={"access_token": token, "type": "image"},
            files={"media": (os.path.basename(img_path), f, "image/png")},
            timeout=60,
        )
    data = resp.json()
    if "media_id" not in data:
        raise RuntimeError(f"Cover upload failed: {data}")
    return data["media_id"]


def clean_html(raw):
    """Strip WeChat-incompatible wrappers and junk from formatted.html."""
    body_match = re.search(r'<body[^>]*>(.*)</body>', raw, re.DOTALL | re.IGNORECASE)
    content = body_match.group(1).strip() if body_match else raw
    content = re.sub(r'<meta[^>]+>', '', content)
    content = re.sub(r'<div>Preview build:[^<]+</div>', '', content)
    content = re.sub(r' style="[^"]{100,}"', '', content)
    content = re.sub(r'</?section[^>]*>', '', content)
    content = re.sub(r'\n\s*\n', '\n', content).strip()
    return content


def create_draft(token, title, author, digest, content, source_url, thumb_media_id):
    """
    Create draft via WeChat API.
    CRITICAL: Uses ensure_ascii=False -- requests json= parameter escapes Chinese as \\uXXXX.
    """
    payload = {
        "articles": [{
            "title": title,
            "author": author,
            "digest": digest,
            "content": content,
            "content_source_url": source_url,
            "thumb_media_id": thumb_media_id,
            "need_open_comment": 1,
            "only_fans_can_comment": 0,
        }]
    }
    # MUST use ensure_ascii=False to preserve Chinese characters
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    resp = requests.post(
        "https://api.weixin.qq.com/cgi-bin/draft/add",
        params={"access_token": token},
        data=body,
        headers={"Content-Type": "application/json; charset=utf-8"},
        timeout=60,
    )
    return resp.json()


def main():
    parser = argparse.ArgumentParser(description="WeChat draft publisher via API (Path C)")
    parser.add_argument("--draft-dir", required=True)
    parser.add_argument("--title", required=True, help="<= 18 Chinese chars")
    parser.add_argument("--author", default="", help="<= 2 Chinese chars or 4 ASCII")
    parser.add_argument("--digest", default="", help="~28 Chinese chars")
    parser.add_argument("--source-url", default="")
    parser.add_argument("--html-file", default="formatted.html")
    parser.add_argument("--cover", default="images/img1.png")
    args = parser.parse_args()

    draft_dir = os.path.expanduser(args.draft_dir)

    # Warn on limit violations
    if len(args.title) > 18:
        print(f"WARNING: title has {len(args.title)} chars; actual limit is 18. Will likely fail.", file=sys.stderr)
    if len(args.author.encode("utf-8")) > 8:
        print(f"WARNING: author exceeds 8 bytes; use <= 2 Chinese chars or <= 4 ASCII.", file=sys.stderr)

    creds = load_credentials()
    token = get_access_token(creds)
    print(f"Access token obtained.")

    cover_path = os.path.join(draft_dir, args.cover)
    print(f"Uploading cover...")
    thumb_media_id = upload_cover_permanent(token, cover_path)
    print(f"Cover media_id: {thumb_media_id[:40]}...")

    img_dir = os.path.join(draft_dir, "images")
    img_url_map = {}
    if os.path.exists(img_dir):
        pngs = sorted(f for f in os.listdir(img_dir) if f.endswith(".png"))
        print(f"Uploading {len(pngs)} images to WeChat CDN...")
        for fname in pngs:
            img_path = os.path.join(img_dir, fname)
            wx_url = upload_image_cdn(token, img_path)
            img_url_map[fname] = wx_url
            print(f"  {fname} -> {wx_url[:60]}...")
    with open(os.path.join(draft_dir, "wechat_image_urls.json"), "w") as f:
        json.dump(img_url_map, f, indent=2)

    html_path = os.path.join(draft_dir, args.html_file)
    with open(html_path) as f:
        raw = f.read()
    content = clean_html(raw)

    # Replace local img paths with WeChat CDN URLs
    for fname, wx_url in img_url_map.items():
        content = content.replace(f'src="images/{fname}"', f'src="{wx_url}"')
        content = content.replace(f'src="{fname}"', f'src="{wx_url}"')

    content_bytes = len(content.encode("utf-8"))
    print(f"Content: {len(content)} chars, {content_bytes} bytes ({content_bytes//1024}KB)")
    if content_bytes > 18000:
        print(f"WARNING: Content {content_bytes}b is close to ~18KB limit. Strip more styles if it fails.", file=sys.stderr)

    print("Creating draft...")
    result = create_draft(token, args.title, args.author, args.digest, content, args.source_url, thumb_media_id)

    if "media_id" in result:
        print(f"\nDraft created successfully!")
        print(f"  media_id: {result['media_id']}")
        print(f"  Title: {args.title}")
        print(f"  Open WeChat MP -> Drafts (草稿箱) to review and edit before publishing")
        with open(os.path.join(draft_dir, "wechat_draft_id.json"), "w") as f:
            json.dump({"media_id": result["media_id"], "title": args.title}, f, ensure_ascii=False, indent=2)
    else:
        errcode = result.get("errcode")
        errmsg = result.get("errmsg", "")
        print(f"\nDraft creation failed: {errcode} -- {errmsg}", file=sys.stderr)
        print("Error code guide (codes are often misleading -- test fields individually):", file=sys.stderr)
        print("  45003: title or content too large", file=sys.stderr)
        print("  45004: digest too large", file=sys.stderr)
        print("  45110: author too large (or content too large)", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

```

### scripts/renderer/main.ts

```typescript
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import https from 'node:https';
import http from 'node:http';
import process from 'node:process';
import type { StyleConfig, HtmlDocumentMeta } from './md/types.js';
import { DEFAULT_STYLE, THEME_STYLE_DEFAULTS } from './md/constants.js';
import { loadThemeCss, normalizeThemeCss } from './md/themes.js';
import { initRenderer, renderMarkdown, postProcessHtml } from './md/renderer.js';
import {
  buildCss, loadCodeThemeCss, buildHtmlDocument,
  inlineCss, normalizeInlineCss, modifyHtmlStructure, removeFirstHeading,
} from './md/html-builder.js';

interface ImageInfo {
  placeholder: string;
  localPath: string;
  originalPath: string;
}

interface ParsedResult {
  title: string;
  author: string;
  summary: string;
  htmlPath: string;
  backupPath?: string;
  contentImages: ImageInfo[];
}

function formatTimestamp(date = new Date()): string {
  const pad = (v: number) => String(v).padStart(2, '0');
  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}

function downloadFile(url: string, destPath: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const protocol = url.startsWith('https') ? https : http;
    const file = fs.createWriteStream(destPath);

    const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
      if (response.statusCode === 301 || response.statusCode === 302) {
        const redirectUrl = response.headers.location;
        if (redirectUrl) {
          file.close();
          fs.unlinkSync(destPath);
          downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
          return;
        }
      }

      if (response.statusCode !== 200) {
        file.close();
        fs.unlinkSync(destPath);
        reject(new Error(`Failed to download: ${response.statusCode}`));
        return;
      }

      response.pipe(file);
      file.on('finish', () => {
        file.close();
        resolve();
      });
    });

    request.on('error', (err) => {
      file.close();
      fs.unlink(destPath, () => {});
      reject(err);
    });

    request.setTimeout(30000, () => {
      request.destroy();
      reject(new Error('Download timeout'));
    });
  });
}

function getImageExtension(urlOrPath: string): string {
  const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
  return match ? match[1]!.toLowerCase() : 'png';
}

async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
  if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
    const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
    const ext = getImageExtension(imagePath);
    const localPath = path.join(tempDir, `remote_${hash}.${ext}`);

    if (!fs.existsSync(localPath)) {
      console.error(`[markdown-to-html] Downloading: ${imagePath}`);
      await downloadFile(imagePath, localPath);
    }
    return localPath;
  }

  if (path.isAbsolute(imagePath)) {
    return imagePath;
  }

  return path.resolve(baseDir, imagePath);
}

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]! };
}

export async function convertMarkdown(markdownPath: string, options?: { title?: string; theme?: string; keepTitle?: boolean }): Promise<ParsedResult> {
  const baseDir = path.dirname(markdownPath);
  const content = fs.readFileSync(markdownPath, 'utf-8');
  const theme = options?.theme ?? 'default';
  const keepTitle = options?.keepTitle ?? false;

  const { frontmatter, body } = parseFrontmatter(content);

  const stripQuotes = (s?: string): string => {
    if (!s) return '';
    if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
      return s.slice(1, -1);
    }
    if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
      return s.slice(1, -1);
    }
    return s;
  };

  let title = options?.title ?? stripQuotes(frontmatter.title) ?? '';
  if (!title) {
    const lines = body.split('\n');
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/);
      if (headingMatch) title = headingMatch[1]!;
      break;
    }
  }
  if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
  const author = stripQuotes(frontmatter.author);
  let summary = stripQuotes(frontmatter.description) || stripQuotes(frontmatter.summary);

  if (!summary) {
    const lines = body.split('\n');
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      if (trimmed.startsWith('#')) continue;
      if (trimmed.startsWith('![')) continue;
      if (trimmed.startsWith('>')) continue;
      if (trimmed.startsWith('-') || trimmed.startsWith('*')) continue;
      if (/^\d+\./.test(trimmed)) continue;

      const cleanText = trimmed
        .replace(/\*\*(.+?)\*\*/g, '$1')
        .replace(/\*(.+?)\*/g, '$1')
        .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
        .replace(/`([^`]+)`/g, '$1');

      if (cleanText.length > 20) {
        summary = cleanText.length > 120 ? cleanText.slice(0, 117) + '...' : cleanText;
        break;
      }
    }
  }

  const images: Array<{ src: string; placeholder: string }> = [];
  let imageCounter = 0;

  const modifiedBody = body.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
    const placeholder = `MDTOHTMLIMGPH_${++imageCounter}`;
    images.push({ src, placeholder });
    return placeholder;
  });

  const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;

  console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}`);

  const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
  const style: StyleConfig = { ...DEFAULT_STYLE, ...themeDefaults };
  const { baseCss, themeCss } = loadThemeCss(theme);
  const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
  const codeThemeCss = loadCodeThemeCss('github');

  const renderer = initRenderer({});
  const { html: baseHtml, readingTime } = renderMarkdown(modifiedMarkdown, renderer);
  let htmlContent = postProcessHtml(baseHtml, readingTime, renderer);
  if (!keepTitle) htmlContent = removeFirstHeading(htmlContent);

  const meta: HtmlDocumentMeta = { title, author, description: summary };
  const fullHtml = buildHtmlDocument(meta, css, htmlContent, codeThemeCss);
  const inlinedHtml = normalizeInlineCss(await inlineCss(fullHtml), style);
  const renderedHtml = modifyHtmlStructure(inlinedHtml);

  const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
  let backupPath: string | undefined;

  if (fs.existsSync(finalHtmlPath)) {
    backupPath = `${finalHtmlPath}.bak-${formatTimestamp()}`;
    console.error(`[markdown-to-html] Backing up existing file to: ${backupPath}`);
    fs.renameSync(finalHtmlPath, backupPath);
  }

  fs.writeFileSync(finalHtmlPath, renderedHtml, 'utf-8');

  const contentImages: ImageInfo[] = [];
  let tempDir: string | undefined;
  for (const img of images) {
    if (!tempDir && (img.src.startsWith('http://') || img.src.startsWith('https://'))) {
      const os = await import('node:os');
      tempDir = fs.mkdtempSync(path.join(os.default.tmpdir(), 'markdown-to-html-'));
    }
    const localPath = await resolveImagePath(img.src, baseDir, tempDir ?? baseDir);
    contentImages.push({
      placeholder: img.placeholder,
      localPath,
      originalPath: img.src,
    });
  }

  let finalContent = fs.readFileSync(finalHtmlPath, 'utf-8');
  for (const img of contentImages) {
    const imgTag = `<img src="${img.originalPath}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
    finalContent = finalContent.replace(img.placeholder, imgTag);
  }
  fs.writeFileSync(finalHtmlPath, finalContent, 'utf-8');

  console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);

  return {
    title,
    author,
    summary,
    htmlPath: finalHtmlPath,
    backupPath,
    contentImages,
  };
}

function printUsage(): never {
  console.log(`Convert Markdown to styled HTML

Usage:
  npx -y bun main.ts <markdown_file> [options]

Options:
  --title <title>     Override title
  --theme <name>      Theme name (default, grace, simple). Default: default
  --keep-title        Keep the first heading in content. Default: false (removed)
  --help              Show this help

Output:
  HTML file saved to same directory as input markdown file.
  Example: article.md -> article.html

  If HTML file already exists, it will be backed up first:
  article.html -> article.html.bak-YYYYMMDDHHMMSS

Output JSON format:
{
  "title": "Article Title",
  "htmlPath": "/path/to/article.html",
  "backupPath": "/path/to/article.html.bak-20260128180000",
  "contentImages": [...]
}

Example:
  npx -y bun main.ts article.md
  npx -y bun main.ts article.md --theme grace
`);
  process.exit(0);
}

async function main(): Promise<void> {
  const args = process.argv.slice(2);
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
    printUsage();
  }

  let markdownPath: string | undefined;
  let title: string | undefined;
  let theme: string | undefined;
  let keepTitle = false;

  for (let i = 0; i < args.length; i++) {
    const arg = args[i]!;
    if (arg === '--title' && args[i + 1]) {
      title = args[++i];
    } else if (arg === '--theme' && args[i + 1]) {
      theme = args[++i];
    } else if (arg === '--keep-title') {
      keepTitle = true;
    } else if (!arg.startsWith('-')) {
      markdownPath = arg;
    }
  }

  if (!markdownPath) {
    console.error('Error: Markdown file path is required');
    process.exit(1);
  }

  if (!fs.existsSync(markdownPath)) {
    console.error(`Error: File not found: ${markdownPath}`);
    process.exit(1);
  }

  const result = await convertMarkdown(markdownPath, { title, theme, keepTitle });
  console.log(JSON.stringify(result, null, 2));
}

await main().catch((err) => {
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
  process.exit(1);
});

```

### scripts/renderer/md/cli.ts

```typescript
import type { CliOptions, ThemeName } from "./types.js";
import {
  FONT_FAMILY_MAP,
  FONT_SIZE_OPTIONS,
  COLOR_PRESETS,
  CODE_BLOCK_THEMES,
} from "./constants.js";
import { THEME_NAMES } from "./themes.js";
import { loadExtendConfig } from "./extend-config.js";

export function printUsage(): void {
  console.error(
    [
      "Usage:",
      "  npx tsx src/md/render.ts <markdown_file> [options]",
      "",
      "Options:",
      `  --theme <name>        Theme (${THEME_NAMES.join(", ")})`,
      `  --color <name|hex>    Primary color: ${Object.keys(COLOR_PRESETS).join(", ")}, or hex`,
      `  --font-family <name>  Font: ${Object.keys(FONT_FAMILY_MAP).join(", ")}, or CSS value`,
      `  --font-size <N>       Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)`,
      `  --code-theme <name>   Code highlight theme (default: github)`,
      `  --mac-code-block      Show Mac-style code block header`,
      `  --line-number         Show line numbers in code blocks`,
      `  --cite                Enable footnote citations`,
      `  --count               Show reading time / word count`,
      `  --legend <value>      Image caption: title-alt, alt-title, title, alt, none`,
      `  --keep-title          Keep the first heading in output`,
    ].join("\n")
  );
}

function parseArgValue(argv: string[], i: number, flag: string): string | null {
  const arg = argv[i]!;
  if (arg.includes("=")) {
    return arg.slice(flag.length + 1);
  }
  const next = argv[i + 1];
  return next ?? null;
}

function resolveFontFamily(value: string): string {
  return FONT_FAMILY_MAP[value] ?? value;
}

function resolveColor(value: string): string {
  return COLOR_PRESETS[value] ?? value;
}

export function parseArgs(argv: string[]): CliOptions | null {
  const ext = loadExtendConfig();

  let inputPath = "";
  let theme: ThemeName = ext.default_theme ?? "default";
  let keepTitle = ext.keep_title ?? false;
  let primaryColor: string | undefined = ext.default_color ? resolveColor(ext.default_color) : undefined;
  let fontFamily: string | undefined = ext.default_font_family ? resolveFontFamily(ext.default_font_family) : undefined;
  let fontSize: string | undefined = ext.default_font_size ?? undefined;
  let codeTheme = ext.default_code_theme ?? "github";
  let isMacCodeBlock = ext.mac_code_block ?? true;
  let isShowLineNumber = ext.show_line_number ?? false;
  let citeStatus = ext.cite ?? false;
  let countStatus = ext.count ?? false;
  let legend = ext.legend ?? "alt";

  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i]!;

    if (!arg.startsWith("--") && !inputPath) {
      inputPath = arg;
      continue;
    }

    if (arg === "--help" || arg === "-h") {
      return null;
    }

    if (arg === "--keep-title") { keepTitle = true; continue; }
    if (arg === "--mac-code-block") { isMacCodeBlock = true; continue; }
    if (arg === "--no-mac-code-block") { isMacCodeBlock = false; continue; }
    if (arg === "--line-number") { isShowLineNumber = true; continue; }
    if (arg === "--cite") { citeStatus = true; continue; }
    if (arg === "--count") { countStatus = true; continue; }

    if (arg === "--theme" || arg.startsWith("--theme=")) {
      const val = parseArgValue(argv, i, "--theme");
      if (!val) { console.error("Missing value for --theme"); return null; }
      theme = val as ThemeName;
      if (!arg.includes("=")) i += 1;
      continue;
    }

    if (arg === "--color" || arg.startsWith("--color=")) {
      const val = parseArgValue(argv, i, "--color");
      if (!val) { console.error("Missing value for --color"); return null; }
      primaryColor = resolveColor(val);
      if (!arg.includes("=")) i += 1;
      continue;
    }

    if (arg === "--font-family" || arg.startsWith("--font-family=")) {
      const val = parseArgValue(argv, i, "--font-family");
      if (!val) { console.error("Missing value for --font-family"); return null; }
      fontFamily = resolveFontFamily(val);
      if (!arg.includes("=")) i += 1;
      continue;
    }

    if (arg === "--font-size" || arg.startsWith("--font-size=")) {
      const val = parseArgValue(argv, i, "--font-size");
      if (!val) { console.error("Missing value for --font-size"); return null; }
      fontSize = val.endsWith("px") ? val : `${val}px`;
      if (!FONT_SIZE_OPTIONS.includes(fontSize)) {
        console.error(`Invalid font size: ${fontSize}. Valid: ${FONT_SIZE_OPTIONS.join(", ")}`);
        return null;
      }
      if (!arg.includes("=")) i += 1;
      continue;
    }

    if (arg === "--code-theme" || arg.startsWith("--code-theme=")) {
      const val = parseArgValue(argv, i, "--code-theme");
      if (!val) { console.error("Missing value for --code-theme"); return null; }
      codeTheme = val;
      if (!CODE_BLOCK_THEMES.includes(codeTheme)) {
        console.error(`Unknown code theme: ${codeTheme}`);
        return null;
      }
      if (!arg.includes("=")) i += 1;
      continue;
    }

    if (arg === "--legend" || arg.startsWith("--legend=")) {
      const val = parseArgValue(argv, i, "--legend");
      if (!val) { console.error("Missing value for --legend"); return null; }
      const valid = ["title-alt", "alt-title", "title", "alt", "none"];
      if (!valid.includes(val)) {
        console.error(`Invalid legend: ${val}. Valid: ${valid.join(", ")}`);
        return null;
      }
      legend = val;
      if (!arg.includes("=")) i += 1;
      continue;
    }

    console.error(`Unknown argument: ${arg}`);
    return null;
  }

  if (!inputPath) {
    return null;
  }

  if (!THEME_NAMES.includes(theme)) {
    console.error(`Unknown theme: ${theme}`);
    return null;
  }

  return {
    inputPath, theme, keepTitle, primaryColor, fontFamily, fontSize,
    codeTheme, isMacCodeBlock, isShowLineNumber, citeStatus, countStatus, legend,
  };
}

```

### scripts/renderer/md/constants.ts

```typescript
import type { StyleConfig } from "./types.js";

export const FONT_FAMILY_MAP: Record<string, string> = {
  sans: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`,
  serif: `Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, 'PingFang SC', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif`,
  "serif-cjk": `"Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif`,
  mono: `Menlo, Monaco, 'Courier New', monospace`,
};

export const FONT_SIZE_OPTIONS = ["14px", "15px", "16px", "17px", "18px"];

export const COLOR_PRESETS: Record<string, string> = {
  blue: "#0F4C81",
  green: "#009874",
  vermilion: "#FA5151",
  yellow: "#FECE00",
  purple: "#92617E",
  sky: "#55C9EA",
  rose: "#B76E79",
  olive: "#556B2F",
  black: "#333333",
  gray: "#A9A9A9",
  pink: "#FFB7C5",
  red: "#A93226",
  orange: "#D97757",
};

export const CODE_BLOCK_THEMES = [
  "1c-light", "a11y-dark", "a11y-light", "agate", "an-old-hope",
  "androidstudio", "arduino-light", "arta", "ascetic",
  "atom-one-dark-reasonable", "atom-one-dark", "atom-one-light",
  "brown-paper", "codepen-embed", "color-brewer", "dark", "default",
  "devibeans", "docco", "far", "felipec", "foundation",
  "github-dark-dimmed", "github-dark", "github", "gml", "googlecode",
  "gradient-dark", "gradient-light", "grayscale", "hybrid", "idea",
  "intellij-light", "ir-black", "isbl-editor-dark", "isbl-editor-light",
  "kimbie-dark", "kimbie-light", "lightfair", "lioshi", "magula",
  "mono-blue", "monokai-sublime", "monokai", "night-owl", "nnfx-dark",
  "nnfx-light", "nord", "obsidian", "panda-syntax-dark",
  "panda-syntax-light", "paraiso-dark", "paraiso-light", "pojoaque",
  "purebasic", "qtcreator-dark", "qtcreator-light", "rainbow", "routeros",
  "school-book", "shades-of-purple", "srcery", "stackoverflow-dark",
  "stackoverflow-light", "sunburst", "tokyo-night-dark", "tokyo-night-light",
  "tomorrow-night-blue", "tomorrow-night-bright", "vs", "vs2015", "xcode",
  "xt256",
];

export const DEFAULT_STYLE: StyleConfig = {
  primaryColor: "#0F4C81",
  fontFamily: FONT_FAMILY_MAP.sans!,
  fontSize: "16px",
  foreground: "0 0% 3.9%",
  blockquoteBackground: "#f7f7f7",
  accentColor: "#6B7280",
  containerBg: "transparent",
};

export const THEME_STYLE_DEFAULTS: Record<string, Partial<StyleConfig>> = {
  default: {
    primaryColor: COLOR_PRESETS.blue,
  },
  grace: {
    primaryColor: COLOR_PRESETS.purple,
  },
  simple: {
    primaryColor: COLOR_PRESETS.green,
  },
  modern: {
    primaryColor: COLOR_PRESETS.orange,
    accentColor: "#E4B1A0",
    containerBg: "rgba(250, 249, 245, 1)",
    fontFamily: FONT_FAMILY_MAP.sans,
    fontSize: "15px",
    blockquoteBackground: "rgba(255, 255, 255, 0.6)",
  },
};

export const macCodeSvg = `
  <svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="45px" height="13px" viewBox="0 0 450 130">
    <ellipse cx="50" cy="65" rx="50" ry="52" stroke="rgb(220,60,54)" stroke-width="2" fill="rgb(237,108,96)" />
    <ellipse cx="225" cy="65" rx="50" ry="52" stroke="rgb(218,151,33)" stroke-width="2" fill="rgb(247,193,81)" />
    <ellipse cx="400" cy="65" rx="50" ry="52" stroke="rgb(27,161,37)" stroke-width="2" fill="rgb(100,200,86)" />
  </svg>
`.trim();

```

### scripts/renderer/md/extend-config.ts

```typescript
import fs from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import type { ExtendConfig } from "./types.js";

function extractYamlFrontMatter(content: string): string | null {
  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*$/m);
  return match ? match[1]! : null;
}

function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
  const config: Partial<ExtendConfig> = {};
  for (const line of yaml.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const colonIdx = trimmed.indexOf(":");
    if (colonIdx < 0) continue;
    const key = trimmed.slice(0, colonIdx).trim();
    let value = trimmed.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, "");
    if (value === "null" || value === "") continue;

    if (key === "default_theme") config.default_theme = value;
    else if (key === "default_color") config.default_color = value;
    else if (key === "default_font_family") config.default_font_family = value;
    else if (key === "default_font_size") config.default_font_size = value.endsWith("px") ? value : `${value}px`;
    else if (key === "default_code_theme") config.default_code_theme = value;
    else if (key === "mac_code_block") config.mac_code_block = value === "true";
    else if (key === "show_line_number") config.show_line_number = value === "true";
    else if (key === "cite") config.cite = value === "true";
    else if (key === "count") config.count = value === "true";
    else if (key === "legend") config.legend = value;
    else if (key === "keep_title") config.keep_title = value === "true";
  }
  return config;
}

export function loadExtendConfig(): Partial<ExtendConfig> {
  const paths = [
    path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
    path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
  ];
  for (const p of paths) {
    try {
      const content = fs.readFileSync(p, "utf-8");
      const yaml = extractYamlFrontMatter(content);
      if (!yaml) continue;
      return parseExtendYaml(yaml);
    } catch {
      continue;
    }
  }
  return {};
}

```

### scripts/renderer/md/html-builder.ts

```typescript
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { StyleConfig, HtmlDocumentMeta } from "./types.js";
import { DEFAULT_STYLE } from "./constants.js";

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CODE_THEMES_DIR = path.resolve(SCRIPT_DIR, "code-themes");

export function buildCss(baseCss: string, themeCss: string, style: StyleConfig = DEFAULT_STYLE): string {
  const variables = `
:root {
  --md-primary-color: ${style.primaryColor};
  --md-font-family: ${style.fontFamily};
  --md-font-size: ${style.fontSize};
  --foreground: ${style.foreground};
  --blockquote-background: ${style.blockquoteBackground};
  --md-accent-color: ${style.accentColor};
  --md-container-bg: ${style.containerBg};
}

body {
  margin: 0;
  padding: 24px;
  background: #ffffff;
}

#output {
  max-width: 860px;
  margin: 0 auto;
}
`.trim();

  return [variables, baseCss, themeCss].join("\n\n");
}

export function loadCodeThemeCss(themeName: string): string {
  const filePath = path.join(CODE_THEMES_DIR, `${themeName}.min.css`);
  try {
    return fs.readFileSync(filePath, "utf-8");
  } catch {
    console.error(`Code theme CSS not found: ${filePath}`);
    return "";
  }
}

export function buildHtmlDocument(meta: HtmlDocumentMeta, css: string, html: string, codeThemeCss?: string): string {
  const lines = [
    "<!doctype html>",
    "<html>",
    "<head>",
    '  <meta charset="utf-8" />',
    '  <meta name="viewport" content="width=device-width, initial-scale=1" />',
    `  <title>${meta.title}</title>`,
  ];
  if (meta.author) {
    lines.push(`  <meta name="author" content="${meta.author}" />`);
  }
  if (meta.description) {
    lines.push(`  <meta name="description" content="${meta.description}" />`);
  }
  lines.push(`  <style>${css}</style>`);
  if (codeThemeCss) {
    lines.push(`  <style>${codeThemeCss}</style>`);
  }
  lines.push(
    "</head>",
    "<body>",
    '  <div id="output">',
    html,
    "  </div>",
    "</body>",
    "</html>"
  );
  return lines.join("\n");
}

export 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}`
    );
  }
}

export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_STYLE): string {
  return cssText
    .replace(/var\(--md-primary-color\)/g, style.primaryColor)
    .replace(/var\(--md-font-family\)/g, style.fontFamily)
    .replace(/var\(--md-font-size\)/g, style.fontSize)
    .replace(/var\(--blockquote-background\)/g, style.blockquoteBackground)
    .replace(/var\(--md-accent-color\)/g, style.accentColor)
    .replace(/var\(--md-container-bg\)/g, style.containerBg)
    .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(/--md-accent-color:\s*[^;"']+;?/g, "")
    .replace(/--md-container-bg:\s*[^;"']+;?/g, "")
    .replace(/--foreground:\s*[^;"']+;?/g, "");
}

export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
  let output = html;
  output = output.replace(
    /<style([^>]*)>([\s\S]*?)<\/style>/gi,
    (_match, attrs: string, cssText: string) =>
      `<style${attrs}>${normalizeCssText(cssText, style)}</style>`
  );
  output = output.replace(
    /style="([^"]*)"/gi,
    (_match, cssText: string) => `style="${normalizeCssText(cssText, style)}"`
  );
  output = output.replace(
    /style='([^']*)'/gi,
    (_match, cssText: string) => `style='${normalizeCssText(cssText, style)}'`
  );
  return output;
}

export 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;
}

export function removeFirstHeading(html: string): string {
  return html.replace(/<h[12][^>]*>[\s\S]*?<\/h[12]>/, "");
}

```

### scripts/renderer/md/package.json

```json
{
  "dependencies": {
    "fflate": "^0.8.2",
    "front-matter": "^4.0.2",
    "highlight.js": "^11.11.1",
    "juice": "^11.0.1",
    "marked": "^15.0.6",
    "reading-time": "^1.5.0",
    "remark-cjk-friendly": "^1.1.0",
    "remark-parse": "^11.0.0",
    "remark-stringify": "^11.0.0",
    "unified": "^11.0.5"
  }
}

```

### scripts/renderer/md/render.ts

```typescript
#!/usr/bin/env npx tsx

import fs from "node:fs";
import path from "node:path";
import type { StyleConfig, HtmlDocumentMeta } from "./types.js";
import { DEFAULT_STYLE, THEME_STYLE_DEFAULTS } from "./constants.js";
import { loadThemeCss, normalizeThemeCss } from "./themes.js";
import { parseArgs, printUsage } from "./cli.js";
import { initRenderer, renderMarkdown, postProcessHtml } from "./renderer.js";
import {
  buildCss,
  loadCodeThemeCss,
  buildHtmlDocument,
  inlineCss,
  normalizeInlineCss,
  modifyHtmlStructure,
  removeFirstHeading,
} from "./html-builder.js";

function formatTimestamp(date = new Date()): string {
  const pad = (value: number) => String(value).padStart(2, "0");
  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
    date.getDate()
  )}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}

async function main(): Promise<void> {
  const options = parseArgs(process.argv.slice(2));
  if (!options) {
    printUsage();
    process.exit(1);
  }

  const inputPath = path.resolve(process.cwd(), options.inputPath);
  if (!inputPath.toLowerCase().endsWith(".md")) {
    console.error("Input file must end with .md");
    process.exit(1);
  }

  if (!fs.existsSync(inputPath)) {
    console.error(`File not found: ${inputPath}`);
    process.exit(1);
  }

  const outputPath = path.resolve(
    process.cwd(),
    options.inputPath.replace(/\.md$/i, ".html")
  );

  const themeDefaults = THEME_STYLE_DEFAULTS[options.theme] ?? {};
  const style: StyleConfig = {
    ...DEFAULT_STYLE,
    ...themeDefaults,
    ...(options.primaryColor !== undefined ? { primaryColor: options.primaryColor } : {}),
    ...(options.fontFamily !== undefined ? { fontFamily: options.fontFamily } : {}),
    ...(options.fontSize !== undefined ? { fontSize: options.fontSize } : {}),
  };

  const { baseCss, themeCss } = loadThemeCss(options.theme);
  const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
  const codeThemeCss = loadCodeThemeCss(options.codeTheme);

  const markdown = fs.readFileSync(inputPath, "utf-8");

  const renderer = initRenderer({
    legend: options.legend,
    citeStatus: options.citeStatus,
    countStatus: options.countStatus,
    isMacCodeBlock: options.isMacCodeBlock,
    isShowLineNumber: options.isShowLineNumber,
  });
  const { yamlData } = renderer.parseFrontMatterAndContent(markdown);
  const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(
    markdown,
    renderer
  );
  let content = postProcessHtml(baseHtml, readingTimeResult, renderer);
  if (!options.keepTitle) {
    content = removeFirstHeading(content);
  }

  const stripQuotes = (s?: string): string | undefined => {
    if (!s) return s;
    if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
      return s.slice(1, -1);
    }
    if ((s.startsWith('\u201c') && s.endsWith('\u201d')) || (s.startsWith('\u2018') && s.endsWith('\u2019'))) {
      return s.slice(1, -1);
    }
    return s;
  };

  const meta: HtmlDocumentMeta = {
    title: stripQuotes(yamlData.title) || path.basename(outputPath, ".html"),
    author: stripQuotes(yamlData.author),
    description: stripQuotes(yamlData.description) || stripQuotes(yamlData.summary),
  };
  const html = buildHtmlDocument(meta, css, content, codeThemeCss);
  const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
  const finalHtml = modifyHtmlStructure(inlinedHtml);

  let backupPath = "";
  if (fs.existsSync(outputPath)) {
    backupPath = `${outputPath}.bak-${formatTimestamp()}`;
    fs.renameSync(outputPath, backupPath);
  }

  fs.writeFileSync(outputPath, finalHtml, "utf-8");

  if (backupPath) {
    console.log(`Backup created: ${backupPath}`);
  }
  console.log(`HTML written: ${outputPath}`);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

```

### scripts/renderer/md/renderer.ts

```typescript
import frontMatter from "front-matter";
import hljs from "highlight.js/lib/core";
import { marked, type RendererObject, type Tokens } from "marked";
import readingTime, { type ReadTimeResults } from "reading-time";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkCjkFriendly from "remark-cjk-friendly";
import remarkStringify from "remark-stringify";

import {
  markedAlert,
  markedFootnotes,
  markedInfographic,
  markedMarkup,
  markedPlantUML,
  markedRuby,
  markedSlider,
  markedToc,
  MDKatex,
} from "./extensions/index.js";
import {
  COMMON_LANGUAGES,
  highlightAndFormatCode,
} from "./utils/languages.js";
import { macCodeSvg } from "./constants.js";
import type { IOpts, ParseResult, RendererAPI } from "./types.js";

Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => {
  hljs.registerLanguage(name, lang);
});

export { hljs };

marked.setOptions({
  breaks: true,
});
marked.use(markedSlider());

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;")
    .replace(/`/g, "&#96;");
}

function buildAddition(): string {
  return `
    <style>
      .preview-wrapper pre::before {
        position: absolute;
        top: 0;
        right: 0;
        color: #ccc;
        text-align: center;
        font-size: 0.8em;
        padding: 5px 10px 0;
        line-height: 15px;
        height: 15px;
        font-weight: 600;
      }
    </style>
  `;
}

function buildFootnoteArray(footnotes: [number, string, string][]): string {
  return footnotes
    .map(([index, title, link]) =>
      link === title
        ? `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code>: <i style="word-break: break-all">${title}</i><br/>`
        : `<code style="font-size: 90%; opacity: 0.6;">[${index}]</code> ${title}: <i style="word-break: break-all">${link}</i><br/>`
    )
    .join("\n");
}

function transform(legend: string, text: string | null, title: string | null): string {
  const options = legend.split("-");
  for (const option of options) {
    if (option === "alt" && text) {
      return text;
    }
    if (option === "title" && title) {
      return title;
    }
  }
  return "";
}

function parseFrontMatterAndContent(markdownText: string): ParseResult {
  try {
    const parsed = frontMatter(markdownText);
    const yamlData = parsed.attributes;
    const markdownContent = parsed.body;
    const readingTimeResult = readingTime(markdownContent);
    return {
      yamlData: yamlData as Record<string, any>,
      markdownContent,
      readingTime: readingTimeResult,
    };
  } catch (error) {
    console.error("Error parsing front-matter:", error);
    return {
      yamlData: {},
      markdownContent: markdownText,
      readingTime: readingTime(markdownText),
    };
  }
}

export function initRenderer(opts: IOpts = {}): RendererAPI {
  const footnotes: [number, string, string][] = [];
  let footnoteIndex = 0;
  let codeIndex = 0;
  const listOrderedStack: boolean[] = [];
  const listCounters: number[] = [];
  const isBrowser = typeof window !== "undefined";

  function getOpts(): IOpts {
    return opts;
  }

  function styledContent(styleLabel: string, content: string, tagName?: string): string {
    const tag = tagName ?? styleLabel;
    const className = `${styleLabel.replace(/_/g, "-")}`;
    const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : "";
    return `<${tag} class="${className}"${headingAttr}>${content}</${tag}>`;
  }

  function addFootnote(title: string, link: string): number {
    const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link);
    if (existingFootnote) {
      return existingFootnote[0];
    }
    footnotes.push([++footnoteIndex, title, link]);
    return footnoteIndex;
  }

  function reset(newOpts: Partial<IOpts>): void {
    footnotes.length = 0;
    footnoteIndex = 0;
    setOptions(newOpts);
  }

  function setOptions(newOpts: Partial<IOpts>): void {
    opts = { ...opts, ...newOpts };
    marked.use(markedAlert());
    if (isBrowser) {
      marked.use(MDKatex({ nonStandard: true }, true));
    }
    marked.use(markedMarkup());
    marked.use(markedInfographic({ themeMode: opts.themeMode }));
  }

  function buildReadingTime(readingTimeResult: ReadTimeResults): string {
    if (!opts.countStatus) {
      return "";
    }
    if (!readingTimeResult.words) {
      return "";
    }
    return `
      <blockquote class="md-blockquote">
        <p class="md-blockquote-p">字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟</p>
      </blockquote>
    `;
  }

  const buildFootnotes = () => {
    if (!footnotes.length) {
      return "";
    }
    return (
      styledContent("h4", "引用链接")
      + styledContent("footnotes", buildFootnoteArray(footnotes), "p")
    );
  };

  const renderer: RendererObject = {
    heading({ tokens, depth }: Tokens.Heading) {
      const text = this.parser.parseInline(tokens);
      const tag = `h${depth}`;
      return styledContent(tag, text);
    },

    paragraph({ tokens }: Tokens.Paragraph): string {
      const text = this.parser.parseInline(tokens);
      const isFigureImage = text.includes("<figure") && text.includes("<img");
      const isEmpty = text.trim() === "";
      if (isFigureImage || isEmpty) {
        return text;
      }
      return styledContent("p", text);
    },

    blockquote({ tokens }: Tokens.Blockquote): string {
      const text = this.parser.parse(tokens);
      return styledContent("blockquote", text);
    },

    code({ text, lang = "" }: Tokens.Code): string {
      if (lang.startsWith("mermaid")) {
        if (isBrowser) {
          clearTimeout(codeIndex as any);
          codeIndex = setTimeout(async () => {
            const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
            if (windowRef && windowRef.mermaid) {
              const mermaid = windowRef.mermaid;
              await mermaid.run();
            } else {
              const mermaid = await import("mermaid");
              await mermaid.default.run();
            }
          }, 0) as any as number;
        }
        return `<pre class="mermaid">${text}</pre>`;
      }
      const langText = lang.split(" ")[0];
      const isLanguageRegistered = hljs.getLanguage(langText);
      const language = isLanguageRegistered ? langText : "plaintext";

      const highlighted = highlightAndFormatCode(
        text,
        language,
        hljs,
        !!opts.isShowLineNumber
      );

      const span = `<span class="mac-sign" style="padding: 10px 14px 0;">${macCodeSvg}</span>`;
      let pendingAttr = "";
      if (!isLanguageRegistered && langText !== "plaintext") {
        const escapedText = text.replace(/"/g, "&quot;");
        pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`;
      }
      const code = `<code class="language-${lang}"${pendingAttr}>${highlighted}</code>`;

      return `<pre class="hljs code__pre">${span}${code}</pre>`;
    },

    codespan({ text }: Tokens.Codespan): string {
      const escapedText = escapeHtml(text);
      return styledContent("codespan", escapedText, "code");
    },

    list({ ordered, items, start = 1 }: Tokens.List) {
      listOrderedStack.push(ordered);
      listCounters.push(Number(start));
      const html = items.map((item) => this.listitem(item)).join("");
      listOrderedStack.pop();
      listCounters.pop();
      return styledContent(ordered ? "ol" : "ul", html);
    },

    listitem(token: Tokens.ListItem) {
      const ordered = listOrderedStack[listOrderedStack.length - 1];
      const idx = listCounters[listCounters.length - 1]!;
      listCounters[listCounters.length - 1] = idx + 1;
      const prefix = ordered ? `${idx}. ` : "• ";
      let content: string;
      try {
        content = this.parser.parseInline(token.tokens);
      } catch {
        content = this.parser
          .parse(token.tokens)
          .replace(/^<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/, "$1");
      }
      return styledContent("listitem", `${prefix}${content}`, "li");
    },

    image({ href, title, text }: Tokens.Image): string {
      const newText = opts.legend ? transform(opts.legend, text, title) : "";
      const subText = newText ? styledContent("figcaption", newText) : "";
      const titleAttr = title ? ` title="${title}"` : "";
      return `<figure><img src="${href}"${titleAttr} alt="${text}"/>${subText}</figure>`;
    },

    link({ href, title, text, tokens }: Tokens.Link): string {
      const parsedText = this.parser.parseInline(tokens);
      if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) {
        return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
      }
      if (href === text) {
        return parsedText;
      }
      if (opts.citeStatus) {
        const ref = addFootnote(title || text, href);
        return `<a href="${href}" title="${title || text}">${parsedText}<sup>[${ref}]</sup></a>`;
      }
      return `<a href="${href}" title="${title || text}">${parsedText}</a>`;
    },

    strong({ tokens }: Tokens.Strong): string {
      return styledContent("strong", this.parser.parseInline(tokens));
    },

    em({ tokens }: Tokens.Em): string {
      return styledContent("em", this.parser.parseInline(tokens));
    },

    table({ header, rows }: Tokens.Table): string {
      const headerRow = header
        .map((cell) => {
          const text = this.parser.parseInline(cell.tokens);
          return styledContent("th", text);
        })
        .join("");
      const body = rows
        .map((row) => {
          const rowContent = row.map((cell) => this.tablecell(cell)).join("");
          return styledContent("tr", rowContent);
        })
        .join("");
      return `
        <section style="max-width: 100%; overflow: auto">
          <table class="preview-table">
            <thead>${headerRow}</thead>
            <tbody>${body}</tbody>
          </table>
        </section>
      `;
    },

    tablecell(token: Tokens.TableCell): string {
      const text = this.parser.parseInline(token.tokens);
      return styledContent("td", text);
    },

    hr(_: Tokens.Hr): string {
      return styledContent("hr", "");
    },
  };

  marked.use({ renderer });
  marked.use(markedMarkup());
  marked.use(markedToc());
  marked.use(markedSlider());
  marked.use(markedAlert({}));
  if (isBrowser) {
    marked.use(MDKatex({ nonStandard: true }, true));
  }
  marked.use(markedFootnotes());
  marked.use(
    markedPlantUML({
      inlineSvg: isBrowser,
    })
  );
  marked.use(markedInfographic());
  marked.use(markedRuby());

  return {
    buildAddition,
    buildFootnotes,
    setOptions,
    reset,
    parseFrontMatterAndContent,
    buildReadingTime,
    createContainer(content: string) {
      return styledContent("container", content, "section");
    },
    getOpts,
  };
}

function preprocessCjkEmphasis(markdown: string): string {
  const processor = unified()
    .use(remarkParse)
    .use(remarkCjkFriendly);
  const tree = processor.parse(markdown);
  const extractText = (node: any): string => {
    if (node.type === "text") return node.value;
    if (node.children) return node.children.map(extractText).join("");
    return "";
  };
  const visit = (node: any, parent?: any, index?: number) => {
    if (node.children) {
      for (let i = 0; i < node.children.length; i++) {
        visit(node.children[i], node, i);
      }
    }
    if (node.type === "strong" && parent && typeof index === "number") {
      const text = extractText(node);
      parent.children[index] = { type: "html", value: `<strong>${text}</strong>` };
    }
    if (node.type === "emphasis" && parent && typeof index === "number") {
      const text = extractText(node);
      parent.children[index] = { type: "html", value: `<em>${text}</em>` };
    }
  };
  visit(tree);
  const stringify = unified().use(remarkStringify);
  let result = stringify.stringify(tree);
  result = result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
    String.fromCodePoint(parseInt(hex, 16))
  );
  return result;
}

export function renderMarkdown(raw: string, renderer: RendererAPI): {
  html: string;
  readingTime: ReadTimeResults;
} {
  const { markdownContent, readingTime: readingTimeResult } =
    renderer.parseFrontMatterAndContent(raw);
  const preprocessed = preprocessCjkEmphasis(markdownContent);
  const html = marked.parse(preprocessed) as string;
  return { html, readingTime: readingTimeResult };
}

export function postProcessHtml(
  baseHtml: string,
  reading: ReadTimeResults,
  renderer: RendererAPI
): string {
  let html = baseHtml;
  html = renderer.buildReadingTime(reading) + html;
  html += renderer.buildFootnotes();
  html += renderer.buildAddition();
  html += `
    <style>
      .hljs.code__pre > .mac-sign {
        display: ${renderer.getOpts().isMacCodeBlock ? "flex" : "none"};
      }
    </style>
  `;
  html += `
    <style>
      h2 strong {
        color: inherit !important;
      }
    </style>
  `;
  return renderer.createContainer(html);
}

```

### scripts/renderer/md/themes.ts

```typescript
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ThemeName } from "./types.js";

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];

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 resolveThemeNames(): ThemeName[] {
  const localThemes = discoverThemesFromDir(THEME_DIR);
  const resolved = localThemes.filter((name) =>
    fs.existsSync(path.join(THEME_DIR, `${name}.css`))
  );
  return resolved.length ? resolved : FALLBACK_THEMES;
}

export const THEME_NAMES: ThemeName[] = resolveThemeNames();

export function loadThemeCss(theme: ThemeName): {
  baseCss: string;
  themeCss: string;
} {
  const basePath = path.join(THEME_DIR, "base.css");
  const themePath = path.join(THEME_DIR, `${theme}.css`);

  if (!fs.existsSync(basePath)) {
    throw new Error(`Missing base CSS: ${basePath}`);
  }

  if (!fs.existsSync(themePath)) {
    throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
  }

  return {
    baseCss: fs.readFileSync(basePath, "utf-8"),
    themeCss: fs.readFileSync(themePath, "utf-8"),
  };
}

export function normalizeThemeCss(css: string): string {
  return stripOutputScope(css);
}

```

### scripts/renderer/md/types.ts

```typescript
import type { ReadTimeResults } from "reading-time";

export type ThemeName = string;

export interface StyleConfig {
  primaryColor: string;
  fontFamily: string;
  fontSize: string;
  foreground: string;
  blockquoteBackground: string;
  accentColor: string;
  containerBg: string;
}

export interface IOpts {
  legend?: string;
  citeStatus?: boolean;
  countStatus?: boolean;
  isMacCodeBlock?: boolean;
  isShowLineNumber?: boolean;
  themeMode?: "light" | "dark";
}

export interface RendererAPI {
  reset: (newOpts: Partial<IOpts>) => void;
  setOptions: (newOpts: Partial<IOpts>) => void;
  getOpts: () => IOpts;
  parseFrontMatterAndContent: (markdown: string) => {
    yamlData: Record<string, any>;
    markdownContent: string;
    readingTime: ReadTimeResults;
  };
  buildReadingTime: (reading: ReadTimeResults) => string;
  buildFootnotes: () => string;
  buildAddition: () => string;
  createContainer: (html: string) => string;
}

export interface ParseResult {
  yamlData: Record<string, any>;
  markdownContent: string;
  readingTime: ReadTimeResults;
}

export interface CliOptions {
  inputPath: string;
  theme: ThemeName;
  keepTitle: boolean;
  primaryColor?: string;
  fontFamily?: string;
  fontSize?: string;
  codeTheme: string;
  isMacCodeBlock: boolean;
  isShowLineNumber: boolean;
  citeStatus: boolean;
  countStatus: boolean;
  legend: string;
}

export interface ExtendConfig {
  default_theme: string | null;
  default_color: string | null;
  default_font_family: string | null;
  default_font_size: string | null;
  default_code_theme: string | null;
  mac_code_block: boolean | null;
  show_line_number: boolean | null;
  cite: boolean | null;
  count: boolean | null;
  legend: string | null;
  keep_title: boolean | null;
}

export interface HtmlDocumentMeta {
  title: string;
  author?: string;
  description?: string;
}

```