一个看起来很小的需求

这件事是施工现场那边提的。他们每晚十点有一个例会,会上要看全国建站进度的汇总图,希望这张图到点自己出现在企微群里,而不是每天都要有人记着去发。

听起来不难,因为图本身已经有了。前端之前做过一张很长的 ECharts 汇总图,横向铺开六个战区,仪表盘和柱状图排得满满当当,右上角还有一个“导出图片”的按钮。也就是说,图能画,也能导出,差的只是“到点自动发出去”这一步。

每晚推送的那张战区汇总图,共 6 列,横向约 28000px

我当时以为这一步很快就能补上,结果前前后后绕了三圈。

绕路

第一个想法最朴素:既然有按钮,那就让业务同学每晚十点去按一下。业务方其实接受了这个方向,只是补了一个很合理的条件,说晚上十点不可能专门打开电脑,只能在手机上的小程序里操作。

这个条件一加,路就断了。“导出图片”要靠浏览器把整张 ECharts 完整渲染出来才能执行,而小程序的渲染环境并不是浏览器,我想不出有什么办法能让小程序绕过 Web 端去触发那个导出。

于是我开始往后端想。如果渲染这件事交给后端来做,模板存在数据库里,想改随时能改,也就不再依赖任何浏览器了。这个方向说到底是退回前后端不分离的做法,灵活性确实最高,但我越往下想越觉得落不了地:前端同学不熟悉 Python 和 Jinja,很难指望他们长期维护后端模板;现有的报表是 Vue 组件加 ECharts 写的,能不能原样搬进后端模板,我自己心里也没底;而后端这边习惯了只写接口,突然要去拼页面,本身也是一种负担。

这几个问题单拎出来都不算致命,可它们叠在一起,就让这个方案显得很重。

真正让我换个角度的,是回头又看了一眼那个按钮。前端在它背后做的事情其实相当完整,用 html2canvas 把 DOM 完全展开,按内容的实际宽高以 scale 4 截出高清图,再触发一次文件下载。我前面一直在琢磨谁能把这张图画出来,可图早就能画出来了,缺的只是一个能在晚上十点按下按钮的人。

既然人不方便,那就让后端起一个无头浏览器,替人去按。

让它去点

我用的是 Playwright。它是微软做的浏览器自动化库(官网 · GitHub),可以用代码驱动一个真实的浏览器内核,人在浏览器里能做的事它基本都能做。所谓无头模式,就是在服务器上跑一个没有界面的浏览器,它做完事就退出,只把结果留在磁盘上。Playwright 支持三种内核:

内核对应浏览器说明
ChromiumChrome、EdgeGoogle 主导的开源浏览器项目。因为开源,Playwright 可以直接把它打进镜像、无界面地跑。
FirefoxFirefoxMozilla 的引擎 Gecko。
WebKitSafariApple 的引擎,也是 iOS 上所有浏览器的底层。

我选了 Chromium,一来内网镜像好拿,二来它跑出来的结果和用户在浏览器里看到的是一致的。

登录

要让它去点按钮,首先得让它进得了页面。看板是需要登录的,前端把登录态存在 localStorage 里,也就是一个 JWT token 和一份 userInfo,页面初始化时 Vue 会去读它们。好在 JWT 本来就是后端签发的,我不需要让浏览器真的走一遍登录流程,直接用 simplejwt 给系统账号签一个 token,在浏览器上下文启动的时候用 add_init_script 写进去就可以了:

python
token = str(AccessToken.for_user(user))
init_js = (
    "try {"
    f"localStorage.setItem('token', {json.dumps(token)});"
    f"localStorage.setItem('userInfo', {json.dumps(json.dumps(user_info, ensure_ascii=False))});"
    "} catch (e) {}"
)
ctx.add_init_script(init_js)

这样页面加载时 token 已经在那里了,路由守卫也就不会把它送去登录页。

点按钮

接下来的流程和人手动操作没什么两样,打开看板,等 ECharts 渲染完,点一下“导出全国”,然后把下载下来的文件接住:

python
page.goto(page_url, wait_until="networkidle", timeout=60000)
page.wait_for_timeout(5000)  # 等 ECharts 动画完成

page.get_by_text("推送图", exact=True).first.click()
page.wait_for_timeout(8000)  # 等各列 ECharts 全部渲染

with page.expect_download(timeout=180000) as di:
    page.get_by_text("导出全国", exact=True).first.click()
di.value.save_as(out_path)
▶ expect_download()

前端的“导出图片”最后是靠一次 link.click() 触发下载的,Playwright 的 expect_download() 正好能拦住这个事件并拿到文件,所以我不需要自己再写任何截图逻辑。拿到的就是 html2canvas 生成的那张原图,图的边界和清晰度都由前端代码来保证。

唯一需要多花点心思的是“等”。战区汇总有六列 ECharts,每一列都要单独请求数据再渲染,有些 tab 还在持续轮询,导致 networkidle 永远等不到。我最后用 try/catch 兜了个底,等不到就退回一个固定的延迟,不太优雅,但足够可靠:

python
try:
    page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
    pass  # 有轮询的页面 networkidle 不可达,用固定延迟兜底
page.wait_for_timeout(8000)

压缩和发送

图拿到之后还不能直接发。前端导出的是 scale 4 的 PNG,战区那张宽度超过 28000px,将近 14MB,而企微图片接口的上限是 2MB。我没有选择缩小尺寸,因为一缩宽度,右边那几列就会被截掉,于是只降 quality,从高往低一档一档地试,压进 2MB 就停下:

python
MAX_BYTES = 2 * 1024 * 1024  # 企微图片上限 2MB

for quality in (82, 72, 62, 50, 40, 30, 20):
    resized.save(dst, quality=quality, optimize=True)
    if os.path.getsize(dst) <= MAX_BYTES:
        break

战区图在 quality 降到 40 的时候压到了 1995KB,刚好卡在线内。如果降到 20 还是超,就记一条 warning,照样尝试上传。

发送这一步比我预想的省事。企业微信本身的开放 API 需要自己维护 access_token,还要用 Tencent 的 userid 来标识用户,但 BYD 在公司层面做了一层代理,把这些细节都挡在了后面,token 由他们统一刷新,用户直接用工号来标识,每个接入方只需要一个 appid 和一个 Authentication 密钥。

发一张图分两步,先上传素材拿到 media_id,再用它发消息:

python
# 第一步:上传素材,拿 media_id
response = requests.post(
    "http://weixin.byd.com/admin/services/weixin/media/temp/upload",
    headers={"Authentication": "Basic ...", "User-Agent": "Mozilla/5.0 ..."},
    files={"file": (filename, f)},
    data={"type": "image"},
)
media_id = response.json()["msg"]["media_id"]  # 有效期 3 天

# 第二步:发图片消息
requests.post(
    "https://weixin.byd.com/admin/services/weixin/msg/send",
    json={"touser": "工号1|工号2", "msgtype": "image", "msgDetail": {"media_id": media_id}, "appid": "..."},
)
⚠ User-Agent 是必须的

上传接口前面有一层 WAF,不带 User-Agent 的请求会直接被 403 拦掉,返回的是一段 HTML 而不是 JSON,于是 response.json() 就抛了异常,加上一个浏览器的 UA 字符串之后才通。如果调试时看到 Expecting value: line 1 column 1,多半就是这个原因。

交给 Admin

逻辑跑通之后,我把配置和状态都接进了 Django Admin,因为我不希望以后每次改推送时间或者加一个接收人,都要回来改代码。

接收人是一张简单的表,每条记录一个工号,可以单独启用或停用。列表页上放了一个“立即推送”的按钮,点下去会在后台起一个子进程去跑,接口本身马上返回:

python
def push_now(self, request):
    cmd = [sys.executable, manage_py, "push_national_dashboard", "--trigger", "manual"]
    subprocess.Popen(cmd, cwd=str(settings.BASE_DIR))
    self.message_user(request, "已触发看板推送,约 10 分钟内送达")

推送时间、前端地址和是否启用定时放在一个单例配置里,保存之后会立刻调用 reschedule_push_job() 重新安排 APScheduler 的任务,不需要重启服务:

python
def save_model(self, request, obj, form, change):
    super().save_model(request, obj, form, change)
    reschedule_push_job(obj.hour, obj.minute, obj.enabled)

每次推送还会留一条记录,写明状态、触发方式、触发人和总大小,每张图另有一条子记录带着缩略图,在 Admin 里能直接点开看某一天发出去的是什么样子。后来证明,这个缩略图帮了大忙。

上线

在真正上线之前,还有一个小插曲。Playwright 需要 Chromium 才能跑,我在 Dockerfile 里加了 playwright install chromium,结果这一步会启动一个 Node.js 进程,在内网构建机上撞到了内核的 seccomp 限制,clone3 系统调用被拦截,Node 直接崩掉,镜像怎么也构建不出来。既然构建机不让装,我就干脆不在构建机上装,在 46 服务器上单独做了一个基础镜像,把 Chromium 和相关依赖预先打进去推到内网 Harbor,主 Dockerfile 直接 FROM 它,Jenkins 构建时什么都不用再装,这个限制也就碰不到了。

然后是上线第一天。

那天晚上用户收到了推送的文字标题,但是没有收到图,而推送日志写的是 success,没有任何报错。我打开 Admin 里那条记录看缩略图,每张图都只有最上面一行蓝色的标题框,下面全是空白。

我第一个怀疑的是上传环节。对比代码的时候我发现,发文字消息的接口用的是 https://weixin.byd.com,上传图片的接口却写成了 http://weixin.byd.com,少了一个 s。我当时几乎确定就是它了,改成 https 提交上去再跑一遍,图还是空的。后来测了一下才知道两个端口其实都是通的,问题根本不在这里。

这次我没有再急着猜,而是把整条调用链完整地画了出来:

画出来之后,我在生产服务器上 ping 了一下 jingpin.byd.com,发现这个域名根本解析不了。我的第一反应是把 Playwright 打开的页面地址换成 nginx 的 IP,绕开域名解析,可图依然是空的。

我漏掉的是页面里面的那一层。Playwright 打开的只是前端的静态页面,页面加载完之后,前端 JS 里配置的后端接口地址同样是这个域名,VITE_API_BASE_URL 指向的就是 jingpin.byd.com。所以页面本身能打开,但它发出去的每一个 API 请求都在走一个容器里解析不了的域名,数据全都静默地失败了,最后渲染出来的只是一副空的框架。

测试环境之所以没有暴露,是因为测试服务器的域名解析是配好的,很多接口地址本身也直接写的是 IP,容器里一访问就通。生产服务器不在那个网络里,域名解析从来没有配过。代码一个字都没变,变的是它所在的环境。

最后的修法是用 Playwright 的 ctx.route(),在页面发出请求的时候把它截下来,在 Python 这一层做转发。Playwright 和 Django 本来就跑在同一个容器里,只要把发往 jingpin.byd.com 的请求转到本机就行:

python
def _proxy(route):
    p = urlparse(route.request.url)
    new_url = f"http://127.0.0.1:{port}" + p.path[len(_prefix):]
    route.fulfill(response=route.fetch(url=new_url))

ctx.route("https://jingpin.byd.com/**", _proxy)

拦截的地址我做成了 Admin 里的一个配置项 api_domain,填上完整的 API base URL 就会拦截,留空就不拦,测试环境不用填,生产环境填上即可。再跑一次,日志终于变成了这样:

API proxy 已启用: https://jingpin.byd.com/.../** -> http://127.0.0.1:8903
API proxy: .../api/dashboard/business-report/ 200
API proxy: .../api/projects/war-zones/ 200
[战区] 截图 1995KB

之前那张空图只有 57KB,这一次是 1995KB。

每晚两遍

图终于正常了,可过了一段时间,又有人告诉我每天晚上会收到两批一模一样的图。

这次的原因藏在调度器的 jobstore 里。项目里 dashboard 和 knowledge_base 两个 app 各自启动了一个 BackgroundScheduler,却都挂在同一张 DjangoJobStore 表上。APScheduler 在多个调度器共享同一个持久化 jobstore 的时候并没有分布式锁,每个调度器都会把表里所有的任务加载进来执行,而不只是它自己注册的那几个,于是 push_national_dashboard 每晚被两个调度器各跑了一次。

修起来倒很简单,让两个调度器各自用回默认的 MemoryJobStore,互不干扰:

python
# 之前:共用 DjangoJobStore,互相串任务
scheduler.add_jobstore(DjangoJobStore(), "default")

# 之后:各用 MemoryJobStore,只执行自己注册的任务
scheduler = BackgroundScheduler()  # 默认即 MemoryJobStore

代价是不会再往 DjangoJobExecution 表里写执行历史,Admin 里看不到调度记录了。不过推送记录本来就单独写在 DashboardPushLog 里,这点损失我觉得可以接受。

回头看

现在回头看,我一开始花了不少时间去想后端要怎么把这张图画出来,其实是在解一个并不存在的问题。前端早就把图画好了,Playwright 在这里扮演的也不是截图工具,它自己什么都不渲染,只是代替人让浏览器按正常的流程走一遍,再把结果接住。

上线那天的空图,本质上也是同一件事的另一面。Playwright 并不运行在用户的网络里,而是运行在服务器的网络里,这两个环境的 DNS 可以完全不同,测试环境恰好一致,所以一直没有暴露。ctx.route() 做的事情,就是把这份差异明确地写进代码里,而不是指望两边的配置碰巧对得上。

很多时候卡住我的并不是做不做得出来,而是没有注意到那个一直就在那里的按钮。

欢迎与我交流。