← 返回文章列表

自动化日报生成与微信推送实践

基于 ChatGPT Tasks、GitOps 状态总线、动态封面排版与企业微信原生图文的零服务器流水线全复盘

本文系统复盘如何利用 ChatGPT 定时任务、GitHub 私有仓库与企业微信微工作台,构建一套零 API 额外计费、零常驻服务器运维的自动化技术日报推送流水线。深入剖析 AI 写入权限验证、Git 增量去重状态机、GitHub 私有库 404 规避、云函数固定出口 IP 中继、微信多媒体素材二进制协议以及纯代码动态封面图排版等关键工程坑点。

获取前沿技术动态与开源进展,是一项高价值却极其消耗精力的日常动作。

面对全球浩瀚的技术社区、学术论文与开源代码库,开发者常常陷入两难困局:若每天手动搜集并整理资讯,需耗费大量碎片化时间在信息筛选、真伪核验与格式排版上;若完全依赖市面上现成的聚合工具或邮件订阅,内容往往同质化严重、噪音密集,且无法针对个人专注领域进行定制化过滤。

市面上常见的主动抓取方案,通常依赖购买云端虚拟主机(VPS)、编写复杂的爬虫脚本并按月采购商业大模型 API(Token 费用高昂)。不仅架构沉重、存在常驻服务器维护负担,移动端查阅体验也往往割裂——邮件通知容易沉底,第三方推送平台数据经手他人且频遭限流。

为了彻底消除这一工程摩擦,我们设计并落地了一套“零模型额外计费、零常驻服务器成本、以 Git 仓库为状态总线、直达个人微信原生阅读器”的自动化技术日报流水线。

本文全面剖析该系统的架构哲学、核心模块设计与五大深度踩坑复盘。


1. 系统总体架构与双半环设计

整个系统由“前半环(AI 调研与状态归档)”与“后半环(事件驱动与图文推送)”两个高度解耦的子系统构成,双方以代码仓库的 Git Commit 作为唯一的异步通信总线。

前半环:AI 思考与状态归档(ChatGPT Tasks / Codex 驱动)状态总线:GitHub 私有仓库(main 分支 · reports/ 目录)单一事实来源(Single Source of Truth)· 审计历史可回溯 · 触发 git push 事件后半环:事件驱动与图文推送(GitHub Actions + 腾讯云函数 + 微信生态)1. 定时任务调度ChatGPT Tasks (09:00)零外部 API 额外计费2. 全网检索与核验官方仓库 / 博客 / 论文排除虚构与营销噪音3. 去重与 Markdown比对 _state.json生成标准 Frontmatter4. 授权写入与回读Contents: Read & Write强制 Fetch File 确认Commit 归档Push 事件自动唤醒5. 变动监听器GitHub Actions 唤醒paths: reports/**.mdgit diff 计算本次增量6. 本地富文本渲染纯原生 Node.js 转换Markdown 转内联 HTML提取 120 字精华摘要7. 固定 IP 代理中继腾讯云函数 (SCF) 代理突破 60020 企业可信 IP精准二进制上传封面素材8. 微信原生图文官方 mpnews 协议直推个人微信微工作台官方阅读器秒级展开

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,必须满足以下两项前置条件:

  1. 仓库粒度精准授权:在 GitHub 个人设置的 Installed GitHub Apps 中,切勿留空或全选,应勾选 Only select repositories 并精准授权给目标私有仓库;
  2. 读写权限声明:确保 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 代理网关:

  1. 在腾讯云函数中开通 NAT 网关或绑定固定弹性公网 IP;
  2. 将此公网 IP 单独填入企业微信后台的“企业可信 IP”白名单中;
  3. 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% 可靠:离线生成,且内置异常自动回退保底机制

方案二的核心工程设计:

  1. 古典书卷排版美学:采用深炭黑底色(#191817)搭配古典暖金渐变饰线(#c99f68),绘制双重精工边框、自动化存证戳与分类徽标;
  2. 中英文字符宽度自适应算法:精准区分中文全角(1.0 字符宽度单位)与西文半角(0.55 字符宽度单位),实现长标题智能截断与多行自动换行,杜绝文字溢出边界;
  3. 全链路平滑降级保底:若动态封面渲染失败或云函数上传异常,系统自动无缝降级为预置的默认封面素材,保证推送主链路 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    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 架构设计:为什么当前流水线支持零改动横向扩展?

  1. 全域事件触发机制(Trigger 零成本):

    • GitHub Actions 监听规则为 reports/** 全域。
    • 无论在 reports/ 下新增什么名称的二级子目录(如 reports/weather/),一旦产生新的 Markdown 提交,工作流都会毫秒级响应并触发分发。
  2. 多专栏与作者动态级联解析(Frontmatter 优先):

    • 流水线优先解析 Markdown 报告 Frontmatter 中的 category(专栏名称)、author(推送署名)和 toUser(定向接收人)。
    • 若未显式提供,系统会自动回退至目录名智能映射(如 reports/weather/ 自动映射为专栏“气象与灾害预警”,作者默认为“气象感知智能体”)。
  3. 动态矢量封面纯代码排版(900x383 自适应):

    • scripts/lib/banner-generator.mjs 基于 Rust 栅格化引擎,左上角的专栏徽标、中间的主副标题、右下角的权威存证戳均与传入报告实时绑定,动态为任意专栏生成专属出版级卡片大图。
  4. 业务状态物理隔离(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 / ForbiddenGitHub App 未开启写入授权在 App 权限设置中将 Contents 提升为 Read and write。
IP 白名单拦截60020: not allow to access from your ipActions 动态 IP 未在企业微信加白部署腾讯云函数固定出口 IP 代理,并在后台配置企业可信 IP。
素材传输拒绝44001: empty media dataNode.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. 架构启示与工程思考

这套自动化日报系统的落地,不仅仅是一次工具链的组装,更沉淀了三个底层工程设计思想:

  1. 状态总线(State as Code)的极简主义: 摒弃专用的任务数据库与中间件,将 Git 仓库自身作为唯一的状态总线。每一次 AI 的思考、决策与输出都被具象化为一个确定的 Commit。不仅享有开箱即用的审计与回滚能力,更让自动化流水线的触发逻辑变得极为单纯坚固。
  2. 边缘计算的“轻量粘合剂”定位: 腾讯云函数与 GitHub Actions 在本系统中只充当无状态的事件翻译器与中继器,不维护持久业务状态,按需唤醒、用完即毁。这种轻量架构消除了传统长驻 VPS 的安全暴露面与维护成本。
  3. 消除最后十厘米的体验摩擦: 无论后端的分析推理多么复杂,最终传递给人类决策者的终端体验必须做到“阻力归零”。通过微信插件将报告推送至个人微信首页,并依托微信官方原生图文秒级渲染,使得技术追踪从一项“刻意为之的繁重任务”,蜕变为日常如刷朋友圈般的自然习惯。