Compare commits

...

9 Commits

Author SHA1 Message Date
Jim Liu 宝玉 ec704c8afd chore: release v2.4.0 2026-05-29 18:59:31 -05:00
Jim Liu 宝玉 a85c81e8db feat(baoyu-wechat-summary): add @bot Q&A section to normal and roast digests 2026-05-29 18:59:26 -05:00
Jim Liu 宝玉 77dd193b58 fix: sync npm lockfile 2026-05-27 23:26:46 -05:00
Jim Liu 宝玉 84d817ed52 chore: release v2.3.0 2026-05-27 23:24:29 -05:00
Jim Liu 宝玉 639e0b4193 feat: support Obsidian wikilink images
Support Obsidian image wikilinks alongside standard markdown images, preserve mixed image order, and resolve Obsidian default Attachments/ paths.

Co-authored-by: Chao Zheng <10296164+zcqqq@users.noreply.github.com>
2026-05-27 23:06:56 -05:00
Jim Liu 宝玉 84cefc2784 fix: harden URL-decoded image paths
Co-authored-by: zcqqq <10296164+zcqqq@users.noreply.github.com>
2026-05-27 21:44:22 -05:00
Chao Zheng 876f01ac19 fix(baoyu-md, baoyu-post-to-x): decode URL-encoded image paths
Obsidian creates markdown image links with URL-encoded filenames
(e.g. `Pasted%20image%2020260524.png`) but the actual file has spaces.
Add `decodeURIComponent()` before path resolution in both baoyu-md's
shared `resolveImagePath()` and baoyu-post-to-x's independent version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 21:43:53 -05:00
Jim Liu 宝玉 e1c0ff7c02 chore: release v2.2.1 2026-05-26 00:53:56 -05:00
Jim Liu 宝玉 860dd36bb6 docs(baoyu-image-gen): surface build-batch.ts in Usage and clarify {baseDir} script paths
Add the outline.md + prompts/ → batch.json one-liner to SKILL.md's Usage section so the
build-batch helper is discoverable next to --batchfile, and update build-batch.ts --help
to print the bun / npx-y bun invocations with the {baseDir}/scripts/... layout used by
the rest of the skill.
2026-05-26 00:52:51 -05:00
20 changed files with 726 additions and 140 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": "2.2.0" "version": "2.4.0"
}, },
"plugins": [ "plugins": [
{ {
+19
View File
@@ -2,6 +2,25 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 2.4.0 - 2026-05-29
### Features
- `baoyu-wechat-summary`: add an `@bot` Q&A section to both the normal and roast digests. Messages mentioning `@bot` / `@精华bot` (customizable via the new `bot_aliases` preference) are detected during the skeleton pass and answered in a dedicated section — earnest and helpful in the normal version, snarky-but-substantive in the roast version. Answers draw only on the chat context and the model's own knowledge (no web access) and honestly flag anything that needs real-time data
## 2.3.0 - 2026-05-28
### Features
- `baoyu-md`, `baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-x`: support Obsidian image wikilinks (`![[...]]`) alongside standard Markdown images, preserve mixed image order, and resolve Obsidian's default `Attachments/` paths (by @zcqqq)
### Fixes
- `baoyu-md`, `baoyu-post-to-x`: decode URL-encoded local image paths before resolution so filenames with spaces or CJK characters are handled correctly (by @zcqqq)
- `baoyu-md`, `baoyu-post-to-x`: harden decoded image path handling so malformed percent escapes fall back safely instead of breaking placeholder extraction (by @zcqqq)
## 2.2.1 - 2026-05-26
### Documentation
- `baoyu-image-gen`: surface `scripts/build-batch.ts` in SKILL.md's Usage section so the `outline.md` + `prompts/``batch.json` workflow (e.g., `baoyu-article-illustrator` output) is discoverable next to `--batchfile`. Clarify that all `scripts/...` paths in SKILL.md are relative to `{baseDir}` and point the Generation Mode table at `{baseDir}/scripts/build-batch.ts`. Update `build-batch.ts --help` to print `bun` / `npx -y bun` invocations with the `{baseDir}/scripts/...` layout used by the rest of the skill
## 2.2.0 - 2026-05-25 ## 2.2.0 - 2026-05-25
### Features ### Features
+19
View File
@@ -2,6 +2,25 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 2.4.0 - 2026-05-29
### 新功能
- `baoyu-wechat-summary`:为正常版和毒舌版简报新增「@bot 答疑」小节。识别群里提及 `@bot` / `@精华bot`(可通过新增的 `bot_aliases` 偏好自定义)的提问/请求,在专门小节中逐条回应——正常版真诚有用,毒舌版带刺但仍给干货。答复只依据群聊上下文和模型自有知识(不联网),需要实时数据的会如实注明
## 2.3.0 - 2026-05-28
### 新功能
- `baoyu-md``baoyu-markdown-to-html``baoyu-post-to-wechat``baoyu-post-to-x`:支持 Obsidian 图片 wikilink`![[...]]`),可与标准 Markdown 图片混用并保持图片顺序,同时解析 Obsidian 默认的 `Attachments/` 路径 (by @zcqqq)
### 修复
- `baoyu-md``baoyu-post-to-x`:解析本地图片路径前先解码 URL 编码,正确处理包含空格或中文等字符的文件名 (by @zcqqq)
- `baoyu-md``baoyu-post-to-x`:加固图片路径解码逻辑,遇到异常百分号转义时安全回退,避免中断占位符提取 (by @zcqqq)
## 2.2.1 - 2026-05-26
### 文档
- `baoyu-image-gen`:在 SKILL.md 的 Usage 段落中显式展示 `scripts/build-batch.ts`,使 `outline.md` + `prompts/``batch.json` 流程(例如 `baoyu-article-illustrator` 的产物)能与 `--batchfile` 并列被发现。明确 SKILL.md 中所有 `scripts/...` 路径均相对 `{baseDir}`,并把 Generation Mode 表中的指引改为 `{baseDir}/scripts/build-batch.ts``build-batch.ts --help` 同步打印 `bun` / `npx -y bun` 调用,与 skill 其它脚本统一使用 `{baseDir}/scripts/...` 写法
## 2.2.0 - 2026-05-25 ## 2.2.0 - 2026-05-25
### 新功能 ### 新功能
+1 -1
View File
@@ -1128,7 +1128,7 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
#### baoyu-wechat-summary #### baoyu-wechat-summary
Summarize WeChat group chat highlights into a structured digest. Extracts topics, quotes, and stats from group messages using [wx-cli](https://github.com/jackwener/wx-cli). Maintains per-group history and per-user profiles across runs. Supports normal and roast (毒舌) versions. Summarize WeChat group chat highlights into a structured digest. Extracts topics, quotes, and stats from group messages using [wx-cli](https://github.com/jackwener/wx-cli). Maintains per-group history and per-user profiles across runs. Supports normal and roast (毒舌) versions, and answers `@bot` questions raised in the chat.
```bash ```bash
# Summarize a group's recent messages # Summarize a group's recent messages
+1 -1
View File
@@ -1119,7 +1119,7 @@ AI 驱动的生成后端。
#### baoyu-wechat-summary #### baoyu-wechat-summary
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版。 微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版,并在简报中回应群里向 `@bot` 提出的问题
```bash ```bash
# 总结群最近消息 # 总结群最近消息
+13
View File
@@ -1787,6 +1787,10 @@
"resolved": "packages/baoyu-chrome-cdp", "resolved": "packages/baoyu-chrome-cdp",
"link": true "link": true
}, },
"node_modules/baoyu-codex-imagegen": {
"resolved": "packages/baoyu-codex-imagegen",
"link": true
},
"node_modules/baoyu-fetch": { "node_modules/baoyu-fetch": {
"resolved": "packages/baoyu-fetch", "resolved": "packages/baoyu-fetch",
"link": true "link": true
@@ -5018,6 +5022,15 @@
"bun": ">=1.2.0" "bun": ">=1.2.0"
} }
}, },
"packages/baoyu-codex-imagegen": {
"version": "0.1.0",
"bin": {
"codex-imagegen": "src/main.ts"
},
"engines": {
"bun": ">=1.2.0"
}
},
"packages/baoyu-fetch": { "packages/baoyu-fetch": {
"version": "0.1.2", "version": "0.1.2",
"dependencies": { "dependencies": {
+83 -9
View File
@@ -72694,15 +72694,32 @@ var import_node_path6 = __toESM(require("node:path"));
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) { function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
const images = []; const images = [];
let imageCounter = 0; let imageCounter = 0;
const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => { let lastIndex = 0;
let rewritten = "";
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
for (const match of markdown2.matchAll(imagePattern)) {
const fullMatch = match[0];
const matchIndex = match.index ?? 0;
const markdownAlt = match[1];
const markdownSrc = match[2];
const wikilinkTarget = match[3];
const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null;
if (wikilinkTarget && !wikilinkImage) {
continue;
}
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
const placeholder = `${placeholderPrefix}${++imageCounter}`; const placeholder = `${placeholderPrefix}${++imageCounter}`;
rewritten += markdown2.slice(lastIndex, matchIndex);
images.push({ images.push({
alt, alt,
originalPath: src, originalPath,
placeholder placeholder
}); });
return placeholder; rewritten += placeholder;
}); lastIndex = matchIndex + fullMatch.length;
}
rewritten += markdown2.slice(lastIndex);
return { images, markdown: rewritten }; return { images, markdown: rewritten };
} }
function getImageExtension(urlOrPath) { function getImageExtension(urlOrPath) {
@@ -72757,8 +72774,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
} }
return localPath; return localPath;
} }
const resolved = import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath); return resolveLocalImagePath(imagePath, baseDir, logLabel);
return resolveLocalWithFallback(resolved, logLabel);
} }
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") { async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
const resolved = []; const resolved = [];
@@ -72770,10 +72786,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
} }
return resolved; return resolved;
} }
function resolveLocalWithFallback(resolved, logLabel) { function parseObsidianImageWikilink(target) {
const separatorIndex = target.indexOf("|");
const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim();
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
if (!hasExplicitImageExtension(originalPath)) {
return null;
}
return { originalPath, alt };
}
function hasExplicitImageExtension(value2) {
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2);
}
function resolveLocalImagePath(imagePath, baseDir, logLabel) {
const decoded = safeDecodeImagePath(imagePath);
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir));
if (decoded === imagePath || import_node_fs5.default.existsSync(decodedWithFallback)) {
return decodedWithFallback;
}
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir));
}
function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) {
if (import_node_fs5.default.existsSync(resolved)) { if (import_node_fs5.default.existsSync(resolved)) {
return resolved; return resolved;
} }
if (attachmentResolved && import_node_fs5.default.existsSync(attachmentResolved)) {
logImageFallback(resolved, attachmentResolved, logLabel);
return attachmentResolved;
}
const originalAlternative = findExtensionFallback(resolved);
if (originalAlternative) {
logImageFallback(resolved, originalAlternative, logLabel);
return originalAlternative;
}
if (attachmentResolved) {
const attachmentAlternative = findExtensionFallback(attachmentResolved);
if (attachmentAlternative) {
logImageFallback(resolved, attachmentAlternative, logLabel);
return attachmentAlternative;
}
}
return resolved;
}
function findExtensionFallback(resolved) {
const ext = import_node_path6.default.extname(resolved); const ext = import_node_path6.default.extname(resolved);
const base = ext ? resolved.slice(0, -ext.length) : resolved; const base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [ const alternatives = [
@@ -72788,10 +72844,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
for (const alternative of alternatives) { for (const alternative of alternatives) {
if (!import_node_fs5.default.existsSync(alternative)) if (!import_node_fs5.default.existsSync(alternative))
continue; continue;
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(resolved)} -> ${import_node_path6.default.basename(alternative)}`);
return alternative; return alternative;
} }
return resolved; return null;
}
function logImageFallback(fromPath, toPath, logLabel) {
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(fromPath)} -> ${import_node_path6.default.basename(toPath)}`);
}
function safeDecodeImagePath(imagePath) {
try {
return decodeURIComponent(imagePath);
} catch {
return imagePath;
}
}
function resolveAgainstBaseDir(imagePath, baseDir) {
return import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
}
function buildAttachmentFallbackPath(imagePath, baseDir) {
if (import_node_path6.default.isAbsolute(imagePath)) {
return;
}
return import_node_path6.default.resolve(baseDir, "Attachments", imagePath);
} }
// src/mermaid-preprocess.ts // src/mermaid-preprocess.ts
var import_node_fs6 = __toESM(require("node:fs")); var import_node_fs6 = __toESM(require("node:fs"));
+83 -9
View File
@@ -72620,15 +72620,32 @@ import path5 from "node:path";
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) { function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
const images = []; const images = [];
let imageCounter = 0; let imageCounter = 0;
const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => { let lastIndex = 0;
let rewritten = "";
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
for (const match of markdown2.matchAll(imagePattern)) {
const fullMatch = match[0];
const matchIndex = match.index ?? 0;
const markdownAlt = match[1];
const markdownSrc = match[2];
const wikilinkTarget = match[3];
const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null;
if (wikilinkTarget && !wikilinkImage) {
continue;
}
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
const placeholder = `${placeholderPrefix}${++imageCounter}`; const placeholder = `${placeholderPrefix}${++imageCounter}`;
rewritten += markdown2.slice(lastIndex, matchIndex);
images.push({ images.push({
alt, alt,
originalPath: src, originalPath,
placeholder placeholder
}); });
return placeholder; rewritten += placeholder;
}); lastIndex = matchIndex + fullMatch.length;
}
rewritten += markdown2.slice(lastIndex);
return { images, markdown: rewritten }; return { images, markdown: rewritten };
} }
function getImageExtension(urlOrPath) { function getImageExtension(urlOrPath) {
@@ -72683,8 +72700,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
} }
return localPath; return localPath;
} }
const resolved = path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath); return resolveLocalImagePath(imagePath, baseDir, logLabel);
return resolveLocalWithFallback(resolved, logLabel);
} }
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") { async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
const resolved = []; const resolved = [];
@@ -72696,10 +72712,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
} }
return resolved; return resolved;
} }
function resolveLocalWithFallback(resolved, logLabel) { function parseObsidianImageWikilink(target) {
const separatorIndex = target.indexOf("|");
const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim();
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
if (!hasExplicitImageExtension(originalPath)) {
return null;
}
return { originalPath, alt };
}
function hasExplicitImageExtension(value2) {
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2);
}
function resolveLocalImagePath(imagePath, baseDir, logLabel) {
const decoded = safeDecodeImagePath(imagePath);
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir));
if (decoded === imagePath || fs5.existsSync(decodedWithFallback)) {
return decodedWithFallback;
}
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir));
}
function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) {
if (fs5.existsSync(resolved)) { if (fs5.existsSync(resolved)) {
return resolved; return resolved;
} }
if (attachmentResolved && fs5.existsSync(attachmentResolved)) {
logImageFallback(resolved, attachmentResolved, logLabel);
return attachmentResolved;
}
const originalAlternative = findExtensionFallback(resolved);
if (originalAlternative) {
logImageFallback(resolved, originalAlternative, logLabel);
return originalAlternative;
}
if (attachmentResolved) {
const attachmentAlternative = findExtensionFallback(attachmentResolved);
if (attachmentAlternative) {
logImageFallback(resolved, attachmentAlternative, logLabel);
return attachmentAlternative;
}
}
return resolved;
}
function findExtensionFallback(resolved) {
const ext = path5.extname(resolved); const ext = path5.extname(resolved);
const base = ext ? resolved.slice(0, -ext.length) : resolved; const base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [ const alternatives = [
@@ -72714,10 +72770,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
for (const alternative of alternatives) { for (const alternative of alternatives) {
if (!fs5.existsSync(alternative)) if (!fs5.existsSync(alternative))
continue; continue;
console.error(`[${logLabel}] Image fallback: ${path5.basename(resolved)} -> ${path5.basename(alternative)}`);
return alternative; return alternative;
} }
return resolved; return null;
}
function logImageFallback(fromPath, toPath, logLabel) {
console.error(`[${logLabel}] Image fallback: ${path5.basename(fromPath)} -> ${path5.basename(toPath)}`);
}
function safeDecodeImagePath(imagePath) {
try {
return decodeURIComponent(imagePath);
} catch {
return imagePath;
}
}
function resolveAgainstBaseDir(imagePath, baseDir) {
return path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
}
function buildAttachmentFallbackPath(imagePath, baseDir) {
if (path5.isAbsolute(imagePath)) {
return;
}
return path5.resolve(baseDir, "Attachments", imagePath);
} }
// src/mermaid-preprocess.ts // src/mermaid-preprocess.ts
import fs6 from "node:fs"; import fs6 from "node:fs";
+90
View File
@@ -28,6 +28,32 @@ test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image m
]); ]);
}); });
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks in document order", () => {
const result = replaceMarkdownImagesWithPlaceholders(
`Intro\n\n![[a.png]]\n\n![B](b.jpg)\n\n![[c.webp|C alt]]\n\n![[note]]`,
"IMG_",
);
assert.equal(result.markdown, `Intro\n\nIMG_1\n\nIMG_2\n\nIMG_3\n\n![[note]]`);
assert.deepEqual(result.images, [
{ alt: "", originalPath: "a.png", placeholder: "IMG_1" },
{ alt: "B", originalPath: "b.jpg", placeholder: "IMG_2" },
{ alt: "C alt", originalPath: "c.webp", placeholder: "IMG_3" },
]);
});
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks with paths", () => {
const result = replaceMarkdownImagesWithPlaceholders(
`![[Attachments/screenshot.png]]`,
"IMG_",
);
assert.equal(result.markdown, `IMG_1`);
assert.deepEqual(result.images, [
{ alt: "", originalPath: "Attachments/screenshot.png", placeholder: "IMG_1" },
]);
});
test("image extension and local fallback resolution handle common path variants", async (t) => { test("image extension and local fallback resolution handle common path variants", async (t) => {
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg"); assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
assert.equal(getImageExtension("/tmp/figure"), "png"); assert.equal(getImageExtension("/tmp/figure"), "png");
@@ -45,6 +71,70 @@ test("image extension and local fallback resolution handle common path variants"
assert.equal(resolved, path.join(baseDir, "figure.webp")); assert.equal(resolved, path.join(baseDir, "figure.webp"));
}); });
test("resolveImagePath falls back to Attachments subdirectory before extension variants", async (t) => {
const root = await makeTempDir("baoyu-md-attachments-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
const attachmentsDir = path.join(baseDir, "Attachments");
await fs.mkdir(attachmentsDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "png");
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(attachmentsDir, "figure.png"));
});
test("resolveImagePath prefers original path before Attachments fallback", async (t) => {
const root = await makeTempDir("baoyu-md-attachments-original-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
const attachmentsDir = path.join(baseDir, "Attachments");
await fs.mkdir(attachmentsDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "figure.png"), "png");
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "attachment png");
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(baseDir, "figure.png"));
});
test("resolveImagePath decodes URL-encoded filenames with spaces", async (t) => {
const root = await makeTempDir("baoyu-md-urlencoded-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "Pasted image 20260524.png"), "png");
const resolved = await resolveImagePath("Pasted%20image%2020260524.png", baseDir, tempDir, "test");
assert.equal(resolved, path.join(baseDir, "Pasted image 20260524.png"));
});
test("resolveImagePath keeps literal percent filenames usable", async (t) => {
const root = await makeTempDir("baoyu-md-percent-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const baseDir = path.join(root, "article");
const tempDir = path.join(root, "tmp");
await fs.mkdir(baseDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(baseDir, "100% complete.png"), "png");
await fs.writeFile(path.join(baseDir, "diagram%23hash.png"), "png");
const malformedPercent = await resolveImagePath("100% complete.png", baseDir, tempDir, "test");
assert.equal(malformedPercent, path.join(baseDir, "100% complete.png"));
const literalEncodedPercent = await resolveImagePath("diagram%23hash.png", baseDir, tempDir, "test");
assert.equal(literalEncodedPercent, path.join(baseDir, "diagram%23hash.png"));
});
test("resolveContentImages resolves image placeholders against the content directory", async (t) => { test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
const root = await makeTempDir("baoyu-md-content-images-"); const root = await makeTempDir("baoyu-md-content-images-");
t.after(() => fs.rm(root, { recursive: true, force: true })); t.after(() => fs.rm(root, { recursive: true, force: true }));
+123 -13
View File
@@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders(
} { } {
const images: ImagePlaceholder[] = []; const images: ImagePlaceholder[] = [];
let imageCounter = 0; let imageCounter = 0;
let lastIndex = 0;
let rewritten = "";
const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => { const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
for (const match of markdown.matchAll(imagePattern)) {
const fullMatch = match[0];
const matchIndex = match.index ?? 0;
const markdownAlt = match[1];
const markdownSrc = match[2];
const wikilinkTarget = match[3];
const wikilinkImage = wikilinkTarget
? parseObsidianImageWikilink(wikilinkTarget)
: null;
if (wikilinkTarget && !wikilinkImage) {
continue;
}
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
const placeholder = `${placeholderPrefix}${++imageCounter}`; const placeholder = `${placeholderPrefix}${++imageCounter}`;
rewritten += markdown.slice(lastIndex, matchIndex);
images.push({ images.push({
alt, alt,
originalPath: src, originalPath,
placeholder, placeholder,
}); });
return placeholder; rewritten += placeholder;
}); lastIndex = matchIndex + fullMatch.length;
}
rewritten += markdown.slice(lastIndex);
return { images, markdown: rewritten }; return { images, markdown: rewritten };
} }
@@ -103,10 +126,7 @@ export async function resolveImagePath(
return localPath; return localPath;
} }
const resolved = path.isAbsolute(imagePath) return resolveLocalImagePath(imagePath, baseDir, logLabel);
? imagePath
: path.resolve(baseDir, imagePath);
return resolveLocalWithFallback(resolved, logLabel);
} }
export async function resolveContentImages( export async function resolveContentImages(
@@ -127,11 +147,79 @@ export async function resolveContentImages(
return resolved; return resolved;
} }
function resolveLocalWithFallback(resolved: string, logLabel: string): string { function parseObsidianImageWikilink(target: string): {
originalPath: string;
alt: string;
} | null {
const separatorIndex = target.indexOf("|");
const originalPath = (separatorIndex === -1
? target
: target.slice(0, separatorIndex)).trim();
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
if (!hasExplicitImageExtension(originalPath)) {
return null;
}
return { originalPath, alt };
}
function hasExplicitImageExtension(value: string): boolean {
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value);
}
function resolveLocalImagePath(imagePath: string, baseDir: string, logLabel: string): string {
const decoded = safeDecodeImagePath(imagePath);
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
const decodedWithFallback = resolveLocalWithFallback(
decodedResolved,
logLabel,
buildAttachmentFallbackPath(decoded, baseDir),
);
if (decoded === imagePath || fs.existsSync(decodedWithFallback)) {
return decodedWithFallback;
}
return resolveLocalWithFallback(
resolveAgainstBaseDir(imagePath, baseDir),
logLabel,
buildAttachmentFallbackPath(imagePath, baseDir),
);
}
function resolveLocalWithFallback(
resolved: string,
logLabel: string,
attachmentResolved?: string,
): string {
if (fs.existsSync(resolved)) { if (fs.existsSync(resolved)) {
return resolved; return resolved;
} }
if (attachmentResolved && fs.existsSync(attachmentResolved)) {
logImageFallback(resolved, attachmentResolved, logLabel);
return attachmentResolved;
}
const originalAlternative = findExtensionFallback(resolved);
if (originalAlternative) {
logImageFallback(resolved, originalAlternative, logLabel);
return originalAlternative;
}
if (attachmentResolved) {
const attachmentAlternative = findExtensionFallback(attachmentResolved);
if (attachmentAlternative) {
logImageFallback(resolved, attachmentAlternative, logLabel);
return attachmentAlternative;
}
}
return resolved;
}
function findExtensionFallback(resolved: string): string | null {
const ext = path.extname(resolved); const ext = path.extname(resolved);
const base = ext ? resolved.slice(0, -ext.length) : resolved; const base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [ const alternatives = [
@@ -146,11 +234,33 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string {
for (const alternative of alternatives) { for (const alternative of alternatives) {
if (!fs.existsSync(alternative)) continue; if (!fs.existsSync(alternative)) continue;
console.error(
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
);
return alternative; return alternative;
} }
return resolved; return null;
}
function logImageFallback(fromPath: string, toPath: string, logLabel: string): void {
console.error(
`[${logLabel}] Image fallback: ${path.basename(fromPath)} -> ${path.basename(toPath)}`,
);
}
function safeDecodeImagePath(imagePath: string): string {
try {
return decodeURIComponent(imagePath);
} catch {
return imagePath;
}
}
function resolveAgainstBaseDir(imagePath: string, baseDir: string): string {
return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath);
}
function buildAttachmentFallbackPath(imagePath: string, baseDir: string): string | undefined {
if (path.isAbsolute(imagePath)) {
return undefined;
}
return path.resolve(baseDir, "Attachments", imagePath);
} }
+6 -2
View File
@@ -27,7 +27,7 @@ Concrete `AskUserQuestion` references below are examples — substitute the loca
## Script Directory ## Script Directory
`{baseDir}` = this SKILL.md's directory. Main script: `{baseDir}/scripts/main.ts`. Resolve `${BUN_X}`: prefer `bun`; else `npx -y bun`; else suggest `brew install oven-sh/bun/bun`. `{baseDir}` = this SKILL.md's directory. All `scripts/...` paths below are relative to `{baseDir}`. Main script: `{baseDir}/scripts/main.ts`. Batch payload helper: `{baseDir}/scripts/build-batch.ts`. Resolve `${BUN_X}`: prefer `bun`; else `npx -y bun`; else suggest `brew install oven-sh/bun/bun`.
## Step 0: Load Preferences ⛔ BLOCKING ## Step 0: Load Preferences ⛔ BLOCKING
@@ -86,6 +86,10 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider c
# Batch mode # Batch mode
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
# Build a batch file from outline.md + prompts/ (e.g. baoyu-article-illustrator output)
${BUN_X} {baseDir}/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
``` ```
## Reference-Image Identity Preservation ## Reference-Image Identity Preservation
@@ -242,7 +246,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`.
| One image, or 1-2 simple images | Sequential | Lower coordination overhead, easier debugging | | One image, or 1-2 simple images | Sequential | Lower coordination overhead, easier debugging |
| Multiple images with saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, predictable throughput | | Multiple images with saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, predictable throughput |
| Each image still needs its own reasoning / prompt writing / style exploration | Subagents | Work is still exploratory, each needs independent analysis | | Each image still needs its own reasoning / prompt writing / style exploration | Subagents | Work is still exploratory, each needs independent analysis |
| Input is `outline.md` + `prompts/` (e.g. from `baoyu-article-illustrator`) | Batch — use `scripts/build-batch.ts` to assemble the payload | The outline + prompt files already contain everything needed | | Input is `outline.md` + `prompts/` (e.g. from `baoyu-article-illustrator`) | Batch — use `{baseDir}/scripts/build-batch.ts` to assemble the payload | The outline + prompt files already contain everything needed |
Rule of thumb: once prompt files are saved and the task is "generate all of these", prefer batch over subagents. Use subagents only when generation is coupled with per-image thinking or divergent creative exploration. Rule of thumb: once prompt files are saved and the task is "generate all of these", prefer batch over subagents. Use subagents only when generation is coupled with per-image thinking or divergent creative exploration.
@@ -28,7 +28,8 @@ type PromptReference = {
function printUsage(): void { function printUsage(): void {
console.log(`Usage: console.log(`Usage:
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments bun <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
npx -y tsx <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
Options: Options:
--outline <path> Path to outline.md --outline <path> Path to outline.md
@@ -54,3 +54,76 @@ test("CLI forwards wrapper title and package render options", async () => {
/<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/, /<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/,
); );
}); });
test("CLI renders Obsidian wikilink images with alt text and Attachments fallback", async () => {
const root = await makeTempDir("baoyu-markdown-to-html-wikilink-cli-");
const attachmentsDir = path.join(root, "Attachments");
await fs.mkdir(attachmentsDir, { recursive: true });
await fs.writeFile(path.join(root, "a.png"), "a", "utf-8");
await fs.writeFile(path.join(attachmentsDir, "b.webp"), "b", "utf-8");
const markdownPath = path.join(root, "article.md");
await fs.writeFile(
markdownPath,
[
"## Section",
"",
"![[a.png]]",
"",
"![[b.webp|B alt]]",
].join("\n"),
"utf-8",
);
const { stdout } = await execFileAsync(
process.execPath,
[
"--import",
"tsx",
SCRIPT_PATH,
markdownPath,
"--keep-title",
],
{ cwd: SCRIPT_DIR },
);
const result = JSON.parse(stdout.trim()) as {
contentImages: Array<{
alt?: string;
localPath: string;
originalPath: string;
placeholder: string;
}>;
htmlPath: string;
};
assert.deepEqual(
result.contentImages.map(({ alt, localPath, originalPath, placeholder }) => ({
alt,
localPath,
originalPath,
placeholder,
})),
[
{
alt: "",
localPath: path.join(root, "a.png"),
originalPath: "a.png",
placeholder: "MDTOHTMLIMGPH_1",
},
{
alt: "B alt",
localPath: path.join(attachmentsDir, "b.webp"),
originalPath: "b.webp",
placeholder: "MDTOHTMLIMGPH_2",
},
],
);
const html = await fs.readFile(result.htmlPath, "utf-8");
assert.match(html, /<img src="a\.png" data-local-path="[^"]+a\.png" alt=""/);
assert.match(
html,
/<img src="b\.webp" data-local-path="[^"]+Attachments[^"]+b\.webp" alt="B alt"/,
);
});
+15 -1
View File
@@ -27,6 +27,7 @@ interface ImageInfo {
placeholder: string; placeholder: string;
localPath: string; localPath: string;
originalPath: string; originalPath: string;
alt?: string;
} }
interface MermaidImageInfo { interface MermaidImageInfo {
@@ -58,6 +59,14 @@ type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
mermaid?: MermaidCliOptions; mermaid?: MermaidCliOptions;
}; };
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export async function convertMarkdown( export async function convertMarkdown(
markdownPath: string, markdownPath: string,
options?: ConvertMarkdownOptions, options?: ConvertMarkdownOptions,
@@ -160,7 +169,12 @@ export async function convertMarkdown(
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8"); let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
for (const image of contentImages) { for (const image of contentImages) {
const imgTag = `<img src="${image.originalPath}" data-local-path="${image.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`; const altAttr = image.alt !== undefined
? ` alt="${escapeHtmlAttribute(image.alt)}"`
: "";
const imgTag = `<img src="${escapeHtmlAttribute(image.originalPath)}" `
+ `data-local-path="${escapeHtmlAttribute(image.localPath)}"${altAttr} `
+ `style="display: block; width: 100%; margin: 1.5em auto;">`;
finalContent = finalContent.replace(image.placeholder, imgTag); finalContent = finalContent.replace(image.placeholder, imgTag);
} }
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8"); fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
@@ -22,6 +22,7 @@ interface ImageInfo {
placeholder: string; placeholder: string;
localPath: string; localPath: string;
originalPath: string; originalPath: string;
alt?: string;
} }
interface ParsedResult { interface ParsedResult {
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { parseMarkdown } from './md-to-html.ts';
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => {
const root = await makeTempDir('x-md-to-html-wikilinks-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const articleDir = path.join(root, 'article');
const attachmentsDir = path.join(articleDir, 'Attachments');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(attachmentsDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(articleDir, 'a.png'), 'a');
await fs.writeFile(path.join(articleDir, 'b.jpg'), 'b');
await fs.writeFile(path.join(attachmentsDir, 'c.webp'), 'c');
const markdownPath = path.join(articleDir, 'post.md');
await fs.writeFile(
markdownPath,
[
'# Title',
'',
'![[a.png]]',
'',
'![B alt](b.jpg)',
'',
'![[c.webp|C alt]]',
'',
'![[note]]',
].join('\n'),
);
const result = await parseMarkdown(markdownPath, { tempDir });
assert.deepEqual(
result.contentImages.map(({ placeholder, originalPath, alt, localPath }) => ({
placeholder,
originalPath,
alt,
localPath,
})),
[
{
placeholder: 'XIMGPH_1',
originalPath: 'a.png',
alt: '',
localPath: path.join(articleDir, 'a.png'),
},
{
placeholder: 'XIMGPH_2',
originalPath: 'b.jpg',
alt: 'B alt',
localPath: path.join(articleDir, 'b.jpg'),
},
{
placeholder: 'XIMGPH_3',
originalPath: 'c.webp',
alt: 'C alt',
localPath: path.join(attachmentsDir, 'c.webp'),
},
],
);
assert.match(result.html, /XIMGPH_1[\s\S]*XIMGPH_2[\s\S]*XIMGPH_3/);
assert.match(result.html, /!\[\[note\]\]/);
});
test('parseMarkdown resolves encoded spaces and literal percent image paths', async (t) => {
const root = await makeTempDir('baoyu-post-to-x-images-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
const articlePath = path.join(root, 'article.md');
const tempDir = path.join(root, 'tmp');
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(root, 'Pasted image.png'), 'png');
await fs.writeFile(path.join(root, '100%.png'), 'png');
await fs.writeFile(
articlePath,
[
'# Title',
'',
'![encoded](Pasted%20image.png)',
'',
'![literal](100%.png)',
].join('\n'),
);
const result = await parseMarkdown(articlePath, { tempDir });
assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png'));
assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png'));
});
+27 -100
View File
@@ -1,10 +1,8 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import https from 'node:https';
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 { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import frontMatter from 'front-matter'; import frontMatter from 'front-matter';
@@ -15,7 +13,11 @@ import remarkCjkFriendly from 'remark-cjk-friendly';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify'; import remarkStringify from 'remark-stringify';
import { preprocessMermaidInMarkdown } from 'baoyu-md'; import {
preprocessMermaidInMarkdown,
replaceMarkdownImagesWithPlaceholders,
resolveImagePath,
} from 'baoyu-md';
import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid'; import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';
interface ImageInfo { interface ImageInfo {
@@ -23,6 +25,7 @@ interface ImageInfo {
localPath: string; localPath: string;
originalPath: string; originalPath: string;
blockIndex: number; blockIndex: number;
alt?: string;
} }
interface ParsedMarkdown { interface ParsedMarkdown {
@@ -109,85 +112,6 @@ function extractTitleFromMarkdown(markdown: string): string {
return ''; return '';
} }
function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise<void> {
return new Promise((resolve, reject) => {
if (!url.startsWith('https://')) {
reject(new Error(`Refusing non-HTTPS download: ${url}`));
return;
}
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'));
return;
}
const file = fs.createWriteStream(destPath);
const request = https.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, maxRedirects - 1).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://')) {
console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`);
return '';
}
if (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(`[md-to-html] Downloading: ${imagePath}`);
await downloadFile(imagePath, localPath);
}
return localPath;
}
if (path.isAbsolute(imagePath)) {
return imagePath;
}
return path.resolve(baseDir, imagePath);
}
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text return text
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -197,6 +121,10 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlightCode(code: string, lang: string): string { function highlightCode(code: string, lang: string): string {
try { try {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
@@ -222,7 +150,7 @@ function preprocessCjkMarkdown(markdown: string): string {
} }
} }
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } { function convertMarkdownToHtml(markdown: string): { html: string; totalBlocks: number } {
const preprocessedMarkdown = preprocessCjkMarkdown(markdown); const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true }); const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
@@ -254,7 +182,7 @@ function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, al
image({ href, text }: Tokens.Image): string { image({ href, text }: Tokens.Image): string {
if (!href) return ''; if (!href) return '';
return imageCallback(href, text ?? ''); return escapeHtml(text ?? '');
}, },
link({ href, title, tokens, text }: Tokens.Link): string { link({ href, title, tokens, text }: Tokens.Link): string {
@@ -340,22 +268,20 @@ export async function parseMarkdown(
); );
} }
const images: Array<{ src: string; alt: string; blockIndex: number }> = []; const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
let imageCounter = 0; mermaidProcessedBody,
'XIMGPH_',
const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => { );
const placeholder = `XIMGPH_${++imageCounter}`; const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);
images.push({ src, alt, blockIndex: -1 });
return placeholder;
});
const htmlLines = html.split('\n'); const htmlLines = html.split('\n');
const imageBlockIndexes = new Map<string, number>();
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
const placeholder = `XIMGPH_${i + 1}`; const placeholder = images[i]!.placeholder;
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) { for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
const regex = new RegExp(`\\b${placeholder}\\b`); const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`);
if (regex.test(htmlLines[lineIndex]!)) { if (regex.test(htmlLines[lineIndex]!)) {
images[i]!.blockIndex = lineIndex; imageBlockIndexes.set(placeholder, lineIndex);
break; break;
} }
} }
@@ -366,17 +292,18 @@ export async function parseMarkdown(
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
const img = images[i]!; const img = images[i]!;
const localPath = await resolveImagePath(img.src, baseDir, tempDir); const localPath = await resolveImagePath(img.originalPath, baseDir, tempDir, 'md-to-html');
if (i === 0 && !coverImagePath) { if (i === 0 && !coverImagePath) {
firstImageAsCover = localPath; firstImageAsCover = localPath;
} }
contentImages.push({ contentImages.push({
placeholder: `XIMGPH_${i + 1}`, placeholder: img.placeholder,
localPath, localPath,
originalPath: img.src, originalPath: img.originalPath,
blockIndex: img.blockIndex, alt: img.alt,
blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,
}); });
} }
@@ -384,7 +311,7 @@ export async function parseMarkdown(
let resolvedCoverImage: string | null = null; let resolvedCoverImage: string | null = null;
if (coverImagePath) { if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir); resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');
} else if (firstImageAsCover) { } else if (firstImageAsCover) {
resolvedCoverImage = firstImageAsCover; resolvedCoverImage = firstImageAsCover;
} }
@@ -31,3 +31,9 @@ default_version: normal
# resolves to `{project_root}/wechat`. Useful if you want a shared archive # resolves to `{project_root}/wechat`. Useful if you want a shared archive
# outside the current project. # outside the current project.
# data_root: ~/Documents/wechat-digests # data_root: ~/Documents/wechat-digests
# OPTIONAL — 触发「🤖 @bot 答疑」小节的名字(逗号分隔)。消息含 @<别名> 即视为
# 冲总结 bot 提的问题/请求,会在每版简报里专门答复。
# 请选用群里【不存在】的人物/机器人名,以免与真人 @ 混淆。
# Default: bot, 精华bot
# bot_aliases: bot, 精华bot
+25
View File
@@ -73,6 +73,7 @@ EXTEND.md is plain text with `key: value` or `key=value` lines, `#` for comments
| `default_version` | `normal` / `roast` / `both` | `normal` | Which version(s) to generate when the user doesn't say otherwise. | | `default_version` | `normal` / `roast` / `both` | `normal` | Which version(s) to generate when the user doesn't say otherwise. |
| `default_time_range` | string (e.g. `7d`, `24h`, `1d`) | (none) | Default range when the user omits time and there's no incremental anchor. | | `default_time_range` | string (e.g. `7d`, `24h`, `1d`) | (none) | Default range when the user omits time and there's no incremental anchor. |
| `data_root` | path | `{project_root}/wechat` | Override where digest folders live. | | `data_root` | path | `{project_root}/wechat` | Override where digest folders live. |
| `bot_aliases` | comma-separated strings | `bot, 精华bot` | Names that trigger the 「@bot 答疑」 section. A message containing `@<alias>` (case-insensitive) is treated as a question/request aimed at the digest bot. Pick names that do NOT match any real group member or existing bot, to avoid ambiguity. |
A starter template lives at [EXTEND.md.example](EXTEND.md.example). A starter template lives at [EXTEND.md.example](EXTEND.md.example).
@@ -239,6 +240,26 @@ If a match is found:
This is a heuristic — when uncertain (multiple matches, malformed title), default to `history.json` and tell the user what was skipped. This is a heuristic — when uncertain (multiple matches, malformed title), default to `history.json` and tell the user what was skipped.
### Step 3.9: Detect @bot requests (if any)
Some group members address the digest bot directly — e.g. `@bot 帮我把昨天的讨论捋一下` or `@精华bot 这个链接讲了啥`. Catch these so each digest can answer them in a dedicated section instead of dropping them as noise.
**Trigger**: a message whose text contains `@<alias>` for any alias in `bot_aliases` (from EXTEND.md; default `bot`, `精华bot`; case-insensitive). Aliases are stored as bare names — match the `@` prefix plus the alias.
**Extract** into an internal worklist `== @bot 请求清单 ==` (working memory only — never written to the final digest):
- Asker's real name — after Step 3.6 resolution; substitute `self_display` for the `self_wxid` user.
- Request body — the text after stripping the `@<alias>` prefix. If the message is a reply (per Step 3.5's quote/reply fields), include the quoted message as context.
- Anchor `local_id` for back-reference.
**Misfire filtering**: if a real member's nickname happens to equal an alias, judge by context. Keep only messages genuinely aimed at the digest bot (a question or request for it); skip clear person-to-person talk — a reply to that real person, or banter teasing them. (Choosing a `bot_aliases` value no real member uses avoids this at the source; the filter is a backstop.) Pure greetings/banter (`@bot 在吗`) may be kept with a brief reply.
**Answer-source constraint** (honored when rendering the section per [references/output-formats.md](references/output-formats.md)): answer from the group chat context plus your own knowledge only — **no web access**. For any request needing real-time or external information you can't verify, say so honestly (`这个我查不到实时数据,需要联网确认`) rather than fabricating.
**No hits** → both versions omit the @bot 答疑 section entirely.
Do this in the same read-through as Round 1's skeleton (via its `== @bot 请求清单 ==` block) so the messages aren't scanned twice.
Generate the digest in three rounds so nothing slips through. The methodology stays here in SKILL.md; the content/style rules live in [references/output-formats.md](references/output-formats.md) — read that file in Round 2 before drafting. Generate the digest in three rounds so nothing slips through. The methodology stays here in SKILL.md; the content/style rules live in [references/output-formats.md](references/output-formats.md) — read that file in Round 2 before drafting.
#### Round 1 — Build the skeleton #### Round 1 — Build the skeleton
@@ -258,6 +279,10 @@ Internal working format (not written to the final file):
== 发言统计 == == 发言统计 ==
1. XXX — N 条 2. YYY — N 条 ... 1. XXX — N 条 2. YYY — N 条 ...
== @bot 请求清单(如有)==
1. {提问者真名}(锚点 id:54080)— {去掉 @别名的请求正文}(reply 时附被回复内容)
(本期无 @bot 请求则写「无」)
``` ```
Topic principles: Topic principles:
@@ -17,6 +17,7 @@ Both versions share the same overall layout and writing rules; the differences a
[群友画像 — one entry per active user (3+ msgs)] [群友画像 — one entry per active user (3+ msgs)]
[Categorized body — 3-6 self-named sections per day] [Categorized body — 3-6 self-named sections per day]
[Optional pain-point section] [Optional pain-point section]
[Optional @bot Q&A section]
[Fixed footer] [Fixed footer]
``` ```
@@ -116,7 +117,23 @@ Example:
``` ```
- Skip the section entirely if there are no genuine pain points — don't pad with trivial questions. - Skip the section entirely if there are no genuine pain points — don't pad with trivial questions.
### 1.8 Footer ### 1.8 @bot 答疑 section (optional)
- 仅当 SKILL.md Step 3.9 本批捕获到至少一条真实 @bot 请求时出现;否则整段省略。
- Heading: `🤖 @bot 答疑`
- 一条请求一个条目(• 请求行 + 缩进的 🤖 答复行)。多人问同一件事合并成一答。
- **请求行措辞自由发挥**:点出提问者真名 + 自然转述其请求即可,别套「X 问:」这类固定句式。
- 语气:真诚、热心、有用的助手——与普通版整体一致。答复落地、给具体建议,别空泛。
- 来源:仅群聊上下文 + 自有知识,不联网。需实时/外部数据又无法核实的,如实说明(`这个我查不到实时数据,需要联网确认`),不编造。
- Format(遵守 §3:不用 markdown、列表用 •、标题一个 emoji):
```
🤖 @bot 答疑
• {提问者 + 自然转述的请求}
🤖 {真诚、简洁、有用的回答;查不到实时信息就如实说明}
```
### 1.9 Footer
Fixed line, last in file: Fixed line, last in file:
@@ -130,7 +147,7 @@ No date, no signature, no version number.
## 2. Roast version (毒舌版) ## 2. Roast version (毒舌版)
Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅的风格重写。整体结构与普通版相同(统计区块、开头概览、群友画像、正文分类、结尾),但风格完全不同。痛点部分省略。仅当 `include_roast=true` 时生成。标题加 "毒舌版" 后缀。 Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅的风格重写。整体结构与普通版相同(统计区块、开头概览、群友画像、正文分类、@bot 答疑(毒舌值班版,如有)、结尾),但风格完全不同。痛点部分省略。仅当 `include_roast=true` 时生成。标题加 "毒舌版" 后缀。
风格要求: 风格要求:
- 你是一位以尖锐和挑衅风格著称的专业评论员 - 你是一位以尖锐和挑衅风格著称的专业评论员
@@ -140,6 +157,14 @@ Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅
- 开头概览用更戏谑的口吻,突出荒诞和讽刺 - 开头概览用更戏谑的口吻,突出荒诞和讽刺
- 正文话题标题可以改得更损 - 正文话题标题可以改得更损
- 引用原话时配上辛辣点评 - 引用原话时配上辛辣点评
- @bot 答疑改为「毒舌值班版」(本批有 @bot 请求时才出现,见 SKILL.md Step 3.9,放结尾前;无则省略):照样把干货答出来,但裹上调侃、嘴硬、吐槽提问者的口吻,与 roast 整体一致;来源同样只用群聊上下文 + 自有知识、不联网,查不到就嘴硬地承认查不到;同守下方红线。请求行措辞自由发挥,用调侃口吻点出提问者和请求即可,别套「又来了」这类固定句式。标题如 `🤖 bot 答疑(毒舌值班版)`,结构示意:
```
🤖 bot 答疑(毒舌值班版)
• {提问者 + 请求,调侃口吻}
🤖 {带刺但仍有实质内容的回答}
```
- 结尾改为:本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责 - 结尾改为:本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
注意:毒舌但不恶毒,调侃但不人身攻击。目标是让群友看了会笑,而不是生气。具体红线: 注意:毒舌但不恶毒,调侃但不人身攻击。目标是让群友看了会笑,而不是生气。具体红线:
@@ -227,6 +252,11 @@ When you forget the structure mid-write, this is the skeleton:
状态: ⚠️ 部分解决 状态: ⚠️ 部分解决
方案: {若有} 方案: {若有}
🤖 @bot 答疑(可选,没有就不写)
• {提问者 + 请求,自然转述}
🤖 {真诚有用的回答}
本简报由 AI 自动生成 本简报由 AI 自动生成
``` ```
@@ -252,6 +282,11 @@ When you forget the structure mid-write, this is the skeleton:
{保留真实引用的毒舌叙述} {保留真实引用的毒舌叙述}
🤖 bot 答疑(毒舌值班版,可选)
• {提问者 + 请求,调侃口吻}
🤖 {带刺但仍有实质的回答}
本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责 本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
``` ```
@@ -271,3 +306,4 @@ Before writing the digest file, mentally walk through:
8. No markdown bold/heading/link syntax leaked through? 8. No markdown bold/heading/link syntax leaked through?
9. (Roast only) Every roast bullet would pass the §2 红线 audit? 9. (Roast only) Every roast bullet would pass the §2 红线 audit?
10. Footer line exact match? 10. Footer line exact match?
11. (本批有 @bot 请求时)两版各有对应 @bot 答疑小节?普通版真诚有用、毒舌版带刺仍有干货?无编造的实时信息?