博客文章自动同步微信公众号实践

885次阅读  |  发布于3年以前

本文来自 zhiyi 的个人博客实践,可以通过开放能力将其他平台的文章同步到微信公众号上,对刚学前端的同学(有自己的博客就更好了)来说,是对后台接口链路的一个不错的探索。

整体思路

微信官方提供了素材管理的 API,通过 API 可以很方便地进行同步。在使用 API 之前需要进行鉴权,所以需要先获取 access token。微信公众号中不允许出现外域图片,因此需要把文章里的图片全部使用微信的图片上传接口处理后替换。此外,微信公众号支持 HTML 标签但是只支持内联样式,所以必须把外联样式全部转换为内联样式。

所以,同步到微信公众号的操作,需要按照以下步骤:

  1. 使用公众号的 appid 和 secret 换取 access token。
  2. 把文章中的所有图片用微信图片上传接口上传,并替换文章里的 URL。这一步需要使用 access token 鉴权。
  3. 将文章中的所有外联 css 转为内联样式。
  4. 调用微信素材管理接口,同步文章。这里需要使用 access token 鉴权。

获取 access token

获取 access token 本身没什么难度,使用微信公众号的 appid 和 secret 就可以从接口获取到。需要注意的是,这个接口有调用频率限制,短时间内调用次数不能过多。

所以我们从微信的接口获取 access token 之后应该将它缓存,之后直接从缓存中获取,缓存失效了再重新从接口获取。这里的缓存机制使用了 Redis,因为 Redis 提供的过期失效机制正好满足我们的需求。

首先我们在 Koa 的全局变量里注册 Redis,以便在各种场景调用。这里,我们把它写成 Koa 中间件的形式,并把几个 Redis 常用操作 Promise 化。

module.exports = (redisConfig) =>async (ctx, next) => {
  const client = redis.createClient(redisConfig.port, redisConfig.host);
  client.auth(redisConfig.password);
  ctx.redis = {
    get: (key) => new Promise((resolve, reject) => {
      client.get(key, (err, result) => {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      });
    }),
    set: (key, value) => new Promise((resolve, reject) => {
      client.set(key, value, (err, result) => {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      });
    }),
    expire: (key, expire) => new Promise((resolve, reject) => {
      client.expire(key, expire, (err, result) => {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      });
    }),
    del: (key) => new Promise((resolve, reject) => {
      client.del(key, (err, result) => {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      });
    }),
  };
  await next();
};

之后,在获取 access token 时,先尝试从 Redis 中取。如果获取到了,就直接返回结果;如果没取到,就向微信接口请求并写入 Redis。

module.exports = async (ctx) => {
  try {
    let accessToken = await ctx.redis.get('wechatAccessToken');
    if (!accessToken) {
      const res = await axios.get(`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${wechatConfig.appid}&secret=${wechatConfig.secret}`);
      if (res?.status !== 200) {
        throw new Error('get access token error');
      }
      accessToken = res?.data?.access_token;
      const expiresIn = res?.data?.expires_in;
      if (!accessToken) {
        throw new Error('get access token error');
      } else {
        await ctx.redis.set('wechatAccessToken', accessToken);
        await ctx.redis.expire('wechatAccessToken', expiresIn);
      }
    }
    return Promise.resolve(accessToken);
  } catch (err) {
    return Promise.reject(err);
  }
};

这样,我们就实现了获取并缓存 access token。

上传并替换图片

我的文章内容是一段 HTML 代码字符串,这是由前端传入的。不管前端使用什么编辑器,这一步都需要先转为 HTML 字符串再操作。

首先需要把文章内容中的图片全部找出来,这里直接用正则即可:

const images = parsedContent.match(/<img.*?(?:>|\/>)/gi);
if (images) {
  for (const image of images) {
    const src = image.match(/src=['"]?([^'"]*)['"]?/i);
    const url = src?.[1];
    // 对取出的 URL 做处理
  }
}

首先匹配所有的 <img /> 标签,之后针对每个标签再做一次匹配,取到其中的 src 值(也就是图片的 URL)。

对匹配到的图片 URL 依次下载为 stream 并上传到微信公众号图片上传接口,之后使用返回的微信域内 URL 替换原文中的 URL。

if (url) {
  const imageStream = (await axios.get(url, { responseType: 'stream' })).data;
  const formData = { media: imageStream };
  const res = await request({
    url: `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${accessToken}`,
    method: 'POST',
    formData,
  });
  parsedContent = parsedContent.replace(url, JSON.parse(res).url);
}

上传图片的接口当然需要鉴权,这里的 access token 是直接使用上一步封装好的方法获取的。

const accessToken = await getWechatAccessToken(ctx);

需要注意的是,上传到微信公众号必须使用 request 或者基于 request 的 request-promise。因为 node 环境的 axios 对 form-data 格式发送文件的 POST 并不能很好地支持。

不要忘记了对封面图也做一样的处理,因为使用 API 编辑公众号图文必须添加封面图,封面图也必须是微信域内的。

const coverImageStream = (await axios.get(coverImage, { responseType: 'stream' })).data;
const coverImageRes = await request({
  url: `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${accessToken}&type=image`,
  method: 'POST',
  formData: { media: coverImageStream },
});
const coverImageId = JSON.parse(coverImageRes).media_id;

这里需要注意的是,上传封面图的接口和上传图片的接口是不一样的,要注意区分。最后我们拿到的是封面图的素材 id,这个 id 我们后面创建图文素材时会用到。

把封面图和文章中的图片都替换一遍后,我们就完成了这一步。

外联 CSS 转为内联

这一步我本来以为会很麻烦,但是幸运的是,在 node 上(前端浏览器等环境不可以用这个包,会报错)有一个名为 juice 的 npm 包可以帮我们一行代码完成任务:

parsedContent = juice(`
  <style>
    ${cssString}
  </style>
  <div>
    ${parsedContent}
  </div>
`);

这里的 cssString 是定义好的字符串,事先把需要应用的 CSS 代码定义好一个字符串变量即可;第一个参数则是需要处理的 HTML 代码,也就是上一步替换图片 URL 的结果。

调用接口创建素材

到这一步已经没什么问题了,按照微信开发文档调用接口即可。

await axios.post(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`, {
  articles: [{
    title: '你的文章标题',
    thumb_media_id: coverImageId, // 刚才取到的封面图素材 id
    author: '文章作者',
    digest: '文章摘要',
    content: parsedContent, // 刚才处理好的文章
    content_source_url: '原文链接', // 非必填,这里我写的是我博客这篇文章的 URL
    need_open_comment: 1, // 是否打开留言功能
    show_cover_pic: 0, // 是否把封面图添加到文章开头
  }],
});

这样就可以成功把文章同步到微信公众号后台的素材库中。最后在微信公众号官方客户端 “订阅号助手” 操作一下,就可以成功把文章发布出去了。当然,最后的发布操作也可以调用 API 解决,不过官方客户端本身就有这个功能,而且官方客户端的 “预览” 功能可以让我提前看到效果,所以我就不必多此一举了。

尚未解决的小问题

虽然同步到微信公众号这个功能帮我打通了在手机上创作到发布的整个链路,但是还是有两个小问题暂时没法解决:

  1. 微信公众号未提供声明原创的接口,官方客户端也没有这个功能,因此想要声明原创文章还是必须在电脑上操作。
  2. 微信公众号网页版管理后台支持对封面图进行自定义裁剪,而通过 API 指定封面图则只能使用图片中间部分。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8