diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 3054d5c4..fa2a2f8f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -48,7 +48,7 @@ jobs:
token: ${{ secrets.GIST_TOKEN }}
gist_id: 862db28df04be7523476d10b61b128f2
gist_description: " iRingo: 🌤 WeatherKit β"
- file_path: dist/iRingo.WeatherKit.dev.plugin
+ file_path: dist/iRingo.WeatherKit.dev.lpx
- name: Deploy Quantumult X Snippet
uses: exuanbo/actions-deploy-gist@main
with:
diff --git a/.github/workflows/loon-validation.yml b/.github/workflows/loon-validation.yml
new file mode 100644
index 00000000..b90b99f7
--- /dev/null
+++ b/.github/workflows/loon-validation.yml
@@ -0,0 +1,43 @@
+name: Loon validation
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths: ["src/**", "tests/**", "scripts/**", "template/loon.handlebars", "arguments-builder.*.config.ts", "package*.json", "rollup.config.mjs", ".github/workflows/loon-validation.yml"]
+ push:
+ branches: ["sync/**"]
+
+permissions:
+ contents: read
+ packages: read
+
+jobs:
+ test-and-build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - name: Install locked dependencies
+ run: npm ci --ignore-scripts --no-audit --no-fund
+ env:
+ NODE_AUTH_TOKEN: ${{ github.token }}
+ - name: Run regressions
+ run: npm test
+ - name: Set fixture-only build version
+ run: npm pkg set version=0.0.0-test
+ - name: Build and verify native Loon artifacts
+ run: npm run build:loon
+ - uses: actions/upload-artifact@v4
+ with:
+ name: weatherkit-loon-validation
+ path: |
+ dist/iRingo.WeatherKit.lpx
+ dist/*.loon.bundle.js
+ dist/LICENSE.txt
+ if-no-files-found: error
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 067da420..b59dd541 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,16 +1,22 @@
### 🆕 New Features
- * none
+ * 为 Loon 增加有界供应商缓存、相同请求合并、失败冷却与可选运行统计,减少重复处理与额外查询。
### 🛠️ Bug Fixes
+ * 修复 iOS 27.3 Beta 主天气 App 的 WeatherKit v3 兼容:协商 Apple JSON,适配原生读取器需要的状态与降水结构,逐产品校验并保留未知数据。
+ * 修复内置空气质量算法的单位、无效值、零浓度、插值与分级边界,保留六项有效污染物浓度;修正原始 Apple QWeather CO 假标、加拿大 AQHI 换算与美标 InstantCast 精度。
+ * 修复来源失败隔离、预警网页错误与过期记录处理;等待原生 HTTP 回调后单次完成,保留既有 Apple 数据。
* 修复 WeatherKit Response 处理范围未同时受用户设置和 URL `dataSets` 约束的问题:现在只处理两者的交集,避免地图天气小组件只请求当前天气与空气质量时扩展处理其他数据集。
* 修复和风天气历史空气质量接口路径多余斜杠的问题,确保请求使用正确的 `/v7/historical/air` 路径。
### 🔣 Dependencies
+ * 增加 @noble/hashes@1.8.0,用于无 Node/WebCrypto 依赖的缓存键哈希。
* 将 FlatBuffer Root 处理器迁移至已发布的 `@nsnanocat/flatbuffer-root@1.0.0`,移除仓库内重复实现。
### ‼️ Breaking Changes
- * none
+ * Loon 原生条件与参数对象要求 3.5.1(983);v3 替换限定主天气 App,其他后台客户端保留原传输。
### 🔄 Other Changes
+ * Loon 使用独立的 request.loon.bundle.js、response.loon.bundle.js 和 scale.loon.bundle.js;其他客户端沿用原版 generic bundle。
+ * 开发版 Loon 文件统一为 .lpx,更新生成与部署引用;增加源码回归、实际压缩脚本和原生表达式检查。
* 从 BoxJs 设置中隐藏仅供插件参数使用的 `Storage` 选项,保留运行时配置行为。
* 补充数据集交集、用户设置过滤、FlatBuffer 根槽映射和 QWeather 接口路径的回归测试。
diff --git a/README.md b/README.md
index 0a7b8332..fc59ca74 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,5 @@
# iRingo: 🌤 WeatherKit
+
+Loon WeatherKit v3 主应用兼容、空气质量算法与有界供应商处理的实现和验证边界见 [Loon v3 兼容说明](docs/loon-v3-compatibility.md)。版本、插件名称和下载地址沿用本项目发布流程。
+
+2026-11-14 后续修复移除了附加请求缓存、在途合并、失败冷却、统计、额外地区推断和备用查询;保留原有设置和来源,新增默认 Auto 的预警正文语言偏好。失败保留、periodic、304/ETag、超时单位与未知预警到期值的回归及本次上游候选构建结果见 [后续修复与验证](docs/verified-followup-2026-11-14.md)。
diff --git a/arguments-builder.dev.config.ts b/arguments-builder.dev.config.ts
index 6f597c42..ccadc930 100644
--- a/arguments-builder.dev.config.ts
+++ b/arguments-builder.dev.config.ts
@@ -1,3 +1,4 @@
+// User-facing weather parameters; retain the original defaults.
import { defineConfig } from "@iringo/arguments-builder";
import { airQualityFull, api, calculateFull, dataSetsFull, logLevel, nextHourFull, storage, weatherAlerts, weatherFull } from "./arguments-builder.full.config";
@@ -8,7 +9,7 @@ export default defineConfig({
template: "./template/surge.dev.handlebars",
},
loon: {
- path: "./dist/iRingo.WeatherKit.dev.plugin",
+ path: "./dist/iRingo.WeatherKit.dev.lpx",
template: "./template/loon.dev.handlebars",
},
customItems: [
diff --git a/arguments-builder.full.config.ts b/arguments-builder.full.config.ts
index 0b0ffb98..5cd357f9 100644
--- a/arguments-builder.full.config.ts
+++ b/arguments-builder.full.config.ts
@@ -1,3 +1,4 @@
+// WeatherKit user settings; preserve original parameters.
import { defineConfig } from "@iringo/arguments-builder";
type Arg = {
@@ -127,7 +128,20 @@ const weatherAlertsProvider: Arg = {
description: "使用选定的数据源处理天气预警数据。",
};
-export const weatherAlerts = [weatherAlertsProvider];
+const weatherAlertsLanguage: Arg = {
+ key: "WeatherAlerts.Language",
+ name: "[天气预警] 正文语言偏好",
+ defaultValue: "Auto",
+ type: "string",
+ options: [
+ { key: "Auto", label: "自动(沿用请求语言)" },
+ { key: "zh-Hans", label: "简体中文(来源支持时)" },
+ { key: "en", label: "English(来源支持时)" },
+ ],
+ description: "仅向所选来源请求预警语言,不使用第三方翻译。来源不支持或返回其他语言时保留原文、签发单位与出处;Apple 原始标题和页面标签可能仍随系统语言。",
+};
+
+export const weatherAlerts = [weatherAlertsProvider, weatherAlertsLanguage];
const nextHourProvider: Arg = {
key: "NextHour.Provider",
diff --git a/arguments-builder.release.config.ts b/arguments-builder.release.config.ts
index ff21cd7e..98302bf2 100644
--- a/arguments-builder.release.config.ts
+++ b/arguments-builder.release.config.ts
@@ -1,3 +1,4 @@
+// User-facing weather parameters; retain the original defaults.
import { defineConfig } from "@iringo/arguments-builder";
import { airQuality, api, calculate, dataSets, logLevel, nextHour, output, storage, weather, weatherAlerts } from "./arguments-builder.full.config";
diff --git a/docs/loon-v3-compatibility.md b/docs/loon-v3-compatibility.md
new file mode 100644
index 00000000..b5f7d3af
--- /dev/null
+++ b/docs/loon-v3-compatibility.md
@@ -0,0 +1,37 @@
+# Loon WeatherKit v3 兼容与供应商处理边界
+
+2026-11-05 核对 [Loon Rewrite](https://nsloon.app/docs/Rewrite/rewrite_v2/)、[Script](https://nsloon.app/docs/Script/script_v2/)、[Script API](https://nsloon.app/docs/Script/script_api/) 与[插件](https://nsloon.app/docs/Plugin/);2026-11-14 重新核对 Script API 与插件参数约定。最低 Loon 版本保持 3.5.1(983)。本文描述源码与自动验证范围,不包含个人订阅、设备授权或原始位置记录;最新固定基准及检查见 [后续修复记录](verified-followup-2026-11-14.md)。
+
+## v3 主应用传输
+
+原生 Rewrite 只为 HTTPS Apple v3 天气 GET 请求、主天气 App 的 iOS/macOS User-Agent 前缀协商 Apple JSON。响应还要求 200 和 application/json,使用必要 URL Guard、一个 script Action 及原生参数对象。其余后台客户端与未识别的 WK3 二进制保留 Apple 原传输;不使用 WK2 schema 解码 WK3。
+
+Apple 的 WK3 FlatBuffer JSON 表示与主应用 JSON 读取器不同。后者要求字符串天气状态、标量降水和每日降水总量;原生 Mac 日志曾明确报告数组类型不匹配及缺字段,并清缓存走 CloudKit 回退。现将已知原始 Apple 产品和供应商结果适配为该读取器所需表示,也处理未选择数据替换的已知分时产品;未知产品和字段保留。独立 condition 记录数组(包括空数组)不当作字符串概述处理。
+
+供应商整数、时间、Unicode 与有限数值按已知字段校验;异常按产品回退。没有改动时保留原正文、空白与缓存验证头;改写后移除旧 bodyBytes、长度和校验标记,设置 no-store。JSON 最多 1,048,576 UTF-16 码元及 4 MiB UTF-8,每产品累计数组元素 8192、深度 64、节点 100000。它们是脚本工作边界,无法限制宿主已接收的 HTTP 缓冲。
+
+## 空气质量与请求开销
+
+11 种内置算法与 None 保留六项有效物理浓度,按模型定义参与指数计算。修复空值、负数、非有限值、零浓度、单位、分级与插值边界;加拿大 AQHI 使用正确 ppb 换算及最近整数取整,美标 InstantCast 保留 WAQI 臭氧分段并按浓度精度截断。研究模型和当前浓度换算不等于官方日均发布值,不新增后台采样。
+
+原始 Apple QWeather 的 CO 存在 mg 数值配 µg 标签的历史格式,只有该入口修复假标;已归一化供应商结果依据明确单位处理,不按浓度大小猜测。已有有效浓度优先,只补缺项;完整六项数据没有补充请求。模型未定义的污染物不填 index=-1,物理浓度仍保留。先匹配原版指数目标列表再应用算法,默认 HJ6332012。默认保留已有昨日对比,缺失或明确开启重算时使用所选的独立昨日来源;污染物补齐不绕过指数目标。
+
+2026-11-14 移除附加的供应商请求缓存、在途合并、队列/发起预算、失败冷却、摘要统计、额外地区推断和备用查询。旧保存值不能重新激活这些行为。无状态 ProviderTransport 只校验当前请求的状态、格式及正文边界;原有位置网格条件请求和昨日对比缓存保留。来源失败或无有效实体的 304 不会制造可用数据,单产品错误不阻止其他健康产品;正常单请求超时仍保留,所有已发请求等待原生回调后只完成一次。
+
+原有 12 项参数和默认值保持,新增第 13 项 WeatherAlerts.Language,默认 Auto。它只向当前预警来源请求中文或英文,不调用外部翻译;来源不支持时保留原文、签发单位和出处。显式偏好使用 no-store 详情响应及 und 消息语言标记,Auto 保留原请求语言和旧行为,不改变天气/空气质量等其他产品语言。未知到期值使用安全的 UTC 午夜占位字符串,真实时间原样保留,不把未知值制造成事件结束时间。
+
+原有按需解码、实际修改产品编码和预警延迟复制保留。完整六项污染物与原生功能列表无需额外供应商工作;普通空气质量标尺通过原生 Rewrite 处理,HK/CN 自定义图例使用独立小脚本。这里的处理边界不构成 CPU、内存或耗电改善比例的实测结论。
+
+## 构建与验证
+
+Loon 使用 request.loon.bundle.js、response.loon.bundle.js、scale.loon.bundle.js;原版其他客户端的 generic bundle 文件名和模板继续保留。版本由原版发布流程决定,本改动不指定 Fork 版本或下载地址。CI 中的 0.0.0-test 仅用于生成验证夹具,不是可订阅发行版本。
+
+构建同时生成 dist/LICENSE.txt,保留项目 Apache-2.0。已删除的缓存/离线地理实现不再打入运行文件,其旧出处记录保留为历史;原 LICENSE、贡献者与源码中引用工作的署名不变。
+
+当前 `npm test` 通过 430 项回归,覆盖算法、供应商失败、保留的条件缓存、WK2 编解码、WK3 开发/发布入口、预警语言及未知时间。`npm run build:loon` 编译本 PR 的原生入口并生成 Loon 文件,52 个实际编译脚本 VM 场景与 15 个表达式/路由/jq 检查通过;原 12 项默认值与新增 Auto 同时核对。这些检查不等于 Loon 真实配置解析器或设备耗电测量。
+
+Native macOS observations on 2026-11-06 and 07 covered all eleven algorithms and None, six physical concentrations, the original target-standard restriction, and disabled datasets. Warning banners and four full warning details were visible. The system-log snapshot contained no decoding errors, invalid weather or CloudKit fallback. The actual candidate runtime files were byte-identical to the three artifacts from commit 4d1f127. See [Mac native observations](mac-native-validation.md) for the exact scope and limitations.
+
+The earlier locked Node v22.23.4 CI passed 335 regressions and 40 compiled VM scenarios on the older artifact. Current local validation uses Node v22.23.5 and the separate counts above; it does not turn older device evidence into validation of this rebuilt artifact. The 0.0.0-test version is only a build fixture. Valid dedicated QWeather credentials, the final all-WeatherKit source combination, background/watch customization, long-running VPN operation and device battery or memory percentages remain outside comprehensive native acceptance.
+
+Original behavior and default settings are documented in [the original behavior audit](original-behavior-audit.md).
diff --git a/docs/mac-native-validation.md b/docs/mac-native-validation.md
new file mode 100644
index 00000000..98000997
--- /dev/null
+++ b/docs/mac-native-validation.md
@@ -0,0 +1,32 @@
+# Mac native observations
+
+Observed on 2026-11-06 and 07, macOS 27.3 (26C5035r), Loon for Mac 0.5.0 (1011). The installed candidate was fetched from a fixed GitHub commit; exactly one WeatherKit installation was enabled. Values below describe public test-city previews at the time of observation.
+
+| City | Algorithm | Displayed index/category | CO (ug/m3) | Concentrations |
+| --- | --- | --- | --- | --- |
+| Binhai | EU_EAQI | 不佳 | 400 | Six present |
+| Wuxi | UBA | 普通 | 400 | Six present |
+| Ningbo | WAQI_InstantCast_US | 107 | 1100 | Six present |
+| Guiyang | WAQI_InstantCast_CN | 39 | 200 | Six present |
+| Xianyang | WAQI_InstantCast_CN_25_DRAFT | 107 | 1200 | Six present |
+| Mianyang | CA_AQHI | 5 | 700 | Six present |
+| Foshan | HK_AQHI | 4 | 1000 | Six present |
+| Zhuzhou | AQHI_Multi_CN | 2 | 500 | Six present |
+| Huanggang | AQHI_Multi_CN_HK | 3 | 400 | Six present |
+| Luoyang | CN_DEATH_AQHI | 3 | 400 | Six present |
+| Wuhu | CN_DEATH_HK_AQHI | 3 | 800 | Six present |
+| Yantai | None | 17 | 400 | Six present |
+
+Binhai showed warning banners. Native Safari displayed four full warning descriptions, issuers and source links. Manchester retained UK DAQI 3, BreezoMeter and the original comparison while CA_AQHI was selected, with no provider requests. With all replacement datasets disabled, Laoshan retained Apple current weather, hourly and ten-day forecasts, AQI 22 and two original warnings (marine strong wind and cold wave).
+
+The 129411-line system-log snapshot contained zero WeatherAlert/other decoding errors, invalidWeather records or CloudKit fallback. The last script snapshot recorded 72 executions, 72 finished and zero exceptions. These are snapshots at different times, not a long-running stability guarantee.
+
+[Source CI 4d1f127](https://github.com/hhhoratioxu/WeatherKit/actions/runs/42234270265) performed a locked Node v22.23.4 install and passed 335 regressions, 40 compiled scenarios, 15 expression checks and twelve original defaults. The downloaded five-file ZIP SHA-256 was 2c564605fd9ee25762a2bb9effefa17ed77d82d40c018c8bec0990ddfb51cd67. Its three runtime files were byte-identical to the actual Mac candidate:
+
+- request: d8e75cb50be818668e26fa7c08b66c471d5fac2b8d63db73a1e5eb6eed333bfd
+- response: 1408002e7fa6cdded1e5aef824c1e6daeb88367e19b9651e2ef0d1b89db544a0
+- scale: 5896bada8857312ebf5a70b7519452d21c8fbd6131e0df882d8d5f83b813016f
+
+## Limits
+
+Valid dedicated QWeather credentials were not configured, so authentication-failure fallback is not reported as successful authorized API acceptance. The final combination with every source set to WeatherKit was not independently repeated on this candidate. Widgets, watch/background custom replacement, long-running VPN operation and device battery/memory percentages were not comprehensively accepted. No original device bodies, authentication headers, precise coordinates or personal configuration are included.
diff --git a/docs/original-behavior-audit.md b/docs/original-behavior-audit.md
new file mode 100644
index 00000000..b3bd8ed0
--- /dev/null
+++ b/docs/original-behavior-audit.md
@@ -0,0 +1,58 @@
+# 原版 WeatherKit 的用途、参数与 Fork 行为核对
+
+历史记录说明:下文保存 2026-11-05 的行为核对。2026-11-14 已移除其中所述附加缓存、合并、冷却、统计、地区回退与备用查询;保留原有 12 项默认值,新增默认 Auto 的预警正文语言选项。当前处理与验证以 [后续修复](verified-followup-2026-11-14.md) 为准,旧描述不代表这些已移除功能仍可启用。
+
+核对日期:2026-11-05。基线为 NSRingo/WeatherKit 的 c40e8f8、v3.3.2 正式 Loon 文件及[官方配置指南](https://NSRingo.github.io/guide/Weather/weather-kit)。下载的原版插件为 5740 字节,SHA-256 为 e994f11d8eb987b81bda0c682e95287d4ee166cabf8a5563d2fa8aa4bdfe3e24。
+
+## 原版具体做什么
+
+插件运行在代理客户端中,处理 Apple 天气接口的请求与响应。它先补齐可用数据类型,再按用户配置选取要处理的产品、来源和指数标准;信息来自 Apple 或所选的第三方供应商。
+
+| 原版功能 | 实际处理 | 默认与约束 |
+| --- | --- | --- |
+| 解锁天气数据类型 | 合并 availability 能力列表,保留 Apple 新增能力 | 不凭空制造观测值;请求未选中的数据不进行来源替换 |
+| 天气数据 | 用彩云或和风合并实况、小时和每日预报,按相同时间匹配 | 默认彩云,仅 CN;WeatherKit 选项保留 Apple 来源 |
+| 分钟降水 | Apple 缺少该产品时才尝试补充 | 默认彩云;已有产品保留,无降水时面板可能隐藏 |
+| 天气预警 | 按来源取得并合并预警、构造详情 | 默认和风网页;WeatherKit 选项保留原预警 |
+| 污染物浓度 | 从所选来源补充物理浓度 | 默认彩云;浓度补充与指数替换是两项不同处理 |
+| 空气指数 | 只针对选中的原始标准,使用指定来源或算法生成指数 | 默认替换 HJ6332012,来源 Calculate、算法 EU_EAQI |
+| 昨日对比 | 已有对比按开关保留,缺失或明确要求重算时才取昨日来源 | 默认不因今日改动而覆盖已有对比;昨日来源独立选择 |
+
+所有算法不一定使用全部六种污染物。单位转换、分项指数和总体指数各有含义,不能为了填满界面而虚构不适用的分项。None 表示不进行本地指数转换。
+
+## 插件各段是什么意思
+
+- `[Argument]`:正式版原有 12 项参数,选择数据集、天气/预警/分钟降水/污染物来源、内置算法、API 配置、储存优先级和日志。完整高级参数由 BoxJs 提供;本 Fork 另外提供缓存、诊断、地区回退及明确开启的备用查询参数。
+- `[Rule]`:原版包含分析请求阻断及 Apple QUIC 限制,配合天气 HTTPS 处理;本轮不扩大这些规则。
+- `[Script]`:availability 补充能力,airQualityScale 处理标尺路径及 HK/CN 自定义图例,weatherAlerts 提供详情,weather Response 处理选中的数据产品。
+- `[MITM]`:正式脚本版只声明 weatherkit.apple.com,客户端用已有证书解密相关 HTTPS 流量。
+
+Storage=Argument 表示模块参数优先,未提供项可从 BoxJs 读取;PersistentStore 只使用持久设置;database 使用作者默认设置。日志默认 WARN,诊断日志不应成为正常数据处理所必需。
+
+## 必须保留的设置含义
+
+1. 天气替换范围与空气指数替换目标不同。天气范围使用地区代码;指数目标使用 HJ6332012、EPA_NowCast、EU.EAQI 等标准名称。
+2. 内置算法只决定“怎么算”,不能绕过指数目标决定“哪些原始标准要改”。默认转换中国标准,不等于强制转换全球全部标准。
+3. `AirQuality.Current.Index.Replace=[]` 应停用指数替换;补齐污染物也不能使外部来源的指数渗入结果。Apple 本来没有可用指数、且用户允许指数替换时,可用补充数据补出指数。
+4. `Comparison.ReplaceWhenCurrentChange=false` 应保留已有 Apple 对比。开启或对比缺失时,沿用用户所选昨日来源;来源与今日显示算法不同时,原版会按该来源的今日和昨日数据进行对比,不能一律禁止查询。
+5. 填写另一来源 Token 可能是用于预警或昨日数据,不能据此自动改变当前天气来源。备用查询属于 Fork 扩展,需要单独明确开启,默认关闭。
+6. 选择 WeatherKit 或取消数据集,应保留相应 Apple 数据;原生 JSON 读取器所需的格式适配与第三方数据替换分别处理。
+
+指南把 Weather.Replace 描述为正则,当前原版源码实际先转数组再用 includes 匹配。本轮保留现有地区列表匹配,不额外引入新的匹配规则。分钟降水和历史空气接口存在供应商权限限制,插件不提供购买或授权能力;通知仍受系统与天气关注城市设置控制。
+
+## 本轮发现与修正
+
+| Fork 先前偏差 | 本轮修正 |
+| --- | --- |
+| 非空目标列表加上算法选择,被当成全局强制重算 | 恢复目标标准匹配;所有算法仍可在显式选中的标准上使用 |
+| 部分污染物补齐也可能触发目标外指数替换 | 有效 Apple 指数与浓度分别保留;空目标列表明确停用指数替换 |
+| 计算算法触发后主动清空已有昨日对比,并只允许同算法历史 | 恢复原版对比开关及独立来源,失败保留安全回退 |
+| 仅配置另一来源凭据就允许自动备用查询 | 新的 ProviderFallback.Enabled 默认 false,明确开启且满足原数据不可用等条件后才查询 |
+
+保留已验证的 CO 假标修复、数值与单位校验、未知字段保留、逐产品失败回退、请求合并及有界缓存。原版标尺、天气代码转换、预警合并、分钟降水类和 availability 合并类与上游文件保持同字节。
+
+## 兼容与证据边界
+
+v2 原路径继续保留。iOS 27.3 的 WK3 JSON 协商目前限主天气 App;未知后台、组件和手表客户端保留 Apple 传输。此前天气可显示不代表所有消费者已获得自定义替换,这是一项兼容范围限制,不能作为永久删减原功能来描述。
+
+本轮新增以原版发布默认值为基线的测试,分别检查目标名单、None、部分补齐、昨日对比开关、独立来源和备用查询的明确同意;原有算法测试改为明确选择要转换的标准。自动测试和编译产物检查与真实设备验证分别记录。See [Mac native observations](mac-native-validation.md) for the actual native coverage and remaining limits.
diff --git a/docs/provider-regions-source.json b/docs/provider-regions-source.json
new file mode 100644
index 00000000..74742192
--- /dev/null
+++ b/docs/provider-regions-source.json
@@ -0,0 +1,26 @@
+{
+ "url": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson",
+ "sourceSHA256": "239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255",
+ "license": "Public domain",
+ "licenseURL": "https://www.naturalearthdata.com/about/terms-of-use/",
+ "providerCodes": [
+ "CN",
+ "HK",
+ "TW",
+ "MO"
+ ],
+ "simplificationToleranceDegrees": {
+ "CN": 0.01,
+ "HK": 0.001,
+ "MO": 0.001,
+ "TW": 0.001
+ },
+ "boundaryExclusionDegrees": {
+ "CN": 0.05,
+ "HK": 0.01,
+ "MO": 0.01,
+ "TW": 0.01
+ },
+ "quantizationDegrees": 1e-05,
+ "purpose": "Conservative weather-provider routing hints, not authoritative administrative boundaries"
+}
diff --git a/docs/verified-followup-2026-11-14.json b/docs/verified-followup-2026-11-14.json
new file mode 100644
index 00000000..36871bd4
--- /dev/null
+++ b/docs/verified-followup-2026-11-14.json
@@ -0,0 +1,120 @@
+{
+ "reportDate": "2026-11-14",
+ "timezone": "Asia/Taipei",
+ "basePRHead": "355b5f5dad9d01cb13167949f8334a489b301811",
+ "baseTarget": "c40e8f8a13c8304a7ef500cb74acc5948af14b3f",
+ "priorCommitCount": 7,
+ "preserveExistingHistoryAndAttribution": true,
+ "sharedImplementationReference": "7be5036acd8ab8b3a231471d3be695f08f52c828",
+ "sharedSourceFileCount": 46,
+ "sharedSourceSHA256": {
+ "src/Hono.js": "12dd8267f48c83b43c9297bb7ae659b60740567bf49edf96ebce08acdb4e1f9e",
+ "src/response.js": "c7f93ace394dc39e46bcc9abf05f565337f1c2f19c4f45d44fe01b5d8dcfd3de",
+ "src/request.js": "e28ada4797e3b38c99886ff9e60c2fcc584a43a49fc2cea6f6ad8f5d915cc8eb",
+ "src/types.d.ts": "804f7d3f30ca3afec7d9ff1533bc3b5ca8397c39bb0c8e59b603d659e5689047",
+ "src/response.dev.js": "fd4d8e0723317f3a6c59e84f1178d774a3e8cd886bca6a627d28a5097027c986",
+ "src/request.dev.js": "28b0d68accfcdf264dc3995e03a488d3e8132b32d028548c0bc106841b4e3808",
+ "src/loon/response.js": "dcf952fe6210610974f653daf56db951cc3d51e3870a7cff22418f0dead5ca9f",
+ "src/loon/request.js": "71f249a4e4d8427554ee7d5c8b0609059c20d76a2271fabeb7d0aa4cb7afc6b9",
+ "src/loon/scale.js": "fa1ed608f977081f0cf8e7f10df81b389d2c92cb0a50b1104400c6e57269ff62",
+ "src/function/providerRecovery.mjs": "21e8c1261a7c5ca37119a5d56f8de2fccae3e47751145257beaec9bc474b6535",
+ "src/function/weatherMemoryLimits.mjs": "8fb9100b9f88b157409fc4e62788c8fea0ec5c7b7d41462a96c634a1ef69f549",
+ "src/function/planWeatherProducts.mjs": "80aae3ecefb8a8b43b9961e42a73acb6f98eb446969456b033272e0ca6387324",
+ "src/function/setENV.mjs": "751da08490a0769dcb3aca14803f160fd56c5edca92a6457a237c487a870872e",
+ "src/function/mergeForecastCopy.mjs": "9b6676ef3923e541684c1c1bc889f870f5660e96a166e70ca7e34750867d6db1",
+ "src/function/fetchProvider.mjs": "ba178b37c087906c118a5389d9cd17b78d79cf0eeb5afd72b473a4564ce73c88",
+ "src/function/rewriteWeatherResponse.mjs": "9686d5380f5e6c94858411a20fc9a99607d9fabc2a9b6b1d91dfccf5de79a0ca",
+ "src/function/mergeWeatherAlertsProduct.mjs": "05ea8ae8446c297e4a767444e6d0310f6a2f14333592bb178fd13c8da985ede5",
+ "src/function/parseWeatherKitURL.mjs": "05cb7249a98ab416bd10b84e4c3a11bae50162be5fb0aa93f2cd44175a3369e1",
+ "src/function/utf8Length.mjs": "7d07252be58a2478b4c9d1f421db3037da3896d47cafb5d2a6a466a4d7dbcd81",
+ "src/function/getWeatherAlerts.mjs": "9668cd9f7a3df5c1cefb9b51804eaf2654251283ba3718f47d3393c3e76cc95b",
+ "src/function/database.mjs": "2acf7330c4e6c9a922ad60b2b1e872f0742c310a1328a070207bec4b228a6c0a",
+ "src/function/providerNameToLogo.mjs": "a4df024193c390b38b247bf2c4e143743e4e4baa1374b1b6c507cf4822a32a73",
+ "src/function/weatherAlertExpiry.mjs": "b97e61879f754462e5612cdfcccfeba4126aa6032279b68e9bba169f7062548f",
+ "src/function/nativeProviderRuntime.mjs": "91bd17c49dc6f11f8885140f894e5bdb95ad5ebb3df593a6f893e3e92e43883c",
+ "src/function/mergeWeatherKitAvailability.mjs": "237189d9d7b421fd3d72db246c836dd93ef3975e6e36d51d77f767e2b6e3e823",
+ "src/function/ensureTextEncoder.mjs": "6b4525858d09bdb1a734c99936350f8bf35ceb25b6d88e76ba0298e499841b05",
+ "src/function/createProviderEnvironment.mjs": "478441be3d71df17f79e06d55976b1155cbb03ca0f3a0812a6a1b8e07982ef1d",
+ "src/function/weatherAlertLanguage.mjs": "db4ef4178e6f5a44e549f60d408c0e3485098e9d609057c01c9e996ff54ae9e7",
+ "src/class/HonoWorkerAdapter.mjs": "e96b6d23766ea394361030c470559d33ed52a5699c59d17c930311546c654dfa",
+ "src/class/ForecastNextHour.mjs": "8b117edf0b365603690ea375122d4520b56dc47969a8dd6778131d2ac2327314",
+ "src/class/AirQuality.mjs": "c5ebd8eab4dc3ed76585151d1241e256950e674dcf80634dc234c517e1aa059c",
+ "src/class/AirQualityScale.mjs": "c96c2570b9a7984d64688d6c63a4a3f8d6e38d0bdba2878f013367c11f433007",
+ "src/class/ColorfulClouds.mjs": "017e08f4bebf98f6003127fc8f05e5d4b1deb8b3eeebb266095584dc2a853589",
+ "src/class/QWeather.mjs": "41730c349364ec75c2db7a3256b9f45135edf3447d2bca1fa2d6e8d4d750ec7f",
+ "src/class/WAQI.mjs": "27566784354a4b1af3468444daae7cbdfa44f07874c5dbece1a32d6b22ae6ad1",
+ "src/class/SimplePrecisionMath.mjs": "5a95761beaa6423f0925ad67d2dba9e5eb08ee03564f739b323686d22478284e",
+ "src/class/WeatherKit2.mjs": "a436cf8a9f636881593dd5f771fa20796a16fa7f93edb67143687c5e4bd4a4ed",
+ "src/class/Weather.mjs": "a5ce014260bca6a1b79ecd38466a4baaac089d05c13bcdbffff149e45ac28153",
+ "src/class/MatchEnum.mjs": "a780ead4fe371a7e4590affa3bffd52bb07becc3bcb0e2f565d7320b94258ff1",
+ "src/class/ProviderTransport.mjs": "b501f0ce51874f60cace172a75561d65f95262dc8b6c8a37d6923eaeee31cdcd",
+ "src/class/WeatherAlerts.mjs": "1051a31fc95b3465150cc353f9a056c10eddee00b02e33c0c933902480d6c978",
+ "src/process/Request.mjs": "76bc95fd7badcf22ad47d9a2b9e16a3a6bc6d84b7341303efbcb1b589805bc86",
+ "src/process/Response.dev.mjs": "3ad6061f8879a71b5ab82ce3f78775a7293987ecfb60d8cb1cdd767ea612fa19",
+ "src/process/Request.dev.mjs": "be0bad21a7bbd7183fd849671a1b6a371f33f56788e3e37a5e29813c5fc1a5f4",
+ "src/process/ResponseJSONV3.mjs": "2ea2a5340018d1221e056b6e87ce28daffdf6350f01ab1b506b861e2d49328a5",
+ "src/process/Response.mjs": "3cfcb0e5a211b2366fb4cc0acf5b532b9c4ba3295c6a1104f047621c24c767d2"
+ },
+ "validation": {
+ "tests": 430,
+ "pass": 430,
+ "fail": 0,
+ "skipped": 0,
+ "compiledLoonPass": 52,
+ "compiledLoonFail": 0,
+ "nativeExpressionAndJQPass": 15,
+ "nativeExpressionAndJQFail": 0,
+ "originalArgumentDefaults": 12,
+ "totalArgumentDeclarations": 13,
+ "languageDefault": "Auto",
+ "BoxJSExistingSettingsPreserved": true,
+ "node": "v22.23.5",
+ "localDependencyMode": "existing recovered local dependencies; not a fresh npm ci"
+ },
+ "PRConventions": {
+ "originalDisplayNamePreserved": true,
+ "packageVersionPolicyPreserved": true,
+ "packageJSONUnchanged": true,
+ "lockfileUnchanged": true,
+ "licenseUnchanged": true,
+ "otherClientTemplatesUnchanged": true,
+ "upstreamReleaseURLsPreserved": true,
+ "independentLoonBundleNamesPreserved": true,
+ "fixtureBuildVersion": "0.0.0-test",
+ "fixtureVersionRestoredAfterBuild": true,
+ "forkDistributionDirectoriesCopied": false,
+ "forkRuntimeURLsIntroduced": false,
+ "machineConfigurationOrRawPayloadsCopied": false
+ },
+ "localBuildArtifactSHA256": {
+ "scale.loon.bundle.js": "5896bada8857312ebf5a70b7519452d21c8fbd6131e0df882d8d5f83b813016f",
+ "iRingo.WeatherKit.lpx": "97fc369dc45af5a910b98ca13788830e4123691dbdf94da6c793593324bd7e7c",
+ "request.loon.bundle.js": "484509dc16aac6e09a6a60bb027377854ae898f4bba4f8006120504fab1bc4d4",
+ "LICENSE.txt": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
+ "response.loon.bundle.js": "e7e1ba8ee5bd394494e1301e6c033a5edc03dcd70b86798730feffc28a113fcb"
+ },
+ "referencedWork": {
+ "PatrickYanPR108": "24c3f9311f051a9df9465238ee85b4a5f2c4f9c2",
+ "hhh2210PR110": "f4676feae89a27a8403c574c6086a433d5adb3c9",
+ "license": "Apache-2.0",
+ "sourceAttributionCommentsPreserved": true
+ },
+ "nativeAcceptance": {
+ "installedThisPRBuild": false,
+ "earlier4d1f127AndF571a49EvidenceIsHistorical": true,
+ "sameSharedBusinessCodeForkWarningAcceptance": "2026-11-14 Chinese and English visible text; complete Chinese JSON source comparison",
+ "differentBuildDistributionFromThisPR": true,
+ "notClaimedAllFunctionsOrClientsPass": true,
+ "fullEnglishNativeJSONSaved": false,
+ "sameTransactionHTMLAndJSONCapture": false
+ },
+ "clientUIOrVPNChangedDuringPRSync": false,
+ "newPRCreated": false,
+ "forcePushAllowed": false,
+ "tagsOrReleaseChanged": false,
+ "PRMetadataUpdatePlan": {
+ "title": "preserve",
+ "body": "synchronize current features and validation limits",
+ "comments": "none"
+ }
+}
diff --git a/docs/verified-followup-2026-11-14.md b/docs/verified-followup-2026-11-14.md
new file mode 100644
index 00000000..8f3f707a
--- /dev/null
+++ b/docs/verified-followup-2026-11-14.md
@@ -0,0 +1,40 @@
+# 后续修复与上游候选验证
+
+日期:2026-11-14,Asia/Taipei。本次在 PR #107 实际 head `355b5f5dad9d01cb13167949f8334a489b301811` 上追加改动。核对时它仍为原有七个提交,目标为 main `c40e8f8a13c8304a7ef500cb74acc5948af14b3f`,没有后续他人提交且可合并;原有提交、作者与 Apache-2.0 保留。PR 标题保持不变;正文同步修正已移除功能及当前验证范围,并保留原有有效描述与贡献来源。
+
+## 本次内容
+
+- 移除九项附加控制及对应行为:短时供应商响应缓存、在途合并、队列/发起预算、失败冷却、运行统计、额外地区推断和备用来源查询。旧保存值不再激活它们。正常单请求超时、失败保留 Apple、原位置网格条件缓存与昨日对比缓存继续保留。
+- 将每次供应商回调的结果与其他请求隔离;无有效来源或没有实际合并预警时保留 Apple 集合、不可用状态、正文和验证头。来源失败、孤立 304 或无预警都不能制造可用数据。
+- 补齐既有 periodic、restOfDay、分钟降水末尾转折和空分钟表的边界;只适配已知 JSON 容器,未知子树保持形状;输出超限使用已适配且有界的 Apple 回退。
+- AQ 等级输入先检查合法正整数或对应字符串,避免布尔、数组或小数被强制转换后参与等级比较;保留原标准目标、算法、物理浓度、单位、零值、CO 入口标记与昨日设置。
+- 新增第 13 项预警正文语言偏好 Auto/zh-Hans/en,原有 12 项默认值不变。只请求所选来源已有语言,不调用翻译服务;来源返回原文或混合语言时保留正文、签发单位和链接。显式详情响应 no-store、消息 language 为 und,Auto 保持旧行为;其他天气产品语言独立。
+- 兼容旧、新未知预警到期值,输出 `9999-12-31T00:00:00Z`,避免正时区跨入 10000 年而失去旧 Apple 客户端的未知年份识别。真实来源及 Apple 时间原样保留,未知值不能制造 eventEndTime;真实已过期记录仍过滤。没有证据将该旧边界归因为历史无正文的原因。
+
+## 上游集成保持
+
+本候选保持本 PR 的原版显示名称、版本策略、NSRingo 发布地址与 `.loon.bundle.js` 独立文件命名。没有复制 Fork 的 Loon/script、Surge/script 分发目录、改名模板、发布版本、用户专用 AGENTS、机器配置或原始抓包。package.json、package-lock.json、LICENSE、原版 generic/dev 入口和其他客户端模板保持 PR 原字节;仅共享参数构造与必要源码适用于原有客户端。BoxJS 补充同一个语言项,其余项逐项不变。
+
+运行源码和测试从已经验证的后续修复中移植,再使用本 PR 的工作树、构建脚本、模板及独立文件名重新测试。共享业务源码与已验证 Fork 提交 `7be5036acd8ab8b3a231471d3be695f08f52c828` 相同;Fork 的四项改名测试与专用分发检查不带入本 PR,不能直接把 Fork 的 434 项计数或原生安装结果冒充这份构建的验证。
+
+源码保留 Patrick Yan 的 [PR #108](https://github.com/NSRingo/WeatherKit/pull/108) 输入检查方向及固定提交 `24c3f9311f051a9df9465238ee85b4a5f2c4f9c2`,以及 hhh2210 的 [PR #110](https://github.com/NSRingo/WeatherKit/pull/110) 已知容器遍历与固定提交 `f4676feae89a27a8403c574c6086a433d5adb3c9`;后者也注明源自 #107 的 JSON 适配关系。这些来源使用 Apache-2.0,没有将其作为新原创或整体替换协议架构。
+
+## 当前验证
+
+| 检查 | 当前结果 |
+| --- | --- |
+| 本 PR 工作树完整回归 | 430/430,失败 0、跳过 0;含 BoxJS 语言声明及旧参数保护 |
+| 本 PR 实际 Loon 产物 | 52/52;表达式/路由/jq 15/15;13 项声明及原 12 项默认值通过 |
+| 原生超时与条件响应 | 毫秒/Surge 秒边界、异步一次完成、位置网格 ETag/304 及无实体 304 的回归通过 |
+| 构建身份 | Node v22.23.5;沿用已恢复的本地依赖,未将本次本地运行称作全新 npm ci |
+| 版本和下载 | 构建临时使用 PR 既有 CI 约定 `0.0.0-test`,完成后恢复无 version 的原 package.json;生成文件只在忽略目录 |
+
+2026-11-14 重新核对 [Loon 插件参数](https://nsloon.app/docs/Plugin/) 与 [Script API](https://nsloon.app/docs/Script/script_api/) 的类型、绑定、毫秒超时与回调完成约定;没有新增宿主 API 或提高最低版本。表达式检查和 VM 模拟不能证明宿主 VPN 的内存上限。
+
+## 设备证据的范围
+
+之前 f571a49 的 Mac Weather 样本曾显示主天气、小时/每日、分钟降水、上海 EAQI 与 CO、昨日比较及英国 DAQI;较早 4d1f127 的原生记录继续保留于 [原有设备观察](mac-native-validation.md)。这些是旧产物证据,本次 PR 构建未重新安装至客户端。
+
+同日受控 Mac Loon/Safari 窗口用共享业务实现的另一份 Fork 分发产物显示了仍有效的革吉公开预警中英文正文、签发单位及来源;完整中文 JSON 的正文、定义和五条指南逐段匹配来源,包含安全未知到期值且没有 eventEndTime。该窗口使用的 Fork request SHA-256 是 `484509dc16aac6e09a6a60bb027377854ae898f4bba4f8006120504fab1bc4d4`,response 为 `e7e1ba8ee5bd394494e1301e6c033a5edc03dcd70b86798730feffc28a113fcb`。它支持共享行为,但不是本 PR 的 `.loon.bundle.js` 设备认证。原生 AX 对英文长字段只提供可见前缀,未另存完整英文原生 JSON,也未取得同一 HTTP 事务的 HTML/JSON 成对抓包。
+
+该窗口已完整恢复原插件、12 项保存值、既有脚本及当前配置并停止临时服务;本次同步 PR 没有再次使用 Mac UI、安装插件或改变 VPN/节点。Surge 真机、其他地区和来源、原文回退的完整原生链路、iOS/watchOS、真实网络切换/304 故障注入、长期稳定性和资源量测仍未验证。不得把本报告写成全功能或全部设备通过。
diff --git a/docs/weather-alert-id-v3.md b/docs/weather-alert-id-v3.md
new file mode 100644
index 00000000..b5e6fb95
--- /dev/null
+++ b/docs/weather-alert-id-v3.md
@@ -0,0 +1,11 @@
+# WK3 预警标识兼容修复
+
+2026-11-06 在 macOS 27.3(26C5035r)、Loon for Mac 0.5.0(1011) 复测 v3.3.4。11 种算法、None、目标外保留及数据集控制已在系统天气显示,但完整系统日志发现 WeatherAlert.id 的类型错误;普通天气显示正常不能证明预警已被采用。
+
+原生错误为:weatherAlerts.alerts[n].id 需要 String,实际收到 dictionary。进一步读取本机这次请求的临时天气响应,确认 WK3 将 UUID 表示为仅含 bytes 的对象,数组恰好 16 个 0–255 整数。读取器需要同一组字节对应的标准 UUID 字符串。原始响应仅留在本机忽略目录,回归使用独立合成数据。
+
+修复只在已知 weatherAlerts 产品的 alerts[].id 路径转换该表示,保留 UUID 全部字节、原字符串标识、预警正文、来源、详情地址、令牌及其他字段。不生成随机标识、不删除预警,也不把任意对象转成字符串。已是原生字符串且其他字段不变时,保留正文、空白和验证头。
+
+这属于新 JSON 传输的读取器兼容,原 Apple 预警在未选择来源替换、选择 WeatherKit 或第三方补充失败时也需要执行。供应商结果同样校验,未知非法标识不能覆盖已有可读预警。原版来源选择、预警合并、指数目标、昨日对比和缓存边界继续保留。
+
+Regression coverage includes release/development entries, disabled replacement, retained Apple source, supplementation failure, malformed identifiers, existing strings, unknown products and repeated execution. The Mac candidate showed warning banners and four full warning details; with all replacement datasets disabled, two original Apple warnings remained visible. See [Mac native observations](mac-native-validation.md).
diff --git a/package-lock.json b/package-lock.json
index 87b4353d..2e617d15 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"packages/*"
],
"dependencies": {
+ "@noble/hashes": "1.8.0",
"@nsnanocat/flatbuffer-root": "^1.0.0",
"@nsnanocat/util": "^2.7.4",
"@nsringo/weatherkit": "^1.1.2",
@@ -304,6 +305,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
diff --git a/package.json b/package.json
index ab06a09e..59e77a18 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,9 @@
"build:args": "npm-run-all --serial build:release-args build:full-args",
"dts": "arguments-builder dts -c arguments-builder.full.config.ts",
"submodule": "git submodule update --remote",
- "update": "npm update --save"
+ "update": "npm update --save",
+ "test": "node --test --test-concurrency=1 tests/*.test.mjs",
+ "build:loon": "rollup -c && arguments-builder loon -c arguments-builder.release.config.ts && node scripts/prepare-loon-plugin.mjs && node scripts/verify-loon-build.mjs"
},
"browserslist": [
"iOS >= 18"
@@ -73,7 +75,8 @@
"fetch-cookie": "^3.2.0",
"flatbuffers": "^24.12.23",
"hono": "^4.12.31",
- "node-fetch": "^3.3.2"
+ "node-fetch": "^3.3.2",
+ "@noble/hashes": "1.8.0"
},
"overrides": {
"@iringo/surge2egern": {
diff --git a/rollup.config.mjs b/rollup.config.mjs
index e634ff32..b4f6f127 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -1,4 +1,5 @@
import path from "node:path";
+import { readFileSync } from "node:fs";
import alias from "@rollup/plugin-alias";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
@@ -45,4 +46,19 @@ export default [
terser(),
],
},
+ ...["request", "response", "scale"].map(name => ({
+ input: `./src/loon/${name}.js`,
+ output: { file: `./dist/${name}.loon.bundle.js`, format: "es" },
+ plugins: [
+ alias({ entries: [{ find: "@nsringo/weatherkit", replacement: path.resolve(import.meta.dirname, "node_modules/@nsringo/weatherkit/dist/index.js") }] }),
+ nodeResolve(),
+ terser(),
+ ...(name === "scale" ? [{
+ name: "weatherkit-third-party-licenses",
+ generateBundle() {
+ this.emitFile({ type: "asset", fileName: "LICENSE.txt", source: readFileSync(new URL("./LICENSE", import.meta.url), "utf8") });
+ },
+ }] : []),
+ ],
+ })),
];
diff --git a/scripts/prepare-loon-plugin.mjs b/scripts/prepare-loon-plugin.mjs
new file mode 100644
index 00000000..1e9cc411
--- /dev/null
+++ b/scripts/prepare-loon-plugin.mjs
@@ -0,0 +1,13 @@
+// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0
+import assert from "node:assert/strict";
+import { readFile, writeFile } from "node:fs/promises";
+let plugin = await readFile(new URL("../dist/iRingo.WeatherKit.lpx", import.meta.url), "utf8");
+assert.doesNotMatch(plugin, /RequestCache|ProviderFallback|Region\.Fallback/);
+const defaults = new Map([...plugin.matchAll(/^([^=\n]+?)\s*=\s*(?:input|select),("(?:\\.|[^"\\])*")/gm)].map(([, key, value]) => [key.trim(), JSON.parse(value)]));
+const original = JSON.parse(await readFile(new URL("../tests/fixtures/upstream-intent.json", import.meta.url), "utf8")).originalArgumentDefaults;
+assert.equal(defaults.size, 13);
+assert.equal(defaults.get("WeatherAlerts.Language"), "Auto");
+for (const [key, value] of Object.entries(original)) assert.equal(defaults.get(key), value);
+
+if (!/^#!loon_version = /m.test(plugin)) plugin = plugin.replace(/^(#!system_version = .+)$/m, "$1\n#!loon_version = 3.5.1(983)");
+await writeFile(new URL("../dist/iRingo.WeatherKit.lpx", import.meta.url), plugin);
diff --git a/scripts/verify-loon-build.mjs b/scripts/verify-loon-build.mjs
new file mode 100644
index 00000000..aa3c07e2
--- /dev/null
+++ b/scripts/verify-loon-build.mjs
@@ -0,0 +1,9 @@
+import path from "node:path";
+import { readFile } from "node:fs/promises";
+import assert from "node:assert/strict";
+import verifyLoonBundles from "./verify-loon-bundles.mjs";
+const root = path.resolve(import.meta.dirname, "..");
+const license = await readFile(path.join(root, "dist/LICENSE.txt"), "utf8");
+assert.match(license, /Apache License/);
+const result = await verifyLoonBundles(root);
+console.log(JSON.stringify(result));
diff --git a/scripts/verify-loon-bundles.mjs b/scripts/verify-loon-bundles.mjs
new file mode 100644
index 00000000..08ca9d5d
--- /dev/null
+++ b/scripts/verify-loon-bundles.mjs
@@ -0,0 +1,586 @@
+// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import vm from "node:vm";
+import verifyLoonRewrites from "./verify-loon-rewrites.mjs";
+
+export default async function verifyLoonCache(root) {
+ const directory = path.join(root, "dist");
+ const requestCode = await readFile(path.join(directory, "request.loon.bundle.js"), "utf8"),
+ responseCode = await readFile(path.join(directory, "response.loon.bundle.js"), "utf8"),
+ scaleCode = await readFile(path.join(directory, "scale.loon.bundle.js"), "utf8");
+ const plugin = await readFile(path.join(directory, "iRingo.WeatherKit.lpx"), "utf8");
+ const upstreamDefaults = JSON.parse(await readFile(path.join(root, "tests/fixtures/upstream-intent.json"), "utf8")).originalArgumentDefaults;
+ const emittedDefaults = new Map([...plugin.matchAll(/^([^=\n]+?)\s*=\s*(?:input|select),("(?:\\.|[^"\\])*")/gm)].map(([, key, value]) => [key.trim(), JSON.parse(value)]));
+ assert.equal(emittedDefaults.size, 13);
+ assert.equal(emittedDefaults.get("WeatherAlerts.Language"), "Auto");
+ for (const code of [plugin, requestCode, responseCode, scaleCode]) assert.doesNotMatch(code, /RequestCache|ProviderFallback|Region\.Fallback|WeatherKit\.Provider(?:Cache|Cooldown)/);
+ for (const [key, value] of Object.entries(upstreamDefaults)) assert.equal(emittedDefaults.get(key), value, `Original argument default: ${key}`);
+ const nativeRewrites = verifyLoonRewrites(plugin);
+ const storage = new Map(),
+ scenarios = [];
+ async function run(code, request, response, http, argument = {}, expectedErrors = false) {
+ let resolve,
+ reject,
+ doneCalls = 0,
+ nativeRequests = 0,
+ active = 0;
+ const complete = new Promise((a, b) => {
+ resolve = a;
+ reject = b;
+ });
+ const timer = setTimeout(() => reject(new Error("Built Loon script did not complete")), 2000);
+ const messages = [],
+ errors = [];
+ const context = vm.createContext({
+ URL,
+ URLSearchParams,
+ TextDecoder,
+ // Binary fixtures and scripts share the host typed-array realm in Loon.
+ // Preserve that relationship when exercising opaque source slots in a VM.
+ Uint8Array,
+ $loon: "simulated Loon 3.5.1",
+ $script: { startTime: new Date() },
+ $argument: { Storage: "Argument", LogLevel: "OFF", ...argument },
+ $request: request,
+ $response: response,
+ $persistentStore: {
+ read: key => storage.get(key),
+ write: (value, key) => {
+ storage.set(key, value);
+ return true;
+ },
+ },
+ $httpClient: {
+ get: (outgoing, callback) => {
+ nativeRequests++;
+ active++;
+ Promise.resolve()
+ .then(() =>
+ http(outgoing, (...args) => {
+ active--;
+ callback(...args);
+ }),
+ )
+ .catch(reject);
+ },
+ },
+ console: { log: value => messages.push(String(value)), info() {}, warn() {}, error: value => errors.push(String(value)) },
+ $done: value => {
+ doneCalls++;
+ assert.equal(active, 0, "$done must wait for every native callback");
+ resolve(value);
+ },
+ });
+ try {
+ vm.runInContext(code, context, { timeout: 2000 });
+ const output = await complete;
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(doneCalls, 1);
+ assert.equal(context.process, undefined);
+ assert.equal(context.require, undefined);
+ if (code === responseCode) assert.equal(typeof context.TextEncoder, "function", "The response bundle supplies its bounded encoder when the host omits it");
+ if (!expectedErrors) assert.equal(errors.length, 0, errors.join("\n"));
+ return { output, nativeRequests, messages, errors };
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ const noNetwork = () => {
+ throw new Error("Unexpected native network request");
+ };
+ const scale = await run(scaleCode, { url: "https://weatherkit.apple.com/api/v1/airQualityScale/zh-Hans-CN/HK.AQHI.2604", method: "GET", headers: {} }, undefined, noNetwork);
+ assert.equal(scale.output.response.status, 200);
+ assert.ok(JSON.parse(scale.output.response.body));
+ scenarios.push("custom scale uses a standalone bundle without storage or provider requests");
+ const unknownScale = await run(scaleCode, { url: "https://weatherkit.apple.com/api/v1/airQualityScale/unknown-HK/HK.AQHI.2604?v=1", method: "GET", headers: { "If-None-Match": "fixture-etag" } }, undefined, noNetwork);
+ assert.ok(unknownScale.output.url.endsWith("/HK.AQHI?v=1"));
+ assert.equal(unknownScale.output.headers["If-None-Match"], "fixture-etag");
+ scenarios.push("unknown scale language falls through with conditional headers intact");
+ const availability = await run(responseCode, { url: "https://weatherkit.apple.com/api/v1/availability/en-US/31.2/121.5", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(["currentWeather", "weatherMaps"]) }, noNetwork);
+ assert.ok(JSON.parse(availability.output.body).includes("weatherMaps"));
+ assert.ok(JSON.parse(availability.output.body).includes("forecastNextHour"));
+ scenarios.push("availability preserves Apple and appends plugin requirements");
+ const plain = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/en-US/31.2/121.5", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "text/plain" }, body: "original" }, noNetwork);
+ assert.equal(plain.output.body, "original");
+ scenarios.push("unhandled content passthrough");
+ const v3Request = { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=currentWeather", method: "GET", headers: {} };
+ const v3Original = ` ${JSON.stringify({ currentWeather: { metadata: { latitude: 31.2, longitude: 121.5, sourceType: "MODELED", temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLOUDY", futureField: [1, 2] }, unknownProduct: { retain: true } })}\n`;
+ const v3Response = { status: 200, headers: { "Content-Type": "application/json", ETag: "fixture", "Content-Length": "1" }, body: v3Original };
+ const v3Replaced = await run(
+ responseCode,
+ v3Request,
+ v3Response,
+ (_, callback) => {
+ setImmediate(() =>
+ callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-04T00:00:00Z", now: { temp: "23", icon: "100", text: "晴", feelsLike: "24", cloud: "20", humidity: "50", precip: "0", pressure: "1000", dew: "10", vis: "10", wind360: "90", windSpeed: "2" } })),
+ );
+ },
+ { DataSets: "currentWeather", "Weather.Provider": "QWeather" },
+ );
+ const v3Body = JSON.parse(v3Replaced.output.body);
+ assert.equal(v3Replaced.nativeRequests, 1);
+ assert.equal(v3Body.currentWeather.temperature, 23);
+ assert.equal(v3Body.currentWeather.conditionCode, "CLEAR");
+ assert.equal(typeof v3Body.currentWeather.metadata.latitude, "number");
+ assert.deepEqual(v3Body.currentWeather.futureField, [1, 2]);
+ assert.deepEqual(v3Body.unknownProduct, { retain: true });
+ assert.equal(v3Replaced.output.headers["Cache-Control"], "no-store");
+ assert.equal(v3Replaced.output.headers.ETag, undefined);
+ assert.equal(v3Response.body, v3Original);
+ scenarios.push("v3 JSON replaces weather with WK3 types and preserves unknown fields after native callbacks finish");
+ const nativeJSON = {
+ currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: ["CLOUDY"] },
+ forecastDaily: { metadata: { temporarilyUnavailable: false }, days: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 3, minimum: 1, maximum: 4, precipitationType: ["RAIN"] }] }] },
+ forecastPeriodic: { metadata: {}, forecasts: [{ periods: [{ conditionCode: ["CLEAR"], snowfallAmount: { expected: 0, minimum: 0, maximum: 0 }, precipitationAmountByType: [] }] }] },
+ };
+ const nativeCompatible = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeJSON) }, noNetwork, { DataSets: "currentWeather", "Weather.Provider": "WeatherKit" });
+ const nativeBody = JSON.parse(nativeCompatible.output.body);
+ assert.equal(nativeBody.currentWeather.conditionCode, "CLOUDY");
+ assert.equal(nativeBody.forecastDaily.days[0].precipitationAmount, 3);
+ assert.equal(nativeBody.forecastPeriodic.forecasts[0].periods[0].conditionCode, "CLEAR");
+ assert.equal(nativeBody.forecastPeriodic.forecasts[0].periods[0].snowfallAmount, 0);
+ assert.equal(nativeCompatible.nativeRequests, 0);
+ scenarios.push("Apple v3 JSON is adapted to the native Weather reader, including unselected periodic forecasts");
+ for (const product of ["forecastPeriodic", "weatherChanges"]) {
+ const input = {
+ status: 200,
+ headers: { "Content-Type": "application/json", ETag: "old", "Content-Encoding": "gzip", "Transfer-Encoding": "chunked" },
+ body: JSON.stringify({ [product]: { metadata: {}, forecasts: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 0.4 }] }] }, futureProduct: { conditionCode: ["OPAQUE"] } }),
+ };
+ const result = await run(responseCode, { ...v3Request, url: v3Request.url.replace("currentWeather", product) }, input, noNetwork, { DataSets: "" });
+ const body = JSON.parse(result.output.body);
+ assert.equal(body[product].forecasts[0].conditionCode, "RAIN");
+ assert.equal(body[product].forecasts[0].precipitationAmount, 0.4);
+ assert.deepEqual(body.futureProduct.conditionCode, ["OPAQUE"]);
+ for (const name of ["ETag", "Content-Encoding", "Transfer-Encoding"]) assert.equal(result.output.headers[name], undefined);
+ assert.equal(result.nativeRequests, 0);
+ scenarios.push(`${product}-only JSON adapts native fields and invalidates original entity headers without supplier work`);
+ }
+ const minuteRequest = { ...v3Request, url: v3Request.url.replace("currentWeather", "forecastNextHour") };
+ for (const invalid of [false, true]) {
+ storage.clear();
+ const result = await run(
+ responseCode,
+ minuteRequest,
+ v3Response,
+ (_, callback) =>
+ callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", server_time: 1800000000, result: { minutely: { status: "ok", description: "未来有小雨", probability: [0.8, 0.8, 0.8, 0.8], precipitation_2h: invalid ? [] : Array.from({ length: 120 }, (_, i) => (i < 70 ? 0 : 1)) } } })),
+ { DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds" },
+ invalid,
+ );
+ assert.equal(result.nativeRequests, 1);
+ if (invalid) assert.equal(result.output.body, v3Original);
+ else {
+ const product = JSON.parse(result.output.body).forecastNextHour;
+ assert.equal(product.minutes.length, 85);
+ assert.deepEqual(
+ product.summary.map(item => item.clear),
+ [true, false],
+ );
+ assert.equal(product.condition.length, 2);
+ }
+ scenarios.push(invalid ? "empty provider minutes preserve the exact original weather response" : "a rain transition at the last summarized minute survives the compiled provider path");
+ }
+ const alertUUID = "00112233-4455-6677-8899-aabbccddeeff";
+ const alertBytes = [0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255];
+ const nativeAlerts = {
+ weatherAlerts: {
+ metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" },
+ detailsUrl: "https://www.qweather.com/severe-weather/fixture-101190110.html?from=AppleWeatherService",
+ alerts: [{ id: { bytes: alertBytes }, description: "Fixture warning", detailsUrl: "https://example.com/fixture-warning", futureAlertField: true }],
+ },
+ futureProduct: { id: { bytes: alertBytes } },
+ };
+ const retainedAlerts = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, { status: 200, headers: { "Content-Type": "application/json", ETag: "original" }, body: JSON.stringify(nativeAlerts) }, noNetwork, {
+ DataSets: "",
+ "WeatherAlerts.Provider": "WeatherKit",
+ });
+ const retainedAlertBody = JSON.parse(retainedAlerts.output.body);
+ assert.equal(retainedAlertBody.weatherAlerts.alerts[0].id, alertUUID);
+ assert.equal(retainedAlertBody.weatherAlerts.alerts[0].description, "Fixture warning");
+ assert.equal(retainedAlertBody.weatherAlerts.alerts[0].futureAlertField, true);
+ assert.deepEqual(retainedAlertBody.futureProduct, nativeAlerts.futureProduct);
+ assert.equal(retainedAlerts.nativeRequests, 0);
+ scenarios.push("unselected Apple warning UUID bytes become native strings without changing warning content or unknown products");
+ const failedNativeAlerts = await run(
+ responseCode,
+ { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") },
+ { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeAlerts) },
+ (_, callback) => setImmediate(() => callback(null, { status: 503, headers: { "Content-Type": "text/html" } }, "Source unavailable")),
+ { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeatherWeb" },
+ true,
+ );
+ assert.equal(JSON.parse(failedNativeAlerts.output.body).weatherAlerts.alerts[0].id, alertUUID);
+ assert.equal(JSON.parse(failedNativeAlerts.output.body).weatherAlerts.alerts[0].description, "Fixture warning");
+ assert.equal(failedNativeAlerts.nativeRequests, 1);
+ scenarios.push("failed warning supplementation preserves a readable original Apple warning identifier");
+ storage.clear();
+ const appleCO = {
+ airQuality: {
+ metadata: { temporarilyUnavailable: false, providerName: "QWeather" },
+ scale: "HJ6332012",
+ index: 42,
+ categoryIndex: 1,
+ primaryPollutant: "PM2_5",
+ previousDayComparison: "SIMILAR",
+ pollutants: [
+ ["PM2_5", 35],
+ ["PM10", 60],
+ ["OZONE", 90],
+ ["NO2", 35],
+ ["SO2", 15],
+ ["CO", 0.7],
+ ].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })),
+ },
+ };
+ const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, {
+ DataSets: "airQuality",
+ "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US",
+ "AirQuality.Current.Index.Replace": "EPA_NowCast",
+ });
+ assert.equal(JSON.parse(appleCOResult.output.body).airQuality.pollutants.find(p => p.pollutantType === "CO").amount, 700);
+ assert.equal(appleCOResult.nativeRequests, 0);
+ scenarios.push("native Apple QWeather CO is repaired at ingress with no duplicate provider lookup");
+ for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "None"]) {
+ const pollutants = [
+ ["PM2_5", 35],
+ ["PM10", 60],
+ ["OZONE", 90],
+ ["NO2", 35],
+ ["SO2", 15],
+ ["CO", 700],
+ ].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" }));
+ const body = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants } };
+ const computed = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, noNetwork, {
+ DataSets: "airQuality",
+ "AirQuality.Calculate.Algorithm": algorithm,
+ "AirQuality.Current.Index.Replace": "EPA_NowCast",
+ });
+ const actual = JSON.parse(computed.output.body).airQuality;
+ assert.equal(computed.nativeRequests, 0);
+ assert.equal(actual.pollutants.length, 6);
+ assert.ok(Number.isInteger(actual.index) && actual.index >= 0);
+ assert.ok(Number.isInteger(actual.categoryIndex) && actual.categoryIndex >= 1);
+ if (algorithm === "None") assert.equal(actual.index, 42);
+ else assert.match(actual.metadata.providerName, /iRingo/);
+ scenarios.push(`v3 ${algorithm} retains all six measurements with no provider request`);
+ }
+ storage.clear();
+ const preservedIndex = await run(
+ responseCode,
+ { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") },
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ airQuality: { ...appleCO.airQuality, metadata: { providerName: "Apple fixture", temporarilyUnavailable: false }, scale: "EPA_NowCast", pollutants: appleCO.airQuality.pollutants.map(p => ({ ...p, amount: p.pollutantType === "CO" ? 700 : p.amount })) } }),
+ },
+ noNetwork,
+ { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" },
+ );
+ assert.equal(JSON.parse(preservedIndex.output.body).airQuality.index, 42);
+ assert.equal(JSON.parse(preservedIndex.output.body).airQuality.scale, "EPA_NowCast");
+ scenarios.push("the original nonmatching target list preserves Apple AQI despite a selected algorithm");
+ const retainedComparison = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, {
+ DataSets: "airQuality",
+ "AirQuality.Calculate.Algorithm": "CA_AQHI",
+ });
+ assert.equal(JSON.parse(retainedComparison.output.body).airQuality.scale, "CA.AQHI");
+ assert.equal(JSON.parse(retainedComparison.output.body).airQuality.previousDayComparison, "SIMILAR");
+ scenarios.push("the original disabled comparison-replacement switch keeps an existing Apple trend");
+ const v3Failed = await run(responseCode, v3Request, v3Response, (_, callback) => setImmediate(() => callback("timeout", {}, "")), { DataSets: "currentWeather", "Weather.Provider": "QWeather" }, true);
+ assert.equal(v3Failed.output.body, v3Original);
+ assert.equal(v3Failed.output.headers.ETag, "fixture");
+ scenarios.push("v3 provider failure preserves exact Apple JSON, whitespace and validators");
+ const v3Opaque = { ...v3Response, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: new Uint8Array([1, 2, 3, 4]) };
+ const v3Kept = await run(responseCode, v3Request, v3Opaque, noNetwork);
+ assert.strictEqual(v3Kept.output.body, v3Opaque.body);
+ scenarios.push("WK3 binary stays opaque if JSON negotiation is not honoured");
+ storage.clear();
+ const alertRequest = { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?lang=en-US&ids=fixture-101190110", method: "GET", headers: { "User-Agent": "fixture-agent" } };
+ const html = '
Fixture warning
Issue date: 2026-10-19T00:00:00Z
';
+ const first = await run(requestCode, alertRequest, undefined, (_, callback) => callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html));
+ assert.equal(first.nativeRequests, 1);
+ assert.equal(first.output.response.status, 200);
+ assert.ok(JSON.parse(first.output.response.body).length > 0);
+ scenarios.push("web warning parsing without TextEncoder");
+ for (const language of ["zh-Hans", "en"]) {
+ const text = language === "en" ? "Source English notice." : "来源中文预警。";
+ const source = html.replace("", `${text}
`);
+ const localized = await run(requestCode, alertRequest, undefined, (outgoing, callback) => {
+ assert.equal(new URL(outgoing.url).pathname.startsWith("/en/"), language === "en");
+ assert.equal(outgoing.headers["Accept-Language"], language);
+ callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, source);
+ }, { "WeatherAlerts.Language": language });
+ assert.equal(localized.nativeRequests, 1);
+ assert.equal(localized.output.response.headers["Cache-Control"], "no-store");
+ const row = JSON.parse(localized.output.response.body)[0];
+ assert.equal(row.messages[0].text, text);
+ assert.equal(row.messages[0].language, "und");
+ assert.equal(row.expireTime, "9999-12-31T00:00:00Z");
+ assert.equal(Object.hasOwn(row, "eventEndTime"), false);
+ scenarios.push(`${language}: compiled Loon preference requests source language without translating original text`);
+ }
+ const second = await run(requestCode, alertRequest, undefined, (_, callback) => callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html));
+ assert.equal(second.nativeRequests, 1);
+ assert.equal(second.output.response.body, first.output.response.body);
+ scenarios.push("a repeated warning request fetches independently without persistent response reuse");
+ storage.clear();
+ const failed = await run(requestCode, alertRequest, undefined, (_, callback) => callback("timeout fixture-secret", {}, ""), {}, true);
+ assert.equal(failed.output.response, undefined);
+ assert.equal(failed.output.url, alertRequest.url);
+ assert.equal(failed.messages.filter(message => message.startsWith("[WeatherKit]")).length, 0);
+ scenarios.push("failed warning request passes through without the removed runtime statistics");
+ const appleDetails = { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=31.2,121.5", method: "GET", headers: { "If-None-Match": "apple-entity" } };
+ const retainedDetails = await run(requestCode, appleDetails, undefined, noNetwork, { "WeatherAlerts.Provider": "WeatherKit" });
+ assert.equal(retainedDetails.output.response, undefined);
+ assert.equal(retainedDetails.output.headers["If-None-Match"], "apple-entity");
+ assert.equal(retainedDetails.nativeRequests, 0);
+ scenarios.push("WeatherKit-selected coordinate warning details retain the original Apple request and validators");
+ const { default: WeatherKit2 } = await import(new URL("../src/class/WeatherKit2.mjs", import.meta.url));
+ const raw = WeatherKit2.encode(undefined, {
+ currentWeather: {
+ metadata: { providerName: "Apple", temporarilyUnavailable: false },
+ temperature: 27,
+ precipitationAmountNext1hByType: [],
+ precipitationAmountNext24hByType: [],
+ precipitationAmountNext6hByType: [],
+ precipitationAmountPrevious1hByType: [],
+ precipitationAmountPrevious24hByType: [],
+ precipitationAmountPrevious6hByType: [],
+ },
+ forecastHourly: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, hours: [] },
+ });
+ const recovered = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw },
+ (_, callback) => callback("timeout", {}, ""),
+ { LogLevel: "ERROR", DataSets: "currentWeather,forecastHourly", "Weather.Provider": "QWeather" },
+ true,
+ );
+ assert.equal(recovered.nativeRequests, 2, "Both requested provider products must complete");
+ const { ByteBuffer } = await import("flatbuffers");
+ const decoded = WeatherKit2.decode(new ByteBuffer(recovered.output.body), ["currentWeather"]);
+ assert.equal(decoded.currentWeather.temperature, 27);
+ scenarios.push("binary weather response retains Apple values and completes after native failures");
+ storage.clear();
+ const empty = WeatherKit2.encode(undefined, {});
+ const nativeMinute = WeatherKit2.encode(undefined, { forecastNextHour: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, condition: [], summary: [], minutes: [] } });
+ const keptMinute = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: nativeMinute }, noNetwork, {
+ DataSets: "forecastNextHour",
+ "NextHour.Provider": "ColorfulClouds",
+ });
+ assert.deepEqual(Array.from(keptMinute.output.body), Array.from(nativeMinute));
+ assert.equal(keptMinute.messages.length, 0, "LogLevel OFF suppresses startup, provider and completion logs");
+ scenarios.push("existing Apple minute table is preserved without provider work or routine logs");
+ const disabled = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/en-US/31.2/121.5?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }, noNetwork, {
+ DataSets: "currentWeather,forecastHourly",
+ "Weather.Provider": "WeatherKit",
+ });
+ assert.deepEqual(Array.from(disabled.output.body), Array.from(raw));
+ scenarios.push("Apple-only provider preserves original binary weather without a decoder round trip");
+ const large = new Uint8Array(4 * 1024 * 1024 + 1);
+ const bounded = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/en-US/31.2/121.5?country=CN&dataSets=forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: large }, noNetwork);
+ assert.strictEqual(bounded.output.body, large);
+ scenarios.push("oversized binary input retains the original view without decoding or provider work");
+ const hourlyRaw = WeatherKit2.encode(undefined, { forecastHourly: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, hours: [0, 1].map(i => ({ forecastStart: 1792368000 + i * 3600, temperature: 20 + i, precipitationAmountByType: [] })) } });
+ const replacedHourly = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=forecastHourly", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: hourlyRaw },
+ (_, callback) =>
+ callback(
+ null,
+ { status: 200, headers: {} },
+ JSON.stringify({ code: "200", updateTime: "2026-10-19T00:00:00Z", hourly: [{ fxTime: new Date(1792368000000).toISOString(), temp: "30", text: "晴", cloud: "20", humidity: "50", precip: "0", pop: "10", pressure: "1000", dew: "10", wind360: "90", windSpeed: "2" }] }),
+ ),
+ { DataSets: "forecastHourly", "Weather.Provider": "QWeather" },
+ );
+ const hours = WeatherKit2.decode(new ByteBuffer(replacedHourly.output.body), ["forecastHourly"]).forecastHourly.hours;
+ assert.equal(replacedHourly.nativeRequests, 1);
+ assert.equal(hours[0].temperature, 30);
+ assert.equal(hours[1].temperature, 21);
+ assert.equal(WeatherKit2.decode(new ByteBuffer(hourlyRaw), ["forecastHourly"]).forecastHourly.hours[0].temperature, 20);
+ scenarios.push("partial hourly replacement changes only a matching row and leaves source binary intact");
+ const noBackup = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: empty },
+ (request, callback) => {
+ assert.ok(request.url.includes("caiyunapp.com"));
+ callback("timeout", {}, "");
+ },
+ { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "configured-for-another-product" },
+ true,
+ );
+ assert.equal(noBackup.nativeRequests, 1);
+ assert.deepEqual(noBackup.output.body, empty);
+ scenarios.push("a dedicated token alone never switches away from the explicitly selected provider");
+ const legacySettings = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: empty },
+ (request, callback) => {
+ assert.ok(request.url.includes("caiyunapp.com"), "Legacy fallback settings must never trigger a different provider");
+ callback("timeout", {}, "");
+ },
+ { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "fixture-account", "ProviderFallback.Enabled": true, "Region.Fallback": "Auto", "RequestCache.Diagnostics": true },
+ true,
+ );
+ assert.equal(legacySettings.nativeRequests, 1);
+ assert.deepEqual(legacySettings.output.body, empty);
+ assert.equal(legacySettings.messages.filter(line => line.startsWith("[WeatherKit]")).length, 0);
+ scenarios.push("legacy stored fallback, region and statistics settings cannot reactivate removed features");
+ for (const [status, temperature, reason] of [
+ [502, "99", "http"],
+ [200, "not-a-number", "invalid"],
+ ]) {
+ storage.clear();
+ const guarded = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=currentWeather", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw },
+ (_, callback) => callback(null, { status, headers: { "Content-Type": "application/json" } }, JSON.stringify({ code: "200", now: { temp: temperature } })),
+ { DataSets: "currentWeather", "Weather.Provider": "QWeather" },
+ true,
+ );
+ assert.equal(guarded.nativeRequests, 1);
+ assert.deepEqual(Array.from(guarded.output.body), Array.from(raw));
+ scenarios.push(`HTTP ${status} provider body with ${reason} failure cannot overwrite Apple weather`);
+ }
+ storage.clear();
+ const mixedRaw = WeatherKit2.encode(undefined, {
+ currentWeather: WeatherKit2.decode(new ByteBuffer(raw), ["currentWeather"]).currentWeather,
+ weatherAlerts: { metadata: { providerName: "National Early Warning Center", temporarilyUnavailable: false }, detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=32.390,119.499", alerts: [] },
+ });
+ const mixed = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=weatherAlerts,currentWeather", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer", ETag: "original", "Content-Length": "1", "Content-Encoding": "gzip" }, body: mixedRaw },
+ (_, callback) =>
+ callback(
+ null,
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ JSON.stringify({
+ status: "ok",
+ location: [32.39, 119.499],
+ result: { server_time: 1792368000, alert: { status: "error" }, realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.2, skycon: "CLEAR_DAY", humidity: 0.8, precipitation: { local: { intensity: 0 } }, pressure: 100000, visibility: 10, wind: { direction: 90, speed: 1 } } },
+ }),
+ ),
+ { DataSets: "weatherAlerts,currentWeather", "Weather.Provider": "ColorfulClouds", "WeatherAlerts.Provider": "ColorfulClouds" },
+ true,
+ );
+ assert.equal(mixed.nativeRequests, 2);
+ for (const name of ["ETag", "Content-Length", "Content-Encoding"]) assert.equal(mixed.output.headers[name], undefined);
+ assert.equal(mixed.output.headers["Cache-Control"], "no-store");
+ scenarios.push("modified WK2 binary invalidates original HTTP entity headers");
+ const mixedDecoded = WeatherKit2.decode(new ByteBuffer(mixed.output.body), ["currentWeather", "weatherAlerts"]);
+ assert.equal(mixedDecoded.currentWeather.temperature, 23);
+ assert.equal(mixedDecoded.weatherAlerts.metadata.providerName, "National Early Warning Center");
+ scenarios.push("independent Caiyun product requests replace healthy weather while preserving failed Apple alerts");
+ storage.clear();
+ const { futureHourlyFixture, futureHourlyText, readFutureHourly, weatherFixture, weatherNow } = await import("../tests/fixtures/processingMemoryFixture.mjs");
+ const futureRaw = futureHourlyFixture();
+ const partial = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.39/119.499?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: futureRaw },
+ (request, callback) => callback(null, { status: request.url.includes("/weather/now") ? 200 : 503, headers: {} }, request.url.includes("/weather/now") ? JSON.stringify(weatherNow) : "{}"),
+ { DataSets: "currentWeather,forecastHourly", "Weather.Provider": "QWeather" },
+ true,
+ );
+ assert.equal(partial.nativeRequests, 2);
+ assert.equal(WeatherKit2.decode(new ByteBuffer(partial.output.body), ["currentWeather"]).currentWeather.temperature, 23);
+ assert.equal(readFutureHourly(partial.output.body), futureHourlyText);
+ scenarios.push("partial replacement preserves future fields inside an unchanged selected hourly table");
+ storage.clear();
+ const alertsRaw = weatherFixture(0, 48);
+ const failedAlerts = await run(
+ responseCode,
+ { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.39/119.499?country=CN&dataSets=weatherAlerts", method: "GET", headers: {} },
+ { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: alertsRaw },
+ (_, callback) => callback(null, { status: 503, headers: {} }, "{}"),
+ { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "ColorfulClouds" },
+ true,
+ );
+ assert.equal(failedAlerts.nativeRequests, 1);
+ assert.strictEqual(failedAlerts.output.body, alertsRaw);
+ scenarios.push("failed alert replacement retains the complete original binary response");
+ const unavailableBody = JSON.stringify({ weatherAlerts: { metadata: { temporarilyUnavailable: true }, alerts: [] } });
+ const unavailable = await run(
+ responseCode,
+ { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") },
+ { status: 200, headers: { "Content-Type": "application/json", ETag: "keep" }, body: unavailableBody },
+ (_, callback) => callback(null, { status: 200, headers: {} }, JSON.stringify({ metadata: {}, alerts: [] })),
+ { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeather" },
+ );
+ assert.equal(unavailable.nativeRequests, 1);
+ assert.equal(unavailable.output.body, unavailableBody);
+ assert.equal(unavailable.output.headers.ETag, "keep");
+ scenarios.push("available provider with no native warning merge cannot clear Apple unavailability");
+ const futureContainer = { conditionCode: ["OPAQUE"], index: "future", precipitationAmount: { expected: 8 } };
+ const opaqueNested = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentWeather: { metadata: {}, conditionCode: ["RAIN"], future: futureContainer } }) }, noNetwork, { DataSets: "" });
+ const opaqueProduct = JSON.parse(opaqueNested.output.body).currentWeather;
+ assert.equal(opaqueProduct.conditionCode, "RAIN");
+ assert.deepEqual(opaqueProduct.future, futureContainer);
+ scenarios.push("known Weather fields adapt while an unknown nested container stays opaque");
+ const populatedUnavailableBody = JSON.stringify({ weatherAlerts: { metadata: { temporarilyUnavailable: true }, alerts: [{ id: "00112233-4455-6677-8899-aabbccddeeff", description: "Apple fixture warning" }] } });
+ const populatedUnavailable = await run(
+ responseCode,
+ { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") },
+ { status: 200, headers: { "Content-Type": "application/json", ETag: "keep-populated" }, body: populatedUnavailableBody },
+ (_, callback) => callback(null, { status: 200, headers: {} }, JSON.stringify({ metadata: {}, alerts: [] })),
+ { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeather" },
+ );
+ assert.equal(populatedUnavailable.nativeRequests, 1);
+ assert.equal(populatedUnavailable.output.body, populatedUnavailableBody);
+ assert.equal(populatedUnavailable.output.headers.ETag, "keep-populated");
+ scenarios.push("metadata-only warning supplementation preserves populated Apple alerts and their unavailable state");
+ // Both inputs fit independently; the real provider conversion makes their combined output exceed 1 Mi characters.
+ const oversizedApple = {
+ currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: ["CLOUDY"] },
+ forecastPeriodic: { metadata: {}, forecasts: [{ periods: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 2, precipitationType: ["RAIN"] }] }] }] },
+ futureProduct: { padding: "x".repeat(768 * 1024) },
+ };
+ const oversizedOriginal = JSON.stringify(oversizedApple);
+ const oversizedProviderBody = JSON.stringify({ code: "200", fxLink: `https://fixture.example/${"p".repeat(384 * 1024)}`, now: { pubTime: "2026-11-14T00:00:00Z", temp: "23", text: "晴", feelsLike: "24", cloud: "20", humidity: "50", precip: "0", pressure: "1000", dew: "10", vis: "10", wind360: "90", windSpeed: "2" } });
+ const oversizedProvider = (_, callback) => callback(null, { status: 200, headers: {} }, oversizedProviderBody);
+ const acceptedControl = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentWeather: oversizedApple.currentWeather }) }, oversizedProvider, { DataSets: "currentWeather", "Weather.Provider": "QWeather" });
+ const acceptedBody = JSON.parse(acceptedControl.output.body);
+ assert.equal(acceptedControl.nativeRequests, 1);
+ assert.equal(acceptedBody.currentWeather.temperature, 23, "The overflow fixture must be a valid provider replacement before combining it with the large Apple payload");
+ assert.ok(acceptedBody.currentWeather.metadata.attributionUrl.length > 384 * 1024);
+ const outputFallback = await run(
+ responseCode,
+ v3Request,
+ { status: 200, headers: { "Content-Type": "application/json", ETag: "before-fallback" }, body: oversizedOriginal },
+ oversizedProvider,
+ { DataSets: "currentWeather", "Weather.Provider": "QWeather" },
+ );
+ const fallbackBody = JSON.parse(outputFallback.output.body);
+ assert.equal(outputFallback.nativeRequests, 1);
+ assert.equal(fallbackBody.currentWeather.temperature, 18);
+ assert.equal(fallbackBody.currentWeather.conditionCode, "CLOUDY");
+ assert.equal(fallbackBody.forecastPeriodic.forecasts[0].periods[0].conditionCode, "RAIN");
+ assert.equal(fallbackBody.forecastPeriodic.forecasts[0].periods[0].precipitationAmount, 2);
+ assert.deepEqual(fallbackBody.futureProduct, oversizedApple.futureProduct);
+ assert.ok(outputFallback.output.body.length < 1024 * 1024);
+ assert.equal(outputFallback.output.headers.ETag, undefined);
+ assert.equal(outputFallback.output.headers["Cache-Control"], "no-store");
+ assert.equal(JSON.stringify(oversizedApple), oversizedOriginal);
+ scenarios.push("provider output overflow returns normalized Apple weather and periodic data through the compiled Loon script");
+ return {
+ pass: scenarios.length,
+ fail: 0,
+ scenarios,
+ nativeRewrites,
+ upstreamArgumentDefaultsVerified: Object.keys(upstreamDefaults).length,
+ withoutNodeWebCryptoHostTextEncoder: true,
+ providedWebAPIs: ["URL", "URLSearchParams", "TextDecoder"],
+ doneExactlyOnce: true,
+ liveWeatherRequests: false,
+ deviceVPNValidation: false,
+ };
+}
diff --git a/scripts/verify-loon-rewrites.mjs b/scripts/verify-loon-rewrites.mjs
new file mode 100644
index 00000000..e91d8a4f
--- /dev/null
+++ b/scripts/verify-loon-rewrites.mjs
@@ -0,0 +1,136 @@
+// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0
+// Checks the emitted expressions and jq program. This is not a Loon parser or device test.
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+
+export function readLoonRules(plugin) {
+ const lines = plugin.split("\n");
+ const availability = lines.find(line => line.startsWith("response if ") && line.includes("response.json.jq("));
+ const scale = lines.find(line => line.startsWith("request if ") && line.includes("url.replace("));
+ const jsonTransport = lines.find(line => line.startsWith("request if ") && line.includes('request.header.set("Accept", "application/json")'));
+ assert.ok(jsonTransport, "v3 must negotiate Apple's JSON representation without a request script");
+ assert.ok(jsonTransport.startsWith('request if ${request.method} == "GET" && '));
+ const transportGuard = jsonTransport.match(/\$\{url\} ~= \/((?:\\.|[^/])*)\/ then /);
+ assert.ok(transportGuard);
+ const transportRegex = new RegExp(transportGuard[1]);
+ const userAgentGuard = /\$\{request\.header\['User-Agent'\]\} ~= \/((?:\\.|[^/])*)\//;
+ const transportAgent = jsonTransport.match(userAgentGuard);
+ assert.ok(transportAgent, "Background clients must retain Apple's original transport");
+ const appAgentRegex = new RegExp(transportAgent[1]);
+ assert.ok(availability && scale, "Both native Rewrite actions must be present");
+ const jq = JSON.parse(availability.slice(availability.indexOf("response.json.jq(") + "response.json.jq(".length, -1));
+ const expression = scale.match(/\$\{url\} ~= \/((?:\\.|[^/])*)\/([i]*) as scale then url\.replace\("\$\{scale\.1\}"\)$/);
+ assert.ok(expression, "Use the documented named capture and a unique required URL guard");
+ assert.ok(scale.startsWith(`request if (\${request.method} == "GET" || \${request.method} == "HEAD" || \${request.method} == "OPTIONS") && `));
+ const regex = new RegExp(expression[1], expression[2]);
+ const scripts = lines
+ .filter(line => /^http-(request|response) /.test(line))
+ .map(line => {
+ const [stage, pattern] = line.split(" ");
+ return { stage, regex: new RegExp(pattern), line };
+ });
+ const v3Script = lines.find(line => line.startsWith("response if ") && line.includes(" then script(") && line.includes("api.v3.weather.json.response"));
+ assert.ok(v3Script);
+ const v3Guard = v3Script.match(/\$\{url\} ~= \/((?:\\.|[^/])*)\//);
+ const v3Agent = v3Script.match(userAgentGuard);
+ const v3Format = v3Script.match(/\$\{response\.header\['Content-Type'\]\} ~= \/((?:\\.|[^/])*)\/i/);
+ assert.ok(v3Guard && v3Agent && v3Format);
+ assert.ok(v3Script.includes('${request.method} == "GET"') && v3Script.includes("${response.status} == 200"));
+ const v3AgentRegex = new RegExp(v3Agent[1]),
+ v3FormatRegex = new RegExp(v3Format[1], "i");
+ scripts.push({ stage: "http-response", regex: new RegExp(v3Guard[1]), line: v3Script, accepts: ({ method = "GET", status = 200, contentType, userAgent } = {}) => method === "GET" && status === 200 && v3FormatRegex.test(contentType ?? "") && v3AgentRegex.test(userAgent ?? "") });
+ const declared = lines.filter(line => /^[\w.]+ = (input|select|switch),/.test(line)).map(line => line.split(" = ")[0]);
+ // Template declarations are generated later; actual imports must bind every parameter.
+ if (declared.length)
+ assert.deepEqual(
+ [...v3Script.matchAll(/\$\{([\w.]+)\}/g)].map(match => match[1]).filter(name => !["url", "request.method", "response.status"].includes(name)),
+ declared,
+ );
+ return {
+ jq,
+ scripts,
+ negotiatesJSON(url, method = "GET", userAgent = "") {
+ return method === "GET" && transportRegex.test(url) && appAgentRegex.test(userAgent);
+ },
+ normalizeScale(url, method = "GET") {
+ return ["GET", "HEAD", "OPTIONS"].includes(method) ? url.replace(regex, (_match, prefix) => prefix) : url;
+ },
+ mergeAvailability(value) {
+ return JSON.parse(execFileSync("jq", ["-c", jq], { input: JSON.stringify(value), encoding: "utf8", maxBuffer: 1024 * 1024 }));
+ },
+ matchingScripts(url, stage = "http-request", context) {
+ return scripts.filter(script => script.stage === stage && script.regex.test(url) && (!script.accepts || script.accepts(context)));
+ },
+ };
+}
+
+export default function verifyLoonRewrites(plugin) {
+ const rules = readLoonRules(plugin),
+ scenarios = [];
+ assert.equal(rules.scripts.length, 5);
+ const v3URL = "https://weatherkit.apple.com/api/v3/weather/en-GB/53.38/-1.47?dataSets=airQuality";
+ const appAgent = "WeatherKit_Weather_iOS_Version 27.3 (Build fixture)",
+ context = { userAgent: appAgent, contentType: "application/json" };
+ assert.ok(rules.negotiatesJSON(v3URL, "GET", appAgent));
+ const macAgent = "WeatherKit_Weather_macOS_Version 27.3 (Build fixture)";
+ assert.ok(rules.negotiatesJSON(v3URL, "GET", macAgent));
+ assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, userAgent: macAgent }).length, 1);
+ scenarios.push("iOS and macOS main Weather app fixtures select the same guarded v3 transport");
+ for (const [url, method] of [
+ [v3URL, "POST"],
+ [v3URL.replace("https:", "http:"), "GET"],
+ [v3URL.replace("weatherkit.apple.com", "example.com"), "GET"],
+ [v3URL.replace("/v3/", "/v2/"), "GET"],
+ ])
+ assert.equal(rules.negotiatesJSON(url, method, appAgent), false);
+ scenarios.push("native v3 JSON negotiation changes only HTTPS Apple weather GET requests");
+ const [v3] = rules.matchingScripts(v3URL, "http-response", context);
+ assert.ok(v3?.line.includes("requires_body=true"));
+ assert.ok(v3.line.includes("${DataSets}"));
+ assert.doesNotMatch(v3.line, /RequestCache|Region\.Fallback|ProviderFallback/);
+ assert.doesNotMatch(v3.line, /binary_body_mode=true/);
+ assert.equal(rules.matchingScripts(v3URL, "http-response", context).length, 1);
+ assert.match(rules.matchingScripts(v3URL.replace("/v3/", "/v2/"), "http-response")[0].line, /binary-body-mode=1/);
+ scenarios.push("v3 text and v2 binary weather select exactly one body script each");
+ for (const userAgent of ["", "WeatherKit_destinationd_iOS_Version 27.3", "WeatherKit_nanoweatherd_watchOS_Version 27.3", "WeatherKit_WidgetExtension_iOS_Version 27.3"]) {
+ assert.equal(rules.negotiatesJSON(v3URL, "GET", userAgent), false);
+ assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, userAgent }).length, 0);
+ }
+ for (const contentType of [undefined, "application/vnd.apple.flatbuffer;messageType=WK3.Weather", "text/html"]) assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, contentType }).length, 0);
+ assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, status: 503 }).length, 0);
+ scenarios.push("background, watch, non-JSON and failed v3 responses start no body script");
+ const base = "https://weatherkit.apple.com/api/v1/airQualityScale/zh-Hans-CN/";
+ for (const [suffix, expected] of [
+ ["EU.EAQI.2604", "EU.EAQI"],
+ ["US.AQI.12?lang=en-US&v=1#s", "US.AQI?lang=en-US&v=1#s"],
+ ["HK.AQHI.8", "HK.AQHI"],
+ ["CN.AQHI.8", "CN.AQHI"],
+ ["EU.EAQI", "EU.EAQI"],
+ ["EU.EAQI.preview", "EU.EAQI.preview"],
+ ]) {
+ assert.equal(rules.normalizeScale(base + suffix), base + expected);
+ scenarios.push(`native scale URL: ${suffix}`);
+ }
+ assert.equal(rules.normalizeScale(`${base}EU.EAQI.12`, "POST"), `${base}EU.EAQI.12`);
+ assert.equal(rules.normalizeScale(`${base.replace("weatherkit.apple.com", "example.com")}EU.EAQI.12`), `${base.replace("weatherkit.apple.com", "example.com")}EU.EAQI.12`);
+ scenarios.push("URL normalization preserves POST and unrelated hosts");
+ for (const suffix of ["EU.EAQI.2604", "US.AQI.12", "HK.AQHI.8", "CN.AQHI.8"]) {
+ const url = rules.normalizeScale(base + suffix);
+ assert.equal(rules.matchingScripts(url).length, /^(HK|CN)/.test(suffix) ? 1 : 0);
+ }
+ assert.equal(rules.matchingScripts("https://weatherkit.apple.com/api/v1/availability/en-US/31.2/121.5", "http-response").length, 0);
+ scenarios.push("availability and ordinary scales start no JavaScript; custom scales use the small entry");
+ for (const prefix of ["", "lang=zh-CN&"]) {
+ for (const ids of ["fixture-101190110", "31.2,121.5", "-31.2,-121.5"]) assert.equal(rules.matchingScripts(`https://weatherkit.apple.com/api/v1/weatherAlerts?${prefix}ids=${ids}`).length, 1);
+ }
+ scenarios.push("alert routes accept first or subsequent ids parameters");
+ const merged = rules.mergeAvailability(["currentWeather", "weatherMaps", "currentWeather"]);
+ assert.deepEqual(merged.slice(0, 2), ["currentWeather", "weatherMaps"]);
+ assert.equal(new Set(merged).size, merged.length);
+ assert.ok(merged.includes("forecastNextHour") && merged.includes("weatherAlerts"));
+ scenarios.push("native jq keeps Apple ordering and unknown capabilities without duplicates");
+ for (const value of [{ error: "fixture" }, ["currentWeather", { future: true }], null, 7]) assert.deepEqual(rules.mergeAvailability(value), value);
+ assert.ok(rules.mergeAvailability([]).includes("airQuality"));
+ scenarios.push("native jq preserves unknown shapes and handles an empty capability list");
+ return { pass: scenarios.length, fail: 0, scenarios, validation: "emitted expressions, JavaScript regex fixtures and system jq; not a Loon parser", deviceValidation: false };
+}
diff --git a/src/class/AirQuality.mjs b/src/class/AirQuality.mjs
index b5c4b770..9ad4ef82 100644
--- a/src/class/AirQuality.mjs
+++ b/src/class/AirQuality.mjs
@@ -15,7 +15,49 @@ export default class AirQuality {
const multiplier = 10 ** decimals;
// 向上取整
- return Math.ceil(a * multiplier) / multiplier;
+ const scaled = a * multiplier;
+ return Math.ceil(scaled - Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) / multiplier;
+ }
+
+ static RequiredPollutants = ["PM2_5", "PM10", "OZONE", "NO2", "SO2", "CO"];
+
+ static ValidPollutants(pollutants) {
+ if (!Array.isArray(pollutants)) return [];
+ const units = AirQuality.Config.Units.Friendly, seen = new Set(), result = [];
+ for (const pollutant of pollutants) {
+ if (!pollutant || typeof pollutant.pollutantType !== "string" || !pollutant.pollutantType || seen.has(pollutant.pollutantType) || !Object.hasOwn(units, pollutant.units)) continue;
+ const raw = pollutant.amount;
+ const amount = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() ? Number(raw) : NaN;
+ if (!Number.isFinite(amount) || amount < 0) continue;
+ const value = { ...pollutant, amount };
+ if (!Number.isFinite(value.index) || value.index < 0) delete value.index;
+ seen.add(value.pollutantType);
+ result.push(value);
+ }
+ return result;
+ }
+
+ static MissingPollutants(pollutants) {
+ const present = new Set(AirQuality.ValidPollutants(pollutants).map(({ pollutantType }) => pollutantType));
+ return AirQuality.RequiredPollutants.filter(type => !present.has(type));
+ }
+
+ static MergePollutants(current, supplement) {
+ return AirQuality.ValidPollutants([...(Array.isArray(current) ? current : []), ...(Array.isArray(supplement) ? supplement : [])]);
+ }
+
+ static HasUsableIndex(airQuality) {
+ return Boolean(airQuality?.metadata && airQuality.metadata.temporarilyUnavailable !== true && typeof airQuality.scale === "string" && airQuality.scale.length && Number.isFinite(airQuality.index) && airQuality.index >= 0 && airQuality.index <= 65535 && Number.isInteger(airQuality.categoryIndex) && airQuality.categoryIndex >= 1 && airQuality.categoryIndex <= 255);
+ }
+
+ static #Unavailable(pollutants = []) {
+ return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants };
+ }
+
+ static #HasRiskInputs(pollutants, scale) {
+ const present = new Set(pollutants.filter(p => p.units === scale.pollutants[p.pollutantType]?.units && Number.isFinite(p.amount) && p.amount >= 0).map(p => p.pollutantType));
+ const hasPM = ["PM10", "PM2_5"].some(type => scale.pollutants[type] && present.has(type));
+ return Object.keys(scale.pollutants).every(type => type === "PM10" || type === "PM2_5" ? hasPM : present.has(type));
}
/**
@@ -44,8 +86,8 @@ export default class AirQuality {
const failedCategoryIndex = -1;
- if (!categories?.ranges || !Array.isArray(categories.ranges) || categories.ranges.length === 0) {
- Console.debug(`categories: ${JSON.stringify(categories)}`);
+ if (!Number.isFinite(index) || index < 0 || !categories?.ranges || !Array.isArray(categories.ranges) || categories.ranges.length === 0) {
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`categories: ${JSON.stringify(categories)}`);
Console.error("GetPrimaryPollutant", "categories无效");
return failedCategoryIndex;
}
@@ -64,17 +106,18 @@ export default class AirQuality {
}
static CompareCategoryIndexes(todayCategoryIndex, yesterdayCategoryIndex) {
- Console.info("☑️ CompareCategoryIndexes", `todayCategoryIndex: ${todayCategoryIndex}`, `yesterdayCategoryIndex: ${yesterdayCategoryIndex}`);
+ Console.info("☑️ CompareCategoryIndexes");
const { UNKNOWN, SAME, WORSE, BETTER } = AirQuality.Config.CompareCategoryIndexes;
- const today = Number(todayCategoryIndex);
- const yesterday = Number(yesterdayCategoryIndex);
-
- // -1、0 和 null 都表示“无可用等级”,不能参与好转/恶化比较。
- if (![today, yesterday].every(categoryIndex => Number.isFinite(categoryIndex) && categoryIndex > 0)) {
+ // Input-validation direction: Patrick Yan, PR #108 at 24c3f9311f051a9df9465238ee85b4a5f2c4f9c2, Apache-2.0.
+ // Preserve numeric strings; booleans, objects and fractional categories are not valid levels.
+ const validCategory = value => (typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isInteger(Number(value)) && Number(value) > 0;
+ if (![todayCategoryIndex, yesterdayCategoryIndex].every(validCategory)) {
Console.error("CompareCategoryIndexes", "categoryIndex无效");
return UNKNOWN;
} else {
+ const today = Number(todayCategoryIndex);
+ const yesterday = Number(yesterdayCategoryIndex);
const diff = today - yesterday;
if (diff === 0) {
Console.info("✅ CompareCategoryIndexes");
@@ -104,7 +147,7 @@ export default class AirQuality {
Console.info("ConvertUnits", `Converting ${pollutant.pollutantType}`);
const amount = AirQuality.ConvertUnit(pollutant.amount, pollutant.units, pollutantScale.units, stpConversionFactors?.[pollutant.pollutantType] || -1, pollutantScale?.stpConversionFactor || -1);
- if (amount < 0) {
+ if (!Number.isFinite(amount) || amount < 0) {
Console.error(
"ConvertUnits",
`无法转换${pollutant.pollutantType}的单位`,
@@ -125,30 +168,25 @@ export default class AirQuality {
*
* 说明:
* - 这是空气质量数据修复的统一入口(当前聚焦污染物单位修复);
- * - 当前仅处理和风天气(QWeather)CO 单位场景:其 CO 原始单位为 mg/m3,
- * 需转换为 WeatherKit 使用的 µg/m3;
+ * - QWeather 供应商结果按明确单位处理;
+ * - Apple 原始 QWeather 产品的 CO 数值为 mg/m3,但可能标成 µg/m3。
+ * 只有在原始 Apple 产品入口显式声明 appleProduct 时修复这个标签错误,
+ * 不对已归一化的供应商结果重复换算,也不按浓度大小猜测单位。
* - 后续同类修复请统一收敛到本方法内,必要时可将本方法升级为 FixAirQuality。
*
* @param {any} airQuality - 空气质量数据对象
+ * @param {{appleProduct?: boolean}} options - 仅原始 Apple 产品入口设为 true
* @returns {any} 单位修复后的空气质量数据对象
*/
- static FixPollutantsUnits(airQuality) {
+ static FixPollutantsUnits(airQuality, options = {}) {
Console.info("☑️ FixPollutantsUnits");
switch (airQuality?.metadata?.providerName) {
case "和风天气":
case "QWeather": {
- airQuality.pollutants = airQuality?.pollutants?.map(pollutant => {
- switch (pollutant.pollutantType) {
- case "CO": {
- const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit;
- pollutant.amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3);
- break;
- }
- default:
- break;
- }
- return pollutant;
- });
+ const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit;
+ airQuality = { ...airQuality, pollutants: airQuality?.pollutants?.map(pollutant => pollutant?.pollutantType === "CO" && (pollutant.units === mgm3 || options.appleProduct === true && pollutant.units === ugm3)
+ ? { ...pollutant, amount: AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3), units: ugm3 }
+ : pollutant) };
break;
}
default: {
@@ -175,12 +213,16 @@ export default class AirQuality {
static Pollutants2AQI(airQuality, Settings, options = {}) {
Console.info("☑️ Pollutants2AQI");
- const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm;
+ const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm ?? "UBA";
+ if (algorithm === "None") return airQuality;
const forcePrimaryPollutant = options?.forcePrimaryPollutant ?? Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants;
const allowOverRange = options?.allowOverRange ?? Settings?.AirQuality?.Calculate?.AllowOverRange;
const stpConversionFactors = AirQuality.#GetStpConversionFactors(airQuality);
- const scale = AirQuality.Config.Scales[algorithm] ?? AirQuality.Config.Scales.UBA;
+ const scale = AirQuality.Config.Scales[algorithm];
+ const pollutants = AirQuality.ValidPollutants(airQuality?.pollutants);
+ if (!scale || !pollutants.length) return AirQuality.#Unavailable(pollutants);
+ airQuality = { ...airQuality, pollutants };
switch (algorithm) {
case "None": {
@@ -196,7 +238,7 @@ export default class AirQuality {
case "WAQI_InstantCast_US": {
// PollutantsToInstantCastUS
Console.info("☑️ PollutantsToInstantCastUS", `allowOverRange: ${allowOverRange}`);
- airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange });
+ airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange, concentrationSteps: { PM2_5: 0.1, PM10: 1, OZONE: 0.1, NO2: 1, SO2: 1, CO: 100 } });
Console.info("✅ PollutantsToInstantCastUS");
break;
}
@@ -257,6 +299,7 @@ export default class AirQuality {
}
}
+ if (!Number.isFinite(airQuality.index) || airQuality.index < 0 || !Number.isInteger(airQuality.categoryIndex) || airQuality.categoryIndex < 1) return AirQuality.#Unavailable(airQuality.pollutants);
Console.info("✅ Pollutants2AQI");
return airQuality;
}
@@ -486,10 +529,12 @@ export default class AirQuality {
* @returns {number}
* 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。
*/
- static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits) {
- if (!pollutantScale) {
- Console.warn("PollutantsToIndexes", `标准未定义污染物${pollutantType},index将标记为-1`);
- return -1;
+ static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits, step) {
+ if (!pollutantScale || pollutant.units !== pollutantScale.units || !Number.isFinite(pollutant.amount) || pollutant.amount < 0) return undefined;
+ let amount = pollutant.amount;
+ if (step) {
+ const scaled = amount / step;
+ amount = Math.floor(scaled + Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) * step;
}
const { add, subtract, multiply, divide } = SimplePrecisionMath;
@@ -497,29 +542,33 @@ export default class AirQuality {
Console.debug(`${pollutantType}: ${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units}`);
const minValidAmount = pollutantScale.ranges.min.amounts[0];
- if (pollutant.amount < minValidAmount) {
+ if (amount < minValidAmount) {
Console.error("PollutantsToIndexes", `${pollutantType}的含量无效:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}需要 >= ${minValidAmount}`);
return pollutantScale.ranges.min.indexes[0];
}
- const isOverRange = pollutant.amount > pollutantScale.ranges.max.amounts[1];
+ const isOverRange = amount > pollutantScale.ranges.max.amounts[1];
if (isOverRange) {
Console.warn("PollutantsToIndexes", `检测到爆表指数!${pollutantType}:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}`);
Console.warn("PollutantsToIndexes", "注意身体!");
}
- const { indexes, amounts } = isOverRange
+ const range = isOverRange
? pollutantScale.ranges.max
: pollutantScale.ranges.value.find(({ amounts }) => {
const [minAmount, maxAmount] = amounts;
- return AirQuality.#CeilByPrecision(pollutant.amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(pollutant.amount, maxAmount) <= maxAmount;
+ return AirQuality.#CeilByPrecision(amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(amount, maxAmount) <= maxAmount;
});
- Console.debug(`indexes: ${JSON.stringify(indexes)}`, `amounts: ${JSON.stringify(amounts)}`);
+ if (!range) return undefined;
+ const { indexes, amounts } = range;
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`indexes: ${JSON.stringify(indexes)}`, `amounts: ${JSON.stringify(amounts)}`);
const [minIndex, maxIndex] = indexes;
const [minAmount, maxAmount] = amounts;
- const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(pollutant.amount, minAmount)), pollutant.amount > maxAmount ? maxIndex : minIndex);
- return index;
+ if (minIndex === maxIndex) return minIndex;
+ const concentration = isOverRange ? amount : Math.max(minAmount, Math.min(maxAmount, amount));
+ const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(concentration, minAmount)), minIndex);
+ return Number.isFinite(index) && index >= 0 ? index : undefined;
}
/**
@@ -547,12 +596,13 @@ export default class AirQuality {
*
* @returns {Array<{pollutantType: string, index: number}>}
* 每种标准污染物对应的 index 列表。
- * - 若标准所需污染物缺失,返回该污染物 index=-1。
+ * - 标准未定义的分项不输出 index,保留有效物理浓度。
*/
- static #PollutantsToIndexes(pollutants = [], pollutantScales) {
+ static #PollutantsToIndexes(pollutants = [], pollutantScales, concentrationSteps) {
return pollutants.map(pollutant => {
- const index = AirQuality.#ComputePollutantIndex(pollutant, pollutant.pollutantType, pollutantScales?.[pollutant.pollutantType], AirQuality.Config.Units.Friendly);
- pollutant.index = index;
+ const index = AirQuality.#ComputePollutantIndex(pollutant, pollutant.pollutantType, pollutantScales?.[pollutant.pollutantType], AirQuality.Config.Units.Friendly, concentrationSteps?.[pollutant.pollutantType]);
+ if (index === undefined) delete pollutant.index;
+ else pollutant.index = index;
return pollutant;
});
}
@@ -562,35 +612,19 @@ export default class AirQuality {
const failedPollutant = { pollutantType: "NOT_AVAILABLE", index: -1, categoryIndex: -1 };
if (!Array.isArray(pollutants) || pollutants.length === 0) {
- Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
Console.error("PrimaryPollutant", "pollutants无效");
return failedPollutant;
}
- const indexesWithCategory = pollutants
- .filter(({ index }) => Number.isFinite(index))
- .map(pollutant => ({
- ...pollutant,
- categoryIndex: AirQuality.CategoryIndex(pollutant.index, categories),
- }))
- .sort((a, b) => b.index - a.index);
-
- if (indexesWithCategory.length === 0) {
- Console.debug(`pollutants: ${JSON.stringify(pollutants)}`, `indexesWithCategory: ${JSON.stringify(indexesWithCategory)}`);
- Console.error("PrimaryPollutant", "找不到有效的index");
- return failedPollutant;
+ let primaryPollutant = failedPollutant;
+ for (const pollutant of pollutants) {
+ if (!Number.isFinite(pollutant.index) || pollutant.index < 0) continue;
+ const categoryIndex = AirQuality.CategoryIndex(pollutant.index, categories);
+ if (categoryIndex < 1) continue;
+ if (categoryIndex > primaryPollutant.categoryIndex || (categoryIndex === primaryPollutant.categoryIndex && pollutant.index > primaryPollutant.index)) primaryPollutant = { ...pollutant, categoryIndex };
}
-
- const maxCategoryIndex = indexesWithCategory.reduce((max, { categoryIndex }) => (categoryIndex > max ? categoryIndex : max), Number.NEGATIVE_INFINITY);
- const primaryPollutants = indexesWithCategory.filter(({ categoryIndex }) => categoryIndex === maxCategoryIndex);
-
- if (primaryPollutants.length > 1) {
- Console.warn("PrimaryPollutant", `检测到多个同级别的污染物,maxCategoryIndex:${maxCategoryIndex}`);
- primaryPollutants.map(({ pollutantType, index }) => Console.warn("GetPrimaryPollutants", `${pollutantType}:${index}`));
- }
-
- const primaryPollutant = primaryPollutants[0];
- Console.info("✅ PrimaryPollutant", `primaryPollutant: ${JSON.stringify(primaryPollutant)}`);
+ if (Console.logLevel === "INFO" || Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.info("✅ PrimaryPollutant", `primaryPollutant: ${JSON.stringify(primaryPollutant)}`);
return primaryPollutant;
}
@@ -640,18 +674,18 @@ export default class AirQuality {
const allowOverRange = options?.allowOverRange ?? true;
if (!Array.isArray(pollutants) || pollutants.length === 0) {
- Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
Console.error("PollutantsToAirQuality", "pollutants无效");
return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } };
}
- let newPollutants = [];
+ let newPollutants = pollutants;
if (stpConversionFactors) {
newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants);
}
- newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants);
- Console.debug("PollutantsToAirQuality", `convertedPollutants: ${JSON.stringify(newPollutants)}`);
+ newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants, options.concentrationSteps);
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug("PollutantsToAirQuality", `convertedPollutants: ${JSON.stringify(newPollutants)}`);
const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories);
const maxIndex = scale?.weatherKitScale?.maxIndex;
@@ -688,32 +722,27 @@ export default class AirQuality {
Console.info("☑️ PollutantsToAQHI");
if (!Array.isArray(pollutants) || pollutants.length === 0) {
- Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
Console.error("PollutantsToAQHI", "pollutants无效");
return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } };
}
const stpConversionFactors = options?.stpConversionFactors;
- const { ugm3 } = AirQuality.Config.Units.WeatherKit;
-
- // 先做单位转换(目标单位均为 µg/m³)
+ // 加拿大气体使用 ppb,其余模型按各自 scale 的单位计算。
const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants;
+ if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) return AirQuality.#Unavailable(convertedPollutants);
// 按 pollutantType 索引浓度
const getAmount = type => {
const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type);
- if (!p) return null;
- if (p.units !== ugm3) {
- Console.warn("PollutantsToAQHI", `${type} 单位不是 µg/m³(${p.units}),跳过`);
- return null;
- }
+ if (!scale.pollutants[type] || !p || p.units !== scale.pollutants[type].units) return null;
return p.amount;
};
const { betas } = scale;
const ar = (beta, concentration) => {
if (concentration === null || concentration < 0) return 0;
- return (Math.exp(beta * concentration) - 1) * 100;
+ return Math.expm1(beta * concentration) * 100;
};
const arNO2 = ar(betas.NO2 || 0, getAmount("NO2"));
@@ -724,10 +753,12 @@ export default class AirQuality {
const arPM = Math.max(arPM10, arPM2_5);
const totalAR = arNO2 + arSO2 + arO3 + arPM;
+ if (!Number.isFinite(totalAR)) return AirQuality.#Unavailable(convertedPollutants);
Console.info("PollutantsToAQHI", `%AR: NO2=${arNO2.toFixed(4)}, SO2=${arSO2.toFixed(4)}, O3=${arO3.toFixed(4)}, PM=${arPM.toFixed(4)} (PM10=${arPM10.toFixed(4)}, PM2.5=${arPM2_5.toFixed(4)}), total=${totalAR.toFixed(4)}`);
// 设置每个污染物的内部 index 为它的 %AR 值
convertedPollutants.forEach(p => {
+ if (!scale.pollutants[p.pollutantType]) { delete p.index; return; }
switch (p.pollutantType) {
case "NO2":
p.index = arNO2;
@@ -745,12 +776,12 @@ export default class AirQuality {
p.index = arPM2_5;
break;
default:
- p.index = -1;
+ delete p.index;
}
});
// 将 %AR 映射到 1–11 的 categoryIndex (即 AQHI 指数)
- const categoryIndex = AirQuality.CategoryIndex(totalAR, scale.categories);
+ const categoryIndex = scale.indexScaleFactor ? Math.max(1, Math.min(11, Math.round(totalAR * scale.indexScaleFactor))) : AirQuality.CategoryIndex(totalAR, scale.categories);
// 以最大 %AR 对应的污染物作为主污染物
const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 });
@@ -771,22 +802,18 @@ export default class AirQuality {
Console.info("☑️ PollutantsToAQHIMulti");
if (!Array.isArray(pollutants) || pollutants.length === 0) {
- Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
+ if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`);
Console.error("PollutantsToAQHIMulti", "pollutants无效");
return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } };
}
const stpConversionFactors = options?.stpConversionFactors;
- const { ugm3 } = AirQuality.Config.Units.WeatherKit;
const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants;
+ if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) return AirQuality.#Unavailable(convertedPollutants);
const getAmount = type => {
const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type);
- if (!p) return null;
- if (p.units !== ugm3) {
- Console.warn("PollutantsToAQHIMulti", `${type} 单位不是 µg/m³(${p.units}),跳过`);
- return null;
- }
+ if (!scale.pollutants[type] || !p || p.units !== scale.pollutants[type].units) return null;
return p.amount;
};
@@ -803,6 +830,7 @@ export default class AirQuality {
const pmContribution = usePM10 ? contributions.PM10 : contributions.PM2_5;
const totalWeighted = contributions.OZONE + contributions.NO2 + contributions.SO2 + pmContribution;
const totalRisk = (beta || 0) * totalWeighted;
+ if (!Number.isFinite(totalRisk)) return AirQuality.#Unavailable(convertedPollutants);
Console.info(
"PollutantsToAQHIMulti",
@@ -810,6 +838,7 @@ export default class AirQuality {
);
convertedPollutants.forEach(p => {
+ if (!scale.pollutants[p.pollutantType]) { delete p.index; return; }
switch (p.pollutantType) {
case "OZONE":
p.index = contributions.OZONE;
@@ -827,7 +856,7 @@ export default class AirQuality {
p.index = contributions.SO2;
break;
default:
- p.index = -1;
+ delete p.index;
}
});
@@ -1667,18 +1696,18 @@ export default class AirQuality {
},
CO: {
units: "PARTS_PER_BILLION",
- stpConversionFactor: 1.144511152104645,
+ stpConversionFactor: 1.14491990608754,
ranges: {
min: { indexes: [0, 50], amounts: [0, 4400] },
- max: { indexes: [301, 500], amounts: [30401, 50400] },
+ max: { indexes: [301, 500], amounts: [30500, 50400] },
value: [
{ indexes: [0, 50], amounts: [0, 4400] }, // Good
- { indexes: [51, 100], amounts: [4401, 9400] }, // Moderate
- { indexes: [101, 150], amounts: [9401, 12400] }, // Unhealthy for Sensitive Groups
- { indexes: [151, 200], amounts: [12401, 15400] }, // Unhealthy
- { indexes: [201, 300], amounts: [15401, 30400] }, // Very Unhealthy
- { indexes: [301, 500], amounts: [30401, 50400] }, // Hazardous
- { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50401, Number.POSITIVE_INFINITY] }, // Hazardous
+ { indexes: [51, 100], amounts: [4500, 9400] }, // Moderate
+ { indexes: [101, 150], amounts: [9500, 12400] }, // Unhealthy for Sensitive Groups
+ { indexes: [151, 200], amounts: [12500, 15400] }, // Unhealthy
+ { indexes: [201, 300], amounts: [15500, 30400] }, // Very Unhealthy
+ { indexes: [301, 500], amounts: [30500, 50400] }, // Hazardous
+ { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50500, Number.POSITIVE_INFINITY] }, // Hazardous
],
},
},
@@ -1858,7 +1887,7 @@ export default class AirQuality {
categories: {
significantIndex: 3, // 轻度污染
ranges: [
- { categoryIndex: 1, indexes: [1, 50] }, // 优
+ { categoryIndex: 1, indexes: [0, 50] }, // 优
{ categoryIndex: 2, indexes: [51, 100] }, // 良
{ categoryIndex: 3, indexes: [101, 150] }, // 轻度污染
{ categoryIndex: 4, indexes: [151, 200] }, // 中度污染
@@ -2045,15 +2074,17 @@ export default class AirQuality {
{ categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] },
],
},
- // β 系数(回归系数),浓度单位 µg/m³
+ // %AR × 10 / 10.4,四舍五入到 1–10,11 表示 10+。
+ indexScaleFactor: 10 / 10.4,
+ // NO2、O3 使用 ppb;PM2.5 使用 µg/m³。
betas: {
NO2: 0.000871,
O3: 0.000537,
PM2_5: 0.000487,
},
pollutants: {
- NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: -1 },
- OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: -1 },
+ NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.880472698306222 },
+ OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.96189649169881 },
PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 },
},
},
@@ -2279,7 +2310,8 @@ export default class AirQuality {
Console.info("☑️ ConvertUnit");
const friendlyUnits = AirQuality.Config.Units.Friendly;
Console.info(`Convert ${amount} from ${friendlyUnits[from]} with ${fromStpConversionFactor} to ${friendlyUnits[to]} with ${toStpConversionFactor}`);
- if (amount < 0) {
+ amount = typeof amount === "number" ? amount : typeof amount === "string" && amount.trim() ? Number(amount) : NaN;
+ if (!Number.isFinite(amount) || amount < 0) {
Console.error("ConvertUnit", `Amount ${amount} < 0`);
return -1;
}
@@ -2297,16 +2329,7 @@ export default class AirQuality {
const isPpxFrom = ppx.includes(from);
const isPpxTo = ppx.includes(to);
const isBothPpx = isPpxFrom && isPpxTo;
- if (isBothPpx && fromStpConversionFactor !== toStpConversionFactor) {
- if (fromStpConversionFactor <= 0 || toStpConversionFactor <= 0) {
- Console.error("ConvertUnit", "无效的STP conversion factor", `fromStpConversionFactor: ${fromStpConversionFactor}`, `toStpConversionFactor: ${toStpConversionFactor}`);
- return -1;
- }
-
- const intermediate = AirQuality.ConvertUnit(numAmount, from, ugm3, fromStpConversionFactor);
- return AirQuality.ConvertUnit(intermediate, ugm3, to, -1, toStpConversionFactor);
- }
-
+ // ppm/ppb 都是摩尔分数,只改变十进制倍率,不经过质量浓度绕行。
if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) {
Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`);
return -1;
diff --git a/src/class/ColorfulClouds.mjs b/src/class/ColorfulClouds.mjs
index 45cfaf7b..5d0a1de1 100644
--- a/src/class/ColorfulClouds.mjs
+++ b/src/class/ColorfulClouds.mjs
@@ -1,14 +1,18 @@
-import { Console, fetch, Lodash as _ } from "@nsnanocat/util";
-import Weather from "./Weather.mjs";
+// WeatherKit provider processing.
+import { Console } from "@nsnanocat/util";
+import fetch from "../function/fetchProvider.mjs";
+import providerNameToLogo from "../function/providerNameToLogo.mjs";
import AirQuality from "./AirQuality.mjs";
import ForecastNextHour from "./ForecastNextHour.mjs";
-import providerNameToLogo from "../function/providerNameToLogo.mjs";
+import ProviderTransport, { providerErrorCode, readProviderJSON } from "./ProviderTransport.mjs";
+import Weather from "./Weather.mjs";
export default class ColorfulClouds {
- constructor(parameters, token) {
+ constructor(parameters, token, transport = new ProviderTransport()) {
+ this.#requests = transport;
this.Name = "ColorfulClouds";
this.Version = "4.3.0";
- Console.log(`🟧 ${this.Name} v${this.Version}`);
+ Console.info(`🟧 ${this.Name} v${this.Version}`);
this.endpoint = `https://api.caiyunapp.com/v2.6/${token}/${parameters.longitude},${parameters.latitude}`;
this.headers = { Referer: "https://caiyunapp.com/" };
this.token = token;
@@ -25,10 +29,35 @@ export default class ColorfulClouds {
this.weatherKitLanguage = String(parameters.weatherKitLanguage ?? parameters.language ?? "").trim() || "zh-CN";
}
- #cache = {
- alert: {},
- realtime: {},
- };
+ #requests;
+
+ #Fetch(request, kind) {
+ const product = new URL(request.url).pathname.split("/").at(-1);
+ return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, {
+ fetcher: fetch,
+ includeJSON: true,
+ validate: body => {
+ if (product === "location") return !body?.error && Array.isArray(body?.alerts);
+ const result = body?.result;
+ if (body?.status !== "ok" || result?.[product]?.status !== "ok") return false;
+ if (product === "minutely") {
+ const { precipitation_2h: minutes, probability, description } = result.minutely;
+ return (
+ Number.isFinite(body.server_time) &&
+ Array.isArray(minutes) &&
+ minutes.length > 0 &&
+ minutes.length <= 8192 &&
+ minutes.every(value => Number.isFinite(value) && value >= 0) &&
+ Array.isArray(probability) &&
+ probability.length >= Math.ceil(Math.min(85, minutes.length) / 30) &&
+ probability.every(value => Number.isFinite(value) && value >= 0 && value <= 1) &&
+ (description === undefined || typeof description === "string")
+ );
+ }
+ return product !== "realtime" || kind !== "alerts" || (result.alert?.status === "ok" && Array.isArray(result.alert.content));
+ },
+ });
+ }
#Config = {
Language: {
@@ -206,39 +235,24 @@ export default class ColorfulClouds {
},
};
- async #RealTime() {
+ async #RealTime(kind = "current") {
Console.info("☑️ RealTime");
-
- if (this.#cache.realtime?.result?.realtime?.status === "ok") {
- Console.info("✅ RealTime", "Using cache");
- return this.#cache.realtime;
- }
-
const request = {
url: `${this.endpoint}/realtime?lang=${this.language}&alert=true`,
headers: this.headers,
};
try {
- const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}"));
+ const body = await this.#Fetch(request, kind).then(response => readProviderJSON(response));
switch (body?.status) {
case "ok": {
switch (body?.result?.realtime?.status) {
case "ok": {
- this.#cache.realtime = body;
break;
}
case "error":
case undefined:
throw Error(JSON.stringify({ status: body?.result?.realtime?.status, reason: body?.result?.realtime }));
}
- switch (body?.result?.alert?.status) {
- case "ok":
- this.#cache.alert = body.result.alert;
- break;
- case "error":
- case undefined:
- break;
- }
Console.info("✅ RealTime");
return body;
}
@@ -248,7 +262,7 @@ export default class ColorfulClouds {
throw Error(JSON.stringify(body ?? {}));
}
} catch (error) {
- Console.error(`RealTime: ${error}`);
+ Console.error(`RealTime: ${providerErrorCode(error)}`);
} finally {
//Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`);
Console.info("✅ RealTime");
@@ -270,7 +284,7 @@ export default class ColorfulClouds {
};
let forecastNextHour;
try {
- const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}"));
+ const body = await this.#Fetch(request).then(response => readProviderJSON(response));
switch (body?.status) {
case "ok":
switch (body?.result?.minutely?.status) {
@@ -297,7 +311,7 @@ export default class ColorfulClouds {
condition: [],
forecastEnd: 0,
forecastStart: minuteStemp,
- minutes: body?.result?.minutely?.precipitation_2h?.map((precipitationIntensity, index) => {
+ minutes: body.result.minutely.precipitation_2h.slice(0, 85).map((precipitationIntensity, index) => {
const minute = {
perceivedPrecipitationIntensity: 0,
precipitationChance: 0,
@@ -331,7 +345,7 @@ export default class ColorfulClouds {
throw Error(JSON.stringify(body ?? {}));
}
} catch (error) {
- Console.error(`Minutely: ${error}`);
+ Console.error(`Minutely: ${providerErrorCode(error)}`);
} finally {
//Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`);
Console.info("✅ Minutely");
@@ -340,8 +354,8 @@ export default class ColorfulClouds {
}
/**
- * 从彩云实况缓存读取 v2.6 预警,并标准化为 WeatherAlerts.mergeAlerts 可消费的来源结构。
- * Read Caiyun v2.6 alerts from the realtime cache and normalize them for WeatherAlerts.mergeAlerts.
+ * 拉取彩云 v2.6 预警,并标准化为 WeatherAlerts.mergeAlerts 可消费的来源结构。
+ * Fetch Caiyun v2.6 alerts and normalize them for WeatherAlerts.mergeAlerts.
* @link https://docs.caiyunapp.com/weather-api/v2/v2.6/5-alert.html
* @returns {Promise<{metadata: object, detailsUrl: string, alerts: Array