Compare commits

...

7 Commits

Author SHA1 Message Date
Jim Liu 宝玉 5d2a39c636 chore: release v1.117.2 2026-05-17 21:10:55 -05:00
Jim Liu 宝玉 db58bdee8c docs(skills): ban programmatic text repair on generated bitmaps
Add a text-correction policy to all raster-image skills: title/subtitle/labels/dialogue/etc. inside generated bitmaps must NOT be patched with ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR overlays, or any other programmatic painter. On error, regenerate from a corrected prompt or switch to a lower-text variant.

Synced to: baoyu-cover-image, baoyu-article-illustrator, baoyu-comic, baoyu-image-cards, baoyu-xhs-images, baoyu-infographic, baoyu-slide-deck.
2026-05-17 21:10:50 -05:00
Jim Liu 宝玉 38cc497748 chore: release v1.117.1 2026-05-16 23:09:11 -05:00
Jim Liu 宝玉 0f95d12a09 Merge pull request #155 from JimLiu/codex/wechat-image-upload-fallback-webp
[codex] Fix WeChat image upload fallback and WebP clipboard
2026-05-16 23:07:09 -05:00
Jim Liu 宝玉 7fd9e51fc2 Fix WeChat image upload fallback and WebP clipboard 2026-05-16 22:33:07 -05:00
Jim Liu 宝玉 a5d227b4e8 Merge pull request #154 from zhangga/codex/fix-wechat-browser-article-publish
Fix WeChat browser article publishing reliability
2026-05-16 22:29:28 -05:00
zeqiang.zhang 81377416b4 Fix WeChat browser article publishing 2026-05-16 22:47:41 +08:00
14 changed files with 535 additions and 59 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.117.0" "version": "1.117.2"
}, },
"plugins": [ "plugins": [
{ {
+12
View File
@@ -2,6 +2,18 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.117.2 - 2026-05-17
### Documentation
- `baoyu-cover-image`: ban programmatic text repair on generated bitmaps — disallow ImageMagick / Pillow / Canvas / SVG / HTML overlays to cover, rewrite, or replace title/subtitle text; regenerate from a corrected prompt or switch to a lower-text or no-title variant instead
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-image-cards`, `baoyu-xhs-images`, `baoyu-infographic`, `baoyu-slide-deck`: sync the same text-repair ban with skill-specific text categories (labels/captions, dialogue/sound effects, titles/body/tags, headings/data values, slide titles/bullets)
## 1.117.1 - 2026-05-16
### Fixes
- `baoyu-post-to-wechat`: fix WeChat browser article publishing (by @zhangga)
- `baoyu-post-to-wechat`: fix image upload fallback and WebP clipboard copy on macOS
## 1.117.0 - 2026-05-16 ## 1.117.0 - 2026-05-16
### Features ### Features
+12
View File
@@ -2,6 +2,18 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.117.2 - 2026-05-17
### 文档
- `baoyu-cover-image`:禁止用代码修补已生成的位图文字 —— 不再使用 ImageMagick / Pillow / Canvas / SVG / HTML 叠层覆盖、重写或替换标题/副标题文字,文字异常时应改 prompt 重新生成或换用少字/无标题版本
- `baoyu-article-illustrator``baoyu-comic``baoyu-image-cards``baoyu-xhs-images``baoyu-infographic``baoyu-slide-deck`:同步上述文字修补禁令,各自针对该 skill 的文字类别(标签/说明、对白/拟声词、标题/正文/标签、标题/数据、幻灯片标题/要点)
## 1.117.1 - 2026-05-16
### 修复
- `baoyu-post-to-wechat`:修复微信浏览器文章发布问题 (by @zhangga)
- `baoyu-post-to-wechat`:修复图片上传回退逻辑及 macOS WebP 剪贴板复制
## 1.117.0 - 2026-05-16 ## 1.117.0 - 2026-05-16
### 新功能 ### 新功能
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, captions, or any other text inside an already generated illustration. If text is wrong or unclear, regenerate from a corrected prompt, redraw with less or no on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -233,6 +235,12 @@ When input is **pasted content** (no file path), always uses `illustrations/{top
| Add | Position → Prompt → Generate → Update outline → Insert | | Add | Position → Prompt → Generate → Update outline → Insert |
| Delete | Delete files → Remove reference → Update outline | | Delete | Delete files → Remove reference → Update outline |
Text correction policy:
- If any rendered text (labels, captions, etc.) is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References ## References
| File | Content | | File | Content |
+8
View File
@@ -40,6 +40,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace dialogue, sound effects, panel labels, or any other text inside an already generated comic page. If text is wrong or unclear, regenerate from a corrected prompt, redraw the page with less or no on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -313,6 +315,12 @@ If EXTEND.md is not found, first-time setup is **blocking** — complete it befo
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible. **IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
Text correction policy:
- If dialogue, sound effects, panel labels, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## Notes ## Notes
- Image generation: 10-30 seconds per page - Image generation: 10-30 seconds per page
+8
View File
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace title/subtitle text inside an already generated cover image. If text is wrong or unclear, regenerate from a corrected prompt, switch to a lower-text or no-title variant, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -241,6 +243,12 @@ Files:
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate | | **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate | | **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
Text correction policy:
- If the title/subtitle is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## Composition Principles ## Composition Principles
- **Whitespace**: 40-60% breathing room - **Whitespace**: 40-60% breathing room
+8
View File
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -431,6 +433,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible. Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
Text correction policy:
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References ## References
| File | Content | | File | Content |
+8
View File
@@ -36,6 +36,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, headings, callouts, data values, or any other text inside an already generated infographic. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -297,6 +299,12 @@ Combine:
4. Call the chosen backend with the prompt file and output path 4. Call the chosen backend with the prompt file and output path
5. On failure, auto-retry once 5. On failure, auto-retry once
Text correction policy:
- If labels, headings, callouts, data values, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
### Step 7: Output Summary ### Step 7: Output Summary
Report: topic, layout, style, aspect, language, image backend, output path, files created. Report: topic, layout, style, aspect, language, image backend, output path, files created.
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const clipboardScript = fs.readFileSync(path.join(__dirname, "copy-to-clipboard.ts"), "utf8");
test("macOS image clipboard copy avoids Swift AppKit JIT", () => {
assert.match(clipboardScript, /copyImageMacWithOsascript/);
assert.doesNotMatch(clipboardScript, /await runCommand\('swift', \[swiftPath, 'image', imagePath\]\)/);
});
test("macOS image clipboard copy converts WebP to PNG before AppleScript", () => {
assert.match(clipboardScript, /convertWebpMacToPng/);
assert.match(clipboardScript, /path\.extname\(imagePath\)\.toLowerCase\(\) === '\.webp'/);
assert.match(clipboardScript, /await copyImageMacWithOsascript\(pngPath\)/);
});
@@ -1,9 +1,12 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { fileURLToPath } from 'node:url';
import decodeWebp, { init as initWebpDecode } from '@jsquash/webp/decode.js';
import { Jimp, JimpMime } from 'jimp';
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']); const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
@@ -186,12 +189,73 @@ default:
`; `;
} }
async function copyImageMac(imagePath: string): Promise<void> { function escapeAppleScriptString(value: string): string {
await withTempDir('copy-to-clipboard-', async (tempDir) => { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const swiftPath = path.join(tempDir, 'clipboard.swift'); }
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
await runCommand('swift', [swiftPath, 'image', imagePath]); function getAppleScriptImageType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.png':
return '«class PNGf»';
case '.jpg':
case '.jpeg':
return 'JPEG picture';
case '.gif':
return 'GIF picture';
default:
throw new Error(`macOS clipboard image copy supports PNG, JPEG, and GIF via AppleScript; convert ${ext || 'this file'} to PNG first.`);
}
}
let webpDecoderReady: Promise<void> | undefined;
async function ensureWebpDecoder(): Promise<void> {
if (!webpDecoderReady) {
webpDecoderReady = (async () => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const wasmPath = path.resolve(__dirname, 'node_modules/@jsquash/webp/codec/dec/webp_dec.wasm');
const wasmModule = await WebAssembly.compile(await readFile(wasmPath));
await initWebpDecode(wasmModule, {});
})();
}
await webpDecoderReady;
}
async function convertWebpMacToPng(webpPath: string, tempDir: string): Promise<string> {
await ensureWebpDecoder();
const decoded = await decodeWebp(await readFile(webpPath));
const image = new Jimp({
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
width: decoded.width,
height: decoded.height,
}); });
const pngPath = path.join(tempDir, `${path.basename(webpPath, path.extname(webpPath))}.png`);
await writeFile(pngPath, await image.getBuffer(JimpMime.png));
return pngPath;
}
async function copyImageMacWithOsascript(imagePath: string): Promise<void> {
const imageType = getAppleScriptImageType(imagePath);
const escapedPath = escapeAppleScriptString(imagePath);
await runCommand('osascript', [
'-e',
`set the clipboard to (read (POSIX file "${escapedPath}") as ${imageType})`,
]);
}
async function copyImageMac(imagePath: string): Promise<void> {
if (path.extname(imagePath).toLowerCase() === '.webp') {
await withTempDir('copy-to-clipboard-', async (tempDir) => {
const pngPath = await convertWebpMacToPng(imagePath, tempDir);
await copyImageMacWithOsascript(pngPath);
});
return;
}
await copyImageMacWithOsascript(imagePath);
} }
async function copyHtmlMac(htmlFilePath: string): Promise<void> { async function copyHtmlMac(htmlFilePath: string): Promise<void> {
@@ -377,4 +441,3 @@ await main().catch((err) => {
console.error(`Error: ${message}`); console.error(`Error: ${message}`);
process.exit(1); process.exit(1);
}); });
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const articleScript = fs.readFileSync(path.join(__dirname, "wechat-article.ts"), "utf8");
test("browser article paste uses CDP-targeted paste instead of global macOS keystrokes", () => {
assert.equal(
articleScript.includes('tell application "System Events" to keystroke "v" using command down'),
false,
);
});
test("browser article publishing verifies the title before saving drafts", () => {
assert.match(articleScript, /verifyTitleUnchangedBeforeSave/);
assert.match(articleScript, /Title was modified during paste/);
});
test("browser article body insertion does not paste HTML through the active form field", () => {
assert.match(articleScript, /insertHtmlIntoEditorFromFile/);
assert.doesNotMatch(articleScript, /await copyHtmlFromBrowser\(cdp, effectiveHtmlFile, contentImages\)/);
});
test("browser article body operations target the body ProseMirror, not the title ProseMirror", () => {
assert.match(articleScript, /BODY_EDITOR_SELECTOR = '\.rich_media_content \.ProseMirror'/);
assert.doesNotMatch(articleScript, /clickElement\(session, '\\.ProseMirror'\)/);
});
test("browser article reuses the selected account Chrome profile before launching", () => {
assert.match(articleScript, /await findExistingChromeDebugPort\(profileDir\)/);
});
test("browser article inserts inline images through WeChat local upload instead of clipboard paste", () => {
assert.match(articleScript, /uploadImageThroughFileInput/);
assert.match(articleScript, /DOM\.setFileInputFiles/);
assert.doesNotMatch(articleScript, /await copyImageToClipboard\(img\.localPath\)/);
});
test("browser article uploads original images before fallback processing", () => {
const rawUploadIndex = articleScript.indexOf("await uploadImagePathThroughFileInput(session, absolutePath, beforeCount)");
const fallbackIndex = articleScript.indexOf("const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath)");
assert.notEqual(rawUploadIndex, -1);
assert.notEqual(fallbackIndex, -1);
assert.ok(rawUploadIndex < fallbackIndex);
assert.match(articleScript, /prepareWechatBodyImageUpload/);
assert.match(articleScript, /Raw image upload failed, retrying with fallback processing/);
});
test("browser article waits for a saved draft appmsgid before reporting success", () => {
assert.match(articleScript, /waitForDraftSaved/);
assert.match(articleScript, /appmsgid/);
assert.doesNotMatch(articleScript, /Waiting for save confirmation/);
});
@@ -1,12 +1,15 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import process from 'node:process'; import process from 'node:process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts'; import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';
import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts'; import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
import { prepareWechatBodyImageUpload } from './wechat-image-processor.ts';
const WECHAT_URL = 'https://mp.weixin.qq.com/'; const WECHAT_URL = 'https://mp.weixin.qq.com/';
const BODY_EDITOR_SELECTOR = '.rich_media_content .ProseMirror';
interface ImageInfo { interface ImageInfo {
placeholder: string; placeholder: string;
@@ -196,27 +199,27 @@ async function pasteInEditor(session: ChromeSession): Promise<void> {
} }
async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> { async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {
if (process.platform === 'darwin') { if (cdp && sessionId) {
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
await sleep(50);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
} else if (process.platform === 'darwin') {
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']); spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
} else if (process.platform === 'linux') { } else if (process.platform === 'linux') {
spawnSync('xdotool', ['key', 'ctrl+c']); spawnSync('xdotool', ['key', 'ctrl+c']);
} else if (cdp && sessionId) {
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
await sleep(50);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
} }
} }
async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> { async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {
if (process.platform === 'darwin') { if (!cdp || !sessionId) {
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']); throw new Error('Targeted paste requires a Chrome DevTools session');
} else if (process.platform === 'linux') {
spawnSync('xdotool', ['key', 'ctrl+v']);
} else if (cdp && sessionId) {
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
await sleep(50);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
} }
const modifiers = process.platform === 'darwin' ? 4 : 2;
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
await sleep(50);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
} }
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> { async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
@@ -294,6 +297,82 @@ async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void>
await sleep(1000); await sleep(1000);
} }
async function insertHtmlIntoEditorFromFile(
session: ChromeSession,
htmlFilePath: string,
contentImages: ImageInfo[] = [],
): Promise<void> {
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
const html = fs.readFileSync(absolutePath, 'utf8');
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
const result = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
expression: `
(function() {
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return JSON.stringify({ ok: false, reason: 'editor-missing' });
const template = document.createElement('template');
template.innerHTML = ${JSON.stringify(html)};
const replacements = ${JSON.stringify(replacements)};
for (const img of Array.from(template.content.querySelectorAll('img'))) {
const src = img.getAttribute('src') || '';
const localPath = img.getAttribute('data-local-path') || '';
const replacement = replacements.find((item) => item.placeholder === src || item.localPath === localPath);
if (replacement) {
img.replaceWith(document.createTextNode(replacement.placeholder));
}
}
const output = template.content.querySelector('#output');
const wrapper = document.createElement('div');
if (output) {
wrapper.innerHTML = output.innerHTML;
} else {
wrapper.appendChild(template.content.cloneNode(true));
}
editor.focus();
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
range.deleteContents();
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
const inserted = document.execCommand('insertHTML', false, wrapper.innerHTML);
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
inputType: 'insertHTML',
data: wrapper.innerText || ''
}));
return JSON.stringify({
ok: inserted || (editor.innerText || '').trim().length > 0,
textLength: (editor.innerText || '').trim().length
});
})()
`,
returnByValue: true,
}, { sessionId: session.sessionId });
const parsed = JSON.parse(result.result.value || '{}') as { ok?: boolean; reason?: string; textLength?: number };
if (!parsed.ok) {
throw new Error(`Failed to insert HTML into body editor${parsed.reason ? `: ${parsed.reason}` : ''}`);
}
}
async function verifyTitleUnchangedBeforeSave(session: ChromeSession, expectedTitle: string): Promise<void> {
if (!expectedTitle) return;
const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);
if (actualTitle !== expectedTitle) {
throw new Error(`Title was modified during paste. Expected: "${expectedTitle}", got: "${actualTitle}"`);
}
}
async function prepareEditorPasteTarget( async function prepareEditorPasteTarget(
session: ChromeSession, session: ChromeSession,
context: string, context: string,
@@ -303,19 +382,24 @@ async function prepareEditorPasteTarget(
await sleep(100); await sleep(100);
if (options.clickEditor) { if (options.clickEditor) {
await clickElement(session, '.ProseMirror'); await clickElement(session, BODY_EDITOR_SELECTOR);
await sleep(200); await sleep(200);
} }
const ready = await evaluate<boolean>(session, ` const ready = await evaluate<boolean>(session, `
(function() { (function() {
const editor = document.querySelector('.ProseMirror'); const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return false; if (!editor) return false;
const active = document.activeElement; const active = document.activeElement;
const selection = window.getSelection(); const selection = window.getSelection();
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode); const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
const focusInEditor = !!active && (active === editor || editor.contains(active)); const focusInEditor = !!active && (active === editor || editor.contains(active));
const activeIsUnsafeInput = !!active && (
active.matches?.('#title, #author, #js_description') ||
((active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') && !editor.contains(active))
);
if (activeIsUnsafeInput) return false;
if (selectionInEditor || focusInEditor) return true; if (selectionInEditor || focusInEditor) return true;
if (${JSON.stringify(Boolean(options.clickEditor))}) { if (${JSON.stringify(Boolean(options.clickEditor))}) {
@@ -438,7 +522,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', { const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: ` expression: `
(function() { (function() {
const editor = document.querySelector('.ProseMirror'); const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return false; if (!editor) return false;
const placeholder = ${JSON.stringify(placeholder)}; const placeholder = ${JSON.stringify(placeholder)};
@@ -456,6 +540,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
// Exact match if next char is not a digit // Exact match if next char is not a digit
if (charAfter === undefined || !/\\d/.test(charAfter)) { if (charAfter === undefined || !/\\d/.test(charAfter)) {
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
editor.focus();
const range = document.createRange(); const range = document.createRange();
range.setStart(node, idx); range.setStart(node, idx);
@@ -486,7 +571,7 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> { async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
const removed = await evaluate<boolean>(session, ` const removed = await evaluate<boolean>(session, `
(function() { (function() {
const editor = document.querySelector('.ProseMirror'); const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return false; if (!editor) return false;
const sel = window.getSelection(); const sel = window.getSelection();
@@ -548,6 +633,188 @@ async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<b
return removed; return removed;
} }
async function getBodyImageCount(session: ChromeSession): Promise<number> {
return await evaluate<number>(session, `
(function() {
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return 0;
return Array.from(editor.querySelectorAll('img')).filter((img) => !img.classList.contains('ProseMirror-separator')).length;
})()
`);
}
async function waitForBodyImageCount(session: ChromeSession, minimumCount: number, timeoutMs = 45_000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const count = await getBodyImageCount(session);
if (count >= minimumCount) return true;
await sleep(500);
}
return false;
}
function inferImageContentType(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.bmp':
return 'image/bmp';
case '.svg':
return 'image/svg+xml';
default:
return 'application/octet-stream';
}
}
async function uploadImagePathThroughFileInput(
session: ChromeSession,
absolutePath: string,
beforeCount: number,
): Promise<void> {
const documentNode = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {
depth: -1,
pierce: true,
}, { sessionId: session.sessionId });
const inputNode = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: documentNode.root.nodeId,
selector: 'input[type="file"][accept*="image"]',
}, { sessionId: session.sessionId });
if (!inputNode.nodeId) throw new Error('WeChat local image upload input not found');
await session.cdp.send('DOM.setFileInputFiles', {
nodeId: inputNode.nodeId,
files: [absolutePath],
}, { sessionId: session.sessionId });
const inserted = await waitForBodyImageCount(session, beforeCount + 1);
if (!inserted) {
const afterCount = await getBodyImageCount(session);
throw new Error(`Image upload did not insert into editor: ${path.basename(absolutePath)} (${beforeCount} -> ${afterCount})`);
}
}
interface FallbackUploadImage {
uploadPath: string;
wasProcessed: boolean;
processingNotes: string[];
cleanup: () => void;
}
async function prepareFallbackWechatBodyImageUpload(absolutePath: string): Promise<FallbackUploadImage> {
const buffer = fs.readFileSync(absolutePath);
const prepared = await prepareWechatBodyImageUpload({
buffer,
filename: path.basename(absolutePath),
contentType: inferImageContentType(absolutePath),
fileExt: path.extname(absolutePath).toLowerCase(),
fileSize: buffer.length,
});
if (!prepared.wasProcessed) {
return {
uploadPath: absolutePath,
wasProcessed: false,
processingNotes: [],
cleanup: () => {},
};
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-body-image-'));
const uploadPath = path.join(tempDir, prepared.filename);
fs.writeFileSync(uploadPath, prepared.buffer);
return {
uploadPath,
wasProcessed: true,
processingNotes: prepared.processingNotes,
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
};
}
async function uploadImageThroughFileInput(session: ChromeSession, imagePath: string): Promise<void> {
const absolutePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);
if (!fs.existsSync(absolutePath)) throw new Error(`Image file not found: ${absolutePath}`);
const beforeCount = await getBodyImageCount(session);
try {
await uploadImagePathThroughFileInput(session, absolutePath, beforeCount);
return;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('local image upload input not found')) throw err;
const currentCount = await getBodyImageCount(session);
if (currentCount > beforeCount) return;
console.warn(`[wechat] Raw image upload failed, retrying with fallback processing: ${message}`);
const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath);
const notes = fallback.processingNotes.length > 0 ? ` (${fallback.processingNotes.join('; ')})` : '';
console.log(`[wechat] Retrying image upload with ${fallback.wasProcessed ? 'processed' : 'original'} file: ${path.basename(fallback.uploadPath)}${notes}`);
try {
await uploadImagePathThroughFileInput(session, fallback.uploadPath, currentCount);
} finally {
fallback.cleanup();
}
}
}
interface DraftSaveStatus {
appmsgid: string;
isLoading: boolean;
submitText: string;
url: string;
messages: string[];
}
async function getDraftSaveStatus(session: ChromeSession): Promise<DraftSaveStatus> {
const raw = await evaluate<string>(session, `
(function() {
const submit = document.querySelector('#js_submit');
const button = submit?.querySelector('button');
const url = location.href;
const appmsgid = new URL(url).searchParams.get('appmsgid') || '';
const messages = Array.from(document.querySelectorAll('.weui-desktop-toast, .weui-desktop-toptips, .js_tips'))
.map((el) => (el.innerText || el.textContent || '').trim())
.filter(Boolean);
return JSON.stringify({
appmsgid,
isLoading: !!submit?.classList.contains('btn_loading') || !!button?.disabled,
submitText: (submit?.innerText || '').trim(),
url,
messages
});
})()
`);
return JSON.parse(raw || '{}') as DraftSaveStatus;
}
async function waitForDraftSaved(session: ChromeSession, timeoutMs = 60_000): Promise<string> {
const start = Date.now();
let lastStatus: DraftSaveStatus | null = null;
while (Date.now() - start < timeoutMs) {
lastStatus = await getDraftSaveStatus(session);
if (lastStatus.appmsgid && !lastStatus.isLoading) return lastStatus.appmsgid;
const relevantFailure = lastStatus.messages.find((message) => /保存.*失败|草稿.*失败|save.*fail/i.test(message));
if (relevantFailure) throw new Error(`Draft save failed: ${relevantFailure}`);
await sleep(1000);
}
throw new Error(`Draft save did not complete${lastStatus ? `: ${JSON.stringify(lastStatus)}` : ''}`);
}
export async function postArticle(options: ArticleOptions): Promise<void> { export async function postArticle(options: ArticleOptions): Promise<void> {
const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options; const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
let { contentImages = [] } = options; let { contentImages = [] } = options;
@@ -591,7 +858,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null; let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
// Try connecting to existing Chrome: explicit port > auto-detect > launch new // Try connecting to existing Chrome: explicit port > auto-detect > launch new
const portToTry = cdpPort ?? await findExistingChromeDebugPort(); const portToTry = cdpPort ?? await findExistingChromeDebugPort(profileDir);
if (portToTry) { if (portToTry) {
const existing = await tryConnectExisting(portToTry); const existing = await tryConnectExisting(portToTry);
if (existing) { if (existing) {
@@ -680,7 +947,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
console.log('[wechat] Waiting for editor to load...'); console.log('[wechat] Waiting for editor to load...');
const editorLoaded = await waitForElement(session, '#title', 30_000); const editorLoaded = await waitForElement(session, '#title', 30_000);
if (!editorLoaded) throw new Error('Editor did not load (#title not found)'); if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
await waitForElement(session, '.ProseMirror', 15_000); await waitForElement(session, BODY_EDITOR_SELECTOR, 15_000);
await sleep(2000); await sleep(2000);
if (effectiveTitle) { if (effectiveTitle) {
@@ -705,25 +972,23 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
} }
console.log('[wechat] Clicking on editor...'); console.log('[wechat] Clicking on editor...');
await clickElement(session, '.ProseMirror'); await clickElement(session, BODY_EDITOR_SELECTOR);
await sleep(1000); await sleep(1000);
console.log('[wechat] Ensuring editor focus...'); console.log('[wechat] Ensuring editor focus...');
await clickElement(session, '.ProseMirror'); await clickElement(session, BODY_EDITOR_SELECTOR);
await sleep(500); await sleep(500);
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) { if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`); console.log(`[wechat] Inserting HTML content from: ${effectiveHtmlFile}`);
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
await sleep(500);
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true }); await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
console.log('[wechat] Pasting into editor...'); await insertHtmlIntoEditorFromFile(session, effectiveHtmlFile, contentImages);
await pasteFromClipboardInEditor(session);
await sleep(3000); await sleep(3000);
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
const editorHasContent = await evaluate<boolean>(session, ` const editorHasContent = await evaluate<boolean>(session, `
(function() { (function() {
const editor = document.querySelector('.ProseMirror'); const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return false; if (!editor) return false;
const text = editor.innerText?.trim() || ''; const text = editor.innerText?.trim() || '';
return text.length > 0; return text.length > 0;
@@ -749,18 +1014,15 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
await sleep(500); await sleep(500);
console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);
await copyImageToClipboard(img.localPath);
await sleep(300);
console.log('[wechat] Deleting placeholder with Backspace...'); console.log('[wechat] Deleting placeholder with Backspace...');
await pressDeleteKey(session); await pressDeleteKey(session);
await sleep(200); await sleep(200);
console.log('[wechat] Pasting image...'); console.log(`[wechat] Uploading image: ${path.basename(img.localPath)}`);
await prepareEditorPasteTarget(session, 'inline image paste'); await prepareEditorPasteTarget(session, 'inline image upload');
await pasteFromClipboardInEditor(session); await uploadImageThroughFileInput(session, img.localPath);
await sleep(3000); await sleep(1000);
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
await removeExtraEmptyLineAfterImage(session); await removeExtraEmptyLineAfterImage(session);
} }
console.log('[wechat] All images inserted.'); console.log('[wechat] All images inserted.');
@@ -768,12 +1030,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
} else if (content) { } else if (content) {
for (const img of images) { for (const img of images) {
if (fs.existsSync(img)) { if (fs.existsSync(img)) {
console.log(`[wechat] Pasting image: ${img}`); console.log(`[wechat] Uploading image: ${img}`);
await copyImageToClipboard(img); await prepareEditorPasteTarget(session, 'leading image upload');
await sleep(500); await uploadImageThroughFileInput(session, img);
await prepareEditorPasteTarget(session, 'leading image paste'); await sleep(1000);
await pasteInEditor(session);
await sleep(2000);
await removeExtraEmptyLineAfterImage(session); await removeExtraEmptyLineAfterImage(session);
} }
} }
@@ -785,7 +1045,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
const editorHasContent = await evaluate<boolean>(session, ` const editorHasContent = await evaluate<boolean>(session, `
(function() { (function() {
const editor = document.querySelector('.ProseMirror'); const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
if (!editor) return false; if (!editor) return false;
const text = editor.innerText?.trim() || ''; const text = editor.innerText?.trim() || '';
return text.length > 0; return text.length > 0;
@@ -822,17 +1082,12 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
} }
} }
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
console.log('[wechat] Saving as draft...'); console.log('[wechat] Saving as draft...');
await evaluate(session, `document.querySelector('#js_submit button').click()`); await evaluate(session, `document.querySelector('#js_submit button').click()`);
await sleep(3000); const appmsgid = await waitForDraftSaved(session);
console.log(`[wechat] Draft saved successfully! appmsgid: ${appmsgid}`);
const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);
if (saved) {
console.log('[wechat] Draft saved successfully!');
} else {
console.log('[wechat] Waiting for save confirmation...');
await sleep(5000);
}
console.log('[wechat] Done. Browser window left open.'); console.log('[wechat] Done. Browser window left open.');
} finally { } finally {
+8
View File
@@ -40,6 +40,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace slide titles, bullets, or any other text inside an already generated slide image. If text is wrong or unclear, regenerate from a corrected prompt, simplify the slide's on-image text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-slide-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-slide-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -337,6 +339,12 @@ PDF: {topic-slug}.pdf
Always update the prompt file before regenerating the image — this keeps the prompts directory as the source of truth and makes changes reproducible. Only `NN` changes on renumber; slugs stay stable so references remain valid. Always update the prompt file before regenerating the image — this keeps the prompts directory as the source of truth and makes changes reproducible. Only `NN` changes on renumber; slugs stay stable so references remain valid.
Text correction policy:
- If a slide's title, bullets, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
See `references/modification-guide.md` for full details. See `references/modification-guide.md` for full details.
## References ## References
+8
View File
@@ -37,6 +37,8 @@ When this skill needs to render an image, resolve the backend in this order:
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs. **⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card text, or ask the user which imperfect candidate to keep.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below. Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts. **Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
@@ -432,6 +434,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible. Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
Text correction policy:
- If a card's title, body copy, tags, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
## References ## References
| File | Content | | File | Content |