一、引言(痛点+价值)
- 为什么需要自动推文?
- 手动发布低效、容易遗漏
- 多平台同步需求(如博客→公众号)
- 本文能学到什么?
- 通过API/工具实现定时/触发式发布
- 代码示例(Java)
二、准备工作
- 微信公众号权限配置
- 开通开发者权限
- 获取AppID和AppSecret
- 准备工具依赖
- Java:
okhttp3库
- Java:
三、实现方案
- 增加配置项 application.yml或bootstrap.yml
weChat:
api:
appId: appId(需替换成自己的)
appSecret: appSecret(需替换成自己的)
token:
url: https://api.weixin.qq.com/cgi-bin/token
draft:
url: https://api.weixin.qq.com/cgi-bin/draft/add
- 定时触发
@Slf4j
@Component
public class WeChatSchedule {
@Resource
DeepSeekDriver deepSeekDriver;
@Resource
WeChatDriver weChatDriver;
@Resource
IBoChaService boChaService;
@Scheduled(cron = "0 30 8 * * ?")
// 本地调试
// @Scheduled(cron = "0/59 * * * * ?")
public void syncWeChat() {
this.sendWeChat();
}
/**
* 通过deepseek以及博查api获取最新消息
**/
private void sendWeChat() {
String searchPrompt = "查询本地的天气,全球最新的三条新闻(当前时间:" + TimeUtils.timestampToDate(new Date().getTime() / 1000) + ")";
String searchResult = boChaService.getBoChaMessage(searchPrompt);
log.info("联网查询结果:{}", searchResult);
String prompt = "生成一篇微信公众号文章,要求:风趣幽默些,在文章末尾增加心灵鸡汤,本地当天的天气(以联网搜索内容为准,不得自创),以及全球最新的三条新闻(以联网搜索内容为准,不得自创)(【联网搜索结果】\n" + searchResult + ")\n\n回答要求: " + "\n- 基于搜索结果提供准确信息" + "\n- 禁止列出信息来源,禁止提供来源链接,禁止回复联网搜索的内容" + "\n- 保持回答简洁专业";
Mono<String> aiContext = deepSeekDriver.chatCompletion(prompt, false, "deepseek")
.filter(DeepSeekResponseEvent.class::isInstance) // 过滤出响应事件
.map(DeepSeekResponseEvent.class::cast)
.map(DeepSeekResponseEvent::getContent) // 提取内容
.collectList() // 收集所有内容到List
.map(list -> String.join("", list));// 拼接所有内容
aiContext.subscribe(completeResponse -> {
String completeMessage = completeResponse.replace("[DONE]", "");
log.info("AI生成结果: {}", completeMessage);
// 微信推文接口
weChatDriver.sendWeChatMessages(completeMessage);
});
}
}
- 微信服务接口
// 公共
@Value("${weChat.api.token.url}")
private String tokenUrl;
@Value("${weChat.api.draft.url}")
private String draftUrl;
@Value("${weChat.api.appId}")
private String appId;
@Value("${weChat.api.appSecret}")
private String appSecret;
private final OkHttpClient client = new OkHttpClient();
@Resource
private ObjectMapper mapper;
- 获取token
//定时每天调用一次,故并未缓存token
public String getWeChatToken() {
String url = tokenUrl + "?grant_type=client_credential&appid=" + appId + "&secret=" + appSecret;
Request request = new Request.Builder().url(url).build();
Response response = null;
try {
response = client.newCall(request).execute();
if (null == response.body() || !response.isSuccessful()) {
log.error("微信token接口请求失败,{}", response);
throw new OperateException("微信token获取失败");
}
String json = response.body().string();
log.info("微信token接口返回数据:{}", json);
WeChatTokenResponse weChatTokenResponse = mapper.readValue(json, WeChatTokenResponse.class);
return weChatTokenResponse.getToken();
} catch (Exception e) {
log.error("微信token接口调用失败,{}", Throwables.getStackTraceAsString(e));
throw new OperateException("微信token获取失败");
} finally {
try {
if (response != null && response.body() != null) {
response.body().close();
}
} catch (Exception e) {
log.error("关闭失败");
}
}
}
- 创建草稿图文消息
接口入参请参考微信公众号官方文档)
private String createDraft(WeChatArticleRequest weChatArticleRequest) {
String url = draftUrl + "?access_token=" + getWeChatToken();
Response response = null;
try {
Map<String, Object> requestBodyMap = new HashMap<>(16);
requestBodyMap.put("articles", List.of(weChatArticleRequest));
String requestJson = mapper.writeValueAsString(requestBodyMap);
log.info("微信创建草稿接口请求数据:{}", requestJson);
RequestBody requestBody = RequestBody.create(requestJson, MediaType.parse("application/json; charset=utf-8"));
Request request = new Request.Builder().url(url).post(requestBody).addHeader("Content-Type", "application/json").build();
response = client.newCall(request).execute();
if (null == response.body() || !response.isSuccessful()) {
log.error("微信创建草稿接口请求失败,{}", response);
throw new OperateException("微信创建草稿获取失败");
}
String json = response.body().string();
log.info("微信创建草稿接口返回数据:{}", json);
HashMap mediaIdMap = mapper.readValue(json, HashMap.class);
return mediaIdMap.get("media_id").toString();
} catch (Exception e) {
log.error("微信创建草稿接口调用失败,{}", Throwables.getStackTraceAsString(e));
throw new OperateException("微信创建草稿获取失败");
} finally {
try {
if (response != null && response.body() != null) {
response.body().close();
}
} catch (Exception e) {
log.error("关闭失败");
}
}
}
- 微信公众号文章格式转换(markdown-->wx文章)
@Slf4j
@Component
public class MarkdownToHtmlUtil {
private MarkdownToHtmlUtil() {
// 初始化
}
// 微信公众号常用样式映射
private static final Map<String, String> WECHAT_STYLES = new HashMap<>();
static {
WECHAT_STYLES.put("h1", "style="font-size:22px; font-weight:bold; color:#333; margin-top:20px; margin-bottom:10px;"");
WECHAT_STYLES.put("h2", "style="font-size:20px; font-weight:bold; color:#333; margin-top:18px; margin-bottom:8px;"");
WECHAT_STYLES.put("h3", "style="font-size:18px; font-weight:bold; color:#333; margin-top:16px; margin-bottom:6px;"");
WECHAT_STYLES.put("p", "style="font-size:16px; line-height:1.8; color:#666; margin-bottom:15px;"");
WECHAT_STYLES.put("pre", "style="background-color:#f8f8f8; border-radius:4px; padding:10px; margin-bottom:15px; overflow-x:auto;"");
WECHAT_STYLES.put("code", "style="font-family:Consolas,Monaco,'Andale Mono',monospace; font-size:14px; color:#c7254e; background-color:#f9f2f4; border-radius:3px; padding:0 3px;"");
WECHAT_STYLES.put("blockquote", "style="border-left:4px solid #ddd; padding-left:15px; color:#777; margin:10px 0;"");
WECHAT_STYLES.put("ul", "style="margin:10px 0; padding-left:30px;"");
WECHAT_STYLES.put("ol", "style="margin:10px 0; padding-left:30px;"");
WECHAT_STYLES.put("li", "style="margin-bottom:5px;"");
WECHAT_STYLES.put("img", "style="max-width:100%; height:auto; display:block; margin:10px auto;"");
WECHAT_STYLES.put("table", "style="border-collapse:collapse; width:100%; margin-bottom:15px;"");
WECHAT_STYLES.put("th", "style="border:1px solid #ddd; padding:8px; background-color:#f2f2f2;"");
WECHAT_STYLES.put("td", "style="border:1px solid #ddd; padding:8px;"");
}
public static String convert(String markdown) {
// 预处理Markdown文本,移除列表中的空行
markdown = markdown.replace("\n", "\n");
markdown = preprocessMarkdown(markdown);
// 配置 Markdown 解析器,支持表格和标题锚点
List<Extension> extensions = Arrays.asList(TablesExtension.create(), HeadingAnchorExtension.create());
// 创建解析器
Parser parser = Parser.builder().extensions(extensions).build();
// 解析 Markdown 文本
Node document = parser.parse(markdown);
// 创建 HTML 渲染器,并添加自定义属性
HtmlRenderer renderer = HtmlRenderer.builder().extensions(extensions).attributeProviderFactory(context -> new CustomAttributeProvider()).build();
// 渲染为 HTML
String html = renderer.render(document);
// 应用微信公众号样式
return applyWechatStyles(html);
}
private static String preprocessMarkdown(String markdown) {
StringBuilder processed = new StringBuilder();
String[] lines = markdown.split("\n");
boolean inOrderedList = false;
boolean inUnorderedList = false;
for (String line : lines) {
line = line.trim();
// 检查是否是有序列表项
boolean isOrderedListItem = line.matches("^\d+\.\s.*");
// 检查是否是无序列表项
boolean isUnorderedListItem = line.matches("^[*+-]\s.*");
// 处理有序列表
if (isOrderedListItem) {
inOrderedList = true;
inUnorderedList = false;
processed.append(line).append("\n");
}
// 处理无序列表
else if (isUnorderedListItem) {
inUnorderedList = true;
inOrderedList = false;
processed.append(line).append("\n");
}
// 处理列表中的空行
else if ((inOrderedList || inUnorderedList) && line.isEmpty()) {
// 跳过列表中的空行
continue;
} else {
// 处理非列表内容
inOrderedList = false;
inUnorderedList = false;
processed.append(line).append("\n");
}
}
return processed.toString();
}
private static String applyWechatStyles(String html) {
StringBuilder styledHtml = new StringBuilder();
styledHtml.append("<div style="font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','PingFang SC','Hiragino Sans GB','Droid Sans Fallback','Microsoft YaHei',sans-serif;">");
for (Map.Entry<String, String> entry : WECHAT_STYLES.entrySet()) {
String tag = entry.getKey();
String style = entry.getValue();
// 处理开始标签
html = html.replaceAll("<" + tag + "(?!\s*style=)(\s|>)", "<" + tag + " " + style + "$1");
// 处理自闭合标签
html = html.replaceAll("<" + tag + "\s*" + style + "\s*/>", "<" + tag + " " + style + " />");
}
styledHtml.append(html.replace("\n", "").replace("\n", ""));
styledHtml.append("</div>");
return styledHtml.toString();
}
// 自定义属性提供器,用于添加额外的 HTML 属性
private static class CustomAttributeProvider implements AttributeProvider {
@Override
public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
// 为链接添加 target="_blank"
if ("a".equals(tagName)) {
attributes.put("target", "_blank");
}
}
}
}
- 手动发布
因还未进行公众号认证,现只能在app或者官网手动发布
四、避坑指南
- API每日有调用限额,请参考微信公众号官方文档
- IP白名单未配置,接口无法调用。配置路径:公众号官网/设置与开发/开发接口管理/IP白名单
五、总结
- 自动化的优点
- 内容生产效率提升3-5倍(常规选题)
- 推送时效性从小时级压缩至分钟级
- 人力成本降低40-60%(标准运营团队)
- 自动化的缺点
- 内容风险:易出现敏感词(建立动态库,发布前过滤)
- 法律风险:AI生成的内容权限认定
六、结语
通过本文的介绍,详细探讨了如何利用Java和微信公众号API实现自动推文功能。从权限配置到代码实现,再到避坑指南,我们一步步拆解了自动化推文的全流程。虽然当前方案仍需手动发布草稿(受限于公众号认证状态),但核心的自动化内容生成和草稿创建已能显著提升效率。
未来优化方向:
- 结合OAuth2.0实现全自动发布(需认证服务号)
- 增加多账号管理能力
- 集成敏感词过滤系统
技术永远是为场景服务的工具,期待看到大家更有创意的实现方式。如果你有更巧妙的方案(比如用Python简化流程/低代码工具搭配),欢迎在评论区分享交流!
保持迭代,持续自动化 🚀
(注:本文代码已脱敏,实际使用时请替换为自己的AppID和密钥)
未完结。。。