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": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "2.2.0"
"version": "2.4.0"
},
"plugins": [
{
+19
View File
@@ -2,6 +2,25 @@
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
### Features
+19
View File
@@ -2,6 +2,25 @@
[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
### 新功能
+1 -1
View File
@@ -1128,7 +1128,7 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
#### 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
# Summarize a group's recent messages
+1 -1
View File
@@ -1119,7 +1119,7 @@ AI 驱动的生成后端。
#### baoyu-wechat-summary
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版。
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版,并在简报中回应群里向 `@bot` 提出的问题
```bash
# 总结群最近消息
+13
View File
@@ -1787,6 +1787,10 @@
"resolved": "packages/baoyu-chrome-cdp",
"link": true
},
"node_modules/baoyu-codex-imagegen": {
"resolved": "packages/baoyu-codex-imagegen",
"link": true
},
"node_modules/baoyu-fetch": {
"resolved": "packages/baoyu-fetch",
"link": true
@@ -5018,6 +5022,15 @@
"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": {
"version": "0.1.2",
"dependencies": {
+83 -9
View File
@@ -72694,15 +72694,32 @@ var import_node_path6 = __toESM(require("node:path"));
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
const images = [];
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}`;
rewritten += markdown2.slice(lastIndex, matchIndex);
images.push({
alt,
originalPath: src,
originalPath,
placeholder
});
return placeholder;
});
rewritten += placeholder;
lastIndex = matchIndex + fullMatch.length;
}
rewritten += markdown2.slice(lastIndex);
return { images, markdown: rewritten };
}
function getImageExtension(urlOrPath) {
@@ -72757,8 +72774,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
}
return localPath;
}
const resolved = import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
return resolveLocalWithFallback(resolved, logLabel);
return resolveLocalImagePath(imagePath, baseDir, logLabel);
}
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
const resolved = [];
@@ -72770,10 +72786,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
}
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)) {
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 base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [
@@ -72788,10 +72844,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
for (const alternative of alternatives) {
if (!import_node_fs5.default.existsSync(alternative))
continue;
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(resolved)} -> ${import_node_path6.default.basename(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
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) {
const images = [];
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}`;
rewritten += markdown2.slice(lastIndex, matchIndex);
images.push({
alt,
originalPath: src,
originalPath,
placeholder
});
return placeholder;
});
rewritten += placeholder;
lastIndex = matchIndex + fullMatch.length;
}
rewritten += markdown2.slice(lastIndex);
return { images, markdown: rewritten };
}
function getImageExtension(urlOrPath) {
@@ -72683,8 +72700,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
}
return localPath;
}
const resolved = path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
return resolveLocalWithFallback(resolved, logLabel);
return resolveLocalImagePath(imagePath, baseDir, logLabel);
}
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
const resolved = [];
@@ -72696,10 +72712,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
}
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)) {
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 base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [
@@ -72714,10 +72770,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
for (const alternative of alternatives) {
if (!fs5.existsSync(alternative))
continue;
console.error(`[${logLabel}] Image fallback: ${path5.basename(resolved)} -> ${path5.basename(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
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) => {
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
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"));
});
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) => {
const root = await makeTempDir("baoyu-md-content-images-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
+123 -13
View File
@@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders(
} {
const images: ImagePlaceholder[] = [];
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}`;
rewritten += markdown.slice(lastIndex, matchIndex);
images.push({
alt,
originalPath: src,
originalPath,
placeholder,
});
return placeholder;
});
rewritten += placeholder;
lastIndex = matchIndex + fullMatch.length;
}
rewritten += markdown.slice(lastIndex);
return { images, markdown: rewritten };
}
@@ -103,10 +126,7 @@ export async function resolveImagePath(
return localPath;
}
const resolved = path.isAbsolute(imagePath)
? imagePath
: path.resolve(baseDir, imagePath);
return resolveLocalWithFallback(resolved, logLabel);
return resolveLocalImagePath(imagePath, baseDir, logLabel);
}
export async function resolveContentImages(
@@ -127,11 +147,79 @@ export async function resolveContentImages(
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)) {
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 base = ext ? resolved.slice(0, -ext.length) : resolved;
const alternatives = [
@@ -146,11 +234,33 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string {
for (const alternative of alternatives) {
if (!fs.existsSync(alternative)) continue;
console.error(
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(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
`{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
@@ -86,6 +86,10 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider c
# Batch mode
${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
@@ -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 |
| 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 |
| 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.
@@ -28,7 +28,8 @@ type PromptReference = {
function printUsage(): void {
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:
--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/,
);
});
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;
localPath: string;
originalPath: string;
alt?: string;
}
interface MermaidImageInfo {
@@ -58,6 +59,14 @@ type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
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(
markdownPath: string,
options?: ConvertMarkdownOptions,
@@ -160,7 +169,12 @@ export async function convertMarkdown(
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
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);
}
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
@@ -22,6 +22,7 @@ interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
alt?: string;
}
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 { mkdir, writeFile } from 'node:fs/promises';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import frontMatter from 'front-matter';
@@ -15,7 +13,11 @@ import remarkCjkFriendly from 'remark-cjk-friendly';
import remarkParse from 'remark-parse';
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';
interface ImageInfo {
@@ -23,6 +25,7 @@ interface ImageInfo {
localPath: string;
originalPath: string;
blockIndex: number;
alt?: string;
}
interface ParsedMarkdown {
@@ -109,85 +112,6 @@ function extractTitleFromMarkdown(markdown: string): string {
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 {
return text
.replace(/&/g, '&amp;')
@@ -197,6 +121,10 @@ function escapeHtml(text: string): string {
.replace(/'/g, '&#39;');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlightCode(code: string, lang: string): string {
try {
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 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 {
if (!href) return '';
return imageCallback(href, text ?? '');
return escapeHtml(text ?? '');
},
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 }> = [];
let imageCounter = 0;
const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => {
const placeholder = `XIMGPH_${++imageCounter}`;
images.push({ src, alt, blockIndex: -1 });
return placeholder;
});
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
mermaidProcessedBody,
'XIMGPH_',
);
const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);
const htmlLines = html.split('\n');
const imageBlockIndexes = new Map<string, number>();
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++) {
const regex = new RegExp(`\\b${placeholder}\\b`);
const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`);
if (regex.test(htmlLines[lineIndex]!)) {
images[i]!.blockIndex = lineIndex;
imageBlockIndexes.set(placeholder, lineIndex);
break;
}
}
@@ -366,17 +292,18 @@ export async function parseMarkdown(
for (let i = 0; i < images.length; 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) {
firstImageAsCover = localPath;
}
contentImages.push({
placeholder: `XIMGPH_${i + 1}`,
placeholder: img.placeholder,
localPath,
originalPath: img.src,
blockIndex: img.blockIndex,
originalPath: img.originalPath,
alt: img.alt,
blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,
});
}
@@ -384,7 +311,7 @@ export async function parseMarkdown(
let resolvedCoverImage: string | null = null;
if (coverImagePath) {
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');
} else if (firstImageAsCover) {
resolvedCoverImage = firstImageAsCover;
}
@@ -31,3 +31,9 @@ default_version: normal
# resolves to `{project_root}/wechat`. Useful if you want a shared archive
# outside the current project.
# 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_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. |
| `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).
@@ -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.
### 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.
#### Round 1 — Build the skeleton
@@ -258,6 +279,10 @@ Internal working format (not written to the final file):
== 发言统计 ==
1. XXX — N 条 2. YYY — N 条 ...
== @bot 请求清单(如有)==
1. {提问者真名}(锚点 id:54080)— {去掉 @别名的请求正文}reply 时附被回复内容)
(本期无 @bot 请求则写「无」)
```
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)]
[Categorized body — 3-6 self-named sections per day]
[Optional pain-point section]
[Optional @bot Q&A section]
[Fixed footer]
```
@@ -116,7 +117,23 @@ Example:
```
- 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:
@@ -130,7 +147,7 @@ No date, no signature, no version number.
## 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 自动生成,如有冒犯,概不负责
注意:毒舌但不恶毒,调侃但不人身攻击。目标是让群友看了会笑,而不是生气。具体红线:
@@ -227,6 +252,11 @@ When you forget the structure mid-write, this is the skeleton:
状态: ⚠️ 部分解决
方案: {若有}
🤖 @bot 答疑(可选,没有就不写)
• {提问者 + 请求,自然转述}
🤖 {真诚有用的回答}
本简报由 AI 自动生成
```
@@ -252,6 +282,11 @@ When you forget the structure mid-write, this is the skeleton:
{保留真实引用的毒舌叙述}
🤖 bot 答疑(毒舌值班版,可选)
• {提问者 + 请求,调侃口吻}
🤖 {带刺但仍有实质的回答}
本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
```
@@ -271,3 +306,4 @@ Before writing the digest file, mentally walk through:
8. No markdown bold/heading/link syntax leaked through?
9. (Roast only) Every roast bullet would pass the §2 红线 audit?
10. Footer line exact match?
11. (本批有 @bot 请求时)两版各有对应 @bot 答疑小节?普通版真诚有用、毒舌版带刺仍有干货?无编造的实时信息?