feat(content-studio): 小红书以图为主打包 xhs_package(强制≥3图 + 在线预览)

This commit is contained in:
mateaix 2026-07-11 20:03:34 +08:00
parent 85bee7a041
commit 81f4b8f827
12 changed files with 518 additions and 13 deletions

View File

@ -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
* <b>image-first</b> online preview plus a downloadable material bundle the
* flagship delivery step for 小红书, mirroring what {@code gzh_package} does for
* 公众号.
*
* <p>小红书 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 <b>requires at least
* {@value #MIN_IMAGES} images</b> (a cover plus content cards / photos) packaging
* fewer is refused so a note never ships text-heavy.
*
* <p>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.
*
* <p>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<ResolvedImg> imgs = new ArrayList<>();
List<String> skipped = new ArrayList<>();
if (images != null && !images.isBlank()) {
for (String raw : images.split(",")) {
String ref = raw.trim();
if (ref.isEmpty()) {
continue;
}
if (imgs.size() >= MAX_IMAGES) {
skipped.add(ref + " (超过 " + MAX_IMAGES + " 张上限)");
continue;
}
try {
imgs.add(resolveImage(ref, ctx));
} catch (Exception e) {
skipped.add(ref + " (" + e.getMessage() + ")");
}
}
}
if (imgs.size() < MIN_IMAGES) {
StringBuilder err = new StringBuilder();
err.append("⛔ 小红书以图为主,至少需要 ").append(MIN_IMAGES)
.append(" 张图1 封面 + ≥").append(MIN_IMAGES - 1)
.append(" 张内容图/照片),当前只解析到 ").append(imgs.size()).append(" 张。\n");
if (!skipped.isEmpty()) {
err.append("未解析:").append(String.join("", skipped)).append("\n");
}
err.append("请先用 image_generate(aspectRatio=portrait) 或 render_html_image 生成到 ≥")
.append(MIN_IMAGES).append(" 张竖版图,再调用 xhs_package。");
return err.toString();
}
// Online preview image-first phone layout served inline (text/html + CSP).
String previewDoc = buildPreview(title.trim(), body, tags, imgs);
String previewUrl = store(previewDoc.getBytes(StandardCharsets.UTF_8), "小红书预览.html", "text/html", ctx);
// Material bundle 文案.txt + numbered card images.
String copy = buildCopy(title, body, tags);
String zipUrl;
try {
zipUrl = store(buildZip(copy, imgs), "小红书素材.zip", "application/zip", ctx);
} catch (Exception e) {
log.warn("[XhsPackage] zip build failed: {}", e.getMessage());
zipUrl = null;
}
StringBuilder out = new StringBuilder();
out.append("✅ 小红书笔记已打包完成(").append(imgs.size()).append(" 张图,以图为主)。\n\n");
out.append("🔍 在线预览(手机版滑动预览,图在上、文案在下):").append(previewUrl).append('\n');
if (zipUrl != null) {
out.append("📦 素材下载(按 01、02… 编号的卡片图 + 文案.txt").append(zipUrl).append('\n');
}
if (!skipped.isEmpty()) {
out.append("⚠️ 未打包:").append(String.join("", skipped)).append('\n');
}
out.append('\n').append(guideText());
log.info("[XhsPackage] packaged '{}' ({} images, {} skipped)", title, imgs.size(), skipped.size());
return out.toString();
}
/** Build the image-first phone-style preview: a swipe carousel of cards, copy beneath. */
private String buildPreview(String title, @Nullable String body, @Nullable String tags, List<ResolvedImg> imgs) {
StringBuilder cards = new StringBuilder();
for (int i = 0; i < imgs.size(); i++) {
cards.append("<div style=\"flex:0 0 100%;scroll-snap-align:center;aspect-ratio:3/4;"
+ "background:#eee;border-radius:14px;overflow:hidden;\">"
+ "<img src=\"").append(escapeAttr(imgs.get(i).url()))
.append("\" alt=\"card ").append(i + 1)
.append("\" style=\"width:100%;height:100%;object-fit:cover;display:block;\" /></div>");
}
String swipeHint = imgs.size() > 1
? "<p style=\"text-align:center;color:" + MUTED + ";font-size:13px;margin:8px 0 0;\">← 左右滑动查看 "
+ imgs.size() + " 张图 →</p>"
: "";
String bodyHtml = (body != null && !body.isBlank())
? "<p style=\"font-size:15px;line-height:1.75;color:" + INK + ";margin:12px 0 0;white-space:pre-wrap;\">"
+ nl2br(body.trim()) + "</p>"
: "";
StringBuilder tagHtml = new StringBuilder();
if (tags != null && !tags.isBlank()) {
tagHtml.append("<p style=\"margin:14px 0 0;line-height:2;\">");
for (String t : tags.split(",")) {
String tag = t.trim().replaceFirst("^#", "");
if (!tag.isEmpty()) {
tagHtml.append("<span style=\"color:").append(TAG)
.append(";font-size:14px;margin-right:10px;\">#")
.append(escapeText(tag)).append("</span>");
}
}
tagHtml.append("</p>");
}
String note =
"<div style=\"max-width:390px;margin:0 auto;background:" + CARD_BG + ";border-radius:18px;"
+ "overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08);\">"
// Image carousel the hero, image-first.
+ "<div style=\"display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:8px;"
+ "padding:10px 10px 0;-webkit-overflow-scrolling:touch;\">" + cards + "</div>"
+ swipeHint
// Copy supporting, beneath the images.
+ "<div style=\"padding:6px 16px 20px;\">"
+ "<h1 style=\"font-size:18px;font-weight:700;line-height:1.5;color:" + INK + ";margin:12px 0 0;\">"
+ escapeText(title) + "</h1>"
+ bodyHtml + tagHtml
+ "</div></div>";
return "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + escapeText(title) + "</title></head>"
+ "<body style=\"margin:0;padding:20px 12px;background:" + PAGE_BG + ";"
+ "font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;\">"
+ note + "</body></html>";
}
private String buildCopy(@Nullable String title, @Nullable String body, @Nullable String tags) {
StringBuilder sb = new StringBuilder();
if (title != null && !title.isBlank()) {
sb.append("【标题】\n").append(title.trim()).append("\n\n");
}
if (body != null && !body.isBlank()) {
sb.append("【正文】\n").append(body.trim()).append("\n\n");
}
if (tags != null && !tags.isBlank()) {
StringBuilder tagLine = new StringBuilder("【话题标签】\n");
for (String t : tags.split(",")) {
String tag = t.trim().replaceFirst("^#", "");
if (!tag.isEmpty()) {
tagLine.append('#').append(tag).append(' ');
}
}
sb.append(tagLine.toString().trim()).append('\n');
}
return sb.toString().strip();
}
private byte[] buildZip(String copy, List<ResolvedImg> imgs) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(bos)) {
zos.putNextEntry(new ZipEntry("文案.txt"));
zos.write(copy.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
for (int i = 0; i < imgs.size(); i++) {
zos.putNextEntry(new ZipEntry(String.format("%02d.%s", i + 1, imgs.get(i).ext())));
zos.write(imgs.get(i).bytes());
zos.closeEntry();
}
}
return bos.toByteArray();
}
/**
* Resolve an image reference to bytes + extension + a servable URL. Generated
* ids resolve via the cache (self-healing a name-based reference by filename);
* http(s) URLs are downloaded; workspace files are read and re-stored so the
* preview has a servable URL to embed.
*/
private ResolvedImg resolveImage(String ref, @Nullable ToolContext ctx) throws Exception {
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref);
if (m.find()) {
String id = m.group(1);
Optional<GeneratedFileCache.Entry> entry = cache.get(id);
if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) {
// Self-heal: the ref may point at the file's name, not its id.
Optional<String> healed = cache.findIdByFilename(lastSegment(ref), "image/");
if (healed.isPresent()) {
id = healed.get();
entry = cache.get(id);
}
}
if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) {
throw new IllegalStateException("生成文件已过期或不存在");
}
return new ResolvedImg(entry.get().bytes(),
extFromMime(entry.get().mimeType(), "png"), cache.downloadUrl(id, ctx));
}
if (ref.startsWith("http://") || ref.startsWith("https://")) {
UrlSafetyChecker.check(ref);
byte[] bytes = HttpUtil.downloadBytes(ref);
if (bytes == null || bytes.length == 0) {
throw new IllegalStateException("下载为空");
}
return new ResolvedImg(bytes, extFromUrl(ref), ref);
}
// Workspace file path read, then re-store so the preview can serve it.
Path path = WorkspacePathGuard.validatePath(ref);
if (!Files.exists(path) || Files.isDirectory(path)) {
throw new IllegalStateException("文件不存在");
}
byte[] bytes = Files.readAllBytes(path);
String ext = extFromUrl(ref);
String id = cache.put(bytes, path.getFileName().toString(), mimeFromExt(ext));
return new ResolvedImg(bytes, ext, cache.downloadUrl(id, ctx));
}
private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) {
return cache.downloadUrl(cache.put(bytes, name, mime), ctx);
}
private String guideText() {
return """
📮 小红书发布步骤手动小红书无官方发布 API
1. 下载素材包并解压
2. 打开创作平台 %s 需已登录上传图文
3. 0102 顺序上传卡片图首图即封面
4. 文案.txt复制标题正文话题标签粘贴到对应输入框
5. 核对无违禁词后自行发布""".formatted(CREATOR_URL);
}
/** Last path segment of a reference, minus any {@code ?query} / {@code #fragment}. */
private static String lastSegment(String ref) {
String s = ref.split("[?#]")[0];
int slash = s.lastIndexOf('/');
return slash >= 0 ? s.substring(slash + 1) : s;
}
private static String extFromMime(String mime, String fallback) {
if (mime == null) {
return fallback;
}
return switch (mime.toLowerCase()) {
case "image/png" -> "png";
case "image/jpeg", "image/jpg" -> "jpg";
case "image/webp" -> "webp";
case "image/gif" -> "gif";
default -> fallback;
};
}
private static String mimeFromExt(String ext) {
return switch (ext.toLowerCase()) {
case "jpg", "jpeg" -> "image/jpeg";
case "webp" -> "image/webp";
case "gif" -> "image/gif";
default -> "image/png";
};
}
private static String extFromUrl(String url) {
String clean = url.split("[?#]")[0];
int dot = clean.lastIndexOf('.');
if (dot >= 0 && dot < clean.length() - 1) {
String ext = clean.substring(dot + 1).toLowerCase();
if (ext.length() <= 4 && ext.matches("[a-z0-9]+")) {
return ext;
}
}
return "png";
}
private static String nl2br(String s) {
return escapeText(s).replace("\n", "<br/>");
}
private static String escapeText(String s) {
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
private static String escapeAttr(String s) {
return escapeText(s).replace("\"", "&quot;");
}
private record ResolvedImg(byte[] bytes, String ext, String url) {}
}

View File

@ -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 ![](url) 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);

View File

@ -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 ![](url) 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;

View File

@ -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 以 ![](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;

View File

@ -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 ![](url) 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);

View File

@ -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 以 ![](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);

View File

@ -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 以 ![](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);

View File

@ -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 以 ![](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 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。

View File

@ -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 以 ![](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 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。

View File

@ -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 以 ![](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 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。

View File

@ -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` 扫一遍正文和标题,命中即标注替换。
## 保存自定义卡片模板 / 对话升级技能

View File

@ -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("<img", -1).length - 1;
assertEquals(3, imgCount, "all 3 images must be embedded");
assertTrue(html.indexOf("<img") < html.indexOf("<h1"),
"image-first: images must come before the title");
assertTrue(html.contains("3天2夜厦门citywalk"), "title present");
assertTrue(html.contains("#厦门"), "tags rendered as chips");
}
@Test
@DisplayName("an image referenced by filename self-heals and still counts")
void selfHealsNameBasedReference() {
cache.put("PNGDATA".getBytes(), "cover_xhs.png", "image/png"); // stored under a uuid
// First ref uses the filename (id pattern can't parse it); two more are valid.
String imgs = "/api/v1/files/generated/cover_xhs.png,"
+ putImg("c1.png") + "," + putImg("c2.png");
String out = tool.xhs_package("标题", "正文", "标签", imgs, null);
assertTrue(out.contains("在线预览"), "name-based ref should self-heal to reach >=3; got:\n" + out);
assertTrue(out.contains("3 张图"), "healed image should be counted");
}
}