feat(content-studio): 公众号凭据设置+截图工具+平台规范; fix: 封面按文件名自愈

This commit is contained in:
mateaix 2026-07-11 18:48:11 +08:00
parent f6fb7f2556
commit 85bee7a041
23 changed files with 732 additions and 29 deletions

View File

@ -34,6 +34,14 @@ public class SystemSettingsDTO {
private String serperApiKeyMasked;
private String tavilyApiKeyMasked;
// ===== WeChat Official Account (公众号) publish credentials =====
/** 公众号 AppID (plain — not sensitive). */
private String weixinoaAppId;
/** 公众号 AppSecret — write-only from the client; never echoed in plaintext. */
private String weixinoaAppSecret;
/** Masked AppSecret for display. */
private String weixinoaAppSecretMasked;
// ===== 视频生成配置 =====
/** 是否启用视频生成能力 */
private Boolean videoEnabled;

View File

@ -30,6 +30,10 @@ public class SystemSettingService {
private static final String SERPER_BASE_URL_KEY = "serperBaseUrl";
private static final String TAVILY_API_KEY_KEY = "tavilyApiKey";
private static final String TAVILY_BASE_URL_KEY = "tavilyBaseUrl";
// WeChat Official Account (公众号) publish credentials read by GzhPublishTool.
private static final String WEIXINOA_APP_ID_KEY = "weixinoa.app_id";
private static final String WEIXINOA_APP_SECRET_KEY = "weixinoa.app_secret";
private static final String DUCKDUCKGO_ENABLED_KEY = "duckduckgoEnabled";
private static final String SEARXNG_BASE_URL_KEY = "searxngBaseUrl";
@ -139,6 +143,10 @@ public class SystemSettingService {
dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, "")));
dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, "")));
// 公众号发布凭证AppSecret 脱敏回显AppID 明文
dto.setWeixinoaAppId(getValue(WEIXINOA_APP_ID_KEY, ""));
dto.setWeixinoaAppSecretMasked(maskApiKey(getValue(WEIXINOA_APP_SECRET_KEY, "")));
// 视频生成配置
dto.setVideoEnabled(Boolean.parseBoolean(getValue(VIDEO_ENABLED_KEY, "false")));
dto.setVideoProvider(getValue(VIDEO_PROVIDER_KEY, "auto"));
@ -295,6 +303,14 @@ public class SystemSettingService {
if (dto.getTavilyBaseUrl() != null) {
saveValue(TAVILY_BASE_URL_KEY, dto.getTavilyBaseUrl(), "Tavily 接口地址");
}
// 公众号发布凭证AppSecret 仅在非空时保存避免脱敏回显覆盖为空
if (dto.getWeixinoaAppId() != null) {
saveValue(WEIXINOA_APP_ID_KEY, dto.getWeixinoaAppId().trim(), "公众号 AppID");
}
if (dto.getWeixinoaAppSecret() != null && !dto.getWeixinoaAppSecret().isBlank()) {
saveValue(WEIXINOA_APP_SECRET_KEY, dto.getWeixinoaAppSecret().trim(), "公众号 AppSecret");
}
// Keyless provider 配置
if (dto.getDuckduckgoEnabled() != null) {
saveValue(DUCKDUCKGO_ENABLED_KEY, String.valueOf(dto.getDuckduckgoEnabled()), "DuckDuckGo 免 Key 搜索(零配置兜底)");

View File

@ -101,8 +101,14 @@ public class GzhPackageTool {
// 1. Markdown -> HTML fragment, then inline every style (公众号 drops <style>/class).
String innerHtml = markdownToInlineHtml(markdown);
String coverTag = (coverImageUrl != null && !coverImageUrl.isBlank())
? "<img src=\"" + escapeAttr(coverImageUrl.trim()) + "\" alt=\"cover\" "
// Resolve the cover to real image bytes + a servable URL up front, so the
// preview never embeds a broken <img>: a reference that doesn't resolve to
// an actual image is dropped (and flagged) rather than rendered. This also
// self-heals a reference that points at the file's name instead of its id.
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
String coverTag = (cover != null)
? "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />"
: "";
String meta = (author != null && !author.isBlank())
@ -130,7 +136,7 @@ public class GzhPackageTool {
String zipUrl;
String coverNote;
try {
byte[] coverBytes = resolveCover(coverImageUrl);
byte[] coverBytes = cover != null ? cover.bytes() : null;
// For the offline bundle, point the cover at the local file.
String bundleContainer = coverBytes != null
? container.replaceFirst("<img src=\"[^\"]*\"", "<img src=\"cover.png\"")
@ -150,6 +156,13 @@ public class GzhPackageTool {
StringBuilder out = new StringBuilder();
out.append("✅ 公众号图文已打包完成。\n\n");
if (coverImageUrl != null && !coverImageUrl.isBlank() && cover == null) {
// Requested a cover but it didn't resolve to an image say so instead
// of silently shipping a broken image tag.
out.append("⚠️ 提供的封面引用无法解析为图片,已跳过封面(未嵌坏图):")
.append(coverImageUrl.trim())
.append("\n 请改用 image_generate 返回的完整 URL/api/v1/files/generated/<id>)再打包一次。\n\n");
}
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
if (zipUrl != null) {
out.append("📦 素材下载article.html + article.md + 封面,").append(coverNote).append("")
@ -157,7 +170,7 @@ public class GzhPackageTool {
}
out.append("\n可将下面的内联样式 HTML 直接粘贴进公众号编辑器(如需直接进草稿箱,用 gzh_publish\n");
out.append("```html\n").append(container).append("\n```");
log.info("[GzhPackage] packaged '{}' ({} md chars, cover={})", title, markdown.length(), coverImageUrl != null);
log.info("[GzhPackage] packaged '{}' ({} md chars, coverResolved={})", title, markdown.length(), cover != null);
return out.toString();
}
@ -222,30 +235,84 @@ public class GzhPackageTool {
return bos.toByteArray();
}
/** Resolve the cover reference to bytes: generated-file id, http(s) URL, else null. */
/** A cover resolved to real image bytes (for the bundle) and a servable URL (for the preview). */
private record ResolvedCover(byte[] bytes, String url) { }
/**
* Resolve the cover reference to real image bytes plus a servable URL, or null
* if it can't be made into an image. Three paths, in order:
* <ol>
* <li>an explicit generated-file id in the URL ({@code .../generated/<uuid>}),
* accepted only if it maps to a live {@code image/*} entry;</li>
* <li><b>self-heal</b>: the ref points at the file's logical <em>name</em>
* ({@code cover_xyz.png}) rather than its id recover the real id by
* filename so a name-based reference still yields a working cover;</li>
* <li>an external {@code http(s)} image downloaded for the bundle, its URL
* kept for the preview.</li>
* </ol>
*/
@Nullable
private byte[] resolveCover(@Nullable String ref) {
private ResolvedCover resolveCover(@Nullable String ref, @Nullable ToolContext ctx) {
if (ref == null || ref.isBlank()) {
return null;
}
String r = ref.trim();
try {
// 1. Explicit generated-file id trust only a live image entry.
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r);
if (m.find()) {
Optional<GeneratedFileCache.Entry> e = cache.get(m.group(1));
return e.map(GeneratedFileCache.Entry::bytes).orElse(null);
if (e.isPresent() && isImage(e.get()) && hasBytes(e.get())) {
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx));
}
}
// 2. Self-heal a name-based reference (the id pattern can't parse
// underscores/dots, so `cover_xyz.png` never matches step 1).
String name = lastSegment(r);
if (name != null && !name.isBlank()) {
Optional<String> healed = cache.findIdByFilename(name, "image/");
if (healed.isPresent()) {
Optional<GeneratedFileCache.Entry> e = cache.get(healed.get());
if (e.isPresent() && hasBytes(e.get())) {
log.info("[GzhPackage] cover ref '{}' healed to generated id {} by filename", r, healed.get());
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(healed.get(), ctx));
}
}
}
// 3. External http(s) image download for the bundle, keep the URL.
if (r.startsWith("http://") || r.startsWith("https://")) {
UrlSafetyChecker.check(r);
byte[] b = HttpUtil.downloadBytes(r);
return (b != null && b.length > 0) ? b : null;
if (b != null && b.length > 0) {
return new ResolvedCover(b, r);
}
}
} catch (Exception e) {
log.warn("[GzhPackage] cover resolve failed: {}", e.getMessage());
log.warn("[GzhPackage] cover resolve failed for '{}': {}", r, e.getMessage());
}
return null;
}
private static boolean isImage(GeneratedFileCache.Entry e) {
return e.mimeType() != null && e.mimeType().startsWith("image/");
}
private static boolean hasBytes(GeneratedFileCache.Entry e) {
return e.bytes() != null && e.bytes().length > 0;
}
/** Last path segment of a URL, minus any {@code ?query} / {@code #fragment}. */
@Nullable
private static String lastSegment(String url) {
String s = url;
int cut = s.indexOf('?');
if (cut >= 0) s = s.substring(0, cut);
cut = s.indexOf('#');
if (cut >= 0) s = s.substring(0, cut);
int slash = s.lastIndexOf('/');
return slash >= 0 ? s.substring(slash + 1) : s;
}
private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) {
String id = cache.put(bytes, name, mime);
return cache.downloadUrl(id, ctx);

View File

@ -0,0 +1,180 @@
package vip.mate.tool.builtin;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.ScreenshotType;
import com.microsoft.playwright.options.WaitUntilState;
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.beans.factory.annotation.Value;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import vip.mate.auth.service.AuthService;
import vip.mate.tool.browser.BrowserLauncher;
import vip.mate.tool.document.FilenameSanitizer;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.tool.document.GeneratedFileLink;
/**
* Built-in tool: capture a screenshot of a MateClaw admin-console page and
* return an embeddable image URL.
*
* <p>Purpose: let content skills illustrate a how-to article with <b>real</b>
* product screenshots. The console lives behind JWT auth, so the tool mints a
* short-lived token for the calling user, injects it into {@code localStorage}
* before the SPA boots (Playwright init script), navigates to the requested
* <b>same-origin relative path</b>, and screenshots the rendered page. The PNG
* is stashed in {@link GeneratedFileCache}; the returned {@code /api/v1/files/
* generated/<id>} URL can be embedded directly as {@code ![](url)} in a
* gzh_package Markdown body.
*
* <p>Security: only relative in-app paths ({@code /chat}, {@code /channels}, )
* are allowed no scheme/host, so the tool cannot be aimed at arbitrary hosts
* (no SSRF). The injected token is a normal user token scoped to whoever is
* driving the conversation.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ScreenshotTool {
private static final String PNG_MIME = "image/png";
private static final int DEFAULT_WIDTH = 1440;
private static final int DEFAULT_HEIGHT = 900;
private static final int NAV_TIMEOUT_MS = 20_000;
private static final int DEFAULT_SETTLE_MS = 2500;
private static final int MAX_SETTLE_MS = 8000;
private final GeneratedFileCache cache;
private final AuthService authService;
@Value("${server.port:18088}")
private int serverPort;
@Tool(name = "capture_screenshot", description = """
Capture a screenshot of a MateClaw admin-console page and return an
embeddable image URL. Use this to put REAL product screenshots into a
how-to / tutorial article (e.g. steps of using 内容工作室).
`path` must be a relative in-app path starting with '/', e.g. '/chat',
'/channels', '/agents', '/skills'. External URLs are rejected.
The returned URL is `/api/v1/files/generated/<id>` (image/png). Embed it
in a gzh_package Markdown body as `![说明](URL)` so the packaged article
shows the real screenshot instead of a 截图placeholder.
""")
public String capture_screenshot(
@ToolParam(description = "Relative in-app path, e.g. /chat, /channels, /agents, /skills")
String path,
@ToolParam(description = "Capture the full scrollable page (default false = just the viewport)", required = false)
Boolean fullPage,
@ToolParam(description = "Output filename without extension, e.g. 'step1-console'", required = false)
String filename,
@ToolParam(description = "Extra settle wait in ms after load for the SPA to render (default 2500, max 8000)", required = false)
Integer waitMs,
@Nullable ToolContext ctx) {
if (path == null || path.isBlank()) {
return "Error: path is required (a relative in-app path like /chat).";
}
String p = path.trim();
if (p.contains("://") || p.startsWith("//") || !p.startsWith("/")) {
return "Error: only relative in-app paths are allowed (must start with '/', no scheme/host). Got: " + path;
}
String token = mintToken();
if (token == null || token.isBlank()) {
return "Error: could not mint an auth token to render the console (no resolvable user).";
}
String url = "http://127.0.0.1:" + serverPort + p;
int settle = waitMs == null ? DEFAULT_SETTLE_MS : Math.min(Math.max(waitMs, 0), MAX_SETTLE_MS);
boolean full = fullPage != null && fullPage;
String displayName = FilenameSanitizer.sanitize(filename, "screenshot", ".png") + ".png";
byte[] png;
try {
png = render(url, token, full, settle);
} catch (Exception e) {
log.warn("[Screenshot] capture failed for {}: {}", p, e.getMessage());
String hint = e.getMessage() != null && e.getMessage().contains("Executable doesn't exist")
? " Hint: install the bundled browser (Playwright chromium)."
: "";
return "Error: screenshot failed — " + e.getMessage() + hint;
}
log.info("[Screenshot] captured {} ({} bytes, fullPage={})", p, png.length, full);
return GeneratedFileLink.resultZh(png, displayName, PNG_MIME, cache, "截图", ctx);
}
private byte[] render(String url, String token, boolean fullPage, int settleMs) {
try (Playwright pw = Playwright.create()) {
BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(BrowserLauncher.chromiumLaunchArgs());
Browser browser = pw.chromium().launch(opts);
try {
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(DEFAULT_WIDTH, DEFAULT_HEIGHT)
.setDeviceScaleFactor(2.0)
.setLocale("zh-CN"));
// Seed the JWT before any app script runs so the SPA boots
// authenticated. Playwright evaluates the init script as-is, so it
// must be raw statements a "() => {...}" arrow would only be
// defined, never called, leaving localStorage untouched (and the
// SPA would render the login page instead).
context.addInitScript("try { window.localStorage.setItem('token', '"
+ token + "'); } catch (e) {}");
try {
Page page = context.newPage();
page.navigate(url, new Page.NavigateOptions()
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED)
.setTimeout(NAV_TIMEOUT_MS));
// The console holds an SSE stream, so 'networkidle' can never
// settle use a fixed render delay instead.
page.waitForTimeout(settleMs);
return page.screenshot(new Page.ScreenshotOptions()
.setFullPage(fullPage)
.setType(ScreenshotType.PNG));
} finally {
try { context.close(); } catch (Exception ignored) {}
}
} finally {
try { browser.close(); } catch (Exception ignored) {}
}
}
}
/** Mint a token for the current user, falling back to the default admin. */
@Nullable
private String mintToken() {
String username = currentUsername();
String token = username != null ? authService.renewToken(username) : null;
if (token == null) {
token = authService.renewToken("admin");
}
return token;
}
@Nullable
private String currentUsername() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getName() != null
&& !"anonymousUser".equals(auth.getName())) {
return auth.getName();
}
} catch (Exception ignored) {
// Async agent thread may have no security context fall back to admin.
}
return null;
}
}

View File

@ -20,6 +20,7 @@ import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@ -235,6 +236,71 @@ public class GeneratedFileCache {
return Optional.of(entry);
}
/**
* Best-effort lookup of a live entry's id by its logical filename, optionally
* constrained to a mime-type prefix (e.g. {@code "image/"}). Scans the
* in-memory cache first which covers the common case of a reference to a
* file generated earlier in the same run then persisted metadata on disk as
* a durable fallback. Returns the first live match, or empty.
*
* <p>This exists to self-heal references that point at a file's logical
* <em>name</em> ({@code cover_xyz.png}) rather than its issued id: such a
* reference cannot be resolved by {@link #GENERATED_URL_PATTERN} (the id
* capture group stops at the first {@code _} or {@code .}), so a name-based
* fallback recovers the real, servable id instead of yielding a broken link.
*/
public Optional<String> findIdByFilename(String filename, @Nullable String mimePrefix) {
if (filename == null || filename.isBlank()) {
return Optional.empty();
}
String target = filename.trim();
// 1. In-memory the fresh-same-run case, and cheap.
synchronized (entries) {
for (Map.Entry<String, Entry> e : entries.entrySet()) {
Entry v = e.getValue();
if (!v.expired() && target.equalsIgnoreCase(v.filename())
&& (mimePrefix == null
|| (v.mimeType() != null && v.mimeType().startsWith(mimePrefix)))) {
return Optional.of(e.getKey());
}
}
}
// 2. Disk metas durable fallback (survives memory eviction / restart).
if (!Files.isDirectory(storageDir)) {
return Optional.empty();
}
List<Path> metas;
try (Stream<Path> files = Files.list(storageDir)) {
metas = files.filter(p -> p.getFileName().toString().endsWith(META_SUFFIX)).toList();
} catch (IOException e) {
log.debug("findIdByFilename disk scan failed: {}", e.toString());
return Optional.empty();
}
long now = System.currentTimeMillis();
for (Path metaPath : metas) {
try {
String[] parts = Files.readString(metaPath).split("\t", 3);
if (Long.parseLong(parts[0].trim()) <= now) {
continue;
}
String mime = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null;
if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) {
continue;
}
String fn = parts.length > 2 && !parts[2].isEmpty()
? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8)
: null;
if (fn != null && target.equalsIgnoreCase(fn)) {
String name = metaPath.getFileName().toString();
return Optional.of(name.substring(0, name.length() - META_SUFFIX.length()));
}
} catch (Exception ignore) {
// Skip unreadable / malformed meta.
}
}
return Optional.empty();
}
private void persist(String id, Entry entry) {
if (entry.bytes() == null) {
return;

View File

@ -1945,3 +1945,8 @@ VALUES (1000100021, 'Weekly 公众号 Draft', '0 9 * * 1', 'Asia/Shanghai', 1000
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
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);

View File

@ -1870,3 +1870,8 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', 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;
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
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;

View File

@ -1867,3 +1867,8 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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;
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
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;

View File

@ -1986,3 +1986,8 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expressio
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', 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);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
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);

View File

@ -1983,3 +1983,8 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expressio
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
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);

View File

@ -1946,3 +1946,8 @@ VALUES (1000100021, '每周公众号入草稿箱', '0 9 * * 1', 'Asia/Shanghai',
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
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);

View File

@ -1,10 +1,11 @@
-- 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),
-- 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 locale seed afterwards.
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
-- capture_screenshot), 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
-- locale seed afterwards.
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
@ -23,6 +24,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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 (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图把返回 URL 以 ![](url) 嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', 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,10 +1,11 @@
-- 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),
-- 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 locale seed afterwards.
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
-- capture_screenshot), 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
-- locale seed afterwards.
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
@ -23,6 +24,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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 (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_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,10 +1,11 @@
-- 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),
-- 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 locale seed afterwards.
-- built-in tools (wechat_article_extract, gzh_publish, xhs_publish, gzh_package,
-- capture_screenshot), 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
-- locale seed afterwards.
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
@ -23,6 +24,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', 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 (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_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: gzh_article
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
version: 1.0.0
version: 1.1.0
tags:
- 公众号
- 图文
@ -18,6 +18,8 @@ platforms:
把一个选题做成可直接粘进公众号编辑器的图文推文。7 个阶段,每步都接到平台真实工具上。
> 📌 **动笔前务必先读 `references/gzh_platform_rules.md`** —— 那是微信公众号的真实平台规矩(封面尺寸、标题/摘要、编辑器排版、诱导分享/关注红线、群发频次与时机、原创机制),懂了这些才叫"会做公众号",而不只是"会写字"。下面 SOP 的成文/配图/自查各步都以它为准。
## 开工前:读取共享人设记忆
先用 `recall_structured` 取回并全程遵守:
@ -57,11 +59,16 @@ platforms:
全程遵守 `writing_style_gzh` + `content_persona`
**同时定好标题和摘要**(打开率的命门,见 `references/gzh_platform_rules.md`
- **标题**≤30 字为佳(后台上限 64带具体信息 / 数字 / 悬念 / 情绪;**与正文一致**,不做标题党、不用"震惊体"、别满屏感叹号(会被限流)。给 23 个候选让用户挑。
- **摘要digest****手写 ≤120 字**,补一句标题没说完的钩子(不填会被系统乱截正文前 54 字)。交付时作为 `gzh_publish``digest` 传入。
### 4. 配图
`image_generate``action=generate`
- **封面头图**`aspectRatio=landscape`。prompt 里写清主题、风格、留白、中文标题可读。
- **封面头图**`aspectRatio=landscape`(公众号头图是 **2.35:1**,约 900×383 / 1080×460。prompt 写清主题、风格、留白;**封面文字要大而少**(缩略图很小,一行主标题足矣)。
- **分享/朋友圈封面**:如需单独出,用 `aspectRatio=square`**1:1**≥500×500
- **关键小节配图**:按需为 23 个重点小节各生成一张,风格与封面统一。
`image_generate` 只认 `landscape` / `portrait` / `square` 三种比例,其它比例映射到最近的一个(如 3:4 → portrait
@ -70,9 +77,13 @@ platforms:
`load_skill deai_humanize`,然后对全文跑它的"打分→改写→复检"循环,`platform=gzh`,目标 `score ≤ 55`
### 6. 违禁词自查
### 6. 违禁词 + 平台规则自查
对照 `banned_words``references/compliance_checklist.md`(广告法极限词、虚假宣传、敏感词、侵权风险),命中就**标注并给出替换建议**,不要静默通过。
对照 `banned_words``references/compliance_checklist.md`,命中就**标注并给出替换建议**,不要静默通过。两类都要扫:
- **广告法**:极限词(最/第一/唯一/国家级/100%)、虚假功效、承诺收益、敏感内容、侵权。
- **微信平台红线**(比广告法更容易封号,见 `references/gzh_platform_rules.md` 第 5 节):**诱导分享**(集赞/助力/分享解锁)、**诱导关注**(关注才能看全文)、**违规外链/二维码**、标题党。引导互动只用话术、不用利诱。
**产品教程/操作类文章配真实截图**:写"如何用 MateClaw 做 XX"这类教程时,用 `capture_screenshot(path)` 截真实后台界面(`path` 为站内相对路径,如 `/chat`、`/channels`、`/agents`、`/skills`),把返回的图片 URL 以 `![步骤说明](URL)` **直接嵌进对应步骤的 Markdown**,替代【截图】占位;再整体交给 `gzh_package`。这样成品里是真实产品截图,不用手动补图。
### 7. 打包交付gzh_package —— 在线预览 + 素材下载)
@ -107,4 +118,5 @@ gzh_package(title="<标题>", markdown="<正文 Markdown含小标题/列表/
- `references/gzh_layout_minimal.html` — 极简编辑风模板。
- `references/gzh_layout_business.html` — 商务专业风模板。
- `references/gzh_structure.md` — 文章结构 + 钩子 / 金句方法论。
- `references/compliance_checklist.md` — 合规自查清单(广告法禁用词、敏感词、侵权、极限词)。
- `references/gzh_platform_rules.md`**微信公众号平台规范与实操**(封面尺寸、标题/摘要规则、编辑器排版、诱导红线、群发频次与时机、原创机制、敏感行业)。写公众号务读。
- `references/compliance_checklist.md` — 合规自查清单(广告法禁用词 + 微信诱导红线)。

View File

@ -33,6 +33,15 @@
- **文字**:本文为原创;参考文章只用于找角度并在文末标注来源,**不逐段搬运洗稿**。
- **商标 / 品牌**:提及第三方品牌时客观陈述,不贬损、不假冒关联。
## 5. 微信平台规则(诱导 / 外链红线,比广告法更容易封号)
这类属于**微信运营规范**,不是广告法——违规轻则限流删文,重则扣原创分 / 封号。详见 `gzh_platform_rules.md` 第 5 节。发文前重点扫:
- **诱导分享**`分享到朋友圈领取` / `集赞送` / `助力` / `分享解锁全文` / `不转不是XX` → 一律删除。
- **诱导关注**`关注才能看全文` / `关注领资料` 式的强制/利诱 → 删除(正常"欢迎关注"可留)。
- **违规外链 / 二维码**:正文直接放淘宝、外部下载等外链 → 删除(合规外链走文末"阅读原文")。
- **引导互动**:只允许话术(`欢迎点赞、在看、转发`),不允许利诱(`转发抽奖`)。
## 输出格式
自查后给用户一份小结:

View File

@ -0,0 +1,73 @@
# 微信公众号平台规范与实操要点
写公众号图文时,除了"写得好",还要"合平台规矩、贴移动端阅读、能被打开"。以下是真实运营里绕不开的硬约束和经验,成文/配图/排版/交付时逐条对照。
## 1. 封面图规范(尺寸是硬要求)
- **头条大图 / 顶部封面**:比例 **2.35 : 1**,建议 **900×383****1080×460** px。这是文章顶部那张大图。
- **分享小图 / 朋友圈封面 / 次条图**:比例 **1 : 1**(正方形),建议 **≥ 500×500** px。这是分享到聊天、朋友圈时显示的缩略图。
- 封面上的**文字要大、要少**——缩略图在手机上很小,一行主标题 + 一句副标题足矣,别塞满字。
- 用 `image_generate` 出头图时 `aspectRatio=landscape`(近似 2.35:1`gzh_package` 会把封面放进文章头部。
## 2. 标题(打开率的命门)
- 后台标题上限 **64 字**,但**强烈建议精炼**1530 字最佳)。订阅号在"订阅号消息"里是折叠列表,**读者先看到的往往只有标题**。
- 好标题带**具体信息 / 数字 / 悬念 / 利益点 / 情绪**,标题和正文**必须一致**——标题党、夸大、"震惊体""速看体"、满屏感叹号会被平台**限流**。
- 反例:`震惊99% 的人都不知道的秘密`。正例:`我用这 3 个方法,把每天的碎片时间省出了 2 小时`。
## 3. 摘要digest别浪费
- 摘要上限 **120 字****不填会自动截取正文前 ~54 字**(常常截得很尴尬)。
- 摘要显示在**分享卡片、部分历史入口**。**手动写摘要**,用它补一句标题没说完的钩子,别让系统乱截。
- `gzh_publish``digest` 参数就是它;不传会自动从正文派生。
## 4. 正文排版(微信编辑器习惯)
- **字号**:正文 15px最常用或 16px小标题 1617px 加粗。
- **行间距** 1.752.0**段间距**要留白。公众号习惯**首行不缩进**,靠段间距分段。
- **字色**:正文用 `#3f3f3f` / `#333`(不用纯黑 `#000`,太硬);重点色**不超过 23 种**(品牌色 + 黄底标重点足够)。
- **图片**宽度自适应100%);图注用小字、灰色、居中。
- **每段 34 行以内,多用短句**——手机一屏能读完一个意群。首屏(首图 + 前 34 行)决定读者跳不跳出。
- `gzh_package` 已内置这套内联样式;手写 HTML 时对齐这些数值。
## 5. 诱导红线(微信平台规则,**比广告法更容易封号**
这是真实运营最常踩的坑,和《广告法》是两回事——属于**微信运营规范**,违规会**限流、删文、扣原创分、封号**
- **诱导分享**(严禁):用利益或胁迫诱导转发,如"**分享到朋友圈截图领取**""**集赞 XX 个送**""**为 XX 助力**""**分享后解锁全文**""**不转不是 XX**"。
- **诱导关注**(严禁):**强制/利诱**才能看全文或领资料,如"**关注公众号才能看后续**""**关注领取完整版**"。**正常引导关注可以**"觉得有用就关注一下"**强制/利诱不行**。
- **外链 / 二维码跳转**:正文**不能直接放外部网址**跳到淘宝、外部下载、非白名单站点;诱导"长按识别二维码加个人微信/关注他人号"批量导流有风险。合规外链走文末"阅读原文"(白名单或公众号文章链接)。
- **标题党 / 内容不符**:标题夸大、与正文不符 → 限流。
- **抄袭 / 洗稿**:被原创方投诉 → 删文 + 扣信用分。
> 引导互动要用**话术**而非**利诱**`觉得有帮助,欢迎点赞、在看、转发,也欢迎关注` ✅;`转发集赞 20 个抽奖` ❌。
## 6. 发布频次与时机
- **群发频次**:订阅号**每天 1 次**(一次可含多条图文合并推送);认证服务号**每月 4 次**。
- **高打开时段**(视受众调整):早 **7:009:00**(通勤)、午 **12:0013:30**、晚 **20:0022:30**(黄金)、睡前 **22:3023:30**。上班族偏早晚通勤,学生/宝妈偏午间和晚间。
- **固定节奏**(如每周二四六早 8 点)有助于养成读者打开习惯。
- 定时推送可用「每周公众号入草稿箱」这类 Cron 模板生成,人工核对后发。
## 7. 原创与互动机制
- **声明原创**(账号需已开通原创功能):可获**原创标、留言、赞赏、被转载保留出处**。原创内容平台推荐权重更高。
- 文末**引导互动**:点赞 / 在看 / 留言 / 转发 / 关注——用真诚话术。**主动引导留言并精选**能提升互动权重。
- **搜一搜 / SEO**:标题、正文前 100 字合理布局关键词,有助于被"搜一搜"收录,带来长尾流量。
## 8. 敏感行业额外合规
- **医疗 / 保健 / 药品 / 保健品**:不得宣称疗效、治愈、抗癌等(见 `compliance_checklist.md`),且多需资质。
- **金融 / 投资 / 理财**:不得承诺收益、保本、稳赚。
- **教育 / 培训**:不得承诺"包过""保分""名校保录"。
- 这些行业发文前务必让用户确认资质与措辞。
## 交付前自检(把上面浓缩成一句话清单)
- [ ] 封面 2.35:1头图/ 1:1分享图文字大而少
- [ ] 标题 ≤30 字、不标题党、与正文一致
- [ ] 手动写了 ≤120 字摘要
- [ ] 段落 34 行、短句、重点色 ≤3 种
- [ ] 无诱导分享 / 诱导关注 / 违规外链
- [ ] 引导互动用话术不用利诱
- [ ] 敏感行业措辞合规

View File

@ -0,0 +1,99 @@
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.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
* Reproduce and pin the fix for the broken-cover bug: when the model references
* the cover by its logical filename ({@code cover_xyz.png}) instead of the
* issued id, the URL id-pattern can't parse it, so the old tool embedded the raw
* (non-serving) reference and the preview showed a broken image. The packager
* must now (a) self-heal a name-based reference to the real generated image, and
* (b) when a cover genuinely can't be resolved, drop it and warn rather than ship
* a broken {@code <img>}.
*/
class GzhPackageCoverHealingTest {
private GeneratedFileCache cache;
private GzhPackageTool tool;
@BeforeEach
void setUp(@TempDir Path tempDir) {
cache = new GeneratedFileCache(tempDir);
tool = new GzhPackageTool(cache);
}
private static final String BODY = "## 小节一\n\n正文一段。\n\n## 小节二\n\n又一段。";
@Test
@DisplayName("name-based cover reference self-heals to the real generated image")
void nameBasedReferenceHeals() {
String id = cache.put("PNGBYTES".getBytes(), "cover_cat_7pits.png", "image/png");
// The model echoed the cover by filename the id pattern stops at the '_'.
String out = tool.gzh_package(
"养猫第一年烧掉3万块",
BODY,
"/api/v1/files/generated/cover_cat_7pits.png",
"内容工作室",
null);
assertTrue(out.contains("/api/v1/files/generated/" + id),
"cover should be healed to the real generated id; got:\n" + out);
assertFalse(out.contains("generated/cover_cat_7pits.png"),
"the broken name-based reference must not be embedded");
assertTrue(out.contains("<img "), "a cover image must be present");
assertFalse(out.contains("⚠️"), "a healed cover must not warn");
}
@Test
@DisplayName("unresolvable cover → dropped with a warning, never a broken <img>")
void unresolvableCoverDroppedAndWarned() {
String out = tool.gzh_package(
"标题",
BODY,
"/api/v1/files/generated/deadbeef-0000-0000-0000-000000000000",
"内容工作室",
null);
assertTrue(out.contains("⚠️"), "an unresolved cover must be flagged; got:\n" + out);
assertFalse(out.contains("<img "), "no broken cover image may be embedded");
}
@Test
@DisplayName("a correct generated-id image reference is embedded as-is, no warning")
void correctIdReferenceEmbedded() {
String id = cache.put("PNGBYTES".getBytes(), "gzh-cover.png", "image/png");
String out = tool.gzh_package(
"标题",
BODY,
"/api/v1/files/generated/" + id,
"内容工作室",
null);
assertTrue(out.contains("/api/v1/files/generated/" + id), "the cover id must be embedded");
assertTrue(out.contains("<img "), "a cover image must be present");
assertFalse(out.contains("⚠️"), "a resolved cover must not warn");
}
@Test
@DisplayName("a non-image generated file referenced as cover is not embedded")
void nonImageReferenceNotEmbedded() {
String id = cache.put("%PDF".getBytes(), "handout.pdf", "application/pdf");
String out = tool.gzh_package(
"标题",
BODY,
"/api/v1/files/generated/" + id,
"内容工作室",
null);
assertTrue(out.contains("⚠️"), "a non-image cover must be flagged");
assertFalse(out.contains("<img "), "a non-image must not be embedded as a cover");
}
}

View File

@ -0,0 +1,71 @@
package vip.mate.tool.document;
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 java.nio.file.Path;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin {@link GeneratedFileCache#findIdByFilename}: recover a file's issued id
* from its logical filename, so a reference that points at the name instead of
* the id (which {@code GENERATED_URL_PATTERN} cannot parse) can still be
* resolved. Covers the in-memory hit, the disk fallback, the mime-prefix
* filter, case-insensitivity, and the misses.
*/
class GeneratedFileCacheFindByFilenameTest {
private Path dir;
private GeneratedFileCache cache;
@BeforeEach
void setUp(@TempDir Path tempDir) {
dir = tempDir;
cache = new GeneratedFileCache(tempDir);
}
@Test
@DisplayName("in-memory: filename + image mime → the issued id")
void memoryHit() {
String id = cache.put("PNG".getBytes(), "cover_cat_7pits.png", "image/png");
assertEquals(Optional.of(id), cache.findIdByFilename("cover_cat_7pits.png", "image/"));
}
@Test
@DisplayName("filename match is case-insensitive")
void caseInsensitive() {
String id = cache.put("PNG".getBytes(), "Cover_Cat.PNG", "image/png");
assertEquals(Optional.of(id), cache.findIdByFilename("cover_cat.png", "image/"));
}
@Test
@DisplayName("mime prefix excludes a non-image with the same name")
void mimePrefixExcludesNonImage() {
cache.put("PDF".getBytes(), "cover.pdf", "application/pdf");
assertTrue(cache.findIdByFilename("cover.pdf", "image/").isEmpty());
// Without the constraint it is found.
assertTrue(cache.findIdByFilename("cover.pdf", null).isPresent());
}
@Test
@DisplayName("disk fallback: a fresh cache with empty memory finds it via persisted meta")
void diskFallback() {
String id = cache.put("PNG".getBytes(), "gzh-cover.png", "image/png");
// A brand-new instance over the same dir has nothing in memory yet.
GeneratedFileCache reopened = new GeneratedFileCache(dir);
assertEquals(Optional.of(id), reopened.findIdByFilename("gzh-cover.png", "image/"));
}
@Test
@DisplayName("unknown filename and null/blank input → empty")
void misses() {
cache.put("PNG".getBytes(), "cover.png", "image/png");
assertTrue(cache.findIdByFilename("nope.png", "image/").isEmpty());
assertTrue(cache.findIdByFilename(null, "image/").isEmpty());
assertTrue(cache.findIdByFilename(" ", "image/").isEmpty());
}
}

View File

@ -1013,6 +1013,8 @@ export default {
searchProvider: 'Search Provider',
searchFallbackEnabled: 'Fallback on Failure',
serperApiKey: 'Serper API Key',
weixinoaAppId: 'Official Account AppID',
weixinoaAppSecret: 'Official Account AppSecret',
serperBaseUrl: 'Serper Base URL',
tavilyApiKey: 'Tavily API Key',
tavilyBaseUrl: 'Tavily Base URL',
@ -1062,6 +1064,8 @@ export default {
searchProvider: 'Primary search provider used when the search tool is invoked.',
searchFallbackEnabled: 'Automatically try the other provider when the primary one fails.',
serperApiKey: 'API key for Google Serper search, get it from serper.dev.',
weixinoaAppId: 'AppID from the Official Account admin (Development → Basic Config).',
weixinoaAppSecret: 'AppSecret (write-only, never echoed). Once set, gzh_publish can push articles into the draft box. Note: the calling server\'s public IP must be whitelisted in the Official Account admin, and a verified account is required; localhost usually cannot reach the WeChat API.',
serperBaseUrl: 'Usually no need to change unless using a custom proxy.',
tavilyApiKey: 'API key for Tavily search, get it from tavily.com.',
tavilyBaseUrl: 'Usually no need to change unless using a custom proxy.',
@ -1116,6 +1120,8 @@ export default {
minimaxApiKey: 'Get from minimaxi.com. Hailuo video with free quota, excellent for Chinese scenes.',
minimaxRegion: 'Choose the API host. Mainland China accounts must use the CN endpoint; others use Global.',
},
weixinoaTitle: 'Official Account Publishing',
weixinoaDesc: 'Configure the WeChat Official Account AppID / AppSecret so Content Studio can push articles into the draft box via gzh_publish (requires a verified account + the server IP whitelisted).',
searchTitle: 'Search Service',
searchDesc: 'Configure the built-in search tool provider and API credentials',
searchCatalogError: 'Failed to load the search provider catalog — showing built-in provider configuration only; plugin providers and status info are unavailable.',

View File

@ -876,6 +876,8 @@ export default {
searchFallbackEnabled: '失败回退',
serperApiKey: 'Serper API Key',
serperBaseUrl: 'Serper 接口地址',
weixinoaAppId: '公众号 AppID',
weixinoaAppSecret: '公众号 AppSecret',
tavilyApiKey: 'Tavily API Key',
tavilyBaseUrl: 'Tavily 接口地址',
duckduckgoEnabled: 'DuckDuckGo免 Key',
@ -931,6 +933,8 @@ export default {
searchFallbackEnabled: '主提供商调用失败时,自动回退到另一个提供商。',
serperApiKey: '用于 Google Serper 搜索服务,从 serper.dev 获取。',
serperBaseUrl: '通常无需修改,除非使用自定义代理地址。',
weixinoaAppId: '公众号后台「开发 → 基本配置」里的 AppID。',
weixinoaAppSecret: 'AppSecret保密仅输入不回显。配好后 gzh_publish 可将图文推进草稿箱。注意:调用微信接口的服务器公网 IP 需加入公众号后台的 IP 白名单,且需认证服务号/订阅号localhost 通常无法直连微信接口。',
tavilyApiKey: '用于 Tavily 搜索服务,从 tavily.com 获取。',
tavilyBaseUrl: '通常无需修改,除非使用自定义代理地址。',
duckduckgoEnabled: '免费搜索兜底,无需 API Key。默认开启作为零配置下的搜索降级方案。',
@ -990,6 +994,8 @@ export default {
minimaxApiKey: '从 minimaxi.com 获取。海螺 Hailuo 视频,有免费额度,中文场景优秀。',
minimaxRegion: '选择 API 入口域名。中国账号用 CN海外账号用 Global。',
},
weixinoaTitle: '公众号发布',
weixinoaDesc: '配置微信公众号 AppID / AppSecret内容工作室即可用 gzh_publish 把图文推进公众号草稿箱(需认证号 + 服务器公网 IP 加白名单)。',
searchTitle: '搜索服务',
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
searchCatalogError: '无法加载搜索提供商目录,仅显示内置提供商配置;插件提供商与状态信息暂不可用。',

View File

@ -818,6 +818,9 @@ export interface SystemSettings {
tavilyBaseUrl: string
serperApiKeyMasked?: string
tavilyApiKeyMasked?: string
weixinoaAppId?: string
weixinoaAppSecret?: string
weixinoaAppSecretMasked?: string
// Keyless 搜索 provider
duckduckgoEnabled: boolean
searxngBaseUrl: string

View File

@ -229,6 +229,42 @@
</div>
</div>
<div class="settings-section system-section">
<h2 class="section-title">{{ t('settings.weixinoaTitle') }}</h2>
<p class="section-desc">{{ t('settings.weixinoaDesc') }}</p>
<div class="settings-card">
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.weixinoaAppId') }}</div>
<div class="setting-hint">{{ t('settings.hints.weixinoaAppId') }}</div>
</div>
<div class="setting-control setting-control-full">
<input
v-model="settings.weixinoaAppId"
type="text"
class="form-input"
placeholder="wx1234567890abcdef"
/>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.weixinoaAppSecret') }}</div>
<div class="setting-hint">{{ t('settings.hints.weixinoaAppSecret') }}</div>
</div>
<div class="setting-control setting-control-full">
<input
v-model="weixinoaAppSecretInput"
type="password"
class="form-input"
autocomplete="new-password"
:placeholder="settings.weixinoaAppSecretMasked || t('settings.model.apiKeyInput')"
/>
</div>
</div>
</div>
</div>
<div class="save-bar">
<button class="btn-secondary" @click="loadSettings">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="onSaveSettings">{{ t('settings.actions.saveSystem') }}</button>
@ -254,6 +290,7 @@ const savedTip = ref('')
// API Key
const serperApiKeyInput = ref('')
const tavilyApiKeyInput = ref('')
const weixinoaAppSecretInput = ref('')
const providerCatalog = ref<SearchProviderCatalog>({ providers: [], resolvedId: null, resolvedSource: null })
const expandedProviderId = ref<string | null>(null)
@ -299,6 +336,7 @@ const settings = reactive<SystemSettings>({
tavilyBaseUrl: 'https://api.tavily.com/search',
duckduckgoEnabled: true,
searxngBaseUrl: '',
weixinoaAppId: '',
})
onMounted(async () => {
@ -313,6 +351,7 @@ async function loadSettings() {
// API Key
serperApiKeyInput.value = ''
tavilyApiKeyInput.value = ''
weixinoaAppSecretInput.value = ''
}
async function onSaveSettings() {
@ -324,6 +363,9 @@ async function onSaveSettings() {
if (tavilyApiKeyInput.value) {
payload.tavilyApiKey = tavilyApiKeyInput.value
}
if (weixinoaAppSecretInput.value) {
payload.weixinoaAppSecret = weixinoaAppSecretInput.value
}
await settingsApi.update(payload)
await applyLocale(settings.language)
//