我用 Cloudflare Workers + GitHub Actions 做了个 2.5 刀/月的 AI 日报,代码开源了

18 阅读5分钟

每天 UTC 00:00,脚本自动抓 HN、GitHub Trending、Product Hunt、HuggingFace、Reddit(indiehackers / SideProject 等 12 个子版)、V2EX、Google Trends 关键词涨幅,300+ 条原始信号扔给 LLM,吐出中英双语结构化日报,commit 到公开仓库,再触发 Cloudflare Queue 推送订阅邮件。

站点:dailydawn.dev · 代码:github.com/TangSY/dail…

文章分成五个部分:

  1. 架构全景
  2. 三个关键决策:为什么不选 Vercel、为什么单 Worker 合并三种 handler、为什么把 GitHub 当 CMS
  3. 三个硬核踩坑:LLM 时间幻觉根治、邮件 RFC 8058 一键退订、Cloudflare Queues 批处理
  4. 成本账单:真实拆解
  5. 下一步 + 邀请你一起玩

全文干货,尽量给你可以直接偷的代码和配置。


1. 架构全景

整体流水:

flowchart LR
    A[GitHub Actions<br/>cron UTC 00:00] --> B[Python pipeline<br/>抓取 去重 LLM]
    B --> C[commit markdown<br/>到开源仓库]
    C --> D[GitHub raw CDN]
    C --> E[curl webhook]
    D --> W[Cloudflare Worker]
    E --> W
    W --> F[(D1 SQLite)]
    W --> Q[[EMAIL_QUEUE]]
    Q --> W
    W --> R[Resend API]
    W --> U[dailydawn.dev]

一个单 Cloudflare Worker 同时扛三种 handler:fetch(网站 + API)、queue(邮件批量消费)、scheduled(cron 兜底):

flowchart TB
    User([访客浏览]) --> H1
    Hook([内容 webhook]) --> H1
    Cron([CF Cron 00:30]) --> H3
    subgraph Worker [单个 Cloudflare Worker]
        H1[fetch handler<br/>Astro SSR + API]
        H2[queue handler<br/>批量发邮件]
        H3[scheduled handler<br/>失败兜底]
    end
    H1 --> Q[[EMAIL_QUEUE]]
    H3 --> Q
    Q --> H2
    H2 --> Resend([Resend API])
    H1 --> D[(D1)]
    H3 --> D

D1 做数据库,Queues 做消息队列,Static Assets 做静态资源。全家桶一个 Dashboard 管。

这套架构三件事合一:

  • 访问 dailydawn.dev 看日报,走的是 Worker 的 fetch handler。
  • Python pipeline push 新日报后 curl 打到这个 Worker 的 /api/webhook/new-report,它会给所有订阅者入队。
  • 队列消息被同一个 Worker 的 queue handler 消费,调 Resend 发邮件。
  • 如果 webhook 失败,cron 每天 00:30 兜底:查今日 report 若已入库且未通知则入队所有订阅者。

听起来简单,实际落地踩了不少坑。往下看。


2. 三个关键决策

2.1 为什么不选 Vercel 或 Next.js

做这种小项目,多数人第一反应是 Next.js + Vercel。我没选,原因三条:

成本。Vercel Pro 是 20/月起步,Hobby有限流。CloudflareWorkers免费额度是10万请求/天,到1000/月之后才20/月起步,Hobby 有限流。Cloudflare Workers 免费额度是 10 万请求/天,到 1000 万/月之后才 5,完全覆盖我的量级。

组件整合度。我需要的是:web server + 数据库 + 消息队列 + 定时任务 + 静态资源。Vercel 上这套要组合:Vercel + Neon/Planetscale + Upstash + Vercel Cron + Vercel Blob。五个 Dashboard、五份账单、五个 SDK。

Cloudflare 一个 wrangler.toml 全搞定:

name = "dailydawn"
main = "dist/_worker.js/index.js"
compatibility_date = "2024-11-06"

[[d1_databases]]
binding = "DB"
database_name = "daily-builder"
database_id = "xxxxx"

[[queues.producers]]
binding = "EMAIL_QUEUE"
queue = "daily-email-queue"

[[queues.consumers]]
queue = "daily-email-queue"
max_batch_size = 50

[triggers]
crons = ["30 0 * * *"]

[assets]
directory = "./dist"
binding = "ASSETS"

冷启动。Workers 跑在边缘节点,无冷启动。订阅确认邮件的点击链接是全球用户打开,任何一个 $29 VPS 都比不上 CF 边缘的延迟。

选 Cloudflare 的副作用:不能用 Node.js 专属 API(fschild_process 那些)。但做 web 层用不上,反而逼你写更简洁的代码。

2.2 为什么单 Worker 合并 fetch + queue + scheduled

默认 Astro Cloudflare adapter 只生成带 fetch handler 的 _worker.js

// dist/_worker.js/index.js(Astro 产出)
export default {
  async fetch(request, env, ctx) {
    // Astro SSR 逻辑
  }
};

Queue 和 scheduled handler 它不管。按官方文档的建议,应该拆成两个 Worker:

  • Worker A:Astro 产出,处理 fetch。
  • Worker B:独立 TypeScript 项目,处理 queue + scheduled。

两个 Worker 意味着两套 deploy、两份代码、两个 D1 binding(或者 service binding 互相调)、config 维护量翻倍。对一个想快速迭代的小项目来说,这税太贵。

我的解法:用 esbuild 把自己写的 worker-entry.ts 追加到 Astro 产出的 _worker.js/index.js 末尾,让自定义的 default export 覆盖 Astro 的。

// scripts/merge-worker.mjs
import esbuild from 'esbuild';
import fs from 'node:fs/promises';

const ASTRO_WORKER = 'dist/_worker.js/index.js';

// 1. 把 worker-entry.ts 编译成独立 bundle
const result = await esbuild.build({
  entryPoints: ['src/worker-entry.ts'],
  bundle: true,
  format: 'esm',
  platform: 'neutral',
  write: false,
  external: ['cloudflare:*'],
});

const extra = result.outputFiles[0].text;

// 2. 追加到 Astro 产出末尾,覆盖 default export
const original = await fs.readFile(ASTRO_WORKER, 'utf-8');
const merged = `
${original.replace('export default', 'const __astroWorker =')}

${extra}
`;

await fs.writeFile(ASTRO_WORKER, merged);

src/worker-entry.ts 长这样:

import { handleEmailQueue } from './lib/queue';
import { handleScheduled } from './lib/scheduled';

declare const __astroWorker: ExportedHandler<Env>;

export default {
  async fetch(request, env, ctx) {
    return __astroWorker.fetch!(request, env, ctx);
  },
  async queue(batch, env, ctx) {
    return handleEmailQueue(batch, env, ctx);
  },
  async scheduled(controller, env, ctx) {
    return handleScheduled(controller, env, ctx);
  },
} satisfies ExportedHandler<Env>;

package.json

{
  "scripts": {
    "build": "astro build && node scripts/merge-worker.mjs"
  }
}

这是 hack,不是官方方案。风险是 Astro 升级如果改了 _worker.js 导出结构可能默默坏掉。我的 CI 加了一个 smoke test:build 之后 grep __astroWorker 看有没有,没有就 fail。

坦白说,我也等官方某天暴露 adapter hook。在那之前这段 35 行代码帮我省了一整个独立 Worker 项目。

2.3 为什么把 GitHub 当 CMS

日报的内容存在哪?最常见的三条路:

  • 传统 CMS(Strapi / Ghost / Directus):要维护一个后台、数据库、鉴权。多一个服务就多一个月成本。
  • Notion API / Airtable 当数据源:免运维,但免费额度吃紧,延迟高,版本化弱。
  • Headless CMS SaaS(Contentful / Sanity):$9 起步,并且内容在别人手里。

我最后选的是把 GitHub 仓库本身当 CMS——每天生成的 markdown 直接 commit 到一个公开仓库,目录按 lang/YYYY/YYYY-MM-DD.md 组织。

.
├── zh/2026/2026-04-20.md
├── zh/2026/2026-04-19.md
├── en/2026/2026-04-20.md
└── en/2026/2026-04-19.md

这套方案免费,且带来四个意外的好处:

  1. 版本化免费git log zh/2026/2026-04-20.md 就是内容修订史。哪一天算法改了、哪一条信号被人肉修正过,git blame 一眼能看。
  2. 读者可以 PR 修正内容。看到事实错误直接提 PR,比任何「留言反馈」都更真实的参与感。
  3. 免费全球 CDNraw.githubusercontent.com 自带 GitHub 的全球边缘,再叠一层 Cloudflare cache,读取延迟低到忽略。
  4. 算法透明度。「AI 日报」这个 pitch 里,开源是信号——告诉读者内容是算法生成还是人肉塞的,算法长什么样,有没有夹带私货。仓库公开这个信任就立住了。

Web 层 SSR 时从 GitHub raw 拉 markdown:

// src/lib/content.ts
import { marked } from 'marked';
import matter from 'gray-matter';

export async function fetchReportMarkdown(lang: 'zh' | 'en', date: string) {
  const [y] = date.split('-');
  const url = `https://raw.githubusercontent.com/TangSY/dailydawn/main/${lang}/${y}/${date}.md`;
  const res = await fetch(url, {
    cf: { cacheEverything: true, cacheTtl: 3600 },
  });
  if (!res.ok) return null;
  const raw = await res.text();
  const { content } = matter(raw);
  return marked.parse(content);
}

cf.cacheEverything + cacheTtl: 3600,CF 边缘缓存 1 小时。命中后不碰 GitHub。

相比 git submodule 方案,它的好处是部署频率和内容更新完全解耦——内容每天变一次,Web 层不需要重新 build 部署;Web 层偶尔改 UI 重新部署,内容不受影响。

这是我觉得这个项目架构上最值得抄的一个决策。


3. 三个硬核踩坑

3.1 LLM 时间幻觉:三天前的事被说成「今天发布」

这是我调试了整整两晚的问题。

现象:LLM 生成的日报里经常有这种句子:

🔍 今天发布:Cursor Composer agents 支持 Vercel AI SDK

点开链接一看,github release 时间是 3 天前。但 LLM 写得信誓旦旦。一开始我以为是 prompt 没说清楚,加了一行「只引用今天发布的内容,如果日期不是今天请明确标注」。

没用。LLM 继续胡说。

第一层:prompt 工程不够

我试了各种 prompt 工程花招——thinking chain、explicit instruction、JSON schema 严格约束、few-shot 示例。效果是从「80% 错」降到「40% 错」。还是烂。

根本原因:LLM 根本不知道真实时间。它看到一条信号文本里写 "released xxx",它不知道这是什么时候的 released。它 fallback 到「听起来像新的 = 今天」。

第二层:数据源注入真实时间

我在 fetcher 层给每条 signal 加了 published_at 字段(ISO 8601 UTC)。以 HN 为例:

# scripts/fetchers/hackernews.py
signal = Signal(
    source="hackernews",
    title=hit["title"],
    url=hit["url"],
    score=hit["points"],
    published_at=hit["created_at"],  # 来自 Algolia API 的真实时间戳
)

Product Hunt 改 GraphQL query 加 featuredAt,Reddit 把 created_utc 转 ISO,V2EX、HuggingFace 各自找原生时间字段。GitHub Trending 的 HTML 没有精确时间字段,我保留 None 并在 prompt 里说明「该信号源无时间信息」。

然后给 expert prompt 注入当前 UTC 日期 + 每条信号的相对天数:

# scripts/pipeline/expert.py
today = datetime.now(timezone.utc).date()

def format_signal(s):
    if s.published_at:
        days_ago = (today - parse_iso(s.published_at).date()).days
        age_label = "今天" if days_ago == 0 else f"{days_ago} 天前"
    else:
        age_label = "时间未知"
    return f"[{age_label}] {s.title} ({s.url})"

效果:错误率从 40% 降到 ~10%。但还是有漏网的——LLM 偶尔会把「3 天前」描述成「今天发布」,因为它觉得说「今天」更有新闻感。

第三层:post-LLM 正则校验

我在 editor 输出 markdown 之后加了一轮 regex 校验:

# scripts/pipeline/editor.py
TIME_STUTTER_RE = re.compile(r"过去\s*(\d+)\s*天前")
INCONSISTENCY_RE = re.compile(
    r"(###\s+[^\n]*今天发布[^\n]*\n[\s\S]*?)"
    r"((\d+)\s*天前|(?:in|over)\s+the\s+past\s+(\d+)\s+days?)",
)

def _fix_time_stutter(md: str) -> str:
    # 「过去 3 天前」→「3 天前」
    return TIME_STUTTER_RE.sub(r"\1 天前", md)

def _validate_time_consistency(md: str) -> str:
    # 标题写「今天发布」但 body 有「N 天前」→ 降级到「最近发布」
    def replacer(m):
        return m.group(1).replace("今天发布", "最近发布") + m.group(2)
    return INCONSISTENCY_RE.sub(replacer, md)

跑完这层,错误率降到几乎为零。

这件事让我明确了一个原则:LLM 幻觉不能只靠 prompt 治,要数据层 + prompt + 后置校验三层。每层兜底一个概率。只靠 prompt 就像只靠 frontend 做表单校验——能跑,但随时会翻车。

对应代码在 scripts/pipeline/editor.py_fix_time_stutter_validate_time_consistency,有兴趣可以去 github.com/TangSY/dail… 自己看。

3.2 邮件 RFC 8058 一键退订

发邮件的坑比想象多。

Gmail 和 Yahoo 在 2024 年 2 月强推了一条规则:发件人必须支持 RFC 8058 的 One-Click Unsubscribe,否则批量邮件直接进垃圾箱

具体要求:

  1. 邮件 header 里要有 List-Unsubscribe,指向一个 mailto 和一个 URL。
  2. 还要有 List-Unsubscribe-Post: List-Unsubscribe=One-Click
  3. 收件人点退订时,邮件客户端会不带任何前置确认直接 POST 到你的 URL,你必须在这一次 POST 里处理掉退订,不能跳确认页。

我第一版用 GET 退订(点击链接 → 跳确认页 → 再点一次确认)。结果 Gmail 认为不合规,直接进垃圾箱。

正确做法:

// src/pages/api/unsubscribe.ts
import type { APIRoute } from 'astro';
import { unsubscribeByToken } from '@/lib/db';

export const POST: APIRoute = async ({ request, locals }) => {
  const env = locals.runtime.env;
  const formData = await request.formData();
  const listHeader = formData.get('List-Unsubscribe');
  if (listHeader !== 'One-Click') {
    return new Response('Bad Request', { status: 400 });
  }
  const token = new URL(request.url).searchParams.get('t');
  if (!token) return new Response('Missing token', { status: 400 });
  await unsubscribeByToken(env.DB, token);
  return new Response('Unsubscribed', { status: 200 });
};

// 同时保留 GET 给用户手动点击邮件里的链接
export const GET: APIRoute = async ({ url, locals }) => {
  const token = url.searchParams.get('t');
  if (!token) return new Response('Missing token', { status: 400 });
  await unsubscribeByToken(locals.runtime.env.DB, token);
  return Response.redirect(`${url.origin}/unsubscribed`, 302);
};

发邮件时带上 header:

// src/lib/email.ts
const unsubUrl = `https://dailydawn.dev/api/unsubscribe?t=${token}`;
await resend.emails.send({
  from: 'DailyDawn <daily@dailydawn.dev>',
  to,
  subject,
  html,
  headers: {
    'List-Unsubscribe': `<mailto:unsub@dailydawn.dev>, <${unsubUrl}>`,
    'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
  },
});

这一步漏掉,后面做 deliverability 调优都白搭。再好的 DKIM/SPF 配置也救不回 Gmail 对不合规发件人的拒绝。

3.3 Cloudflare Queues 批处理 + DLQ

第一版我用同步发邮件——收到 webhook 之后循环所有订阅者,挨个调 Resend。当订阅到 50 个的时候,Worker 直接超时(CF Worker 单次 fetch 最多 30 秒)。

换成 Queue:webhook 只负责入队,queue handler 批量消费。

// src/pages/api/webhook/new-report.ts
const subscribers = await listConfirmedSubscribers(env.DB);
for (const sub of subscribers) {
  await env.EMAIL_QUEUE.send({
    type: 'daily-report',
    email: sub.email,
    lang: sub.lang,
    date,
    token: sub.unsubscribe_token,
  });
}
// src/lib/queue.ts
export async function handleEmailQueue(
  batch: MessageBatch<EmailJob>,
  env: Env,
  ctx: ExecutionContext,
) {
  // 同一批里复用 markdown(同 date + lang)
  const cache = new Map<string, string>();
  for (const msg of batch.messages) {
    const { email, lang, date, token } = msg.body;
    const cacheKey = `${lang}:${date}`;
    let html = cache.get(cacheKey);
    if (!html) {
      html = await renderReportHtml(lang, date);
      cache.set(cacheKey, html);
    }
    try {
      await sendEmail(env, { to: email, html, token, lang, date });
      msg.ack();
    } catch (err) {
      msg.retry({ delaySeconds: 60 });
    }
  }
}

wrangler.toml 里配 DLQ(死信队列),重试 3 次后进 DLQ,我手动介入:

[[queues.consumers]]
queue = "daily-email-queue"
max_batch_size = 50
max_retries = 3
dead_letter_queue = "daily-email-dlq"

同 batch 内 date + lang 一致的 HTML 只渲染一次,50 条消息大概 2-3 次 markdown fetch + render。CPU 时间省 90%。


4. 成本账单

真实数字(基于当前量级):

项目免费额度实际用量费用
Cloudflare Workers10 万请求/天<1k/天$0
Cloudflare D1500 万读/天<1 万$0
Cloudflare Queues100 万/月<1 万$0
Cloudflare Cron无限1/天$0
GitHub Actions2000 min/月~150 min$0
Resend3000 封/月<3000$0
LLM API-~100 万 token/天$1.5
域名 .dev-年付 $12~$1
合计~$2.5/月

LLM 那一栏最大头。我选的是成本便宜一个量级的开源模型(GPT-4o 级别会翻 10 倍),写日报的质量跑过 200+ 天,对中文内容生成完全够用,英文版语感略弱但可接受。这一栏你想换模型自己替换 LLM_BASE_URLLLM_MODEL 两个环境变量即可,pipeline 用的是兼容 OpenAI chat completion 的接口。

订阅突破 3000 后 Resend 要升级 20/月,其他还是免费。预计订阅破万之前整体成本都压在20/月,其他还是免费。预计订阅破万之前整体成本都压在 25 以内。


不管你是独立开发者、AI 从业者、还是只想每天 5 分钟看完一天的 AI 信号:

  • 订阅dailydawn.dev,双重确认 + 一键退订 + 中英双语 + 永久免费
  • Star 仓库github.com/TangSY/dail…,代码完全开源。
  • 提 issue:信号源想加什么、日报格式想怎么改。