--- name: wechat-publishing-workflow description: "Use when extracting WeChat articles, rendering Markdown for official-account drafts, generating covers, or publishing Feishu/Markdown content into WeChat workflows." version: 1.0.0 author: Hermes Agent license: MIT metadata: hermes: tags: [wechat, publishing, official-account, productivity] related_skills: [] --- # WeChat Publishing Workflow ## Overview A class-level entry for WeChat Official Account production: source extraction, Markdown rendering, cover generation, media upload, draft creation, and Feishu-to-WeChat handoff. ## When to Use - A task mentions 微信公众号, WeChat Official Account, article extraction, cover art, Doocs-like rendering, or draft publishing. - You need to move content from Feishu/Markdown/browser pages into a WeChat draft. - You need to choose between BrowserUse, Camofox, official APIs, or local renderers. ## Workflow Router - **Extraction:** use Camofox/local anti-detection browser for difficult public article pages; BrowserUse is a cloud-browser fallback. - **Rendering:** use a Doocs-like theme/profile layer for Markdown-to-WeChat HTML before publishing. - **Cover generation:** derive article theme/style, confirm the prompt, generate 2.35:1 cover art, then upload if needed. - **Draft publishing:** use official account API: token, media upload, cover upload, then `draft_add`. - **Feishu source:** fetch Feishu native doc content first, then route through rendering and draft publishing. ## Common Pitfalls **Publisher-native since 2026-08-27** (canonical repo `feishu-doc-to-wechat-draft`, covered by `tests/test_native_publish_fixes.py`): locked-width wrapping code blocks at 12px, Feishu `/` → fixed-layout tables, long Mac code block height cap (>30 lines → `max-height: 480px` + fixed dots header), two trailing blank paragraphs in every payload, no implicit `content_source_url` (explicit `--source-url` only), and playable `rich_pages video_iframe` embeds when video upload + `material/get_material` vid resolution succeed (neutral poster card without failure wording otherwise). The repair rules below (20/21c/21g/24) remain as the post-publish fix path for drafts created before these behaviors were native, or when WeChat strips something. 1. Do not publish before the user confirms final generated image prompt when the user explicitly asks to review/approve the cover prompt. For routine Feishu-doc draft pushes, do not ask for extra confirmation just to satisfy this rule; generate a sensible cover, QA it, and publish with `--cover-image` unless the user requested manual review. 1a. For WeChat article/engagement visuals, do **not** hand-draw production artwork with PIL unless the user explicitly asks for deterministic/icon-only output. It reads as crude engineering art. Use `image_generate` / `image_gen` to create the complete article cover, including public-facing text; only crop/resize/compress/upload locally. If the user asks for pixel/voxel style, prefer polished voxel/3D editorial cover style over rough pixel blocks. 2. For WeChat article engagement banners / footer CTA images (e.g. “点赞、转发、在看 / 一键三连”), do **not** hand-draw crude pixel/PIL graphics as the primary visual. For article covers, generate the complete visual including text directly with `image_generate` / `image_gen`; do not add exact Chinese copy locally. For non-cover engagement banners, local text overlay is allowed only when explicitly requested. If the user asks for voxel/体素风, prompt for high-end 3D voxel editorial cover style, warm Doocs orange-red `#FA5151`, cream background, three clean glowing icons, generous negative space, and no watermarks/text. Always run vision QA after both generation and local text overlay, explicitly checking icon clarity and exact Chinese OCR. 3. WeChat body images and cover images use different upload endpoints and media IDs. 3. Public article pages are noisy; prefer accessibility snapshot plus WeChat-specific cleanup. 4. Keep dry-run/preview output until API credentials and content formatting are verified. 5. Feishu tables can render badly in WeChat if long cells squeeze the first column. Force all exported tables to `table-layout: fixed; width: 100%` and add cell `word-break` / `overflow-wrap`; verify the updated draft via `draft/get` rather than creating duplicates. 5a. WeChat draft storage strips external `` anchors from article content, leaving plain text. If the user wants links to *look* clickable, render them as visual spans instead of anchors: `URL`. Verify with `draft/get` after publish/update; do not trust dry-run HTML containing `` tags. 6. When publishing a Feishu doc with a newly generated cover, pass the exact local cover path via `--cover-image`; otherwise the Feishu pipeline may fall back to the article’s first embedded image. Current canonical pipeline can publish without `--cover-image`/`--thumb-media-id` if the Feishu doc has a first embedded image: it auto-downloads that image, uploads it as cover, and uses the returned `thumb_media_id`. Still run preview → dry-run → publish → `draft/get`/`draft/batchget` verification. 7. If the user explicitly says to call `image_generate` for the cover, do exactly that first; do not substitute a different image backend just because another cover skill mentions Nano Banana/OpenRouter. 8. Runtime path may differ from old archived examples. On this machine the active Feishu→WeChat publisher can be the canonical standalone project at `publisher/scripts/run.py`; check path existence before assuming `~/.hermes/skills/.../scripts/run.py` exists. 12. For the user's WeChat draft pushes, preserve the Doocs visual default as orange + grace/elegant + 15px unless explicitly changed. Avoid bare `--profile doocs` defaults that drift to blue or 16px; use the default style config or explicit flags: `--profile doocs --theme grace --primary-color '#FA5151' --font-size 15`. After publishing, verify `draft/get` actually contains the orange color, 15px font sizing where expected, and no `var(--md-primary-color)` residue; WeChat may strip root CSS variables, so theme accents must be inlined. 12b. For this user's default “不填原文链接” convention, do not trust omission of `--source-url`: the current standalone Feishu publisher may still set `content_source_url` to the Feishu doc URL internally. After publish, inspect `draft/get` `news_item[0].content_source_url`; if non-empty, call `draft/update` for index 0 with the existing article payload and `content_source_url: ""`, then verify again. **Never change the article title to work around update errors; the WeChat draft title must match the Feishu document title exactly unless the user explicitly asks otherwise.** When using the raw `draft/get` article as the update payload, strip server-side/read-only fields such as `url`, `is_deleted`, `update_time`, `create_time`, `thumb_url`, and `article_type`; ensure `need_open_comment` and `only_fans_can_comment` exist (default `0`) before POSTing to `draft/update`. The `draft/update` envelope is `{"media_id": ..., "index": 0, "articles": }`—`articles` is an object, not a one-element array; the array form returns WeChat `47001 data format error`. Send WeChat `draft/update` JSON as raw UTF-8 with `json.dumps(payload, ensure_ascii=False).encode('utf-8')` plus `Content-Type: application/json; charset=utf-8`; Python `requests.post(json=payload)` escapes Chinese as `\uXXXX` and can trigger misleading `45003 title size out of limit` even for short titles. The READ side has the mirror-image pitfall: `resp.json()` on `draft/get` can misdecode the UTF-8 body as ISO-8859-1 (WeChat omits charset), yielding mojibake like `æå°è£`. If you then round-trip that mojibake string back through `draft/update`, you permanently store garbled Chinese in the draft (the WeChat editor will show 乱码). Always decode reads explicitly: `json.loads(resp.content.decode('utf-8'))`, and sanity-check the decoded title for mojibake before using it as update payload; if in doubt, rebuild the update payload from the local publish-output JSON instead of from `draft/get`. If clearing `content_source_url` still conflicts with keeping the exact title, preserve the exact title and report the blocker instead of silently shortening it. Then re-run both the packaged verifier and a direct `draft/get` source-url/title check; the packaged verifier currently does not report `content_source_url`. 9. `lark-cli docs +fetch` uses `--doc` for either URL or token; do not use the stale/nonexistent `--doc-token` flag. If the publisher fails with `need_user_authorization`, the lark-cli user token has expired/been cleared; use `--identity bot` (publisher flag, maps to `lark-cli --as bot`) for both preview and publish, and tell the user `lark-cli auth login` is needed to restore user identity. 10. Dry-run for Feishu docs may still contain `lark-image://` placeholders because it validates payload structure without uploading/replacing all images. Judge image replacement only after formal publish and `draft/get`. 10b. Native Feishu image captions live in raw Docx image blocks as `image.caption.content`; `lark-cli docs +fetch` Markdown may omit them. The canonical publisher must query `/open-apis/docx/v1/documents/:document_id/blocks`, map captions by image token, and feed them into Markdown image alt text before rendering. Publish with `--caption-mode alt-first`, then verify final `draft/get` has one gray small `
` per genuinely captioned image. Do not re-create separate bold caption paragraphs. Feishu images without native captions normalize to generic alt text such as `image`; treat `image`, `img`, `picture`, `photo`, `图片`, and `图像` as placeholders, never render them as `
`. Preserve a real title/alt caption when present, and verify the final draft contains zero literal `>image
` entries. 11. Formal `publish-feishu-doc` can print a huge JSON payload plus noisy `lark-cli` fetch output. Redirect/tee stdout/stderr to files, then parse/summarize with Python; do not paste raw payload back to the user. For image-heavy docs, run as a background process and poll so the user is not left staring at silence. 12. If the source Feishu doc contains embedded videos/files, final WeChat `draft/get` image count may exceed the source `` count because video poster/material cards can become additional `` elements. Verify `lark-image://` residue is 0 rather than expecting preview/source image counts to match exactly. 12a. For image-heavy Feishu docs, source fetch dimensions can be misleading: an image block may show `width="100" height="100"` even when the WeChat draft renders it responsively. After formal publish, inspect final `draft/get` `` tags for literal `width=100`/`height=100` attrs and confirm responsive width styling before reporting success. 12c. WeChat may reject extreme-dimension Lark PNG screenshots with `40137 invalid image format` even when the file is a valid PNG (observed 18018×24477 RGBA PNG downloaded with a `.jpg` filename). The active publisher should downsample these before upload; if this recurs, inspect the failing `lark_img_.jpg` with `file`, then use/patch `_ensure_wechat_supported_image()` to stream-downsample giant PNGs to JPEG rather than loading the full bitmap with PIL, which can trigger decompression-bomb or OOM issues. 13. After successful publish, the publisher stores WeChat stable token cache at `~/.cache/wechat-draft-publisher/access_token_.json`; use it for manual `draft/get` and `draft/batchget` verification when the CLI has no built-in verify command. A reusable verifier is available at `scripts/verify_wechat_draft.py ` inside this skill directory; if the standalone publisher repo lacks that helper, run the skill-packaged path (`scripts/verify_wechat_draft.py`) rather than treating it as a missing capability. It reports title/author/thumb, image counts, `lark-image://` residue, style markers, table layout, bad renderer markers, and batch visibility. Current verifier output may not include `content_source_url`; for this user's default “不填原文链接”, still manually query `draft/get` and inspect `news_item[0].content_source_url`, then run `draft/update` to clear it if needed. If the verifier returns WeChat `42001` (`access_token expired`) for an older draft, treat it as a stale cached-token problem, not proof the draft is missing; refresh via `stable_token` or the publish pipeline, then retry `draft/get`/`batchget`. 14. For the canonical `publish-feishu-doc-default` JSON output, do not expect `thumb_media_id` at the top level; it may be `null` there while the real cover ID is in `payload.articles[0].thumb_media_id`. Verify cover presence from `draft/get` (`news_item[0].thumb_media_id`) rather than the top-level wrapper. 15. For this user's WeChat draft pushes, default to generating a dedicated cover image unless the user explicitly says to reuse the article’s existing first image/old cover, or says only text changed. Do not silently fall back to the first embedded image. Generate a complete 2.35:1 cover via `image_generate` / `image_gen` (text included in the generated image), pass it via `--cover-image`, then dry-run/formal publish and `draft/get`/`draft/batchget` verification. Report a compact table: title, author, draft media_id, thumb present, generated cover path/media status, image count, `mmbiz.qpic.cn` count, `lark-image://` residue, `` while preserving `
`, Mac dot ``, and inner code-line `` styles. For Mac-style code blocks, keep the dot row at `padding:10px 14px 0`, add durable code-line wrappers with `padding-left:14px`, and add `padding-top:8px` on the first code-line wrapper for breathing room. Do not rely on `` padding, SVG margin, or spacer spans; async WeChat sanitization can revert/strip them. Long code lines must not be clipped on mobile, but the old fix (horizontal scroll wrapper `md-code-scroll-wrap` + natural-width `
` with `display:inline-block; min-width:100%; max-width:none`) caused a mobile bug: on overflowing lines the pre physically grows past the viewport and paints its light background into the off-screen area to the right. The durable pattern is now **locked width + line folding**: wrapper `overflow-x: hidden` (no horizontal scroll), pre `display: block; width: 100%; max-width: 100%; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;`, and Mac code-line spans get `max-width: 100%; box-sizing: border-box` so they fold inside the locked pre. Put the wrap rules on `
` as well as `` because WeChat strips `` styles. Wrapping preserves all content, so the no-clipping requirement still holds. Exception: line-number mode keeps horizontal scroll (wrapped lines would misalign the number column). Verify final/dry-run HTML has zero `overflow-x: auto` / `max-width: none` in code blocks and contains the pre-wrap/overflow-wrap markers. Code-block body text defaults to 12px: set `font-size: 12px` on the `
` (WeChat strips `` styles); inline `md-inline-code` stays at 90%. After `draft/update`, WeChat may normalize `#FA5151` to `rgb(250, 81, 81)`, so count both when verifying orange styling. See `references/wechat-mac-code-block-alignment.md` for the repro, durable HTML pattern, and verification counts.
20a. List/quote QA must inspect the final `draft/get`, not only dry-run HTML or source Markdown. In the WeChat editor, native `
    /
      /
    1. ` markers can be repeated on wrapped visual lines, while flex-based custom lists can split a marker from its text after sanitization. The durable pattern is one ordinary `

      ` per item, with exactly one inline marker span immediately followed by one inline text span; use hanging indent (`padding-left` + negative `text-indent`) for continuation lines. For unordered items emit the standard U+2022 BULLET through the numeric entity `•` (not the Chinese middle dot U+00B7 / `·` typed by an IME); for ordered items emit `1.`, `2.`, etc. Do not emit native `

        /
          /
        1. `, `display:flex`, or `list-style` for user-facing items. Verify the final HTML: native-list tag counts, flex count, and renderer sentinel tags are all 0; each unordered marker count equals the item count; ordered marker/text share one paragraph. Nested lists are publisher-native since 2026-09-17 (tests/test_nested_list_indent.py): each nesting level renders as its own block section with progressive indent (padding-left 1.6em per level, text-indent stays -1.6em), nested items keep per-item

          structure, and nested sections are hoisted out of the parent item's inline span as block siblings. Verify with `padding-left: 3.2em` / `md-list-depth-1` counts in final draft/get. 21. Feishu may export terminal snippets as fences like `javascript {wrap}` or `yaml`. If shell commands containing `# ~/.path` are highlighted as JavaScript, Pygments emits red error borders and comments are not gray. Normalize command-looking blocks to Bash before highlighting; verify `border: 1px solid #F00` is 0, comment gray `#6A737D` is present, and spacing before comments survives as ` `. 21a. HTTP header-only fences like `Content-Type: application/json` / `Authorization: Bearer; ...` may be highlighted as Pygments error spans and survive into final `draft/get` as `style="border: 1px solid #F00"`. If discovered after publish, do not create a duplicate draft just for this: fetch the draft article, replace the red-border error span style with a neutral readable style (e.g. `color: #24292f;`), keep `content_source_url: ""`, strip read-only fields, call `draft/update`, then re-run verifier plus an explicit red-border count check. 21b. If final `draft/get` shows `lark-image://` residue or escaped `<figure ... lark-image://...>`, do not report success. Inspect the fetched Feishu Markdown for nested or orphaned code fences, especially outer ```markdown blocks containing inner ```bash/plaintext fences. Fix/unwrap the Markdown fence normalization, re-run dry-run until all source images render as real `` placeholders, then republish or update. After creating a clean replacement draft, delete any earlier bad duplicate draft. 21c. Feishu embedded `.mp4` handling: WeChat permanent video material upload has a hard practical limit from the official docs: MP4 and <=10MB. If the source video is larger, compress it first (e.g. `ffmpeg -vf "fps=30,scale='min(1280,iw)':-2" -c:v libx264 -crf 28 -c:a aac -b:a 96k -movflags +faststart`). Upload with `material/add_material?type=video`; then call `material/get_material` on the returned video `media_id` to obtain `vid` such as `apiv_...`. Insert playable video into article HTML as `