# Changelog 本文件记录 dsh-quake-alert 的显著变更,格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 条目只写「改了什么」,改动的理由与实现细节见提交记录与 `DESIGN.md`。 ## [Unreleased] ## [0.5.4] - 2026-09-19 **Review 版**:四路独立对抗性审查(Host 与传输协议 / Client 运行时与生命周期 / 解析与匹配 / 配置·持久化·设置页),每条结论都要求"能按给出的路径复现"。修掉的问题分成三类:**信号被覆盖** (用户看不见已经发生的事)、**没有自愈路径**(一次失败之后永久停在错的状态)、**测试与文档 落后于实现**。回归断言 1070 → 1103,新增部分集中在"这个能力真的生效了吗"(0.5.3 复盘的要求)。 ### Fixed — 静默失效:状态信号被后写者覆盖 - **数据格式异常的蓝点会被常态断线或探针抹掉,而且不再恢复**(本轮最严重的一条)。 `store.pushSource` 是**整体替换** status + detail 的,而展示状态本应是「蓝点 > 停更 > 连接」 的合成结果——此前只有 12b / 12c 走了 `effectiveStatusOf`,探针(12d)、健康层自身(05g)与 WebSocket 连接层(12-websocket)都直接写 store。实测两条路径都真实可达:**P2PQuake 约每 10 分钟一次的常态断线**上报 `reconnecting` 就把蓝点冲掉;探针在「数据已过期 → 恢复」翻转时 写 `open` 把它刷成绿色,而 12b 的去重键认定"自己的 eff 没变",此后每一轮都不再上报—— 蓝点永久消失(`sourceHealthOf()` 里 escalated 仍是 true),设置页那个「重试」按钮也跟着消失。 现在新增 `publishStatus`(05g)作为**唯一**的 store 写入出口,12-websocket / 12d / 15-entry / 05g 内部四处全部改走它;12b / 12c 的去重判断除自己的键,还比"store 里当前实际的状态", 任何被外部覆盖的状态都会在下一轮自愈。另加 `republishDataHealth()`:刷新页面后**立刻**把 已持久化的蓝点重新发布出来,不必等该源下一次上报(3 秒 + 15 秒)。 - **降级客户端建立失败后,该源整个会话彻底静默,UI 却显示「已降级为轮询」**(12c)。 `activateFallback` 原来先置 `inFallback` / `mode='poll'` / 关掉 SSE,再建轮询客户端,失败 路径直接 return —— 于是 tick 的四条分支没有一条能再建起链路(只有刷新页面),而设置页、 侧边栏与诊断快照都显示"已降级"。现在**先建成功再置位**,失败回滚到 `idle` 并如实上报 `unreachable`(用户显式选了「强制轮询」时 tick 会每 5 秒重试)。 - **跨标签页「清空记录」没有真的清干净**:接收方只清了 `alertedEvents`,内存里的历史仍在, 它的下一次 `addEvent` 会把刚被清掉的整份记录写回 localStorage——发起清空的那个标签页一刷新 又看到了(清空若出于隐私动机,这就是实际的泄漏面)。现在接收方同时清内存与磁盘。 - **`12-websocket.start()` 不重置 `stopped`**:stop 之后再 start 是死的(`connect()` 第一行就 `if (stopped) return`),只有 `restart()` 能复活。当前唯一调用点是入口的一次 start,所以不是 活缺陷,但语义不对称迟早会被踩到(单测里也没有一条覆盖 `start ≠ restart`)。 - **`12c` 的 `consecutiveFails` 跨连接累计**:它只在 `sync` 帧归零,`onEntry` 不清,于是一条 健康跑了很久的连接上零星累积的 error 会留在计数里,下一次"连上但没首帧"的第一跳就可能直接 ≥ `maxFails`,降级理由写成"连续 N 次未收到首帧"(实际只失败一次)。收到真实数据即归零。 ### Fixed — 误报方向:文案与等级 - **JMA 汇总副本一律被标成「气象特别警报」**(05b)。`気象特別警報・警報・注意報` 是 VPWW53 的 **产品名**,与这条电文里有没有特別警報无关——仓库样本 `jma-vpww53-hyogo-danger-20260914.xml` 实测是 L4 危険警報(大雨),却被显示成「气象特别警报」(特別警報是**最高级 L5**);反过来 VPWW54 在 L5 时只显示成「气象警报」,是低估。现在汇总族**按级别**取标签(L5 → 特别警报), 带灾种名的副本((大雨)/(土砂)…)仍走具体灾种——与 0.4.2 修过的"文案夸大成避难指示级" 是同一类问题,方向相反。 - **未配置中国大陆关注点时,nmc 预警仍然写进「最近预警」**(11-pipeline)。它与坐标型源的 `watchlessPoint` 是同一个形态(命中概率恒为 0),而 nmc 默认就在轮询:一条关注点都没配的 用户每天会有几十条「未命中:未设置中国大陆关注点」挤进 `HISTORY_MAX = 30` 的列表,真正的 地震 / 海啸提醒被挤出去。现在 `matchCnAreaAlert` 给这一类带 `noWatch` 标记,pipeline 据此 **不进历史**(但不整条丢弃:设置页与诊断仍需要"有预警、但你没配关注点"这个信息)。 - **大陆气象电文会清空日本电文留下的「L3 正在升级」提示**(11-pipeline):nmc 的 `regions` 恒为空数组,`updateWeatherHint` 的命中判据因此恒为 undefined,每一条都会走到"清空提示"分支。 现在只对日本气象电文生效。 - **`looksReplayed` 的抑制理由写错了原因**:它的判据是 24 小时的已提醒记忆,覆盖的不只是 "Host 重启 / 重连后的重放",还包括"同一官署同一灾种在 3 小时事件窗口之后、24 小时之内等强度 的第二次独立发布"。旧文案会让人去翻 Host 重启日志。行为方向是安全的(不重复响铃), 所以只改措辞、不改判据,并把"实际窗口是 24 小时"写进 DESIGN 11.10。 - **「发送测试气象警报」第二次演示同一汇总型场景时静默**:汇总型事件键不含发布时刻,连点两次 会算出同一个键,被判成"同一事件的后续发布"而只记历史——与按钮文案"可反复点击"直接矛盾。 全球链路早就显式改写过事件键(`alert.eventKey = 'test:' + id`),气象这条漏了;现在同样 带上毫秒。另修正测试场景的半径说明:旧文案写"超出默认 300km 半径,刻意不命中",而 **0.5.0 起新建关注点默认半径是 100km**,且半径可配到 2000——半径 ≥556km 的用户点这条 会真的响铃(实测半径 1000 命中)。 ### Fixed — 数据健康与契约 - **`parseNmcAlarmResult` / `parseNmcAlarm` 用直接下标查表**,`kind: 'constructor'` 这类键会 命中原型链返回 `Object` 构造函数(truthy),于是**绕过契约的 empty / schema 判据**被放行, 一路带进 Alert:`kindLabel` 变成「大陆function Object() { [native code] }预警(…)」, `severity` / `cnRank` 变成函数对象。契约层存在的意义就是"Host 的 JSON 属于不可信输入" (0.1.1 的 `tsunamiGrade`、04-city-table 的 `setCnAreas` 都是同一条约定),这里改用 `own()`。 - **`12b` 的 `schedule()` 排新定时器前不 clear**:与 12c / 12-websocket 的纪律不一致,当前 控制流不会双排,但少这一行意味着"以后谁改了一处控制流就多一条自续的轮询链"(上游请求翻倍)。 ### Fixed — Host 侧 - **按需轮询空闲后每 5 秒空转一轮,永不停止**(`poller.js`)。`skipped` 之后按 `IDLE_RETRY_MS` 自续,于是任何一次"连续 idleMs 无人读 /feed"(页面关 10 分钟即发生)都会留下一条常驻的 5 秒定时器链,`idleSkips` 每小时 +720——而它是**用户可见**的(12b 渲染成「节流跳过 N 次」), 关一天网页再打开会看到"跳过了 8640 次"这种既不表达故障也不表达用量的读数。现在**真空闲时 停链**,由 `markRead()`(/feed 被访问)唤醒;短退避只用于"还没有人来读"的启动窗口。 - **settings namespace 注册失败会静默退化**:settings.yaml 是用户可编辑的机器级文件,而这个 schema 不做类型强转——一个手写的 `notify: { volume: "0.5" }` 会让 `register` 抛错,命名空间 根本没注册,客户端退回 localStorage,而界面上只会多一行"配置存储:浏览器 localStorage"。 现在包一层 try/catch + 一条明确的警告日志(插件仍可用,但"配置为什么没生效"能查到)。 ### Fixed — 配置持久化 - **Host 迁移标记(`MIGRATED_KEY`)两个方向都不可靠**(03-settings-bridge): ① 它只在"Host 为空 + 本地非默认"那条分支里落,于是**首次 bind 时 Host 已非空**(第二个浏览器 / 手写过 settings.yaml)永远不落标记——此后 Host 一旦变空(用户在别处恢复默认),本浏览器会 把**过期**的本地镜像重新迁回 Host,静默复活旧配置(实测复现);② 落标记依赖 `pending` 的 resolve,而平台的 `mutate` 在 Host 拒绝时(revision 冲突等)**也是 resolve**(内部 recover 后重新推送),于是标记落了、配置却没进 Host,随后被 Host 值覆盖——永久且静默地丢配置。 现在:以 Host 为准的每一条路径都落认领标记;迁移改为 settle 后**回读 `getSnapshot().user`** 确认字段真的落上才算完成,并有尝试上限,不再把 resolve 当成功。 ### Added — 无障碍(读屏可用性) - 设置页的下拉、滑块、时间框、搜索框此前只有旁边的视觉文字,两者在 DOM 里毫无关联——读屏软件 念到的是无名组合框。现在 `s.select` 支持 `aria-label`,6 个阈值 / 链路 / 行政区下拉、音量滑块、 两个静默时间框与市町村搜索框都有可编程名称;测试结果与诊断提示加 `role="status"`;页内 toast 加 `role="alert"`——页面可见时**只用 toast**,那是读屏用户唯一能收到警报的通道。 ### Changed — 门禁与文档 - **CI 现在跑核心回归套件**(`.github/workflows/contracts.yml` 增加 `check-imports.mjs` 与 `tests/sync-test.cjs` 两步,都是零依赖)。此前 CI 只有契约自检,1070 条断言(去重 / 匹配 / 解析 / 健康机制)全在本地跑:PR 破坏了这些逻辑,CI 不会红。实测在 UTC 时区下也全绿。 (`build-client --check` 仍留给本地 `npm run check`:它需要 rollup,而 CI 刻意不装依赖。) - `DESIGN.md` 4.2:EMSC 的「久无数据」检测**从来不是关掉的**——0.4.0 曾置 0,**0.4.1 已改为 3 小时**,但本节与三语 README 一直写着"刻意关掉"(与 05d 契约里的 `staleReason` 自相矛盾)。 四处一起改正。 - `05e` / `05c` 的注释里 EEW 的 EventID 形态与仓库样本不符(写的是 `202609182050.0001`, 实测 `samples/cn/cenc-eew-last.json` 是 `b4kybfnuqayyy` 这类随机串),按样本修正。 ### 已知残留(写进 DESIGN 11.10,避免下一轮 review 当成新发现) - 551 / 552 / 556 的契约 `required` 仍比实现严(0.5.1 只修了 cenc 两源):实测缺 `earthquake.time` / `points[]` 逐项字段 / `areas[].name` / `grade` 枚举 / `issue.eventId` / `scaleTo` 的载荷仍返回 `ok:true`。其中 556 缺 `scaleTo` → 阈值判定恒 false(整条静默)、 缺 `eventId` → `eventKey` 为空(每条后续发布各响一次),是"该响的失败变成了静默的漏报"。 - `regionInWeatherWatch` 与自己的注释不一致(注释说 pref 为空一律放行,代码只在 city 为空时 放行)。当前数据下不可达(1567 个河川市町村条目全部能归县、1917 个市町村别名零冲突)。 - 气象事件的**实际去重窗口是 24 小时**而不是 DESIGN 11.6-6 写的 3 小时:`looksReplayed` (24 小时已提醒记忆)在 `isEventRepeat` 之后仍然拦得住等强度的第二次独立发布。 - **JMA 的 L1〜L3 未命中电文仍然进历史**(0.3.0 的设计)。实测量级约 170 条/天,而 `HISTORY_MAX = 30`——默认配置下「最近预警」基本只显示最近几小时的气象注意报。要么把 "完全未命中"排除出历史,要么把「我的提醒」与「环境观察」拆成两条队列(属产品决策)。 - `poller` 的失败路径没有退避(与 wolfx 的 1s→60s 不对称);`createFetchText` 的 `content-length` 预检挡不住**解压后**的大响应(`res.text()` 已经解压到内存); wolfx 的环缓冲只有条数约束、没有 poller 那样的字节预算。 - Host schema 只校验阈值范围不校验档位(`quakeScale: 42` 会让下拉渲染成空/首项); `togglePref` 在市区町村表未就绪时不清理该县下的 cities;切换数据源后 80ms 内关闭设置页会 取消那次重启(等待下一次强制断线自愈)。 ## [0.5.3] - 2026-09-19 **校验机制层**(DESIGN 11.1 的第三层 / 11.9)。前两层(约定 / 内容)已经在 0.4.1–0.5.2 随源落地, 这一版把它们积累的信息**系统化**:阈值从文档变成开关、失败记录有了生命周期、上游改版有了能自动 发现的入口。开工前的 grep 给出了这一版存在的理由——`SOURCE_CONTRACTS[*].staleAfterMs`(8 个源) **在整个代码库里没有任何读取点**。 ### Added - **`client/src/05g-source-health.js`**:统一的源健康记录。三层模型(conn 不持久化 / data 持久化 / fresh 由探针写),取代此前"一个只记最近一次失败的内存 Map"。 - **蓝点跨刷新持久化**:data 一层落盘。它表达的"上游改了字段、要等插件更新"与用户刷新页面无关, 刷新就消失会让这个信号没人看见(DESIGN 11.6 第 7 条)。`15-entry` 装载时也不再清它 (`resetSourceHealth()` → `resetConnHealth()`:只重置连接与新鲜度两层)。 - **蓝点 TTL 自愈**:24 小时没有复现就自动清除并记一次自愈。这条直接解掉 11.7 第 2 条 ——`cenc_eew` 实测数天一条数据,而它的 empty 判据(10 个字段全空)与 Host"无 ID 不转发" 互斥,于是"等一次成功解析"永远等不到,一条判错的蓝点可以挂数天。 - **逐条升级阈值**(11.7 第 1 条):单条解析失败**不再**点亮源级蓝点,改为先记计数;只有"同一失败 原因在 10 分钟内累计 ≥5 条"或"连续 10 条全失败"才升级。线上接的是逐条 entry(Host 已把整表 拆开),此前上游一条脏数据就会点亮蓝点 + 一个按了也没用的「重试」;实测速报整表 50 条里坏 1〜2 条是常态。第二条路径(连续失败)兜的是"坏法不重样"——上游把结构改得面目全非时, 每条失败的原因字符串可能都不同,按原因计数永远到不了阈值。 - **`client/src/12d-health-probe.js`**:探针调度。一个 30 秒的定时器按**契约里的 `staleAfterMs`** 判定每个源的新鲜度——**声明从此是唯一的阈值来源**,各源只上报"最后一次拿到数据的时刻"。 此前阈值散在四处硬编码(`lib/index.js` 的 `feedStaleMs`、`lib/wolfx-source.js` 的 preset、 `15-entry` 传给 12-websocket 的 `staleAfterMs`、12c 对 SSE status 帧的直通)。 两个刻意的例外:`staleAfterMs: null` 的推送源不判(日本可能数小时没有有感地震,活性归连接层); 从未上报过数据时间时不判("不知道数据什么时候来的"不等于"数据是旧的")。 - **`scripts/check-contracts.mjs`** + **`scripts/lib/load-client.mjs`** + **`.github/workflows/contracts.yml`**: CI 契约测试。它的形态是**端到端**——拉真实数据交给解析器,看它是否还认(契约里的 `required` 是自然语言,不能机器校验)。上游改版会直接变成 `schema` / `value` 失败。触发方式是 `workflow_dispatch` + `pull_request`,**不设定时任务**(个人项目每天两次可能失败的邮件通知 不值当;用户侧的蓝点已经能看见)。`--offline` 用 `samples/` 的快照跑,是脚本自身的自检。 网络不可达**不算失败**(CI 在网络上抖动不该变红,只有"上游改版"值得让人处理)。 - **环缓冲字节预算**(11.6 第 5 条):`poller.js` 的缓冲此前只按条数限容(120 条),单条响应上限 512KB 时最坏 60MB 常驻。现在加 8MB 预算,超了从最旧的淘汰并计入 `dropped`——`truncated` 的语义 因此自动生效(Client 会知道中间有缺口)。预算装不下一条时仍留一条:那一条正是要看的数据。 - **`JMA_STALE_MS` / `USGS_STALE_MS`**:Host 侧的停更阈值从内联数字提成导出常量。Host 与 Client 是两个分开构建的半边,Host 不能 import 契约,所以一致性只能靠断言守护(见下)。 ### Changed - **回归断言 1041 → 1070**。新增的 29 条全部在问"这个能力**真的生效**了吗",而不是"函数被调用了" ——依据是 0.5.1 的复盘:那一轮抓出的 4 条缺陷全是"写了、注释齐、单测过、但能力不生效"的形态。 具体覆盖:改契约里的阈值 → 探针行为跟着变;`null` 的源不判;没上报数据时间时不判;蓝点点亮 → 模拟页面重载(重新加载 bundle,同一个 localStorage)→ 蓝点仍在;推进 24 小时 → 自动清除; 过期记录在**读取**时也被丢弃;字节预算淘汰最旧的且 `truncated` 为真;预算装不下一条时仍留一条; `jma` / `usgs` / `nmc_alarm` 的契约阈值与 Host 常量逐一相等。 - 三处既有断言按新语义重写("schema 失败 → 进入 schema-error" 现在要多喂几条)。 - 测试沙箱补上 `setInterval` / `clearInterval`(探针在 `apply` 里排定时器,此前沙箱没有这两个全局, "插件能不能装载"这件事本身就会失败)。 ### 已知边界(写进 DESIGN 11.9,避免下一轮当成新发现) - **`empty` 会掩盖一部分结构漂移**:解析器对"与本插件无关的电文"和"结构变了导致认不出"都可能返回 `empty`,而 CI 的在线模式把 `empty` 判为通过。脚本能做的只有把每条的 detail 原样打印出来供人扫 一眼;机器判不了,那需要在契约里对关键字段做显式断言。 - **Host 与 Client 各持一份阈值**(两个半边分开构建),靠回归断言对齐,不靠"记得同时改两处"。 - **推送源的在线契约测试天然偏弱**:P2PQuake / EMSC 是"有事件才推",20 秒窗口内收到业务消息是 例外。`check-contracts.mjs` 因此改用两条 REST 端点取一条真实电文(`api.p2pquake.net/v2/history` 与 EMSC 的 FDSN query),而不是干等推送。 ## [0.5.2] - 2026-09-19 中国大陆气象灾害:**暴雨**与**地质灾害**。数据来自 `nmc.cn`(中央气象台汇总各级气象台发布的 预警信号),**一个 Host 源承载两个灾种**——同一个端点、同一份响应,只有图标编码不同。 ### Added - **`lib/nmc-source.js`**:`nmc.cn` 预警源的 Host 半边。列表一次返回**当前全部生效预警** (实测 241 条 / 65KB),Host 只把本插件接的两类灾种拆成逐条 entry——实测雷电 / 大风 / 高温 占完整列表的 76%,原样转发会把用户的历史刷满。参数:120 秒轮询(站点无开放 API 声明, 比 JMA 的 60 秒更保守)、冷启动回看 30 分钟、停更阈值 3 小时(判据是列表里最新一条的发布 时间:实测 40 分钟窗口里新增 11 条、相邻两次新增的最长间隔只有 10 分钟,3 小时有近 20 倍余量; 同一次采样还量到"发布 → 出现在列表里"的滞后最大 19.5 分钟,那是回看取 30 分钟的依据)。 - **灾种与等级只认图标编码,不认中文**:`pic` 的文件名是 `p` + 4 位灾种码 + 3 位等级码 (`0002`=暴雨、`0021`=地质灾害;`001`=红 `002`=橙 `003`=黄 `004`=蓝)。`title` 是给人读的, 措辞会随上游改(样本里"预警信号"与"预警"两种都有),图标文件名才是程序契约。 - **详情只对橙色及以上拉取**:蓝 / 黄占实测样本的 94%,逐条拉 46KB 的详情页既无必要也不礼貌; 而正文(影响乡镇、防御指引)对真正收到提醒的人有实际价值。靠 `poller.js` 新增的逐条 `needDetail` 钩子实现。 - **`lib/poller.js` 新增两个逐条钩子**:`needDetail`(这一条要不要第二次请求)与 `detailTransform`(详情原文 → 入库载荷)。后者让 Host 把 46KB 的详情页收敛成正文再进环缓冲 ——环缓冲按**条数**计容,不收敛会被整页 HTML 撑大(DESIGN 11.7 第 5 条)。既有四个源不受影响。 - **`client/src/05f-nmc-parsers.js`**:解析层。`kind` 复用 `'weather'`(与日本气象电文共用 "进历史 / 配色 / 文案"的整条链路),但 `locator` 是 `'area'`——匹配走行政区层级, 与日本的都道府県 / 市町村匹配和全球源的半径匹配都不是一回事(三条路径的兜底规则各不相同)。 - **行政区层级匹配**(`06-matcher` 的 `matchCnAreaAlert` + `04-city-table` 的 `cnAreaOf`): 机构名 → 省 + 地级市 → 与用户关注的地级市比对。三条规则:① 省名必须出现在机构名**开头** (省别名里有「海南」,而青海省有「海南藏族自治州」,全局搜会把它归到海南省);② 市级用 GeoNames 的**全部中文候选**做最长匹配(显示名可能是旧名);③ 市级找不到时若该省下只有一个 条目(直辖市 / 港澳)就用它。市级归属未知时**按省放行**并说明原因(省直辖县 / 省台发布 / 上游错字——实测「黑龙江省齐哈尔市克山县」少了"齐"),认不出省的(国家级机构)全国放行。 - **`cn-areas.js` 新增 `aliases`**:每条记录的其余中文候选,只参与匹配、不进 UI。存在的理由 是 GeoNames 会挑到**旧名**(实测「毕节地区」而气象台写「毕节市」、「思茅市」而气象台写 「普洱市」),只按显示名匹配会让这些真实预警退化成"仅省"。加别名后实测可定位率 **88% → 95%**(226/238)。产物 21KB → 27.8KB。 - **两个独立灾种开关** `disasters.cnRainstorm` / `cnGeology`(默认都开)。分开的理由是产出差别 很大:暴雨的橙 / 红常年可见,而地质灾害在 238 条样本里**全是黄色**(达不到门槛,只在历史里 留痕)。两者仍共用一个 Host 源——任一开着就继续拉增量。 - **`samples/nmc/`** + **`scripts/capture-nmc-fixtures.mjs`**:真实样本(列表裁剪版 24 条 + 5 个详情页)与采集脚本。列表存**裁剪版**但保持与真实响应**同形**(`data.page.list`), 否则测的就不是线上那条路径了。 ### Changed - **回归断言 966 → 1039**,新增的直接覆盖上面每一条:图标编码与等级映射、逐条跳过 vs 整表失败、 详情只为橙红而拉、冷启动回看窗口的边界、停更探针(同一批数据、时钟只走 10 分钟不判停更)、 契约的三类失败(含"不在范围内的灾种判 empty 而非 schema"的向前兼容)、归属匹配的六种机构名 形态、青海省海南州不被省别名「海南」抢走、省级兜底对别的省不命中、两个开关互不牵连。 - `DESIGN.md` 的 4.1 表把 nmc 的两行合并为一行并标为已接入;顺带修正 Wolfx 两行仍写着 "待接入"(0.5.0 已落地却没改)。8.4 / 8.5 补上 0.5.2 的落地记录与实测数据。 - `lib/index.js` 的 `pollers` 表新增 `nmc_alarm`(与既有四源接口同形,`/feed?source=nmc_alarm` 开箱可用);settings schema 新增两个灾种开关。 ### 已知取舍(写进 DESIGN 8.4 / 8.5 / 9.1,避免下一轮当成新发现) - **不建三级行政区划表**。0.5.0 曾预留"省 → 市 → 县"(约 2,900 县)用于"从县名向上找地级市", 但实测 238 条预警的机构名**全部以省名开头且自带完整层级链**,226 条可直接定位到地级市, 剩下 12 条是海南省直辖县与上海市辖区(前者本就不属于任何地级市,县表也救不了)。 县表的边际价值≈0,而它要多背 2,900 条无法验证的数据。真正需要的是**别名**(见上)。 - **橙色预警不穿透静默时段**。severity 忠实映射(橙→orange、红→red),不做 `cenc_eew` 那种 "警报恒 red"的拔高——气象预警的四个颜色本身就是等级,拔高会让这个用户看得见的官方等级 失去意义。后果是夜间发布的橙色预警只在历史里留痕,已写进设置页文案。 - **没有"解除"标志**(与大陆地震源同类缺口)。列表是"当前生效集合",预警过期即消失, 我们看不到"解除"这个动作,`cancelled` 恒为 false。UI 与三语 README 必须如实说明—— 没有它,用户会把"没收到取消"读成"警报仍然有效"。 - **只接暴雨与地质灾害**。其余灾种(雷电 / 大风 / 高温 / 大雾…)占完整列表的 76%, 接进来就是持续误报,会摧毁真警报(DESIGN 3.2 的误报密度判据)。 ## [0.5.1] - 2026-09-19 三路**独立对抗性审查**(Host 与传输协议 / Client 运行时与生命周期 / 解析·匹配·去重·配置),外加两处实证复现(可推进的假时钟、真实样本驱动),每路另附"查过、没问题" 的方向。下面按 漏报 → 误报与速率 → 静默失效 → 文档 列出。 ### Fixed — 漏报 / 静默失效 - **`cenc_eew` 的 severity 按震级分档 → 静默时段不穿透**(漏报)。日本 EEW 恒 `red`(警报本质), 而大陆预警走坐标型路径,severity 由 `severityOfMagnitude` 给——实测只有 M4.2 的预警因此是 `info`, 静默时段(默认红色穿透)直接把它吞掉,而同配置下的日本 EEW 照常响。现在大陆**预警恒 red**, 与日本 556 同口径;**速报仍按震级分档**(它是分钟级确认,不是警报)。 - **`cenc_eqlist` 的中继停更探针(48 小时)在最需要它的两种形态下永不触发**(静默失效)。停更的 真实形态是"不再有新帧"或"一直转发同一张旧表",而探针只在**收到内容有变化的帧**时求值:前者根本 不进 `handleDataFrame`,后者被 md5 短路一起带走。实测:同一实例喂同一帧、时钟推进 50 小时, `stale` 仍是 `false`。现在抽成 `refreshStale(t)`,由**每个数据帧**(含被短路的)与**每 30 秒的 例行检查**共同推动。 - **默认(SSE)路径下"中继停更"完全不可见**。`stale` 只能由 Host 判定(它才知道阈值与中继节奏), 但 `sync` 帧不带它——只有降级到轮询之后才读得到 `/feed` 的 stats。现在 `sync` 帧带上 `stale` / `dataTime`,并新增每 15 秒一帧的 `event: status`(兼作 keep-alive),把停更推给 Client, 显示成中灰「数据已过期」。 - **SSE → 轮询降级时不衔接游标 → 降级期间 Host 缓冲里的条目被静默跳过**(漏报窗口)。降级客户端用 的是另一个游标键(`feedCursor.` vs SSE 的 `streamCursor.`)且不注入当前游标,于是首次轮询 是 `?since=tail`。现在两侧**共用同一个游标键**(seq 本就来自 Host 同一个源)。 - **整表"有 NoN 项却一条都解析不出来"与"空表"同形**(静默失效)。字段改名会让整表 50 条全被丢弃, 而它此前 `errors` 不涨、`dataTime` 停在 0——用户看到绿色"已连接"却一条都收不到。现在只有真正的 空表才算正常,有条目但全解析失败会计入 `errors` / `schemaSkipped`(DESIGN 4.5 要求两者不同形)。 - **周期 `status` 帧会把 `sync` 帧的告警抹掉**(本批次修复自身引入的回归,被审查通道当场抓出)。 `store.pushSource` 整体替换 status + detail:`sync` 报出"增量缺口 / Host 游标重置 / Host 侧该源 未在运行"之后 15 秒,`status` 帧报一条"已连接"就把三条告警全清了,而条件仍然成立。现在 `status` 帧继续携带这些告警。 ### Fixed — 误报 / 速率 / 资源 - **选了「强制轮询」再关掉灾种开关,重新打开时会先白建一条 SSE**:`mode === 'disabled'` 的恢复分支 排在链路选择判断之前且无条件 `connectSse()`,8 秒探针超时后还会对一个明确选了轮询的用户谎报 "连上但不推流",并白占一条 Wolfx 长连接。现在恢复时同样先看链路选择。 - **降级这件事会被轮询自己的"成功"覆盖**:12b 有独立的状态去重键,降级后第一次成功轮询就报 `open`, 把 12c 刚报的"已降级为轮询"顶掉(聚合状态回绿、侧边栏悬停里只剩正常源)。现在降级态下轮询侧的 上报经过一层合并,状态保持"已降级",细节附在后面。 - **自动降级之后手动开关救不回来**:切「强制轮询」再切回「自动」时 `fallbackManual` 永远设不上, 于是永久停在轮询,只能刷新页面。现在"已经在轮询(自动降级来的)"也会认下用户这次明确的选择。 - **`markRead()` 会把正在等待的退避重置为 0**:Host 的 `/feed` 每次请求都调它,于是上游连不上时重连 上限被压平成 `/feed` 的 15 秒周期(实测 300 秒窗口 23 次 vs 10 次建连),而"不能死循环猛敲"正是 退避的全部意义(DESIGN 5.3)。现在只有"因无人使用而主动断开"这一种情形才立刻重连。 - **`pruneSeen()` 从未被调用**:`seenTtlMs` 成了假选项,去重键表只增不减。现在挂在数据帧上。 - **`geoEventKey` 的 `-0.0` 与 `0.0` 是两个桶**:`(-0.02).toFixed(1)` 给 `-0.0`、`(0.02).toFixed(1)` 给 `0.0`,赤道 / 本初子午线两侧的同一场地震跨源归并会失败(表现为概率性重复)。现在把 `-0.0` 归一。 - **空闲断开被写成 `lastError`**:它是正常行为,写进去会让 `check-wolfx-live.mjs` 之类的排障脚本把 正常的空闲断开打印成故障。现在只计 `idleSkips`。 - **事件记忆的过期清理混用了窗口**:`isEventRepeat` 用**本次调用**的窗口清理,于是一条按 3 小时 窗口记住的气象事件,会被 10 分钟后任意一条"命中"地震带着的 10 分钟窗口清掉——随后 L4 的更新 被判成新事件,重复响铃。现在每条记录存下**自己的**窗口(旧记录没有该字段时退回本次窗口, 行为不突变)。顺带给它加了可注入的时钟,否则这类跨时间行为只能靠读码验证。 - **SSE 侧游标无法自愈**:12c 的游标只前进、不处理 Host 的 `reset`(12b 的轮询路径有 `regressed` 自愈)。Host 因重启伴随时钟回拨而让游标回退时,本地会永远卡在一个更大的值上,之后每次重连都 触发 reset + 全量重放。现在 Host 明确说 `reset` 时允许回退,其余情况仍只前进。 ### Changed - 回归断言 **946 → 966**,新增断言直接覆盖上面每一条(整表全坏 vs 空表、退避不被压平、去重键过 TTL 后重新入缓冲、status 帧不抹告警、降级客户端共用游标键、大陆预警 severity 恒 red、 事件记忆按各自窗口过期、Host 说 reset 时游标可回退)。 - `check-cn-e2e.mjs` 的读流器此前读到 8 帧就收工,而断言要求 ≥20 条 entry —— 它只在"一次 TCP 读 把整批吞下"时才通过,网络分片会把健康的系统判成 FAIL。现在按"读到足够多 / 连续 1.5 秒没有新 数据 / 整体超时"收工。 - 文档一致性:`DESIGN.md` 里 5.3 / 第 8 节 / 第 9 节的标题仍写着"待实现"(0.5.0 已落地); `TROUBLESHOOTING.zh.md` 的入口表里"Client 侧诊断快照""手动降级开关"还标着"0.5.0 补齐"(都已经 可用);三语 README 的 Known limitations 补上大陆源的四条限制。 - **设置页「② 中国大陆」补上"没有取消 / 最终报标志"的说明**:DESIGN 10.2 把它列为安全相关缺口并 要求 UI 如实说明,代码注释也写着"不得假装能处理"——但此前 UI 与三语 README 都没有这句话。 没有它,用户会把"没收到取消"读成"警报仍然有效"。 - `SOURCE_CONTRACTS.cenc_*` 的 `required` 与实现对齐(此前比实现严得多),新增 `tolerant` 说明 **明确不判 schema** 的字段范围。 - 代码卫生:`15-entry.js` 的 `__test` 导出有四个重复键;`heartbeatSeen` 是只写不读的死变量。 ### 已知残留(写进 DESIGN 11.7,避免下一轮 review 当成新发现) - **逐条契约与批量语义的分叉**:`parseCencEqlistResult` 写的"坏条目逐条丢弃并计数、全坏才判整表 schema"是**批量**语义,而线上接的是逐条 entry(Host 已拆表),于是单条上游脏数据会经 `noteParseResult` 升级成源级蓝点 + 一个按了也没用的「重试」。Host 侧已负责"整表全坏"的可见性; 逐条侧的计数与阈值需要 0.5.3 的机制层一起定。 - **`cenc_eew` 的蓝点没有 empty 自愈路径**:清蓝点只靠"成功解析"或 empty 分支,而该源走 SSE 时 apply 只在新预警入缓冲时被调用,empty 判据(10 个字段全空)与 Host"无 ID 不转发"互斥。预警稀疏 (数天一次),一条判错的蓝点可以挂数天。 - **跨源同一场地震只要震级略高就二次响铃**:`isEventRepeat` 用裸 `strength` 比较判"升级",而不同 机构对同一场地震的震级差 0.3 很常见(实测 `cenc_eqlist` 福克斯群岛 M6.5 vs USGS M6.8 → 响两次)。 **2026-09-19 定:解法是 DESIGN 3.3 的事件级权威源(同一事件只让一个源播报),不是加容差**—— 容差只是把 0.3 的差吞掉,差 0.6 照样重复。属 11.8 的实现工作。 ## [0.5.0] - 2026-09-19 中国大陆地震源(Wolfx `cenc_eew` 预警 + `cenc_eqlist` 速报)。**五个增量全部落地**: 解析层 + 校验约定 + 双阈值、Host 单点常连 + SSE、Client 消费链路、诊断快照 + 手动降级开关、 设置页三级级联。 ### Added - **`client/src/12c-cn-stream.js`**:大陆源的 Client 半边——消费 Host 的 SSE 推送 (延迟 ≈0;轮询 15 秒一次会把预警变成事后通知),带游标持久化与断线补齐。 - **SSE 自动降级**(不是"等 0.5.0 的开关"):EventSource 不可用、连续 3 次拿不到首帧、 或"连上但不推流"(代理把流缓冲住了)都会自动切到 `/feed?source=cenc_*` 轮询,并把 **降级这件事说出来**。没有它,这些网络下的用户会静默地只收到轮询源、永远收不到大陆预警。 - **设置页的「国家 / 地区」三级结构**(DESIGN 9):① 日本(都道府县 → 市区町村,原有行为不变) ② **中国大陆(省 → 地级市 → 半径)** ③ 其他地区(坐标 + 半径)。②③ 写进同一份 `watch.places`,共享 20 个上限——数据模型仍是单一的一张关注点列表。 **为什么不做成一个统一下拉级联**:日本那一路是多选(日本源按行政区名匹配),中国这一路是 单选 + 半径(大陆源按坐标匹配),选择语义不同;硬塞进一个控件只会让三条路都变难用。 本节要的是**用户视角的结构一致**,这一点已经达成。 - **三档半径语义 + 自定义数值**(DESIGN 9.2):仅本地(约 30 km)/ 本市及周边(约 100 km, 新建默认)/ 较大范围(约 300 km),下拉旁边始终有一个可直接填的公里数输入框 (数字藏进"自定义"分支会让改一次半径多两步)。**既有配置里的 radiusKm 一律不动**—— 静默把用户配好的 300 改成 100 会让提醒变窄,那是漏报方向的变化。 - **`/areas` 随同一份响应下发中国行政区划表**(`cnAreas`):与市区町村表同一理由不内联进 bundle(约 21KB)。Client 侧 `setCnAreas` 逐字段规整——Host 的 JSON 与 localStorage 一样 属于不可信输入,一个坏条目会让级联渲染出幽灵选项、或把用户带到错误的坐标上。 - **`cnPlaceOf(省, 市, 半径)`**(纯函数):级联的产物。抽出来是为了能直接断言"用户点了添加之后 配置里到底多出什么",比"界面上出现了两个下拉框"重要得多。 - **`client/src/16-diag.js` + 设置页「诊断」区块**(DESIGN 11.3):把 Client 侧的实时状态压成 一份 JSON——聚合状态、逐源状态与数据健康、增量计数、大陆源的链路模式、关注点摘要、最近几条 记录为什么没响铃。三条纪律写在文件头:**只读**(诊断不改变被诊断的东西)、**永不抛错** (每个片段各自兜错,出问题时最不能掉链子)、**只放可 JSON 化的叶子字段**(活对象直接 stringify 会拖出整个模块图)。剪贴板不可用时把文本显示成可手动复制的文本框,而不是卡住。 快照**不含插件版本**:版本号只在 package.json / CHANGELOG / README 三处,复制进 bundle 会多一个漂移点。 - **`config.cnTransport`(自动 / 强制轮询)**:大陆源的手动链路出口(DESIGN 11.5)。自动降级 判不出的那几种网络(能连上、偶尔漏、整体像坏的)需要它。与自动降级的关键区别是 **可撤销**:改回「自动」会升回 SSE;而自动降级不再升回(那条链路已经证明过不通,反复试探只是抖动)。 - **`lib/data/cn-areas.js` + `scripts/build-cn-areas.mjs`**:中国行政区划表(省 / 特别行政区 34 → 地级行政区 384,带坐标),服务设置页的「国家/地区 → 一级行政区 → 市」级联与关注点坐标填充。 来源 GeoNames(CC BY 4.0,含 TW/HK/MO dump),构建脚本可复现、带 `--check` 与 `--from`。 **为什么不用国家统计局 / 民政部的数据**:那两份有权威中文名与区划代码但**没有坐标**, 且与 GeoNames 无共同主键(一个用拼音罗马字、一个用六位数字码),硬凑的名字匹配会引入 "名字看着对、位置错了几百公里"这类最难查的错误。 - **`scripts/check-cn-e2e.mjs`**(新):**Host↔Client 端到端复验**。仓库内的用例把两半各自 用假件覆盖,它们之间那段**真实协议**(帧格式、事件名、`id:` 字段、Last-Event-ID 补发、 `sec-fetch-site` 防护)此前没人守。这个脚本把两半接起来跑一次真的:真实 Wolfx → `lib/wolfx-source` → `createStreamHandler` → 真实 HTTP SSE → **从零手写的** SSE 读流器 → Client 解析契约。不进 CI(依赖外部服务),改传输层前后手动跑。 - **`scripts/check-wolfx-live.mjs`**:大陆源连通性诊断(仓库内开发 / 排障用)。用真实的 `lib/wolfx-source.js` 连真实 Wolfx,回答"连得上吗 / query 指令取得到数据吗 / 数据有多旧", 并按 DESIGN 11.5 的故障分类给出结论。已进 `TROUBLESHOOTING.zh.md` 的入口一览。 - **`client/src/05e-cn-parsers.js`**:大陆源解析器。`cenc_eew`(预警,坐标型)与 `cenc_eqlist`(速报,整表逐条)。两条链路的实测坑都写在文件头:整表字段**全是字符串** 而 EEW 是 number、整表里混有境外地震、两源 EventID 格式互不相干。 - **`SOURCE_CONTRACTS` 追加 `cenc_eew` / `cenc_eqlist`**,含四要素(必需字段、源时区、 新鲜度阈值、empty 判据)。速报的 **48 小时新鲜度阈值是中继探针**——速报每天都有数据, 停更即是中继异常(`fj_eew` 那种「连得上但停更 4 个月」的形态靠连接检测发现不了); 预警本身稀疏,不给阈值。 - **`lib/wolfx-source.js`**:Wolfx 大陆源的 Host 半边——WS 常连、心跳看门狗、退避重连、 环缓冲 + 游标、逐条去重。与 `poller.js` 接口同形(`start/stop/snapshot/stats/markRead`), 所以直接并进同一张源表:**`/feed?source=cenc_eew|cenc_eqlist` 开箱可用**, 而那条路径正好就是 WS 不可达时的 HTTP 轮询降级通道。 - **`/dsh-quake-alert/stream` SSE 路由**:大陆源的推送通道(延迟 ≈0;轮询 15 秒一次等于把 预警变成事后通知)。首帧 `event: sync` 给出游标与缓冲状态,逐条 entry 带 `id:` 供浏览器 重连时用 `Last-Event-ID` 补齐断线期间的消息。 - **事件年龄闸门**(安全阀):连上就回放最后一条,而 `cenc_eew` 的"最后一条"实测可能已过去数天 ——把几天前的 EEW 当实时警报播出去是纯误报。按发震时刻设 10 分钟 / 6 小时,被挡下的条目 **记为已见并计入 `ageSkipped`**(可见,不静默)。 - **`scripts/check-cn-e2e.mjs`**(新):**Host↔Client 端到端复验**。仓库内的用例把两半各自 用假件覆盖,它们之间那段**真实协议**(帧格式、事件名、`id:` 字段、Last-Event-ID 补发、 `sec-fetch-site` 防护)此前没人守。这个脚本把两半接起来跑一次真的:真实 Wolfx → `lib/wolfx-source` → `createStreamHandler` → 真实 HTTP SSE → **从零手写的** SSE 读流器 → Client 解析契约。不进 CI(依赖外部服务),改传输层前后手动跑。 - **`scripts/check-wolfx-live.mjs`**:大陆源连通性诊断(仓库内开发 / 排障用)。用真实的 `lib/wolfx-source.js` 连真实 Wolfx,回答"连得上吗 / query 指令取得到数据吗 / 数据有多旧", 并按 DESIGN 11.5 的故障分类给出结论。已进 `TROUBLESHOOTING.zh.md` 的入口一览。 - **`samples/cn/`**:真实抓取的 fixture(WS 推送形态)。`scripts/capture-cn-fixtures.mjs` 是 **可复现的抓取脚本**——大陆 EEW 稀疏(约数天一次),开发时不能等真实事件,用 Wolfx 自带的 回放能力取结构(DESIGN 11.4)。WS 的 query 指令是**纯文本** `query_cenceew` / `query_cenceqlist`, 发 JSON 不会有任何响应(实测)。 - **中国标准时间常量**:`CN_TZ_OFFSET` / `CN_TIME_RE` / `cnTimeToIso`。大陆源给的是裸北京时间 (`2026-09-18 20:50:23`),与 P2PQuake 的 JST **只差分隔符**,不补偏移会让非 +08 时区的用户 看到一个差 1 小时、且没有任何标注的时间戳。`issuedToDate` 同步认这两种格式。 - **`thresholds.cnReportMagnitude`**(默认 M4.5,`CN_REPORT_MAG_OPTIONS`):速报用**独立的** 震级门槛。预警与全球源共用 `globalMagnitude`,速报单独一把——共用会被 M2.5–M3.8 的小震刷屏。 `DEFAULT_CFG`、`normalizeCfg`、Host schemastery schema、`matchPointAlert` 四处同步新增。 ### Fixed - **`geoEventKey` 在跨源归并时会因时区偏移而永久失效**(0.5.0 接入大陆源时发现)。 它此前直接切 ISO 字符串前 16 位取「发震时刻(分钟)」,而全球源给的是 `Z`、大陆源给的是 `+08:00` —— 同一场地震(如 `cenc_eqlist` 里实测有的福克斯群岛 M6.5,USGS / EMSC 也会推) 会被分到**相隔 8 小时**的两个桶里,键永不相等,跨源归并彻底失效、同一场地震响两次。 新增 `minuteKeyOf()` 先归一到 UTC 再取分钟;时间不可解析时退回原串切片(宁可归并失败多响一次, 不丢消息)。对既有的 UTC 输入是幂等改动,EMSC ↔ USGS 的行为不变。 - **`query` 指令名被拼错**(写 Host 源时被自己的回归用例抓住):实测指令是 `query_cenceew` (源 id 去掉下划线),而不是 `query_` + `cenc_eew`。错了完全静默——连接是绿的、心跳照常来、 只是永远没有数据。改为按源显式写死指令名。 - **`dataTime` 只在条目入缓冲时更新**(独立复验真实数据时发现):整表最新一条可能已 12 小时前 (被年龄闸门挡下、不入缓冲),但中继明明在正常转发。只统计入缓冲的条目会让 `dataTime` 一直是 0,"中继停更"判定**永远不会触发**——而它恰恰是这个源存在的意义之一。改为取所有解析出的 候选里最新的事件时刻。 - **SSE 路由把"缺 `since`"当成"从 0 取"**:`Number(null)` 与 `Number('')` 都是 0, 于是没有游标的连接会一口气吐出整个环缓冲(`/feed` 那条路由踩过同一个坑,这里重演了一次)。 与 `/feed` 同一口径:空值显式排掉再看数字,缺失一律按 tail。 - **通知文案把主管机构硬编码成「气象厅」**:希腊的一场 USGS 地震、四川的一场 CENC 预警 都会让用户"以气象厅官方发布为准"——免责声明里出现错误机构,会直接削弱这份声明本身的可信度。 现在按源给出机构名(気象庁 / EMSC / USGS / NOAA / 中国地震台网),认不出时用中性表述。 同时大陆源的通知标题不再套用日方产品名(気象庁叫「緊急地震速報」,CENC 叫「地震预警」—— 套错会让用户以为收到了日本气象厅的速报)。 - **降级状态被自己的状态去重吃掉**:状态去重键原本只取 `status`,而"进入降级"与 "出错但正在重连"是同一个 `degraded`——于是那条最该被看见的"已降级为轮询"被上一条吃掉。 改成由调用方给一个稳定的**语义**键(`fallback`),既保留"不因子面计数反复重渲"的性质, 又不静默任何真的变化。 - **设置页的源列表在三个地方各写了一遍**(状态行 / 增量计数行 / 重试按钮),加一个源要改三处 ——漏掉任何一处就是"某个源坏了但界面上看不见",而"让失败可见"正是这个区块存在的全部理由。 收敛成 `SOURCE_ORDER` / `FEED_STAT_ORDER` / `STREAM_ORDER` 一处维护,并加结构性断言: **每个有校验约定的源都必须在状态行里出现**,否则测试失败。 - **`createStreamHandler` 忘了注入跨站防护时会静默失去防护**(默认值是 `() => false`)。 默认值改为真实实现——这条路由会让 Host 保持与 Wolfx 的长连接,不该有"忘了注入就等于不设防"的形态。 - **设置页的免责声明也硬编码了日本气象厅**("请务必以日本气象厅(気象庁)官方发布为准"): 与通知文案是同一类错误,只是位置在设置页里。改为列出各源的主管机构。 - **行政区划中文名提取的两处实错**(生成时踩到,回归守住):① GeoNames 的 `雲林` 候选里有 日文变体 `雲林県`,中文里不用这个字形;② `新北市` 的候选里有旧名 `臺灣省`, 按统一的"省 > 市"排序会把它挑成 `臺灣省`——一条记录的名字错成另一个行政区,是这类表最危险的错误。 修法:名称优先级**按层级分开**(省级把 `省/自治区` 排最高,市级反过来把 `省` 压到最低), 并直接排除日文字形候选。 ### Changed - 回归用例 **678 → 933 通过 / 0 失败**。其中 56 项由 `samples/cn/` 的**真实样本**驱动 (真实 EEW 多报、真实 50 条整表);Host 半边 72 项用注入的假 socket 与可推进的假时钟覆盖 (建连看门狗、心跳超时、退避重连、空闲断开、年龄闸门、md5 短路、SSE 首帧与断线补齐); Client 半边覆盖 SSE 生命周期、游标只前进、四类降级路径、灾种开关与链路开关的双向切换、 贯通主链、同事件不二次响铃、源文案与状态行覆盖、诊断快照的只读与容错; 行政区划表是**表驱动**的(全量结构不变量 + 已知城市坐标锚点 + 两处名称提取回归); 级联覆盖表规整 / 产物 / 越界拒绝,并有一条**真实事件端到端**:选「甘孜藏族自治州」+ 默认 100km 能命中实测的四川新龙县事件,而选成都(约 380km 外)不会。 - **补上「设置页能不能渲染」这条从未被守过的检查**:本项目的测试历来不渲染 UI(react 是空 stub), 0.5.0 加了级联这块新 UI 之后,一个拼错的 `h(...)` 会让整页白屏却没有任何断言会失败。 测试沙箱现在可以注入一个极简 React(`createElement` / `useState` / `useEffect` / `useRef`), 用它渲染一次设置页并断言三级区块、省份选项、半径预设与按钮都出现在渲染树里。 - 三个 README 同步更新(功能列表 + 使用步骤重新编号)。 - 三次**独立复验**(都不进 CI,改传输层 / 数据表前后手动跑):`scripts/check-wolfx-live.mjs` 用真实模块连真实 Wolfx(10/10);`scripts/check-cn-e2e.mjs` 把 Host 与 Client 用真实 HTTP 接起来(15/15,含真实载荷过 Client 契约、Last-Event-ID 补发、跨站 403); 行政区划表用 `scripts/build-cn-areas.mjs --check` 确认幂等,并对结构 / 名称 / 坐标量级 / 11 个已知城市做了独立核对。 ## [0.4.2] - 2026-09-14 对 **0.4.1 那批修复自身**的 review:三条独立审查通道(Host 与契约 / 解析与匹配 / 运行时与配置), 外加**用真实 live 数据复核契约**(JMA feed、P2PQuake `/history`、USGS 摘要 feed 各取一批过一遍 新的解析契约)。下面每一条都是 **0.4.1 引入的回归**,按类别列出。 ### Fixed — 0.4.1 引入的漏报 - **气象「降级后再次升级」被永久静默。** 0.4.1 把发布时刻从汇总型事件键里去掉之后,同一气象台 同一灾种共用一键:L4 播报 → 降级 L3(未达 L4,不进入事件去重)→ 再升回 L4 时记忆里的 `strength` 仍是 4,于是被判成"强度未升级的重复发布"。新增 `weakenEvent()`:降级电文把该键的强度**下调** (只在确实更低时下调,所以"关注地区未命中"这类 not-hit 没有副作用),再次升级即可重新播报。 - **坐标型近似归并会吞掉同源的两次不同地震。** 0.4.1 新增的「±2 分钟 + 50km」归并没有限定跨源: 同源相隔 40 秒、相距 7km 的两条消息(主震与紧邻余震)会被当成同一事件而静默。现在只做**跨源** 归并——EMSC ↔ USGS 才是它要归并的对象;同源的修订复用了同一个 id,走强度升级那条路。 - **官署名碼取不到时事件键退化成空。** `entry.id` 不含 6 位后缀(或 JMA 改文件名格式)时键会变成 `jma:summary:大雨:`,于是**所有气象台的同一灾种共键**,一次发布被当成另一次的重复。现在退回 `` 文本兜底,绝不留空。 - **震级上修的放行只在事件键逐字相同时成立**(Critical)。`isStrengthUpgrade` 只查**精确**事件键, 而 `isEventRepeat` 有「±2 分钟 + 50km」的近似归并 —— 源修订时挪坐标跨过 0.1° 桶、或让发震时刻 跨分钟,键就变了,于是 `isDuplicate(id) && !isStrengthUpgrade` 判成"重复",把 0.4.1 声称修好的 「M5.2 → M6.4 不再静默」重新变成漏报。现在抽出 `findPrevEvent()`:`isEventRepeat` 用它做**跨源** 近似,`isStrengthUpgrade` 用它做**含同源**的近似(后者只在消息 id 已重复时求值,语义安全)。 - **真实的 JMA 解除电文被当成一次新发布**。Head 的 `` 摘要项**没有 ``** (实测 live 电文只有 Name/Code/Condition),而解除判定是跨 Head 摘要 + Body 明细聚合的 `every()` —— 摘要里那条同名 Item 把"Status=解除"稀释掉。现在解除判定**只看 Body 副本** (Body 缺失时才退回全部),`levelOf` 的 `allInactive` 用同一口径。 - **Notice 解析的两处漏提升**(都是 0.4.1 新增的正则):① 省略标记只排除全角 `*`,**半角 `*` 会粘在最后一个地区名上** —— Notice 只列一个市町村时,那个唯一的 L4 城市不被提升,整条 L4 电文 不播报;② `[^〉]*` 会**跨段**吃到后面某段的 `〉`,把级别错配到别的地区(实测构造:西脇市被抬成 L4 而真 L4 的姫路市保持 L3,误报与漏报同时发生)。现在逐 token 去尾随标记 + 级别段限同行限长。 另外补上「[警戒レベル4相当情報の発表状況]」这种**级别写在栏目名里**的形态。 - **`stopped` 打断后游标仍推进到整批末条**。插件停用恰逢一轮轮询在飞时,被 `break` 跳过的条目 会随游标一起被跳过,下次回来补不回来(永久漏报)。现在只用"已经处理到的那条"推进,一条都没 处理就原地不动。 - **`timeIsImpossible(null)` 返回 true** → "时间**缺失**"被判成 value,整条 551 / USGS feature 丢弃。 缺失不是"客观不可能",存在性由 schema 判据负责。 ### Fixed — 0.4.1 引入的误报 - **「危険警報」的语义兜底把 Notice 的栏目名当了真**(用真实 live 电文复核时发现)。气象电文的 `` 在没有内容时写作「[危険警報・氾濫特別警報の発表状況]なし」——**栏目名本身 含"危険警報"**,于是每一条这类电文都被抬成 L4:实测同一发布的総合副本被判 level=4(文案与配色 夸大成"避难指示级"),而 R06 的大雨分灾种副本只有 L2。现在要求它出现在 `〈…危険警報` 条目里 (真正的发布形态是 `〈レベル4大雨危険警報〉姫路市…`)。 - **`pref===''` 一律放行让 552 的唯一收窄手段失效**。552/556 走 `cityLevel=false`,县级过滤是唯一 闸门;一律放行意味着**任何含一个未收录预报区名的海啸电文会提醒所有关注列表非空的用户** (实测:只关注沖縄県也会命中一个"謎の海域"的注意报)。现在改为:同一条消息里只要**有**区域能 归到县,归不到的条目不参与县级过滤;只有整条消息都归不到县时才放行(保留"不静默"的初衷)。 - **显式但不认识的 `codeType` 会退回按码位数猜**。`` 这类非行政区域 只要码长恰好 6/7 位,就会被当成府県予報区 / 市町村(幻影区域)。现在显式给出 codeType 但不认识 就判未知;位数兜底只服务于"根本没给 codeType"的裸 ``。 - **解析契约对 551 的观测点过严。** 0.4.1 要求每个 `points[]` 项都带 `scale`,于是**单个观测点** 缺字段就会让整条地震情报被判 schema 丢弃(并亮蓝点)。改为"存在则类型必须正确",缺失容忍—— 整条丢弃在预警产品里的代价是漏报。 - **契约对"只有注意報"的电文判 empty 的语义写错了**(实现没错,描述错):它不只包含天气预报, 还包含"只有注意報 / なし"的警报电文(L1〜L2 按设计既不播报也不进历史)。`SOURCE_CONTRACTS` 的 `empty` 说明已改准。 ### Fixed — 0.4.1 引入的可见性错误 - **WebSocket 的 `degraded` 状态不会恢复。** 主链抛错一次后侧边栏会永久停在"链路降级"—— 成功路径只在 `onopen` 复位。现在只要有一条消息处理成功就复位并上报回 `open`。 - **feed 源的 `degraded` 同样永久不恢复**(同一条病,另一处)。判断用的是 Host 的 `errors` / `detailDropped`,而它们是**进程内累计计数、永不归零** —— 一次瞬时失败之后每一轮都 判定为"有失败"。现在只看**增量**。 - **`stale`(中灰"数据已过期")从来没有被上报过**:0.4.1 加了状态与配色、README 也写了, 但 12b 把 Host 的 `stale` 折叠进了 `degraded`,`07-store` 里那个分支不可达。现在 「源在响应但数据是旧的」会真的显示成中灰。 - **状态上报的去重键含单调计数** → 每 15 秒一轮都判定为"变化",整页重渲(正是拆 `SourceStatusBlock` 想避免的事)。现在键只取状态,数字由设置页自己的 5 秒时钟读快照。 - **插件停用(abort)被上报成 `unreachable`** + 计一次失败 + 打一条失败日志;重载时旧 fiber 的 这次上报还会把新会话短暂染红。现在 `stopped` 时直接返回,不计不报。 - **打开设置页会创建 AudioContext。** `audioState()` 为了回答状态调用了 `ensureAudio()`, 于是"只是打开设置页"就会真的 new 一个音频上下文(控制台警告,也破坏了"只在用户手势里创建" 的设计)。改为未创建时按"未解锁"回答,不为查询而创建。 - **`disabled` 没有按设计的"空心"显示。** DESIGN 用"空心 vs 实心"区分"用户关掉了"与"数据旧了", 实现里两者都是实心灰。现在 disabled 用描边圆点。 - **插件重载后数据健康记录残留**:上一代留下的 `schema-error` 会让新会话一开始就显示蓝点。 现在与 `store.clearSources()` 一起清掉。 - **`restart()` 不重新绑定 `visibilitychange`**:stop 之后再 restart 的 socket 会永久失去 "恢复可见时重置 stale 计时"。 ### Fixed — 其它 - **`empty` 不清理 `schema-error`** → 蓝点(+ 重试按钮)会挂到下一次成功解析为止,而 JMA 的常态 恰恰是 empty(天气预报、只有注意報)。现在 empty 也视为"结构是好的",清掉异常并恢复 `open`。 - **JMA / NOAA 的 Atom 解析对拦截页返回 `[]`** → 0.4.1 新加的 `parseFeed` 抛错路径对这两个源 **完全没生效**(只有 USGS 会抛),"上游被拦成 HTML"与"上游没有新闻"在 UI 上同形。 现在:正常空 feed → `[]`(empty);HTML 拦截页 / 被截断(有 ``)→ 抛错。 - **迁移标记先落盘、Host 写入后失败** → 本地配置既没进 Host、又因为标记不再重试,最终被 Host 的 空值覆盖(永久且静默地丢配置)。现在等 `scope.mutate()` 成功之后再落标记(`pushCfgToHost` 返回 pending),失败就不写、下次再试。 - **历史详情里旧数据(无 `code`)的全球地震仍显示成「code 551」**:0.4.1 只覆盖了新写入的条目。 现在数值 code 走 `/^\d{3}$/` 分支,无 code 时按 `id` 前缀(`emsc:` / `usgs:` / `noaa:`)认来源。 - **`/feed` 与 `/areas` 的 `writeHead` / `end` 移出了 try** → 客户端中途断开时 `end()` 抛 `ERR_STREAM_WRITE_AFTER_END` 会直接抛回 webServer handler,catch 里的 `headersSent` 成了死代码。 现在整段在 try 内,且先序列化再发头。 ### Changed - 契约补上"**真实样本必须通过**"的回归断言:真实 551/551 速报/556/552、VXWW50/VXKO/VPWW53、 EMSC 帧、USGS 全部 feature、NOAA CAP 必须全部判 `ok` —— 防止以后调整字段清单时把正常数据 判成 schema(那等于新漏报)。 - **用真实 live 数据复核**(一次性、不重复请求):JMA feed 10 条电文全部 `ok`(0 schema / 0 value)、 P2PQuake `551` ×10 全部 `ok`、USGS `2.5_day` 38 条 feature 全部 `ok`;`metadata.generated` 距当时 1 分钟(stale 阈值 30 分钟成立)。 - **解析前剥离 XML 注释**:`block()` / `tag()` 只认标签不认注释,注释里写出 `` / `` / 「レベル4」就会被当成真内容(本轮回归样本自己的注释就曾让断言"通过得不对")。样本注释也已改写, 不再写会被正则命中的文本。 - 配置归一化加**字段漂移断言**(`normalizeCfg` 必须覆盖 `DEFAULT_CFG` 的每一个顶层与嵌套字段), 并给 `normalizeCfg` 加非对象输入兜底(不再因 `stored.watch` 抛错)。 - 代码卫生:删掉 `05-parser` 里因 `own()` 迁移而遗留的孤立注释,修正 `normalizeCfg` 的错行格式。 - 回归断言 631 → 679。 ### 已知残留(已写进 DESIGN 11.6,避免下一轮 review 当成新发现) - 同一气象台 + 同一灾种在 3 小时事件窗口内的**两次独立发布**(中间既无降级也无解除)会被归并成 一个事件,只提醒一次。 - 552 海啸的**部分区域解除**(解除电文只列一部分预报区)匹配不上发布时的事件键 → 只记历史、 不提示。这是为"避免任意海域的解除都被当成你关心的事件"(假安全)付的代价;改成"区域交集" 会引入相反方向的假安全,故保留严格匹配。 - 解除/取消事件的键推导依赖电文主文里的灾种词:真实解除报知(`VPNO50`「…を警報に切り替えました」) 认不出灾种,键退化成中性的「气象」,与发布的「大雨」不同 → 不提示(DESIGN 11.6 #3)。 - 灾种判定取"级别最高的那条 Kind":総合副本含多种灾种时,键里的灾种可能与真正命中的那段不一致。 - `alertedEvents`("真正播报过"的 24 小时记忆)是内存态:**页面刷新后**,Host 重启按回看窗口 重放的事件仍会再响一次(同一会话内已经挡住)。 ## [0.4.1] - 2026-09-14 本版是 **0.4.0 的 review 与修正**:四条独立的对抗性审查(Host 侧 / 解析与匹配 / 浏览器运行时 / 配置与工具链)加上真实 live 电文对照,改掉了若干"漏报"与"静默失效"这两个最危险的缺陷; 随后补齐**已有 5 个源的校验约定**,并产出面向 AI 的大陆网络排查文档。 ### Fixed — 漏报(该提醒却没提醒) - **JMA 総合副本里的「危険警報(=L4)」被读成 L3**,整条电文不播报。`levelOf()` 只读 Kind 名称与 标题 / 主文,而 R06 総合副本(VPWW53/54)的 Kind 只写灾种名(「大雨警報」),地区级级别只在 `` 里。实测 2026-09-14 兵庫県:`〈レベル4大雨危険警報〉姫路市…` —— 修复后 读 Notice 并纳入级别判定;同时把 `` 的匹配放宽(原先要求裸 `` 紧跟 ``, 带属性或只有 Name 的条目会被静默丢弃)。 - **USGS / EMSC 的修订版永远不会再提醒**。两者对同一场地震复用同一个 id(feature id / unid), 而 Host 按 entry id 去重 → 修订版连环缓冲都进不去;Client 的消息级去重又会二次挡下。 震级复核上修(M5.2 → M6.4)因此完全静默。修复:单级源的去重键改为 `id@updated`(版本没变仍只 处理一次),并让**同 id 的强度升级穿透消息级去重**(`isStrengthUpgrade`)。 - **详情电文抓取失败即永久放弃**。失败的 entry 原本也记为已见,而 `extra.xml` 是滚动 feed, 同一 id 不会再出现 —— 一次瞬时故障(超时 / 连接重置,大陆网络下是常态)就是该条警报的永久漏报。 改为**有界重试**(默认最多 3 次),超过上限才放弃并计入 `detailDropped`。 - **冷启动容差不分源**。原实现三源共用「进程启动后 2 分钟」:NOAA 的 PHEBAtom 是「当前生效事件 列表」、USGS 是 24 小时窗口摘要,条目年龄与"是否还是活的灾害"无关 —— 重启 Host 会让正在生效的 警报被当历史永久丢弃且不自愈。现在按源配置回看窗口(USGS 6 小时 / NOAA 24 小时,JMA 仍 2 分钟)。 - **JMA 解除电文与发布电文永远算不出同一个键**,叠加 `wasRecentlyAlerted` 只有 10 分钟窗口 (而解除常在数小时后),0.1.3 加入的解除链路**从未真正生效**。修复:汇总型事件键改为 「灾种 + 編集官署名コード」(不含发布时刻;同一次发布的副本还会跨分钟),取消匹配窗口与 `alertedEvents` 保留期对齐为 24 小时,解除时清掉事件键("解除后再次发布"能重新播报)。 - **552 海啸的事件键恒为空**,`cancelKeyOf` 退化成 `'tsunami'` —— 任意海域的解除都会被当成 "此前提醒过的事件",播出一条**假解除**(海啸域的假安全)。改用「预报区名集合」做事件键, 并去掉 `cancelKeyOf` 的 kind 兜底。 - **未识别归属县的区域被直接否决**(`regionInWatch`),与气象侧语义相反,也让"新设的观测点 / 未收录的预报区名"变成静默漏报。现在与气象侧一致:`pref` 为空时放行。 - **最大的一处**:全球点型地震命中后 severity 恒为 `info`。EMSC / USGS 的 `maxScale` 是 -1, 走震度路径会算成"信息蓝" —— 既显示不出严重性,又让**静默时段的红色穿透失效**(一场 M7.4 在 夜间被静默)。现在坐标型地震直接用解析层按震级判定的 severity。 - **坐标缺失的全球警报被彻底静默**(连历史都不记),与 DESIGN 3.1「不猜、如实说明」矛盾。 现在这类"无法判定"会进历史并写明原因。 ### Fixed — 误报(提醒了不该提醒的) - **同一条电文里不同市町村的级别被拉平**:闸门用电文最大值,于是一条含 L4 危険警報的电文会把 只到 L2 大雨注意報的西脇市也播成「警戒レベル4(避难指示级)」(实测 2026-09-14 兵庫県)。 现在 `region` 各自携带级别,闸门看命中地区自己的级别。 - **NOAA 的「Tsunami Information」也会响铃**(明确表示没有破坏性海啸的信息类电文)。 `tsunamiGrade` 此前只作用于日本 552;现在全球海啸共用同一把尺(Information=0 / Advisory・Watch=2 / Warning=3),并把 `alert` 的多 `` 全部纳入匹配(原先只看第一个圆)。 - **字符串类型的震级被静默丢成 null** → 震级闸门被整个跳过,低于阈值的地震照常响铃。 `firstNumber` 现在接受数字字符串(并保持 `Number('') !== 0` 的陷阱处理)。 ### Fixed — 静默失效(用户以为在被保护,实际链路已死) - **WebSocket 的 `onmessage` 用一个空 catch 包住了整条主链**:parse / match / handleAlert 里任何 确定性异常都被吞掉,表现为"socket 正常、状态常绿、零提醒"。现在只包 `JSON.parse`,主链异常 计入连续失败次数并上报 `degraded` 状态。 - **轮询链路不参与状态聚合**:只有 WebSocket 源上报状态,于是 JMA / USGS / NOAA 整体死掉时侧边栏 仍然是绿的。现在 feed 客户端按"只在变化时上报"送进 store,状态点与设置页都会反映。 - **EMSC 关掉了「久无数据」检测**,而半开连接恰恰没有 `onclose`、建连看门狗在 `onopen` 后又已撤销 —— 连接可以永久停在绿色上。改为 3 小时阈值(远大于正常推送间隔),并在页面从冻结中恢复时 重置计时(否则后台标签页恢复时会先拆掉一条健康连接,丢掉缓冲里的消息)。 - **Host 侧健康计数无人消费**:`/feed?stats=1` 存在但 Client 从不带它,设置页显示的"最近拉取" 只是**本地路由**的拉取时刻,上游被墙 / 被限流时照样显示"2 秒前"。现在 Client 每轮都带 `stats=1` 并把 Host 的失败数 / 放弃数 / 上游停更显示出来;三个轮询器也补上了 `onError` 日志。 - **`/feed` 无鉴权且有 markRead 副作用**:任意网页一个 `` 就能把三个源 的按需轮询永久压住(对気象庁是封 IP 风险)。现在拒绝 `sec-fetch-site: cross-site` 的请求。 - **未知 `source` 静默退回 jma**:Client 会把 jma 的原文交给 noaa 的解析器,解析必然失败却仍在 推进游标 —— 条目被永久跳过而表面正常。现在显式 400,Client 也校验回显的 `source`。 - **XML 正则的二次方回溯**:`([\s\S]*?)` 在没有闭合标签时对每个起始位置回溯到串尾, 2MB 上限下可冻结 Host 事件循环数十秒。每处提取前加了 `indexOf` 早退,响应体上限从 2MB 收到 512KB, 并在 `res.text()` 之前先看 `content-length`。 - **feed 客户端没有可取消的在途请求**:插件停用后回来的响应仍会 apply → 响铃 / 弹窗 / 写历史。 现在自持 AbortController,`stop()` 时中止。 - **`schema-error` / `stale` 以前不存在**:上游改版或停更时,用户看到的与"没有新闻"完全一样。 ### Added — 已有 5 个源的校验约定(0.4.1 交付物) - 新增解析契约 `{ ok:true, alert } | { ok:false, kind:'empty'|'schema'|'value', detail }` (DESIGN 4.5),并为五个源各自实现包装:P2PQuake 551/552/556、JMA 电文、EMSC、USGS、NOAA CAP。 三类失败的语义:`empty`(源正常但当前无相关数据,**不计失败**)/ `schema`(结构不符)/ `value`(结构对但值客观不可能)。 - 新增 `SOURCE_CONTRACTS`:每源的**必需字段与类型**(schema 判据)、**源时区**、 **新鲜度阈值**(stale 判据)、**empty 判据**,一处可读、可测试。 - 健康状态落地:解析失败进入 `schema-error`(蓝点,与"网络不可达"的红点分开)、同一失败原因只记 一次日志、解析恢复后自动回到正常、设置页提供**手动重试**;连接状态不再掩盖数据状态。 - **源时区**:P2PQuake 的时间是裸 JST(`2026/09/07 23:25:14`),此前原样显示 —— 大陆用户看到的是 一个差 1 小时且无标注的时间。现在解析层统一转成带 `+09:00` 的 ISO 8601,UI 按本地时区渲染 (旧历史数据按 JST 解释)。 - **上游停更检测**:JMA feed 的 ``、USGS 的 `metadata.generated` 超出阈值即标 stale ("源在响应但数据是旧的",与"没有新闻"是两件事)。 ### Added — 大陆网络故障的预想清单与面向 AI 的排查文档 - 新增 `TROUBLESHOOTING.zh.md`:**给 AI 读**的排查文档,按「触发条件 → 可执行的检查 → 结果对应的 处置」组织,全部是能直接跑的命令或能直接读到的状态;`README.zh.md` 加入口句。 - `DESIGN.md` 新增 11.5:13 类故障形态的可观测信号与分类、降级策略(哪些现在就能用、哪些是 0.5.0 才落地、哪些不可降级),以及"不要建议关闭证书校验 / 高频重试 / 尚未实现的开关"这类边界。 ### Changed - 汇总型 JMA 电文的事件键不再含发布时刻(副本跨分钟、解除晚数小时都会导致键错位); `気象警報・注意報(R06)` 纳入汇总族。`region` 增加 `level` 字段。 - 气象灾害的事件去重窗口 10 分钟 → 3 小时(持续过程中的更新不该反复响铃;升级仍会提醒)。 - 跨标签页认领的 TTL 5 秒 → 10 分钟(冻结后恢复的标签页此前会二次播报)。 - `applyCfg` 也走 `normalizeCfg`(写入与读取路径一致);`freshCfg` 由 `DEFAULT_CFG` 深拷贝派生 (手抄字段清单会在加字段时静默漂移);settings 订阅随 fiber 释放;「已迁移到 Host」标记落盘 (否则用户清空 Host 会被本地镜像静默恢复)。 - 生命周期收口:`store.clearSources()` 真正被调用(此前定义了没人用)、`activeClient` 随停用置空、 跨标签页通道的建立与关闭都在同一个 effect 内、切换数据源的 80ms 延时随组件卸载清理。 - 「清空记录」广播给其它标签页(否则它们的下一次 `addEvent` 会把整份记录写回磁盘)。 - 历史详情:可键盘操作(`role`/`tabIndex`/回车与空格);「类型」行按 `alert.code` 标注来源 (全球地震此前与日本地震共用 `kind: 'quake'`,会被标成「code 551」)。 - 设置页「源状态」拆成独立组件(原先每 5 秒重渲整个设置页,关注县多时那意味着几千个按钮重建); 提示音未解锁时显式提示(页面可见时只用页内 toast,未交互过的用户会一条声音都听不到)。 - `scripts/check-imports.mjs` 的未声明赋值检查去掉 `owner.has` 兜底(它会放过"给别处导出的名字 裸赋值"这一整类,正是 0.2.1 修过的那类 bug)。 - 回归断言 561 → 631;`samples/` 新增 1 条真实 live 电文的精简样本 (`jma-vpww53-hyogo-danger-20260914.xml`,锁定"危険警報被读成 L3"与"逐区级别被拉平"两处缺陷)。 ## [0.4.0] - 2026-09-12 ### Added - 全球化第一阶段:全球地震与海啸 - 新数据源三个:EMSC(WebSocket 实时全球地震,Client 直连)、USGS(GeoJSON 摘要,Host 单点轮询)、 NOAA tsunami.gov(CAP 1.2 海啸电文,Host 单点轮询) - `Alert` 增加坐标模型:`source` / `locator`('area' | 'point')/ `geo{lat,lon,depthKm}` / `magnitude` / `magType`——全球源只有震中坐标,没有都道府县 - 新增「全球关注点」(坐标 + 半径,上限 20 个),坐标型警报按 Haversine 距离判定命中 - 新增阈值 `thresholds.globalMagnitude`(默认 M4.5)。震级与日本的震度不可换算,因此是独立旋钮 - 设置页新增「全球关注点」区块(含「用当前位置」按钮)与全球震级阈值 - 多源连接状态聚合:任一链路异常即不显示为"一切正常",悬停详情逐个列出各源 - 跨源同事件归并:EMSC 与 USGS 对同一场地震按「发震时刻(分) + 震中(0.1°)」算出同一事件键 - 设置页「**发送测试全球警报(轮换场景)**」:全球源的地震不是随时都有,没法"等一条"验证链路。 按钮在本地构造 EMSC / USGS / NOAA 三种**源格式原文**,走真实解析器与匹配引擎,不发任何网络 请求。四个场景覆盖三个源,外加一条刻意落在半径之外的(演示半径的作用);结果按实际播报情况 如实提示,不写死"应看到弹窗" - 设置页「**全球源状态**」:EMSC 连接状态 + 三个轮询源各自的增量条数、失败次数与最近拉取时间。 此前用户看不出链路到底在不在工作——全球地震本来就不频繁,这一点尤其要紧 ### Fixed - **最高级「特別警報」漏报**:`levelOf()` 只认 R06 新格式的「レベルN」字样,旧格式(VPWW53「気象特別警報・ 警報・注意報」、「気象警報・注意報(H27)」)与 VPNO50 报知电文里的「大雨特別警報」「大雨警報」不带 级别数字,于是整条电文被判为"与预警无关"丢弃。实测 2026-09-07 東京都大雨特別警報的三条电文全部返回 null —— 插件对该事件完全静默(同刻的 R06 电文只有「その他注意報 / 暴風 / 波浪」,不含这条警报)。 修复:补 Kind 名称的语义映射(特別警報→5、危険警報→4、警報→3),并给「気象特別警報報知」加标题兜底。 - 同一次发布的多份格式副本会各播报一次:汇总型电文的事件键改用「灾种 + 发布时刻(分) + 府县码」内容指纹。 - 全球源在未配置关注点时的"未命中"不再写入历史(否则每天的全球地震会把历史列表刷屏)。 - `scripts/check-imports.mjs` 误报:对象字面量的键(`{ name: ... }`)被当成模块引用。 - **海啸被全球震级阈值误伤**:坐标匹配把 `thresholds.globalMagnitude` 也套在了 NOAA 海啸上, 于是把阈值调到 M7 的用户会连一场 M6.7 引发的海啸警报一起静默掉。海啸的严重性由它自己的等级 (警报 / 注意报 / 信息)决定,电文里的前震震级只是参考值——现在该阈值只作用于地震 - **测试消息连点两次不会播报第二次**:生产的事件键按「分钟 + 震中」归并(为了让同一场地震只响 一次),两次点击必然落在同一分钟,第二次会被判成"同一场地震的重复发布"而只记历史。 测试消息改用带毫秒的事件键,语义也成立:每次点击本来就是一次独立演示 ### Changed - Host 侧 `/feed` 支持 `?source=jma|usgs|noaa` 分派;三个源各有独立的环缓冲与游标(默认 jma,旧 Client 兼容) - `lib/poller.js` 支持注入 feed 解析器与单级源(USGS 一次请求即含全部字段,不拉详情) - 回归断言 439 → 561;`samples/` 新增 4 条真实特别警报电文与 4 个全球源样本 ## [0.3.3] - 2026-09-12 ### Added - WebSocket 建连看门狗:建连 15 秒仍无 `onopen` / `onclose` 时关闭该连接并按指数退避重连(`connectTimeoutMs` 可配置,置 0 关闭) ### Changed - README 三语的 Known limitations 增加一条:多个 DSH 页面共享同一个气象电文游标 - 回归断言 433 → 439 ## [0.3.2] - 2026-09-11 ### Fixed - `/areas` 未下发河川予報区域表 → 指定河川洪水予報跨县误报、市级收窄失效 - 河川区域表与市区町村表的假名写法不一致(8 例)→ 反查失败误报、市级比对漏报 - 刷新页面重放 Host 缓冲内的历史警报(游标未持久化) - Host 重启后 Client 游标不回退 → 气象电文静默失联 - 冷启动丢弃进程启动后发布的电文 - 外部请求无超时;响应体无大小上限 - WebSocket 半开连接不重连(状态点仍显示已连接) - 气象条目在历史详情里被标成「code 551」;震度4 命中显示为信息蓝;气象条目落灰色兜底 - 跨标签页配置同步只在设置页打开时生效 - 音频节点播完不 `disconnect()`;`loadCityTable()` 未纳入 effect(停用后仍会写配置) ### Changed - 游标:落盘持久化;首次用 `tail` 只对齐位置;Host 重启自愈;起点改为跨进程单调的时间戳 - `/feed`:`since` 缺失或非法(含空串与纯空白)按 `tail` 处理;单次上限 50 条并标记 `more` - `/areas`:`cache-control` 改为 `no-cache` - 显示:气象条目标注「JMA 电文」;severity 配色补 `yellow`;灾种配色补 `weather` - 回归断言 346 → 433 ## [0.3.1] - 2026-09-11 ### Added - 设置页「发送测试气象警报(轮换场景)」按钮:本地构造 5 种电文走完整链路,零网络请求 - 气象警报独立音色(下行三音 + triangle),设置页新增「试听气象音」 ### Fixed - 「静默提示」在 L4 已播报后仍显示「未达 L4,未播报」 - 提示里的地区名重复(「東京都東京都」) - 测试提示写死「应看到提示音与弹窗」,未按实际结果生成 - `check-imports.mjs` 的词法剥离误报 ### Changed - 回归断言 320 → 346 ## [0.3.0] - 2026-09-11 ### Added - 气象灾害预警(JMA 防災情報XML):Host 侧轮询 Atom feed 并两级抓取(feed 列表 → 详情电文),Client 侧解析 - 覆盖 `VXWW50` 土砂災害警戒情報、`VXKO` 指定河川洪水予報、`VPWW55/56/57` 気象警報・注意報 等 - 播报边界固定为警戒レベル4 以上(级别取自电文,不推算);L1〜L3 仍解析并入历史,L3 命中只在侧边栏提示 - 河川予報区域表 `lib/data/river-areas.js`(338 区域 / 884 市町村 / 1,567 区域×市町村对),以 12 位区域码为主键 - 零依赖 zip 读取器 `scripts/lib/zip.mjs` - 设置页「灾害类型」小节(地震 / 海啸 / 气象灾害三个开关) - 构建脚本 `pnpm build:areas` ### Changed - 外部请求只由 Host 发起;10 分钟内无增量读取则跳过轮询 - 冷启动不回放历史;详情拉取失败的 entry 不再重试 - `handleRaw` 拆出 `handleAlert`,两条数据源汇到同一处 - 静默时段的红色穿透说明补全(震度6弱以上的地震与 L4 以上的气象警报同样穿透) - 回归断言 236 → 320;`samples/` 新增 5 条気象庁官方样本电文 ## [0.2.2] - 2026-09-10 ### Fixed - 跨标签页配置同步失效(0.2.1 拆分的回归) - 音量草稿在机器级持久化下仍会丢改动 ### Changed - `scripts/check-imports.mjs` 增加「赋值给本文件未声明、也未 import 的名字」检查 - 回归断言 230 → 236 - README 三语与 DESIGN.md 同步修正过时描述 ## [0.2.1] - 2026-09-10 ### Changed - 客户端源码模块化:`client/client.js` 拆成 `client/src/` 下 15 个标准 ESM 模块,由 rollup 打包回 DSH 要求的单文件 bundle - 新增 `scripts/check-imports.mjs`(跨模块引用检查);构建脚本把循环依赖升级为构建失败 - `package.json` 增加 `build` / `check` / `test` 脚本与 rollup devDependency ## [0.2.0] - 2026-09-10 ### Added - 静默时段:按浏览器本地时间判定,开始晚于结束表示跨午夜;红色等级默认穿透、可关闭 - 机器级持久化:Host 注册 `quake-alert` settings namespace,配置存入机器的 `settings.yaml`;localStorage 保留为镜像与回退,首次启用时迁移已有本地配置 - 市区町村级匹配:`/areas` 提供 47 都道府县 / 1,917 个市町村,设置页按县搜索多选;观测点 addr 先归一到市町村再比对 - 解析 551 的 `pref` 简写(「京都」→「京都府」) - 运行期新增依赖 `@deepseek-ai/schemastery` ### Fixed - EEW 提醒颜色被降级(0.1.3 的回归)→ 恢复恒为红色 - 音量草稿丢失;「试听」按钮改用当前草稿值 - 侧边栏状态圆点补 `role="status"` 与 `aria-label` ### Changed - 回归断言 130 → 230 ## [0.1.3] - 2026-09-10 ### Added - 取消 / 解除提醒:此前提醒过的事件被取消 / 解除时补一条下行音 - 侧边栏连接状态指示(绿 / 黄 / 红 + 悬停详情) - 音效新增 `cancel` ### Fixed - 提醒里看不到震度(551 / 556 的 headline 补上震度) - 配置版本变化会清空用户配置 - 历史记录字段被污染会使设置页崩溃 - 前台同时弹 toast 与系统通知 - 取消消息静默(用户不知道已发出的警报作废) - `client.start()` 在 `ctx.effect` 之外,注册失败会留下没有清理器的连接 - 去重窗口在系统时间回拨时失效;toast 颜色按全日本最大震度 ### Changed - 音量滑块改为本地草稿 + 300ms 防抖落盘 - 设置页监听 `storage` 事件,其它标签页改配置后本页立即同步 - 回归断言 105 → 130 ## [0.1.2] - 2026-09-09 ### Fixed - EEW 未携带区域数据时的提示措辞错误 ### Changed - 清理死代码与未用字段(`unique()`、`prefOfName()`、Alert 的 `second`) - `tests/` 与 `samples/` 纳入仓库 - README 提供三语版本;`package.json` 的 `files` 补上 `CHANGELOG.md` 与两份 README 译文 ## [0.1.1] - 2026-09-09 ### Added - 事件级去重:同一地震的多次发布只响一次,震度升级时才再次提醒 - 跨标签页去重:`BroadcastChannel` 协商,同一条预警只由一个标签页播报 ### Fixed - 关注特定都道府县时海啸 / EEW 静默漏报:区域名归一改为「显式区域表 + 47 县名按长度降序前缀匹配 + 府県予報区名兜底」三层策略(修复前 16 个都道府县受影响) - localStorage 被污染导致插件整体加载失败:读入数据一律做类型校验 - 音量 0 被显示成 70% - 「最近预警」写盘从 20 条提升到 30 条 - 重连计数从「第 0 次」改为「第 1 次」;`restart()` 重置退避计数 - 「测试系统通知」在权限被拒 / 不支持时无反馈 - 震源情报的未命中原因不明确 - `notificationSupported()` 的构造函数判断(`typeof === 'function'`) - 删除死代码 `alert.kind !== 'other'` 与 Host 半边空的 `ctx.on('dispose')` - toast 的 z-index 由 2147483000 降为 2000 ### Changed - 回归测试 33 → 105 项 - README 修正 EEW 延迟说法(实测约 811ms,改为「数百毫秒级」) ## [0.1.0] - 2026-09-08 ### Added(M1 MVP:日本地震 / 海啸实时预警) - P2PQuake WebSocket 实时推送(`wss://api.p2pquake.net/v2/ws`) - 断线自动重连(指数退避 1s → 60s 封顶) - 解析器:code 551 / 556 / 552 → 统一 Alert;556 的 `pref` 简写归一为都道府县全称 - 匹配引擎:关注都道府县多选(默认全日本)× 分级阈值 × 消息 id 去重 - 通知:Web Audio 合成三种灾害音色 + 音量调节;前台 toast、后台系统通知 - 设置页(设置 → 灾害预警):关注地区、阈值、声音、系统通知、试听 / 测试、数据源切换 - 数据源切换:正式源与沙箱源(回放 2023 年历史,约 30 秒/条) - 历史记录:最近 30 条(含未达阈值的灰色条目),可展开、可清空 - 配置持久化:localStorage(`dsh.quakeAlert.v1`),带版本字段 - 回归测试 `tests/sync-test.cjs` ### Fixed - 设置页区块辅助函数只渲染首个子元素 - 深色主题下原生下拉框选项难以辨识 - WebSocket 推送的 `_id` 未被识别,历史记录恒为 1 条 - 配置写入时机不确定 - 最近预警记录只展示单条、无展开反馈