From 81f4b8f827055bf180c5b54312e73cf5f554fe72 Mon Sep 17 00:00:00 2001 From: mateaix <57164338+mateaix@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:03:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(content-studio):=20=E5=B0=8F=E7=BA=A2?= =?UTF-8?q?=E4=B9=A6=E4=BB=A5=E5=9B=BE=E4=B8=BA=E4=B8=BB=E6=89=93=E5=8C=85?= =?UTF-8?q?=20xhs=5Fpackage=EF=BC=88=E5=BC=BA=E5=88=B6=E2=89=A53=E5=9B=BE?= =?UTF-8?q?=20+=20=E5=9C=A8=E7=BA=BF=E9=A2=84=E8=A7=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vip/mate/tool/builtin/XhsPackageTool.java | 366 ++++++++++++++++++ .../src/main/resources/db/data-en.sql | 4 + .../main/resources/db/data-kingbase-en.sql | 4 + .../main/resources/db/data-kingbase-zh.sql | 4 + .../src/main/resources/db/data-mysql-en.sql | 4 + .../src/main/resources/db/data-mysql-zh.sql | 4 + .../src/main/resources/db/data-zh.sql | 4 + .../h2/V168__content_studio_seed.sql | 6 +- .../kingbase/V168__content_studio_seed.sql | 6 +- .../mysql/V168__content_studio_seed.sql | 6 +- .../main/resources/skills/xhs_note/SKILL.md | 39 +- .../vip/mate/tool/builtin/XhsPackageTest.java | 84 ++++ 12 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java new file mode 100644 index 00000000..27c687fd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java @@ -0,0 +1,366 @@ +package vip.mate.tool.builtin; + +import cn.hutool.http.HttpUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.tool.browser.UrlSafetyChecker; +import vip.mate.tool.document.GeneratedFileCache; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Built-in tool: assemble a Xiaohongshu (小红书) image-text note into an + * image-first online preview plus a downloadable material bundle — the + * flagship delivery step for 小红书, mirroring what {@code gzh_package} does for + * 公众号. + * + *
小红书 is an image-first medium: readers swipe a set of vertical (3:4) + * cards, and the copy is supporting. This tool therefore renders a phone-style + * preview where the images dominate (a horizontal swipe carousel up top) and the + * title / body / topic tags sit beneath as support, and it requires at least + * {@value #MIN_IMAGES} images (a cover plus content cards / photos) — packaging + * fewer is refused so a note never ships text-heavy. + * + *
Image references are usually {@code render_html_image} / {@code image_generate} + * outputs ({@code /api/v1/files/generated/{id}} links), resolved to bytes via + * {@link GeneratedFileCache}; plain http(s) URLs and workspace file paths also + * work. A reference that points at a file's logical name instead of its issued id + * is self-healed by filename, the same way {@code gzh_package} resolves its cover. + * + *
Outputs: an online preview (served {@code text/html} behind a strict CSP),
+ * plus a {@code .zip} of numbered card images + {@code 文案.txt}. Publishing stays
+ * manual — 小红书 has no official publish API — so the result carries the same
+ * creator-platform upload steps as {@code xhs_publish}.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class XhsPackageTool {
+
+ /** 小红书 is image-first: a note must carry at least a cover plus two more images. */
+ private static final int MIN_IMAGES = 3;
+ /** Xiaohongshu allows up to 18 images per note. */
+ private static final int MAX_IMAGES = 18;
+ private static final String CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish";
+
+ // Palette — light, clean, 小红书-ish.
+ private static final String INK = "#222222";
+ private static final String MUTED = "#7a7a7a";
+ private static final String TAG = "#13386c";
+ private static final String CARD_BG = "#ffffff";
+ private static final String PAGE_BG = "#f4f4f4";
+
+ private final GeneratedFileCache cache;
+
+ @Tool(name = "xhs_package", description = """
+ Package a Xiaohongshu (小红书) note into an IMAGE-FIRST online preview plus a
+ downloadable material bundle — the default delivery step of xhs_note.
+
+ 小红书 leads with images: pass the ordered card images (first = cover) and the
+ copy plays a supporting role. REQUIRES at least 3 images (a cover + >=2 content
+ cards / photos); fewer is refused, so generate enough with image_generate
+ (aspectRatio=portrait) / render_html_image first.
+
+ Params: title, body (with emoji + line breaks), tags (comma-separated), and
+ images (comma-separated references in display order — render_html_image /
+ image_generate URLs /api/v1/files/generated/{id}, http(s) image URLs, or
+ workspace file paths).
+
+ Returns: an 在线预览 link (a phone-style swipe preview: images up top, copy
+ below), a 素材下载 .zip (numbered card images + 文案.txt), and the manual
+ creator-platform upload steps. 小红书 has no publish API — never auto-uploads.
+ """)
+ public String xhs_package(
+ @ToolParam(description = "Note title (小红书 标题, <=20 chars recommended)")
+ String title,
+ @ToolParam(description = "Note body text, with emoji and line breaks")
+ String body,
+ @ToolParam(description = "Topic tags, comma-separated, e.g. 咖啡,探店,周末去哪儿", required = false)
+ String tags,
+ @ToolParam(description = "Comma-separated image references in display order (first = cover); >=3 required")
+ String images,
+ @Nullable ToolContext ctx) {
+
+ if (title == null || title.isBlank()) {
+ return "Error: title is required.";
+ }
+
+ // Resolve images first — 小红书 is image-first, so this is the gate.
+ List ← 左右滑动查看 "
+ + imgs.size() + " 张图 → "
+ + nl2br(body.trim()) + " ");
+ for (String t : tags.split(",")) {
+ String tag = t.trim().replaceFirst("^#", "");
+ if (!tag.isEmpty()) {
+ tagHtml.append("#")
+ .append(escapeText(tag)).append("");
+ }
+ }
+ tagHtml.append(""
+ + escapeText(title) + "
"
+ + bodyHtml + tagHtml
+ + "
");
+ }
+
+ private static String escapeText(String s) {
+ return s.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+
+ private static String escapeAttr(String s) {
+ return escapeText(s).replace("\"", """);
+ }
+
+ private record ResolvedImg(byte[] bytes, String ext, String url) {}
+}
diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql
index 5b1ef32f..6db36b2c 100644
--- a/mateclaw-server/src/main/resources/db/data-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-en.sql
@@ -1950,3 +1950,7 @@ VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finis
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as  in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
index 2c8d7b49..a7f2f1e9 100644
--- a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
@@ -1875,3 +1875,7 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as  in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
index ef66abdd..dea6ce35 100644
--- a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
@@ -1872,3 +1872,7 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
index 9d3e1a12..12452bf6 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
@@ -1991,3 +1991,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as  in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
index 9efe9862..b34d5bbe 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
@@ -1988,3 +1988,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql
index a9180ff6..6cfef638 100644
--- a/mateclaw-server/src/main/resources/db/data-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-zh.sql
@@ -1951,3 +1951,7 @@ VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿(M
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V168__content_studio_seed.sql b/mateclaw-server/src/main/resources/db/migration/h2/V168__content_studio_seed.sql
index 3a64c9d3..6c208aaa 100644
--- a/mateclaw-server/src/main/resources/db/migration/h2/V168__content_studio_seed.sql
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V168__content_studio_seed.sql
@@ -1,7 +1,7 @@
-- V168: Content Studio scenario seed (公众号 / 小红书 图文创作).
-- Delivers to EXISTING databases the rows fresh installs get from db/data-*.sql:
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
--- capture_screenshot), the 内容工作室 (Content Studio) agent, and two disabled
+-- capture_screenshot, xhs_package), the 内容工作室 (Content Studio) agent, and two disabled
-- cron templates. DatabaseBootstrapRunner skips seeding once a database is
-- initialized, so these would otherwise never reach upgraders. Idempotent upsert
-- on id; content is the default (zh-CN) locale — a fresh install re-runs the
@@ -28,6 +28,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
+
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文:选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V168__content_studio_seed.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V168__content_studio_seed.sql
index 78776575..1eec9f29 100644
--- a/mateclaw-server/src/main/resources/db/migration/kingbase/V168__content_studio_seed.sql
+++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V168__content_studio_seed.sql
@@ -1,7 +1,7 @@
-- V168: Content Studio scenario seed (公众号 / 小红书 图文创作).
-- Delivers to EXISTING databases the rows fresh installs get from db/data-*.sql:
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
--- capture_screenshot), the 内容工作室 (Content Studio) agent, and two disabled
+-- capture_screenshot, xhs_package), the 内容工作室 (Content Studio) agent, and two disabled
-- cron templates. DatabaseBootstrapRunner skips seeding once a database is
-- initialized, so these would otherwise never reach upgraders. Idempotent upsert
-- on id; content is the default (zh-CN) locale — a fresh install re-runs the
@@ -28,6 +28,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文:选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V168__content_studio_seed.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V168__content_studio_seed.sql
index 9b5f9ea9..6c56b99c 100644
--- a/mateclaw-server/src/main/resources/db/migration/mysql/V168__content_studio_seed.sql
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V168__content_studio_seed.sql
@@ -1,7 +1,7 @@
-- V168: Content Studio scenario seed (公众号 / 小红书 图文创作).
-- Delivers to EXISTING databases the rows fresh installs get from db/data-*.sql:
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
--- capture_screenshot), the 内容工作室 (Content Studio) agent, and two disabled
+-- capture_screenshot, xhs_package), the 内容工作室 (Content Studio) agent, and two disabled
-- cron templates. DatabaseBootstrapRunner skips seeding once a database is
-- initialized, so these would otherwise never reach upgraders. Idempotent upsert
-- on id; content is the default (zh-CN) locale — a fresh install re-runs the
@@ -28,6 +28,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels)并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图,把返回 URL 以  嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文:选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
diff --git a/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md b/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md
index 69f2f025..dfa7d8d6 100644
--- a/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md
+++ b/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md
@@ -1,7 +1,7 @@
---
name: xhs_note
-description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→图文卡片(HTML→图)→去AI化→交付。标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片。honors user persona & style memory.'
-version: 1.0.0
+description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→配图(≥3 张竖版)→去AI化→在线预览打包交付。以图为主、文字辅助:标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片,最少 3 张图。honors user persona & style memory.'
+version: 1.1.0
tags:
- 小红书
- 图文
@@ -18,6 +18,10 @@ platforms:
把一个主题做成可直接发布的小红书笔记:文案 + 竖版图文卡片。
+> 🖼️ **小红书是「以图为主、文字辅助」的平台**。读者先滑图、再看字——首图(封面)决定点不点进来,图不够好、不够多,文案再好也没人看。
+>
+> **硬性要求:每篇笔记至少 3 张竖版图(1 封面 + ≥2 张内容图/照片)**。`xhs_package` 会强制校验,不足 3 张直接拒绝打包。图要成组、风格统一、信息落在图上(大标题/清单/对比都做进图里),正文只作补充。
+
## 开工前:读取共享人设记忆
先用 `recall_structured` 取回并全程遵守:
@@ -52,7 +56,18 @@ platforms:
大词 + 中词 + 长尾组合,例:`#护肤` `#敏感肌护肤` `#学生党平价护肤`。
-### 2. 图文卡片(HTML → 图)
+### 2. 配图(以图为主,**≥3 张竖版**)
+
+这是小红书的重头戏。**至少出 3 张 3:4 竖版图**,一组风格统一:
+
+1. **封面(第 1 张,必出)** — 大标题 + 一句钩子,缩略图上就能读懂、想点进来。
+2. **内容图(≥2 张)** — 把干货做进图里:清单卡、步骤卡、对比卡、金句卡,或用 `image_generate`(`aspectRatio=portrait`)出实拍风照片/场景图。一条要点一张,别把所有字堆一张。
+3. **结尾图(可选)** — 关注 / 互动引导卡。
+
+两种出图方式,按需混用,凑够 ≥3 张:
+
+- **HTML 卡片 → 图**:用下面的模板库填文案后 `render_html_image` 渲染。
+- **AI 生成照片/背景**:`image_generate(action=generate, aspectRatio=portrait)`(3:4 竖版),做封面底图或实拍风内容图。
**卡片模板库**(竖版 3:4,挑选组合或据用户口味自创):
- `references/xhs_card_cover.html` — 封面 / 大标题。
@@ -81,21 +96,25 @@ platforms:
`load_skill deai_humanize`,对正文跑"打分→改写→复检"循环,`platform=xhs`,目标 `score ≤ 55`。小红书口吻要碎、要有情绪,别写成公众号。
-### 4. 交付
+### 4. 打包交付(xhs_package —— 在线预览 + 素材下载)
-用 `xhs_publish`(action=export)把**卡片图 + 正文 + 话题标签**打成一个 `.zip` 发布包,一键下载:
+**默认用 `xhs_package` 交付**。它产出小红书风的**在线预览**(手机版:图在上、可左右滑动,标题/正文/标签在下辅助)+ **素材 zip**(按 01、02… 编号的卡片图 + 文案.txt),并附手动上传步骤:
```
-xhs_publish(action="export", title="<标题>", body="<正文>",
+xhs_package(title="<标题>", body="<正文,含 emoji 与换行>",
tags="标签1,标签2,标签3",
- images="<封面卡的 render_html_image 下载链接>,<内容卡链接>,<结尾卡链接>")
+ images="<封面图链接>,<内容图1链接>,<内容图2链接>[,更多]")
```
-`images` 按顺序传每张卡片的 `render_html_image` 返回链接(首图即封面)。工具会返回发布包下载链接 + 手动上传步骤。
+`images` 按展示顺序传每张图的 `render_html_image` / `image_generate` 返回链接(**首图即封面**)。
-小红书**没有官方发布 API**:由用户下载后到创作平台手动上传完成发布,**不自动上传、不绕过任何风控/人机验证**。发布属于对外动作,必须用户明确同意。
+> ⚠️ **`xhs_package` 强制 ≥3 张图**:解析到的图不足 3 张会被直接拒绝,并提示去补图。所以第 2 步务必先把 ≥3 张竖版图都出好,再来打包。
-发布前对照 `banned_words` 扫一遍正文和标题,命中即标注替换。
+把返回的**在线预览链接**发给用户看;满意后由用户下载素材 zip,到创作平台手动上传。小红书**没有官方发布 API**:**不自动上传、不绕过任何风控/人机验证**。发布属于对外动作,必须用户明确同意。
+
+(旧的 `xhs_publish` 只出 zip、无在线预览,`xhs_package` 已覆盖并更完整;仅在用户只要发布包、不需要预览时才用它。)
+
+打包前对照 `banned_words` 扫一遍正文和标题,命中即标注替换。
## 保存自定义卡片模板 / 对话升级技能
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java
new file mode 100644
index 00000000..283f27e6
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/XhsPackageTest.java
@@ -0,0 +1,84 @@
+package vip.mate.tool.builtin;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import vip.mate.tool.document.GeneratedFileCache;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.regex.Matcher;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Pin {@link XhsPackageTool}: 小红书 is image-first, so packaging must (a) refuse
+ * a note with fewer than 3 resolvable images, (b) render an image-first preview
+ * (the images come before the copy), and (c) self-heal an image referenced by
+ * filename instead of its issued id.
+ */
+class XhsPackageTest {
+
+ private GeneratedFileCache cache;
+ private XhsPackageTool tool;
+
+ @BeforeEach
+ void setUp(@TempDir Path tempDir) {
+ cache = new GeneratedFileCache(tempDir);
+ tool = new XhsPackageTool(cache);
+ }
+
+ private String putImg(String name) {
+ String id = cache.put("PNGDATA".getBytes(), name, "image/png");
+ return "/api/v1/files/generated/" + id;
+ }
+
+ /** Read back the online-preview HTML that the tool stored, given its result text. */
+ private String previewHtml(String out) {
+ Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(out);
+ assertTrue(m.find(), "result should contain a preview URL");
+ return new String(cache.get(m.group(1)).orElseThrow().bytes(), StandardCharsets.UTF_8);
+ }
+
+ @Test
+ @DisplayName("fewer than 3 images → refused, no preview minted")
+ void refusesUnderThreeImages() {
+ String imgs = putImg("cover.png") + "," + putImg("c1.png");
+ String out = tool.xhs_package("夏日穿搭", "正文", "穿搭,夏天", imgs, null);
+ assertTrue(out.contains("至少需要 3 张"), "should demand >=3 images; got:\n" + out);
+ assertFalse(out.contains("在线预览"), "must not produce a preview when refused");
+ }
+
+ @Test
+ @DisplayName("3 images → packaged; preview is image-first (images before the copy)")
+ void packagesThreeImagesImageFirst() {
+ String imgs = putImg("cover.png") + "," + putImg("c1.png") + "," + putImg("c2.png");
+ String out = tool.xhs_package("3天2夜厦门citywalk", "第一天去了鼓浪屿\n人不多", "厦门,citywalk,旅行", imgs, null);
+
+ assertTrue(out.contains("在线预览"), "should return a preview link");
+ assertTrue(out.contains("素材下载"), "should return a material zip");
+ assertTrue(out.contains("3 张图"), "should report the image count");
+
+ String html = previewHtml(out);
+ int imgCount = html.split("=3; got:\n" + out);
+ assertTrue(out.contains("3 张图"), "healed image should be counted");
+ }
+}