记录一次复刻头条新闻站并打通公网入口(2026-10-11)

金句:新闻站的坑从来不在接口签名,而在图片域名和 CF 隧道的配置合并。

今天把书库骨架复用成新闻聚合站,从上午接口验证到晚上日期归档,基本是一天打满。写这篇时站已经改叫「头条资讯」,入口挂在 https://news.demo.fan。

一、仿书库的骨架,新写的只有采集器、新闻库、新闻页

书库那套东西是现成的,Flask 8800 后面跟只读网关,再走 CF 隧道,最后用看门狗守护。新闻站真正要新写的只有三块:采集器、新闻库、新闻页,剩下全部复用。

落地后的架构很干净:

  • news_fetcher.py --loop:采集器,热榜 5 分钟一轮、频道流 15 分钟一轮,带单实例锁和 30 秒心跳文件 .fetcher_heartbeat
  • data/news.db:SQLite,四张表 news / hot_snapshot / fetch_log / meta
  • news_server.py :8802:纯标准库 http.server,零依赖
  • news_gate.py :8803:只读白名单网关,fail-closed,POST 全部 403
  • watchdog.py:三段守护,分别探活服务、网关、采集器心跳,不用 psutil,用 netstat 定位端口 PID
  • 任务计划 NewsHubWatchdog:SYSTEM 每分钟跑一次,开机自启

公网复用书库命名隧道 book-hub,没用新隧道。

二、头条两个接口都不用签名,但 category 有几个暗号

接口实测结论比想象中顺利。热榜 /hot-event/hot-board/?origin=toutiao_pc 直接返回 50 条,标题、热度、封面、聚合链接都有。频道流 /api/pc/feed/?category=<cat>&visit_source=pc&max_behot_time=<ts> 无需签名,还额外拿到摘要、来源、时间、评论数和 item_id。

真正容易踩的是频道 category 的正确值。有几个和直觉不一样:

  • 教育频道是 news_edu,不是 news_education
  • news_society(社会)和 news_car(汽车)头条已经停供,接口不再返回数据
  • 推荐位用 __all__,其余像 news_tech、news_finance、news_military 这些都能正常抓

三、图片域名三种死法,只有 p*-sign.toutiaoimg.com 活着

这是今天踩得最深的一个坑。头条图片域名分三种,只有一种能用:

域名 状态
p*.pstatp.com ❌ DNS 已不解析,频道流封面全落在这里
p*.toutiaoimg.com/list/... ❌ 无签名统一 403,换域名也没救
p*-sign.toutiaoimg.com ✅ 可用,热榜封面、作者头像都走这里

频道流的封面图全部落在死域名上,所以我干脆没用封面,前端改用 media_avatar_url 作者头像叠加来源首字色块兜底。另外头条封面 URL 带 x-expires / x-signature,会过期,所以图片必须服务端代理,请求时带 Referer: https://www.toutiao.com/,同时落磁盘缓存,前端不能直连。还有一个小坑:Cloudflare 会 403 掉 Python-urllib 的默认 UA,脚本访问自家域名也要带上浏览器 UA。

四、CF 隧道追加 hostname:直接 PUT 会把 books.demo.fan 覆盖掉

给现有 book-hub 隧道追加 news.demo.fan 时,踩到一个配置合并的坑。CF 的隧道 configurations 不能用直接 PUT 的写法,必须先 GET /cfd_tunnel/<id>/configurations 拿到现有 ingress,合并新 hostname 后再 PUT 回去。直接 PUT 会覆盖掉原来的 books.demo.fan,书库入口就没了。

我写了一个幂等脚本 cf_news_tunnel.py,保留原规则再追加新的。后来 cloudflared 日志确认热加载成功,ingress 变成 [books→8801, news→8803, catch-all]。

五、误判书库被改坏:本机 DNS 被污染

上完线后有一阵我以为书库被隧道配置搞坏了,本地访问 books.demo.fan 不通。查下来发现是本机 DNS 被污染:本地解析返回 141.193.154.146,这个 IP 不通;权威 DNS(1.1.1.1 / 8.8.8.8)返回正常地址 104.21.34.138。用 --resolve 或走代理验证,书库一切正常。虚惊一场,但这个问题不记下来,下次还会误判。

六、v1.1:按 first_seen 归档 + 全仓改名「头条资讯」

下午把站名从「云盘资讯」改成「头条资讯」,全仓 6 处:页面标题、顶栏 brand、阅读页标题、动态 document.title 以及三个文件的注释,最后确认无旧名残留。

更大的改动是按日期翻历史。我原本想按头条的发布时间归档,查库后发现不行:behot_time(也就是 item_time)在所有条目里都等于推送时刻,287 条全落在同一天,根本分不出历史日期。最终改用 first_seen(我们首次抓到的时间)作为归档维度,这是唯一会自然按天累积的字段,而且已经命中索引 idx_news_seen。

具体改动:

  • lib/store.py:加 TZ(东八区)、today_str()、day_range(date)、archive_days(limit=120);feed() 加 date 参数,非法日期直接返回空
  • news_server.py:/api/feed 支持 &date=YYYY-MM-DD;新增 /api/archive/days
  • news_gate.py:白名单 ALLOW_PREFIX 加 /api/archive,否则网关会 403
  • 前端状态栏加日期按钮和面板,点开按“收录日期”分组;选中历史日期后锁定历史视图,60 秒增量轮询自动暂停,按 ESC 或点空白处关闭

验收是:公网 /、/api/archive/days、/api/feed?date= 全 200,POST 仍 403,Python/JS 语法自检全过。

遗留

demo.fan 快到期,下午开始迁到新域名 demohappy.top,但卡在用户手动操作。CF 的 API token 只有 Zone→DNS→Edit 和 Cloudflare Tunnel→Edit 两项,没有建站权限,POST /zones 直接 403:requires permission "com.cloudflare.api.account.zone.create"。所以添加站点必须用户在 CF 面板手动做。迁移脚本 cf_migrate_domain.py 已经写好并通过 --check 验证能正确识别“zone 不存在”,映射是 books.demohappy.top→8801、news.demohappy.top→8803。下一步等用户把 NS 改好、zone active 后再执行合并,博客侧集成还要处理 CORS 拦截的事。