每天 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…
文章分成五个部分:
- 架构全景
- 三个关键决策:为什么不选 Vercel、为什么单 Worker 合并三种 handler、为什么把 GitHub 当 CMS
- 三个硬核踩坑:LLM 时间幻觉根治、邮件 RFC 8058 一键退订、Cloudflare Queues 批处理
- 成本账单:真实拆解
- 下一步 + 邀请你一起玩
全文干货,尽量给你可以直接偷的代码和配置。
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 是 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(fs、child_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
这套方案免费,且带来四个意外的好处:
- 版本化免费。
git log zh/2026/2026-04-20.md就是内容修订史。哪一天算法改了、哪一条信号被人肉修正过,git blame 一眼能看。 - 读者可以 PR 修正内容。看到事实错误直接提 PR,比任何「留言反馈」都更真实的参与感。
- 免费全球 CDN。
raw.githubusercontent.com自带 GitHub 的全球边缘,再叠一层 Cloudflare cache,读取延迟低到忽略。 - 算法透明度。「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,否则批量邮件直接进垃圾箱。
具体要求:
- 邮件 header 里要有
List-Unsubscribe,指向一个 mailto 和一个 URL。 - 还要有
List-Unsubscribe-Post: List-Unsubscribe=One-Click。 - 收件人点退订时,邮件客户端会不带任何前置确认直接 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 Workers | 10 万请求/天 | <1k/天 | $0 |
| Cloudflare D1 | 500 万读/天 | <1 万 | $0 |
| Cloudflare Queues | 100 万/月 | <1 万 | $0 |
| Cloudflare Cron | 无限 | 1/天 | $0 |
| GitHub Actions | 2000 min/月 | ~150 min | $0 |
| Resend | 3000 封/月 | <3000 | $0 |
| LLM API | - | ~100 万 token/天 | $1.5 |
域名 .dev | - | 年付 $12 | ~$1 |
| 合计 | ~$2.5/月 |
LLM 那一栏最大头。我选的是成本便宜一个量级的开源模型(GPT-4o 级别会翻 10 倍),写日报的质量跑过 200+ 天,对中文内容生成完全够用,英文版语感略弱但可接受。这一栏你想换模型自己替换 LLM_BASE_URL 和 LLM_MODEL 两个环境变量即可,pipeline 用的是兼容 OpenAI chat completion 的接口。
订阅突破 3000 后 Resend 要升级 25 以内。
不管你是独立开发者、AI 从业者、还是只想每天 5 分钟看完一天的 AI 信号:
- 订阅:dailydawn.dev,双重确认 + 一键退订 + 中英双语 + 永久免费
- Star 仓库:github.com/TangSY/dail…,代码完全开源。
- 提 issue:信号源想加什么、日报格式想怎么改。