一条部署在 Cloudflare Pages 上的多域名信息聚合站。 不是 hao123 那种静态链接格子,而是一条永远往下流、滚动不息的信息河—— 滚到底自动续接,一直有新东西。
v2.5(本版本):一个 Pages 项目,五个独立站点,按 Host 头切换形态。
- 🎮 新增游戏站
game.myinfostream.com:Epic 免费领取,三个 tab 分「本周免费 / 即将开放 / 长期免费」, 按领取截止时间排序。三个 tab 共用一次上游请求,靠客户端按badge.k收窄(见下方「为什么 game 站不用三个源」)。 - 🔥 新增热搜站
hot.myinfostream.com:微博 / 百度 / 抖音三榜分榜对照,一个 tab 对应一个独立上游。 - 🔗 互链双向性由测试强制:加站时最容易漏掉某个方向的
siblings,单向链不会让页面报错, 但会让搜索引擎把子站当成互不相关的独立站——hosttest.mjs现在断言每条互链都是双向的。 - 🌍 i18n 覆盖率由测试强制:
uiText()在缺语言时静默回落到裸 key 名,页面不报错、只有中文用户看不出来。 现在 23 个 tab / 入口键的十语种齐全性与非空性都是断言。
v2.4:一个 Pages 项目,三个独立站点,按 Host 头切换形态。
- 🌐 三站同源部署:
myinfostream.com(聚合主站)/ai.myinfostream.com(AI 情报)/news.myinfostream.com(国际新闻)。 同一份代码、同一次构建,靠 Host 区分站点——canonical / description / tagline / 语言集 / Tab / 数据源全部独立。 - 🧭 Tab 取代类目多选:顶部 Tab 单选,每个 Tab 声明自己的数据源组合(由服务端下发),前端不再硬编码类目。
- 🚪 主站改入口模式:主站不再把所有内容混成一条流,改为「简报 + 两个子站入口卡片」,子站整段隐藏看板。
- 🔒 源码不再泄漏:
build.mjs白名单构建到public/,任何人访问/functions/_lib/sources.js都拿不到源码。 - 🗺️ sitemap / robots 按站生成:三站各自独立,预览域名自动
noindex。
v2.2:接口不再暴露,工具区只给自己用。
- 🔒 私有接口默认关闭:
/api/discover、/api/translate未配密钥一律 403,界面上也不显示入口;地址栏带?key=才解锁。 - 🛡️ 公开接口限流 + 同源校验:
/api/feed30 次/分/IP,跨站Origin直接拒绝,响应头不再有Access-Control-Allow-Origin: *。 - 🔎 接口检索支持英文:每条内置清单加了英文别名,中英文都能搜到。
v2:面向全球用户,尤其是小语种用户。
- 🌍 19 种语言界面(含加泰罗尼亚语、巴斯克语、威尔士语、加利西亚语、世界语、斯瓦希里语等小语种),阿拉伯语自动 RTL。
- 🧭 固定常驻看板:天气 / 游戏 / AI 各占独立固定位置,不随瀑布流滚动消失。
- 🔭 发现全球免费接口:内置接口发现面板,可搜索全球免费、无需密钥的公共 API(仅自己可见)。
- 🌐 按你的语言展示内容:卡片标题可经 MyMemory 一键翻译为你所选语言(仅自己可用)。
- 🎨 暖心配色:奶油 / 陶土 / 琥珀色系,告别冷色调。
| hao123 | 信息河 | |
|---|---|---|
| 形态 | 静态链接网格 / 榜单 | 无限瀑布流(masonry)+ 固定常驻看板 |
| 信息 | 停在那里等你点 | 持续流动,滚到底自动加载下一批 |
| 内容 | 新闻 + 导航 | 五个独立站点:聚合简报 / AI 情报 / 国际新闻 / 游戏限免 / 实时热搜 |
| 布局 | 一栏 | Tab 单选分区,每个 Tab 绑定自己的数据源组合 |
| 语言 | 单一(中文) | 主站 19 种语言,含小语种,可翻译卡片内容 |
| 新鲜度 | 手动刷新 | 每次滚动重新洗牌 + 上游实时刷新 |
| 接口 | 全裸 | 私有接口默认关闭 + 密钥解锁,公开接口限流同源 |
一个 Cloudflare Pages 项目同时服务五个域名,靠 Host 头切换站点形态。
代码只有一份,站点配置在 functions/_lib/hosts.js 里分开声明。
| 域名 | 定位 | 默认语言 | Tab | 常驻看板 | 入口卡片 |
|---|---|---|---|---|---|
myinfostream.com |
聚合主站 | 简中 | 简报 / 国际 / AI / 娱乐 | ✅ | ✅(四个子站) |
ai.myinfostream.com |
AI 情报站 | 简中 | 全部 / 模型 / 芯片 / 商业 / 论文 | ❌ | ❌ |
news.myinfostream.com |
国际新闻站 | 英文 | 头条 / 财经 / 科技 / 地区 | ❌ | ❌ |
game.myinfostream.com |
游戏限免站 | 简中 | 本周免费 / 即将开放 / 长期免费 | ❌ | ❌ |
hot.myinfostream.com |
实时热搜站 | 简中 | 微博 / 百度 / 抖音 | ❌ | ❌ |
子站之间靠 siblings 互链,主站靠 entries 给四个子站各一张入口卡片。
- SEO:每个域名是一个独立站点,canonical / robots / sitemap 各自独立, 搜索引擎不会把子目录当成主站的重复内容。
- 隔离:各站可挂不同的 Cloudflare WAF 规则、不同的缓存策略。
- 成本:同一个 Pages 项目、同一次构建、同一份代码,没有额外部署。
hosts.js 里 game 站的三个 tab 都写着 sources: ["games"],看起来像笔误,其实是刻意的:
tabs: [
{ id: "live", i18n: "tabGameLive", sources: ["games"] },
{ id: "soon", i18n: "tabGameSoon", sources: ["games"] },
{ id: "perma", i18n: "tabGamePerma", sources: ["games"] },
],Epic 的 freeGamesPromotions 一次就返回全部三态,只是每条带一个 badge.k
(LiveNow / Upcoming / KeepForever)。如果按常规做法把一个函数拆成
games-live / games-soon / games-perma 三个源,fetchBySources 会并发把
同一个 EPIC 接口打三遍——这正是 sources.js 顶部注释里记着的、
news 站把自己打到限流的那个坑。
所以三个 tab 共用一次请求,取回的整批结果由前端 filterByTabState() 按
badge.k === tabId 收窄。tabId 与 badge.k 的 live/soon/perma 恰好同名,
不需要额外映射表。
代价与收益(实测,2026-10-22):
| 拆成三个源 | 共用一个源 + 客户端过滤 | |
|---|---|---|
| EPIC 上游请求 | 3 次 / 切 tab | 1 次,切 tab 零额外请求 |
| 单 tab 耗时 | 578ms × 3 | 578ms |
| 过滤逻辑位置 | 服务端,浪费带宽 | 客户端,浏览器零成本 |
顺序不能反:客户端必须先按 tab 收窄、再交给 feedSeen 去重。
反过来的话,「本周免费」tab 会把即将/长期也标记成已见,切到「即将开放」时
它们被当成「见过了」丢掉——两个 tab 互相把对方的数据吃掉。这是 app.js
loadFeed() 里有注释锁住的地方。
apitest.mjs 现在会对共源 tab 做一次切片校验(live=2 / soon=2 / perma=5,
任一为空就报错),因为「三态切得开」这件事只有真打一次上游才算验证过。
和 game 站相反,hot 站的三个 tab 是一对一的关系:
tabs: [
{ id: "weibo", i18n: "tabHotWeibo", sources: ["weibo"] },
{ id: "baidu", i18n: "tabHotBaidu", sources: ["baidu"] },
{ id: "douyin", i18n: "tabHotDouyin", sources: ["douyin"] },
],三个源是三个不同的接口(微博热搜榜 / 百度热搜榜 / 抖音热点),不重复调用,
所以常规写法就对了:每个 tab 声明自己的源,fetchBySources 各打一次。
实测 52 / 51 / 49 条,59–165ms。hot 站刻意不复用主站娱乐 tab 里的 「抖音 + 海外娱乐媒体」组合——那会把娱乐新闻混进纯热搜榜,破坏这个站 「分榜对照,一眼看出全网此刻在关注什么」的定位。
hao-feed/
├── index.html # 唯一静态模板(五域名共用,运行时由 functions/index.js 按 Host 改写)
├── assets/
│ ├── styles.css # 暖心配色瀑布流样式(含 RTL / Tab / 入口卡片)
│ └── app.js # i18n(19 语) + Tab 单选 + 看板 + 无限加载 + 翻译 + 演示模式
├── functions/
│ ├── index.js # HTML 改写:按 Host 注入 canonical/description/tagline/lang/Tab/入口
│ ├── robots.txt.js # 按 Host 生成 robots.txt(主站 Allow,屏蔽 /api/)
│ ├── sitemap.xml.js # 按 Host 生成 sitemap,五站各自独立
│ ├── _lib/
│ │ ├── hosts.js # ★ 五站配置唯一真相源(域名/语言/Tab/数据源/互链)
│ │ ├── budget.js # ★ 超时预算唯一真相源(内层 < 外层 deadline < 最坏预算)
│ │ ├── page.js # HTML 模板改写与配置注入
│ │ ├── sources.js # 数据源注册表(每个源 = async (opts) => card[])
│ │ ├── gnews.js # Google News 多语种接入与 source 映射
│ │ └── util.js # 抓取/解析/去重/时间戳等公共工具
│ └── api/
│ ├── _guard.js # 公共守卫:限流 + 密钥校验 + 同源校验
│ ├── feed.js # 唯一公开接口:?host=&tab=&lang= 分发取数(三层缓存)
│ ├── translate.js # MyMemory 翻译代理(私有,需密钥)
│ └── discover.js # 全球免费接口发现(私有,需密钥)
├── build.mjs # 白名单构建 → public/(只发布该发布的,防源码泄漏)
├── public/ # ★ 构建产物(部署目标,不要手改)
├── alltest.mjs # ★ 一键跑全部测试(预算→契约→守卫→API→浏览器)
├── budgettest.mjs # ★ 超时预算不变式测试(禁裸字面量 + 内层必小于外层)
├── localsim.mjs # ★ 本地「准线上」预览(真跑 functions handler)
├── webtest.mjs # ★ 真实浏览器逐 tab 点一遍,统计真实卡片数
├── hosttest.mjs # ★ 多域名契约测试(228 项:配置/DOM/去重/幽灵语言/互链/i18n)
├── apitest.mjs # API 分发测试(五站 × 各自 tab 真调 handler,共源 tab 做切片校验)
├── gametest.mjs # Epic 三态归一化测试(live/soon/perma 分类与排序)
├── gamefixture.mjs # Epic 上游的真实响应样本(测试用,不联网)
├── gameshot.mjs # 游戏站多语言截图(Playwright 出图,肉眼验视觉)
├── hotshot.mjs # ★ 热搜站出图与断言(3 语言 × 3 榜 = 148 项,走真数据)
├── simhosts.mjs # ★ game/hot 子域名的本机解析规则(--host-resolver-rules)
├── pwpath.mjs # ★ Playwright 的定位与加载(多套安装路径时只改这一处)
├── guardtest.mjs # 守卫行为测试(A–G 七组场景:鉴权/跨站/限流/CORS)
├── test-guard.mjs # ★ 限流分桶回归(拆桶后最怕「桶没了限流也没了」,专门盯这点)
├── brandtest.mjs # 8 语言品牌名 + RTL + 标题测试(真实浏览器)
├── uitest.mjs # ★ 双场景 UI 测试(访客 / 带密钥开发者,密钥走 SIM_KEY)
├── diag.mjs # 单点诊断:手动打某个 tab 的接口看原始 JSON
├── timeouttest.mjs # 超时保护必须盖到「读 body」阶段
├── edgetest.mjs # stale-while-error 兜底层测试
├── staletest.mjs # 前端 stale 横幅弹出测试
├── probe-*.mjs # 一次性诊断探针(不进版本库,见 .gitignore)
├── favicon.svg # 三道水波图标(品牌主色)
├── _routes.json # 路由规则(仅 / 与 /api/* 等走函数)
├── wrangler.toml # 构建命令 + 输出目录 + 环境变量说明
├── .gitignore # 排除出图产物 / public/ / bak/ / probe-* / node_modules
├── bak/ # 历史诊断脚本与旧截图归档(不参与构建/部署)
└── README.md
robots.txt/sitemap.xml不再是静态文件,而是functions/下的函数—— 五站的 robots 与 sitemap 内容不同,静态文件没法按 Host 区分。
私密端点(discover / translate)只在 API_SECRET 存在时才放行。测试脚本与
本地服务共用同一个 env 名,这样就不可能两边错开:
API_SECRET=simsecret node localsim.mjs 8788 # 起服务
SIM_KEY=simsecret node uitest.mjs # 跑测试(同一把钥匙)
SIM_KEY=simsecret node probe-429.mjs
SIM=1 node alltest.mjs # SIM=1 让 webtest 也打本机密钥不匹配的典型症状不是「限流」,而是整齐的一片 401:接口区块空白、 检索 0 命中。probe-429 会把 401 单独点出来,因为它不限流——混在一起看 会误判成「无 429,一切正常」。
已就位,无需额外操作:
favicon.svg+<link rel="icon">+apple-touch-icon(iOS 主屏图标)。canonical/og:url由functions/index.js按 Host 动态注入——五个域名各自指向自己,不会互相重复计权。- 完整
og:*/twitter:*分享元信息(含og:locale:alt声明 zh_CN / zh_TW / en_US 三种)。 description/og:description五站独立文案,互不重复。robots.txt/sitemap.xml由函数生成:放行全站但屏蔽/api/,sitemap 按站列出该站支持的语言 URL。- 预览域名(
*.pages.dev/localhost)自动注入noindex,避免与正式域名争索引。 theme-color暖米色,移动端浏览器地址栏同色。
待补(下次做):
og:image目前用的是 svg,部分平台(尤其 Twitter)不支持 svg 分享图, 建议补一张og.png(1200×630)。
- 前端只有一份
index.html,五个域名共用;站点差异全部由服务端注入的window.__CFG决定。 - 前端只调用同源的
/api/feed?tab=xxx,由 Cloudflare 边缘函数按Tab 声明的源列表抓取。 - 函数端并发拉取各数据源 → 归一化为统一卡片 → 整体打乱 → 返回 JSON;单源失败被隔离,不影响整条流。
- 天气 / 游戏 / AI 走独立固定看板(
#w-weather/#w-games/#w-ai),通过浏览器直连 Open-Meteo / Epic / HuggingFace,常驻页面顶部。 - 瀑布流部分每次触底拉取下一批;不同批次之间上游已刷新,加上重新洗牌,视觉上永不枯竭。
- 所有数据源免费、无需密钥;服务端发起的请求天然绕开浏览器 CORS。
v2.4 部署后,用户打开三个站全都没有卡片。这次排查花了很久, 因为所有原有测试全绿 —— 记录在此,因为教训比修复本身更值得留下。
| 现象 | 真正原因 |
|---|---|
/api/feed 返回 200、19 条完整 JSON |
后端完全正常 |
页面 #feed 的 innerHTML 是空字符串 |
数据卡在 buffer 里没被渲染 |
| 控制台 0 错误、无失败请求 | 因为确实没有代码抛错 |
死锁链条:
启动 → io.observe(sentinel) 立即触发
→ pump(12) → renderMore(12)
→ 此时 buffer 还是空的 → 异步启动 loadFeed()
→ 循环条件 buffer.length > 0 为假,画了 0 张,renderMore 返回
→ loadFeed 回来后只往 buffer 里塞数据,没有调用 renderMore
→ sentinel 的相交状态没变化,IO 不再触发
→ 用户没滚动,scroll 兜底也不跑
→ 死锁:数据在 buffer 里,页面永远空白
v2.4 重构时把 loadFeed 结尾的绘制调用丢了,只留下 setStatus。
修复:loadFeed 的 finally 里判断 feed.childElementCount === 0
(用户还没看到任何东西)就主动 renderMore(24)。
为什么三个原有测试都没抓到:它们测的全是「接口返回的 JSON 对不对」,
没有一个测「数据有没有变成 DOM 里的卡片」,而用户看到的恰恰是后者。
curl 能拿到 19 条,浏览器内 fetch 也能拿到 19 条 —— 断的那一环在两者之间。
1. 内层超时 > 外层 deadline,一天内出了三次
症状都是「200、JSON 合法、0 条」,且没有任何报错:
| # | 位置 | 内层 | 外层 | 后果 |
|---|---|---|---|---|
| 1 | gnews.js vs sources.js |
8000ms | 6000ms | 所有 Google News 源被判空,world/ai 随机 0 条 |
| 2 | 修复时把外层调到 11000ms | — | — | 最坏延迟 11+4=15s,线上实测顶到 23.4 秒 |
| 3 | sources.js 的 arxiv() |
9000ms | 8000ms | arXiv 论文源一直是死的,但同 tab 有别的源,页面看不出异常 |
根因不是「忘了调数值」,是超时值散落三个文件、大小关系只能靠人脑记住。
现在全部收进 functions/_lib/budget.js,并由 budgettest.mjs 强制断言
「所有内层超时 < SOURCE_DEADLINE」+「源码里不许再出现裸的超时字面量」。
改坏了测试立刻红。
2. 模块级缓存只在单个 isolate 内有效
feed.js 原本是 new Map() + 15 秒 TTL。Cloudflare Pages 会随时回收 isolate、
新建冷 isolate,冷 isolate 上 Map 全空 → 每个请求都重新抓上游。
实测同一个 URL 连续两次:1.4 秒 / 23.4 秒。
现在加了一层 caches.default 边缘缓存(跨 isolate 共享,TTL 60s)。
三层结构:模块 Map(同 isolate,零开销)→ 边缘缓存(跨 isolate)→ 真抓上游。
3. 空结果被缓存
上游偶发超时时抓回 0 条也照样写缓存,接下来 15 秒整个 tab 一直空白 —— 一次偶发失败被放大成一整段用户可见的空白。 现在空结果不进任何一层缓存。边缘层尤其要小心:TTL 60s,一次空响应缓存住就是整整一分钟白屏。
修复前后同一个真实浏览器测试(webtest.mjs):
| 修复前 | 修复后 | |
|---|---|---|
| 简报 / 国际 / AI / 娱乐 | 4 个 tab 全部 0 张卡片 | 4 个 tab 全部有数据 |
| 首屏渲染耗时 | 8.9 秒后仍空白 | 2.8 秒出 24 张卡片 |
budgettest.mjs—— 把「靠人记住的不变式」变成「CI 强制的不变式」webtest.mjs—— 真实浏览器逐 tab 点一遍,统计渲染出的卡片数。 这是唯一能抓到本次这类 bug 的测试localsim.mjs—— 前端走本地、/api/走本地或线上,能隔离出「渲染链路」还是「取数链路」的锅alltest.mjs—— 一键跑全套。少这一层的教训是:补写的测试很可能被忘掉
hosts.js 的 langs 与 app.js 的 LANGS + I18N 必须对齐。
只声明了 ko 但前端没有 I18N.ko 时,会出现一个很难发现的 bug:
?lang=ko 通过了服务端校验 → 前端 I18N["ko"] undefined 回落到中文内容,
但 <html lang="ko"> 还挂着 —— 页面语言声明与实际内容不符,对 SEO 和读屏都是错的。
现在两处都被守住:
- 前端
renderable()=LANGS ∩ HOST.langs,detectLang/ 语言下拉都只认这个交集。 hosttest.mjs有专门断言扫「幽灵语言」,v2.4 就是在这一步抓到ai/news两站多写的ko。
要加韩语:先在
app.js补LANGS的{code:"ko"}与I18N.ko全部文案, 再回hosts.js加这一行。顺序反了就会掉进上面那个坑。
旧的顶部「类目 chips」是多选,前端硬编码一份类目表,服务端只认 ?cat=热搜,娱乐。
v2.4 改成Tab 单选:
- Tab 列表与「Tab → 数据源组合」的映射由
hosts.js下发,前端不硬编码。 - 单选避免了「勾了三个类目、每类只出几条、页面看着像坏了」的空档感。
- 只有 1 个 Tab 时整个 Tab 条
hidden,不留一条孤零零的胶囊。 - 子站整段隐藏常驻看板(
#widgetsWrap直接hidden)——留一个空标题加空框比不显示更糟。
原来 wrangler.toml 里 pages_build_output_dir = ".",
把整个项目目录当静态资源上传 —— 任何人访问 /functions/_lib/sources.js 都能下载完整源码。
_routes.json 的 "/*.js" 只匹配根目录一层,挡不住子目录。
现在 build.mjs 用白名单(index.html / assets / favicon.svg)拷进 public/,
并在结束时自检:public/ 里出现 functions/、_lib/、.mjs 就直接构建失败。
node alltest.mjs # 一键跑秒级套件(budgettest / hosttest / guardtest)
node alltest.mjs --full # 再加上 apitest(真打 Google News,约 15 秒)
node alltest.mjs --e2e # 再加上真实浏览器端到端 4 件(需先 node localsim.mjs 8788)
# 也可以单独跑:
node budgettest.mjs # 超时预算不变式(见下方「2026-10-22 事故记录」)
node timeouttest.mjs # 超时保护必须覆盖到「读 body」(起一个故意挂死 body 的坏上游)
node edgetest.mjs # stale-while-error 兜底:mock 掉全部上游,验证回退旧快照
node staletest.mjs # 前端真的弹出 stale 横幅(需先 node localsim.mjs 8788,SIM=1)
node hosttest.mjs # 多域名契约 228 项:配置/DOM 契约/跨站去重/幽灵语言/preview noindex/互链双向/i18n 覆盖
node apitest.mjs # API 分发 26 项:五站 × 各自 tab 真调 handler,校验字段完整 + 共源 tab 切片
node gametest.mjs # Epic 三态归一化(live/soon/perma 分类正确 + 按截止日排序)
node guardtest.mjs # 守卫行为 A–G 七组场景
node brandtest.mjs # 8 语言品牌名 + htmlLang + dir + 标题(真实浏览器)
node webtest.mjs [url]# 端到端:真实浏览器逐 tab 点一遍,统计渲染出的卡片数
node gameshot.mjs # 游戏站出图(拦 /api/feed 注夹具,Epic 每周换货所以 img URL 会漂)
node hotshot.mjs # 热搜站出图(不拦,走真数据 —— 见下方「两个 shot 的分岔」)
node diag.mjs [url] [tab] # 单点深诊断:接口层/渲染层/边界层分开打,直接指出断在哪层
node build.mjs # 构建到 public/,并自检未泄漏源码两个 shot 的分岔:gameshot 拦
/api/feed注入夹具,hotshot 不拦。 因为两站要断言的根本不是同一件事 —— game 站验的是「客户端按badge.k收窄出三种徽章」,夹具正好;hot 站验的是「host → tab → source 整条取数 链路」,一夹具就把最容易坏的那一环屏蔽掉了。上一轮localsim.mjs的/api/分支漏做.test → .com还原(导致 hot 站抓到 0 条),就是靠 hotshot 走真数据才暴露出来的。
hosttest.mjs里最值钱的一条是幽灵语言检测:比对hosts.js声明的langs与app.js的LANGS,多写一个码就报错。v2.4 的ko就是这一步抓出来的。
白屏时先用
diag.mjs定位再修:它会分别报接口返回多少条、DOM 里渲染多少条、 网络与控制台有什么错,最后直接给一句判定(如「接口有数据、DOM 没卡片 → 渲染层断了」)。 v2.4.1 那次接口层和边界层全是绿的,只有分层打才看得见真正断点在渲染层。
node localsim.mjs 8788 # 前端走本地 public/,/api/ 调本地 functions handler
SIM=1 node webtest.mjs http://myinfostream.test:8788/
UPSTREAM=1 node localsim.mjs 8789 # /api/ 改成代理线上,对比本地代码与已部署代码必须用
http://myinfostream.test:8788/,不能用127.0.0.1。app.js的isLocal()判的是 hostname,localhost/127.0.0.1会让它切到 演示模式,真实取数链路整个被绕过 —— 那样测出来的「全绿」什么也证明不了。 配 playwright 的--host-resolver-rules把假域名指到本机即可(SIM=1会自动加)。
v2.4.1 修好渲染死锁后,AI 站(ai.myinfostream.com)仍有 tab 空白。 这次和上次不是同一个原因,而且上次那个修复还帮了倒忙。
用真实浏览器逐 tab 点,发现 大模型 / 芯片算力 / 商业 三个 tab 全白,
全部 / 论文 正常。curl 单打接口则显示「200、JSON 合法、0 条」——
后端没报错,只是没取到东西。
「时好时坏」是最关键的一条线索:如果是代码写错,应该是稳定地错;
会好会坏,说明有间歇性因素。定位到 Cloudflare 边缘 IP 访问
news.google.com 会随机失败(同一分钟内,商业 tab 拿到 15 条,
大模型 tab 跑满 11 秒双轮超时后返回 0 条)。
这是上一轮埋下的、v2.4.1 没能暴露的雷:
// 修复前
const id = setTimeout(() => ctrl.abort(), timeout);
try {
const res = await fetch(url, { signal }); // ← 这一行在**响应头到达时**就 resolve
if (!res.ok) throw ...;
return res; // ← 定时器在 finally 里被清了
} finally { clearTimeout(id); }fetch() 的 promise 在响应头到达时就 resolve,body 还在流。
所以 clearTimeout 执行的时刻比想象中早得多 —— abort 保护在
「开始读 body」之前就已经被摘掉了。后果是:
- 上游慢在响应头 → 超时生效,表现正常(所以本机测不出来,本机到 GN 只要 2 秒)
- 上游慢在body → 没有超时保护,
res.text()裸奔,可以挂到天荒地老
timeouttest.mjs 起了一个「发完响应头就永久挂住 body」的坏上游来锁死这条。
用修复前的实现跑同一个场景:预算 1200ms,实际 8000ms 仍未返回。
修复方式是让 reader 闭包跑在保护范围内:
async function fetchGuarded(url, headers, timeout, reader) {
const ctrl = new AbortController();
const id = timeout > 0 ? setTimeout(() => ctrl.abort(), timeout) : null;
try {
const res = await fetch(url, { signal: ctrl.signal, ... });
if (!res.ok) throw new Error("HTTP " + res.status);
return await reader(res); // ← 读 body 也在保护内
} finally {
if (id) clearTimeout(id); // ← 到这里才清理
try { ctrl.abort(); } catch {} // 主动断 socket,别让读完的 body 占着连接
}
}fetchText / fetchJSON 都改走 fetchGuarded。
strictSource: true 会把结果硬过滤到 5 家指定媒体,叠加关键词后实测:
| 配置 | 条数 | 命中的媒体数 |
|---|---|---|
| 白名单 + 关键词 | 10 | 3(5 家里有 2 家永远不命中) |
| 纯关键词(放宽) | 100 | 42 |
改成 strictSource: false,质量交给 isJunkTitle 闸门兜。
这和 news 站的 fr/ar/ru 是同一个结论:Google News 的 source: 运算符
只按媒体名匹配,任何固定白名单在多语种/长尾查询下都会漏。
前两个 bug 修完,上游依然可能抽风 —— 边缘 IP 的网络问题是外部条件, 代码只能减少暴露面,不能根除。所以加了兜底层:
维护两份边缘缓存,用 URL 路径前缀区分(Cache API 的 match 不按 TTL 过滤,
只能靠不同 key 来分):
| 层 | key | TTL | 用途 |
|---|---|---|---|
| 新鲜层 | /__f60?… |
60s | 正常读取路径 |
| 兜底层 | /__s86400?… |
24h | 只在「新鲜层没有」且「本次抓取失败」时读 |
抓取成功 → 两份都刷新;抓取失败 → 回退兜底,响应里标 stale: true,
前端在顶部挂一条琥珀色横幅(「实时源暂时无响应 · 显示最近一次成功获取的内容」)。
恢复正常后横幅自动消失。
关键约束:兜底层只在有非空数据时才写,绝不让一次失败污染兜底。 另外「冷启动时兜底也没有」这种情况会如实返回 0 条且 不标 stale —— 没有旧数据却标「显示最近一次成功获取的内容」是骗用户。
配色也刻意和 demo 模式区分开(橙 vs 琥珀), 否则用户看到橙色会以为「我一直看的是演示数据」。
这两个测试都测的是正常路径永远不执行的代码,靠正常跑永远测不出来:
edgetest.mjs(后端)—— mock 掉全部上游 + 补一个内存版caches, 验证「上游全挂时回退旧快照且stale:true」「fresh 命中时不误标 stale」 「冷启动无数据时不谎标 stale」。 写这个测试时踩了两个坑,都记在文件注释里:mock 只拦news.google.com会让 arXiv 等真实源照常返回,stale 分支根本没被执行; 场景设计还必须让 L1(15s)和 fresh 层(60s)都过期才测得到回退。staletest.mjs(前端)—— 拦截/api/feed注入stale:true, 验证横幅真的会弹出来、文案对、class 带warn,且恢复正常后消失。 stale 是「后端标了、前端响了」的跨端约定,两边各写各的最容易一边改了另一边没跟。
会好会坏的 bug,先怀疑外部条件,不要先怀疑代码写错。 而「修好了也不保证不复发」的外部条件,要用兜底数据兜住,而不是等它恢复。
两个都是早就存在、只是这轮才被测出来的问题:一个让功能形同虚设, 一个让末段内容永远画不出来。
| 症状 | 端点返回 200、results 长度也对,但每条结果都等于原文 |
| 根因 | fetchJSON() 里写成 const res = fetch(url) —— 少一个 await,res 拿到的是 Promise 而不是 Response。于是 res.ok 恒为 undefined,!res.ok 恒真,每一条都抛 HTTP undefined |
| 为何没被发现 | 失败被 catch 后静默回落原文。所有断言只看状态码和数组长度,全绿;curl 和接口测试也看不出——只有把译文和原文对照着看才知道它压根没翻 |
| 修复 | fetchJSON 改 async 并补上 await |
教训:静默兜底(catch 后回落原文)把一个致命错误伪装成了正常输出。 降级路径必须有独立的观测手段,否则它会一直把错误藏起来。
批量化的验证(probe-translate.mjs):POST[10] POST[10] POST[1] 覆盖 21 条文本,
抽样 8 条标题全部译出且顺序正确。顺序这项必须单独验——顺序错了不会报错,
只会让整屏卡片张冠李戴。
首屏 24 张 → 滚一下变 36 张 → 无论怎么滚都停在 36,buffer 里还剩 3 张, 底部却已经写着「✓ 暂时到底」。
三层原因叠加,每层单独看都合理:
| 层 | 机制 | 问题 |
|---|---|---|
| IO | 只在 sentinel「相交状态变化」时回调 | 画完 36 张后 sentinel 仍停在相交区,状态没再变 → 不再回调 |
| scroll 兜底 | 只在滚动事件里跑 | scrollY 已到文档最大高度,再滚也不动 → 不再产生事件 |
| 220ms 节流 | if (now - ioThrottle < 220) return |
前一次 pump 后仅隔 120ms 就来了新事件,被直接丢弃 |
于是两条路同时断掉,而 buffer 里的货无人认领。
修法:节流语义从「丢弃」改为「延后补跑」(尾沿)——窗口内的调用记下要画的 张数,窗口结束后保证再执行一次。节流本来是为了防止一次滚动里铺出几百张卡, 补跑一次不影响这个目的,但让「唯一的机会」不再丢失。
教训:丢弃式节流只在「保证还有下一次事件」时安全。这两个触发源都是 边沿触发(IO 状态变化、scroll 位置变化),一次机会丢掉就是永久丢失。 节流丢的不是重复调用,而是唯一调用。
补货回来的自愈条件也一并放宽(loadFeed 的 finally):原来只看
feed.childElementCount === 0(页面是不是全空),现在加上 nearBottom()——
数据到货时页面通常已经有一屏卡了,只判断「空」救不了末段滞留。
回归口径也改了:uitest.mjs 场景 A 原来写死「滚 10 次」,等于把
「滚够了没」和「画完了没」混成一件事——bug 存在时它看到的增长就停在 36,
看起来像"数据源就这么少"。现在改成滚到连续 3 次不涨为止,并断言
buffer.length === 0(货必须清空),而不只看卡片数有没有涨。
回归结果(冷启动、每滚 4000px 等 120ms):12 → 24 → 36 → 39,buffer 清零,
/api/feed 仍只 2 次请求(brief + ai 各一),无重复取数。
uitest.mjs/probe-429.mjs的密钥走SIM_KEY:原先硬编码"demo", 而localsim只在API_SECRET存在时放行私密端点。两边错开就是整齐的 401, 症状像功能坏了。probe-429 额外把 401 单独点出来——401 不限流, 混在状态分布里会被读成「无 429,一切正常」。uitest.mjs场景 B 的等待:原来拍固定时长,改为等真实结果出现。
用户反馈两个问题,均已修复并用真实浏览器(Playwright/Chromium)回归验证:
| 问题 | 真正原因 | 修复 |
|---|---|---|
本地双击 index.html 一片空白 |
index.html 用了绝对路径 /assets/app.js,在 file:// 下解析成 file:///assets/app.js(磁盘根目录),脚本根本没加载 |
改为相对路径 assets/app.js,file:// 与 Cloudflare 两侧都可用 |
| 首屏只有十几张,之后一直转圈 | 转圈状态文案从不复位;IO 在数据返回前就已触发,buffer 空 → 什么都不渲染 | bootstrapFeed() 一次性打底 24 张;setStatus() 三态机(loading / more / end),loader 默认隐藏、仅 .is-on 时显示 |
| 滚到底后不再出新内容 | IntersectionObserver 只在「相交状态变化」时回调,sentinel 加载后一直停在视口内 → 不再触发 |
额外挂 scroll 兜底:只要 sentinel 还在视口下方 400px 内就继续补;220ms 节流防抖 |
实测(本地演示模式,1360×940):
- 首屏 24 张卡片,状态栏显示「↓ 向下滚动 · 信息川流不息」
- 连续滚动 20 次 → 264 张;再连续小幅滚动 15 次 → 336 张,页面高度同步增长,永远触不到底
- 控制台错误 0(本地无 Functions 时,翻译 / 看板 / 接口发现直接短路走演示数据,不再刷 fetch 协议报错)
- 语言切到 العربية 自动
dir="rtl",状态文案正确本地化
v2 里 /api/discover、/api/translate 是裸接口——任何人拿到你的域名就能刷翻译额度、扒接口清单。
v2.2 把这两块改成默认关闭、密钥开启,并给公开的 /api/feed 加了限流与同源校验。
| 层 | 手段 | 覆盖 |
|---|---|---|
| 1. 限流 | 内存滑动窗口,按 CF-Connecting-IP 计 |
全部接口 |
| 2. 密钥 | API_SECRET 环境变量,请求需带 x-api-secret 头或 ?key= |
/api/discover、/api/translate |
| 3. 同源校验 | Origin 存在且非本站 → 403(防浏览器跨站盗用) |
/api/feed |
| 接口 | 默认状态 | 说明 |
|---|---|---|
GET /api/feed |
开放 | 首页数据源。限流 30 次/分/IP + 同源校验。想彻底锁死设 FEED_REQUIRE_KEY=1,但页面会随之空白 |
GET /api/discover |
关闭(403) | 未配 API_SECRET 时一律拒绝。配了才开放 |
GET /api/translate |
关闭(403) | 同上 |
/api/feed无法在「页面能用」的同时做到「完全不可见」——数据总得从某处出来。 它的实际风险是额度/流量被白嫖,30 次/分/IP 的限流 + 同源校验已经把刷取成本抬到不划算。 真正有价值的两个接口(翻译、接口发现)已经默认关掉了。
-
Cloudflare 控制台 → 你的 Pages 项目 → Settings → Environment variables → 加一条:
名称 值 API_SECRET一串随机字符,例如 openssl rand -hex 24 -
访问
https://你的域名/?key=那串密钥。 密钥会写进localStorage,地址栏里的key=会被自动抹掉,之后直接开域名就行。 -
效果:
- 顶部出现 🌐 翻译 按钮(非中文界面下)
- 页面出现 「发现全球免费接口」 区块
- 点 ⟳ 刷新 会连同接口清单一起刷新
-
别人访问你的域名:看不到这两块,直接点按钮/直接请求接口都会拿到
403 disabled。
| 变量 | 默认 | 作用 |
|---|---|---|
API_SECRET |
未设置 | 私有接口的总开关。不设置 = discover/translate 一律 403 |
FEED_LIMIT |
30 |
/api/feed 每 IP 每分钟上限,超了返回 429 |
FEED_REQUIRE_KEY |
0 |
设为 1 则 /api/feed 也要密钥 |
RATE_LIMIT |
10 |
私有接口每 IP 每分钟上限 |
ALLOW_NO_ORIGIN |
0 |
设为 1 放行不带 Origin 头的请求(curl / 脚本) |
限流是内存实现:Cloudflare Pages Functions 每个 isolate 各自持有计数, 高流量下会被打散到多个 isolate,实际阈值会高于配置值。 够用来挡住随手刷,不是扛得住有组织的攻击——真要严格限流得配 Cloudflare Rate Limiting 规则。
node guardtest.mjs直接调 handler、不依赖 wrangler,覆盖 7 组场景:默认关闭、跨站拦截、密钥校验、
限流触发、ALLOW_NO_ORIGIN、中英文检索命中、响应头无 CORS 通配。
| 站点 | Tab | 源组合 |
|---|---|---|
| 主站 | 简报 | 境内热搜(微博+百度)· 国际要闻 · AI 动态 · 生活(天气/市场/游戏/足球/HN) |
| 主站 | 国际 | en / es / fr / ar / ru 五语种严肃媒体 |
| 主站 | AI | AI 产品 · 每日论文 · 中英文 AI 商业 |
| 主站 | 娱乐 | 抖音热点 · 海外娱乐媒体(Variety / THR / Guardian / BBC) |
| AI 站 | 全部 | AI 产品 · 论文 · 中英文商业 · 芯片算力 |
| AI 站 | 模型 | 大模型发布 / benchmark |
| AI 站 | 芯片 | Nvidia / AMD / TPU / HBM / GPU / 数据中心 |
| AI 站 | 商业 | 融资 / 估值 / 并购 / IPO + FT · Bloomberg · 经济学人 |
| AI 站 | 论文 | HuggingFace 每日论文 + arXiv |
| news 站 | 头条/财经/科技/地区 | 五语种 Google News,按语言走不同通道 |
| game 站 | 本周免费 / 即将开放 / 长期免费 | 同一个 games 源(Epic 一次返回三态),客户端按 badge.k 切片 |
| hot 站 | 微博 / 百度 / 抖音 | weibo / baidu / douyin,三个独立上游,一对一 |
game 站那一行是唯一一个「多 tab 共用一行源」的,展开原因见上文 「game 站:为什么三个 tab 只声明一个源」。
| 类别 | 数据源 | 说明 |
|---|---|---|
| 境内热搜 | 微博热搜 + 百度热搜 | 主站简报「境内」部分 |
| 国际新闻 | Google News(11 语种分区) | 白名单媒体见 gnews.js 的 GN_SOURCES |
| 国际新闻 | Hacker News | 主站生活区的技术向内容 |
| 娱乐 | 抖音热点 v2.xxapi.cn |
国内娱乐 |
| 天气(看板 + 源) | Open-Meteo | 任意全球城市,支持浏览器定位 |
| 游戏(看板 + 源) | Epic 限免 | 官方 promotions 接口 |
| 市场 | Exchangerate + Binance | 主要货币兑人民币 + BTC/ETH |
| 足球 | OpenLigaDB | 德甲为主,自动回退 |
| 论文 | HuggingFace 每日论文 + arXiv | 前沿 AI 研究 |
| 翻译 | MyMemory | 免费翻译 API 代理(/api/translate,需密钥) |
| 接口发现 | 内置清单 + publicapis.org | /api/discover?q=...(需密钥),带英文别名检索 |
source: 过滤在不同语种分区表现完全不一致(2026-10 实测):
| 语种 | source: 过滤 |
结论 |
|---|---|---|
| en / es | 正常,能精确命中本地大媒体 | strict=true:写 source: + 白名单硬校验 |
| fr / ar | 白名单完全匹配不上,返回 0 条 | strict=false:只走本地化关键词 + 垃圾特征闸门 |
| ru | 6 家白名单返回 0 条;单个 source:Интерфакс 有效 |
关键词为主,单源补充 |
结论:小语种媒体没被完整索引进对应语种分区,死守 source: 白名单等于自杀。
所以 gnews.js 给每个语种一个 strict 开关——质量靠 isJunkTitle 闸门兜底,不靠白名单。
界面支持 19 种语言(顶部下拉框切换):
简体中文、繁體中文、English、Español、Français、日本語、Português、 Deutsch、Русский、العربية(RTL)、Tiếng Việt、ภาษาไทย、 Bahasa Indonesia、Kiswahili、Català、Euskara、Cymraeg、Galego、Esperanto
站名是双轨的:主名随语言变,副名固定 InfoStream,浏览器标题同步更新。
| 当前语言 | 显示为 | 页面标题 |
|---|---|---|
| 简体中文 | 信息河 InfoStream | 信息河 · InfoStream |
| 繁體中文 | 資訊河 InfoStream | 資訊河 · InfoStream |
| English | InfoStream 信息河 | InfoStream · 信息河 |
| Español | Flujo de Información · InfoStream | Flujo de Información · InfoStream |
| العربية(RTL) | تيار المعلومات · InfoStream | تيار المعلومات · InfoStream |
| 日本語 | 情報の川 · InfoStream | 情報の川 · InfoStream |
- 中文(简/繁)走「中文在前」,其余语言走「英文在前、中文在后」,
同时把该语言的本地音译顶到最前(
情報の川、تيار المعلومات、Flujo de Información…), 这样非中文用户一眼认得出,SEO 也能命中各语言的搜索词。 - 本地译名表在
assets/app.js的BRAND_ALT,加语言时顺手加一条即可。 - 繁体自动识别:浏览器语言是
zh-TW / zh-HK / zh-MO / zh-Hant时首次访问直接进繁体界面, 不用手动切(三种 URL 写法?lang=zh-TW/zh-HK/zh-Hant也统一归一到zh-Hant)。
| 站点 | 语言数 | 语言集 |
|---|---|---|
| 主站 | 19 | 简中 繁中 英 西 法 日 葡 德 俄 阿 越 泰 印尼 斯瓦希里 加泰 巴斯克 威尔士 加利西亚 世界语 |
| AI 站 | 10 | 简中 繁中 英 西 法 日 葡 德 俄 阿 |
| news 站 | 10 | 英 西 法 阿 俄 简中 繁中 葡 德 日 |
| game 站 | 10 | 简中 繁中 英 西 法 日 葡 德 俄 阿(游戏名多为英文,en 为最佳体验) |
| hot 站 | 10 | 同 AI 站,但内容本身是中文热搜——界面可切十语种,卡片标题始终是中文原题 |
语言下拉只列本站声明且前端真能渲染的语言(renderable() = LANGS ∩ HOST.langs),
不会出现「选得到但显示不出」的空选项。
- 阿拉伯语自动切换为从右到左(RTL)排版;主副名用 flex
gap排版,方向切换不会错位。 - 切换语言后,若你是带密钥访问(
?key=),非中文界面下会出现 🌐 翻译 按钮, 可将中文 / 英文卡片标题翻译为所选语言(经/api/translate代理 MyMemory,前端带缓存与并发限制)。 简繁中文界面不显示该按钮(源内容本就是中文,不需要转)。 - 内容卡片本身来自中文源 + 英文源,因此「翻译」是针对非中/英文用户的增强。
- 访客看不到翻译按钮:未带密钥时该按钮隐藏,对应接口服务端也返回 403。
- 「发现全球免费接口」区块同理:整块默认
hidden,只有带密钥才显示。
# 在项目根目录 hao-feed/ 下
node build.mjs # 先构建到 public/
npx wrangler pages dev . --compatibility-date=2024-09-23
# 浏览器打开提示的 http://localhost:8788
wrangler pages dev .会读wrangler.toml,已经配好command = "node build.mjs", 通常会自动构建。手动跑一次node build.mjs只是为了先看到构建自检结果。
测五个站点的不同形态:本地 host 是
localhost,会回落到主站配置。 想看子站形态,把ai.myinfostream.com/news.myinfostream.com/game.myinfostream.com/hot.myinfostream.com指到127.0.0.1再带 Host 头访问(curl-H "Host: ai.myinfostream.com")。 预览域名一律注入noindex,不会与正式域名争索引。
快速看效果(无需 Node):直接双击
index.html(file://)也能看到完整效果—— 页面检测到本地无聚合接口时,自动进入「演示模式」,加载内置真实示例数据并循环滚动 (顶部有黄色提示条)。部署到 Cloudflare 后自动切换为实时数据,演示提示条消失。 本地演示模式下「发现接口」区块默认隐藏;想看效果可在地址栏加?key=demo(本地不校验密钥,只是为了把这块显出来)。 若想本地测试真实聚合链路,仍建议用wrangler pages dev(能触发functions/)。
五个正式域名:
myinfostream.com/ai.myinfostream.com/news.myinfostream.com/game.myinfostream.com/hot.myinfostream.com。canonical由functions/index.js按 Host 动态注入,五个域名各自指向自己,互不重复计权。
- 把
hao-feed/推到 GitHub / GitLab 仓库。 - Cloudflare 控制台 → Workers & Pages → Create → Pages → 关联仓库。
- 构建设置:
- Build command 填
node build.mjs - Build output directory 填
public - Root directory 填
hao-feed(若仓库里有一层文件夹)
- Build command 填
- 保存部署,几秒后得到
*.pages.dev域名。
⚠️ 不要把 Output directory 填.(v2.4 修掉的就是这个问题)。 填.会把整个项目目录当静态资源上传,/functions/_lib/sources.js和/apitest.mjs都能被直接下载 —— 源码、接口结构、测试脚本全部对外公开。_routes.json里的"/*.js"只匹配根目录一层,挡不住子目录。
- 控制台 → Workers & Pages → Create → Pages → 直接上传。
- 选择
hao-feed/整个文件夹上传即可(functions/会被自动识别为 Pages Functions)。
要打成 zip 分发给别人部署,用
node mkzip.mjs(输出到项目上一层的hao-feed.zip)。 不要用 PowerShell 的Compress-Archive—— 它写出的条目名用反斜杠分隔 (assets\app.js),Windows 自己解压无感,Linux / macOS 解出来是「一个名字里 带反斜杠的单个文件」,目录层级整个塌掉,包直接废掉。mkzip.mjs手写 ZIP 结构, 条目名强制正斜杠,跨平台可解。
域名已在 Cloudflare 注册并把 NS 指向了 Cloudflare,接着:
- 控制台 → Workers & Pages → 你的 Pages 项目 → Custom domains → Set up a custom domain。
- 分别加
myinfostream.com、ai.myinfostream.com、news.myinfostream.com、game.myinfostream.com、hot.myinfostream.com(共五个)。 - 等 DNS 自动签发证书,通常 1 分钟内生效,HTTPS 证书自动签发。
加/改域名时要同步改四处,缺一处都不会报错、只是悄悄失效: ①
functions/_lib/hosts.js的HOSTS(加站点全量配置,含tabs/siblings)、 ②MAIN_HOST(如果动主域名)、 ③ 其它站的siblings与主站的entries(双向,漏一个方向测试会红)、 ④ 本 README。index.html里不需要改 —— canonical / description / tagline 现在都是运行时注入的。② 之后还要在
app.js的UI_I18N里补齐该站所有 tab / 入口键的十语种文案, 漏了不会崩,只会在界面上露出裸 key 名(tabGameLive这种)——hosttest.mjs现在会拦。
# ① 五站各自的 canonical / description / 语言是否正确
curl -s https://myinfostream.com/ | grep -o '<link rel="canonical"[^>]*>'
curl -s https://ai.myinfostream.com/ | grep -o '<meta name="description"[^>]*>'
curl -s https://news.myinfostream.com/ | grep -o '<html lang="[^"]*"'
curl -s https://game.myinfostream.com/ | grep -o '<meta name="description"[^>]*>'
curl -s https://hot.myinfostream.com/ | grep -o '<title>[^<]*</title>'
# ② sitemap / robots 是否按站生成
curl -s https://news.myinfostream.com/sitemap.xml | head -5
curl -s https://game.myinfostream.com/robots.txt
# ③ 源码不应该能被下载(应返回 404 或 HTML)
curl -sI https://myinfostream.com/functions/_lib/sources.js | head -1
# ④ ★ 接口层:真调一次,看有没有卡片
node diag.mjs https://myinfostream.com/ 简报
node apitest.mjs # 本地直调 handler,五站全 tab 一遍
# ⑤ ★★ 浏览器层:唯一能证明「用户真看到东西」的一步,别跳
node webtest.mjs https://myinfostream.com/
node webtest.mjs https://game.myinfostream.com/
node webtest.mjs https://hot.myinfostream.com/
node gameshot.mjs # 游戏站多语种出图,肉眼验三态 tab④⑤ 的区别是这次事故的全部教训:curl 和 apitest 全绿,页面照样是白的。 唯一能证伪「用户看到空白」的是
webtest.mjs——它开真浏览器、逐个tab 点过去、 数 DOM 里的卡片数。只有它报出 N 条才算真的修好了。
- 换/加城市天气:看板支持任意全球城市(Open-Meteo 地理编码),亦可点 📍 用浏览器定位。
- 切换内容分区:点顶部 Tab;URL 支持
?tab=xxx(tab id 由hosts.js定义)。 - 加 Tab:在
hosts.js对应站的tabs数组加一项(id/i18n/sources), 在sources.js的SOURCES注册对应的源 id,在app.js的UI_I18N补该i18n键的十语种。 - 加站点:在
hosts.js的HOSTS加一项(含tabs/siblings/langs), 在主站的entries加入口卡片,并回其它站的siblings补上它(要双向), 最后在app.js补entryXxx/entryXxxDesc两个键的十语种。跑node hosttest.mjs验收。 - 接入更多数据源:在
functions/_lib/sources.js写一个返回card[]的函数, 注册到SOURCES。用g()工厂写 Google News 源最省事(白名单 + 垃圾闸门都现成)。 - 加翻译支持的语言:先在
app.js的LANGS加一项(含mc= MyMemory 语言码)并补齐I18N[code]全部文案,再回hosts.js对应站的langs加这个码。顺序反了会出现「幽灵语言」。 - 加内置接口清单:编辑
functions/api/discover.js里的CURATED(始终可用,离线也返回)。 - 改刷新频率:
feed.js的CACHE_TTL(默认 20s,控制批次间重复度)。
「一个 tab 一个源」不是美学偏好,是硬约束:
fetchBySources并发拉取时, 多个源 id 若映射到同一个函数,等于自己打自己限流。game 站三个 tab 共用一个games源 就是这个约束的正解(一次上游,客户端切片),详见「game 站:为什么三个 tab 只声明一个源」。
-
限流是内存实现,多 isolate 下会失准,只够挡随手刷(详见 v2.2 说明)。
-
/api/feed在页面正常工作的前提下无法完全隐藏,只能靠限流 + 同源抬高刷取成本。 -
五站共用一个 Cloudflare Pages 项目:无法给某个子站单独回滚(要么全发要么不发), 也无法给子站单独配 WAF 规则(DNS 层可以,Pages 项目层不行)。
-
前端一份 HTML 意味着全站同版本:任何改动五站同时生效,无法灰度。
-
game 站的「即将开放」依赖 Epic 的预告数据:Epic 官方偶尔会临时撤下预告, 那一刻该 tab 会短暂变空(
apitest.mjs的切片断言会捕捉到这种情况)。 -
hot 站的内容是中文原生热搜:界面支持十语种,但微博/百度/抖音的词条本身没有多语种版本, 非中文用户看到的是中文原题(这与 news 站的 Google News 多语种不同源)。
-
game 站的语言集对非英语用户不完美:游戏名几乎全是英文原文,界面文案可切十语种但卡片标题不会本地化。
-
Google News 小语种保量靠关键词而非白名单(fr / ar / ru),质量靠垃圾标题闸门兜底, 偶尔会混进不够硬的源。
-
足球源依赖 OpenLigaDB 公开接口,部分联赛随赛季可能返回空,自动回退到德甲。
-
市场加密行情依赖 Binance 公共接口,不可达时自动跳过。
-
翻译依赖 MyMemory 免费接口(有每日额度),超限或失败时卡片保留原文。
-
接口发现内置清单始终可用;publicapis.org 实时合并为增强项,该源实测可能不可达,不可达时静默降级为纯内置清单。
-
所有数据源均为第三方免费接口,稳定性不保证;单个源失败会被隔离,不影响整条信息河。
-
子站没有独立的 og:image,五站共用一张 svg,部分平台(尤其 Twitter)不支持 svg 分享图。
-
线上首访某 tab 可能等 10 秒上下:
webtest.mjs偶尔报「AI tab 空白(20s 超时)」, 已实测定性——同一 URL 直查三次是10.2s / 3.4s / 10.2s,三次都返回 200 与 34 条数据。 首次请求吃满WORST_CASE(SOURCE_DEADLINE 8000 + RETRY_DEADLINE 3000), 第二次命中边缘缓存就降到 3s 级。这是边缘缓存冷启动开销,不是数据缺失, 也不该靠加超时解决(budget.js顶部写明:超WORST_CASE该看缓存,不是加预算)。 真要优化方向是预热/更长的缓存 TTL,而不是动超时预算。webtest.mjs现在把这两类情况分开报:单 tab 一轮等满 20s 为空就再点一次重试, 重试后有数据 → 计入⚠ 边缘冷启动一行(不算失败),重试仍空 → 才报✗ 重试后仍空白。这样冷启动不会再冒充回归,而真空白也藏不进「重试成功」里。 另有SIM=1 node webtest.mjs走本机localsim.mjs模拟环境,同一份代码 四个 tab 全绿,是区分「代码坏了」和「冷启动慢」最直接的对照。