获取前沿技术动态与开源进展,是一项高价值却极其消耗精力的日常动作。
面对全球浩瀚的技术社区、学术论文与开源代码库,开发者常常陷入两难困局:若每天手动搜集并整理资讯,需耗费大量碎片化时间在信息筛选、真伪核验与格式排版上;若完全依赖市面上现成的聚合工具或邮件订阅,内容往往同质化严重、噪音密集,且无法针对个人专注领域进行定制化过滤。
市面上常见的主动抓取方案,通常依赖购买云端虚拟主机(VPS)、编写复杂的爬虫脚本并按月采购商业大模型 API(Token 费用高昂)。不仅架构沉重、存在常驻服务器维护负担,移动端查阅体验也往往割裂——邮件通知容易沉底,第三方推送平台数据经手他人且频遭限流。
为了彻底消除这一工程摩擦,我们设计并落地了一套“零模型额外计费、零常驻服务器成本、以 Git 仓库为状态总线、直达个人微信原生阅读器”的自动化技术日报流水线。
本文全面剖析该系统的架构哲学、核心模块设计与五大深度踩坑复盘。
1. 系统总体架构与双半环设计
整个系统由“前半环(AI 调研与状态归档)”与“后半环(事件驱动与图文推送)”两个高度解耦的子系统构成,双方以代码仓库的 Git Commit 作为唯一的异步通信总线。
1.1 核心组件与职责划分
| 层次节点 | 技术选型 | 核心职责与工程约束 |
|---|---|---|
| 调研引擎 | ChatGPT Tasks / Codex | 负责定时唤醒、全网检索一手资料、比对去重数据库并生成符合规范的结构化 Markdown。零外部 API Key 采购。 |
| 写入中枢 | GitHub App 授权通道 | 具备精细化 Contents: Read and write 权限,执行文件提交并强制实施回读验证(Fetch File)。 |
| 状态总线 | GitHub 私有仓库 | 充当唯一的事实数据源(Single Source of Truth)。版本可 Diff、可追溯、具备完整的 Git 提交审计历史。 |
| 流水线调度 | GitHub Actions | 监听 reports/**.md 路径变更,计算提交差异,运行轻量解析引擎将 Markdown 转换为内联样式 HTML。 |
| 网络中继代理 | 腾讯云函数(SCF) | 绑定固定弹性公网 IP,中继企业微信 API 请求,突破企业微信管理后台“企业可信 IP”硬性校验。 |
| 终端展示 | 企业微信插件(微工作台) | 个人微信聊天首页独立会话,调用 mp.weixin.qq.com 原生图文阅读器,支持免翻墙、免认证的移动端阅读。 |
2. 第一阶段:ChatGPT 自动化写入与权限闭环
在将 AI 接入 GitOps 流水线时,最容易出现的认知偏差是以为“连接了 GitHub 账号”就能自动推送代码。
2.1 辨析只读连接与 Push 写入权限
OpenAI 官方对 GitHub 的集成具有明确边界:标准聊天中的 GitHub 工具默认倾向于只读分析。要在无人值守的自动化任务中提交 Commit,必须满足以下两项前置条件:
- 仓库粒度精准授权:在 GitHub 个人设置的 Installed GitHub Apps 中,切勿留空或全选,应勾选 Only select repositories 并精准授权给目标私有仓库;
- 读写权限声明:确保 App 的权限包含
Contents: Read and write。
在正式编写定时任务前,必须在交互界面中通过最小 Prompt 执行权限核验:
请检查 GitHub 仓库 yuruchuan/agentAssistant,
读取仓库元数据、默认分支和根目录文件,
重点返回当前连接拥有的权限(admin, pull, push 等)。
不要修改任何文件。
当且仅当返回结果中明确包含 "push": true 时,AI 才具备向分支提交 Markdown 文件的能力。
2.2 最小写入测试与回读验证准则
为了防止 AI 在长篇生成中断后出现“伪提交”(即对话声称已提交,但远端分支空无一物),系统确立了“写入后立即回读(Write-Then-Read-Back)”的强校验纪律:
请在目标仓库 main 分支创建:
reports/ai-creative/2026/09/2026-09-14-write-test.md
写入一段包含标题与日期的最小测试内容。
创建成功后返回 commit SHA,并立即执行 fetch_file 回读该文件,确认内容真实落地。
只有在回读成功并确认 Commit SHA 物理生成后,才允许将任务交由定时调度器托管。
3. 第二阶段:报告目录规范与去重状态机
一份具备工程严肃性的自动化报告系统,严禁无序覆盖与反复推荐相同项目。
3.1 目录规划规范
项目在代码库中设立了严谨的分层结构:
.github/—— 持续集成与自动化流水线workflows/notify-report.yml—— 变更监听与图文推送工作流
reports/—— 日报归档根目录template.md—— 报告标准 Frontmatter 与排版范本ai-creative/—— AI 创意专题分支目录_state.json—— 持久化去重状态数据库2026/09/2026-09-14.md—— 当日正式报告归档
scripts/—— 变动比对与推送执行脚本lib/qy-wechat.mjs—— 原生企业微信 API 封装客户端
notify-report.mjs—— Markdown 解析、HTML 转换与调度总入口
3.2 持久化状态去重机制
在 reports/ai-creative/_state.json 中,系统维护着轻量级状态字典:
{
"last_successful_run": "2026-09-14T09:00:00+08:00",
"reported_projects": {
"worldlabs-marble": {
"first_reported": "2026-09-14",
"url": "https://www.worldlabs.ai/"
}
}
}
这里确立了一条核心的更新顺序纪律:
- 检索与生成完成后,首先比对
_state.json过滤已有项目; - 成功写入并回读当天的 Markdown 报告后,最后一步才提交更新
_state.json; - 严禁在报告尚未完全落地前提前标记项目为“已收录”,避免因提交异常导致优质内容永久漏报。
3.3 生产级 ChatGPT Tasks 定时任务 Prompt(对齐古典书卷风格)
在 ChatGPT 中创建定时任务(Tasks),设定每天固定时间(如 09:00),配置以下核心 Prompt,要求大模型输出直接对齐全站排版规范:
你是一名专业的前沿 AI 科技主编兼系统架构师。
请通过自带的联网检索与工具,搜集全球最新的有创意、有实用价值的 AI 项目,生成结构化中文 Markdown 日报并提交至 GitHub。
排版风格严格遵循古典书卷印刷质感(严谨理性 · 零 Emoji 纯净规范)。
【强制排版纪律】
1. 零 Emoji 纯净准则:全篇严禁出现任何彩色 Emoji 表情符号,依靠文字层级、标点留白与三线表格传递专业美感。
2. 时间规范(仅限北京时间):检索窗口与所有时间标识**一律只使用北京时间**(如 `> **检索窗口**:YYYY-MM-DD HH:mm ~ YYYY-MM-DD HH:mm(北京时间)`),**严禁出现东京时间(Asia/Tokyo)或其他任何时区**。
3. 标题与 Frontmatter 规范:
- 主标题(title):必须简明扼要(8~14 字以内,直击主题,严禁用冒号拼接长从句);
- 副标题(subtitle):承载技术栈明细、背景展开或解决的具体问题;
- 专栏名称(category):固定为 "全球 AI 创意项目";
- 署名(author):固定为 "全球 AI 创意项目助手";
- 导读摘要(description):100~200 字精炼导读,概述核心看点与工程启示(此字段直接作为手机端推送卡片导读)。
4. 粗体规范:涉及括号的术语加粗,必须写为 **术语**(别名) 或 <strong>术语(别名)</strong>,防止 CommonMark CJK 渲染异常。
5. 表格规范:必须使用标准响应式三线表格进行多项目横向对比,项目名称必须带有语义超链接,严禁 ASCII 假文本框。
6. 文末收尾纪律(核心规范):
- 报告正文以“直接来源汇总”自然优雅收尾;
- **绝对严禁在 Markdown 文件末尾添加任何“运行状态”、“归档仓库”、“推送通道”、“验证时间”、“提交 SHA”等工程元数据引用块**,保持出版物级别的纯净阅读体验。
【项目剖析维度】
每个收录项目必须包含:
- 项目定性与解决痛点(附官方仓库或官网语义超链接);
- 核心技术架构或突破点(模型架构、推理加速、技术栈);
- 商业价值或个人开发者可借鉴启示。
【标准章节结构】
1. 卷首导读:开门见山切入背景与痛点,拒绝“大家好”、“今天带来...”等空话套话。
2. ## 一、今日重点与总览
3. ## 二、按方向分类的详细项目分析(### 1, ### 2...)
4. ## 三、已收录项目的重要变化
5. ## 四、产品体验与工程复现排序及理由
6. ## 五、2—3 个可延伸的小产品构想(分析建议)
7. ## 六、风险、未核实事项和结论
8. ## 七、直接来源汇总(自然收尾,无末尾工程引用块)
【仓库写入指令】
目标仓库:yuruchuan/agentAssistant
目标分支:main
目标路径:reports/ai-creative/YYYY/MM/YYYY-MM-DD.md(使用当前年月日)
【执行流程】
1. 读取 reports/ai-creative/_state.json 进行项目去重。
2. 生成包含标准 Frontmatter 的 Markdown:
---
title: "简明主标题"
subtitle: "副标题说明"
category: "全球 AI 创意项目"
author: "全球 AI 创意项目助手"
date: "YYYY-MM-DD"
description: "精炼摘要导读(100~200字)"
tags: ["标签1", "标签2"]
timezone: "Asia/Shanghai"
draft: false
---
3. 检查当天文件是否已存在:若不存在则调用 create_file;若已存在则增量合并,严禁覆盖人工修改。
4. 提交后必须重新读取该文件,验证 Commit SHA 与内容完整性。
5. 成功后同步更新 _state.json。
6. 严格限制只允许修改 reports/ai-creative/ 目录,禁止改动系统其他文件。
4. 第三阶段:移动端通知演进与重大避坑实录
报告成功入库只是前半程,如何以极低的使用阻力将内容推送到开发者手机端,是决定整个系统生命力的关键。在此过程中,我们先后攻克了四个深层技术陷阱。
4.1 踩坑一:GitHub 私有仓库导致个人微信打开报 404
在早期的设计中,推送卡片仅携带一条指向 GitHub 网页的链接(形如 https://github.com/user/repo/blob/main/reports/...md)。
现象与根因: 当在微信聊天界面点击该链接时,直接遭遇 GitHub 官方的 404 This is not the web page you are looking for 页面。 原因是该仓库为私有(Private)属性,微信内置浏览器属于未登录任何 GitHub 凭证的独立沙箱,完全没有权限查看该代码文件。若将仓库公开,又会导致部分定制化指标与日志被迫对外暴露。
结论:绝不能让外部无鉴权的移动端浏览器直接访问私有仓库的原始网页。
4.2 踩坑二:企业微信自建应用 60020 企业可信 IP 拦截
为了让消息直接推送到微信聊天列表,我们选用了企业微信的“自建应用 + 微信插件”通道。但在 GitHub Actions 执行直连推送时遭遇硬性拦截:
[60020]: not allow to access from your ip, from ip: 20.246.x.x
根因分析: 企业微信 API 为了防止凭据泄露,强制要求调用接口的服务端 IP 必须预先在管理后台登记为“企业可信 IP”。而 GitHub Actions 运行在微软 Azure 的庞大动态公网 IP 池中,每次构建的出口 IP 均完全随机,根本无法手工加白。
解决方案: 引入腾讯云无服务器云函数(SCF)充当固定 IP 代理网关:
- 在腾讯云函数中开通 NAT 网关或绑定固定弹性公网 IP;
- 将此公网 IP 单独填入企业微信后台的“企业可信 IP”白名单中;
- GitHub Actions 只需将载荷加密 POST 给云函数代理,由云函数代替 Actions 携带合法可信 IP 调用微信官方接口,完美解决动态 IP 阻断问题。
4.3 踩坑三:云函数默认域名触发 ERR_CONNECTION_RESET
既然引入了云函数,我们曾尝试在云函数内内置一个极简阅读器,将 Markdown 压缩作为 URL 参数挂载在云函数自带的默认域名(*.tencentscf.com)上。但在微信内点击卡片打开该链接时,页面立刻白屏并弹出错误:
ERR_CONNECTION_RESET / 链接已重置
根因分析: 国内公有云 API 网关对未经 ICP 备案的默认二级域名实施了极其严格的安全管控。网关在 HTTP 底层强制注入了响应头:
Content-Disposition: attachment; filename=response.txt
即禁止将其作为 HTML 网页在浏览器中直接展示,强制客户端将其识别为可疑文件下载。微信浏览器检测到底层强制下载标记后,为防范安全风险立刻主动重置并关闭了 HTTP 管道连接。
4.4 终局解法:微信官方原生图文卡片(mpnews)
为了彻底摆脱外部域名、ICP 备案与网络连接重置的困扰,我们最终锁定了企业微信体系内最高阶的消息协议:微信官方原生图文(mpnews)。
其核心优势体现在:
- 官方 CDN 权威承载:文章内容通过 API 提交后,直接托管在微信官方的
mp.weixin.qq.com体系内部; - 原生全屏阅读器:用户点击卡片后,直接调起与微信公众号完全一致的原生全屏阅读界面,原生支持深色模式、字号自适应缩放与平滑滚屏,秒级打开,体验极其纯粹。
4.5 踩坑四:微信多媒体素材接口 44001 与二进制 Buffer 修复
落地 mpnews 时,企业微信 API 规定图文消息必须绑定一张封面素材 thumb_media_id,需要通过 /cgi-bin/media/upload?type=image 上传。
然而,在 Node.js 18+ 环境中使用原生 fetch(url, { method: 'POST', body: formData }) 上传图片时,遭遇了顽固的报错:
上传图文封面素材失败 [44001]: empty media data, from ip: 1.1xx.49.8
深度定位与机制解析:
现代 Node.js 的 fetch 在传输 FormData 时,底层默认采用 Transfer-Encoding: chunked(分块流式传输)。然而,微信早期的素材接收服务器底层实现相对古老,完全无法处理 chunked 分块协议,强制要求请求头必须具备显式且精确的 Content-Length!
由于未读取到 Content-Length 标头,微信底层直接将请求体判定为空,从而抛出 44001: empty media data。
规范解法:纯原生二进制拼接:
我们彻底摈弃了不可控的 FormData 抽象,改用原生 Node.js 的 Buffer 精确计算字节边界:
// 手动拼接精确的 Multipart 二进制 Buffer
const pngBuffer = Buffer.from(BANNER_BASE64, 'base64');
const boundary = '----WeComBoundary' + crypto.randomBytes(8).toString('hex');
const header = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="banner.png"\r\nContent-Type: image/png\r\n\r\n`
);
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
const bodyBuffer = Buffer.concat([header, pngBuffer, footer]);
const uploadRes = await fetch(
`https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${accessToken}&type=image`,
{
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(bodyBuffer.length) // 显式提供精准字节数,根治 44001
},
body: bodyBuffer
}
);
通过显式指定 Content-Length: bodyBuffer.length,封面素材实现秒级上传,成功拿到永久有效的 thumb_media_id(云函数本地缓存 60 小时),打通了原生图文推送的最后一道关卡。
4.6 踩坑五与封面图演进:纯代码排版动态生成出版级卡片(方案二:零 API 成本 · 900x383 黄金比例)
在微信官方图文(mpnews)的实际推送中,若所有日报都共用同一张固定的静态封面图,不仅视觉上容易产生审美疲劳,更关键的是在个人微信聊天首页与微工作台对话列表中,无法仅凭封面一眼辨识当天的核心议题与技术看点。
针对“根据当天报告内容动态生成封面”的工程诉求,业界通常存在两条实现路径:
| 维度对比 | 方案一:外部绘图大模型 API(如 DALL-E 3) | 方案二:纯代码动态矢量排版渲染(终选方案) |
|---|---|---|
| API 与运行成本 | 需申请第三方 Key,单张生成费用较高 | 零成本:基于纯代码与开源渲染引擎,无额外账单 |
| 生成耗时与延迟 | 单次耗时 10~30 秒,极易引发超时 | 极速:@resvg/resvg-js 单图渲染仅需 5~10 毫秒 |
| 排版可控性 | 易产生文字畸变、乱码或随机背景噪音 | 像素级精准控制:对齐全站古典书卷印刷与深炭金质感 |
| 系统稳定性 | 易受外部模型网络波动或接口限流影响 | 100% 可靠:离线生成,且内置异常自动回退保底机制 |
方案二的核心工程设计:
- 古典书卷排版美学:采用深炭黑底色(
#191817)搭配古典暖金渐变饰线(#c99f68),绘制双重精工边框、自动化存证戳与分类徽标; - 中英文字符宽度自适应算法:精准区分中文全角(1.0 字符宽度单位)与西文半角(0.55 字符宽度单位),实现长标题智能截断与多行自动换行,杜绝文字溢出边界;
- 全链路平滑降级保底:若动态封面渲染失败或云函数上传异常,系统自动无缝降级为预置的默认封面素材,保证推送主链路 100% 成功送达。
5. 核心模块工程实现
5.1 腾讯云函数固定 IP 代理实现(tencent-scf-index.js 通用版)
部署位置:腾讯云无服务器云函数(SCF)代码 Web IDE 中的
index.js(执行方法填index.main_handler)。 运行环境:Node.js 16、18 或 20 均可(采用通用 CommonJS 规范,天然杜绝 ESM/CJS 模块差异)。 配置建议:在云函数配置中建议将执行超时时间调至 15 秒 ~ 30 秒(默认 3 秒过短,可能导致二进制素材上传未完成被掐断),内存建议设为 128MB 或 256MB。
const crypto = require('crypto');
const https = require('https');
const url = require('url');
// 企业微信配置凭据(可在此内置或通过云函数环境变量注入)
const WECHAT_TOKEN = '29pm4';
const WECHAT_AES_KEY = '87B9xcydPGvOz03kjEmuD4QwYxL3LRRnY4uTO8MLHJt';
// 微信图文卡片备用封面图(精美深蓝渐变 300x160 PNG,约 760 字节,无需依赖外部图床)
const BANNER_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAASwAAACgCAIAAAA5GsY1AAACwklEQVR4nO3TVRaVBQCF0TMSpVEkVBoV7l+X1qGqtEFZtEVYtDoPh3Ee3GvtAXwvX95YfQoUpV4A/3N5c/UZUGRCKDMhlGXD6nOgyIRQZkIoy4bVOaAoG1fngCITQlk2rs4DRdm0Og8UmRDKsmm4ABRl83ABKDIhlGXzcBEoypbhIlBkQijLluESUGRCKMvW4RJQlK3DZaDIhFCWbcNloCjbhitAkQmhLNuHK0BRtg9fAEUmhDITQlneGr4EikwIZSaEsrw9fAUUmRDKTAhl2TF8DRSZEMqyY7wKFOWd8SpQZEIoyzvjNaDIhFCWneM1oCg7x+tAkQmhLLvG60BRdo03gCITQll2jzeAouwebwJFJoSy7BlvAkXZM34DFJkQykwIZXl3/BYoMiGUmRDK8t74HVBkQigzIZTl/fF7oMiEUGZCKMve6QegyIRQZkIoy97pFlCUfdMtoMiEUJZ9022gKPun20CRCaEs+6c7QFEOTHeAIhNCWQ5Md4GiHJzuAkUmhLIcnO4BRSaEshya7gFFOTTdB4pMCGU5PN0HinJ4egAUmRDKcmR6ABTlyPQjUGRCKDMhlOWD+SegyIRQZkIoy4fzz0CRCaHMhFCWj+ZfgCITQpkJoSxH51+BIhNCmQmhLEfnh0BRjs0PgSITQlmOzY+AoqzmR0CRCaEsq/kxUJRhfgwUmRDKMsxPgKKM8xOgyIRQlnH+DSgyIZRlWn4HikwIZSaEsszLH0CRCaHMhFCWZfkTKDIhlJkQyrJe/gKKTAhlJoSyrJenQFGOL0+BIhNCWY4vz4CinFieAUUmhLKcWJ4DRTm5PAeKTAhlObm8AIpMCGU5tbwAinJqeQkUmRDKcnp5CRTl9PoVUGRCKMuZ9SugKGfWr4EiE0JZzq5fA0U5u/4bKDIhlJkQyvLx+h+gyIRQZkIoyyfrf4Gi/wAJbMElOAnL1wAAAABJRU5ErkJggg==';
let cachedToken = null;
let tokenExpiresAt = 0;
let cachedMediaId = null;
let mediaExpiresAt = 0;
/**
* 原生 HTTPS 请求封装(彻底摆脱不同 Node.js 版本全局 fetch 差异)
*/
function request(targetUrl, options = {}, bodyBuffer = null) {
return new Promise((resolve, reject) => {
const parsed = url.parse(targetUrl);
const reqOptions = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.path,
method: options.method || 'GET',
headers: options.headers || {}
};
const req = https.request(reqOptions, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const resBody = Buffer.concat(chunks).toString('utf8');
try {
const json = JSON.parse(resBody);
resolve(json);
} catch {
resolve(resBody);
}
});
});
req.on('error', (err) => reject(err));
req.setTimeout(options.timeout || 15000, () => {
req.destroy(new Error(`HTTPS 请求超时: ${targetUrl}`));
});
if (bodyBuffer) {
req.write(bodyBuffer);
}
req.end();
});
}
function sha1(str) {
return crypto.createHash('sha1').update(str).digest('hex');
}
function verifySignature(token, timestamp, nonce, echostr, signature) {
const sorted = [token, timestamp, nonce, echostr].sort().join('');
return sha1(sorted) === signature;
}
function decryptEcho(echostr, encodingAESKey) {
const aesKey = Buffer.from(encodingAESKey + '=', 'base64');
const iv = aesKey.subarray(0, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', aesKey, iv);
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(Buffer.from(echostr, 'base64')), decipher.final()]);
const pad = decrypted[decrypted.length - 1];
const unpadded = decrypted.subarray(0, decrypted.length - pad);
const msgLen = unpadded.readUInt32BE(16);
return unpadded.subarray(20, 20 + msgLen).toString('utf8');
}
async function getAccessToken(corpId, corpSecret) {
const now = Date.now();
if (cachedToken && now < tokenExpiresAt - 300 * 1000) {
return cachedToken;
}
const tokenUrl = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(corpSecret)}`;
const data = await request(tokenUrl, { method: 'GET' });
if (!data || data.errcode !== 0) {
throw new Error(`获取企业微信 access_token 失败 [${data?.errcode}]: ${data?.errmsg}`);
}
cachedToken = data.access_token;
tokenExpiresAt = now + data.expires_in * 1000;
return cachedToken;
}
async function uploadMediaBuffer(accessToken, buffer, filename = 'banner.png') {
const boundary = '----WeComBoundary' + crypto.randomBytes(8).toString('hex');
const header = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="${filename}"\r\nContent-Type: image/png\r\n\r\n`
);
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
const bodyBuffer = Buffer.concat([header, buffer, footer]);
const uploadUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${encodeURIComponent(accessToken)}&type=image`;
const uploadData = await request(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(bodyBuffer.length) // 显式精准字节头,彻底避免微信 44001
}
}, bodyBuffer);
if (!uploadData || uploadData.errcode !== 0 || !uploadData.media_id) {
throw new Error(`上传图文封面素材失败 [${uploadData?.errcode}]: ${uploadData?.errmsg}`);
}
return uploadData.media_id;
}
async function getThumbMediaId(accessToken, customBase64 = null) {
// 若上游传入了动态生成的文章封面图,则优先上传动态图
if (customBase64) {
try {
const customBuffer = Buffer.from(customBase64, 'base64');
const mediaId = await uploadMediaBuffer(accessToken, customBuffer, 'custom_banner.png');
return mediaId;
} catch (err) {
console.warn('动态封面上传失败,自动平滑回退为默认封面:', err.message);
}
}
// 否则使用本地缓存的默认封面素材
const now = Date.now();
if (cachedMediaId && now < mediaExpiresAt) {
return cachedMediaId;
}
const fallbackBuffer = Buffer.from(BANNER_BASE64, 'base64');
cachedMediaId = await uploadMediaBuffer(accessToken, fallbackBuffer, 'default_banner.png');
mediaExpiresAt = now + 60 * 3600 * 1000; // 缓存 60 小时
return cachedMediaId;
}
exports.main_handler = async (event, context) => {
const method = ((event && event.httpMethod) || 'GET').toUpperCase();
// 1. GET 请求:用于企业微信自建应用后台接收消息服务器 URL 验证握手 / 探活
if (method === 'GET') {
const query = (event && event.queryString) || {};
const { msg_signature, timestamp, nonce, echostr } = query;
if (echostr && verifySignature(WECHAT_TOKEN, timestamp, nonce, echostr, msg_signature)) {
try {
const reply = decryptEcho(echostr, WECHAT_AES_KEY);
return {
statusCode: 200,
headers: { 'Content-Type': 'text/plain' },
body: reply
};
} catch (err) {
return { statusCode: 500, body: 'Decrypt failed: ' + err.message };
}
}
return {
statusCode: 200,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: 'WeCom Proxy Active (CommonJS Universal Version)'
};
}
// 2. POST 请求:GitHub Actions 代理转发,携带固定 IP 推送企业微信
let body = {};
if (event && event.body) {
try {
if (typeof event.body === 'object') {
body = event.body;
} else {
const raw = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body;
body = JSON.parse(raw);
}
} catch (e) {
console.error('Failed to parse event.body:', e);
}
}
const corpId = body.corpId || process.env.WECHAT_CORP_ID;
const corpSecret = body.corpSecret || process.env.WECHAT_CORP_SECRET;
const agentId = body.agentId || process.env.WECHAT_AGENT_ID;
if (!corpId || !corpSecret || !agentId) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ errcode: 400, errmsg: 'Missing corpId, corpSecret or agentId' })
};
}
const {
title = '任务报告已就绪',
content = '',
digest = '',
toUser = '@all',
msgtype = 'mpnews',
bannerBase64 = null
} = body;
try {
const accessToken = await getAccessToken(corpId, corpSecret);
let pushPayload;
if (msgtype === 'text') {
pushPayload = {
touser: toUser,
msgtype: 'text',
agentid: parseInt(agentId, 10),
text: { content: (content || '').slice(0, 2040) }
};
} else if (msgtype === 'textcard' || body.description) {
pushPayload = {
touser: toUser,
msgtype: 'textcard',
agentid: parseInt(agentId, 10),
textcard: {
title: (title || '').slice(0, 120),
description: (body.description || '').slice(0, 480),
url: body.url || '',
btntxt: body.btnText || '详情'
}
};
} else {
// 方案 A:微信官方原生图文 (mpnews),点击直接在微信官方原生阅读器中全屏优雅展示
const mediaId = await getThumbMediaId(accessToken, bannerBase64);
pushPayload = {
touser: toUser,
msgtype: 'mpnews',
agentid: parseInt(agentId, 10),
mpnews: {
articles: [
{
title: title.slice(0, 120),
thumb_media_id: mediaId,
author: 'Codex 报告助手',
content: content,
digest: (digest || '').slice(0, 120)
}
]
}
};
}
const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
const pushBody = Buffer.from(JSON.stringify(pushPayload), 'utf8');
const pushData = await request(sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': String(pushBody.length)
}
}, pushBody);
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pushData)
};
} catch (err) {
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ errcode: 500, errmsg: err.message, error: err.message })
};
}
};
---
### 5.2 动态封面图排版生成器(`scripts/lib/banner-generator.mjs`)
在 GitHub Actions 中执行,基于纯代码动态生成古典书卷质感的 900x383 矢量卡片,并使用 Rust 原生引擎 `@resvg/resvg-js` 毫秒级栅格化为 PNG:
```javascript
import { Resvg } from '@resvg/resvg-js';
function escapeXml(unsafe) {
if (!unsafe) return '';
return String(unsafe)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function getCharWidth(char) {
const code = char.charCodeAt(0);
if ((code >= 0x4e00 && code <= 0x9fa5) || (code >= 0xff00 && code <= 0xffef) || code > 0x2000) {
return 1.0;
}
return 0.55;
}
function wrapText(text, maxUnitsPerLine, maxLines = 2) {
if (!text) return [];
const lines = [];
let currentLine = '';
let currentUnits = 0;
for (const char of text) {
const w = getCharWidth(char);
if (currentUnits + w > maxUnitsPerLine) {
if (lines.length + 1 === maxLines) {
if (currentLine.length > 2) currentLine = currentLine.slice(0, -1) + '...';
lines.push(currentLine);
return lines;
}
lines.push(currentLine);
currentLine = char;
currentUnits = w;
} else {
currentLine += char;
currentUnits += w;
}
}
if (currentLine) lines.push(currentLine);
return lines.slice(0, maxLines);
}
export function generateBannerSvg({ title = 'Codex 任务报告', subtitle = '', category = '全球 AI 创意项目', date = '', tags = [] } = {}) {
const titleLines = wrapText(title, 19, 2);
const subtitleLines = wrapText(subtitle, 38, 1);
const safeSubtitle = subtitleLines[0] || '';
const tagList = Array.isArray(tags) ? tags : [];
const tagText = tagList.slice(0, 4).map(t => `#${t}`).join(' ');
const isMultiLine = titleLines.length > 1;
const titleStartY = isMultiLine ? 148 : 172;
const subtitleY = isMultiLine ? 254 : 228;
const titleSvg = titleLines.map((line, idx) => {
const y = titleStartY + idx * 46;
return `<text x="56" y="${y}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif" font-size="34" font-weight="700" fill="#f5f3ee">${escapeXml(line)}</text>`;
}).join('\n');
return `
<svg width="900" height="383" viewBox="0 0 900 383" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#232220"/>
<stop offset="50%" stop-color="#181716"/>
<stop offset="100%" stop-color="#121110"/>
</linearGradient>
<linearGradient id="goldGlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#c99f68" stop-opacity="0.9"/>
<stop offset="60%" stop-color="#a37e4c" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#8a6d45" stop-opacity="0.0"/>
</linearGradient>
</defs>
<rect width="900" height="383" fill="url(#bgGradient)"/>
<rect x="24" y="24" width="852" height="335" rx="6" fill="none" stroke="#ffffff" stroke-width="1" stroke-opacity="0.07"/>
<rect x="28" y="28" width="844" height="327" rx="4" fill="none" stroke="#ffffff" stroke-width="0.5" stroke-opacity="0.03"/>
<g transform="translate(56, 62)">
<circle cx="5" cy="11" r="3.5" fill="#c99f68"/>
<text x="18" y="15" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" fill="#a8a49c" letter-spacing="1.5">
${escapeXml(category.toUpperCase())}${date ? ' · ' + escapeXml(date) : ''}
</text>
</g>
<rect x="56" y="96" width="788" height="1.5" fill="url(#goldGlow)"/>
${titleSvg}
${safeSubtitle ? `<text x="56" y="${subtitleY}" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" fill="#9e9b94">${escapeXml(safeSubtitle)}</text>` : ''}
<g transform="translate(56, 310)">
${tagText ? `<text x="0" y="14" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#c99f68">${escapeXml(tagText)}</text>` : ''}
<text x="788" y="14" text-anchor="end" font-family="Consolas, monospace" font-size="11" fill="#5a5751">
CODEX REPORT ENGINE · VERIFIED
</text>
</g>
</svg>
`.trim();
}
export async function renderBannerPng(options = {}) {
const svg = generateBannerSvg(options);
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 900 } });
const pngData = resvg.render();
return pngData.asPng();
}
5.3 变更解析与 HTML 转换器(scripts/notify-report.mjs)
在 GitHub Actions 中执行,仅依赖原生 Node.js 与 @resvg/resvg-js,负责动态渲染卡片封面并将 Markdown 转换成适配微信内置阅读器的内联样式 HTML:
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { renderBannerPng } from './lib/banner-generator.mjs';
function getChangedReportFiles() {
try {
const diffOutput = execSync('git diff --name-only HEAD~1 HEAD', { credentials: 'omit' })
.toString()
.trim();
if (!diffOutput) return [];
return diffOutput
.split('\n')
.map(file => file.trim().replace(/\\/g, '/'))
.filter(file => file.startsWith('reports/') && file.endsWith('.md'));
} catch (err) {
console.warn('回退为扫描最新文件:', err.message);
const dir = 'reports/ai-creative';
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
return files.map(f => `${dir}/${f}`);
}
}
function formatMarkdownForCard(rawContent, maxLength = 120) {
if (!rawContent) return '';
let text = rawContent.replace(/^\uFEFF/, '').trim();
text = text.replace(/^---[\r\n]+[\s\S]*?[\r\n]+---\s*/, '');
text = text.replace(/^#\s+[^\n\r]+[\n\r]*/m, '');
text = text.replace(/\n---[\s\S]*$/, '');
text = text.replace(/^>\s*(运行状态|归档仓库|推送通道|生成时间|验证时间)[^\n\r]*[\n\r]*/gim, '');
text = text.replace(/\[\u{1F449}?\s*(?:点击)?查看[^\]]*\]\([^)]+\)/gu, '');
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
text = text.replace(/\$\\to\$/g, '→');
text = text.replace(/->/g, '→');
text = text.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '');
text = text.replace(/^#{2,4}\s*(.+)$/gm, '\n$1');
text = text.replace(/^[ \t]*[-*+][ \t]+/gm, '• ');
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
text = text.replace(/\*([^*]+)\*/g, '$1');
text = text.replace(/_([^_]+)_/g, '$1');
text = text.replace(/`([^`]+)`/g, '$1');
text = text.replace(/^>[ \t]*/gm, '');
text = text.replace(/[\r\n]{2,}/g, ' ').trim();
if (text.length > maxLength) {
text = text.slice(0, maxLength) + '...';
}
return text;
}
function markdownToWechatArticleHtml(rawContent, {
title = '',
subtitle = '',
description = '',
category = '',
tags = [],
statusBadge = 'SUCCESS',
dateStr = ''
} = {}) {
let md = rawContent.replace(/^\uFEFF/, '').trim();
md = md.replace(/^---[\r\n]+[\s\S]*?[\r\n]+---\s*/, '');
md = md.replace(/^#\s+[^\n\r]+[\n\r]*/m, '');
md = md.replace(/\n---[\s\S]*$/, '');
md = md.replace(/^>\s*(运行状态|归档仓库|推送通道|生成时间|验证时间)[^\n\r]*[\n\r]*/gim, '');
md = md.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '');
const codeBlocks = [];
md = md.replace(/```([\w-]*)\r?\n([\s\S]*?)```/g, (match, lang, code) => {
const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const idx = codeBlocks.length;
codeBlocks.push(
`<pre style="background-color: #1a1918; color: #dcdad5; padding: 12px 14px; border-radius: 6px; font-family: Consolas, Monaco, monospace; font-size: 12.5px; line-height: 1.55; overflow-x: auto; margin: 16px 0; word-break: break-all; white-space: pre-wrap; border: 1px solid #2d2b29;"><code>${escaped}</code></pre>`
);
return `<!--CODE_BLOCK_${idx}-->`;
});
function inlineFormat(str) {
let res = str;
res = res.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width: 100%; border-radius: 6px; margin: 12px 0;" />');
res = res.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: #2b5797; text-decoration: underline;">$1</a>');
res = res.replace(/\*\*([^*]+)\*\*/g, '<strong style="color: #141312; font-weight: 600;">$1</strong>');
res = res.replace(/\*([^*]+)\*/g, '<em style="color: #4a4742;">$1</em>');
res = res.replace(/`([^`]+)`/g, '<code style="background-color: #f2f0ea; color: #8a3b34; padding: 2px 5px; border-radius: 3px; font-size: 13px; font-family: Consolas, monospace;">$1</code>');
res = res.replace(/\$\\to\$/g, '→');
res = res.replace(/->/g, '→');
return res;
}
const lines = md.split(/\r?\n/);
const htmlParts = [];
let listType = null;
let inQuote = false;
let quoteLines = [];
let inTable = false;
let tableRows = [];
const flushQuote = () => {
if (inQuote && quoteLines.length > 0) {
const qText = quoteLines.join('<br>');
htmlParts.push(`<blockquote style="margin: 16px 0; padding: 12px 16px; background: #f7f6f2; border-left: 3px solid #a8a49c; color: #5a5751; font-size: 14px; line-height: 1.7; border-radius: 0 4px 4px 0;">${qText}</blockquote>`);
quoteLines = [];
inQuote = false;
}
};
const flushList = () => {
if (listType === 'ul') { htmlParts.push(`</ul>`); listType = null; }
else if (listType === 'ol') { htmlParts.push(`</ol>`); listType = null; }
};
const flushTable = () => {
if (inTable && tableRows.length > 0) {
let headerCells = [];
let bodyRows = [];
let separatorFound = false;
for (let r = 0; r < tableRows.length; r++) {
const rowStr = tableRows[r].trim();
const cells = rowStr.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
if (r === 0) {
headerCells = cells;
} else if (!separatorFound && cells.every(c => /^:?-+:?$/.test(c))) {
separatorFound = true;
} else {
bodyRows.push(cells);
}
}
if (headerCells.length > 0) {
let tHtml = `<div style="overflow-x: auto; margin: 18px 0;"><table style="width: 100%; border-collapse: collapse; font-size: 13.5px; line-height: 1.6; border-top: 2px solid #2c2a29; border-bottom: 2px solid #2c2a29;">`;
tHtml += `<thead><tr style="background-color: #f7f6f2; border-bottom: 1px solid #d0ceca;">`;
for (const hc of headerCells) {
tHtml += `<th style="padding: 8px 10px; text-align: left; font-weight: 600; color: #2c2a29;">${inlineFormat(hc)}</th>`;
}
tHtml += `</tr></thead><tbody>`;
for (const br of bodyRows) {
tHtml += `<tr style="border-bottom: 1px solid #e5e4e0;">`;
for (let c = 0; c < headerCells.length; c++) {
const val = br[c] || '';
tHtml += `<td style="padding: 8px 10px; color: #333333;">${inlineFormat(val)}</td>`;
}
tHtml += `</tr>`;
}
tHtml += `</tbody></table></div>`;
htmlParts.push(tHtml);
}
tableRows = [];
inTable = false;
}
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trimEnd();
if (line.includes('<!--CODE_BLOCK_')) { flushQuote(); flushList(); flushTable(); htmlParts.push(line); continue; }
if (/^\s*\|.*\|\s*$/.test(line)) { flushQuote(); flushList(); inTable = true; tableRows.push(line); continue; } else { flushTable(); }
if (line.startsWith('>')) { flushList(); inQuote = true; quoteLines.push(inlineFormat(line.replace(/^>[ \t]*/, ''))); continue; } else { flushQuote(); }
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) { flushList(); htmlParts.push(`<hr style="border: none; border-top: 1px solid #e2e0db; margin: 24px 0;" />`); continue; }
const hMatch = line.match(/^(#{2,4})\s+(.+)$/);
if (hMatch) {
flushList();
const level = hMatch[1].length;
const hText = inlineFormat(hMatch[2].trim());
if (level === 2) {
htmlParts.push(`<h2 style="font-size: 17px; font-weight: 700; color: #1f1e1d; margin: 28px 0 14px; padding-bottom: 6px; border-bottom: 1px solid #e5e3dd; letter-spacing: 0.3px;">${hText}</h2>`);
} else {
htmlParts.push(`<h3 style="font-size: 15px; font-weight: 600; color: #2c2a29; margin: 22px 0 10px; border-left: 3px solid #6b6760; padding-left: 9px; line-height: 1.4;">${hText}</h3>`);
}
continue;
}
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (olMatch) {
if (listType !== 'ol') { flushList(); htmlParts.push(`<ol style="margin: 12px 0; padding-left: 22px; line-height: 1.8; font-size: 14.5px;">`); listType = 'ol'; }
htmlParts.push(`<li style="margin: 6px 0;">${inlineFormat(olMatch[2].trim())}</li>`);
continue;
}
const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)$/);
if (ulMatch) {
if (listType !== 'ul') { flushList(); htmlParts.push(`<ul style="margin: 12px 0; padding-left: 20px; line-height: 1.8; font-size: 14.5px;">`); listType = 'ul'; }
htmlParts.push(`<li style="margin: 6px 0;">${inlineFormat(ulMatch[2].trim())}</li>`);
continue;
}
flushList();
if (!line.trim()) continue;
// 普通段落:不设硬编码偏色,深浅模式完全自适应(白底黑字、黑底白字)
htmlParts.push(`<p style="font-size: 15px; line-height: 1.8; margin: 14px 0; word-break: break-word; text-align: justify;">${inlineFormat(line.trim())}</p>`);
}
flushQuote();
flushList();
flushTable();
let bodyHtml = htmlParts.join('\n');
for (let idx = 0; idx < codeBlocks.length; idx++) {
bodyHtml = bodyHtml.replace(`<!--CODE_BLOCK_${idx}-->`, codeBlocks[idx]);
}
const tagHtml = tags.map(t => `<span style="display: inline-block; background-color: rgba(128, 128, 128, 0.1); opacity: 0.8; font-size: 11.5px; padding: 2px 8px; border-radius: 3px; margin-right: 6px; margin-bottom: 6px;"># ${t}</span>`).join('');
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 100%; line-height: 1.8;">
<style>
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
body { background-color: #111111 !important; color: rgba(255, 255, 255, 0.88) !important; }
}
</style>
<div style="background-color: rgba(128, 128, 128, 0.06); border: 1px solid rgba(128, 128, 128, 0.18); border-radius: 8px; padding: 18px 20px; margin-bottom: 22px;">
<div style="font-size: 12px; opacity: 0.65; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; letter-spacing: 0.5px;">
<span>${category || '技术报告'} · ${dateStr}</span>
<span style="font-family: monospace; font-size: 11px; background-color: rgba(128, 128, 128, 0.12); padding: 2px 6px; border-radius: 3px;">${statusBadge}</span>
</div>
<div style="font-size: 20px; font-weight: 700; line-height: 1.4; letter-spacing: 0.2px;">${title}</div>
${subtitle ? `<div style="font-size: 13.5px; opacity: 0.75; font-style: italic; margin-top: 6px; line-height: 1.5;">${subtitle}</div>` : ''}
${description ? `<div style="margin-top: 12px; padding-top: 10px; border-top: 1px dashed rgba(128, 128, 128, 0.22); font-size: 13.5px; opacity: 0.85; line-height: 1.7;">${description}</div>` : ''}
</div>
${tagHtml ? `<div style="margin-bottom: 14px;">${tagHtml}</div>` : ''}
<div style="font-size: 15px; line-height: 1.8;">
${bodyHtml}
</div>
<div style="margin-top: 36px; padding: 14px 16px; background-color: rgba(128, 128, 128, 0.05); border-radius: 6px; border: 1px solid rgba(128, 128, 128, 0.15); font-size: 12px; opacity: 0.65; line-height: 1.8;">
<div>归档状态:已存证于 GitOps 仓库(yuruchuan/agentAssistant)</div>
<div>分发渠道:企业微信微工作台(原生图文引擎)</div>
<div>排版标准:依据《个人网站文章撰写与排版风格指南》古典书卷规范(深浅模式自适应)</div>
</div>
</div>
`.trim();
}
function parseReportContent(content, filePath = '') {
let title = 'Codex 任务报告已就绪';
const fmTitleMatch = content.match(/^title:\s*["']?([^"'\n\r]+)["']?/m);
if (fmTitleMatch) title = fmTitleMatch[1].trim();
let subtitle = '';
const fmSubMatch = content.match(/^subtitle:\s*["']?([^"'\n\r]+)["']?/m);
if (fmSubMatch) subtitle = fmSubMatch[1].trim();
let description = '';
const fmDescMatch = content.match(/^description:\s*["']?([^"'\n\r]+)["']?/m);
if (fmDescMatch) description = fmDescMatch[1].trim();
let tags = [];
const fmTagsMatch = content.match(/^tags:\s*\[(.*?)\]/m);
if (fmTagsMatch) {
tags = fmTagsMatch[1].split(',').map(t => t.replace(/["'\s]/g, '').trim()).filter(Boolean);
}
let category = '';
const fmCatMatch = content.match(/^category:\s*["']?([^"'\n\r]+)["']?/m);
if (fmCatMatch) {
category = fmCatMatch[1].trim();
} else if (filePath.includes('reports/ai-creative/')) {
category = '全球 AI 创意项目';
} else {
const parts = filePath.split('/');
if (parts.length > 2 && parts[1] !== 'template.md') category = parts[1];
}
let author = '';
const fmAuthorMatch = content.match(/^author:\s*["']?([^"'\n\r]+)["']?/m);
if (fmAuthorMatch) author = fmAuthorMatch[1].trim();
let toUser = '@all';
const fmToUserMatch = content.match(/^toUser:\s*["']?([^"'\n\r]+)["']?/m);
if (fmToUserMatch) toUser = fmToUserMatch[1].trim();
let statusBadge = 'SUCCESS';
if (/FAIL|ERROR|失败|报错|异常/i.test(content)) statusBadge = 'FAILED';
else if (/WARN|警告|注意|阻塞/i.test(content)) statusBadge = 'WARNING';
const formattedCardContent = formatMarkdownForCard(content, 1600);
return { title, subtitle, description, category, author, toUser, tags, statusBadge, formattedCardContent };
}
async function main() {
const proxyUrl = process.env.WECHAT_PROXY_URL;
const corpId = process.env.WECHAT_CORP_ID;
const corpSecret = process.env.WECHAT_CORP_SECRET;
const agentId = process.env.WECHAT_AGENT_ID;
if (!proxyUrl || !corpId || !corpSecret || !agentId) {
console.error('环境变量缺失,请检查 Actions Secrets 配置。');
process.exit(1);
}
const changedFiles = getChangedReportFiles();
if (changedFiles.length === 0) {
console.log('无报告变更,结束任务。');
return;
}
const dateStr = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
for (const file of changedFiles) {
if (!fs.existsSync(file)) continue;
const content = fs.readFileSync(file, 'utf-8');
const { title, subtitle, description, category, author, toUser, tags, statusBadge } = parseReportContent(content, file);
const html = markdownToWechatArticleHtml(content, { title, subtitle, description, category, author, tags, statusBadge, dateStr });
const digest = description || formatMarkdownForCard(content, 120);
// 动态生成高清封面图
let bannerBase64 = null;
try {
const pngBuffer = await renderBannerPng({
title,
subtitle,
category: category || '任务报告',
date: dateStr.split(' ')[0],
tags
});
bannerBase64 = pngBuffer.toString('base64');
} catch (e) {
console.warn('动态封面生成失败,将使用默认封面:', e.message);
}
console.log(`正在向微信推送原生图文: ${title}`);
const res = await fetch(proxyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ corpId, corpSecret, agentId, title, digest, content: html, toUser, bannerBase64 })
});
const result = await res.json();
console.log('推送完成,响应结果:', result);
}
}
main().catch(err => {
console.error('执行失败:', err);
process.exit(1);
});
5.4 GitHub Actions 流水线定义(.github/workflows/notify-report.yml)
name: Notify Codex Report to WeChat
on:
push:
paths:
- 'reports/**'
- 'reports/**/*.md'
- 'reports/**.md'
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Checkout Code with Commit Depth
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup Node.js Runtime
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install CJK Fonts
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends fonts-wqy-microhei
- name: Install Dependencies
run: npm install
- name: Run Report Dispatcher
env:
WECHAT_CORP_ID: ${{ secrets.WECHAT_CORP_ID }}
WECHAT_CORP_SECRET: ${{ secrets.WECHAT_CORP_SECRET }}
WECHAT_AGENT_ID: ${{ secrets.WECHAT_AGENT_ID }}
WECHAT_PROXY_URL: ${{ secrets.WECHAT_PROXY_URL }}
run: |
node scripts/notify-report.mjs
5.5 项目依赖清单(package.json)
在 GitHub Actions 运行环境与本地离线测试中,仅需安装 @resvg/resvg-js 原生渲染库:
{
"name": "codex-wechat-notifier",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test:push": "node scripts/notify-report.mjs"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2"
}
}
6. 第四阶段:多应用可见范围与标签敏捷治理
随着企业微信自建应用的增多(例如技术日报小助手、博客碎碎念助手、运维监控助手并存),必须对人员可见性实施严格管控。
6.1 新成员可见性逻辑
- 默认全员范围:若应用可见范围配置为“根部门”,新成员扫码加入后,微信客户端会默认展示全部应用,并接收广播通知;
- 白名单隔离:若可见范围指定了具体人员或标签,未在列表中的成员完全无感知,既不显示应用入口,也接收不到任何消息。
6.2 基于通讯录标签的最佳实践
如果人员变动频繁,在各个应用间反复修改可见成员极易出错。最优雅的解法是绑定通讯录标签:
| 实施步骤 | 操作路径 | 核心收益 |
|---|---|---|
| 1. 创建业务标签 | 后台 $\to$ 通讯录 $\to$ 标签 $\to$ 新建 AI 日报订阅组 | 将权限逻辑与业务角色解耦。 |
| 2. 绑定应用范围 | 后台 $\to$ 应用管理 $\to$ 可见范围 $\to$ 选中该标签 | 应用无需再维护分散的具体个人账号。 |
| 3. 成员动态流转 | 在标签内勾选或移除人员 | 个人微信端的微工作台即时生效,自动化完成应用增删。 |
7. 第五阶段:多智能体横向扩展与自定义报告专栏接入指南
随着业务边界的扩展,系统往往需要同时接入多个不同职责的智能体(如“气象与灾害预警”、“金融前沿跟进”、“开源技术雷达”等)。得益于前文确立的 GitOps 事件驱动架构,本系统具备天然的无限横向扩展能力,新增智能体无需修改任何下游推送代码,做到 100% 开箱即用。
7.1 架构设计:为什么当前流水线支持零改动横向扩展?
全域事件触发机制(Trigger 零成本):
- GitHub Actions 监听规则为
reports/**全域。 - 无论在
reports/下新增什么名称的二级子目录(如reports/weather/),一旦产生新的 Markdown 提交,工作流都会毫秒级响应并触发分发。
- GitHub Actions 监听规则为
多专栏与作者动态级联解析(Frontmatter 优先):
- 流水线优先解析 Markdown 报告 Frontmatter 中的
category(专栏名称)、author(推送署名)和toUser(定向接收人)。 - 若未显式提供,系统会自动回退至目录名智能映射(如
reports/weather/自动映射为专栏“气象与灾害预警”,作者默认为“气象感知智能体”)。
- 流水线优先解析 Markdown 报告 Frontmatter 中的
动态矢量封面纯代码排版(900x383 自适应):
scripts/lib/banner-generator.mjs基于 Rust 栅格化引擎,左上角的专栏徽标、中间的主副标题、右下角的权威存证戳均与传入报告实时绑定,动态为任意专栏生成专属出版级卡片大图。
业务状态物理隔离(State Isolation):
- 历史去重数据库以目录为作用域严格物理隔离(如
reports/ai-creative/_state.json与reports/finance/_state.json),各智能体独立管理自身记忆与防重逻辑,绝不产生数据穿透或写入冲突。
- 历史去重数据库以目录为作用域严格物理隔离(如
7.2 新智能体接入三步极简流程
第一步:规划专栏目录
在仓库根目录规划新的报告归档路径,格式统一为:
reports/<专栏英文名>/YYYY/MM/YYYY-MM-DD.md
- 若该专栏需要历史去重,可在该目录下创建初始
_state.json(内容填入{}或[]); - 若该专栏属于纯即时/快报类(如天气预警、地震速报),无需维护
_state.json。
第二步:配置新 Agent 的 Prompt 核心模板
在 ChatGPT Tasks、自定义 GPTs 或其他 Agent 平台创建任务时,遵循古典书卷排版纪律配置系统提示词(参见下文 7.3 节案例)。
第三步:执行与回读验证
新智能体通过 GitHub App 执行 Commit 提交并回读验证,GitHub Actions 将全自动提取变动、渲染深浅模式自适应文章富文本并经由云函数中继推送至个人微信。
7.3 典型场景实战范本
案例 A:气象与灾害预警智能体(突发/即时型报告)
- 适用场景:极端天气研判、台风暴雨预警、强对流天气预报。
- 存储路径:
reports/weather/YYYY/MM/YYYY-MM-DD.md - 去重特性:无需历史状态文件,每次分析均为即时最新数据。
你是一名国家级气象分析专家与防灾减灾应急研判助手。
请通过联网检索各权威气象中心、应急管理部门及卫星遥感数据,分析最新极端天气与气象过程,生成专业 Markdown 分析报告并归档至 GitHub。
【强制排版纪律】
1. 零 Emoji:全篇严禁彩色 Emoji,依靠排版留白与三线表格展现古典理性美感。
2. 仅限北京时间:检索窗口与所有时间标识一律只使用北京时间(Asia/Shanghai / UTC+8)。
3. 卷首 Frontmatter 必须规范:
---
title: "气象与灾害预警简报|YYYY-MM-DD"
subtitle: "极端天气过程机理、演变路径与防御指引(30~50字)"
category: "气象与灾害预警"
author: "气象感知智能体"
date: "YYYY-MM-DD"
description: "100~200字核心灾情研判与预警摘要(直接作为手机微信卡片外层摘要)"
tags: ["气象预警", "数值预报", "防灾减灾"]
timezone: "Asia/Shanghai"
draft: false
---
4. 收尾纪律:正文必须以“## 直接来源汇总”自然收尾,严禁在文末追加任何运行状态、仓库链接或推送通道等工程调试块。
【GitHub 写入要求】
目标仓库:yuruchuan/agentAssistant,分支:main
目标路径:reports/weather/YYYY/MM/YYYY-MM-DD.md
只允许修改 reports/weather/ 目录。提交成功后回读验证。
生成的标准报告示例如下:
| 预警区域 | 预警级别 | 预计降水量 / 风力 | 核心致灾风险 | 防御响应建议 |
|---|---|---|---|---|
| 北京西部与北部山前 | 橙色暴雨预警 | 累计降水 100~160mm | 山洪暴发、地质滑坡、泥石流 | 暂停涉山涉水户外活动,关闭山区景区 |
| 河北中部与沿山一带 | 黄色强对流 | 局地短时强降水伴 8~10 级大风 | 城市内涝、农作物倒伏、树木折断 | 加固临时围挡与广告牌,排查排水管网 |
| 天津沿海及海面 | 蓝色大风预警 | 阵风 7~8 级,海面风浪加剧 | 港口作业风险、小型船舶颠覆 | 渔船回港避风,加固锚泊设施 |
案例 B:金融前沿与量化投研智能体(定期/深度研报)
- 适用场景:宏观流动性跟踪、多因子量化策略拥挤度分析、跨资产波动率监测。
- 存储路径:
reports/finance/YYYY/MM/YYYY-MM-DD.md - 去重特性:维护
reports/finance/_state.json,防止重复收录已剖析过的策略或研报。
你是一名顶级对冲基金的宏观量化策略研究员与金融科技主编。
请通过权威金融数据库、央行公开报告及开源量化平台,跟踪过去一周全球宏观流动性与量化因子最新异动,输出结构化 Markdown 周报。
【排版纪律】
1. 严格遵守零 Emoji 纯净规范,全篇禁止彩色小图标;
2. 仅限使用北京时间(Asia/Shanghai / UTC+8);
3. Frontmatter 中指定 category: "金融前沿跟进",author: "量化投研助手";
4. 必须使用标准三线表对比资产波动率与因子收益率;
5. 必须以“## 直接来源汇总”自然收尾,严禁在文件末尾附加任何运行状态、Git SHA 或推送通道等机器块。
【GitHub 写入要求】
目标仓库:yuruchuan/agentAssistant,分支:main
写入路径:reports/finance/YYYY/MM/YYYY-MM-DD.md
去重状态:先读取 reports/finance/_state.json,并在提交成功后更新。
严格限制只允许读写 reports/finance/ 目录。
7.4 进阶技巧:按专栏定向推送(toUser)与自建应用路由
1. 按专栏指定单人或定向群组(toUser)
在实际使用中,并非所有报告都应全员广播给 @all。例如:“金融研报”可能只希望投研团队负责人接收,而“AI 创意日报”全员可见。
通过在 Markdown 的 Frontmatter 中加入 toUser 字段,流水线会自动将其透传给企业微信接口:
---
title: "全球宏观流动性与量化多因子策略周报"
category: "金融前沿跟进"
author: "量化投研助手"
toUser: "YuRuChuan|InvestManager" # 企业微信通讯录账号ID,多个以竖线隔开
---
若 Frontmatter 中未指定
toUser,系统默认安全回退至@all(推送到当前自建应用可见范围内的所有成员)。
2. 多业务独立应用路由
若企业内部需要更加纯粹的业务隔离(例如“技术报告”进入「全能助手」自建应用,“碎碎念/备忘”进入「个人备忘」自建应用):
- 在企业微信后台创建多个自建应用,分别获取不同的
AgentID与Secret; - 结合第四阶段介绍的「可见范围与标签」,分配不同的订阅人员;
- 在推送脚本或 GitHub Secrets 中配置对应的环境变量映射,实现多业务线独立分发。
8. 故障排查速查矩阵
| 故障现象 | 错误特征码 / 日志关键词 | 核心根因 | 标准解决对策 |
|---|---|---|---|
| 写入权限被拒 | push: false / Forbidden | GitHub App 未开启写入授权 | 在 App 权限设置中将 Contents 提升为 Read and write。 |
| IP 白名单拦截 | 60020: not allow to access from your ip | Actions 动态 IP 未在企业微信加白 | 部署腾讯云函数固定出口 IP 代理,并在后台配置企业可信 IP。 |
| 素材传输拒绝 | 44001: empty media data | Node.js 默认 chunked 流传输,微信不支持 | 使用原生 Buffer.concat 精确计算并声明 Content-Length 请求头。 |
| 打开链接重置 | ERR_CONNECTION_RESET | 云函数未备案域名被强制注入下载头 | 选用官方原生图文(mpnews),正文由微信内部 CDN 承载。 |
| 动态封面中文字体方块 | 封面标题文字呈现为乱码或空白 | Linux 构建环境缺失 CJK 中文字体 | 流水线增加 sudo apt-get install -y fonts-wqy-microhei 安装开源中文字体。 |
| 封面渲染异常 | renderBannerPng failed | 依赖缺失或 Rust 运行时异常 | 代码层通过 try...catch 兜底,自动无缝回退至环境变量预置的静态 Base64 封面。 |
| 用户收不到卡片 | 接口返回 errcode: 0 但手机无反应 | 用户不在自建应用的可见范围列表内 | 检查应用“可见范围”,将成员加入绑定的通讯录标签中。 |
9. 架构启示与工程思考
这套自动化日报系统的落地,不仅仅是一次工具链的组装,更沉淀了三个底层工程设计思想:
- 状态总线(State as Code)的极简主义: 摒弃专用的任务数据库与中间件,将 Git 仓库自身作为唯一的状态总线。每一次 AI 的思考、决策与输出都被具象化为一个确定的 Commit。不仅享有开箱即用的审计与回滚能力,更让自动化流水线的触发逻辑变得极为单纯坚固。
- 边缘计算的“轻量粘合剂”定位: 腾讯云函数与 GitHub Actions 在本系统中只充当无状态的事件翻译器与中继器,不维护持久业务状态,按需唤醒、用完即毁。这种轻量架构消除了传统长驻 VPS 的安全暴露面与维护成本。
- 消除最后十厘米的体验摩擦: 无论后端的分析推理多么复杂,最终传递给人类决策者的终端体验必须做到“阻力归零”。通过微信插件将报告推送至个人微信首页,并依托微信官方原生图文秒级渲染,使得技术追踪从一项“刻意为之的繁重任务”,蜕变为日常如刷朋友圈般的自然习惯。