让自己的网站在微信里分享时好看一些:记一次败中有成的尝试

缘由

有一天,我看到这篇文章:实践:使个人网站在微信/QQ 中被优雅地访问 - 小站背面。我才发现,个人网站如果没有特别在微信那边设置的话,在微信里面分享出来的效果很差,只有一个链接符号,以及网站的标题。

如果是在浏览器端分享,或许还好一点。比如我用 iPhone 的 Safari 浏览器,分享到微信就可以展示标题、头图和简介。这些应该是网页的一些 <meta> 标签在起作用,具体哪些就不知道了,因为我的网站中一堆这种功能的标签。

网页分享给联系人的效果,上面是 Safari 分享到微信,下面是微信内分享

网页分享到朋友圈的效果,上面是 Safari 分享到微信,下面是微信内分享

前面提到的文章中,对于微信的这种限制这么描述:

微信的链接描述和缩略图其实不是自动获取,而是手动定义的。而为什么我们普通人分享时没有看到相关选项呢?因为这是一个白名单功能,是附属于微信公众号的一项功能。简言之,你要想自定义卡片样式,你就需要申请一个公众号,并将自己的域名接入该公众号后台。

雪上加霜的是,这一步要求域名已备案。

我的域名是备案过的,而且我也有公众号,我倒是想知道怎么搞。

但是,我找了一下微信公众号的后台,没有相关的设置。

我搜了一下,发现,原来这个功能用的是微信公众平台的 JS-SDK。文档 里面写的比较详细,可以看看。

我目前的博客是基于 Hexo 的静态博客,使用 安知鱼主题的魔改版本

基本步骤

微信公众平台的 JS-SDK 中,调用之前需要生成签名。开发者必须在服务器端实现签名的逻辑。

总的来说,想要为在微信内分享的外链添加图片和简介,分为以下几个步骤:

  1. 绑定域名:公众号设置 → 功能设置 → JS 接口安全域名 绑定域名
  2. 设置 IP 白名单:仅支持 IPv4 格式的地址;这里也能看到 AppID 和 AppSecret(如果没设置的话生成一下,记下来) 设置 IP 白名单
  3. 在页面引入 JS 文件:http://res.wx.qq.com/open/js/jweixin-1.6.0.js
  4. 在白名单所在网络获取 access_token
1
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
1
2
// 正常情况下
{"access_token":"ACCESS_TOKEN","expires_in":7200}
  1. 在白名单所在网络获取 jsapi_ticket
1
GET https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=ACCESS_TOKEN&type=jsapi
1
2
3
4
5
6
7
// 正常情况下
{
"errcode":0,
"errmsg":"ok",
"ticket":"bxLdikRXVbTPdHSM05e5u5sUoXNKd8-41ZO3MhKoyN5OfkWITDGgnr2fwJ0m9E8NYzWKVZvdVtaUgWvsdshFKA",
"expires_in":7200
}
  1. 通过特定的签名算法生成签名 signature,详见 文档。除了前面得到的 jsapi_ticket,还要传入秒为单位的时间戳 timestamp、随机字符串 noncestr 和调用 JS 接口页面的完整 URL url(路径要在绑定域名下)。
  2. 注入权限验证配置:
1
2
3
4
5
6
7
8
9
10
11
wx.config({
debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
appId: 'APPID', // 必填,公众号的唯一标识
timestamp: TIMESTAMP, // 必填,生成签名的时间戳
nonceStr: 'NONCESTR', // 必填,生成签名的随机串
signature: 'SIGNATURE',// 必填,签名
jsApiList: [
'updateAppMessageShareData',
'updateTimelineShareData'
] // 必填,需要使用的JS接口列表。上面写了本例中需要的接口
});
  1. 自定义“分享给朋友”及“分享到 QQ”按钮的分享内容,以及“分享到朋友圈”及“分享到 QQ 空间”按钮的分享内容:
1
2
3
4
wx.ready(function () {   //需在用户可能点击分享按钮前就先调用
wx.updateAppMessageShareData({ ... })
wx.updateTimelineShareData({ ... })
});

解决签名的问题

开发者必须在服务器端实现签名的逻辑,而且 access_tokenjsapi_ticket 的获取都要在 IP 白名单内进行,并存储,有效期内不应重新请求。因此,我们同样需要在服务器端获取 access_tokenjsapi_ticket

由于 Hexo 生成的文件是一堆静态文件,如果想这么搞就要另起炉灶,专门写一套服务用来生成它们、处理签名。

我不太想写,就找有没有现成可用的,最后找到了一个使用 Express 框架的 JS 项目:wx_jsapi_sign。可以调用它提供的 API,直接得到签名:

1
GET /api/getWechatJsapiSign/?noncestr=NONCESTR&timestamp=TIMESTAMP&url=URL
1
{"signature": "aaaabbbbbbbbbbbbbb"}

后端 Docker 化

由于我几乎所有服务器上的服务都用 Docker 镜像托管,所以我自然就想将它打包为 Docker 镜像,这样部署的时候方便一些。

由于它需要使用 Redis 存储 access_tokenjsapi_ticket 的信息,因此我使用 Redis 镜像为基础,安装 Node.js,启动。

原来的项目由 config.js 写入配置项:

1
2
3
4
exports.weixin = {
AppId: 'wx9999999999',
AppSecret: 'ad73709c6e0815c999999999999'
};

我为了部署安全方便,全部改为读取环境变量:

1
2
3
4
exports.weixin = {
AppId: process.env.APP_ID,
AppSecret: process.env.APP_SECRET
};
1
2
ENV APP_ID=wx9999999999
ENV APP_SECRET=ad73709c6e0815c999999999999

这样一来,部署时只需要传入环境变量即可:

1
2
3
4
5
docker run \
... \
-e APP_ID=wx9999999999 \
-e APP_SECRET=ad73709c6e0815c999999999999 \
dingjunyao/wx_jsapi_sign:latest

在 Docker 里面启动一个服务容易,但是同时启动两个服务,并且还要输出日志,就麻烦了。使用 dumb-init,便可以通过简单的 Shell 脚本,轻松实现同时开启多个服务:

1
2
3
#!/bin/bash
redis-server &
node app.js
1
CMD ["/usr/bin/dumb-init", "--", "bash", "start.sh"]    # start.sh 即上面的 Shell 脚本

如果镜像在 Debian 的基础上构建,用 APT 即可安装,不需要另外添加软件源:

1
2
RUN apt-get update -y && \
apt-get install -y dumb-init

详细的更改见我 fork 的项目:DingJunyao/wx_jsapi_sign。我同时把镜像发布到了 Docker Hub (dingjunyao/wx_jsapi_sign)GitHub Packages(ghcr.io/dingjunyao/wx_jsapi_sign)

前端的操作

实际上,上面的操作是我后来发现的。我在 Hexo 主题上折腾了半天,才发现还要搞后端。

我上次大段写前端代码,还是在 2018 年做课设的时候。五年过去了,现在的前端早已变得亲妈都不认识了。比如我目前的博客项目,除了 Hexo 及附带的各种插件负责管理、渲染、部署等操作外,使用的主题也采用 pug 模板引擎,以及 stylus 样式引擎。这带来的后果就是,我这种习惯于 HTML + CSS + JS(还是那种最基本的 JS,现在发展的各项技术我早已跟不上了,哪怕是号称入门 ES6 的文档都看不懂) 的外行人,面对现在的代码,只能摸着石头过河,照着已有的代码,猜测其含义,小修小改。调试则变得异常困难:在浏览器上看到的是渲染后的版本,内容、样式有什么不对的,很大程度上只能靠猜。我一般的做法,就是找到可能是标志性的属性和值,在项目里查找这段文本出现的位置,然后据此修改。

我大致的操作如下:

  1. 照着主题里面引入配置的方式,照着写微信的配置项和引入它的脚本
  2. 照着主题里面引用 JS 的方法,引入微信的 JS API
  3. 复制主题中的一个 JS 文件,找到可能用得上的片段,魔改。简单来说,就是通过 fetch 访问签名 API,然后根据得到的签名进行微信 JS-SDK 的各项操作,包括注入配置、应用 API。

我不想大段放代码,有兴趣的可以看 GitHub 上 DingJunyao/hexo-theme-anzhiyu-ding-mod 在 2023-10-17 的提交记录

这种方式极易漏写变量等字符,因此我花了两天的时间才改好。

解决跨域问题

本地部署好后,我初次测试,发现报跨域相关的错误。简单搜索后,我在后端添加了 cors 包,并使用它。

实际上,只要在添加路由前执行以下语句就可以解决问题了:

1
2
const cors = require('cors');
app.use(cors());

但是我希望能够指定允许的域,于是这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const cors = require('cors')
if (config.whitelist.join(',') === '') {
app.use(cors());
} else {
var corsOptions = {
origin: function (origin, callback) {
console.log(config.whitelist);
if (config.whitelist.indexOf(origin) !== -1) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS: ' + origin + 'not in' + config.whitelist))
}
}
};
app.use(cors(corsOptions));
}

config.whitelist 同样是从环境变量那里读取:

1
exports.whitelist = (process.env.WHITELIST || '').split(',')

但是它的值,我琢磨了半天,才发现,如果不处理的话,除了端口号之外,协议也要写上:

1
2
https://4ading.com
https://4ading.com,http://localhost:4000

折腾了两天才发现没有权限

在浏览器上很难看出来微信相关的代码是否正确运行,需要在微信开发者工具里面才能看到。

微信开发者工具

注入权限验证配置的时候,我调试了大半天,总算看到 config ok 的提示了。但是,后续的操作仍然失败。然后我发现,config ok 的时候,返回的 jsApiList 列表为空。

配置成功但无 API 被调用

我还挺纳闷,把 config 里面的文本改为方法:

1
2
3
4
jsApiList: [
wx.updateAppMessageShareData,
wx.updateTimelineShareData
]

还是不行。

后来我不知为什么,到自己的接口权限里面一看:

接口权限

这下终于明白了。通过微信认证的公众号才能调用这些功能,而注册主体为个人是搞不了认证的。

搞了两天,才发现自己一开始就没有权限。

败中有成

本来到了这里,我想把代码弃之不用了,不过还是觉得可惜,可是我没有能用的号拿来测试效果。

所幸,微信的开发者工具中还提供了公众平台测试账号,可以测试需要权限的接口。你需要用自己的微信账号登录、关注测试号,然后用它的 appID 和动态变化的 appSecret 来测试。

测试号管理界面

我用它测试了一下,可行。

测试号下的执行结果

趁着还有效,我测试了添加代码后,在微信里面分享出来的效果,结果如下:

添加代码后,网页分享给联系人的效果

添加代码后,网页分享到朋友圈的效果

所以,虽然自己用不了,但是还是写出了可用的代码。