package vip.mate.tool.builtin; import cn.hutool.http.HttpRequest; import lombok.extern.slf4j.Slf4j; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.tool.browser.UrlSafetyChecker; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; /** * Built-in tool: fetch a WeChat Official Account article and return its cleaned, * structured body (title / author / publish time / markdown text / image URLs). * *
WeChat article pages ({@code mp.weixin.qq.com/s/...}) are largely static * HTML: the body lives in {@code #js_content} and images lazy-load through a * {@code data-src} attribute. A plain HTTP GET plus a jsoup cleanup is therefore * enough for the common case, which is far cheaper and more reliable than * driving a headless browser. Callers that need to summarise several reference * articles ("参考公众号信息抓取汇总") get clean text instead of a raw page * snapshot. * *
The URL is constrained to the {@code mp.weixin.qq.com} host and validated
* through {@link UrlSafetyChecker} so the tool cannot be used for SSRF.
*/
@Slf4j
@Component
public class WechatArticleExtractTool {
private static final String ALLOWED_HOST_SUFFIX = "mp.weixin.qq.com";
private static final int FETCH_TIMEOUT_MS = 15_000;
private static final int MAX_BODY_CHARS = 20_000;
private static final int MAX_IMAGES = 30;
private static final String USER_AGENT =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.0";
@Tool(name = "wechat_article_extract", description = """
Fetch a WeChat Official Account (微信公众号) article by URL and return its
cleaned content: title, author, publish time, body as Markdown, and the
list of image URLs.
Use this to gather and summarise reference 公众号 articles before writing
(参考公众号信息抓取汇总). It returns clean readable text rather than a raw
page snapshot, so it is preferred over browser_use for mp.weixin.qq.com
article pages.
Only https://mp.weixin.qq.com/... URLs are accepted. Content is meant for
reference and summarisation — produce original, differentiated writing and
cite the source; do not copy verbatim.
""")
public String wechat_article_extract(
@ToolParam(description = "Full WeChat article URL, e.g. https://mp.weixin.qq.com/s/xxxxxxxx")
String url) {
if (url == null || url.isBlank()) {
return "Error: url is required.";
}
String trimmed = url.trim();
// SSRF guard + host allowlisting: only public mp.weixin.qq.com pages.
try {
UrlSafetyChecker.check(trimmed);
} catch (SecurityException e) {
return "Error: unsafe URL — " + e.getMessage();
}
String host = java.net.URI.create(trimmed).getHost();
if (host == null || !(host.equals(ALLOWED_HOST_SUFFIX) || host.endsWith("." + ALLOWED_HOST_SUFFIX))) {
return "Error: only mp.weixin.qq.com article URLs are supported (got host: " + host + ").";
}
String html;
try {
html = HttpRequest.get(trimmed)
.header("User-Agent", USER_AGENT)
.timeout(FETCH_TIMEOUT_MS)
.execute()
.body();
} catch (Exception e) {
log.warn("[WechatExtract] fetch failed for {}: {}", trimmed, e.getMessage());
return "Error: failed to fetch the article — " + e.getMessage();
}
if (html == null || html.isBlank()) {
return "Error: empty response from the article URL.";
}
Document doc = Jsoup.parse(html, trimmed);
String title = firstNonBlank(
text(doc, "#activity-name"),
text(doc, "h1.rich_media_title"),
text(doc, "meta[property=og:title]", "content"),
doc.title());
String author = firstNonBlank(
text(doc, "#js_author_name"),
text(doc, "#js_name"),
text(doc, "a#js_name"),
text(doc, "meta[name=author]", "content"));
String publishTime = firstNonBlank(
text(doc, "#publish_time"),
text(doc, "em#publish_time"));
Element content = doc.selectFirst("#js_content");
if (content == null) {
// The article may be intercepted (verification / deleted / anti-scrape).
return "Error: could not locate the article body (#js_content). "
+ "The page may require verification or has been removed. "
+ "Try browser_use as a fallback.\nTitle: " + safe(title);
}
// Drop non-content noise before walking the tree.
content.select("script, style, noscript").remove();
List