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

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, areaName: string, source: string}>} WeatherKit 顶级预警对象 / Top-level WeatherKit alert object. */ @@ -349,6 +363,7 @@ export default class ColorfulClouds { Console.info("☑️ WeatherAlert"); const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: "https://www.caiyunapp.com/h5", }, detailsUrl: `https://weatherkit.apple.com/alertDetails/index.html?ids=${this.latitude},${this.longitude}&lang=${encodeURIComponent(this.weatherKitLanguage)}&party=ColorfulClouds`, @@ -358,11 +373,13 @@ export default class ColorfulClouds { }; let weatherAlerts = failedWeatherAlerts; try { - await this.#RealTime(); - if (!Array.isArray(this.#cache.alert?.content)) throw Error(JSON.stringify(this.#cache.alert)); - weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(this.#cache.alert) }; + const response = await this.#RealTime("alerts"); + const alert = response?.result?.alert; + if (!Array.isArray(alert?.content)) throw Error("Unavailable provider alerts"); + weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(alert) }; + if (!weatherAlerts.metadata?.temporarilyUnavailable || weatherAlerts !== failedWeatherAlerts) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { - Console.error(`WeatherAlert: ${error}`); + Console.error(`WeatherAlert: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlert"); } @@ -378,6 +395,7 @@ export default class ColorfulClouds { Console.info("☑️ WeatherAlertV3CAP"); const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: "https://www.caiyunapp.com/h5", }, detailsUrl: `https://weatherkit.apple.com/alertDetails/index.html?ids=${this.latitude},${this.longitude}&lang=${encodeURIComponent(this.weatherKitLanguage)}&party=ColorfulClouds`, @@ -396,16 +414,16 @@ export default class ColorfulClouds { }; let weatherAlerts = failedWeatherAlerts; try { - const response = await fetch(request); - const body = JSON.parse(response?.body ?? "{}"); + const response = await this.#Fetch(request); + const body = readProviderJSON(response); if (response?.ok === false) { Console.warn("WeatherAlertV3CAP", `upstreamStatus: ${response.statusCode ?? response.status}`); return failedWeatherAlerts; } if (!Array.isArray(body?.alerts)) throw Error(JSON.stringify(body?.error ?? body?.reason ?? body?.code ?? body)); - weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlertsV3CAP(body) }; + weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlertsV3CAP(body), metadata: { ...failedWeatherAlerts.metadata, temporarilyUnavailable: false } }; } catch (error) { - Console.error(`WeatherAlertV3CAP: ${error}`); + Console.error(`WeatherAlertV3CAP: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlertV3CAP"); } @@ -420,7 +438,7 @@ export default class ColorfulClouds { }; if (begin) request.url += `&begin=${begin}`; 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?.hourly?.status) { @@ -439,7 +457,7 @@ export default class ColorfulClouds { throw Error(JSON.stringify(body ?? {})); } } catch (error) { - Console.error(`Hourly: ${error}`); + Console.error(`Hourly: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(this.airQuality, null, 2)}`); Console.info("✅ Hourly"); @@ -456,7 +474,7 @@ export default class ColorfulClouds { if (begin) request.url += `&begin=${begin}`; let forecastDaily; 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?.daily?.status) { @@ -519,7 +537,7 @@ export default class ColorfulClouds { daytimeForecast: { forecastStart: timeStamp + 8 * 3600, // 8 hours forecastEnd: timeStamp + 8 * 3600 + 12 * 3600, // 8 + 12 hours - cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given @@ -543,7 +561,7 @@ export default class ColorfulClouds { overnightForecast: { forecastStart: timeStamp + 20 * 3600, // 20 hours forecastEnd: timeStamp + 20 * 3600 + 12 * 3600, // 20 + 12 hours - cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given @@ -579,7 +597,7 @@ export default class ColorfulClouds { throw Error(JSON.stringify(body ?? {})); } } catch (error) { - Console.error(`Daily: ${error}`); + Console.error(`Daily: ${providerErrorCode(error)}`); } finally { //Console.debug(`Daily: ${JSON.stringify(Daily, null, 2)}`); Console.info("✅ Daily"); @@ -760,8 +778,8 @@ export default class ColorfulClouds { async CurrentWeather() { Console.info("☑️ CurrentWeather"); const realtime = await this.#RealTime(); - if (!realtime.result) { - Console.error("CurrentWeather", "无法获取realtime数"); + if (!realtime.result || !Number.isFinite(realtime.result.realtime?.temperature)) { + Console.error("CurrentWeather", "无法获取有效的实时气温"); return { metadata: this.#Metadata(undefined, undefined, true), }; @@ -787,7 +805,7 @@ export default class ColorfulClouds { async YesterdayAirQuality(useUsa = true) { Console.info("☑️ YesterdayAirQuality"); - const yesterdayHourly = await this.#Hourly(1, Math.trunc((Date.now() - 864e5) / 1000)); + const yesterdayHourly = await this.#Hourly(1, Math.floor(Date.now() / 3600000) * 3600 - 86400); const scale = useUsa ? AirQuality.Config.Scales.EPA_NowCast : AirQuality.Config.Scales.HJ6332012; const particularAirQuality = { previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, @@ -839,7 +857,7 @@ export default class ColorfulClouds { return { metadata: this.#Metadata(hourly.result.server_time, hourly.location), hours: Array.from({ length: hourly.result.hourly.skycon.length }, (_, i) => ({ - cloudCover: hourly.result.hourly.cloudrate[i].value, + cloudCover: Math.round(hourly.result.hourly.cloudrate[i].value * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given diff --git a/src/class/ForecastNextHour.mjs b/src/class/ForecastNextHour.mjs index ae2ed6e8..c07f9a7d 100644 --- a/src/class/ForecastNextHour.mjs +++ b/src/class/ForecastNextHour.mjs @@ -184,68 +184,39 @@ export default class ForecastNextHour { static Summary(minutes = []) { Console.info("☑️ Summary"); - const Summaries = []; - const Summary = { - condition: "CLEAR", - startTime: 0, - precipitationChance: 0, - precipitationIntensity: 0, - //beginCondition: "", - //endCondition: "", - maxCondition: "", - clear: true, - }; - const Length = Math.min(71, minutes.length); - for (let i = 0; i < Length; i++) { + const summaries = []; + let current; + const length = Math.min(71, minutes.length); + for (let i = 0; i < length; i++) { const minute = minutes[i]; - const previousMinute = minutes[i - 1]; - switch (i) { - case 0: // 第一个 - Summary.startTime = minute.startTime; - Summary.condition = minute.summaryCondition; // condition 只关心降水类型,不关心具体强弱描述 - Summary.precipitationChance = minute.precipitationChance; - Summary.precipitationIntensity = minute.precipitationIntensity; - //Summary.beginCondition = minute.condition; - //Summary.endCondition = ""; - Summary.maxCondition = minute.condition; - Summary.clear = minute.clear; - break; - case Length - 1: // 最后一个 - Summary.endTime = 0; // ⚠️空值必须写零! - //Summary.endCondition = minute.condition; - Summary.clear = minute.clear; - Console.debug(`Summaries[${i}]`, JSON.stringify({ ...minute, ...Summary }, null, 2)); - Summaries.push({ ...Summary }); - break; - default: // 中间 - if (minute.summaryCondition !== previousMinute.summaryCondition) { - // 结束当前summary - Summary.endTime = minute.startTime; - //Summary.endCondition = previousMinute.condition; - Console.debug(`Summaries[${i}]`, JSON.stringify({ ...previousMinute, ...Summary }, null, 2)); - Summaries.push({ ...Summary }); - - // 开始新的summary - Summary.startTime = minute.startTime; - Summary.condition = minute.summaryCondition; // condition 只关心降水类型,不关心具体强弱描述 - Summary.precipitationChance = minute.precipitationChance; - Summary.precipitationIntensity = minute.precipitationIntensity; - //Summary.beginCondition = minute.condition; - //Summary.endCondition = ""; // 重置 - Summary.maxCondition = minute.condition; // 重置 - Summary.clear = minute.clear; - } else { - // 条件相同,更新最大值 - Summary.precipitationChance = Math.max(Summary.precipitationChance, minute.precipitationChance); - Summary.precipitationIntensity = Math.max(Summary.precipitationIntensity, minute.precipitationIntensity); - if (Summary.precipitationIntensity === minute.precipitationIntensity) Summary.maxCondition = minute.condition; - } - break; + if (!current || minute.summaryCondition !== current.condition) { + if (current) { + current.endTime = minute.startTime; + summaries.push(current); + } + current = { + condition: minute.summaryCondition, + startTime: minute.startTime, + precipitationChance: minute.precipitationChance, + precipitationIntensity: minute.precipitationIntensity, + maxCondition: minute.condition, + clear: minute.clear, + }; + } else { + current.precipitationChance = Math.max(current.precipitationChance, minute.precipitationChance); + current.precipitationIntensity = Math.max(current.precipitationIntensity, minute.precipitationIntensity); + if (current.precipitationIntensity === minute.precipitationIntensity) current.maxCondition = minute.condition; } } - Console.debug(`Summaries: ${JSON.stringify(Summaries, null, 2)}`); + if (current) { + // The last sample is processed like every other sample. Zero keeps + // the existing open-ended WK2 summary/condition convention. + current.endTime = 0; + summaries.push(current); + } + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`Summaries: ${JSON.stringify(summaries, null, 2)}`); Console.info("✅ Summary"); - return Summaries; + return summaries; } static Condition(summaries = []) { diff --git a/src/class/ProviderTransport.mjs b/src/class/ProviderTransport.mjs new file mode 100644 index 00000000..3d480f3c --- /dev/null +++ b/src/class/ProviderTransport.mjs @@ -0,0 +1,47 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Per-request validation only. No response storage, merging, queue or retry policy. +export const PROVIDER_RESPONSE_LIMITS = Object.freeze({ responseCharacters: 1024 * 1024, maximumResponseCharacters: 2 * 1024 * 1024 }); +export class ProviderRequestError extends Error { + constructor(code) { + super(`WeatherKit provider request: ${code}`); + this.name = "ProviderRequestError"; + this.code = code; + } +} +export function providerErrorCode(error) { + if (error instanceof ProviderRequestError) return error.code; + return /timeout|timed out|超时/i.test(String(error?.message ?? error ?? "")) ? "timeout" : "network"; +} +const parsedJSON = Symbol("validated provider JSON"); +export function readProviderJSON(response) { + return response && Object.hasOwn(response, parsedJSON) ? response[parsedJSON] : JSON.parse(response?.body ?? "{}"); +} + +export default class ProviderTransport { + async fetch(request, { fetcher, validate, format = "json", maximumResponseCharacters = PROVIDER_RESPONSE_LIMITS.responseCharacters, allowNotModified = false, includeJSON = false } = {}) { + if (typeof fetcher !== "function" || typeof validate !== "function") throw new TypeError("Provider transport requires fetch and validation functions"); + let response; + try { + response = await fetcher(request); + } catch (error) { + throw new ProviderRequestError(providerErrorCode(error)); + } + const status = Number(response?.statusCode ?? response?.status); + if (!Number.isInteger(status)) throw new ProviderRequestError("invalid"); + const output = { ...response, headers: { ...response?.headers } }; + // Only the owner of an existing entity may opt into conditional reuse. + if (status === 304 && allowNotModified) return { ...output, body: "" }; + if (response?.ok === false || status < 200 || status >= 300) throw new ProviderRequestError(status === 429 ? "rate_limit" : status === 401 || status === 403 ? "auth" : "http"); + const maximum = Number.isFinite(maximumResponseCharacters) ? Math.max(1, Math.min(PROVIDER_RESPONSE_LIMITS.maximumResponseCharacters, maximumResponseCharacters)) : PROVIDER_RESPONSE_LIMITS.responseCharacters; + if (typeof response.body !== "string" || response.body.length > maximum) throw new ProviderRequestError("invalid"); + let value; + try { + value = format === "text" ? response.body : JSON.parse(response.body); + if (validate(value) !== true) throw new Error("Invalid provider data"); + } catch { + throw new ProviderRequestError("invalid"); + } + if (includeJSON && format !== "text") Object.defineProperty(output, parsedJSON, { value }); + return output; + } +} diff --git a/src/class/QWeather.mjs b/src/class/QWeather.mjs index b086d42b..b71b70e2 100644 --- a/src/class/QWeather.mjs +++ b/src/class/QWeather.mjs @@ -1,16 +1,21 @@ -import { Lodash as _, Console, fetch, time } from "@nsnanocat/util"; +// WeatherKit provider processing. +import { Lodash as _, Console, time } from "@nsnanocat/util"; import AirQuality from "../class/AirQuality.mjs"; +import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import utf8Length from "../function/utf8Length.mjs"; import ForecastNextHour from "./ForecastNextHour.mjs"; +import ProviderTransport, { ProviderRequestError, providerErrorCode, readProviderJSON } from "./ProviderTransport.mjs"; import Weather from "./Weather.mjs"; export default class QWeather { static #MaximumWeatherAlertPageSize = 2 * 1024 * 1024; - constructor(parameters, token, host = "devapi.qweather.com") { + constructor(parameters, token, host = "devapi.qweather.com", transport = new ProviderTransport()) { + this.#requests = transport; this.Name = "QWeather"; this.Version = "5.3.1"; - Console.log(`🟧 ${this.Name} v${this.Version}`); + Console.info(`🟧 ${this.Name} v${this.Version}`); this.endpoint = `https://${host}`; this.headers = { "X-QW-Api-Key": token }; this.version = parameters.version; @@ -26,9 +31,38 @@ export default class QWeather { this.weatherKitLanguage = String(parameters.weatherKitLanguage ?? parameters.language ?? "").trim() || "zh-CN"; } - #cache = { - airQualityCurrent: {}, - }; + #requests; + + #Fetch(request) { + const path = new URL(request.url).pathname; + return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { + fetcher: fetch, + includeJSON: true, + validate: body => { + if (body?.error) return false; + if (path.startsWith("/weatheralert/")) return Boolean(body?.metadata) && Array.isArray(body?.alerts); + if (path.startsWith("/airquality/")) return Array.isArray(body?.pollutants) && Array.isArray(body?.indexes) && body.indexes.some(index => typeof index?.code === "string" && index.aqi !== null && String(index.aqi).trim() !== "" && Number.isFinite(Number(index.aqi))); + if (body?.code !== "200") return false; + if (path.startsWith("/geo/")) return Array.isArray(body.location) && body.location.length > 0; + if (path.includes("/minutely/")) + return ( + typeof body.updateTime === "string" && + Number.isFinite(Date.parse(body.updateTime)) && + (body.summary === undefined || typeof body.summary === "string") && + Array.isArray(body.minutely) && + body.minutely.length > 0 && + body.minutely.length <= 8192 && + body.minutely.every(row => row && typeof row.fxTime === "string" && Number.isFinite(Date.parse(row.fxTime)) && (typeof row.precip === "number" || (typeof row.precip === "string" && row.precip.trim() !== "")) && Number.isFinite(Number(row.precip)) && Number(row.precip) >= 0) + ); + if (path.endsWith("/historical/air")) return Array.isArray(body.airHourly) && body.airHourly.length > 0; + if (path.endsWith("/weather/now")) return body.now?.temp !== undefined && body.now.temp !== null && String(body.now.temp).trim() !== "" && Number.isFinite(Number(body.now.temp)); + if (path.endsWith("/now")) return Boolean(body.now && typeof body.now === "object"); + if (/\/weather\/\d+h$/.test(path)) return Array.isArray(body.hourly) && body.hourly.length > 0; + if (/\/weather\/\d+d$/.test(path)) return Array.isArray(body.daily) && body.daily.length > 0; + return false; + }, + }); + } #Config = { Language: { @@ -77,20 +111,173 @@ export default class QWeather { // QWeather 没有提供 CAP category,本表按官方事件名称映射;具体类别必须先于 Met 匹配。 // QWeather does not provide CAP category; this table maps official event names, with specific categories matched before Met. EventCategories: [ - { category: "Geo", codes: [[1013, 1013], [1037, 1037], [1241, 1251], [1603, 1603], [2032, 2032], [2159, 2159], [2163, 2163], [2320, 2323], [2348, 2348], [2363, 2363], [2373, 2373], [2378, 2378], [2399, 2400], [3140, 3140], [3144, 3144]] }, - { category: "Safety", codes: [[1044, 1045], [1218, 1218], [2419, 2420], [2713, 2713]] }, + { + category: "Geo", + codes: [ + [1013, 1013], + [1037, 1037], + [1241, 1251], + [1603, 1603], + [2032, 2032], + [2159, 2159], + [2163, 2163], + [2320, 2323], + [2348, 2348], + [2363, 2363], + [2373, 2373], + [2378, 2378], + [2399, 2400], + [3140, 3140], + [3144, 3144], + ], + }, + { + category: "Safety", + codes: [ + [1044, 1045], + [1218, 1218], + [2419, 2420], + [2713, 2713], + ], + }, { category: "Security", codes: [] }, { category: "Rescue", codes: [] }, - { category: "Fire", codes: [[1025, 1026], [1041, 1041], [1077, 1077], [1084, 1084], [1605, 1605], [2005, 2005], [2132, 2132], [2158, 2158], [2192, 2192], [2207, 2207], [2302, 2302], [2349, 2349], [2414, 2414], [2743, 2743], [3139, 3139]] }, - { category: "Health", codes: [[1024, 1024], [1042, 1042], [1066, 1066], [1068, 1069], [1071, 1072], [1082, 1082], [1210, 1210], [2851, 2851]] }, - { category: "Env", codes: [[1029, 1029], [1032, 1032], [1067, 1067], [1074, 1074], [1217, 1217], [1271, 1274], [2202, 2202], [2374, 2374], [2389, 2389], [2413, 2413], [2527, 2527]] }, - { category: "Transport", codes: [[1021, 1021], [1046, 1046], [1057, 1057], [2077, 2078], [2300, 2301], [2328, 2328], [2360, 2360], [2375, 2376], [2385, 2388], [2415, 2415], [2554, 2554], [2722, 2723], [2791, 2797]] }, - { category: "Infra", codes: [[1081, 1081], [1203, 1204], [1216, 1216], [1221, 1221]] }, + { + category: "Fire", + codes: [ + [1025, 1026], + [1041, 1041], + [1077, 1077], + [1084, 1084], + [1605, 1605], + [2005, 2005], + [2132, 2132], + [2158, 2158], + [2192, 2192], + [2207, 2207], + [2302, 2302], + [2349, 2349], + [2414, 2414], + [2743, 2743], + [3139, 3139], + ], + }, + { + category: "Health", + codes: [ + [1024, 1024], + [1042, 1042], + [1066, 1066], + [1068, 1069], + [1071, 1072], + [1082, 1082], + [1210, 1210], + [2851, 2851], + ], + }, + { + category: "Env", + codes: [ + [1029, 1029], + [1032, 1032], + [1067, 1067], + [1074, 1074], + [1217, 1217], + [1271, 1274], + [2202, 2202], + [2374, 2374], + [2389, 2389], + [2413, 2413], + [2527, 2527], + ], + }, + { + category: "Transport", + codes: [ + [1021, 1021], + [1046, 1046], + [1057, 1057], + [2077, 2078], + [2300, 2301], + [2328, 2328], + [2360, 2360], + [2375, 2376], + [2385, 2388], + [2415, 2415], + [2554, 2554], + [2722, 2723], + [2791, 2797], + ], + }, + { + category: "Infra", + codes: [ + [1081, 1081], + [1203, 1204], + [1216, 1216], + [1221, 1221], + ], + }, { category: "CBRNE", codes: [] }, - { category: "Other", codes: [[2166, 2166], [3106, 3106], [3147, 3147], [9999, 9999]] }, + { + category: "Other", + codes: [ + [2166, 2166], + [3106, 3106], + [3147, 3147], + [9999, 9999], + ], + }, { category: "Met", - codes: [[1001, 1089], [1201, 1221], [1241, 1251], [1271, 1274], [1601, 1610], [1701, 1710], [1801, 1805], [2001, 2007], [2029, 2033], [2050, 2054], [2070, 2085], [2100, 2109], [2111, 2111], [2120, 2135], [2150, 2150], [2152, 2168], [2190, 2193], [2200, 2205], [2207, 2221], [2300, 2309], [2311, 2328], [2330, 2333], [2341, 2341], [2343, 2343], [2345, 2346], [2348, 2400], [2409, 2409], [2411, 2426], [2501, 2502], [2521, 2532], [2550, 2554], [2581, 2581], [2601, 2620], [2641, 2641], [2713, 2713], [2722, 2723], [2743, 2743], [2749, 2749], [2751, 2753], [2755, 2756], [2791, 2797], [2801, 2804], [2839, 2853], [2873, 2874], [3101, 3107], [3131, 3148]], + codes: [ + [1001, 1089], + [1201, 1221], + [1241, 1251], + [1271, 1274], + [1601, 1610], + [1701, 1710], + [1801, 1805], + [2001, 2007], + [2029, 2033], + [2050, 2054], + [2070, 2085], + [2100, 2109], + [2111, 2111], + [2120, 2135], + [2150, 2150], + [2152, 2168], + [2190, 2193], + [2200, 2205], + [2207, 2221], + [2300, 2309], + [2311, 2328], + [2330, 2333], + [2341, 2341], + [2343, 2343], + [2345, 2346], + [2348, 2400], + [2409, 2409], + [2411, 2426], + [2501, 2502], + [2521, 2532], + [2550, 2554], + [2581, 2581], + [2601, 2620], + [2641, 2641], + [2713, 2713], + [2722, 2723], + [2743, 2743], + [2749, 2749], + [2751, 2753], + [2755, 2756], + [2791, 2797], + [2801, 2804], + [2839, 2853], + [2873, 2874], + [3101, 3107], + [3131, 3148], + ], }, ], }, @@ -100,33 +287,55 @@ export default class QWeather { }, }; - static async GetLocationsGrid(qweatherCache, setCache) { + static #ValidLocationsGrid(data) { + if (!data || !Number.isFinite(data.gridSize) || data.gridSize <= 0 || !data.grid || typeof data.grid !== "object" || Array.isArray(data.grid)) return false; + // Application validation bounds, independent of native HTTP buffers. + let cells = 0, + locations = 0; + for (const key of Object.keys(data.grid)) { + if (++cells > 8192 || !/^-?\d+,-?\d+$/.test(key) || !Array.isArray(data.grid[key])) return false; + for (const location of data.grid[key]) { + if (++locations > 32768 || !location || !((typeof location.id === "string" && location.id.trim()) || Number.isFinite(location.id)) || !Number.isFinite(location.latitude) || Math.abs(location.latitude) > 90 || !Number.isFinite(location.longitude) || Math.abs(location.longitude) > 180) return false; + } + } + return locations > 0; + } + + static async GetLocationsGrid(qweatherCache, setCache, runtime = new ProviderTransport()) { Console.info("☑️ GetLocationsGrid"); const locationsGrid = qweatherCache?.locationsGrid; + const usable = QWeather.#ValidLocationsGrid(locationsGrid?.data); + const now = Date.now(); // cache within 30 days - if (locationsGrid?.lastUpdated && locationsGrid.lastUpdated + 30 * 24 * 60 * 60 * 1000 > Date.now()) { + if (usable && Number.isFinite(locationsGrid.lastUpdated) && now >= locationsGrid.lastUpdated && now - locationsGrid.lastUpdated < 30 * 24 * 60 * 60 * 1000) { Console.info("✅ GetLocationsGrid", "Cache found!"); return locationsGrid.data; } else { Console.info("⚠️ GetLocationsGrid", "Cache not found or stale, fetching..."); - const response = await fetch({ - headers: locationsGrid?.etag ? { "If-None-Match": locationsGrid?.etag } : undefined, - url: "https://raw.githubusercontent.com/NSRingo/QWeather-Location-Grid/refs/heads/main/data/qweather-china-city-list-grid.json", - }); - - if (response.status === 304) { + const etag = typeof locationsGrid?.etag === "string" && locationsGrid.etag.trim() && locationsGrid.etag.length <= 1024 ? locationsGrid.etag : undefined; + const revalidate = usable && Boolean(etag); + const response = await runtime.fetch( + { + headers: revalidate ? { "If-None-Match": etag } : undefined, + url: "https://raw.githubusercontent.com/NSRingo/QWeather-Location-Grid/refs/heads/main/data/qweather-china-city-list-grid.json", + }, + { fetcher: fetch, includeJSON: true, allowNotModified: revalidate, validate: QWeather.#ValidLocationsGrid, maximumResponseCharacters: 2 * 1024 * 1024 }, + ); + const notModified = Number(response.statusCode ?? response.status) === 304; + if (notModified && !revalidate) throw new ProviderRequestError("invalid"); + const data = notModified ? locationsGrid.data : readProviderJSON(response); + const returnedETag = Object.entries(response.headers ?? {}).find(([key]) => key.toLowerCase() === "etag")?.[1]; + const nextETag = typeof returnedETag === "string" && returnedETag.trim() && returnedETag.length <= 1024 ? returnedETag : notModified ? etag : undefined; + if (notModified) { Console.info("✅ GetLocationsGrid", "Cache not modified"); - setCache({ ...qweatherCache, locationsGrid: { ...locationsGrid, lastUpdated: Date.now() } }); - return locationsGrid.data; } - - const newLocationsGrid = JSON.parse(response.body); - setCache({ - ...qweatherCache, - locationsGrid: { etag: response.headers.ETag, lastUpdated: Date.now(), data: newLocationsGrid }, - }); + try { + if (typeof setCache === "function") setCache({ ...qweatherCache, locationsGrid: { ...locationsGrid, etag: nextETag, lastUpdated: Date.now(), data } }); + } catch { + /* Optional storage must not discard a validated response. */ + } Console.info("✅ GetLocationsGrid"); - return newLocationsGrid; + return data; } } @@ -323,48 +532,38 @@ export default class QWeather { * @param {Record} requestHeaders 原请求头 / Original request headers. * @returns {Promise<{alerts: Array, areaName: string, source: string}>} 来源集合 / Source collection. */ - static async FetchWeatherAlertPage(identifier, language = "zh-CN", requestHeaders = {}) { + static async FetchWeatherAlertPage(identifier, language = "zh-CN", requestHeaders = {}, runtime = new ProviderTransport()) { const sourceUrl = QWeather.BuildWeatherAlertPageURL(identifier, language); const normalizedHeaders = Object.fromEntries(Object.entries(requestHeaders).map(([key, value]) => [key.toLowerCase(), value])); - const sourceHeaders = { - Accept: "text/html,application/xhtml+xml", - "Accept-Language": normalizedHeaders["accept-language"] ?? language, - Referer: "https://www.qweather.com/", - }; + const sourceHeaders = { Accept: "text/html,application/xhtml+xml", "Accept-Language": normalizedHeaders["accept-language"] ?? language, Referer: "https://www.qweather.com/" }; if (normalizedHeaders["user-agent"]) sourceHeaders["User-Agent"] = normalizedHeaders["user-agent"]; - - Console.info("☑️ QWeather.FetchWeatherAlertPage", `url: ${sourceUrl}`, `language: ${language}`); - const sourceResponse = await fetch({ - url: sourceUrl.toString(), - headers: sourceHeaders, - "auto-cookie": false, - }); - const contentType = sourceResponse.headers?.["Content-Type"] ?? sourceResponse.headers?.["content-type"] ?? ""; - Console.info("QWeather.FetchWeatherAlertPage", `status: ${sourceResponse.statusCode ?? sourceResponse.status}`, `contentType: ${contentType || "undefined"}`); - if (!sourceResponse.ok) { - Console.warn("QWeather.FetchWeatherAlertPage", `upstreamStatus: ${sourceResponse.statusCode ?? sourceResponse.status}`); - return { alerts: [], areaName: "", source: "QWeather" }; - } - if (!contentType.toLowerCase().includes("text/html")) { - Console.warn("QWeather.FetchWeatherAlertPage", `unexpectedContentType: ${contentType || "undefined"}`); - return { alerts: [], areaName: "", source: "QWeather" }; + const unavailable = { alerts: [], areaName: "", source: "QWeather", pageValid: false }; + let response; + try { + response = await runtime.fetch( + { url: sourceUrl.toString(), headers: sourceHeaders, "auto-cookie": false }, + { + includeJSON: true, + validate: body => body?.pageValid === true && Array.isArray(body.alerts) && typeof body.areaName === "string", + fetcher: async request => { + const source = await fetch(request); + const type = String(source.headers?.["Content-Type"] ?? source.headers?.["content-type"] ?? "").toLowerCase(); + const html = typeof source.body === "string" ? source.body : ""; + if (html.length > QWeather.#MaximumWeatherAlertPageSize || utf8Length(html, QWeather.#MaximumWeatherAlertPageSize) > QWeather.#MaximumWeatherAlertPageSize) throw new RangeError("QWeather alert page is too large"); + const recognized = /\bc-submenu__location\b/.test(html) && /c-city-warning|暂无预警|无生效预警|No(?: active)? (?:weather )?warnings/i.test(html); + const page = source.ok && type.includes("text/html") && recognized ? { ...QWeather.ExtractWeatherAlertPage(html), pageValid: true } : unavailable; + // Persist only parsed alert records, not an entire HTML document. + return { ...source, body: JSON.stringify(page) }; + }, + }, + ); + } catch { + return { ...unavailable, unavailable: true, metadata: { temporarilyUnavailable: true } }; } - const html = String(sourceResponse.body ?? ""); - const sourceSize = new TextEncoder().encode(html).byteLength; - Console.debug("QWeather.FetchWeatherAlertPage", `bodyBytes: ${sourceSize}`); - if (sourceSize > QWeather.#MaximumWeatherAlertPageSize) throw new RangeError("QWeather alert page is too large"); - - const extracted = QWeather.ExtractWeatherAlertPage(html); - Console.info("✅ QWeather.FetchWeatherAlertPage", `alerts: ${extracted.alerts.length}`, `source: ${extracted.source}`); + const parsed = readProviderJSON(response); + if (!response.ok || parsed.pageValid !== true) return { ...unavailable, unavailable: true, metadata: { temporarilyUnavailable: true } }; const areaId = identifier.match(/-(\d+)$/)?.[1]; - return { - ...extracted, - alerts: extracted.alerts.map(alert => ({ - ...alert, - ...(areaId && !alert.areaId ? { areaId } : {}), - ...(extracted.areaName && !alert.areaName ? { areaName: extracted.areaName } : {}), - })), - }; + return { alerts: parsed.alerts.map(alert => ({ ...alert, ...(areaId && !alert.areaId ? { areaId } : {}), ...(parsed.areaName && !alert.areaName ? { areaName: parsed.areaName } : {}) })), areaName: parsed.areaName, source: parsed.source, metadata: { temporarilyUnavailable: false } }; } async GeoAPI(path = "city/lookup") { @@ -375,7 +574,7 @@ export default class QWeather { }; let metadata; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": metadata = { @@ -390,9 +589,9 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`GeoAPI: ${error}`); + Console.error(`GeoAPI: ${providerErrorCode(error)}`); } finally { - Console.debug(`metadata: ${JSON.stringify(metadata, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`metadata: ${JSON.stringify(metadata, null, 2)}`); Console.info("✅ GeoAPI"); } return metadata; @@ -407,6 +606,7 @@ export default class QWeather { Console.info("☑️ WeatherAlert"); const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: "https://developer.qweather.com/attribution.html", }, detailsUrl: `https://weatherkit.apple.com/alertDetails/index.html?ids=${this.latitude},${this.longitude}&lang=${encodeURIComponent(this.weatherKitLanguage)}&party=QWeather`, @@ -423,16 +623,17 @@ export default class QWeather { }; let weatherAlerts = failedWeatherAlerts; try { - const response = await fetch(request); - const body = JSON.parse(response?.body ?? "{}"); + const response = await this.#Fetch(request); + const body = readProviderJSON(response); if (response?.ok === false) { Console.warn("WeatherAlert", `upstreamStatus: ${response.statusCode ?? response.status}`); return failedWeatherAlerts; } if (!body?.metadata || !Array.isArray(body?.alerts)) throw Error(JSON.stringify(body?.error ?? body?.code ?? body)); weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(body) }; + if (!weatherAlerts.metadata?.temporarilyUnavailable || weatherAlerts !== failedWeatherAlerts) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { - Console.error(`WeatherAlert: ${error}`); + Console.error(`WeatherAlert: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlert"); } @@ -454,6 +655,7 @@ export default class QWeather { } const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: String(url), }, detailsUrl: QWeather.BuildAppleAlertDetailsURL(identifier, this.weatherKitLanguage), @@ -463,9 +665,11 @@ export default class QWeather { }; let weatherAlerts = failedWeatherAlerts; try { - weatherAlerts = { ...failedWeatherAlerts, ...(await QWeather.FetchWeatherAlertPage(identifier, this.weatherKitLanguage)) }; + const source = await QWeather.FetchWeatherAlertPage(identifier, this.weatherKitLanguage, {}, this.#requests); + weatherAlerts = { ...failedWeatherAlerts, ...source, metadata: { ...failedWeatherAlerts.metadata, ...source.metadata } }; + if (weatherAlerts !== failedWeatherAlerts && !weatherAlerts.unavailable) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { - Console.error(`WeatherAlertWeb: ${error}`); + Console.error(`WeatherAlertWeb: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlertWeb"); } @@ -480,7 +684,7 @@ export default class QWeather { }; let currentWeather; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -524,7 +728,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`WeatherNow: ${error}`); + Console.error(`WeatherNow: ${providerErrorCode(error)}`); } finally { //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); Console.info("✅ WeatherNow"); @@ -540,7 +744,7 @@ export default class QWeather { }; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -581,7 +785,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`AirNow: ${error}`); + Console.error(`AirNow: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AirNow"); @@ -592,27 +796,21 @@ export default class QWeather { async #AirQualityCurrent() { Console.info("☑️ AirQualityCurrent"); - if (this.#cache.airQualityCurrent?.metadata?.tag && !this.#cache.airQualityCurrent?.error) { - Console.info("✅ AirQualityCurrent", "Using cache"); - return this.#cache.airQualityCurrent; - } - const request = { url: `${this.endpoint}/airquality/v1/current/${this.latitude}/${this.longitude}`, headers: this.headers, }; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.error) { case undefined: { - this.#cache.airQualityCurrent = body; return body; } default: throw Error(JSON.stringify(body?.error, null, 2)); } } catch (error) { - Console.error(`AirQualityCurrent: ${error}`); + Console.error(`AirQualityCurrent: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AirQualityCurrent"); @@ -634,7 +832,7 @@ export default class QWeather { }; 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?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -658,8 +856,9 @@ export default class QWeather { condition: [], forecastEnd: 0, forecastStart: minuteStemp, - minutes: body?.minutely - ?.map((minutely, index) => { + minutes: body.minutely + .slice(0, 17) + .map(minutely => { const minute = { perceivedPrecipitationIntensity: 0, precipitationChance: 0, @@ -695,7 +894,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`Minutely: ${error}`); + Console.error(`Minutely: ${providerErrorCode(error)}`); } finally { //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); Console.info("✅ Minutely"); @@ -711,7 +910,7 @@ export default class QWeather { }; let forecastHourly; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -743,7 +942,7 @@ export default class QWeather { // perceivedPrecipitationIntensity: "", // Not given precipitationAmount: Number.parseFloat(hourly?.precip), precipitationChance: Number.parseInt(hourly?.pop, 10), - precipitationIntensity: Number.parseInt(hourly?.precip, 10), + precipitationIntensity: Number.parseFloat(hourly?.precip), // precipitationType: "", // Not given pressure: Number.parseFloat(hourly?.pressure), // pressureTrend: "", // Not given @@ -774,7 +973,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`Hourly: ${error}`); + Console.error(`Hourly: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(forecastHourly, null, 2)}`); Console.info("✅ Hourly"); @@ -790,7 +989,7 @@ export default class QWeather { }; let forecastDaily; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -919,7 +1118,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`Daily: ${error}`); + Console.error(`Daily: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(forecastDaily, null, 2)}`); Console.info("✅ Daily"); @@ -934,7 +1133,7 @@ export default class QWeather { headers: this.headers, }; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { return body; @@ -951,7 +1150,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`HistoricalAir: ${error}`); + Console.error(`HistoricalAir: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ HistoricalAir"); @@ -1051,7 +1250,7 @@ export default class QWeather { */ #CreatePollutants(pollutantsObj, scaleCode) { Console.info("☑️ CreatePollutants"); - Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); // TODO: what is ppmC? https://dev.qweather.com/docs/resource/air-info/#pollutants const pollutants = pollutantsObj @@ -1086,7 +1285,7 @@ export default class QWeather { */ #CreatePollutantsV7(pollutantsObj) { Console.info("☑️ CreatePollutantsV7"); - Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; const pollutants = Object.entries(pollutantsObj) @@ -1247,7 +1446,7 @@ export default class QWeather { } async YesterdayAirQuality(locationInfo) { - Console.info("☑️ YesterdayAirQuality", `locationInfo ${JSON.stringify(locationInfo)}`); + if (Console.logLevel === "INFO" || Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.info("☑️ YesterdayAirQuality", `locationInfo ${JSON.stringify(locationInfo)}`); const failedAirQuality = { metadata: this.#Metadata(undefined, undefined, true), categoryIndex: -1, @@ -1278,7 +1477,7 @@ export default class QWeather { const pollutants = this.#CreatePollutantsV7(historicalAir.airHourly[hour]); Console.debug(`hour: ${hour}`, `index: ${index}`); - Console.info("✅ YesterdayAirQuality", `pollutants: ${JSON.stringify(pollutants)}`, `categoryIndex: ${categoryIndex}`); + if (Console.logLevel === "INFO" || Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.info("✅ YesterdayAirQuality", `pollutants: ${JSON.stringify(pollutants)}`, `categoryIndex: ${categoryIndex}`); return { metadata: this.#Metadata(historicalAir.fxLink), categoryIndex, diff --git a/src/class/WAQI.mjs b/src/class/WAQI.mjs index 9eed0343..58536eb6 100644 --- a/src/class/WAQI.mjs +++ b/src/class/WAQI.mjs @@ -1,12 +1,16 @@ -import { Console, fetch } from "@nsnanocat/util"; +// WeatherKit provider processing. +import { Console } from "@nsnanocat/util"; import AirQuality from "../class/AirQuality.mjs"; +import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import ProviderTransport, { providerErrorCode, readProviderJSON } from "./ProviderTransport.mjs"; export default class WAQI { - constructor(parameters, token) { + constructor(parameters, token, transport = new ProviderTransport()) { + this.#requests = transport; this.Name = "WAQI"; this.Version = "1.4.3"; - Console.log(`🟧 ${this.Name} v${this.Version}`); + Console.info(`🟧 ${this.Name} v${this.Version}`); this.headers = { Accept: "application/json" }; this.token = token; this.version = parameters.version; @@ -16,6 +20,20 @@ export default class WAQI { this.country = parameters.country; } + #requests; + + #Fetch(request) { + return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { + fetcher: fetch, + includeJSON: true, + validate: body => { + if (request.url.includes("/mapq/")) return body?.status !== "error" && Array.isArray(body?.d) && body.d.length > 0; + if (request.url.includes("/mapq2/")) return body?.status === "ok" && Array.isArray(body?.data?.stations) && body.data.stations.length > 0; + return body?.status === "ok" && Boolean(body?.data?.city); + }, + }); + } + #Configs = { Pollutants: { co: "CO", @@ -56,7 +74,7 @@ export default class WAQI { }; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (mapqVersion) { case "mapq": switch (body?.status) { @@ -121,7 +139,7 @@ export default class WAQI { break; } } catch (error) { - Console.error(`Nearest: ${error}`); + Console.error(`Nearest: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ Nearest"); @@ -137,7 +155,12 @@ export default class WAQI { }; let token; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#requests + .fetch(request, { + fetcher: fetch, + validate: value => value?.rxs?.status === "ok" && value.rxs.obs?.[0]?.status === "ok" && typeof value.rxs.obs[0].msg?.token === "string" && value.rxs.obs[0].msg.token.trim().length > 0, + }) + .then(response => readProviderJSON(response)); switch (body?.status) { case "error": throw JSON.stringify({ status: body?.status, reason: body?.data }); @@ -160,7 +183,7 @@ export default class WAQI { break; } } catch (error) { - Console.error(`Token: ${error}`); + Console.error(`Token: ${providerErrorCode(error)}`); } finally { //Console.debug(`token: ${token}`); Console.info("✅ Token"); @@ -169,15 +192,20 @@ export default class WAQI { } async AQI(stationId = Number(), token = this.token) { - Console.info("☑️ AQI", `stationId: ${stationId}`, `token: ${token}`); + Console.info("☑️ AQI", `stationId: ${stationId}`); const request = { url: `https://api.waqi.info/api/feed/@${stationId}/aqi.json`, headers: this.headers, - body: `token=${token}&id=${stationId}`, + body: `token=${encodeURIComponent(token)}&id=${encodeURIComponent(stationId)}`, }; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#requests + .fetch(request, { + fetcher: fetch, + validate: value => value?.rxs?.status === "ok" && value.rxs.obs?.[0]?.status === "ok" && Boolean(value.rxs.obs[0].msg?.city) && value.rxs.obs[0].msg.aqi !== null && String(value.rxs.obs[0].msg.aqi).trim() !== "" && Number.isFinite(Number(value.rxs.obs[0].msg.aqi)), + }) + .then(response => readProviderJSON(response)); switch (body?.status) { case "error": throw JSON.stringify({ status: body?.status, reason: body?.data }); @@ -220,7 +248,7 @@ export default class WAQI { break; } } catch (error) { - Console.error(`AQI: ${error}`); + Console.error(`AQI: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AQI"); @@ -237,7 +265,7 @@ export default class WAQI { if (stationId) request.url = `https://api2.waqi.info/feed/@${stationId}/?token=${token}`; let airQuality; 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": { const timeStamp = (Date.now() / 1000) | 0; @@ -266,7 +294,7 @@ export default class WAQI { throw JSON.stringify({ status: body?.status, reason: body?.data }); } } catch (error) { - Console.error(`AQI2: ${error}`); + Console.error(`AQI2: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AQI2"); diff --git a/src/class/WeatherAlerts.mjs b/src/class/WeatherAlerts.mjs index 4a1f0615..54c7c94f 100644 --- a/src/class/WeatherAlerts.mjs +++ b/src/class/WeatherAlerts.mjs @@ -1,4 +1,5 @@ import { Console } from "@nsnanocat/util"; +import { knownWeatherAlertExpiry, UNKNOWN_WEATHER_ALERT_EXPIRY } from "../function/weatherAlertExpiry.mjs"; /** * 从来源页面提取的预警记录。 @@ -122,13 +123,14 @@ export default class WeatherAlerts { static #FillAlert(appleAlert, sourceAlert, appleAlertIndex, sourceAlertIndex) { let before; if (WeatherAlerts.#IsDebug()) before = WeatherAlerts.#WeatherAlertDebugSnapshot(appleAlert, appleAlertIndex); - const sourceExpireTime = sourceAlert.expireTime === "9999-12-31T23:59:59Z" ? undefined : sourceAlert.expireTime; + const sourceExpireTime = knownWeatherAlertExpiry(sourceAlert.expireTime); + const sourceEndTime = knownWeatherAlertExpiry(sourceAlert.eventEndTime); WeatherAlerts.#FillText(appleAlert, "areaId", sourceAlert.areaId); WeatherAlerts.#FillText(appleAlert, "areaName", sourceAlert.areaName); WeatherAlerts.#FillTime(appleAlert, "effectiveTime", sourceAlert.effectiveTime ?? sourceAlert.issuedTime); WeatherAlerts.#FillTime(appleAlert, "eventOnsetTime", sourceAlert.eventOnsetTime ?? sourceAlert.effectiveTime ?? sourceAlert.issuedTime ?? appleAlert.effectiveTime ?? appleAlert.issuedTime); - WeatherAlerts.#FillTime(appleAlert, "eventEndTime", sourceAlert.eventEndTime ?? sourceExpireTime ?? appleAlert.expireTime); - WeatherAlerts.#FillTime(appleAlert, "expireTime", sourceExpireTime ?? sourceAlert.eventEndTime); + WeatherAlerts.#FillTime(appleAlert, "eventEndTime", sourceEndTime ?? sourceExpireTime ?? (knownWeatherAlertExpiry(appleAlert.expireTime) || undefined)); + WeatherAlerts.#FillTime(appleAlert, "expireTime", sourceExpireTime ?? sourceEndTime); WeatherAlerts.#FillTime(appleAlert, "issuedTime", sourceAlert.issuedTime ?? sourceAlert.effectiveTime); WeatherAlerts.#FillDescription(appleAlert, sourceAlert); WeatherAlerts.#FillText(appleAlert, "source", sourceAlert.source); @@ -366,9 +368,10 @@ export default class WeatherAlerts { const areaId = alert.areaId || contextAreaId; const areaName = alert.areaName || extracted.areaName; const effectiveTime = alert.effectiveTime ?? alert.issuedTime; - const expireTime = alert.expireTime ?? "9999-12-31T23:59:59Z"; + const sourceExpireTime = knownWeatherAlertExpiry(alert.expireTime); + const expireTime = sourceExpireTime ?? UNKNOWN_WEATHER_ALERT_EXPIRY; const eventOnsetTime = alert.eventOnsetTime ?? effectiveTime; - const eventEndTime = alert.eventEndTime ?? (alert.expireTime ? expireTime : undefined); + const eventEndTime = knownWeatherAlertExpiry(alert.eventEndTime) ?? sourceExpireTime; const source = alert.source || extracted.source || ""; const importance = alert.importance || WeatherAlerts.#ImportanceFromSeverity(alert.severity); const phenomenon = alert.phenomenon; diff --git a/src/class/WeatherKit2.mjs b/src/class/WeatherKit2.mjs index 09db4f40..5a4cdc20 100644 --- a/src/class/WeatherKit2.mjs +++ b/src/class/WeatherKit2.mjs @@ -1,6 +1,8 @@ +import "../function/ensureTextEncoder.mjs"; import { FlatBufferRootProcessor } from "@nsnanocat/flatbuffer-root"; import * as WK2 from "@nsringo/weatherkit"; import database from "../function/database.mjs"; +import { decodedVectorLength, newDecodeBudget } from "../function/weatherMemoryLimits.mjs"; /** * 使用 Weather 根模型和产品 codec 配置通用根表处理器。 @@ -24,6 +26,15 @@ const WeatherKit2 = new FlatBufferRootProcessor({ rootClass: WK2.Weather, }); +// O(1) table presence probe; no minute vector decoding or copying. +export function hasForecastNextHour(byteBuffer) { + try { + return Boolean(WK2.Weather.getRootAsWeather(byteBuffer).forecastNextHour()); + } catch { + return false; + } +} + export default WeatherKit2; function encodeAirQuality(builder, data) { @@ -40,27 +51,27 @@ function encodeCurrentWeather(builder, data) { const metadataOffset = encodeMetadata(builder, data.metadata); const precipitationAmountNext1hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext1hByTypeVector( builder, - data.precipitationAmountNext1hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountNext1hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountNext24hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext24hByTypeVector( builder, - data.precipitationAmountNext24hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountNext24hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountNext6hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext6hByTypeVector( builder, - data.precipitationAmountNext6hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountNext6hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountPrevious1hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious1hByTypeVector( builder, - data.precipitationAmountPrevious1hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountPrevious1hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountPrevious24hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious24hByTypeVector( builder, - data.precipitationAmountPrevious24hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountPrevious24hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountPrevious6hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious6hByTypeVector( builder, - data.precipitationAmountPrevious6hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountPrevious6hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); return WK2.CurrentWeatherData.createCurrentWeatherData( builder, @@ -428,6 +439,7 @@ function encodeLocationInfo(builder, data) { } function decodeAirQuality(data) { + const budget = newDecodeBudget(); const AirQualityData = data; data = { metadata: decodeMetadata(AirQualityData.metadata()), @@ -439,7 +451,7 @@ function decodeAirQuality(data) { primaryPollutant: WK2.PollutantType[AirQualityData.primaryPollutant()], scale: AirQualityData.scale(), }; - for (let i = 0; i < AirQualityData.pollutantsLength(); i++) + for (let i = 0, count = decodedVectorLength(AirQualityData.pollutantsLength(), budget); i < count; i++) data.pollutants.push({ amount: AirQualityData.pollutants(i).amount(), pollutantType: WK2.PollutantType[AirQualityData.pollutants(i).pollutantType()], @@ -449,6 +461,7 @@ function decodeAirQuality(data) { } function decodeCurrentWeather(data) { + const budget = newDecodeBudget(); const CurrentWeatherData = data; data = { metadata: decodeMetadata(CurrentWeatherData.metadata()), @@ -492,7 +505,7 @@ function decodeCurrentWeather(data) { windGust: CurrentWeatherData.windGust(), windSpeed: CurrentWeatherData.windSpeed(), }; - for (let i = 0; i < CurrentWeatherData.precipitationAmountNext1hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountNext1hByTypeLength(), budget); i < count; i++) data.precipitationAmountNext1hByType.push({ expected: CurrentWeatherData.precipitationAmountNext1hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).expectedSnow(), @@ -500,7 +513,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext1hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountNext24hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountNext24hByTypeLength(), budget); i < count; i++) data.precipitationAmountNext24hByType.push({ expected: CurrentWeatherData.precipitationAmountNext24hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).expectedSnow(), @@ -508,7 +521,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext24hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountNext6hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountNext6hByTypeLength(), budget); i < count; i++) data.precipitationAmountNext6hByType.push({ expected: CurrentWeatherData.precipitationAmountNext6hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).expectedSnow(), @@ -516,7 +529,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext6hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious1hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountPrevious1hByTypeLength(), budget); i < count; i++) data.precipitationAmountPrevious1hByType.push({ expected: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expectedSnow(), @@ -524,7 +537,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious1hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious24hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountPrevious24hByTypeLength(), budget); i < count; i++) data.precipitationAmountPrevious24hByType.push({ expected: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expectedSnow(), @@ -532,7 +545,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious24hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious6hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountPrevious6hByTypeLength(), budget); i < count; i++) data.precipitationAmountPrevious6hByType.push({ expected: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expectedSnow(), @@ -544,179 +557,184 @@ function decodeCurrentWeather(data) { } function decodeForecastDaily(data) { + const budget = newDecodeBudget(); const DailyForecastData = data; data = { metadata: decodeMetadata(DailyForecastData.metadata()), days: [], }; - for (let i = 0; i < DailyForecastData.daysLength(); i++) { + for (let i = 0, count = decodedVectorLength(DailyForecastData.daysLength(), budget); i < count; i++) { + const sourceDay = DailyForecastData.days(i); const day = { - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).conditionCode()], - forecastEnd: DailyForecastData.days(i).forecastEnd(), - forecastStart: DailyForecastData.days(i).forecastStart(), - humidityMax: DailyForecastData.days(i).humidityMax(), - humidityMin: DailyForecastData.days(i).humidityMin(), - maxUvIndex: DailyForecastData.days(i).maxUvIndex(), - moonPhase: WK2.MoonPhase[DailyForecastData.days(i).moonPhase()], - moonrise: DailyForecastData.days(i).moonrise(), - moonset: DailyForecastData.days(i).moonset(), - precipitationAmount: DailyForecastData.days(i).precipitationAmount(), + conditionCode: WK2.WeatherCondition[sourceDay.conditionCode()], + forecastEnd: sourceDay.forecastEnd(), + forecastStart: sourceDay.forecastStart(), + humidityMax: sourceDay.humidityMax(), + humidityMin: sourceDay.humidityMin(), + maxUvIndex: sourceDay.maxUvIndex(), + moonPhase: WK2.MoonPhase[sourceDay.moonPhase()], + moonrise: sourceDay.moonrise(), + moonset: sourceDay.moonset(), + precipitationAmount: sourceDay.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).precipitationType()], - snowfallAmount: DailyForecastData.days(i).snowfallAmount(), - solarMidnight: DailyForecastData.days(i).solarMidnight(), - solarNoon: DailyForecastData.days(i).solarNoon(), - sunrise: DailyForecastData.days(i).sunrise(), - sunriseCivil: DailyForecastData.days(i).sunriseCivil(), - sunriseNautical: DailyForecastData.days(i).sunriseNautical(), - sunriseAstronomical: DailyForecastData.days(i).sunriseAstronomical(), - sunset: DailyForecastData.days(i).sunset(), - sunsetCivil: DailyForecastData.days(i).sunsetCivil(), - sunsetNautical: DailyForecastData.days(i).sunsetNautical(), - sunsetAstronomical: DailyForecastData.days(i).sunsetAstronomical(), - temperatureMax: DailyForecastData.days(i).temperatureMax(), - temperatureMaxTime: DailyForecastData.days(i).temperatureMaxTime(), - temperatureMin: DailyForecastData.days(i).temperatureMin(), - temperatureMinTime: DailyForecastData.days(i).temperatureMinTime(), - visibilityMax: DailyForecastData.days(i).visibilityMax(), - visibilityMin: DailyForecastData.days(i).visibilityMin(), - windGustSpeedMax: DailyForecastData.days(i).windGustSpeedMax(), - windSpeedAvg: DailyForecastData.days(i).windSpeedAvg(), - windSpeedMax: DailyForecastData.days(i).windSpeedMax(), + precipitationChance: sourceDay.precipitationChance(), + precipitationType: WK2.PrecipitationType[sourceDay.precipitationType()], + snowfallAmount: sourceDay.snowfallAmount(), + solarMidnight: sourceDay.solarMidnight(), + solarNoon: sourceDay.solarNoon(), + sunrise: sourceDay.sunrise(), + sunriseCivil: sourceDay.sunriseCivil(), + sunriseNautical: sourceDay.sunriseNautical(), + sunriseAstronomical: sourceDay.sunriseAstronomical(), + sunset: sourceDay.sunset(), + sunsetCivil: sourceDay.sunsetCivil(), + sunsetNautical: sourceDay.sunsetNautical(), + sunsetAstronomical: sourceDay.sunsetAstronomical(), + temperatureMax: sourceDay.temperatureMax(), + temperatureMaxTime: sourceDay.temperatureMaxTime(), + temperatureMin: sourceDay.temperatureMin(), + temperatureMinTime: sourceDay.temperatureMinTime(), + visibilityMax: sourceDay.visibilityMax(), + visibilityMin: sourceDay.visibilityMin(), + windGustSpeedMax: sourceDay.windGustSpeedMax(), + windSpeedAvg: sourceDay.windSpeedAvg(), + windSpeedMax: sourceDay.windSpeedMax(), }; - for (let j = 0; j < DailyForecastData.days(i).precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(sourceDay.precipitationAmountByTypeLength(), budget); j < count; j++) day.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).precipitationAmountByType(j).precipitationType()], + expected: sourceDay.precipitationAmountByType(j).expected(), + expectedSnow: sourceDay.precipitationAmountByType(j).expectedSnow(), + maximumSnow: sourceDay.precipitationAmountByType(j).maximumSnow(), + minimumSnow: sourceDay.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[sourceDay.precipitationAmountByType(j).precipitationType()], }); - if (DailyForecastData.days(i).daytimeForecast()) { + const daytimeForecast = sourceDay.daytimeForecast(); + if (daytimeForecast) { day.daytimeForecast = { - cloudCover: DailyForecastData.days(i).daytimeForecast().cloudCover(), - cloudCoverHighAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverHighAltPct(), - cloudCoverLowAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverLowAltPct(), - cloudCoverMidAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).daytimeForecast().conditionCode()], - forecastEnd: DailyForecastData.days(i).daytimeForecast().forecastEnd(), - forecastStart: DailyForecastData.days(i).daytimeForecast().forecastStart(), - humidity: DailyForecastData.days(i).daytimeForecast().humidity(), - humidityMax: DailyForecastData.days(i).daytimeForecast().humidityMax(), - humidityMin: DailyForecastData.days(i).daytimeForecast().humidityMin(), - precipitationAmount: DailyForecastData.days(i).daytimeForecast().precipitationAmount(), + cloudCover: daytimeForecast.cloudCover(), + cloudCoverHighAltPct: daytimeForecast.cloudCoverHighAltPct(), + cloudCoverLowAltPct: daytimeForecast.cloudCoverLowAltPct(), + cloudCoverMidAltPct: daytimeForecast.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[daytimeForecast.conditionCode()], + forecastEnd: daytimeForecast.forecastEnd(), + forecastStart: daytimeForecast.forecastStart(), + humidity: daytimeForecast.humidity(), + humidityMax: daytimeForecast.humidityMax(), + humidityMin: daytimeForecast.humidityMin(), + precipitationAmount: daytimeForecast.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).daytimeForecast().precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).daytimeForecast().precipitationType()], - snowfallAmount: DailyForecastData.days(i).daytimeForecast().snowfallAmount(), - temperatureMax: DailyForecastData.days(i).daytimeForecast().temperatureMax(), - temperatureMin: DailyForecastData.days(i).daytimeForecast().temperatureMin(), - visibilityMax: DailyForecastData.days(i).daytimeForecast().visibilityMax(), - visibilityMin: DailyForecastData.days(i).daytimeForecast().visibilityMin(), - windDirection: DailyForecastData.days(i).daytimeForecast().windDirection(), - windGustSpeedMax: DailyForecastData.days(i).daytimeForecast().windGustSpeedMax(), - windSpeed: DailyForecastData.days(i).daytimeForecast().windSpeed(), - windSpeedMax: DailyForecastData.days(i).daytimeForecast().windSpeedMax(), - precipitationIntensityMax: DailyForecastData.days(i).daytimeForecast().precipitationIntensityMax(), - perceivedPrecipitationIntensityMax: DailyForecastData.days(i).daytimeForecast().perceivedPrecipitationIntensityMax(), - uvIndexMin: DailyForecastData.days(i).daytimeForecast().uvIndexMin(), - uvIndexMax: DailyForecastData.days(i).daytimeForecast().uvIndexMax(), - temperatureApparentMin: DailyForecastData.days(i).daytimeForecast().temperatureApparentMin(), - temperatureApparentMax: DailyForecastData.days(i).daytimeForecast().temperatureApparentMax(), - daylight: DailyForecastData.days(i).daytimeForecast().daylight(), + precipitationChance: daytimeForecast.precipitationChance(), + precipitationType: WK2.PrecipitationType[daytimeForecast.precipitationType()], + snowfallAmount: daytimeForecast.snowfallAmount(), + temperatureMax: daytimeForecast.temperatureMax(), + temperatureMin: daytimeForecast.temperatureMin(), + visibilityMax: daytimeForecast.visibilityMax(), + visibilityMin: daytimeForecast.visibilityMin(), + windDirection: daytimeForecast.windDirection(), + windGustSpeedMax: daytimeForecast.windGustSpeedMax(), + windSpeed: daytimeForecast.windSpeed(), + windSpeedMax: daytimeForecast.windSpeedMax(), + precipitationIntensityMax: daytimeForecast.precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: daytimeForecast.perceivedPrecipitationIntensityMax(), + uvIndexMin: daytimeForecast.uvIndexMin(), + uvIndexMax: daytimeForecast.uvIndexMax(), + temperatureApparentMin: daytimeForecast.temperatureApparentMin(), + temperatureApparentMax: daytimeForecast.temperatureApparentMax(), + daylight: daytimeForecast.daylight(), }; - for (let j = 0; j < DailyForecastData.days(i).daytimeForecast().precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(daytimeForecast.precipitationAmountByTypeLength(), budget); j < count; j++) day.daytimeForecast.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).precipitationType()], + expected: daytimeForecast.precipitationAmountByType(j).expected(), + expectedSnow: daytimeForecast.precipitationAmountByType(j).expectedSnow(), + maximumSnow: daytimeForecast.precipitationAmountByType(j).maximumSnow(), + minimumSnow: daytimeForecast.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[daytimeForecast.precipitationAmountByType(j).precipitationType()], }); } - if (DailyForecastData.days(i).overnightForecast()) { + const overnightForecast = sourceDay.overnightForecast(); + if (overnightForecast) { day.overnightForecast = { - cloudCover: DailyForecastData.days(i).overnightForecast().cloudCover(), - cloudCoverHighAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverHighAltPct(), - cloudCoverLowAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverLowAltPct(), - cloudCoverMidAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).overnightForecast().conditionCode()], - forecastEnd: DailyForecastData.days(i).overnightForecast().forecastEnd(), - forecastStart: DailyForecastData.days(i).overnightForecast().forecastStart(), - humidity: DailyForecastData.days(i).overnightForecast().humidity(), - humidityMax: DailyForecastData.days(i).overnightForecast().humidityMax(), - humidityMin: DailyForecastData.days(i).overnightForecast().humidityMin(), - precipitationAmount: DailyForecastData.days(i).overnightForecast().precipitationAmount(), + cloudCover: overnightForecast.cloudCover(), + cloudCoverHighAltPct: overnightForecast.cloudCoverHighAltPct(), + cloudCoverLowAltPct: overnightForecast.cloudCoverLowAltPct(), + cloudCoverMidAltPct: overnightForecast.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[overnightForecast.conditionCode()], + forecastEnd: overnightForecast.forecastEnd(), + forecastStart: overnightForecast.forecastStart(), + humidity: overnightForecast.humidity(), + humidityMax: overnightForecast.humidityMax(), + humidityMin: overnightForecast.humidityMin(), + precipitationAmount: overnightForecast.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).overnightForecast().precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).overnightForecast().precipitationType()], - snowfallAmount: DailyForecastData.days(i).overnightForecast().snowfallAmount(), - temperatureMax: DailyForecastData.days(i).overnightForecast().temperatureMax(), - temperatureMin: DailyForecastData.days(i).overnightForecast().temperatureMin(), - visibilityMax: DailyForecastData.days(i).overnightForecast().visibilityMax(), - visibilityMin: DailyForecastData.days(i).overnightForecast().visibilityMin(), - windDirection: DailyForecastData.days(i).overnightForecast().windDirection(), - windGustSpeedMax: DailyForecastData.days(i).overnightForecast().windGustSpeedMax(), - windSpeed: DailyForecastData.days(i).overnightForecast().windSpeed(), - windSpeedMax: DailyForecastData.days(i).overnightForecast().windSpeedMax(), - precipitationIntensityMax: DailyForecastData.days(i).overnightForecast().precipitationIntensityMax(), - perceivedPrecipitationIntensityMax: DailyForecastData.days(i).overnightForecast().perceivedPrecipitationIntensityMax(), - uvIndexMin: DailyForecastData.days(i).overnightForecast().uvIndexMin(), - uvIndexMax: DailyForecastData.days(i).overnightForecast().uvIndexMax(), - temperatureApparentMin: DailyForecastData.days(i).overnightForecast().temperatureApparentMin(), - temperatureApparentMax: DailyForecastData.days(i).overnightForecast().temperatureApparentMax(), - daylight: DailyForecastData.days(i).overnightForecast().daylight(), + precipitationChance: overnightForecast.precipitationChance(), + precipitationType: WK2.PrecipitationType[overnightForecast.precipitationType()], + snowfallAmount: overnightForecast.snowfallAmount(), + temperatureMax: overnightForecast.temperatureMax(), + temperatureMin: overnightForecast.temperatureMin(), + visibilityMax: overnightForecast.visibilityMax(), + visibilityMin: overnightForecast.visibilityMin(), + windDirection: overnightForecast.windDirection(), + windGustSpeedMax: overnightForecast.windGustSpeedMax(), + windSpeed: overnightForecast.windSpeed(), + windSpeedMax: overnightForecast.windSpeedMax(), + precipitationIntensityMax: overnightForecast.precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: overnightForecast.perceivedPrecipitationIntensityMax(), + uvIndexMin: overnightForecast.uvIndexMin(), + uvIndexMax: overnightForecast.uvIndexMax(), + temperatureApparentMin: overnightForecast.temperatureApparentMin(), + temperatureApparentMax: overnightForecast.temperatureApparentMax(), + daylight: overnightForecast.daylight(), }; - for (let j = 0; j < DailyForecastData.days(i).overnightForecast().precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(overnightForecast.precipitationAmountByTypeLength(), budget); j < count; j++) day.overnightForecast.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).precipitationType()], + expected: overnightForecast.precipitationAmountByType(j).expected(), + expectedSnow: overnightForecast.precipitationAmountByType(j).expectedSnow(), + maximumSnow: overnightForecast.precipitationAmountByType(j).maximumSnow(), + minimumSnow: overnightForecast.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[overnightForecast.precipitationAmountByType(j).precipitationType()], }); } - if (DailyForecastData.days(i).restOfDayForecast()) { + const restOfDayForecast = sourceDay.restOfDayForecast(); + if (restOfDayForecast) { day.restOfDayForecast = { - cloudCover: DailyForecastData.days(i).restOfDayForecast().cloudCover(), - cloudCoverHighAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverHighAltPct(), - cloudCoverLowAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverLowAltPct(), - cloudCoverMidAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).restOfDayForecast().conditionCode()], - forecastEnd: DailyForecastData.days(i).restOfDayForecast().forecastEnd(), - forecastStart: DailyForecastData.days(i).restOfDayForecast().forecastStart(), - humidity: DailyForecastData.days(i).restOfDayForecast().humidity(), - humidityMax: DailyForecastData.days(i).restOfDayForecast().humidityMax(), - humidityMin: DailyForecastData.days(i).restOfDayForecast().humidityMin(), - precipitationAmount: DailyForecastData.days(i).restOfDayForecast().precipitationAmount(), + cloudCover: restOfDayForecast.cloudCover(), + cloudCoverHighAltPct: restOfDayForecast.cloudCoverHighAltPct(), + cloudCoverLowAltPct: restOfDayForecast.cloudCoverLowAltPct(), + cloudCoverMidAltPct: restOfDayForecast.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[restOfDayForecast.conditionCode()], + forecastEnd: restOfDayForecast.forecastEnd(), + forecastStart: restOfDayForecast.forecastStart(), + humidity: restOfDayForecast.humidity(), + humidityMax: restOfDayForecast.humidityMax(), + humidityMin: restOfDayForecast.humidityMin(), + precipitationAmount: restOfDayForecast.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).restOfDayForecast().precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).restOfDayForecast().precipitationType()], - snowfallAmount: DailyForecastData.days(i).restOfDayForecast().snowfallAmount(), - temperatureMax: DailyForecastData.days(i).restOfDayForecast().temperatureMax(), - temperatureMin: DailyForecastData.days(i).restOfDayForecast().temperatureMin(), - visibilityMax: DailyForecastData.days(i).restOfDayForecast().visibilityMax(), - visibilityMin: DailyForecastData.days(i).restOfDayForecast().visibilityMin(), - windDirection: DailyForecastData.days(i).restOfDayForecast().windDirection(), - windGustSpeedMax: DailyForecastData.days(i).restOfDayForecast().windGustSpeedMax(), - windSpeed: DailyForecastData.days(i).restOfDayForecast().windSpeed(), - windSpeedMax: DailyForecastData.days(i).restOfDayForecast().windSpeedMax(), - precipitationIntensityMax: DailyForecastData.days(i).restOfDayForecast().precipitationIntensityMax(), - perceivedPrecipitationIntensityMax: DailyForecastData.days(i).restOfDayForecast().perceivedPrecipitationIntensityMax(), - uvIndexMin: DailyForecastData.days(i).restOfDayForecast().uvIndexMin(), - uvIndexMax: DailyForecastData.days(i).restOfDayForecast().uvIndexMax(), - temperatureApparentMin: DailyForecastData.days(i).restOfDayForecast().temperatureApparentMin(), - temperatureApparentMax: DailyForecastData.days(i).restOfDayForecast().temperatureApparentMax(), - daylight: DailyForecastData.days(i).restOfDayForecast().daylight(), + precipitationChance: restOfDayForecast.precipitationChance(), + precipitationType: WK2.PrecipitationType[restOfDayForecast.precipitationType()], + snowfallAmount: restOfDayForecast.snowfallAmount(), + temperatureMax: restOfDayForecast.temperatureMax(), + temperatureMin: restOfDayForecast.temperatureMin(), + visibilityMax: restOfDayForecast.visibilityMax(), + visibilityMin: restOfDayForecast.visibilityMin(), + windDirection: restOfDayForecast.windDirection(), + windGustSpeedMax: restOfDayForecast.windGustSpeedMax(), + windSpeed: restOfDayForecast.windSpeed(), + windSpeedMax: restOfDayForecast.windSpeedMax(), + precipitationIntensityMax: restOfDayForecast.precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: restOfDayForecast.perceivedPrecipitationIntensityMax(), + uvIndexMin: restOfDayForecast.uvIndexMin(), + uvIndexMax: restOfDayForecast.uvIndexMax(), + temperatureApparentMin: restOfDayForecast.temperatureApparentMin(), + temperatureApparentMax: restOfDayForecast.temperatureApparentMax(), + daylight: restOfDayForecast.daylight(), }; - for (let j = 0; j < DailyForecastData.days(i).restOfDayForecast().precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(restOfDayForecast.precipitationAmountByTypeLength(), budget); j < count; j++) day.restOfDayForecast.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).precipitationType()], + expected: restOfDayForecast.precipitationAmountByType(j).expected(), + expectedSnow: restOfDayForecast.precipitationAmountByType(j).expectedSnow(), + maximumSnow: restOfDayForecast.precipitationAmountByType(j).maximumSnow(), + minimumSnow: restOfDayForecast.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[restOfDayForecast.precipitationAmountByType(j).precipitationType()], }); } data.days.push(day); @@ -725,44 +743,48 @@ function decodeForecastDaily(data) { } function decodeForecastHourly(data) { + const budget = newDecodeBudget(); const HourlyForecastData = data; data = { metadata: decodeMetadata(HourlyForecastData.metadata()), hours: [], }; - for (let i = 0; i < HourlyForecastData.hoursLength(); i++) + for (let i = 0, count = decodedVectorLength(HourlyForecastData.hoursLength(), budget); i < count; i++) { + const hour = HourlyForecastData.hours(i); data.hours.push({ - cloudCover: HourlyForecastData.hours(i).cloudCover(), - cloudCoverHighAltPct: HourlyForecastData.hours(i).cloudCoverHighAltPct(), - cloudCoverLowAltPct: HourlyForecastData.hours(i).cloudCoverLowAltPct(), - cloudCoverMidAltPct: HourlyForecastData.hours(i).cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[HourlyForecastData.hours(i).conditionCode()], - daylight: HourlyForecastData.hours(i).daylight(), - forecastStart: HourlyForecastData.hours(i).forecastStart(), - humidity: HourlyForecastData.hours(i).humidity(), - perceivedPrecipitationIntensity: HourlyForecastData.hours(i).perceivedPrecipitationIntensity(), - precipitationAmount: HourlyForecastData.hours(i).precipitationAmount(), - precipitationChance: HourlyForecastData.hours(i).precipitationChance(), - precipitationIntensity: HourlyForecastData.hours(i).precipitationIntensity(), - precipitationType: WK2.PrecipitationType[HourlyForecastData.hours(i).precipitationType()], - pressure: HourlyForecastData.hours(i).pressure(), - pressureTrend: WK2.PressureTrend[HourlyForecastData.hours(i).pressureTrend()], - snowfallAmount: HourlyForecastData.hours(i).snowfallAmount(), - snowfallIntensity: HourlyForecastData.hours(i).snowfallIntensity(), - temperature: HourlyForecastData.hours(i).temperature(), - temperatureApparent: HourlyForecastData.hours(i).temperatureApparent(), - unknown20: HourlyForecastData.hours(i).unknown20(), - temperatureDewPoint: HourlyForecastData.hours(i).temperatureDewPoint(), - uvIndex: HourlyForecastData.hours(i).uvIndex(), - visibility: HourlyForecastData.hours(i).visibility(), - windDirection: HourlyForecastData.hours(i).windDirection(), - windGust: HourlyForecastData.hours(i).windGust(), - windSpeed: HourlyForecastData.hours(i).windSpeed(), + cloudCover: hour.cloudCover(), + cloudCoverHighAltPct: hour.cloudCoverHighAltPct(), + cloudCoverLowAltPct: hour.cloudCoverLowAltPct(), + cloudCoverMidAltPct: hour.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[hour.conditionCode()], + daylight: hour.daylight(), + forecastStart: hour.forecastStart(), + humidity: hour.humidity(), + perceivedPrecipitationIntensity: hour.perceivedPrecipitationIntensity(), + precipitationAmount: hour.precipitationAmount(), + precipitationChance: hour.precipitationChance(), + precipitationIntensity: hour.precipitationIntensity(), + precipitationType: WK2.PrecipitationType[hour.precipitationType()], + pressure: hour.pressure(), + pressureTrend: WK2.PressureTrend[hour.pressureTrend()], + snowfallAmount: hour.snowfallAmount(), + snowfallIntensity: hour.snowfallIntensity(), + temperature: hour.temperature(), + temperatureApparent: hour.temperatureApparent(), + unknown20: hour.unknown20(), + temperatureDewPoint: hour.temperatureDewPoint(), + uvIndex: hour.uvIndex(), + visibility: hour.visibility(), + windDirection: hour.windDirection(), + windGust: hour.windGust(), + windSpeed: hour.windSpeed(), }); + } return data; } function decodeForecastNextHour(data) { + const budget = newDecodeBudget(); const NextHourForecastData = data; data = { metadata: decodeMetadata(NextHourForecastData.metadata()), @@ -772,7 +794,7 @@ function decodeForecastNextHour(data) { minutes: [], summary: [], }; - for (let i = 0; i < NextHourForecastData.conditionLength(); i++) { + for (let i = 0, count = decodedVectorLength(NextHourForecastData.conditionLength(), budget); i < count; i++) { const condition = { beginCondition: WK2.ConditionType[NextHourForecastData.condition(i).beginCondition()], endCondition: WK2.ConditionType[NextHourForecastData.condition(i).endCondition()], @@ -781,21 +803,21 @@ function decodeForecastNextHour(data) { parameters: [], startTime: NextHourForecastData.condition(i).startTime(), }; - for (let j = 0; j < NextHourForecastData.condition(i).parametersLength(); j++) + for (let j = 0, count = decodedVectorLength(NextHourForecastData.condition(i).parametersLength(), budget); j < count; j++) condition.parameters.push({ date: NextHourForecastData.condition(i).parameters(j).date(), type: WK2.ParameterType[NextHourForecastData.condition(i).parameters(j).type()], }); data.condition.push(condition); } - for (let i = 0; i < NextHourForecastData.minutesLength(); i++) + for (let i = 0, count = decodedVectorLength(NextHourForecastData.minutesLength(), budget); i < count; i++) data.minutes.push({ perceivedPrecipitationIntensity: NextHourForecastData.minutes(i).perceivedPrecipitationIntensity(), precipitationChance: NextHourForecastData.minutes(i).precipitationChance(), precipitationIntensity: NextHourForecastData.minutes(i).precipitationIntensity(), startTime: NextHourForecastData.minutes(i).startTime(), }); - for (let i = 0; i < NextHourForecastData.summaryLength(); i++) + for (let i = 0, count = decodedVectorLength(NextHourForecastData.summaryLength(), budget); i < count; i++) data.summary.push({ condition: WK2.PrecipitationType[NextHourForecastData.summary(i).condition()], endTime: NextHourForecastData.summary(i).endTime(), @@ -807,18 +829,19 @@ function decodeForecastNextHour(data) { } function decodeNews(data) { + const budget = newDecodeBudget(); const NewsData = data; data = { metadata: decodeMetadata(NewsData.metadata()), placements: [], }; - for (let i = 0; i < NewsData.placementsLength(); i++) { + for (let i = 0, count = decodedVectorLength(NewsData.placementsLength(), budget); i < count; i++) { const placement = { articles: [], placement: WK2.PlacementType[NewsData.placements(i).placement()], priority: NewsData.placements(i).priority(), }; - for (let j = 0; j < NewsData.placements(i).articlesLength(); j++) { + for (let j = 0, count = decodedVectorLength(NewsData.placements(i).articlesLength(), budget); j < count; j++) { const article = { alertIds: [], headlineOverride: NewsData.placements(i).articles(j).headlineOverride(), @@ -827,9 +850,9 @@ function decodeNews(data) { phenomena: [], supportedStorefronts: [], }; - for (let k = 0; k < NewsData.placements(i).articles(j).alertIdsLength(); k++) article.alertIds.push(decodeUuid(NewsData.placements(i).articles(j).alertIds(k))); - for (let k = 0; k < NewsData.placements(i).articles(j).phenomenaLength(); k++) article.phenomena.push(NewsData.placements(i).articles(j).phenomena(k)); - for (let k = 0; k < NewsData.placements(i).articles(j).supportedStorefrontsLength(); k++) article.supportedStorefronts.push(NewsData.placements(i).articles(j).supportedStorefronts(k)); + for (let k = 0, count = decodedVectorLength(NewsData.placements(i).articles(j).alertIdsLength(), budget); k < count; k++) article.alertIds.push(decodeUuid(NewsData.placements(i).articles(j).alertIds(k))); + for (let k = 0, count = decodedVectorLength(NewsData.placements(i).articles(j).phenomenaLength(), budget); k < count; k++) article.phenomena.push(NewsData.placements(i).articles(j).phenomena(k)); + for (let k = 0, count = decodedVectorLength(NewsData.placements(i).articles(j).supportedStorefrontsLength(), budget); k < count; k++) article.supportedStorefronts.push(NewsData.placements(i).articles(j).supportedStorefronts(k)); placement.articles.push(article); } data.placements.push(placement); @@ -838,50 +861,53 @@ function decodeNews(data) { } function decodeWeatherAlerts(data) { + const budget = newDecodeBudget(); const WeatherAlertCollectionData = data; data = { metadata: decodeMetadata(WeatherAlertCollectionData.metadata()), alerts: [], detailsUrl: WeatherAlertCollectionData.detailsUrl(), }; - for (let i = 0; i < WeatherAlertCollectionData.alertsLength(); i++) { + for (let i = 0, count = decodedVectorLength(WeatherAlertCollectionData.alertsLength(), budget); i < count; i++) { + const alertView = WeatherAlertCollectionData.alerts(i); const alert = { - areaId: WeatherAlertCollectionData.alerts(i).areaId(), - areaName: WeatherAlertCollectionData.alerts(i).areaName(), - attributionUrl: WeatherAlertCollectionData.alerts(i).attributionUrl(), - certainty: WK2.Certainty[WeatherAlertCollectionData.alerts(i).certainty()], - countryCode: WeatherAlertCollectionData.alerts(i).countryCode(), - description: WeatherAlertCollectionData.alerts(i).description(), - detailsUrl: WeatherAlertCollectionData.alerts(i).detailsUrl(), - effectiveTime: WeatherAlertCollectionData.alerts(i).effectiveTime(), - eventEndTime: WeatherAlertCollectionData.alerts(i).eventEndTime(), - eventOnsetTime: WeatherAlertCollectionData.alerts(i).eventOnsetTime(), - eventSource: WeatherAlertCollectionData.alerts(i).eventSource(), - expireTime: WeatherAlertCollectionData.alerts(i).expireTime(), - id: decodeUuid(WeatherAlertCollectionData.alerts(i).id()), - importance: WK2.ImportanceType[WeatherAlertCollectionData.alerts(i).importance()], - issuedTime: WeatherAlertCollectionData.alerts(i).issuedTime(), - phenomenon: WeatherAlertCollectionData.alerts(i).phenomenon(), + areaId: alertView.areaId(), + areaName: alertView.areaName(), + attributionUrl: alertView.attributionUrl(), + certainty: WK2.Certainty[alertView.certainty()], + countryCode: alertView.countryCode(), + description: alertView.description(), + detailsUrl: alertView.detailsUrl(), + effectiveTime: alertView.effectiveTime(), + eventEndTime: alertView.eventEndTime(), + eventOnsetTime: alertView.eventOnsetTime(), + eventSource: alertView.eventSource(), + expireTime: alertView.expireTime(), + id: decodeUuid(alertView.id()), + importance: WK2.ImportanceType[alertView.importance()], + issuedTime: alertView.issuedTime(), + phenomenon: alertView.phenomenon(), responses: [], - severity: WK2.Severity[WeatherAlertCollectionData.alerts(i).severity()], - significance: WK2.SignificanceType[WeatherAlertCollectionData.alerts(i).significance()], - source: WeatherAlertCollectionData.alerts(i).source(), - token: WeatherAlertCollectionData.alerts(i).token(), - urgency: WK2.Urgency[WeatherAlertCollectionData.alerts(i).urgency()], - unknown23: WeatherAlertCollectionData.alerts(i).unknown23(), - unknown24: WeatherAlertCollectionData.alerts(i).unknown24(), - unknown25: WeatherAlertCollectionData.alerts(i).unknown25(), - unknown26: WeatherAlertCollectionData.alerts(i).unknown26(), + severity: WK2.Severity[alertView.severity()], + significance: WK2.SignificanceType[alertView.significance()], + source: alertView.source(), + token: alertView.token(), + urgency: WK2.Urgency[alertView.urgency()], + unknown23: alertView.unknown23(), + unknown24: alertView.unknown24(), + unknown25: alertView.unknown25(), + unknown26: alertView.unknown26(), }; - //for (let j = 0; j < WeatherAlertCollectionData.alerts(i).idLength(); j++) alert.id.push(WeatherAlertCollectionData.alerts(i).id(j)); - //for (let j = 0; j < WeatherAlertCollectionData.alerts(i).idLength(); j++) alert.id.push({ "lowBytes": WeatherAlertCollectionData.alerts(i).id(j).lowBytes(), "highBytes": WeatherAlertCollectionData.alerts(i).id(j).highBytes() }); - for (let j = 0; j < WeatherAlertCollectionData.alerts(i).responsesLength(); j++) alert.responses.push(WK2.ResponseType[WeatherAlertCollectionData.alerts(i).responses(j)]); + //for (let j = 0, count = decodedVectorLength(alertView.idLength(), budget); j < count; j++) alert.id.push(alertView.id(j)); + //for (let j = 0, count = decodedVectorLength(alertView.idLength(), budget); j < count; j++) alert.id.push({ "lowBytes": alertView.id(j).lowBytes(), "highBytes": alertView.id(j).highBytes() }); + for (let j = 0, count = decodedVectorLength(alertView.responsesLength(), budget); j < count; j++) alert.responses.push(WK2.ResponseType[alertView.responses(j)]); data.alerts.push(alert); } return data; } function decodeWeatherChanges(data) { + const budget = newDecodeBudget(); const WeatherChangesData = data; data = { metadata: decodeMetadata(WeatherChangesData.metadata()), @@ -889,7 +915,7 @@ function decodeWeatherChanges(data) { forecastEnd: WeatherChangesData.forecastEnd(), forecastStart: WeatherChangesData.forecastStart(), }; - for (let i = 0; i < WeatherChangesData.changesLength(); i++) { + for (let i = 0, count = decodedVectorLength(WeatherChangesData.changesLength(), budget); i < count; i++) { const change = { dayPrecipitationChange: WK2.Direction[WeatherChangesData.changes(i).dayPrecipitationChange()], forecastEnd: WeatherChangesData.changes(i).forecastEnd(), @@ -904,12 +930,13 @@ function decodeWeatherChanges(data) { } function decodeHistoricalComparisons(data) { + const budget = newDecodeBudget(); const HistoricalComparisonsData = data; data = { metadata: decodeMetadata(HistoricalComparisonsData.metadata()), comparisons: [], }; - for (let i = 0; i < HistoricalComparisonsData.comparisonsLength(); i++) { + for (let i = 0, count = decodedVectorLength(HistoricalComparisonsData.comparisonsLength(), budget); i < count; i++) { const comparison = { baselineStartDate: HistoricalComparisonsData.comparisons(i).baselineStartDate(), baselineType: HistoricalComparisonsData.comparisons(i).baselineType(), diff --git a/src/function/createProviderEnvironment.mjs b/src/function/createProviderEnvironment.mjs new file mode 100644 index 00000000..319865a8 --- /dev/null +++ b/src/function/createProviderEnvironment.mjs @@ -0,0 +1,30 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; +import WAQI from "../class/WAQI.mjs"; +import { weatherAlertParameters } from "./weatherAlertLanguage.mjs"; +export default function createProviderEnvironment(parameters, settings, transport) { + let colorfulClouds, qWeather, waqi, weatherAlertsColorfulClouds, weatherAlertsQWeather; + const alertParameters = weatherAlertParameters(parameters, settings); + return { + country: parameters.country, + transport, + get colorfulClouds() { + return (colorfulClouds ??= new ColorfulClouds(parameters, settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", transport)); + }, + get qWeather() { + return (qWeather ??= new QWeather(parameters, settings.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", settings.API?.QWeather?.Host, transport)); + }, + get weatherAlertsColorfulClouds() { + if (alertParameters === parameters) return this.colorfulClouds; + return (weatherAlertsColorfulClouds ??= new ColorfulClouds(alertParameters, settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", transport)); + }, + get weatherAlertsQWeather() { + if (alertParameters === parameters) return this.qWeather; + return (weatherAlertsQWeather ??= new QWeather(alertParameters, settings.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", settings.API?.QWeather?.Host, transport)); + }, + get waqi() { + return (waqi ??= new WAQI(parameters, settings.API?.WAQI?.Token, transport)); + }, + }; +} diff --git a/src/function/database.mjs b/src/function/database.mjs index 1a244f02..f4483a90 100644 --- a/src/function/database.mjs +++ b/src/function/database.mjs @@ -1,10 +1,11 @@ +// WeatherKit default settings. export default { WeatherKit: { Settings: { // 只有插件会修改的产品允许被配置关闭;其余 Apple 数据集必须始终透传。 DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], Weather: { Replace: ["CN"], Provider: "ColorfulClouds" }, - WeatherAlerts: { Provider: "QWeatherWeb" }, + WeatherAlerts: { Provider: "QWeatherWeb", Language: "Auto" }, NextHour: { Provider: "ColorfulClouds" }, AirQuality: { Current: { Pollutants: { Provider: "ColorfulClouds", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: ["HJ6332012"], Provider: "Calculate", ForceCNPrimaryPollutants: true } }, diff --git a/src/function/ensureTextEncoder.mjs b/src/function/ensureTextEncoder.mjs new file mode 100644 index 00000000..df0cab8f --- /dev/null +++ b/src/function/ensureTextEncoder.mjs @@ -0,0 +1,46 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import utf8Length from "./utf8Length.mjs"; + +// FlatBuffers constructs a TextEncoder even when it only writes numbers. +// Supply its used encode() operation when a script host omits the Web API. +// Keep native implementations intact and bound the allocation before encoding. +if (typeof globalThis.TextEncoder !== "function") { + globalThis.TextEncoder = class TextEncoder { + get encoding() { + return "utf-8"; + } + encode(input = "") { + const value = String(input), + maximum = 2 * 1024 * 1024; + const size = utf8Length(value, maximum); + if (size > maximum) throw new RangeError("Weather string exceeds UTF-8 encoding limit"); + const bytes = new Uint8Array(size); + let offset = 0; + for (let index = 0; index < value.length; index++) { + let code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) << 10) + next - 0xdc00; + index++; + } else code = 0xfffd; + } else if (code >= 0xdc00 && code <= 0xdfff) code = 0xfffd; + if (code < 0x80) bytes[offset++] = code; + else if (code < 0x800) { + bytes[offset++] = 0xc0 | (code >> 6); + bytes[offset++] = 0x80 | (code & 0x3f); + } else if (code < 0x10000) { + bytes[offset++] = 0xe0 | (code >> 12); + bytes[offset++] = 0x80 | ((code >> 6) & 0x3f); + bytes[offset++] = 0x80 | (code & 0x3f); + } else { + bytes[offset++] = 0xf0 | (code >> 18); + bytes[offset++] = 0x80 | ((code >> 12) & 0x3f); + bytes[offset++] = 0x80 | ((code >> 6) & 0x3f); + bytes[offset++] = 0x80 | (code & 0x3f); + } + } + return bytes; + } + }; +} diff --git a/src/function/fetchProvider.mjs b/src/function/fetchProvider.mjs new file mode 100644 index 00000000..eac73b89 --- /dev/null +++ b/src/function/fetchProvider.mjs @@ -0,0 +1,36 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { fetch as sharedFetch } from "@nsnanocat/util"; +import { hasNativeProviderRuntime, isSurgeRuntime } from "./nativeProviderRuntime.mjs"; + +// Internal budgets are milliseconds. Loon uses milliseconds and Surge seconds; +// convert only at this native boundary, without the shared utility's rounding. +export default function fetchProvider(request, options = {}) { + if (!hasNativeProviderRuntime()) return sharedFetch(request, options); + const input = typeof request === "string" ? { ...options, url: request } : { ...options, ...request }; + const method = String(input.method ?? (input.body !== undefined || input.bodyBytes !== undefined ? "POST" : "GET")).toLowerCase(); + const timeout = Number(input.timeout); + const milliseconds = Number.isFinite(timeout) && timeout > 0 ? Math.max(1, Math.min(5000, Math.floor(timeout))) : 5000; + const surge = isSurgeRuntime() && typeof globalThis.$loon !== "string"; + const outgoing = { url: input.url, timeout: surge ? milliseconds / 1000 : milliseconds }; + if (input.headers) { + outgoing.headers = { ...input.headers }; + for (const key of Object.keys(outgoing.headers)) if (["host", ":authority", "content-length"].includes(key.toLowerCase())) delete outgoing.headers[key]; + } + const fields = surge ? ["body", "policy", "binary-mode", "auto-redirect", "auto-cookie", "insecure"] : ["body", "body-base64", "node", "binary-mode", "auto-redirect", "auto-cookie", "insecure", "alpn"]; + for (const key of fields) if (input[key] !== undefined) outgoing[key] = input[key]; + if (outgoing.body === undefined && input.bodyBytes !== undefined) outgoing.body = input.bodyBytes instanceof ArrayBuffer ? new Uint8Array(input.bodyBytes) : input.bodyBytes; + return new Promise((resolve, reject) => { + if (!["get", "post", "head", "delete", "put", "options", "patch"].includes(method) || typeof $httpClient?.[method] !== "function") { + reject(new TypeError("Unsupported provider HTTP method")); + return; + } + $httpClient[method](outgoing, (error, response, body) => { + if (error) { + reject(error); + return; + } + const status = Number(response?.status); + resolve({ ...response, status, statusCode: status, ok: status >= 200 && status < 300, headers: { ...response?.headers }, body: status === 304 ? "" : (body ?? "") }); + }); + }); +} diff --git a/src/function/getWeatherAlerts.mjs b/src/function/getWeatherAlerts.mjs new file mode 100644 index 00000000..b57a3af1 --- /dev/null +++ b/src/function/getWeatherAlerts.mjs @@ -0,0 +1,104 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Shared by native entries and both general request processors. +import { Console } from "@nsnanocat/util"; +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; +import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import { activeProviderAlerts, providerProductAvailable } from "./providerRecovery.mjs"; +import { weatherAlertLanguage, weatherAlertParameters } from "./weatherAlertLanguage.mjs"; + +/** + * 获取天气预警响应 + * @param {string | undefined} identifier - 预警标识 + * @param {ReturnType} parameters - WeatherKit 请求参数 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {Record} headers - 请求头 + * @param {import("../class/ProviderTransport.mjs").default} runtime - 当前请求传输校验 + * @returns {Promise} 天气预警响应,undefined 保留 Apple 请求 + */ +export async function GetWeatherAlerts(identifier, parameters, Settings, headers, runtime) { + const preferredLanguage = weatherAlertLanguage(Settings); + parameters = weatherAlertParameters(parameters, Settings); + // Providers can return original or mixed-language notices despite a language request. + // Do not falsely label those messages as a verified translation of the selected language. + const messageLanguage = preferredLanguage ? "und" : parameters?.language; + const warningHeaders = preferredLanguage ? { ...headers, "Accept-Language": preferredLanguage, "accept-language": preferredLanguage } : headers; + let body; + try { + switch (true) { + case /^[^&#]*-[0-9]{9}$/.test(identifier): { + Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); + const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, warningHeaders, runtime); + if (!providerProductAvailable(source) || !Array.isArray(source.alerts)) { + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), + identifier, + language: messageLanguage, + countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", + }, + ); + break; + } + case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { + if (parameters.latitude === undefined || parameters.longitude === undefined) return undefined; + switch (Settings?.WeatherAlerts?.Provider) { + case "ColorfulClouds": { + Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); + const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", runtime); + const source = await colorfulClouds.WeatherAlert(); + if (!providerProductAvailable(source) || !Array.isArray(source.alerts)) { + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: messageLanguage, + countryCode: parameters.country, + }, + ); + break; + } + case "QWeatherWeb": + case "QWeather": { + const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host, runtime); + const source = await qWeather.WeatherAlert(); + if (!providerProductAvailable(source) || !Array.isArray(source.alerts)) { + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: "https://www.12379.cn/", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: messageLanguage, + countryCode: parameters.country, + }, + ); + break; + } + } + break; + } + } + } catch { + return undefined; + } + if (!Array.isArray(body)) return undefined; + Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); + return { + status: 200, + statusCode: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": preferredLanguage ? "no-store" : "max-age=0", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }; +} diff --git a/src/function/mergeForecastCopy.mjs b/src/function/mergeForecastCopy.mjs new file mode 100644 index 00000000..a5abec0d --- /dev/null +++ b/src/function/mergeForecastCopy.mjs @@ -0,0 +1,16 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Preserve both inputs and share untouched rows/fields. A failed replacement can retain Apple data. +export default function mergeForecastCopy(to = [], from = []) { + let result, + j = 0; + for (let i = 0; i < to.length; i++) { + while (j < from.length && from[j].forecastStart < to[i].forecastStart) j++; + if (j >= from.length || from[j].forecastStart !== to[i].forecastStart) continue; + const source = from[j++], + row = { ...to[i], ...source }; + for (const key of ["daytimeForecast", "overnightForecast", "restOfDayForecast"]) if (Object.hasOwn(source, key)) row[key] = { ...to[i][key], ...source[key] }; + result ??= to.slice(); + result[i] = row; + } + return result ?? to; +} diff --git a/src/function/mergeWeatherAlertsProduct.mjs b/src/function/mergeWeatherAlertsProduct.mjs new file mode 100644 index 00000000..1d3dac30 --- /dev/null +++ b/src/function/mergeWeatherAlertsProduct.mjs @@ -0,0 +1,19 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import providerNameToLogo from "./providerNameToLogo.mjs"; +import { activeProviderAlerts, cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "./providerRecovery.mjs"; + +// Provider warnings enrich existing Apple events; they cannot manufacture native IDs. +// Keep the original availability and entity when no warning fields were installed. +export default function mergeWeatherAlertsProduct(original, supplement) { + if (!providerProductAvailable(supplement) || !Array.isArray(original?.alerts) || !original.alerts.length) return original; + const active = activeProviderAlerts(supplement.alerts); + if (!active.length) return original; + const result = cloneWeatherProduct(original); + result.alerts = WeatherAlerts.mergeAlerts(result.alerts, active); + if (weatherProductsEqual(result.alerts, original.alerts)) return original; + result.metadata = { ...original.metadata, ...supplement.metadata }; + result.detailsUrl = supplement.detailsUrl ?? original.detailsUrl; + result.metadata.providerLogo ||= providerNameToLogo(result.metadata.providerName); + return result; +} diff --git a/src/function/nativeProviderRuntime.mjs b/src/function/nativeProviderRuntime.mjs new file mode 100644 index 00000000..6d53259e --- /dev/null +++ b/src/function/nativeProviderRuntime.mjs @@ -0,0 +1,3 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +export const isSurgeRuntime = () => typeof globalThis.$environment?.["surge-version"] === "string"; +export const hasNativeProviderRuntime = () => typeof globalThis.$loon === "string" || isSurgeRuntime(); diff --git a/src/function/parseWeatherKitURL.mjs b/src/function/parseWeatherKitURL.mjs index 22c68dbe..e592694a 100644 --- a/src/function/parseWeatherKitURL.mjs +++ b/src/function/parseWeatherKitURL.mjs @@ -1,3 +1,4 @@ +// Parse only the request country and locale; no stored or geographic inference. import { Console } from "@nsnanocat/util"; export default function parseWeatherKitURL(url = new URL($request.url)) { @@ -53,6 +54,6 @@ export default function parseWeatherKitURL(url = new URL($request.url)) { break; } } - Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, `🟧latitude: ${result.latitude} 🟧longitude: ${result.longitude}`); + Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, "coordinates: redacted"); return result; } diff --git a/src/function/planWeatherProducts.mjs b/src/function/planWeatherProducts.mjs new file mode 100644 index 00000000..8b52ae91 --- /dev/null +++ b/src/function/planWeatherProducts.mjs @@ -0,0 +1,9 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +export default function planWeatherProducts(requested, settings, country, hasNextHour = false) { + return requested.filter(product => { + if (["currentWeather", "forecastDaily", "forecastHourly"].includes(product)) return ["QWeather", "ColorfulClouds"].includes(settings.Weather?.Provider) && settings.Weather?.Replace?.includes(country); + if (product === "forecastNextHour") return settings.NextHour?.Provider !== "WeatherKit" && !hasNextHour; + if (product === "weatherAlerts") return settings.WeatherAlerts?.Provider !== "WeatherKit"; + return product === "airQuality"; + }); +} diff --git a/src/function/providerRecovery.mjs b/src/function/providerRecovery.mjs new file mode 100644 index 00000000..a34432b1 --- /dev/null +++ b/src/function/providerRecovery.mjs @@ -0,0 +1,45 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { knownWeatherAlertExpiry } from "./weatherAlertExpiry.mjs"; +export function providerProductAvailable(value) { + return Boolean(value && typeof value === "object" && value.metadata && value.metadata.temporarilyUnavailable !== true); +} +export function cloneWeatherProduct(value, seen = new Map(), depth = 0) { + if (!value || typeof value !== "object") return value; + if (depth > 64 || seen.size > 100000) throw new RangeError("Weather product exceeds clone bounds"); + if (seen.has(value)) return seen.get(value); + if (ArrayBuffer.isView(value)) return value instanceof DataView ? new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)) : new value.constructor(value); + if (value instanceof ArrayBuffer) return value.slice(0); + if (value instanceof Date) return new Date(value.getTime()); + if (value instanceof Map) return new Map([...value].map(([key, item]) => [key, cloneWeatherProduct(item, seen, depth + 1)])); + if (value instanceof Set) return new Set([...value].map(item => cloneWeatherProduct(item, seen, depth + 1))); + const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value)); + seen.set(value, copy); + for (const key of Object.keys(value)) copy[key] = cloneWeatherProduct(value[key], seen, depth + 1); + return copy; +} + +export function activeProviderAlerts(alerts, now = Date.now()) { + return (Array.isArray(alerts) ? alerts : []).filter(alert => { + const value = knownWeatherAlertExpiry(alert?.expireTime) ?? knownWeatherAlertExpiry(alert?.eventEndTime); + if (!value) return true; + const timestamp = typeof value === "number" ? (value < 1e12 ? value * 1000 : value) : Date.parse(value); + return Number.isFinite(timestamp) && timestamp > now; + }); +} + +export function weatherProductsEqual(left, right, seen = new Map(), depth = 0) { + if (Object.is(left, right)) return true; + if (!left || !right || typeof left !== "object" || typeof right !== "object" || depth > 64) return false; + if (seen.get(left) === right) return true; + seen.set(left, right); + if (left instanceof Date || right instanceof Date) return left instanceof Date && right instanceof Date && left.getTime() === right.getTime(); + if (ArrayBuffer.isView(left) || left instanceof ArrayBuffer || ArrayBuffer.isView(right) || right instanceof ArrayBuffer) { + const bytes = value => (ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : value instanceof ArrayBuffer ? new Uint8Array(value) : undefined); + const a = bytes(left), + b = bytes(right); + return Boolean(a && b && a.length === b.length && a.every((value, index) => value === b[index])); + } + const keys = Object.keys(left); + if (keys.length !== Object.keys(right).length) return false; + return keys.every(key => Object.hasOwn(right, key) && weatherProductsEqual(left[key], right[key], seen, depth + 1)); +} diff --git a/src/function/rewriteWeatherResponse.mjs b/src/function/rewriteWeatherResponse.mjs new file mode 100644 index 00000000..90d36995 --- /dev/null +++ b/src/function/rewriteWeatherResponse.mjs @@ -0,0 +1,11 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Validators describe Apple's original entity, not the newly encoded body. +export default function rewriteWeatherResponse(response, body) { + const headers = { ...response.headers }; + for (const name of Object.keys(headers)) { + if (["etag", "last-modified", "content-md5", "digest", "content-length", "content-encoding", "transfer-encoding", "cache-control", "expires"].includes(name.toLowerCase())) delete headers[name]; + } + headers["Cache-Control"] = "no-store"; + const { bodyBytes: _originalBytes, ...result } = response; + return { ...result, headers, body }; +} diff --git a/src/function/setENV.mjs b/src/function/setENV.mjs index e4bfc831..9f76b4a3 100644 --- a/src/function/setENV.mjs +++ b/src/function/setENV.mjs @@ -1,3 +1,4 @@ +import { cloneWeatherProduct } from "./providerRecovery.mjs"; import getStorage from "@nsnanocat/util/getStorage.mjs"; import { Console, Lodash as _ } from "@nsnanocat/util"; @@ -11,9 +12,9 @@ import { Console, Lodash as _ } from "@nsnanocat/util"; */ export default function setENV(name, platforms, database) { Console.info("☑️ Set Environment Variables"); - const { Settings, Caches, Configs } = getStorage(name, platforms, database); + const { Settings, Caches, Configs } = getStorage(name, platforms, cloneWeatherProduct(database)); /***************** Settings *****************/ - Console.info(`typeof Settings: ${typeof Settings}`, `Settings: ${JSON.stringify(Settings, null, 2)}`); + Console.info("WeatherKit settings loaded"); string2array("DataSets"); string2array("Weather.Replace"); string2array("AirQuality.Current.Index.Replace"); diff --git a/src/function/utf8Length.mjs b/src/function/utf8Length.mjs new file mode 100644 index 00000000..7baa92bc --- /dev/null +++ b/src/function/utf8Length.mjs @@ -0,0 +1,16 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Count UTF-8 bytes without allocating another full body or requiring TextEncoder. +export default function utf8Length(value, maximum = Number.POSITIVE_INFINITY) { + let size = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 128) size++; + else if (code < 2048) size += 2; + else if (code >= 0xd800 && code <= 0xdbff && value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) { + size += 4; + index++; + } else size += 3; + if (size > maximum) return size; + } + return size; +} diff --git a/src/function/weatherAlertExpiry.mjs b/src/function/weatherAlertExpiry.mjs new file mode 100644 index 00000000..c6dd987b --- /dev/null +++ b/src/function/weatherAlertExpiry.mjs @@ -0,0 +1,11 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Apple identifies an unknown expiry by its local calendar year. UTC midnight +// stays in 9999 across UTC-12 through UTC+14; 23:59:59 crosses into year 10000. +export const UNKNOWN_WEATHER_ALERT_EXPIRY = "9999-12-31T00:00:00Z"; +const legacyUnknownExpiry = "9999-12-31T23:59:59Z"; + +// Keep genuine values verbatim, including offsets and fractional seconds. +// Both reserved strings represent absence, never a real event end. +export function knownWeatherAlertExpiry(value) { + return value == null || value === legacyUnknownExpiry || value === UNKNOWN_WEATHER_ALERT_EXPIRY ? undefined : value; +} diff --git a/src/function/weatherAlertLanguage.mjs b/src/function/weatherAlertLanguage.mjs new file mode 100644 index 00000000..0a62c456 --- /dev/null +++ b/src/function/weatherAlertLanguage.mjs @@ -0,0 +1,24 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// A source-language preference, never a translation service or a claim about returned text. +export function weatherAlertLanguage(settings) { + const value = settings?.WeatherAlerts?.Language; + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase().replaceAll("_", "-")) { + case "zh": + case "zh-cn": + case "zh-hans": + case "zh-hans-cn": + return "zh-Hans"; + case "en": + case "en-us": + case "en-gb": + return "en"; + default: + return undefined; + } +} + +export function weatherAlertParameters(parameters, settings) { + const language = weatherAlertLanguage(settings); + return language ? { ...parameters, language, weatherKitLanguage: language } : parameters; +} diff --git a/src/function/weatherMemoryLimits.mjs b/src/function/weatherMemoryLimits.mjs new file mode 100644 index 00000000..0422eb20 --- /dev/null +++ b/src/function/weatherMemoryLimits.mjs @@ -0,0 +1,9 @@ +// Application work bounds, not limits on Loon's native HTTP buffers. +export const MAX_WEATHER_BODY_BYTES = 4 * 1024 * 1024; +export const MAX_DECODED_VECTOR_ENTRIES = 8192; +export const newDecodeBudget = () => ({ remaining: MAX_DECODED_VECTOR_ENTRIES }); +export function decodedVectorLength(length, budget) { + if (!Number.isSafeInteger(length) || length < 0 || length > budget.remaining) throw new RangeError("Weather product exceeds vector decoding budget"); + budget.remaining -= length; + return length; +} diff --git a/src/loon/request.js b/src/loon/request.js new file mode 100644 index 00000000..dba5c1d7 --- /dev/null +++ b/src/loon/request.js @@ -0,0 +1,22 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 + +import ProviderTransport from "../class/ProviderTransport.mjs"; +import database from "../function/database.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import setENV from "../function/setENV.mjs"; +import { GetWeatherAlerts } from "../process/Request.mjs"; + +(async () => { + const url = new URL($request.url), + identifier = url.searchParams.get("ids")?.trim(); + if (url.hostname !== "weatherkit.apple.com" || url.pathname !== "/api/v1/weatherAlerts" || ($request.method && !["GET", "HEAD", "OPTIONS"].includes($request.method)) || !identifier) return $request; + const { Settings } = setENV("iRingo", "WeatherKit", database); + const parameters = parseWeatherKitURL(url); + const runtime = new ProviderTransport(); + + const response = await GetWeatherAlerts(identifier, parameters, Settings, $request.headers, runtime); + return response ? { response } : $request; +})().then( + value => $done(value), + () => $done($request), +); diff --git a/src/loon/response.js b/src/loon/response.js new file mode 100644 index 00000000..3e9e001e --- /dev/null +++ b/src/loon/response.js @@ -0,0 +1,11 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { Console } from "@nsnanocat/util"; +import { Response } from "../process/Response.mjs"; + +Response($request, $response).then( + value => $done(value), + error => { + Console.error(error); + $done($response); + }, +); diff --git a/src/loon/scale.js b/src/loon/scale.js new file mode 100644 index 00000000..f3f04b23 --- /dev/null +++ b/src/loon/scale.js @@ -0,0 +1,16 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import AirQualityScale from "../class/AirQualityScale.mjs"; + +let result = $request; +try { + const url = new URL($request.url); + const match = url.pathname.match(/^\/api\/v1\/airQualityScale\/([A-Z0-9]+(?:-[A-Z0-9]+)*)\/((?:HK|CN)\.AQHI)(?:\.\d+)?$/i); + if (url.hostname === "weatherkit.apple.com" && match && (!$request.method || ["GET", "HEAD", "OPTIONS"].includes($request.method))) { + url.pathname = `/api/v1/airQualityScale/${match[1]}/${match[2]}`; + const response = new AirQualityScale().Build(match[1], match[2]); + result = response ? { response } : { ...$request, url: url.toString() }; + } +} catch { + /* Unknown requests preserve the caller's data. */ +} +$done(result); diff --git a/src/process/Request.dev.mjs b/src/process/Request.dev.mjs index 1baa5cd9..33824568 100644 --- a/src/process/Request.dev.mjs +++ b/src/process/Request.dev.mjs @@ -1,19 +1,18 @@ +// WeatherKit provider processing. import { Lodash as _, Console, Storage } from "@nsnanocat/util"; import AirQualityScale from "../class/AirQualityScale.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; -import QWeather from "../class/QWeather.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import setENV from "../function/setENV.mjs"; +import { GetWeatherAlerts } from "../function/getWeatherAlerts.mjs"; /***************** Processing *****************/ export async function Request($request) { // 构造回复数据 let $response = undefined; // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - const parameters = parseWeatherKitURL(url); + Console.info("WeatherKit request"); // 解析格式 const FORMAT = ($request.headers?.["Content-Type"] ?? $request.headers?.["content-type"])?.split(";")?.[0]; Console.info(`FORMAT: ${FORMAT}`); @@ -22,6 +21,9 @@ export async function Request($request) { * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); + const parameters = parseWeatherKitURL(url); + const runtime = new ProviderTransport(); + // 方法判断 switch ($request.method) { case "POST": @@ -86,15 +88,17 @@ export async function Request($request) { case "HEAD": case "OPTIONS": default: - delete $request?.headers?.["If-None-Match"]; - delete $request?.headers?.["if-none-match"]; + if (url.pathname !== "/api/v1/weatherAlerts") { + delete $request?.headers?.["If-None-Match"]; + delete $request?.headers?.["if-none-match"]; + } // 主机判断 switch (url.hostname) { case "weatherkit.apple.com": // 路径判断 switch (url.pathname) { case "/api/v1/weatherAlerts": { - $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers); + $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); break; } default: @@ -164,74 +168,6 @@ export async function Request($request) { break; } $request.url = url.toString(); - Console.debug(`$request.url: ${$request.url}`); - return { $request, $response }; -} -/** - * 获取天气预警响应 - * @param {string | undefined} identifier - 预警标识 - * @param {ReturnType} parameters - WeatherKit 请求参数 - * @param {import('../types').Settings} Settings - 设置对象 - * @param {Record} headers - 请求头 - * @returns {Promise} 天气预警响应 - */ -async function GetWeatherAlerts(identifier, parameters, Settings, headers) { - let body = []; - try { - switch (true) { - case /^[^&#]*-[0-9]{9}$/.test(identifier): { - Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); - const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers); - body = WeatherAlerts.Build(source, { - attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), - identifier, - language: parameters.language, - countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", - }); - break; - } - case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { - switch (Settings?.WeatherAlerts?.Provider) { - case "ColorfulClouds": { - Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); - const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="); - const source = await colorfulClouds.WeatherAlert(); - body = WeatherAlerts.Build(source, { - attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); - break; - } - case "QWeatherWeb": - case "QWeather": { - const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host); - body = WeatherAlerts.Build(await qWeather.WeatherAlert(), { - attributionUrl: "https://www.12379.cn/", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); - break; - } - } - break; - } - } - } catch (error) { - Console.error("WeatherAlerts", error?.stack ?? error?.message ?? String(error)); - } - Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); - return { - status: 200, - statusCode: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "max-age=0", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }; + return { $request, $response }; } diff --git a/src/process/Request.mjs b/src/process/Request.mjs index aa4f0322..f0a588e6 100644 --- a/src/process/Request.mjs +++ b/src/process/Request.mjs @@ -1,19 +1,19 @@ +// WeatherKit provider processing. import { Lodash as _, Console, Storage } from "@nsnanocat/util"; import AirQualityScale from "../class/AirQualityScale.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; -import QWeather from "../class/QWeather.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import setENV from "../function/setENV.mjs"; +import { GetWeatherAlerts } from "../function/getWeatherAlerts.mjs"; +export { GetWeatherAlerts }; /***************** Processing *****************/ export async function Request($request) { // 构造回复数据 let $response = undefined; // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - const parameters = parseWeatherKitURL(url); + Console.info("WeatherKit request"); // 解析格式 const FORMAT = ($request.headers?.["Content-Type"] ?? $request.headers?.["content-type"])?.split(";")?.[0]; Console.info(`FORMAT: ${FORMAT}`); @@ -22,6 +22,9 @@ export async function Request($request) { * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); + const parameters = parseWeatherKitURL(url); + const runtime = new ProviderTransport(); + // 方法判断 switch ($request.method) { case "POST": @@ -86,15 +89,17 @@ export async function Request($request) { case "HEAD": case "OPTIONS": default: - delete $request?.headers?.["If-None-Match"]; - delete $request?.headers?.["if-none-match"]; + if (url.pathname !== "/api/v1/weatherAlerts") { + delete $request?.headers?.["If-None-Match"]; + delete $request?.headers?.["if-none-match"]; + } // 主机判断 switch (url.hostname) { case "weatherkit.apple.com": // 路径判断 switch (url.pathname) { case "/api/v1/weatherAlerts": { - $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers); + $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); break; } default: @@ -138,74 +143,6 @@ export async function Request($request) { break; } $request.url = url.toString(); - Console.debug(`$request.url: ${$request.url}`); - return { $request, $response }; -} -/** - * 获取天气预警响应 - * @param {string | undefined} identifier - 预警标识 - * @param {ReturnType} parameters - WeatherKit 请求参数 - * @param {import('../types').Settings} Settings - 设置对象 - * @param {Record} headers - 请求头 - * @returns {Promise} 天气预警响应 - */ -async function GetWeatherAlerts(identifier, parameters, Settings, headers) { - let body = []; - try { - switch (true) { - case /^[^&#]*-[0-9]{9}$/.test(identifier): { - Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); - const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers); - body = WeatherAlerts.Build(source, { - attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), - identifier, - language: parameters.language, - countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", - }); - break; - } - case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { - switch (Settings?.WeatherAlerts?.Provider) { - case "ColorfulClouds": { - Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); - const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="); - const source = await colorfulClouds.WeatherAlert(); - body = WeatherAlerts.Build(source, { - attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); - break; - } - case "QWeatherWeb": - case "QWeather": { - const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host); - body = WeatherAlerts.Build(await qWeather.WeatherAlert(), { - attributionUrl: "https://www.12379.cn/", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); - break; - } - } - break; - } - } - } catch (error) { - Console.error("WeatherAlerts", error?.stack ?? error?.message ?? String(error)); - } - Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); - return { - status: 200, - statusCode: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "max-age=0", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }; + return { $request, $response }; } diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 2eb95155..01c6dc8c 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -1,35 +1,56 @@ +// WeatherKit provider processing. +import ResponseJSONV3 from "./ResponseJSONV3.mjs"; import { Console, Storage } from "@nsnanocat/util"; import * as flatbuffers from "flatbuffers"; import AirQuality from "../class/AirQuality.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; import MatchEnum from "../class/MatchEnum.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import QWeather from "../class/QWeather.mjs"; -import WAQI from "../class/WAQI.mjs"; -import Weather from "../class/Weather.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; -import WeatherKit2 from "../class/WeatherKit2.mjs"; +import mergeWeatherAlertsProduct from "../function/mergeWeatherAlertsProduct.mjs"; +import WeatherKit2, { hasForecastNextHour } from "../class/WeatherKit2.mjs"; +import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; +import mergeForecastCopy from "../function/mergeForecastCopy.mjs"; import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import planWeatherProducts from "../function/planWeatherProducts.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import { cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import rewriteWeatherResponse from "../function/rewriteWeatherResponse.mjs"; import setENV from "../function/setENV.mjs"; +import { MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; /***************** Processing *****************/ export async function Response($request, $response) { // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - // 获取连接参数 - const PATHs = url.pathname.split("/").filter(Boolean); - Console.info(`PATHs: ${PATHs}`); + // 解析格式 - const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]; - Console.info(`FORMAT: ${FORMAT}`); + const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]?.trim()?.toLowerCase(); + if (FORMAT === "application/json" || FORMAT === "text/json") { + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) + return ResponseJSONV3($request, $response, { + airQuality: InjectAirQuality, + currentWeather: InjectCurrentWeather, + forecastDaily: InjectForecastDaily, + forecastHourly: InjectForecastHourly, + forecastNextHour: InjectForecastNextHour, + weatherAlerts: InjectWeatherAlerts, + }); + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v1/availability/")) $response.body = JSON.stringify(mergeWeatherKitAvailability(JSON.parse($response.body), database.WeatherKit.Configs.Availability.v2)); + return $response; + } + if (FORMAT !== "application/vnd.apple.flatbuffer" || url.hostname !== "weatherkit.apple.com" || !url.pathname.startsWith("/api/v2/weather/")) return $response; + const input = $response.bodyBytes ?? $response.body; + if ((input?.byteLength ?? input?.length ?? 0) > MAX_WEATHER_BODY_BYTES) return $response; /** * 设置 * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches, Configs } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; + Console.info("WeatherKit response", `FORMAT: ${FORMAT}`); + let transport; + // 创建空数据 let body = {}; // 格式判断 @@ -74,7 +95,7 @@ export async function Response($request, $response) { switch (url.pathname) { default: if (url.pathname.startsWith("/api/v1/availability/")) { - Console.debug(`body: ${JSON.stringify(body)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body: ${JSON.stringify(body)}`); body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); } break; @@ -91,7 +112,8 @@ export async function Response($request, $response) { case "application/grpc+proto": case "application/octet-stream": { //Console.debug(`$response: ${JSON.stringify($response, null, 2)}`); - let rawBody = $response.bodyBytes ? new Uint8Array($response.bodyBytes) : ($response.body ?? new Uint8Array()); + const bytes = $response.bodyBytes; + let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody)}`); switch (FORMAT) { case "application/vnd.apple.flatbuffer": { @@ -103,7 +125,10 @@ export async function Response($request, $response) { // 路径判断 if (url.pathname.startsWith("/api/v2/weather/")) { const parameters = parseWeatherKitURL(url); - const dataSets = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); + if (!dataSets.length) break; + transport = new ProviderTransport(); const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); body = WeatherKit2.decode(ByteBuffer, rootNames); const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; @@ -111,78 +136,89 @@ export async function Response($request, $response) { if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { await matchEnum.init(); } - const enviroments = { - colorfulClouds: new ColorfulClouds(providerParameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), - qWeather: new QWeather(providerParameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host), - waqi: new WAQI(parameters, Settings?.API?.WAQI?.Token), - country: parameters.country, - }; + const enviroments = createProviderEnvironment(providerParameters, Settings, transport); + let patch; await Promise.all( dataSets.map(async dataSet => { - switch (dataSet) { - case "airQuality": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.airQuality(); + if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; + const original = body[dataSet]; + try { + body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; + switch (dataSet) { + case "airQuality": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.airQuality(); + } + body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); + break; } - body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); - break; - } - case "currentWeather": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.weatherCondition(); - matchEnum.pressureTrend(); + case "currentWeather": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.weatherCondition(); + matchEnum.pressureTrend(); + } + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); + break; } - body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); - break; - } - case "forecastDaily": { - body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); - break; - } - case "forecastHourly": { - body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); - break; - } - case "forecastNextHour": { - Console.debug(`body.forecastNextHour: ${JSON.stringify(body?.forecastNextHour, null, 2)}`); - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.conditionType(); - matchEnum.forecastToken(); + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); + break; } - body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); - break; - } - case "news": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.placementType(); + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); + break; } - Console.debug(`body.news: ${JSON.stringify(body?.news, null, 2)}`); - break; - } - case "weatherAlerts": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.severity(); - matchEnum.significanceType(); - matchEnum.urgency(); - matchEnum.certainty(); - matchEnum.importanceType(); - matchEnum.responseType(); + case "forecastNextHour": { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.forecastNextHour: ${JSON.stringify(body?.forecastNextHour, null, 2)}`); + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.conditionType(); + matchEnum.forecastToken(); + } + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); + break; + } + case "news": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.placementType(); + } + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.news: ${JSON.stringify(body?.news, null, 2)}`); + break; + } + case "weatherAlerts": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.severity(); + matchEnum.significanceType(); + matchEnum.urgency(); + matchEnum.certainty(); + matchEnum.importanceType(); + matchEnum.responseType(); + } + body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.weatherAlerts: ${JSON.stringify(body?.weatherAlerts, null, 2)}`); + break; + } + case "locationInfo": { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.locationInfo: ${JSON.stringify(body?.locationInfo, null, 2)}`); + break; } - body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); - Console.debug(`body.weatherAlerts: ${JSON.stringify(body?.weatherAlerts, null, 2)}`); - break; + default: + break; } - case "locationInfo": { - Console.debug(`body.locationInfo: ${JSON.stringify(body?.locationInfo, null, 2)}`); - break; + if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; + else { + patch ??= {}; + patch[dataSet] = body[dataSet]; } - default: - break; + } catch { + body[dataSet] = original; } }), ); - rawBody = WeatherKit2.encode(ByteBuffer, body); + if (patch) { + const encoded = WeatherKit2.encode(ByteBuffer, patch); + if (encoded.buffer !== rawBody.buffer || encoded.byteOffset !== rawBody.byteOffset || encoded.byteLength !== rawBody.byteLength) return rewriteWeatherResponse($response, encoded); + } break; } break; @@ -235,12 +271,14 @@ async function InjectCurrentWeather(currentWeather, Settings, enviroments) { break; } } - if (newCurrentWeather?.metadata) { + + if (providerProductAvailable(newCurrentWeather)) { newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; currentWeather = { ...currentWeather, ...newCurrentWeather }; currentWeather.metadata.providerLogo ||= providerNameToLogo(currentWeather.metadata.providerName); //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); } + Console.info("✅ InjectCurrentWeather"); return currentWeather; } @@ -276,12 +314,17 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { break; } } - if (newForecastDaily?.metadata) { - forecastDaily.metadata = { ...forecastDaily?.metadata, ...newForecastDaily.metadata }; - Weather.mergeForecast(forecastDaily?.days, newForecastDaily?.days); + + if (providerProductAvailable(newForecastDaily)) { + forecastDaily = { + ...forecastDaily, + metadata: { ...forecastDaily?.metadata, ...newForecastDaily.metadata }, + days: forecastDaily?.days?.length ? mergeForecastCopy(forecastDaily.days, newForecastDaily.days) : (newForecastDaily.days ?? []), + }; forecastDaily.metadata.providerLogo ||= providerNameToLogo(forecastDaily.metadata.providerName); //Console.debug(`forecastDaily: ${JSON.stringify(forecastDaily, null, 2)}`); } + Console.info("✅ InjectForecastDaily"); return forecastDaily; } @@ -317,12 +360,17 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { break; } } - if (newForecastHourly?.metadata) { - forecastHourly.metadata = { ...forecastHourly?.metadata, ...newForecastHourly.metadata }; - forecastHourly.hours = Weather.mergeForecast(forecastHourly?.hours, newForecastHourly?.hours); + + if (providerProductAvailable(newForecastHourly)) { + forecastHourly = { + ...forecastHourly, + metadata: { ...forecastHourly?.metadata, ...newForecastHourly.metadata }, + hours: forecastHourly?.hours?.length ? mergeForecastCopy(forecastHourly.hours, newForecastHourly.hours) : (newForecastHourly.hours ?? []), + }; forecastHourly.metadata.providerLogo ||= providerNameToLogo(forecastHourly.metadata.providerName); //Console.debug(`forecastHourly: ${JSON.stringify(forecastHourly, null, 2)}`); } + Console.info("✅ InjectForecastHourly"); return forecastHourly; } @@ -356,12 +404,14 @@ async function InjectForecastNextHour(forecastNextHour, Settings, enviroments) { break; } } - if (newForecastNextHour?.metadata) { + + if (providerProductAvailable(newForecastNextHour)) { newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; forecastNextHour.metadata.providerLogo ||= providerNameToLogo(forecastNextHour.metadata.providerName); - Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); } + Console.info("✅ InjectForecastNextHour"); return forecastNextHour; } @@ -382,25 +432,22 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } case "ColorfulClouds": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsColorfulClouds ?? enviroments.colorfulClouds).WeatherAlert(); break; } case "QWeather": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlert(); break; } case "QWeatherWeb": default: { - newWeatherAlerts = await enviroments.qWeather.WeatherAlertWeb(weatherAlerts?.detailsUrl); + newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlertWeb(weatherAlerts?.detailsUrl); break; } } - if (newWeatherAlerts?.metadata) { - weatherAlerts.metadata = { ...weatherAlerts?.metadata, ...newWeatherAlerts.metadata }; - weatherAlerts.detailsUrl = newWeatherAlerts.detailsUrl ?? weatherAlerts.detailsUrl; - weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, newWeatherAlerts?.alerts); - weatherAlerts.metadata.providerLogo ||= providerNameToLogo(weatherAlerts.metadata.providerName); - } + + weatherAlerts = mergeWeatherAlertsProduct(weatherAlerts, newWeatherAlerts); + Console.info("✅ InjectWeatherAlerts"); return weatherAlerts; } @@ -416,23 +463,54 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step1. 修复污染物单位 // Step 1. Fix pollutant units. - airQuality = AirQuality.FixPollutantsUnits(airQuality); - - // Step2. 判断原始污染物是否为空,并在需要时注入污染物数据 - const isPollutantEmpty = !Array.isArray(airQuality?.pollutants) || airQuality.pollutants.length === 0; - const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; - const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); + airQuality = AirQuality.FixPollutantsUnits(airQuality, { appleProduct: true }); + + // 只补齐缺失/无效的污染物,已有有效 Apple 浓度优先;失败不丢弃原数据。 + const originalPollutants = AirQuality.ValidPollutants(airQuality?.pollutants); + let injectedPollutants = { ...airQuality, pollutants: originalPollutants }; + let needPollutants = false; + if (AirQuality.MissingPollutants(originalPollutants).length) { + try { + const supplement = await InjectPollutants(Settings, enviroments); + if (providerProductAvailable(supplement)) { + const pollutants = AirQuality.MergePollutants(originalPollutants, supplement.pollutants); + needPollutants = pollutants.length > originalPollutants.length; + if (needPollutants) injectedPollutants = { ...supplement, ...airQuality, pollutants, metadata: { ...supplement.metadata, temporarilyUnavailable: false } }; + } + } catch {} + } - // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; + // The algorithm chooses how to convert; the target list decides whether. + const targets = Settings?.AirQuality?.Current?.Index?.Replace ?? []; + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider ?? "Calculate"; + const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; + const originalIndexAvailable = AirQuality.HasUsableIndex(airQuality); + const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!originalIndexAvailable && needPollutants)); + let injectedIndex = airQuality, + indexAvailable = false; + if (needInjectIndex) { + try { + const candidate = await InjectIndex(injectedPollutants, Settings, enviroments); + if (AirQuality.HasUsableIndex(candidate)) { + injectedIndex = candidate; + indexAvailable = true; + } + } catch {} + } - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Follow the original comparison switch and independently selected source. + const indexChanged = indexAvailable && ["index", "scale", "categoryIndex"].some(key => airQuality?.[key] !== injectedIndex[key]); const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = needInjectIndex && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const previousDayComparison = indexChanged && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; - const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; + const currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; + let injectedComparison = { ...injectedIndex, previousDayComparison }; + if (needInjectComparison) { + try { + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments); + } catch {} + } // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 const weatherKitMetadata = airQuality?.metadata; @@ -442,7 +520,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const providers = [ ...(weatherKitMetadata?.providerName && !weatherKitMetadata.temporarilyUnavailable ? [weatherKitMetadata.providerName] : []), ...(needPollutants && pollutantMetadata?.providerName && !pollutantMetadata.temporarilyUnavailable ? [`污染物:${pollutantMetadata.providerName}`] : []), - ...(needInjectIndex && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), + ...(indexAvailable && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), ...(needInjectComparison && comparisonMetadata?.providerName && !comparisonMetadata.temporarilyUnavailable ? [`对比昨日:\n${comparisonMetadata.providerName}`] : []), ]; @@ -452,16 +530,17 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison airQuality = { ...airQuality, - ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? injectedIndex : {}), + ...(indexAvailable ? injectedIndex : {}), metadata: { ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), + ...(indexAvailable ? { temporarilyUnavailable: false } : !originalIndexAvailable ? { temporarilyUnavailable: true } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + pollutants: AirQuality.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings)), previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; - Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); return airQuality; } @@ -597,7 +676,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayAirQuality.metadata.temporarilyUnavailable) { - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayAirQuality, metadata: getMetadata(false), @@ -634,7 +713,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Storage.setItem("@iRingo.WeatherKit.Caches", { ...Caches, qweather: qweatherCache }); }; - const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache); + const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.transport); const { latitude, longitude } = enviroments.qWeather; const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); @@ -647,7 +726,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayQWeather.metadata.temporarilyUnavailable) { - const airQualityFromPollutants = pollutantsToAirQuality(yesterdayQWeather); + const airQualityFromPollutants = pollutantsToAirQuality?.(yesterdayQWeather); const yesterdayAirQuality = pollutantsToAirQuality ? { ...airQualityFromPollutants, @@ -664,7 +743,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }, }; - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayQWeather, metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), diff --git a/src/process/Response.mjs b/src/process/Response.mjs index 941e2a17..f43e3410 100644 --- a/src/process/Response.mjs +++ b/src/process/Response.mjs @@ -1,34 +1,55 @@ +// WeatherKit provider processing. +import ResponseJSONV3 from "./ResponseJSONV3.mjs"; import { Console, Storage } from "@nsnanocat/util"; import * as flatbuffers from "flatbuffers"; import AirQuality from "../class/AirQuality.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import QWeather from "../class/QWeather.mjs"; -import WAQI from "../class/WAQI.mjs"; -import Weather from "../class/Weather.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; -import WeatherKit2 from "../class/WeatherKit2.mjs"; +import mergeWeatherAlertsProduct from "../function/mergeWeatherAlertsProduct.mjs"; +import WeatherKit2, { hasForecastNextHour } from "../class/WeatherKit2.mjs"; +import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; +import mergeForecastCopy from "../function/mergeForecastCopy.mjs"; import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import planWeatherProducts from "../function/planWeatherProducts.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import { cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import rewriteWeatherResponse from "../function/rewriteWeatherResponse.mjs"; import setENV from "../function/setENV.mjs"; +import { MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; /***************** Processing *****************/ export async function Response($request, $response) { // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - // 获取连接参数 - const PATHs = url.pathname.split("/").filter(Boolean); - Console.info(`PATHs: ${PATHs}`); + // 解析格式 - const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]; - Console.info(`FORMAT: ${FORMAT}`); + const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]?.trim()?.toLowerCase(); + if (FORMAT === "application/json" || FORMAT === "text/json") { + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) + return ResponseJSONV3($request, $response, { + airQuality: InjectAirQuality, + currentWeather: InjectCurrentWeather, + forecastDaily: InjectForecastDaily, + forecastHourly: InjectForecastHourly, + forecastNextHour: InjectForecastNextHour, + weatherAlerts: InjectWeatherAlerts, + }); + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v1/availability/")) $response.body = JSON.stringify(mergeWeatherKitAvailability(JSON.parse($response.body), database.WeatherKit.Configs.Availability.v2)); + return $response; + } + if (FORMAT !== "application/vnd.apple.flatbuffer" || url.hostname !== "weatherkit.apple.com" || !url.pathname.startsWith("/api/v2/weather/")) return $response; + const input = $response.bodyBytes ?? $response.body; + if ((input?.byteLength ?? input?.length ?? 0) > MAX_WEATHER_BODY_BYTES) return $response; /** * 设置 * @type {{Settings: import('../types').Settings}} */ const { Settings, Caches, Configs } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; + Console.info("WeatherKit response", `FORMAT: ${FORMAT}`); + let transport; + // 创建空数据 let body = {}; // 格式判断 @@ -78,7 +99,8 @@ export async function Response($request, $response) { case "application/grpc": case "application/grpc+proto": case "application/octet-stream": { - let rawBody = $response.bodyBytes ? new Uint8Array($response.bodyBytes) : ($response.body ?? new Uint8Array()); + const bytes = $response.bodyBytes; + let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); switch (FORMAT) { case "application/vnd.apple.flatbuffer": { // 解析FlatBuffer @@ -89,50 +111,66 @@ export async function Response($request, $response) { // 路径判断 if (url.pathname.startsWith("/api/v2/weather/")) { const parameters = parseWeatherKitURL(url); - const dataSets = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); + if (!dataSets.length) break; + transport = new ProviderTransport(); const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); body = WeatherKit2.decode(ByteBuffer, rootNames); const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; - const enviroments = { - colorfulClouds: new ColorfulClouds(providerParameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), - qWeather: new QWeather(providerParameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host), - waqi: new WAQI(parameters, Settings?.API?.WAQI?.Token), - country: parameters.country, - }; + const enviroments = createProviderEnvironment(providerParameters, Settings, transport); + let patch; await Promise.all( dataSets.map(async dataSet => { - switch (dataSet) { - case "airQuality": { - body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); - break; - } - case "currentWeather": { - body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); - break; - } - case "forecastDaily": { - body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); - break; - } - case "forecastHourly": { - body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); - break; - } - case "forecastNextHour": { - body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); - break; + if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; + const original = body[dataSet]; + try { + body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; + switch (dataSet) { + case "airQuality": { + body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); + break; + } + case "currentWeather": { + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); + break; + } + case "forecastNextHour": { + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); + break; + } + case "weatherAlerts": { + body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); + break; + } + default: + break; } - case "weatherAlerts": { - body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); - break; + if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; + else { + patch ??= {}; + patch[dataSet] = body[dataSet]; } - default: - break; + } catch { + body[dataSet] = original; } }), ); - rawBody = WeatherKit2.encode(ByteBuffer, body); + if (patch) { + const encoded = WeatherKit2.encode(ByteBuffer, patch); + // The pinned root processor returns a view of the original + // bytes when every product fails to compile. + if (encoded.buffer !== rawBody.buffer || encoded.byteOffset !== rawBody.byteOffset || encoded.byteLength !== rawBody.byteLength) return rewriteWeatherResponse($response, encoded); + } break; } break; @@ -185,11 +223,13 @@ async function InjectCurrentWeather(currentWeather, Settings, enviroments) { break; } } - if (newCurrentWeather?.metadata) { + + if (providerProductAvailable(newCurrentWeather)) { newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; currentWeather = { ...currentWeather, ...newCurrentWeather }; currentWeather.metadata.providerLogo ||= providerNameToLogo(currentWeather.metadata.providerName); } + Console.info("✅ InjectCurrentWeather"); return currentWeather; } @@ -224,11 +264,16 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { break; } } - if (newForecastDaily?.metadata) { - forecastDaily.metadata = { ...forecastDaily?.metadata, ...newForecastDaily.metadata }; - Weather.mergeForecast(forecastDaily?.days, newForecastDaily?.days); + + if (providerProductAvailable(newForecastDaily)) { + forecastDaily = { + ...forecastDaily, + metadata: { ...forecastDaily?.metadata, ...newForecastDaily.metadata }, + days: forecastDaily?.days?.length ? mergeForecastCopy(forecastDaily.days, newForecastDaily.days) : (newForecastDaily.days ?? []), + }; forecastDaily.metadata.providerLogo ||= providerNameToLogo(forecastDaily.metadata.providerName); } + Console.info("✅ InjectForecastDaily"); return forecastDaily; } @@ -263,11 +308,16 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { break; } } - if (newForecastHourly?.metadata) { - forecastHourly.metadata = { ...forecastHourly?.metadata, ...newForecastHourly.metadata }; - forecastHourly.hours = Weather.mergeForecast(forecastHourly?.hours, newForecastHourly?.hours); + + if (providerProductAvailable(newForecastHourly)) { + forecastHourly = { + ...forecastHourly, + metadata: { ...forecastHourly?.metadata, ...newForecastHourly.metadata }, + hours: forecastHourly?.hours?.length ? mergeForecastCopy(forecastHourly.hours, newForecastHourly.hours) : (newForecastHourly.hours ?? []), + }; forecastHourly.metadata.providerLogo ||= providerNameToLogo(forecastHourly.metadata.providerName); } + Console.info("✅ InjectForecastHourly"); return forecastHourly; } @@ -301,11 +351,13 @@ async function InjectForecastNextHour(forecastNextHour, Settings, enviroments) { break; } } - if (newForecastNextHour?.metadata) { + + if (providerProductAvailable(newForecastNextHour)) { newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; forecastNextHour.metadata.providerLogo ||= providerNameToLogo(forecastNextHour.metadata.providerName); } + Console.info("✅ InjectForecastNextHour"); return forecastNextHour; } @@ -326,25 +378,22 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } case "ColorfulClouds": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsColorfulClouds ?? enviroments.colorfulClouds).WeatherAlert(); break; } case "QWeather": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlert(); break; } case "QWeatherWeb": default: { - newWeatherAlerts = await enviroments.qWeather.WeatherAlertWeb(weatherAlerts?.detailsUrl); + newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlertWeb(weatherAlerts?.detailsUrl); break; } } - if (newWeatherAlerts?.metadata) { - weatherAlerts.metadata = { ...weatherAlerts?.metadata, ...newWeatherAlerts.metadata }; - weatherAlerts.detailsUrl = newWeatherAlerts.detailsUrl ?? weatherAlerts.detailsUrl; - weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, newWeatherAlerts?.alerts); - weatherAlerts.metadata.providerLogo ||= providerNameToLogo(weatherAlerts.metadata.providerName); - } + + weatherAlerts = mergeWeatherAlertsProduct(weatherAlerts, newWeatherAlerts); + Console.info("✅ InjectWeatherAlerts"); return weatherAlerts; } @@ -360,23 +409,54 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step1. 修复污染物单位 // Step 1. Fix pollutant units. - airQuality = AirQuality.FixPollutantsUnits(airQuality); - - // Step2. 判断原始污染物是否为空,并在需要时注入污染物数据 - const isPollutantEmpty = !Array.isArray(airQuality?.pollutants) || airQuality.pollutants.length === 0; - const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; - const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); + airQuality = AirQuality.FixPollutantsUnits(airQuality, { appleProduct: true }); + + // 只补齐缺失/无效的污染物,已有有效 Apple 浓度优先;失败不丢弃原数据。 + const originalPollutants = AirQuality.ValidPollutants(airQuality?.pollutants); + let injectedPollutants = { ...airQuality, pollutants: originalPollutants }; + let needPollutants = false; + if (AirQuality.MissingPollutants(originalPollutants).length) { + try { + const supplement = await InjectPollutants(Settings, enviroments); + if (providerProductAvailable(supplement)) { + const pollutants = AirQuality.MergePollutants(originalPollutants, supplement.pollutants); + needPollutants = pollutants.length > originalPollutants.length; + if (needPollutants) injectedPollutants = { ...supplement, ...airQuality, pollutants, metadata: { ...supplement.metadata, temporarilyUnavailable: false } }; + } + } catch {} + } - // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; + // The algorithm chooses how to convert; the target list decides whether. + const targets = Settings?.AirQuality?.Current?.Index?.Replace ?? []; + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider ?? "Calculate"; + const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; + const originalIndexAvailable = AirQuality.HasUsableIndex(airQuality); + const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!originalIndexAvailable && needPollutants)); + let injectedIndex = airQuality, + indexAvailable = false; + if (needInjectIndex) { + try { + const candidate = await InjectIndex(injectedPollutants, Settings, enviroments); + if (AirQuality.HasUsableIndex(candidate)) { + injectedIndex = candidate; + indexAvailable = true; + } + } catch {} + } - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Follow the original comparison switch and independently selected source. + const indexChanged = indexAvailable && ["index", "scale", "categoryIndex"].some(key => airQuality?.[key] !== injectedIndex[key]); const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = needInjectIndex && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const previousDayComparison = indexChanged && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; - const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; + const currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; + let injectedComparison = { ...injectedIndex, previousDayComparison }; + if (needInjectComparison) { + try { + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments); + } catch {} + } // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 const weatherKitMetadata = airQuality?.metadata; @@ -386,7 +466,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const providers = [ ...(weatherKitMetadata?.providerName && !weatherKitMetadata.temporarilyUnavailable ? [weatherKitMetadata.providerName] : []), ...(needPollutants && pollutantMetadata?.providerName && !pollutantMetadata.temporarilyUnavailable ? [`污染物:${pollutantMetadata.providerName}`] : []), - ...(needInjectIndex && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), + ...(indexAvailable && indexMetadata?.providerName && !indexMetadata.temporarilyUnavailable ? [`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`] : []), ...(needInjectComparison && comparisonMetadata?.providerName && !comparisonMetadata.temporarilyUnavailable ? [`对比昨日:\n${comparisonMetadata.providerName}`] : []), ]; @@ -396,16 +476,17 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison airQuality = { ...airQuality, - ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? injectedIndex : {}), + ...(indexAvailable ? injectedIndex : {}), metadata: { ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), + ...(indexAvailable ? { temporarilyUnavailable: false } : !originalIndexAvailable ? { temporarilyUnavailable: true } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + pollutants: AirQuality.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings)), previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; - Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); return airQuality; } @@ -541,7 +622,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayAirQuality.metadata.temporarilyUnavailable) { - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayAirQuality, metadata: getMetadata(false), @@ -578,7 +659,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Storage.setItem("@iRingo.WeatherKit.Caches", { ...Caches, qweather: qweatherCache }); }; - const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache); + const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.transport); const { latitude, longitude } = enviroments.qWeather; const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); @@ -591,7 +672,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayQWeather.metadata.temporarilyUnavailable) { - const airQualityFromPollutants = pollutantsToAirQuality(yesterdayQWeather); + const airQualityFromPollutants = pollutantsToAirQuality?.(yesterdayQWeather); const yesterdayAirQuality = pollutantsToAirQuality ? { ...airQualityFromPollutants, @@ -608,7 +689,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }, }; - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayQWeather, metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), diff --git a/src/process/ResponseJSONV3.mjs b/src/process/ResponseJSONV3.mjs new file mode 100644 index 00000000..c8c99aa9 --- /dev/null +++ b/src/process/ResponseJSONV3.mjs @@ -0,0 +1,366 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// v3 JSON is negotiated with Apple; never feed WK3 bytes into the WK2 schema. +import { Console } from "@nsnanocat/util"; +import ProviderTransport from "../class/ProviderTransport.mjs"; +import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; +import database from "../function/database.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import planWeatherProducts from "../function/planWeatherProducts.mjs"; +import rewriteWeatherResponse from "../function/rewriteWeatherResponse.mjs"; +import { cloneWeatherProduct, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import setENV from "../function/setENV.mjs"; +import utf8Length from "../function/utf8Length.mjs"; +import { MAX_DECODED_VECTOR_ENTRIES, MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; + +export const MAX_WEATHER_JSON_CHARACTERS = 1024 * 1024; +const productNames = ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"]; +const clientProductNames = [...productNames, "forecastPeriodic", "weatherChanges"]; +// Known-container traversal adapted from hhh2210/WeatherKit PR #110, +// f4676feae89a27a8403c574c6086a433d5adb3c9, Apache-2.0; its JSON adapter credits PR #107. +const clientContainers = new Set(["metadata", "days", "hours", "minutes", "daytimeForecast", "overnightForecast", "restOfDayForecast", "series", "forecasts", "periods", "condition", "parameters", "summary", "alerts", "pollutants"]); +const providerMethods = new Set(["WeatherNow", "CurrentWeather", "Hourly", "ForecastHourly", "Daily", "Minutely", "WeatherAlert", "WeatherAlertWeb"]); +const object = value => value !== null && typeof value === "object" && !Array.isArray(value); +// WK3's FlatBuffer JSON and Weather's JSON reader have different shapes. +// Native macOS 27.3 logs confirm that the JSON reader expects string conditions +// and scalar precipitation amounts, even when Apple's v3 JSON contains vectors. +const percentageFields = new Set(["cloudCover", "cloudCoverLowAltPct", "cloudCoverMidAltPct", "cloudCoverHighAltPct", "humidity", "humidityMax", "humidityMin", "precipitationChance"]); +const byteFields = new Set(["uvIndex", "maxUvIndex", "uvIndexMax", "uvIndexMin", "categoryIndex"]); +const timestampFields = new Set([ + "asOf", + "readTime", + "reportedTime", + "expireTime", + "forecastStart", + "forecastEnd", + "startTime", + "endTime", + "date", + "effectiveTime", + "issuedTime", + "eventOnsetTime", + "eventEndTime", + "moonrise", + "moonset", + "sunrise", + "sunset", + "sunriseCivil", + "sunsetCivil", + "sunriseNautical", + "sunsetNautical", + "sunriseAstronomical", + "sunsetAstronomical", + "solarNoon", + "solarMidnight", + "temperatureMaxTime", + "temperatureMinTime", +]); +const optionalTimestampFields = new Set([ + "reportedTime", + "endTime", + "eventOnsetTime", + "eventEndTime", + "moonrise", + "moonset", + "sunrise", + "sunset", + "sunriseCivil", + "sunsetCivil", + "sunriseNautical", + "sunsetNautical", + "sunriseAstronomical", + "sunsetAstronomical", + "solarNoon", + "solarMidnight", + "temperatureMaxTime", + "temperatureMinTime", +]); +const precipitationByTypeField = /^precipitationAmount(?:(?:Previous|Next)\d+h)?ByType$/; +const percentRange = [0, 100], + byteRange = [0, 255], + shortRange = [0, 65535], + timeRange = [0, 0xffffffff]; +function integerRange(key) { + if (percentageFields.has(key)) return percentRange; + if (byteFields.has(key)) return byteRange; + if (key === "index" || key === "windDirection") return shortRange; + if (timestampFields.has(key)) return timeRange; +} + +// Share the existing per-product vector budget. Reject before starting provider work. +export function boundedWeatherJSON(value) { + let entries = 0, + nodes = 0; + function visit(item, depth) { + if (++nodes > 100000 || depth > 64) throw new RangeError("Weather JSON exceeds traversal bounds"); + if (!item || typeof item !== "object") return; + if (Array.isArray(item)) { + entries += item.length; + if (entries > MAX_DECODED_VECTOR_ENTRIES) throw new RangeError("Weather product exceeds vector decoding budget"); + for (const child of item) visit(child, depth + 1); + } else for (const key of Object.keys(item)) visit(item[key], depth + 1); + } + visit(value, 0); + return value; +} + +// Work only on a parsed, owned product. Unknown top-level products are untouched. +// Preserve by-type totals while adding the scalar required by the native reader. +export function adaptV3ClientProduct(product, name) { + boundedWeatherJSON(product); + let changed = false; + // WK3 UUID tables contain sixteen octets. Weather's JSON reader expects + // the same identifier as a UUID string, including for retained Apple alerts. + if (name === "weatherAlerts" && Array.isArray(product?.alerts)) { + const hex = "0123456789abcdef"; + for (const alert of product.alerts) { + const bytes = alert?.id?.bytes; + if (!Array.isArray(bytes) || bytes.length !== 16 || !bytes.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255)) continue; + let uuid = ""; + for (let i = 0; i < 16; i++) { + if (i === 4 || i === 6 || i === 8 || i === 10) uuid += "-"; + uuid += hex[bytes[i] >> 4] + hex[bytes[i] & 15]; + } + alert.id = uuid; + changed = true; + } + } + function visit(value) { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + for (const key of Object.keys(value)) { + let item = value[key]; + if ((key === "conditionCode" || key === "precipitationType" || (key === "condition" && item?.length > 0)) && Array.isArray(item) && item.every(entry => typeof entry === "string")) { + const first = item.find(entry => typeof entry === "string" && entry); + if (first === undefined) delete value[key]; + else value[key] = first; + item = value[key]; + changed = true; + } + if ((key === "precipitationAmount" || key.startsWith("snowfallAmount")) && object(item) && Number.isFinite(item.expected)) { + value[key] = item = item.expected; + changed = true; + } + if (precipitationByTypeField.test(key) && Array.isArray(item)) { + const scalarKey = key.slice(0, -"ByType".length); + if (value[scalarKey] === undefined && item.every(part => object(part) && Number.isFinite(part.expected) && part.expected >= 0)) { + const amount = item.reduce((sum, part) => sum + part.expected, 0); + if (Number.isFinite(amount)) { + value[scalarKey] = amount; + changed = true; + } + } + } + if (clientContainers.has(key) || precipitationByTypeField.test(key)) visit(item); + } + } + visit(product); + return changed; +} + +// Provider graphs are owned by their consumer. Apple input graphs stay read-only. +export function adaptV3ProviderProduct(product, name) { + if (!object(product)) return product; + boundedWeatherJSON(product); + function visit(value) { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + for (const key of Object.keys(value)) { + let item = value[key]; + if (item == null) { + if (item === undefined || integerRange(key) || clientContainers.has(key) || ["latitude", "longitude", "conditionCode", "precipitationType", "precipitationAmount", "snowfallAmount"].includes(key)) delete value[key]; + continue; + } + if (typeof item === "number" && !Number.isFinite(item)) { + delete value[key]; + continue; + } + // WK2 used zero as "no end"; WK3's optional timestamps must be absent. + if (item === 0 && optionalTimestampFields.has(key)) { + delete value[key]; + continue; + } + const range = integerRange(key); + if (range && typeof item === "number") { + if (item < range[0] || item > range[1]) { + delete value[key]; + continue; + } + value[key] = item = timestampFields.has(key) ? Math.trunc(item) : Math.round(item); + } + if (key === "metadata" && object(item)) { + for (const coordinate of ["latitude", "longitude"]) + if (typeof item[coordinate] === "string") { + const number = Number(item[coordinate]); + if (item[coordinate].trim() && Number.isFinite(number)) item[coordinate] = number; + else delete item[coordinate]; + } + } + if (clientContainers.has(key) || precipitationByTypeField.test(key)) visit(item); + } + } + visit(product); + adaptV3ClientProduct(product, name); + return product; +} + +// Do not serialize a partly valid provider product into an otherwise valid Apple response. +// Unknown fields are left alone. Known scalar arrays and statistics must remain JSON-safe. +export function validateWeatherJSONV3(product, name) { + if (!object(product)) throw new TypeError("Missing weather JSON product"); + boundedWeatherJSON(product); + if (name === "airQuality" && product?.metadata?.temporarilyUnavailable !== true && (!Number.isInteger(product?.index) || product.index < 0 || product.index > 65535 || !Number.isInteger(product?.categoryIndex) || product.categoryIndex < 1 || product.categoryIndex > 255)) + throw new TypeError("Invalid WK3 air quality index"); + if (name === "weatherAlerts" && Array.isArray(product.alerts) && product.alerts.some(alert => object(alert) && alert.id !== undefined && typeof alert.id !== "string")) throw new TypeError("Invalid native weather alert identifier"); + function visit(value, known = true) { + const kind = typeof value; + if (kind === "bigint" || kind === "symbol" || kind === "function") throw new TypeError("Unsupported weather JSON value"); + if (kind === "string") { + // JSON.stringify escapes lone surrogates, but Apple's JSON reader rejects them. + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new TypeError("Invalid weather JSON string"); + } else if (code >= 0xdc00 && code <= 0xdfff) throw new TypeError("Invalid weather JSON string"); + } + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || (known && item === null)) throw new TypeError("Missing weather JSON array item"); + if (typeof item === "number" && (!Number.isFinite(item) || (known && !Number.isFinite(Math.fround(item))))) throw new TypeError("Invalid weather JSON array number"); + visit(item, known); + } + return; + } + for (const key of Object.keys(value)) { + const item = value[key], + range = integerRange(key); + if (known && range && item !== null && (typeof item !== "number" || !Number.isInteger(item) || item < range[0] || item > range[1])) throw new TypeError("Invalid WK3 integer field"); + if (typeof item === "number" && (!Number.isFinite(item) || (known && !Number.isFinite(Math.fround(item))))) throw new TypeError("Invalid weather JSON number"); + if (known && (key === "conditionCode" || key === "precipitationType" || (key === "condition" && !Array.isArray(item))) && typeof item !== "string") throw new TypeError("Invalid native weather JSON condition"); + if (known && (key === "precipitationAmount" || key === "snowfallAmount") && (typeof item !== "number" || !Number.isFinite(item))) throw new TypeError("Invalid native weather JSON precipitation amount"); + visit(item, known && (clientContainers.has(key) || precipitationByTypeField.test(key))); + } + } + visit(product); + return product; +} + +function v3Environment(environment) { + function wrap(provider) { + return new Proxy(provider, { + get(target, key) { + const value = Reflect.get(target, key, target); + if (typeof value !== "function") return value; + if (providerMethods.has(key)) return async (...args) => adaptV3ProviderProduct(await value.apply(target, args), key === "WeatherAlert" || key === "WeatherAlertWeb" ? "weatherAlerts" : undefined); + return value.bind(target); + }, + }); + } + let colorfulClouds, qWeather, weatherAlertsColorfulClouds, weatherAlertsQWeather; + return { + country: environment.country, + transport: environment.transport, + get colorfulClouds() { + return (colorfulClouds ??= wrap(environment.colorfulClouds)); + }, + get qWeather() { + return (qWeather ??= wrap(environment.qWeather)); + }, + get weatherAlertsColorfulClouds() { + return (weatherAlertsColorfulClouds ??= wrap(environment.weatherAlertsColorfulClouds)); + }, + get weatherAlertsQWeather() { + return (weatherAlertsQWeather ??= wrap(environment.weatherAlertsQWeather)); + }, + get waqi() { + return environment.waqi; + }, + }; +} + +export default async function ResponseJSONV3(request, response, injectors) { + const url = new URL(request.url); + if (url.hostname !== "weatherkit.apple.com" || !/^\/api\/v3\/weather\//.test(url.pathname) || (request.method && request.method !== "GET") || (response.status !== undefined && response.status !== 200)) return response; + const raw = response.body; + if (typeof raw !== "string" || raw.length > MAX_WEATHER_JSON_CHARACTERS || utf8Length(raw, MAX_WEATHER_BODY_BYTES) > MAX_WEATHER_BODY_BYTES) return response; + const parameters = parseWeatherKitURL(url); + const latitude = Number(parameters.latitude), + longitude = Number(parameters.longitude); + if (!parameters.version || parameters.latitude === undefined || parameters.longitude === undefined || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return response; + let body; + try { + body = JSON.parse(raw); + } catch { + return response; + } + if (!object(body) || !clientProductNames.some(name => object(body[name]) && object(body[name].metadata))) return response; + let modified = false; + for (const name of clientProductNames) { + if (!object(body[name])) continue; + try { + modified = adaptV3ClientProduct(body[name], name) || modified; + } catch { + /* Keep products beyond processing bounds. */ + } + } + const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); + Console.logLevel = Settings.LogLevel; + const regionParameters = parameters; + const requested = regionParameters.dataSets.filter(name => Settings.DataSets.includes(name)); + const selected = planWeatherProducts(requested, Settings, regionParameters.country, Boolean(body.forecastNextHour)); + const eligible = selected.filter(name => { + try { + if (body[name] !== undefined && (!object(body[name]) || !object(body[name].metadata))) return false; + boundedWeatherJSON(body[name]); + return typeof injectors[name] === "function"; + } catch { + return false; + } + }); + if (!eligible.length) return modified ? encodeV3Response(body, response) : response; + // Keep one bounded, normalized Apple fallback before provider replacements. + // Output-size failure must not restore the incompatible pre-normalization JSON. + const fallbackResponse = modified ? encodeV3Response(body, response) : response; + const transport = new ProviderTransport(); + const environment = v3Environment(createProviderEnvironment({ ...regionParameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }, Settings, transport)); + + await Promise.all( + eligible.map(async name => { + const original = body[name]; + + try { + const input = name === "airQuality" ? cloneWeatherProduct(original) : original; + const value = name === "airQuality" ? await injectors[name](input, Settings, Caches, environment) : await injectors[name](input, Settings, environment); + if (!weatherProductsEqual(value, original)) { + // AQI and warning injectors can also add numeric metadata fields. + body[name] = validateWeatherJSONV3(adaptV3ProviderProduct(value, name), name); + modified = true; + } + } catch { + body[name] = original; + } + }), + ); + if (!modified) return response; + return encodeV3Response(body, fallbackResponse); +} + +function encodeV3Response(body, response) { + let encoded; + try { + encoded = JSON.stringify(body); + } catch { + return response; + } + if (encoded.length > MAX_WEATHER_JSON_CHARACTERS || utf8Length(encoded, MAX_WEATHER_BODY_BYTES) > MAX_WEATHER_BODY_BYTES) return response; + return rewriteWeatherResponse(response, encoded); +} diff --git a/src/types.d.ts b/src/types.d.ts index b14e0be8..a2a1ebf9 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,3 +1,4 @@ +// WeatherKit settings. export interface Settings { DataSets?: { /** @@ -58,6 +59,8 @@ export interface Settings { }; WeatherAlerts?: { Provider?: "WeatherKit" | "QWeatherWeb" | "QWeather" | "ColorfulClouds"; + /** Source-native alert text preference; missing/Auto preserves existing behavior. No machine translation. */ + Language?: "Auto" | "zh-Hans" | "en"; }; NextHour?: { /** diff --git a/template/boxjs.settings.json b/template/boxjs.settings.json index 85ce3410..a991f649 100644 --- a/template/boxjs.settings.json +++ b/template/boxjs.settings.json @@ -1,151 +1 @@ -[ - { - "id": "@iRingo.WeatherKit.Settings.DataSets", - "name": "[数据集]", - "type": "checkboxes", - "val": ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "news", "weatherAlerts", "weatherChange", "trendComparison", "locationInfo"], - "items": [ - { "key": "airQuality", "label": "空气质量" }, - { "key": "currentWeather", "label": "当前天气" }, - { "key": "forecastDaily", "label": "每日预报" }, - { "key": "forecastHourly", "label": "每小时预报" }, - { "key": "forecastNextHour", "label": "未来一小时降水强度" }, - { "key": "news", "label": "新闻" }, - { "key": "weatherAlerts", "label": "天气预警" }, - { "key": "weatherChange", "label": "天气变化" }, - { "key": "trendComparison", "label": "历史对比" }, - { "key": "locationInfo", "label": "位置信息" } - ], - "desc": "选择需要解析并处理的数据集;未选择的数据槽始终保持原样透传,不作修改。" - }, - { "id": "@iRingo.WeatherKit.Settings.Weather.Replace", "name": "[天气] 替换范围", "type": "text", "val": "CN", "desc": "正则表达式,只替换指定地区的天气。" }, - { - "id": "@iRingo.WeatherKit.Settings.Weather.Provider", - "name": "[天气] 数据源", - "type": "selects", - "val": "ColorfulClouds", - "items": [{ "key": "WeatherKit", "label": "WeatherKit(不替换)" }, { "key": "ColorfulClouds", "label": "彩云天气" }, { "key": "QWeather", "label": "和风天气" }], - "desc": "使用选定的数据源替换天气数据。" - }, - { - "id": "@iRingo.WeatherKit.Settings.WeatherAlerts.Provider", - "name": "[天气预警] 数据源", - "type": "selects", - "val": "QWeatherWeb", - "items": [{ "key": "WeatherKit", "label": "WeatherKit(不修改)" }, { "key": "QWeatherWeb", "label": "和风天气网页" }, { "key": "QWeather", "label": "和风天气 API" }, { "key": "ColorfulClouds", "label": "彩云天气 API" }], - "desc": "使用选定的数据源处理天气预警数据。" - }, - { - "id": "@iRingo.WeatherKit.Settings.NextHour.Provider", - "name": "[未来一小时降水强度] 数据源", - "type": "selects", - "val": "ColorfulClouds", - "items": [{ "key": "WeatherKit", "label": "WeatherKit(不添加)" }, { "key": "ColorfulClouds", "label": "彩云天气" }, { "key": "QWeather", "label": "和风天气" }], - "desc": "使用选定的数据源填充未来一小时降水强度的数据。" - }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Provider", "name": "[今日污染物] 数据源", "type": "selects", "val": "ColorfulClouds", "items": [{ "key": "ColorfulClouds", "label": "彩云天气" }, { "key": "QWeather", "label": "和风天气" }], "desc": "使用选定的数据源填补污染物数据。" }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Replace", - "name": "[今日污染物 - 单位转换] 替换目标", - "type": "checkboxes", - "val": [], - "items": [{ "key": "EPA_NowCast", "label": "美国AQI(EPA_NowCast)" }, { "key": "EU.EAQI", "label": "欧盟EAQI(EU.EAQI)" }, { "key": "HJ6332012", "label": "中国AQI(HJ6332012)" }, { "key": "UBA", "label": "德国LQI(UBA)" }], - "desc": "转换污染物的单位,方便与空气质量标准比对。单位转换会产生小数,有略微精度损失,且小数部分可能会被省略。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Mode", - "name": "[今日污染物 - 单位转换] 模式", - "type": "selects", - "val": "Scale", - "items": [ - { "key": "Scale", "label": "与空气质量标准的要求相同" }, - { "key": "ugm3", "label": "除非标准要求,都转为µg/m³" }, - { "key": "EU_ppb", "label": "除非标准要求,都转为欧盟ppb" }, - { "key": "US_ppb", "label": "除非标准要求,都转为美标ppb" }, - { "key": "Force_ugm3", "label": "µg/m³" }, - { "key": "Force_EU_ppb", "label": "欧盟ppb" }, - { "key": "Force_US_ppb", "label": "美标ppb" } - ], - "desc": "污染物单位的转换目标。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Replace", - "name": "[今日空气指数] 替换目标", - "type": "checkboxes", - "val": ["HJ6332012"], - "items": [ - { "key": "HJ6332012", "label": "中国AQI(HJ6332012)" }, - { "key": "IE.AQIH", "label": "爱尔兰AQIH(IE.AQIH)" }, - { "key": "AT.AQI", "label": "奥地利AQI(AT.AQI)" }, - { "key": "BE.BelAQI", "label": "比利时BelAQI(BE.BelAQI)" }, - { "key": "UBA", "label": "德国LQI(UBA)" }, - { "key": "FR.ATMO", "label": "法国IQA(FR.ATMO)" }, - { "key": "KR.CAI", "label": "韩国CAI(KR.CAI)" }, - { "key": "CA.AQHI", "label": "加拿大AQHI(CA.AQHI)" }, - { "key": "CZ.AQI", "label": "捷克AQI(CZ.AQI)" }, - { "key": "NL.LKI", "label": "荷兰LKI(NL.LKI)" }, - { "key": "EPA_NowCast", "label": "美国AQI(EPA_NowCast)" }, - { "key": "ICARS", "label": "墨西哥ICARS(ICARS)" }, - { "key": "EU.EAQI", "label": "欧盟EAQI(EU.EAQI)" }, - { "key": "CH.KBI", "label": "瑞士KBI(CH.KBI)" }, - { "key": "ES.MITECO", "label": "西班牙ICA(ES.MITECO)" }, - { "key": "SG.NEA", "label": "新加坡PSI(SG.NEA)" }, - { "key": "NAQI", "label": "印度NAQI(NAQI)" }, - { "key": "DAQI", "label": "英国DAQI(DAQI)" } - ], - "desc": "替换指定标准的空气质量指数。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Provider", - "name": "[今日空气指数] 数据源", - "type": "selects", - "val": "Calculate", - "items": [{ "key": "Calculate", "label": "iRingo内置算法" }, { "key": "ColorfulCloudsUS", "label": "彩云天气(美标,18年9月版)" }, { "key": "ColorfulCloudsCN", "label": "彩云天气(国标,12年2月版)" }, { "key": "QWeather", "label": "和风天气(国标,12年2月版)" }], - "desc": "使用选定的数据源填补和替换空气质量指数。" - }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants", "name": "[今日空气指数] 强制主要污染物", "type": "boolean", "val": true, "desc": "忽略国标(HJ 633—2012)的AQI > 50规定,始终将IAQI最大的空气污染物作为主要污染物。" }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Comparison.ReplaceWhenCurrentChange", "name": "[空气质量 - 对比昨日] 变化时替换", "type": "boolean", "val": false, "desc": "即使已有对比昨日数据,当今日空气质量指数发生变化时,替换对比昨日数据。" }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.PollutantsProvider", "name": "[昨日污染物] 数据源", "type": "selects", "val": "QWeather", "items": [{ "key": "QWeather", "label": "和风天气" }], "desc": "为iRingo内置算法提供污染物数据,计算出昨日的空气质量指数。" }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.IndexProvider", - "name": "[昨日空气指数] 数据源", - "type": "selects", - "val": "ColorfulCloudsUS", - "items": [{ "key": "Calculate", "label": "iRingo内置算法" }, { "key": "ColorfulCloudsUS", "label": "彩云天气(美标,18年9月版)" }, { "key": "ColorfulCloudsCN", "label": "彩云天气(国标,12年2月版)" }, { "key": "QWeather", "label": "和风天气(国标,12年2月版)" }], - "desc": "用来和今日空气质量指数对比的数据。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Calculate.Algorithm", - "name": "[iRingo内置算法] 算法", - "type": "selects", - "val": "EU_EAQI", - "items": [ - { "key": "None", "label": "不转换" }, - { "key": "UBA", "label": "德国LQI(FB001846)" }, - { "key": "EU_EAQI", "label": "欧盟EAQI(ETC HE Report 2024/17)" }, - { "key": "WAQI_InstantCast_US", "label": "美标InstantCast(EPA-454/B-24-002)" }, - { "key": "WAQI_InstantCast_CN", "label": "国标InstantCast(HJ 633—2012)" }, - { "key": "WAQI_InstantCast_CN_25_DRAFT", "label": "国标InstantCast(HJ 633 2025年草案)" }, - { "key": "CA_AQHI", "label": "加拿大AQHI(10.17269/s41997-019-00237-w)" }, - { "key": "HK_AQHI", "label": "香港AQHI" }, - { "key": "AQHI_Multi_CN", "label": "AQHI-Multi(中国)" }, - { "key": "AQHI_Multi_CN_HK", "label": "AQHI-Multi(中国+香港)" }, - { "key": "CN_DEATH_AQHI", "label": "中国(致死风险)AQHI" }, - { "key": "CN_DEATH_HK_AQHI", "label": "中国(致死风险)+香港AQHI" } - ], - "desc": "使用内置算法,通过污染物数据本地计算空气指数。InstantCast源自于WAQI,美标版本使用了WAQI的臭氧标准。" - }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Calculate.AllowOverRange", "name": "[iRingo内置算法] 允许指数超标", "type": "boolean", "val": true, "desc": "允许美标和国标的指数超过500。超过500时,指示颜色的小圆点会消失。" }, - { "id": "@iRingo.WeatherKit.Settings.API.ColorfulClouds.Token", "name": "[API] 彩云天气令牌", "type": "text", "val": "", "desc": "彩云天气 API 令牌", "placeholder": "123456789123456789abcdefghijklmnopqrstuv" }, - { "id": "@iRingo.WeatherKit.Settings.API.QWeather.Host", "name": "[API] 和风天气主机", "type": "text", "val": "devapi.qweather.com", "desc": "和风天气 API 使用的主机名", "placeholder": "devapi.qweather.com" }, - { "id": "@iRingo.WeatherKit.Settings.API.QWeather.Token", "name": "[API] 和风天气令牌", "type": "text", "val": "", "desc": "和风天气 API 令牌", "placeholder": "123456789123456789abcdefghijklmnopqrstuv" }, - { "id": "@iRingo.WeatherKit.Settings.API.WAQI.Token", "name": "[API] WAQI 令牌", "type": "text", "val": "", "desc": "WAQI API 令牌,填写此字段将自动使用WAQI高级API", "placeholder": "123456789123456789abcdefghijklmnopqrstuv" }, - { - "id": "@iRingo.WeatherKit.Settings.LogLevel", - "name": "[调试] 日志等级", - "type": "selects", - "val": "WARN", - "items": [{ "key": "OFF", "label": "关闭" }, { "key": "ERROR", "label": "❌ 错误" }, { "key": "WARN", "label": "⚠️ 警告" }, { "key": "INFO", "label": "ℹ️ 信息" }, { "key": "DEBUG", "label": "🅱️ 调试" }, { "key": "ALL", "label": "全部" }], - "desc": "选择脚本日志的输出等级,低于所选等级的日志将全部输出。" - } -] +[{"id":"@iRingo.WeatherKit.Settings.DataSets","name":"[数据集]","type":"checkboxes","val":["airQuality","currentWeather","forecastDaily","forecastHourly","forecastNextHour","news","weatherAlerts","weatherChange","trendComparison","locationInfo"],"items":[{"key":"airQuality","label":"空气质量"},{"key":"currentWeather","label":"当前天气"},{"key":"forecastDaily","label":"每日预报"},{"key":"forecastHourly","label":"每小时预报"},{"key":"forecastNextHour","label":"未来一小时降水强度"},{"key":"news","label":"新闻"},{"key":"weatherAlerts","label":"天气预警"},{"key":"weatherChange","label":"天气变化"},{"key":"trendComparison","label":"历史对比"},{"key":"locationInfo","label":"位置信息"}],"desc":"选择需要解析并处理的数据集;未选择的数据槽始终保持原样透传,不作修改。"},{"id":"@iRingo.WeatherKit.Settings.Weather.Replace","name":"[天气] 替换范围","type":"text","val":"CN","desc":"正则表达式,只替换指定地区的天气。"},{"id":"@iRingo.WeatherKit.Settings.Weather.Provider","name":"[天气] 数据源","type":"selects","val":"ColorfulClouds","items":[{"key":"WeatherKit","label":"WeatherKit(不替换)"},{"key":"ColorfulClouds","label":"彩云天气"},{"key":"QWeather","label":"和风天气"}],"desc":"使用选定的数据源替换天气数据。"},{"id":"@iRingo.WeatherKit.Settings.WeatherAlerts.Provider","name":"[天气预警] 数据源","type":"selects","val":"QWeatherWeb","items":[{"key":"WeatherKit","label":"WeatherKit(不修改)"},{"key":"QWeatherWeb","label":"和风天气网页"},{"key":"QWeather","label":"和风天气 API"},{"key":"ColorfulClouds","label":"彩云天气 API"}],"desc":"使用选定的数据源处理天气预警数据。"},{"id":"@iRingo.WeatherKit.Settings.WeatherAlerts.Language","name":"[天气预警] 正文语言偏好","type":"selects","val":"Auto","items":[{"key":"Auto","label":"自动(沿用请求语言)"},{"key":"zh-Hans","label":"简体中文(来源支持时)"},{"key":"en","label":"English(来源支持时)"}],"desc":"仅向所选来源请求预警语言,不使用第三方翻译。来源不支持或返回其他语言时保留原文、签发单位与出处;Apple 原始标题和页面标签可能仍随系统语言。"},{"id":"@iRingo.WeatherKit.Settings.NextHour.Provider","name":"[未来一小时降水强度] 数据源","type":"selects","val":"ColorfulClouds","items":[{"key":"WeatherKit","label":"WeatherKit(不添加)"},{"key":"ColorfulClouds","label":"彩云天气"},{"key":"QWeather","label":"和风天气"}],"desc":"使用选定的数据源填充未来一小时降水强度的数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Provider","name":"[今日污染物] 数据源","type":"selects","val":"ColorfulClouds","items":[{"key":"ColorfulClouds","label":"彩云天气"},{"key":"QWeather","label":"和风天气"}],"desc":"使用选定的数据源填补污染物数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Replace","name":"[今日污染物 - 单位转换] 替换目标","type":"checkboxes","val":[],"items":[{"key":"EPA_NowCast","label":"美国AQI(EPA_NowCast)"},{"key":"EU.EAQI","label":"欧盟EAQI(EU.EAQI)"},{"key":"HJ6332012","label":"中国AQI(HJ6332012)"},{"key":"UBA","label":"德国LQI(UBA)"}],"desc":"转换污染物的单位,方便与空气质量标准比对。单位转换会产生小数,有略微精度损失,且小数部分可能会被省略。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Mode","name":"[今日污染物 - 单位转换] 模式","type":"selects","val":"Scale","items":[{"key":"Scale","label":"与空气质量标准的要求相同"},{"key":"ugm3","label":"除非标准要求,都转为µg/m³"},{"key":"EU_ppb","label":"除非标准要求,都转为欧盟ppb"},{"key":"US_ppb","label":"除非标准要求,都转为美标ppb"},{"key":"Force_ugm3","label":"µg/m³"},{"key":"Force_EU_ppb","label":"欧盟ppb"},{"key":"Force_US_ppb","label":"美标ppb"}],"desc":"污染物单位的转换目标。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Replace","name":"[今日空气指数] 替换目标","type":"checkboxes","val":["HJ6332012"],"items":[{"key":"HJ6332012","label":"中国AQI(HJ6332012)"},{"key":"IE.AQIH","label":"爱尔兰AQIH(IE.AQIH)"},{"key":"AT.AQI","label":"奥地利AQI(AT.AQI)"},{"key":"BE.BelAQI","label":"比利时BelAQI(BE.BelAQI)"},{"key":"UBA","label":"德国LQI(UBA)"},{"key":"FR.ATMO","label":"法国IQA(FR.ATMO)"},{"key":"KR.CAI","label":"韩国CAI(KR.CAI)"},{"key":"CA.AQHI","label":"加拿大AQHI(CA.AQHI)"},{"key":"CZ.AQI","label":"捷克AQI(CZ.AQI)"},{"key":"NL.LKI","label":"荷兰LKI(NL.LKI)"},{"key":"EPA_NowCast","label":"美国AQI(EPA_NowCast)"},{"key":"ICARS","label":"墨西哥ICARS(ICARS)"},{"key":"EU.EAQI","label":"欧盟EAQI(EU.EAQI)"},{"key":"CH.KBI","label":"瑞士KBI(CH.KBI)"},{"key":"ES.MITECO","label":"西班牙ICA(ES.MITECO)"},{"key":"SG.NEA","label":"新加坡PSI(SG.NEA)"},{"key":"NAQI","label":"印度NAQI(NAQI)"},{"key":"DAQI","label":"英国DAQI(DAQI)"}],"desc":"替换指定标准的空气质量指数。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Provider","name":"[今日空气指数] 数据源","type":"selects","val":"Calculate","items":[{"key":"Calculate","label":"iRingo内置算法"},{"key":"ColorfulCloudsUS","label":"彩云天气(美标,18年9月版)"},{"key":"ColorfulCloudsCN","label":"彩云天气(国标,12年2月版)"},{"key":"QWeather","label":"和风天气(国标,12年2月版)"}],"desc":"使用选定的数据源填补和替换空气质量指数。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants","name":"[今日空气指数] 强制主要污染物","type":"boolean","val":true,"desc":"忽略国标(HJ 633—2012)的AQI > 50规定,始终将IAQI最大的空气污染物作为主要污染物。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Comparison.ReplaceWhenCurrentChange","name":"[空气质量 - 对比昨日] 变化时替换","type":"boolean","val":false,"desc":"即使已有对比昨日数据,当今日空气质量指数发生变化时,替换对比昨日数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.PollutantsProvider","name":"[昨日污染物] 数据源","type":"selects","val":"QWeather","items":[{"key":"QWeather","label":"和风天气"}],"desc":"为iRingo内置算法提供污染物数据,计算出昨日的空气质量指数。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.IndexProvider","name":"[昨日空气指数] 数据源","type":"selects","val":"ColorfulCloudsUS","items":[{"key":"Calculate","label":"iRingo内置算法"},{"key":"ColorfulCloudsUS","label":"彩云天气(美标,18年9月版)"},{"key":"ColorfulCloudsCN","label":"彩云天气(国标,12年2月版)"},{"key":"QWeather","label":"和风天气(国标,12年2月版)"}],"desc":"用来和今日空气质量指数对比的数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Calculate.Algorithm","name":"[iRingo内置算法] 算法","type":"selects","val":"EU_EAQI","items":[{"key":"None","label":"不转换"},{"key":"UBA","label":"德国LQI(FB001846)"},{"key":"EU_EAQI","label":"欧盟EAQI(ETC HE Report 2024/17)"},{"key":"WAQI_InstantCast_US","label":"美标InstantCast(EPA-454/B-24-002)"},{"key":"WAQI_InstantCast_CN","label":"国标InstantCast(HJ 633—2012)"},{"key":"WAQI_InstantCast_CN_25_DRAFT","label":"国标InstantCast(HJ 633 2025年草案)"},{"key":"CA_AQHI","label":"加拿大AQHI(10.17269/s41997-019-00237-w)"},{"key":"HK_AQHI","label":"香港AQHI"},{"key":"AQHI_Multi_CN","label":"AQHI-Multi(中国)"},{"key":"AQHI_Multi_CN_HK","label":"AQHI-Multi(中国+香港)"},{"key":"CN_DEATH_AQHI","label":"中国(致死风险)AQHI"},{"key":"CN_DEATH_HK_AQHI","label":"中国(致死风险)+香港AQHI"}],"desc":"使用内置算法,通过污染物数据本地计算空气指数。InstantCast源自于WAQI,美标版本使用了WAQI的臭氧标准。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Calculate.AllowOverRange","name":"[iRingo内置算法] 允许指数超标","type":"boolean","val":true,"desc":"允许美标和国标的指数超过500。超过500时,指示颜色的小圆点会消失。"},{"id":"@iRingo.WeatherKit.Settings.API.ColorfulClouds.Token","name":"[API] 彩云天气令牌","type":"text","val":"","desc":"彩云天气 API 令牌","placeholder":"123456789123456789abcdefghijklmnopqrstuv"},{"id":"@iRingo.WeatherKit.Settings.API.QWeather.Host","name":"[API] 和风天气主机","type":"text","val":"devapi.qweather.com","desc":"和风天气 API 使用的主机名","placeholder":"devapi.qweather.com"},{"id":"@iRingo.WeatherKit.Settings.API.QWeather.Token","name":"[API] 和风天气令牌","type":"text","val":"","desc":"和风天气 API 令牌","placeholder":"123456789123456789abcdefghijklmnopqrstuv"},{"id":"@iRingo.WeatherKit.Settings.API.WAQI.Token","name":"[API] WAQI 令牌","type":"text","val":"","desc":"WAQI API 令牌,填写此字段将自动使用WAQI高级API","placeholder":"123456789123456789abcdefghijklmnopqrstuv"},{"id":"@iRingo.WeatherKit.Settings.LogLevel","name":"[调试] 日志等级","type":"selects","val":"WARN","items":[{"key":"OFF","label":"关闭"},{"key":"ERROR","label":"❌ 错误"},{"key":"WARN","label":"⚠️ 警告"},{"key":"INFO","label":"ℹ️ 信息"},{"key":"DEBUG","label":"🅱️ 调试"},{"key":"ALL","label":"全部"}],"desc":"选择脚本日志的输出等级,低于所选等级的日志将全部输出。"}] \ No newline at end of file diff --git a/template/loon.handlebars b/template/loon.handlebars index 3fa77afd..47cf6eb5 100644 --- a/template/loon.handlebars +++ b/template/loon.handlebars @@ -19,12 +19,17 @@ DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP DOMAIN,tether.edge.apple,REJECT-DROP AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP +[Rewrite] +request if ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// then request.header.set("Accept", "application/json") +response if ${url} ~= /^https?:\/\/weatherkit\.apple\.com\/api\/v1\/availability\// then response.json.jq("if type == \"array\" and all(.[]; type == \"string\") then reduce (. + [\"airQuality\",\"currentWeather\",\"forecastDaily\",\"forecastHourly\",\"forecastPeriodic\",\"historicalComparisons\",\"weatherChanges\",\"forecastNextHour\",\"weatherAlerts\",\"weatherAlertNotifications\",\"news\"])[] as $item ([]; if index($item) == null then . + [$item] else . end) else . end") +request if (${request.method} == "GET" || ${request.method} == "HEAD" || ${request.method} == "OPTIONS") && ${url} ~= /^(https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/[A-Z0-9]+(?:-[A-Z0-9]+)*\/[^\/?#]+)\.\d+(?=[?#]|$)/i as scale then url.replace("${scale.1}") + [Script] -http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/availability\/ requires-body=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.bundle.js, tag=🌤 WeatherKit.api.v1.availability.response, argument={{{scriptParams}}} -http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/ script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.bundle.js, tag=🌤 WeatherKit.api.v1.airQualityScale.request, argument={{{scriptParams}}} -http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?[^#]*&ids=[^&#]*-[0-9]{9}(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.html.request, argument={{{scriptParams}}} -http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?[^#]*&ids=-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.api.request, argument={{{scriptParams}}} -http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v2\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.bundle.js, tag=🌤 WeatherKit.api.v2.weather.response, argument={{{scriptParams}}} +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\/(?:HK|CN)\.AQHI(?:\.\d+)?(?:[?#]|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/scale.loon.bundle.js, tag=🌤 WeatherKit.api.v1.airQualityScale.request +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=[^&#]*-[0-9]{9}(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.html.request, argument={{{scriptParams}}} +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.api.request, argument={{{scriptParams}}} +http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v2\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js, tag=🌤 WeatherKit.api.v2.weather.response, argument={{{scriptParams}}} +response if ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// && ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${response.status} == 200 && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js", {${DataSets}, ${Weather.Provider}, ${WeatherAlerts.Provider}, ${WeatherAlerts.Language}, ${NextHour.Provider}, ${AirQuality.Current.Pollutants.Provider}, ${AirQuality.Calculate.Algorithm}, ${API.ColorfulClouds.Token}, ${API.QWeather.Host}, ${API.QWeather.Token}, ${API.WAQI.Token}, ${Storage}, ${LogLevel}}) with requires_body=true, tag="🌤 WeatherKit.api.v3.weather.json.response" [MITM] hostname = weatherkit.apple.com diff --git a/tests/airQualityAlgorithms.test.mjs b/tests/airQualityAlgorithms.test.mjs new file mode 100644 index 00000000..41503ac1 --- /dev/null +++ b/tests/airQualityAlgorithms.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$environment = { "surge-version": "test" }; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { LogLevel: "OFF", Storage: "database" }; +const [{ default: AQ }, { default: WeatherKit2 }, { Console }] = await Promise.all([import("../src/class/AirQuality.mjs"), import("../src/class/WeatherKit2.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const ug = "MICROGRAMS_PER_CUBIC_METER", ppb = "PARTS_PER_BILLION", mg = "MILLIGRAMS_PER_CUBIC_METER"; +const algorithms = ["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"]; +const pollutant = (pollutantType, amount, units = ug) => ({ pollutantType, amount, units }); +const six = () => [pollutant("PM2_5", 35), pollutant("PM10", 60), pollutant("OZONE", 90), pollutant("NO2", 35), pollutant("SO2", 15), pollutant("CO", 700)]; +const compute = (algorithm, pollutants, extra = {}) => AQ.Pollutants2AQI({ metadata: { providerName: "fixture" }, pollutants }, {}, { algorithm, ...extra }); + +for (const algorithm of algorithms) { + test(`${algorithm}: all six concentrations survive calculation and WK2 encoding without negative subindexes`, () => { + const input = six(), original = structuredClone(input); + const output = compute(algorithm, input); + assert.equal(output.metadata.temporarilyUnavailable, false); + assert.equal(Number.isInteger(output.index) && output.index >= 0, true); + assert.equal(Number.isInteger(output.categoryIndex) && output.categoryIndex > 0, true); + assert.equal(output.pollutants.length, 6); + for (const p of output.pollutants) assert.ok(p.index === undefined || Number.isFinite(p.index) && p.index >= 0); + const decoded = WeatherKit2.decode(new ByteBuffer(WeatherKit2.encode(undefined, { airQuality: output })), ["airQuality"]).airQuality; + assert.equal(decoded.index, output.index); + assert.equal(decoded.categoryIndex, output.categoryIndex); + assert.equal(decoded.scale, output.scale); + assert.deepEqual(decoded.pollutants.map(p => p.pollutantType), input.map(p => p.pollutantType)); + assert.deepEqual(input, original, "calculation must not mutate Apple/provider objects"); + }); + test(`${algorithm}: clean zero measurements are valid; missing/invalid measurements are unavailable`, () => { + const zero = compute(algorithm, six().map(p => ({ ...p, amount: 0 }))); + assert.equal(zero.metadata.temporarilyUnavailable, false); + assert.ok(zero.categoryIndex >= 1); + for (const amount of [NaN, Infinity, -1, null, undefined, "", "bad"]) { + const result = compute(algorithm, [pollutant("PM2_5", amount)]); + assert.equal(result.metadata.temporarilyUnavailable, true, String(amount)); + assert.equal(result.index, undefined); + } + }); +} + +test("US: EPA May 2026 truncation occurs before interpolation without changing the physical concentration", () => { + for (const [type, amount, units, expected] of [["PM2_5", 9.05, ug, 50], ["PM2_5", 9.1, ug, 51], ["PM10", 54.99, ug, 50], ["NO2", 53.99, ppb, 50], ["SO2", 35.99, ppb, 50], ["CO", 4499, ppb, 50], ["CO", 4500, ppb, 51]]) { + const result = compute("WAQI_InstantCast_US", [pollutant(type, amount, units)]); + assert.equal(result.index, expected, type); + assert.equal(result.pollutants[0].amount, amount); + } +}); + +test("CN: exact breakpoint is not promoted by floating point error and SO2's capped branch remains finite", () => { + assert.equal(compute("WAQI_InstantCast_CN", [pollutant("PM2_5", 35)]).categoryIndex, 1); + assert.equal(compute("WAQI_InstantCast_CN", [pollutant("PM2_5", 75)]).index, 100); + assert.equal(compute("WAQI_InstantCast_CN_25_DRAFT", [pollutant("SO2", 900)]).index, 200); +}); + +test("above-range US interpolation is continuous and respects the optional 500 cap", () => { + assert.equal(compute("WAQI_InstantCast_US", [pollutant("PM10", 604)]).index, 500); + assert.equal(compute("WAQI_InstantCast_US", [pollutant("PM10", 605)]).index, 501); + assert.equal(compute("WAQI_InstantCast_US", [pollutant("PM10", 605)], { allowOverRange: false }).index, 500); +}); + +test("Canada: ppb and equivalent mass inputs produce the reference rounded AQHI 4", () => { + const ppbInput = [pollutant("NO2", 20, ppb), pollutant("OZONE", 30, ppb), pollutant("PM2_5", 10)]; + assert.equal(compute("CA_AQHI", ppbInput).index, 4); + const massInput = [pollutant("NO2", 20 * 1.880472698306222), pollutant("OZONE", 30 * 1.96189649169881), pollutant("PM2_5", 10)]; + assert.equal(compute("CA_AQHI", massInput).index, 4); + // AQHI rounds 3.49 down, 3.50 up; use PM2.5-only risk with measured zero gases. + for (const [index, expected] of [[3.49, 3], [3.51, 4]]) { + const concentration = Math.log1p(index * 10.4 / 1000) / 0.000487; + assert.equal(compute("CA_AQHI", [pollutant("NO2", 0, ppb), pollutant("OZONE", 0, ppb), pollutant("PM2_5", concentration)]).index, expected); + } +}); + +test("HK: official 2025 coefficients and risk bands produce AQHI 5 for this complete concentration set", () => { + const result = compute("HK_AQHI", six()); + assert.equal(result.index, 5); + assert.equal(result.primaryPollutant, "OZONE"); +}); + +test("inapplicable CO keeps its concentration but cannot manufacture a healthy EU/UBA/AQHI index", () => { + for (const algorithm of algorithms.filter(a => !a.startsWith("WAQI_"))) { + const result = compute(algorithm, [pollutant("CO", 700)]); + assert.equal(result.metadata.temporarilyUnavailable, true, algorithm); + assert.equal(result.pollutants[0].amount, 700); + assert.equal(result.pollutants[0].index, undefined); + } +}); + +test("risk algorithms require their actual gas inputs and accept the available configured PM alternative", () => { + for (const algorithm of algorithms.filter(a => a.includes("AQHI"))) { + const noOzone = compute(algorithm, six().filter(p => p.pollutantType !== "OZONE")); + assert.equal(noOzone.metadata.temporarilyUnavailable, true, algorithm); + } + assert.equal(compute("HK_AQHI", six().filter(p => p.pollutantType !== "PM2_5")).metadata.temporarilyUnavailable, false); + assert.equal(compute("CA_AQHI", six().filter(p => p.pollutantType !== "PM2_5")).metadata.temporarilyUnavailable, true); +}); + +test("sanitizing supplements preserves first valid concentrations and rejects malformed values/units", () => { + const current = [pollutant("PM2_5", 0), pollutant("NO2", null), pollutant("OZONE", "12.5"), pollutant("SO2", 4, "invalid")]; + const result = AQ.MergePollutants(current, six()); + assert.equal(result.length, 6); + assert.equal(result.find(p => p.pollutantType === "PM2_5").amount, 0); + assert.equal(result.find(p => p.pollutantType === "OZONE").amount, 12.5); + assert.equal(result.find(p => p.pollutantType === "NO2").amount, 35); + assert.deepEqual(AQ.MissingPollutants(result), []); + assert.deepEqual(AQ.MissingPollutants([pollutant("PM2_5", 0)]), ["PM10", "OZONE", "NO2", "SO2", "CO"]); +}); + +test("QWeather CO normalization is unit-aware and idempotent", () => { + const input = { metadata: { providerName: "QWeather" }, pollutants: [pollutant("CO", 0.7, mg)] }; + const fixed = AQ.FixPollutantsUnits(input); + assert.equal(fixed.pollutants[0].amount, 700); + assert.equal(fixed.pollutants[0].units, ug); + assert.deepEqual(AQ.FixPollutantsUnits(fixed), fixed); + assert.equal(input.pollutants[0].amount, 0.7); +}); + +test("raw Apple QWeather CO repairs the mg value mislabeled as ug without reprocessing normalized provider data", () => { + for (const providerName of ["QWeather", "和风天气"]) { + const rawApple = { metadata: { providerName }, pollutants: [pollutant("CO", 0.7), null] }; + const normalized = AQ.FixPollutantsUnits(rawApple, { appleProduct: true }); + assert.equal(normalized.pollutants[0].amount, 700); + assert.equal(normalized.pollutants[0].units, ug); + assert.equal(rawApple.pollutants[0].amount, 0.7); + assert.deepEqual(AQ.FixPollutantsUnits(normalized), normalized); + const provider = { metadata: { providerName }, pollutants: [pollutant("CO", 700)] }; + assert.deepEqual(AQ.FixPollutantsUnits(provider), provider); + } + const other = { metadata: { providerName: "BreezoMeter" }, pollutants: [pollutant("CO", 700)] }; + assert.strictEqual(AQ.FixPollutantsUnits(other, { appleProduct: true }), other); +}); + +test("unit conversion rejects nonfinite values; numeric strings and identity ppb work without artificial STP factors", () => { + for (const amount of [NaN, Infinity, null, undefined, "", "wrong", -1]) assert.equal(AQ.ConvertUnit(amount, ug, mg), -1); + assert.equal(AQ.ConvertUnit("700", ug, mg), 0.7); + assert.equal(AQ.ConvertUnit(30, ppb, ppb, 1.96189649169881), 30); +}); + +test("None preserves the original product and unknown algorithms fail safely", () => { + const product = { index: 42, pollutants: six() }; + assert.strictEqual(AQ.Pollutants2AQI(product, {}, { algorithm: "None" }), product); + assert.equal(compute("typo", six()).metadata.temporarilyUnavailable, true); +}); diff --git a/tests/fixtures/processingMemoryFixture.mjs b/tests/fixtures/processingMemoryFixture.mjs new file mode 100644 index 00000000..be0d924b --- /dev/null +++ b/tests/fixtures/processingMemoryFixture.mjs @@ -0,0 +1,69 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import * as WK from "@nsringo/weatherkit"; +import { Builder, ByteBuffer } from "flatbuffers"; +import WeatherKit2 from "../../src/class/WeatherKit2.mjs"; + +export const currentWeather = { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + temperature: 20, + precipitationAmountNext1hByType: [], + precipitationAmountNext24hByType: [], + precipitationAmountNext6hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious24hByType: [], + precipitationAmountPrevious6hByType: [], +}; +export function weatherFixture(hours = 273, alerts = 4) { + return WeatherKit2.encode(undefined, { + currentWeather, + forecastHourly: { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + hours: Array.from({ length: hours }, (_, index) => ({ forecastStart: 1792368000 + index * 3600, temperature: 20 + (index % 5), humidity: 50, windSpeed: 2, precipitationAmountByType: [{ precipitationType: "RAIN", expected: 0 }] })), + }, + weatherAlerts: { + metadata: { providerName: "National Early Warning Center", temporarilyUnavailable: false }, + detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=32.39,119.499", + alerts: Array.from({ length: alerts }, (_, index) => ({ + id: `00000000-0000-0000-0000-${String(index).padStart(12, "0")}`, + description: `Apple warning ${index}`, + areaId: String(index), + areaName: `Fixture area ${index}`, + severity: "UNKNOWN", + responses: [], + expireTime: 4102444800, + })), + }, + }); +} + +export const futureHourlyText = "future-hourly-field-must-survive"; +export function futureHourlyFixture() { + const builder = new Builder(); + const provider = builder.createString("Apple"); + WK.Metadata.startMetadata(builder); + WK.Metadata.addProviderName(builder, provider); + const metadata = WK.Metadata.endMetadata(builder); + const hours = WK.HourlyForecastData.createHoursVector(builder, []); + const future = builder.createString(futureHourlyText); + builder.startObject(3); + WK.HourlyForecastData.addMetadata(builder, metadata); + WK.HourlyForecastData.addHours(builder, hours); + builder.addFieldOffset(2, future, 0); + const hourly = builder.endObject(); + WK.Weather.startWeather(builder); + WK.Weather.addForecastHourly(builder, hourly); + builder.finish(WK.Weather.endWeather(builder)); + return WeatherKit2.encode(new ByteBuffer(builder.asUint8Array()), { currentWeather }); +} +export function readFutureHourly(bytes) { + const hourly = WK.Weather.getRootAsWeather(new ByteBuffer(bytes)).forecastHourly(); + const field = hourly.bb.__offset(hourly.bb_pos, 8); + return field ? hourly.bb.__string(hourly.bb_pos + field) : undefined; +} + +export const weatherNow = { + code: "200", + updateTime: "2026-11-01T00:00:00+08:00", + fxLink: "https://example.test/weather", + now: { obsTime: "2026-11-01T00:00:00+08:00", temp: "23", feelsLike: "24", text: "Sunny", icon: "100", wind360: "90", windSpeed: "1", humidity: "80", precip: "0", pressure: "1000", vis: "10", cloud: "20", dew: "15" }, +}; diff --git a/tests/fixtures/upstream-intent.json b/tests/fixtures/upstream-intent.json new file mode 100644 index 00000000..25b9b495 --- /dev/null +++ b/tests/fixtures/upstream-intent.json @@ -0,0 +1,52 @@ +{ + "upstreamCommit": "c40e8f8a13c8304a7ef500cb74acc5948af14b3f", + "releaseURL": "https://github.com/NSRingo/WeatherKit/releases/tag/v3.3.2", + "pluginSHA256": "e994f11d8eb987b81bda0c682e95287d4ee166cabf8a5563d2fa8aa4bdfe3e24", + "originalArgumentDefaults": { + "DataSets": "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,weatherAlerts", + "Weather.Provider": "ColorfulClouds", + "WeatherAlerts.Provider": "QWeatherWeb", + "NextHour.Provider": "ColorfulClouds", + "AirQuality.Current.Pollutants.Provider": "ColorfulClouds", + "AirQuality.Calculate.Algorithm": "EU_EAQI", + "API.ColorfulClouds.Token": "", + "API.QWeather.Host": "devapi.qweather.com", + "API.QWeather.Token": "", + "API.WAQI.Token": "", + "Storage": "Argument", + "LogLevel": "WARN" + }, + "weather": { + "Replace": [ + "CN" + ], + "Provider": "ColorfulClouds" + }, + "airQualityIndex": { + "Replace": [ + "HJ6332012" + ], + "Provider": "Calculate", + "ForceCNPrimaryPollutants": true + }, + "airQualityComparison": { + "ReplaceWhenCurrentChange": false, + "Yesterday": { + "PollutantsProvider": "QWeather", + "IndexProvider": "ColorfulCloudsUS" + } + }, + "capabilities": [ + "airQuality", + "currentWeather", + "forecastDaily", + "forecastHourly", + "forecastPeriodic", + "historicalComparisons", + "weatherChanges", + "forecastNextHour", + "weatherAlerts", + "weatherAlertNotifications", + "news" + ] +} diff --git a/tests/loonNativeRewrites.test.mjs b/tests/loonNativeRewrites.test.mjs new file mode 100644 index 00000000..97953ac2 --- /dev/null +++ b/tests/loonNativeRewrites.test.mjs @@ -0,0 +1,7 @@ +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import verifyLoonRewrites from "../scripts/verify-loon-rewrites.mjs"; + +test("the actual Loon template retains capabilities and narrows JavaScript startup", async () => { + verifyLoonRewrites(await readFile(new URL("../template/loon.handlebars", import.meta.url), "utf8")); +}); diff --git a/tests/loonProviderFetch.test.mjs b/tests/loonProviderFetch.test.mjs new file mode 100644 index 00000000..f51c28ba --- /dev/null +++ b/tests/loonProviderFetch.test.mjs @@ -0,0 +1,53 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +globalThis.$loon = "simulated native Loon"; +const { default: fetchProvider } = await import("../src/function/fetchProvider.mjs"); +for (const timeout of [250, 499, 500, 501, 999, 1499, 5000]) { + test(`Loon provider timeout ${timeout} ms reaches the native API unchanged`, async () => { + globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.timeout, timeout); + callback(null, { status: 200, headers: {} }, "{}"); + }, + }; + assert.equal((await fetchProvider({ url: "https://provider.example/weather", timeout })).status, 200); + }); +} + +test("native provider POST bodies and copied headers keep their values and leave caller inputs intact", async () => { + const input = { url: "https://provider.example/auth", body: "token=fixture", headers: { Host: "old-host", "Content-Length": "1", Authorization: "fixture-private" }, timeout: 800 }; + globalThis.$httpClient = { + post(request, callback) { + assert.equal(request.body, input.body); + assert.equal(request.timeout, 800); + assert.deepEqual(request.headers, { Authorization: "fixture-private" }); + callback(null, { status: 200, headers: { "Content-Type": "application/json" } }, "{}"); + }, + }; + assert.equal((await fetchProvider(input)).ok, true); + assert.equal(input.headers.Host, "old-host"); + assert.equal(input.headers["Content-Length"], "1"); +}); + +test("native conditional responses normalize an omitted 304 body without treating it as success JSON", async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 304, headers: {} }); + }, + }; + const response = await fetchProvider("https://provider.example/grid"); + assert.equal(response.status, 304); + assert.equal(response.ok, false); + assert.equal(response.body, ""); +}); + +test("native request failures reject and a repeated callback cannot replace the first result", async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback("native timeout"); + callback(null, { status: 200 }, "{}"); + }, + }; + await assert.rejects(fetchProvider("https://provider.example/weather"), value => value === "native timeout"); +}); diff --git a/tests/mergeWeatherAlertsProduct.test.mjs b/tests/mergeWeatherAlertsProduct.test.mjs new file mode 100644 index 00000000..3bc9c249 --- /dev/null +++ b/tests/mergeWeatherAlertsProduct.test.mjs @@ -0,0 +1,42 @@ +// Focused, synthetic regressions for the independently reviewable alert-state fix. +import assert from "node:assert/strict"; +import test from "node:test"; +import mergeWeatherAlertsProduct from "../src/function/mergeWeatherAlertsProduct.mjs"; +import { Console } from "@nsnanocat/util"; +Console.logLevel = "OFF"; + +const original = (alerts = []) => ({ metadata: { providerName: "Apple", temporarilyUnavailable: true }, detailsUrl: "https://weatherkit.apple.com/original", alerts, future: { retain: true } }); +const nativeAlert = () => ({ id: "00112233-4455-6677-8899-aabbccddeeff", description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", message: "" }); +const supplements = [ + undefined, + { metadata: { temporarilyUnavailable: false, providerName: "provider" } }, + { metadata: { temporarilyUnavailable: false }, alerts: [] }, + { metadata: { temporarilyUnavailable: true }, alerts: [{ description: "unavailable source" }] }, + { metadata: { temporarilyUnavailable: false }, alerts: [{ expireTime: "2000-01-01T00:00:00Z", description: "expired" }] }, +]; +for (const [index, source] of supplements.entries()) for (const filled of [false, true]) test(`no usable alert enrichment preserves the original product (${index}, existing=${filled})`, () => { + const apple = original(filled ? [nativeAlert()] : []); + const before = structuredClone(apple); + assert.strictEqual(mergeWeatherAlertsProduct(apple, source), apple); + assert.deepEqual(apple, before); +}); + +test("a provider warning cannot manufacture an Apple native event when its destination is empty", () => { + const apple = original(); + const source = { metadata: { temporarilyUnavailable: false }, alerts: [{ ...nativeAlert(), message: "source" }] }; + assert.strictEqual(mergeWeatherAlertsProduct(apple, source), apple); + assert.equal(apple.metadata.temporarilyUnavailable, true); +}); + +test("an installed warning field permits metadata enrichment while preserving both inputs and the native ID", () => { + const apple = original([nativeAlert()]); + const source = { metadata: { providerName: "provider", temporarilyUnavailable: false, providerLogo: "https://fixture.example/logo" }, alerts: [{ description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", expireTime: "2099-01-01T00:00:00Z" }] }; + const beforeApple = structuredClone(apple), beforeSource = structuredClone(source); + const result = mergeWeatherAlertsProduct(apple, source); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(result.alerts[0].expireTime, Date.parse("2099-01-01T00:00:00Z") / 1000); + assert.equal(result.alerts[0].id, beforeApple.alerts[0].id); + assert.deepEqual(result.future, beforeApple.future); + assert.deepEqual(apple, beforeApple); + assert.deepEqual(source, beforeSource); +}); diff --git a/tests/pr107ReviewRegressions.test.mjs b/tests/pr107ReviewRegressions.test.mjs new file mode 100644 index 00000000..aa31a4c2 --- /dev/null +++ b/tests/pr107ReviewRegressions.test.mjs @@ -0,0 +1,171 @@ +// Synthetic fixtures. Review cases tracked in NSRingo/WeatherKit #107 and #110. +// Unknown-container and output-size cases reference #110 at f4676feae89a27a8403c574c6086a433d5adb3c9 (hhh2210, Apache-2.0). +import assert from "node:assert/strict"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; +const [{ Response }, { Response: ResponseDev }, { default: QWeather }, { default: ColorfulClouds }, { default: WeatherKit2 }, json, { Console }] = await Promise.all([ + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/class/ColorfulClouds.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("../src/process/ResponseJSONV3.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; +const metadata = { temporarilyUnavailable: false, providerName: "Apple fixture" }; +const request = (products = "weatherAlerts", version = "v3") => (globalThis.$request = { method: "GET", url: `https://weatherkit.apple.com/api/${version}/weather/en-US/31.2/121.5?country=CN&dataSets=${products}`, headers: {} }); +const response = body => ({ status: 200, headers: { "Content-Type": "application/json", ETag: "apple-entity" }, body: JSON.stringify(body) }); +const settings = () => (globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "weatherAlerts,currentWeather,airQuality", Weather: { Provider: "QWeather", Replace: ["CN"] }, WeatherAlerts: { Provider: "QWeather" } }); +const activeWarning = { description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", expireTime: "2099-01-01T00:00:00Z" }; +const { default: AirQuality } = await import("../src/class/AirQuality.mjs"); + +for (const invalid of [true, false, 1.2, "1.5", {}, [], [2], null, undefined, "", 0, -1, Infinity, NaN]) + test(`AQ comparison rejects a malformed category: ${String(invalid)} (${typeof invalid})`, () => { + assert.equal(AirQuality.CompareCategoryIndexes(invalid, 2), AirQuality.Config.CompareCategoryIndexes.UNKNOWN); + assert.equal(AirQuality.CompareCategoryIndexes(2, invalid), AirQuality.Config.CompareCategoryIndexes.UNKNOWN); + }); +test("AQ comparison keeps numeric strings and valid integer category semantics", () => { + assert.equal(AirQuality.CompareCategoryIndexes("2", 2), AirQuality.Config.CompareCategoryIndexes.SAME); + assert.equal(AirQuality.CompareCategoryIndexes(1, 2), AirQuality.Config.CompareCategoryIndexes.BETTER); + assert.equal(AirQuality.CompareCategoryIndexes(2, 1), AirQuality.Config.CompareCategoryIndexes.WORSE); +}); + +test("malformed JSON category objects are rejected before coercion or diagnostic formatting", () => { + const invalid = JSON.parse('{"toString":null,"valueOf":null}'); + assert.equal(AirQuality.CompareCategoryIndexes(invalid, 2), AirQuality.Config.CompareCategoryIndexes.UNKNOWN); +}); + +for (const [label, process] of [ + ["release", Response], + ["development", ResponseDev], +]) { + for (const sourceAlerts of [[activeWarning], [], [{ ...activeWarning, expireTime: "2000-01-01T00:00:00Z" }]]) + test(`${label}: an unavailable empty Apple alert collection stays unavailable when no native warning can be merged (${sourceAlerts.length}, ${sourceAlerts[0]?.expireTime ?? "none"})`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "provider" }, alerts: sourceAlerts, detailsUrl: "https://fixture.example/alert" })); + const body = { weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [], detailsUrl: "https://weatherkit.apple.com/original" } }; + const original = response(body); + const output = await process(request(), original); + assert.equal(output.body, original.body); + assert.equal(output.headers.ETag, "apple-entity"); + assert.deepEqual(JSON.parse(output.body).weatherAlerts, body.weatherAlerts); + }); + + test(`${label}: a failed alert source keeps Apple's unavailable state and exact entity`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: true }, alerts: [] })); + const original = response({ weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [] } }); + assert.equal((await process(request(), original)).body, original.body); + }); + + test(`${label}: healthy no-warning data stays byte-identical when a source also reports no warnings`, async t => { + settings(); + const fetch = t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "provider" }, alerts: [] })); + const original = response({ weatherAlerts: { metadata: { ...metadata, providerName: "National Early Warning Center" }, alerts: [] } }); + assert.equal((await process(request(), original)).body, original.body); + assert.equal(fetch.mock.callCount(), 1); + }); + + test(`${label}: an actual warning merge can restore availability without replacing the native ID`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "provider", providerLogo: "https://fixture.example/logo.png" }, alerts: [activeWarning] })); + const warning = { id: "00112233-4455-6677-8899-aabbccddeeff", description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", message: "" }; + const original = response({ weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [warning] } }); + const output = JSON.parse((await process(request(), original)).body).weatherAlerts; + assert.equal(output.metadata.temporarilyUnavailable, false); + assert.equal(output.alerts[0].id, warning.id); + assert.equal(output.metadata.providerLogo, "https://fixture.example/logo.png"); + }); + + test(`${label}: WK2 empty unavailable alerts retain original binary bytes`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false }, alerts: [activeWarning] })); + const raw = WeatherKit2.encode(undefined, { weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [] } }); + const original = { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer", ETag: "apple-binary" }, body: raw }; + const output = await process(request("weatherAlerts", "v2"), original); + assert.deepEqual(output.body, raw); + assert.equal(WeatherKit2.decode(new ByteBuffer(output.body), ["weatherAlerts"]).weatherAlerts.metadata.temporarilyUnavailable, true); + assert.equal(output.headers.ETag, "apple-binary"); + }); + + test(`${label}: EAQI omits unsupported CO sub-indices without losing the normalized concentration`, async () => { + settings(); + globalThis.$argument.DataSets = "airQuality"; + globalThis.$argument.AirQuality = { Calculate: { Algorithm: "EU_EAQI" }, Comparison: { ReplaceWhenCurrentChange: false } }; + const pollutants = [["PM10", 40], ["NO2", 57], ["CO", 0.6], ["OZONE", 46], ["PM2_5", 26], ["SO2", 9]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); + const original = response({ airQuality: { metadata: { ...metadata, providerName: "QWeather" }, index: 40, categoryIndex: 1, scale: "HJ6332012.2604", pollutants } }); + const output = JSON.parse((await process(request("airQuality"), original)).body).airQuality; + assert.match(output.scale, /^EU\.EAQI/); + const co = output.pollutants.find(p => p.pollutantType === "CO"); + assert.equal(co.amount, 600); + assert.equal(co.index, undefined); + assert.equal(co.categoryIndex, undefined); + }); +} + +const future = () => ({ conditionCode: ["FUTURE"], precipitationAmount: { expected: 8 }, index: "opaque", humidity: 101, metadata: { latitude: "opaque" }, hours: [{ conditionCode: ["ALSO_FUTURE"] }], values: [null] }); +test("Apple unknown nested containers keep their original types while known rest-of-day and periodic rows adapt", () => { + const unknown = future(); + const value = { days: [{ restOfDayForecast: { conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 3, precipitationType: ["RAIN"] }] }, future: structuredClone(unknown) }], future: structuredClone(unknown) }; + json.adaptV3ClientProduct(value, "forecastDaily"); + assert.equal(value.days[0].restOfDayForecast.conditionCode, "RAIN"); + assert.equal(value.days[0].restOfDayForecast.precipitationAmount, 3); + assert.deepEqual(value.future, unknown); + assert.deepEqual(value.days[0].future, unknown); +}); + +test("provider adaptation and validation preserve opaque nested containers", () => { + const unknown = future(); + const value = { metadata: { ...metadata }, temperature: 23, conditionCode: ["RAIN"], future: structuredClone(unknown), futureNull: null }; + json.adaptV3ProviderProduct(value, "currentWeather"); + assert.equal(value.conditionCode, "RAIN"); + assert.doesNotThrow(() => json.validateWeatherJSONV3(value, "currentWeather")); + assert.deepEqual(value.future, unknown); + assert.equal(value.futureNull, null); +}); + +test("a provider result exceeding the final output limit falls back to normalized Apple JSON", async () => { + settings(); + const apple = { currentWeather: { metadata, temperature: 18, conditionCode: ["CLOUDY"] }, forecastPeriodic: { metadata, forecasts: [{ periods: [{ conditionCode: ["RAIN"] }] }] } }; + const output = await json.default(request("currentWeather"), response(apple), { currentWeather: async () => ({ metadata, temperature: 23, conditionCode: "RAIN", future: "x".repeat(json.MAX_WEATHER_JSON_CHARACTERS) }) }); + const actual = JSON.parse(output.body); + assert.equal(actual.currentWeather.temperature, 18); + assert.equal(actual.currentWeather.conditionCode, "CLOUDY"); + assert.equal(actual.forecastPeriodic.forecasts[0].periods[0].conditionCode, "RAIN"); + assert.equal(output.headers.ETag, undefined); +}); + +test("periodic-only responses normalize without querying a provider", async () => { + settings(); + const value = { forecastPeriodic: { metadata, forecasts: [{ periods: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 2, precipitationType: ["RAIN"] }] }] }] } }; + const result = JSON.parse((await json.default(request("forecastPeriodic"), response(value), { currentWeather: () => assert.fail("No replacement target") })).body); + assert.equal(result.forecastPeriodic.forecasts[0].periods[0].precipitationAmount, 2); +}); + +test("Caiyun concurrent current weather cannot supply alerts after the alert-specific request fails", async () => { + const pending = []; + globalThis.$httpClient = { get: (outgoing, callback) => pending.push({ outgoing, callback }) }; + const provider = new ColorfulClouds({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en" }, "fixture"); + const current = provider.CurrentWeather(); + const alerts = provider.WeatherAlert(); + assert.equal(pending.length, 2); + const body = { + status: "ok", + server_time: 1800000000, + result: { + alert: { status: "ok", content: [{ code: "0202", title: "fixture rain", pubtimestamp: 1800000000 }] }, + realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.8, skycon: "CLOUDY", humidity: 0.5, precipitation: { local: { intensity: 0 } }, pressure: 100000, visibility: 10, wind: { direction: 90, speed: 2 } }, + }, + }; + pending[0].callback(null, { status: 200, headers: {} }, JSON.stringify(body)); + pending[1].callback("timeout", {}, ""); + assert.equal((await current).cloudCover, 80); + const result = await alerts; + assert.equal(result.metadata.temporarilyUnavailable, true); + assert.deepEqual(result.alerts, []); +}); diff --git a/tests/providerResilience.test.mjs b/tests/providerResilience.test.mjs new file mode 100644 index 00000000..f13fe93d --- /dev/null +++ b/tests/providerResilience.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import ProviderTransport from "../src/class/ProviderTransport.mjs"; +import QWeather from "../src/class/QWeather.mjs"; +import { activeProviderAlerts, cloneWeatherProduct } from "../src/function/providerRecovery.mjs"; +import utf8Length from "../src/function/utf8Length.mjs"; +import { Console } from "@nsnanocat/util"; +globalThis.$loon = "simulated Loon"; +const persistent = new Map(); +globalThis.$persistentStore = { + read: key => persistent.get(key), + write: (value, key) => { + persistent.set(key, value); + return true; + }, +}; +const callbacks = []; +globalThis.$httpClient = { get: (request, callback) => callbacks.push({ request, callback }) }; +Console.logLevel = "OFF"; +test("an unrecognized webpage is unavailable, never a cached no-warning success", async () => { + persistent.clear(); + callbacks.length = 0; + const runtime = new ProviderTransport(); + const pending = QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, runtime); + callbacks.shift().callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, "Access denied"); + const result = await pending; + assert.equal(result.metadata.temporarilyUnavailable, true); +}); + +test("expired and invalid third-party alerts are excluded without altering unknown-expiry alerts", () => { + assert.deepEqual( + activeProviderAlerts([{ id: 1, expireTime: "2000-01-01T00:00:00Z" }, { id: 2, expireTime: "2099-01-01T00:00:00Z" }, { id: 3 }, { id: 4, expireTime: "bad" }], Date.parse("2026-10-19T00:00:00Z")).map(a => a.id), + [2, 3], + ); +}); + +test("product snapshots preserve binary values and isolate partial mutations before a failed replacement", () => { + const original = { metadata: { providerName: "Apple" }, days: [{ v: 1 }], bytes: new Uint8Array([1, 2]) }, + copy = cloneWeatherProduct(original); + copy.days[0].v = 2; + copy.bytes[0] = 9; + assert.equal(original.days[0].v, 1); + assert.equal(original.bytes[0], 1); + assert.ok(copy.bytes instanceof Uint8Array); +}); + +test("UTF-8 body bounds work without TextEncoder and stop once the limit is exceeded", () => { + for (const value of ["abc", "天气", "😀", "\ud800", "a😀天"]) assert.equal(utf8Length(value), Buffer.byteLength(value)); + assert.ok(utf8Length("天".repeat(1000), 100) > 100); +}); + +test("actual response processing preserves Apple products on provider failure and waits for every native callback", async () => { + persistent.clear(); + callbacks.length = 0; + const saved = globalThis.$argument; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["currentWeather", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] } }; + try { + const [{ default: WeatherKit2 }, { Response }, { ByteBuffer }] = await Promise.all([import("../src/class/WeatherKit2.mjs"), import("../src/process/Response.mjs"), import("flatbuffers")]); + 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 before = WeatherKit2.decode(new ByteBuffer(raw), ["currentWeather", "forecastHourly"]); + assert.equal(before.currentWeather?.temperature, 27); + const pending = Response({ 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 }); + let settled = false; + pending.then(() => { + settled = true; + }); + assert.equal(callbacks.length, 2); + callbacks.shift().callback("timeout fixture-secret", {}, ""); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false); + callbacks.shift().callback("timeout fixture-secret", {}, ""); + const result = await pending; + const after = WeatherKit2.decode(new ByteBuffer(result.body), ["currentWeather", "forecastHourly"]); + assert.deepEqual(after, before); + assert.deepEqual(Array.from(result.body), Array.from(raw)); + assert.equal(after.currentWeather.temperature, 27); + } finally { + globalThis.$argument = saved; + } +}); + +test("sequential requests cannot mutate shared defaults or inherit another credential", async () => { + const [{ default: setENV }, { default: database }] = await Promise.all([import("../src/function/setENV.mjs"), import("../src/function/database.mjs")]); + const token = database.WeatherKit.Settings.API.QWeather.Token, + saved = globalThis.$argument; + try { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", API: { QWeather: { Token: "dedicated-fixture" } } }; + const a = setENV("iRingo", "WeatherKit", database); + assert.equal(a.Settings.API.QWeather.Token, "dedicated-fixture"); + assert.equal(database.WeatherKit.Settings.API.QWeather.Token, token); + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; + const b = setENV("iRingo", "WeatherKit", database); + assert.equal(b.Settings.API.QWeather.Token, token); + assert.notEqual(a.Settings.API, b.Settings.API); + } finally { + globalThis.$argument = saved; + } +}); + +test("bounded fallback encoder matches Unicode replacement behavior without host TextEncoder", async () => { + const countSource = (await readFile(new URL("../src/function/utf8Length.mjs", import.meta.url), "utf8")).replace("export default function", "function"); + const encoderSource = (await readFile(new URL("../src/function/ensureTextEncoder.mjs", import.meta.url), "utf8")).replace(/^import .+$/m, ""); + const context = vm.createContext({}); + vm.runInContext(`${countSource}\n${encoderSource}`, context); + for (const value of ["", "ASCII", "天气雨雪🌦️", "a\ud800b\udc00c", "\ud83c\udf27\ud83c\udf08", "\u0000\u07ff\u0800\uffff"]) { + const actual = vm.runInContext(`new TextEncoder().encode(${JSON.stringify(value)})`, context); + assert.deepEqual(Array.from(actual), Array.from(new TextEncoder().encode(value))); + } + assert.throws(() => vm.runInContext('new TextEncoder().encode("x".repeat(2 * 1024 * 1024 + 1))', context), /encoding limit/); + const nativeContext = vm.createContext({ TextEncoder }); + vm.runInContext(`${countSource}\n${encoderSource}`, nativeContext); + assert.equal(nativeContext.TextEncoder, TextEncoder); +}); + +test("missing Apple daily and hourly roots can receive available provider forecasts", { timeout: 2000 }, async () => { + const [{ Response }, { default: WeatherKit2 }, { ByteBuffer }] = await Promise.all([import("../src/process/Response.mjs"), import("../src/class/WeatherKit2.mjs"), import("flatbuffers")]); + const savedArgument = globalThis.$argument, + daily = QWeather.prototype.Daily, + hourly = QWeather.prototype.Hourly; + const metadata = { providerName: "QWeather", temporarilyUnavailable: false }; + try { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["forecastDaily", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] } }; + QWeather.prototype.Daily = async () => ({ metadata, days: [{ forecastStart: 1792368000, temperatureMax: 29, precipitationAmountByType: [] }] }); + QWeather.prototype.Hourly = async () => ({ metadata, hours: [{ forecastStart: 1792368000, temperature: 25 }] }); + const raw = WeatherKit2.encode(undefined, {}); + const result = await Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=forecastDaily,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); + const decoded = WeatherKit2.decode(new ByteBuffer(result.body), ["forecastDaily", "forecastHourly"]); + assert.equal(decoded.forecastDaily.days[0].temperatureMax, 29); + assert.equal(decoded.forecastHourly.hours[0].temperature, 25); + } finally { + globalThis.$argument = savedArgument; + QWeather.prototype.Daily = daily; + QWeather.prototype.Hourly = hourly; + } +}); diff --git a/tests/providerTransport.test.mjs b/tests/providerTransport.test.mjs new file mode 100644 index 00000000..152a7319 --- /dev/null +++ b/tests/providerTransport.test.mjs @@ -0,0 +1,147 @@ +// Stateless transport regressions: validation is retained, request policy is removed. +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderTransport, { PROVIDER_RESPONSE_LIMITS, readProviderJSON } from "../src/class/ProviderTransport.mjs"; + +const request = { url: "https://fixture.example/weather", headers: { Authorization: "fixture" } }; +const valid = value => Number.isFinite(value?.temperature); +const response = (temperature = 18) => ({ status: 200, headers: { ETag: "fixture" }, body: JSON.stringify({ temperature, nested: { value: 1 } }) }); +const errorCode = code => error => error.code === code; + +test("identical sequential requests always fetch and never reuse response objects", async () => { + const transport = new ProviderTransport(); + let calls = 0; + const options = { fetcher: async () => response(++calls), validate: valid, includeJSON: true }; + const first = await transport.fetch(request, options); + const second = await transport.fetch(request, options); + assert.equal(calls, 2); + assert.equal(readProviderJSON(first).temperature, 1); + assert.equal(readProviderJSON(second).temperature, 2); +}); + +test("identical parallel requests remain independent and each waits for its own fetch", async () => { + const transport = new ProviderTransport(); + const callbacks = []; + const options = { fetcher: () => new Promise(resolve => callbacks.push(resolve)), validate: valid }; + const first = transport.fetch(request, options); + const second = transport.fetch(request, options); + assert.equal(callbacks.length, 2); + let firstFinished = false; + first.then(() => { + firstFinished = true; + }); + callbacks[1](response(22)); + assert.equal(JSON.parse((await second).body).temperature, 22); + assert.equal(firstFinished, false); + callbacks[0](response(18)); + assert.equal(JSON.parse((await first).body).temperature, 18); +}); + +test("a shared native response does not share mutable headers or parsed JSON between requests", async () => { + const native = response(); + const transport = new ProviderTransport(); + const options = { fetcher: async () => native, validate: valid, includeJSON: true }; + const first = await transport.fetch(request, options); + first.headers.ETag = "changed"; + readProviderJSON(first).nested.value = 9; + const second = await transport.fetch(request, options); + assert.equal(native.headers.ETag, "fixture"); + assert.equal(second.headers.ETag, "fixture"); + assert.equal(readProviderJSON(second).nested.value, 1); +}); + +for (const [status, code] of [ + [401, "auth"], + [403, "auth"], + [429, "rate_limit"], + [500, "http"], + [304, "http"], +]) { + test(`HTTP ${status} is rejected and repeated failures never create a cooldown`, async () => { + const transport = new ProviderTransport(); + let calls = 0; + const options = { + fetcher: async () => { + calls++; + return { ...response(), status: calls < 4 ? status : 200 }; + }, + validate: valid, + }; + for (let i = 0; i < 3; i++) await assert.rejects(transport.fetch(request, options), errorCode(code)); + assert.equal((await transport.fetch(request, options)).status, 200); + assert.equal(calls, 4); + }); +} + +for (const [failure, code] of [ + [new Error("timeout"), "timeout"], + [new Error("network changed"), "network"], +]) { + test(`${code} failures never suppress the next request`, async () => { + const transport = new ProviderTransport(); + let calls = 0; + const options = { + fetcher: async () => { + if (++calls < 4) throw failure; + return response(); + }, + validate: valid, + }; + for (let i = 0; i < 3; i++) await assert.rejects(transport.fetch(request, options), errorCode(code)); + await transport.fetch(request, options); + assert.equal(calls, 4); + }); +} + +for (const status of [undefined, "bad", 200.5, Infinity]) + test(`invalid HTTP status ${String(status)} cannot replace Apple data`, async () => { + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => ({ ...response(), status }), validate: valid }), errorCode("invalid")); + }); + +for (const body of ["", "null", "[]", "{broken", new Uint8Array([123, 125]), "x".repeat(PROVIDER_RESPONSE_LIMITS.responseCharacters + 1)]) + test(`invalid provider body (${typeof body}, ${body?.length}) is rejected`, async () => { + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => ({ ...response(), body }), validate: valid }), errorCode("invalid")); + }); + +test("a consumer validation exception is contained as an invalid provider response", async () => { + await assert.rejects( + new ProviderTransport().fetch(request, { + fetcher: async () => response(), + validate: () => { + throw new Error("fixture"); + }, + }), + errorCode("invalid"), + ); +}); + +test("native ok=false cannot turn an HTTP 200 body into a successful provider result", async () => { + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => ({ ...response(), ok: false }), validate: valid }), errorCode("http")); +}); + +test("conditional 304 reuse is opt-in, never parses or validates an empty body", async () => { + const options = { + fetcher: async () => ({ status: 304, ok: false, headers: { ETag: "unchanged" }, body: null }), + validate: () => { + throw new Error("304 is not JSON"); + }, + }; + const transport = new ProviderTransport(); + await assert.rejects(transport.fetch(request, options), errorCode("http")); + const notModified = await transport.fetch(request, { ...options, allowNotModified: true }); + assert.equal(notModified.status, 304); + assert.equal(notModified.body, ""); + assert.equal(notModified.headers.ETag, "unchanged"); +}); + +test("text responses retain explicit validation and size bounds", async () => { + const transport = new ProviderTransport(); + const options = { fetcher: async () => ({ status: 200, body: "fixture" }), format: "text", validate: text => text.includes("fixture") }; + assert.equal((await transport.fetch(request, options)).body, "fixture"); + await assert.rejects(transport.fetch(request, { ...options, maximumResponseCharacters: 3 }), errorCode("invalid")); +}); + +test("every provider request must supply a fetch function and data validator", async () => { + await assert.rejects(new ProviderTransport().fetch(request), TypeError); + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => response() }), TypeError); +}); diff --git a/tests/qWeatherGridCache.test.mjs b/tests/qWeatherGridCache.test.mjs new file mode 100644 index 00000000..0f3935b8 --- /dev/null +++ b/tests/qWeatherGridCache.test.mjs @@ -0,0 +1,146 @@ +// Synthetic location grids and native callbacks; no live provider requests. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +const [{ default: QWeather }, { default: ProviderTransport }, { Console }] = await Promise.all([import("../src/class/QWeather.mjs"), import("../src/class/ProviderTransport.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const NOW = 1_800_000_000_000; +const MONTH = 30 * 86400000; +const grid = () => ({ gridSize: 1, grid: { "121,31": [{ id: "101020100", latitude: 31.2, longitude: 121.5 }] } }); +const runtime = () => new ProviderTransport(); + +test("a recent valid grid is reused without a network request or a write", async t => { + t.mock.method(Date, "now", () => NOW); + globalThis.$httpClient = { + get() { + assert.fail("Fresh grid must not fetch"); + }, + }; + const data = grid(); + assert.strictEqual(await QWeather.GetLocationsGrid({ locationsGrid: { data, lastUpdated: NOW - 1 } }, () => assert.fail("Fresh grid must not write"), runtime()), data); +}); + +for (const name of ["ETag", "etag", "eTaG"]) { + test(`a grid 200 stores the case-insensitive ${name} validator and revalidates with 304`, async t => { + let now = NOW, + requests = 0, + saved; + t.mock.method(Date, "now", () => now); + globalThis.$httpClient = { + get(request, callback) { + requests++; + if (requests === 1) callback(null, { status: 200, headers: { [name]: 'W/"grid-v1"' } }, JSON.stringify(grid())); + else { + assert.equal(request.headers["If-None-Match"], 'W/"grid-v1"'); + callback(null, { status: 304, headers: {} }); + } + }, + }; + await QWeather.GetLocationsGrid({ retained: true }, value => (saved = value), runtime()); + assert.equal(saved.locationsGrid.etag, 'W/"grid-v1"'); + const data = saved.locationsGrid.data; + now += MONTH; + const cache = runtime(); + assert.strictEqual(await QWeather.GetLocationsGrid(saved, value => (saved = value), cache), data); + assert.equal(saved.locationsGrid.lastUpdated, now); + assert.equal(saved.retained, true); + assert.equal(requests, 2); + }); +} + +for (const data of [undefined, null, {}, { gridSize: 0, grid: {} }, { gridSize: 1, grid: { bad: null } }]) { + test(`an orphan validator cannot turn a 304 into usable grid data: ${JSON.stringify(data)}`, async t => { + t.mock.method(Date, "now", () => NOW); + let writes = 0; + globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.headers?.["If-None-Match"], undefined); + callback(null, { status: 304, headers: {} }); + }, + }; + await assert.rejects(QWeather.GetLocationsGrid({ locationsGrid: { data, etag: '"orphan"', lastUpdated: NOW - MONTH } }, () => writes++, runtime())); + assert.equal(writes, 0); + }); +} + +for (const data of [undefined, {}, { gridSize: 1, grid: { "121,31": [null] } }]) { + test(`a fresh timestamp does not hide a missing or corrupt grid: ${JSON.stringify(data)}`, async t => { + t.mock.method(Date, "now", () => NOW); + let calls = 0, + saved; + globalThis.$httpClient = { + get(request, callback) { + calls++; + assert.equal(request.headers?.["If-None-Match"], undefined); + callback(null, { status: 200, headers: {} }, JSON.stringify(grid())); + }, + }; + assert.deepEqual(await QWeather.GetLocationsGrid({ locationsGrid: { data, lastUpdated: NOW - 1, etag: '"bad"' } }, value => (saved = value), runtime()), grid()); + assert.equal(calls, 1); + assert.deepEqual(saved.locationsGrid.data, grid()); + }); +} + +test("clock rollback forces revalidation rather than extending a grid indefinitely", async t => { + t.mock.method(Date, "now", () => NOW); + let calls = 0; + globalThis.$httpClient = { + get(_request, callback) { + calls++; + callback(null, { status: 200, headers: {} }, JSON.stringify(grid())); + }, + }; + await QWeather.GetLocationsGrid({ locationsGrid: { data: grid(), lastUpdated: NOW + 86400000 } }, () => {}, runtime()); + assert.equal(calls, 1); +}); + +for (const body of ["", "{}", "{broken", '{"gridSize":1,"grid":[]}', '{"gridSize":1,"grid":{"121,31":[null]}}']) { + test(`malformed downloaded grids never replace existing cache state: ${body}`, async t => { + t.mock.method(Date, "now", () => NOW); + let writes = 0; + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, body); + }, + }; + await assert.rejects(QWeather.GetLocationsGrid({ locationsGrid: { data: grid(), lastUpdated: NOW - MONTH, etag: '"old"' } }, () => writes++, runtime())); + assert.equal(writes, 0); + }); +} + +test("a failed optional storage write does not discard a validated live grid", async t => { + t.mock.method(Date, "now", () => NOW); + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify(grid())); + }, + }; + assert.deepEqual( + await QWeather.GetLocationsGrid( + undefined, + () => { + throw new Error("Storage unavailable"); + }, + runtime(), + ), + grid(), + ); +}); + +test("304 has no JSON representation to validate and remains opt-in", async () => { + const cache = runtime(); + let validations = 0; + const options = { + cacheable: false, + fetcher: async () => ({ status: 304, body: "", headers: {} }), + validate: () => { + validations++; + return false; + }, + }; + assert.equal((await cache.fetch({ url: "https://fixture.example/grid" }, { ...options, allowNotModified: true })).status, 304); + assert.equal(validations, 0); + await assert.rejects(cache.fetch({ url: "https://fixture.example/grid" }, options), error => error.code === "http"); +}); diff --git a/tests/removedControls.test.mjs b/tests/removedControls.test.mjs new file mode 100644 index 00000000..ddfbed4e --- /dev/null +++ b/tests/removedControls.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +const store = new Map(); +globalThis.$persistentStore = { + read: key => store.get(key) ?? null, + write: (value, key) => { + store.set(key, value); + return true; + }, +}; +const [{ Response }, { default: parseWeatherKitURL }, { Console }] = await Promise.all([import("../src/process/Response.mjs"), import("../src/function/parseWeatherKitURL.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const removedSettings = { RequestCache: { TTL: 300, ForecastTTL: 300, MinutelyTTL: 30, AlertsTTL: 30, BudgetMs: 1, CooldownSeconds: 120, Diagnostics: true }, Region: { Fallback: "CN" }, ProviderFallback: { Enabled: true } }; + +test("legacy settings cannot restore provider switching, dispatch budgets or failure cooldown", async () => { + store.set("iRingo", JSON.stringify({ WeatherKit: { Settings: removedSettings } })); + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", Weather: { Provider: "QWeather", Replace: ["CN"] }, API: { ColorfulClouds: { Token: "configured-other-provider" }, QWeather: { Token: "selected-provider" } }, ...removedSettings }; + const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=currentWeather", method: "GET", headers: {} }; + const original = { status: 200, headers: { "Content-Type": "application/json", ETag: "apple" }, body: ' {"currentWeather":{"metadata":{"temporarilyUnavailable":false},"temperature":18,"conditionCode":"CLOUDY"}}\n' }; + let calls = 0; + globalThis.$httpClient = { + get(outgoing, callback) { + calls++; + assert.ok(outgoing.url.includes("/weather/now")); + assert.equal(outgoing.headers["X-QW-Api-Key"], "selected-provider"); + assert.equal(outgoing.timeout, 5000); + callback(null, { status: 429, headers: {} }, "{}"); + }, + }; + for (let i = 0; i < 3; i++) { + const output = await Response(request, { ...original, headers: { ...original.headers } }); + assert.equal(output.body, original.body); + assert.equal(output.headers.ETag, "apple"); + } + assert.equal(calls, 3); + assert.deepEqual([...store.keys()], ["iRingo"], "No removed feature may write new response or cooldown stores"); +}); + +test("legacy region fallback does not override the explicit country or locale", () => { + globalThis.$argument = { LogLevel: "OFF", ...removedSettings }; + const base = "https://weatherkit.apple.com/api/v3/weather/"; + assert.equal(parseWeatherKitURL(new URL(base + "en-US/31.2/121.5")).country, "US"); + assert.equal(parseWeatherKitURL(new URL(base + "zh-Hans/31.2/121.5")).country, undefined); + assert.equal(parseWeatherKitURL(new URL(base + "en-US/31.2/121.5?country=HK")).country, "HK"); +}); + +test("legacy region settings never cause a China-only weather replacement for a US locale", async () => { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", Weather: { Provider: "QWeather", Replace: ["CN"] }, ...removedSettings }; + globalThis.$httpClient = { + get() { + assert.fail("No inferred-country request should be made"); + }, + }; + const response = { status: 200, headers: { "Content-Type": "application/json" }, body: '{"currentWeather":{"metadata":{"temporarilyUnavailable":false},"temperature":18,"conditionCode":"CLOUDY"}}' }; + assert.equal((await Response({ url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?dataSets=currentWeather", method: "GET", headers: {} }, response)).body, response.body); +}); + +test("active runtime and argument definitions contain none of the removed controls", async () => { + const forbidden = /RequestCache|ProviderFallback|Region\.Fallback|ProviderRequestCache|resolveWeatherRegion|recoverConfiguredProduct/; + async function verify(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = new URL(entry.name + (entry.isDirectory() ? "/" : ""), directory); + if (entry.isDirectory()) await verify(file); + else if (/\.(?:mjs|js|ts)$/.test(entry.name)) assert.doesNotMatch(await readFile(file, "utf8"), forbidden, file.pathname); + } + } + await verify(new URL("../src/", import.meta.url)); + for (const name of ["full", "release", "dev"]) assert.doesNotMatch(await readFile(new URL(`../arguments-builder.${name}.config.ts`, import.meta.url), "utf8"), forbidden); + const settings = JSON.parse(await readFile(new URL("../template/boxjs.settings.json", import.meta.url), "utf8")); + assert.doesNotMatch(JSON.stringify(settings), forbidden); +}); diff --git a/tests/responseJSONV3.test.mjs b/tests/responseJSONV3.test.mjs new file mode 100644 index 00000000..5beaa64d --- /dev/null +++ b/tests/responseJSONV3.test.mjs @@ -0,0 +1,300 @@ +// Synthetic protocol fixtures; never commit captured phone responses or credentials. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; +const [{ Response }, { Response: ResponseDev }, { default: QWeather }, { default: ColorfulClouds }, { default: ResponseJSONV3, adaptV3ProviderProduct, validateWeatherJSONV3, adaptV3ClientProduct }] = await Promise.all([ + import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("../src/class/QWeather.mjs"), import("../src/class/ColorfulClouds.mjs"), import("../src/process/ResponseJSONV3.mjs"), +]); +const { qs } = await import("@nsnanocat/util/polyfill/qs.mjs"); +const metadata = () => ({ latitude: 31.2, longitude: 121.5, temporarilyUnavailable: false, sourceType: "MODELED", futureMetadata: true }); +const current = () => ({ metadata: metadata(), temperature: 18, conditionCode: "CLOUDY", futureCurrent: { retain: true } }); +const request = (products = "currentWeather") => globalThis.$request = { url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=${products}`, method: "GET" }; +const response = body => ({ status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ETag: "apple", "cOnTeNt-LeNgTh": "1", "cache-control": "max-age=60", "X-Apple": "retain" }, body: typeof body === "string" ? body : JSON.stringify(body) }); +const settings = overrides => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather,forecastHourly,forecastDaily,forecastNextHour,weatherAlerts", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "WeatherKit", ...overrides }); +const airQualitySettings = overrides => settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", ...overrides }); +const allPollutants = () => [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); +const airQualityProduct = pollutants => ({ metadata: { ...metadata(), providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants }); + +test("native Weather JSON uses string conditions and scalar amounts, including unselected periodic forecasts", async () => { + settings({ DataSets: "currentWeather", "Weather.Provider": "WeatherKit" }); + const body = { + currentWeather: { ...current(), conditionCode: ["CLOUDY", "BREEZY"], snowfallAmountPrevious1h: { expected: 2, minimum: 1, maximum: 3 } }, + forecastDaily: { metadata: metadata(), days: [{ conditionCode: ["RAIN"], precipitationType: ["RAIN"], precipitationAmountByType: [{ expected: 3, minimum: 1, maximum: 4, precipitationType: ["RAIN"] }, { expected: 1, minimum: 0, maximum: 2, precipitationType: ["SNOW"] }] }] }, + forecastPeriodic: { metadata: metadata(), forecasts: [{ periods: [{ conditionCode: ["RAIN"], precipitationAmountByType: [] }] }] }, + futureProduct: { conditionCode: ["DO_NOT_CHANGE"], opaque: true }, + }; + const original = structuredClone(body); + const result = await ResponseJSONV3(request("currentWeather"), response(body), {}); + const actual = JSON.parse(result.body); + assert.equal(actual.currentWeather.conditionCode, "CLOUDY"); + assert.equal(actual.currentWeather.snowfallAmountPrevious1h, 2); + assert.equal(actual.forecastDaily.days[0].precipitationAmount, 4); + assert.equal(actual.forecastDaily.days[0].precipitationType, "RAIN"); + assert.equal(actual.forecastDaily.days[0].precipitationAmountByType[0].minimum, 1); + assert.equal(actual.forecastPeriodic.forecasts[0].periods[0].conditionCode, "RAIN"); + assert.equal(actual.forecastPeriodic.forecasts[0].periods[0].precipitationAmount, 0); + assert.deepEqual(actual.futureProduct, body.futureProduct); + assert.deepEqual(body, original); + assert.equal(result.headers["Cache-Control"], "no-store"); +}); + +test("native JSON adaptation preserves existing totals and condition-record vectors and is idempotent", () => { + const product = { precipitationAmount: 9, precipitationAmountByType: [{ expected: 4 }], condition: [{ beginCondition: "CLEAR", endCondition: "RAIN" }], summary: [{ condition: ["RAIN"] }] }; + assert.equal(adaptV3ClientProduct(product), true); + assert.equal(product.precipitationAmount, 9); + assert.deepEqual(product.condition, [{ beginCondition: "CLEAR", endCondition: "RAIN" }]); + assert.equal(product.summary[0].condition, "RAIN"); + assert.equal(adaptV3ClientProduct(product), false); + validateWeatherJSONV3(product); + const emptyConditions = { condition: [], summary: [] }; + assert.equal(adaptV3ClientProduct(emptyConditions), false); + assert.deepEqual(emptyConditions.condition, []); +}); + +test("native JSON adaptation never invents a total from missing or invalid precipitation measurements", () => { + for (const amounts of [[{}], [{ expected: NaN }], [{ expected: -1 }]]) { + const product = { precipitationAmountByType: amounts }; + adaptV3ClientProduct(product); + assert.equal(product.precipitationAmount, undefined); + } +}); + +for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { + test(`${name}: native Apple QWeather CO is correctly displayed in ug after ingress normalization`, async t => { + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US" }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("Complete Apple concentrations require no extra query"); }); + const product = airQualityProduct(allPollutants()); + product.metadata.providerName = "QWeather"; + product.pollutants.find(p => p.pollutantType === "CO").amount = 0.7; + const actual = JSON.parse((await processResponse(request("airQuality"), response({ airQuality: product }))).body).airQuality; + assert.equal(actual.pollutants.find(p => p.pollutantType === "CO").amount, 700); + assert.equal(actual.pollutants.find(p => p.pollutantType === "CO").units, "MICROGRAMS_PER_CUBIC_METER"); + assert.equal(product.pollutants.find(p => p.pollutantType === "CO").amount, 0.7); + assert.equal(actual.index, 99); + }); + 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"]) { + test(`${name}: v3 ${algorithm} selects the requested algorithm on an explicitly targeted Apple standard`, async t => { + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("Complete concentrations must not start provider requests"); }); + t.mock.method(QWeather.prototype, "CurrentAirQuality", () => { throw new Error("Complete concentrations must not start provider requests"); }); + const body = { airQuality: airQualityProduct(allPollutants()), futureProduct: { retained: true } }; + const result = await processResponse(request("airQuality"), response(body)); + const actual = JSON.parse(result.body).airQuality; + 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); + assert.equal(actual.previousDayComparison, "SIMILAR"); + } else { + assert.match(actual.metadata.providerName, /iRingo/); + assert.equal(actual.previousDayComparison, "SIMILAR", "Keep the original comparison when ReplaceWhenCurrentChange is off"); + assert.equal(result.headers["Cache-Control"], "no-store"); + } + assert.deepEqual(JSON.parse(result.body).futureProduct, body.futureProduct); + }); + } + test(`${name}: a partial pollutant array is supplemented without replacing valid Apple concentrations`, async t => { + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + let calls = 0; + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { calls++; return { metadata: { providerName: "fixture", temporarilyUnavailable: false }, pollutants: allPollutants() }; }); + const body = { airQuality: airQualityProduct([{ ...allPollutants()[0], amount: 0 }, { ...allPollutants()[3], amount: null }]) }; + const result = JSON.parse((await processResponse(request("airQuality"), response(body))).body).airQuality; + assert.equal(calls, 1); + assert.equal(result.pollutants.length, 6); + assert.equal(result.pollutants.find(p => p.pollutantType === "PM2_5").amount, 0); + assert.equal(result.pollutants.find(p => p.pollutantType === "NO2").amount, 35); + assert.equal(result.scale, "CA.AQHI"); + }); + test(`${name}: a failed supplement and incomplete risk inputs retain the available Apple index`, async t => { + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "HK_AQHI" }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { throw new Error("fixture provider unavailable"); }); + const body = { airQuality: airQualityProduct([allPollutants()[0]]) }; + const result = JSON.parse((await processResponse(request("airQuality"), response(body))).body).airQuality; + assert.equal(result.index, 42); + assert.equal(result.categoryIndex, 1); + assert.equal(result.scale, "EPA_NowCast"); + assert.equal(result.previousDayComparison, "SIMILAR"); + assert.equal(result.pollutants.length, 1); + }); + test(`${name}: v3 changes provider fields while retaining unknown Apple data and native JSON types`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture", latitude: "31.2", longitude: "121.5" }, temperature: 23, conditionCode: "CLEAR", windGust: NaN })); + t.mock.method(QWeather.prototype, "Hourly", async () => ({ metadata: { providerName: "Fixture" }, hours: [{ forecastStart: 100, temperature: 24, conditionCode: "RAIN", precipitationType: "RAIN", precipitationAmount: 0.4, snowfallAmount: 0 }] })); + const body = { currentWeather: current(), forecastHourly: { metadata: metadata(), hours: [100, 200].map(forecastStart => ({ forecastStart, temperature: 17, conditionCode: ["CLOUDY"], precipitationAmount: { expected: 0.1, minimum: 0, maximum: 0.2 }, newHourField: true })) }, futureProduct: { untouched: [1, 2] } }; + const original = JSON.stringify(body); + const result = await processResponse(request("currentWeather,forecastHourly"), response(body)); + const updated = JSON.parse(result.body); + assert.equal(updated.currentWeather.temperature, 23); + assert.equal(updated.currentWeather.conditionCode, "CLEAR"); + assert.equal(updated.currentWeather.metadata.latitude, 31.2); + assert.equal(updated.currentWeather.windGust, undefined); + assert.deepEqual(updated.currentWeather.futureCurrent, { retain: true }); + assert.equal(updated.currentWeather.metadata.futureMetadata, true); + assert.equal(updated.forecastHourly.hours[0].precipitationAmount, 0.4); + assert.equal(updated.forecastHourly.hours[0].conditionCode, "RAIN"); + assert.deepEqual(updated.forecastHourly.hours[1], { ...body.forecastHourly.hours[1], conditionCode: "CLOUDY", precipitationAmount: 0.1 }); + assert.deepEqual(updated.futureProduct, body.futureProduct); + assert.equal(JSON.stringify(body), original); + assert.deepEqual(result.headers, { "Content-Type": "application/json; charset=utf-8", "X-Apple": "retain", "Cache-Control": "no-store" }); + }); + test(`${name}: missing provider data and Apple-only settings retain exact original response`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => undefined); + const original = response(` ${JSON.stringify({ currentWeather: current() })}\n`); + assert.strictEqual(await processResponse(request(), original), original); + settings({ "Weather.Provider": "WeatherKit" }); + t.mock.method(ColorfulClouds.prototype, "CurrentWeather", () => { throw new Error("must not start"); }); + assert.strictEqual(await processResponse(request(), original), original); + }); + test(`${name}: v3 binary stays opaque rather than using a WK2 decoder`, async () => { + settings(); + const original = { ...response("opaque WK3"), headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" } }; + assert.strictEqual(await processResponse(request(), original), original); + }); +} + +test("JSON provider adapter covers daily periods and minute summary without changing supported condition records", () => { + const value = adaptV3ProviderProduct({ metadata: { latitude: "31.2", longitude: "invalid" }, days: [{ conditionCode: "RAIN", precipitationType: "RAIN", snowfallAmount: 2, daytimeForecast: { conditionCode: "CLOUDY" } }], summary: [{ condition: "CLEAR" }], condition: [{ beginCondition: "CLEAR", endCondition: "RAIN", parameters: [] }] }); + assert.equal(value.days[0].daytimeForecast.conditionCode, "CLOUDY"); + assert.equal(value.days[0].snowfallAmount, 2); + assert.equal(value.summary[0].condition, "CLEAR"); + assert.equal(value.condition[0].beginCondition, "CLEAR"); + assert.equal(value.metadata.longitude, undefined); +}); + +test("v3 limits, invalid JSON, HTTP failures and unrelated URLs never start injectors", async () => { + settings(); + const good = { currentWeather: current() }; + const deep = current(); let tail = deep; + for (let i = 0; i < 66; i++) tail = tail.nested = {}; + const candidates = [response("{"), response(" ".repeat(1024 * 1024 + 1)), response({ currentWeather: { ...current(), huge: Array(8193).fill(0) } }), response({ currentWeather: deep }), response({ error: "not weather" }), { ...response(good), status: 503 }]; + let calls = 0; + const injectors = { currentWeather: () => { calls++; throw new Error("unexpected work"); } }; + for (const original of candidates) assert.strictEqual(await ResponseJSONV3(request(), original, injectors), original); + for (const url of [request().url.replace("weatherkit.apple.com", "example.com"), request().url.replace("/31.2/", "/91.2/")]) { + const original = response(good); + assert.strictEqual(await ResponseJSONV3({ url, method: "GET" }, original, injectors), original); + } + assert.equal(calls, 0); +}); + +test("a failed hourly product preserves its exact fields while a healthy current product changes", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23 })); + t.mock.method(QWeather.prototype, "Hourly", async () => { throw new Error("provider failed"); }); + const body = { currentWeather: current(), forecastHourly: { metadata: metadata(), hours: [{ forecastStart: 100, futureValue: [1, 2] }] } }; + const result = JSON.parse((await Response(request("currentWeather,forecastHourly"), response(body))).body); + assert.equal(result.currentWeather.temperature, 23); + assert.deepEqual(result.forecastHourly, body.forecastHourly); +}); + +test("v3 air-quality calculation uses symbolic enums and leaves unrelated Apple products intact", async () => { + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "UBA", "AirQuality.Current.Index.Replace": "HJ6332012" }); + const body = { airQuality: { metadata: metadata(), scale: "HJ6332012", index: 50, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [{ pollutantType: "PM2_5", amount: 30, units: "MICROGRAMS_PER_CUBIC_METER" }, { pollutantType: "PM10", amount: 40, units: "MICROGRAMS_PER_CUBIC_METER" }] }, currentWeather: current(), futureProduct: { version: 3 } }; + const updated = JSON.parse((await Response(request("airQuality"), response(body))).body); + assert.notEqual(updated.airQuality.scale, "HJ6332012"); + assert.ok(Number.isFinite(updated.airQuality.index)); + assert.equal(typeof updated.airQuality.primaryPollutant, "string"); + assert.equal(updated.airQuality.pollutants[0].units, "MICROGRAMS_PER_CUBIC_METER"); + assert.deepEqual(updated.currentWeather, body.currentWeather); + assert.deepEqual(updated.futureProduct, body.futureProduct); +}); + +test("WK3 integer scalars are normalized without rounding continuous measurements", () => { + const product = adaptV3ProviderProduct({ metadata: { readTime: 100.9 }, hours: [{ cloudCover: 38.2, humidity: 80.7, precipitationChance: 32.5, windDirection: 183.8, uvIndex: 2.4, temperature: 17.38, pressure: 1013.42, windSpeed: 2.75 }] }); + validateWeatherJSONV3(product); + assert.deepEqual(product.hours[0], { cloudCover: 38, humidity: 81, precipitationChance: 33, windDirection: 184, uvIndex: 2, temperature: 17.38, pressure: 1013.42, windSpeed: 2.75 }); + assert.equal(product.metadata.readTime, 100); +}); + +test("bad provider integer fields keep Apple's values and obsolete bodyBytes are removed", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23.4, windDirection: -1, cloudCover: Infinity, humidity: 101, uvIndex: 2.7 })); + const original = { ...response({ currentWeather: { ...current(), windDirection: 90, cloudCover: 40, humidity: 80 } }), bodyBytes: new Uint8Array([1, 2]) }; + const result = await Response(request(), original); + const updated = JSON.parse(result.body).currentWeather; + assert.equal(updated.windDirection, 90); + assert.equal(updated.cloudCover, 40); + assert.equal(updated.humidity, 80); + assert.equal(updated.uvIndex, 3); + assert.equal(updated.temperature, 23.4); + assert.equal(result.bodyBytes, undefined); + assert.ok(original.bodyBytes); +}); + +test("a nonnumeric integer or malformed vector rejects only its replacement product", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23, windDirection: "bad" })); + t.mock.method(QWeather.prototype, "Hourly", async () => ({ metadata: { providerName: "Fixture" }, hours: [{ forecastStart: 100, temperature: 24, conditionCode: "RAIN", precipitationChance: 32.5 }] })); + const body = { currentWeather: current(), forecastHourly: { metadata: metadata(), hours: [{ forecastStart: 100, temperature: 17 }] } }; + const result = JSON.parse((await Response(request("currentWeather,forecastHourly"), response(body))).body); + assert.deepEqual(result.currentWeather, body.currentWeather); + assert.equal(result.forecastHourly.hours[0].temperature, 24); + assert.equal(result.forecastHourly.hours[0].precipitationChance, 33); +}); + +test("invalid unsigned air-quality results cannot replace a healthy Apple product", async () => { + settings({ DataSets: "airQuality,currentWeather" }); + const body = { airQuality: { metadata: metadata(), index: 30, categoryIndex: 1 }, currentWeather: current() }; + const injectors = { airQuality: async input => ({ ...input, index: -1, categoryIndex: -1 }), currentWeather: async input => ({ ...input, temperature: 23 }) }; + const updated = JSON.parse((await ResponseJSONV3(request("airQuality,currentWeather"), response(body), injectors)).body); + assert.deepEqual(updated.airQuality, body.airQuality); + assert.equal(updated.currentWeather.temperature, 23); +}); + +test("WK3 open-ended minute forecasts omit WK2 zero timestamp sentinels", () => { + const product = adaptV3ProviderProduct({ metadata: { reportedTime: 0, readTime: 100 }, forecastStart: 0, forecastEnd: 300, condition: [{ startTime: 100, endTime: 0, beginCondition: "CLEAR", endCondition: "CLEAR", parameters: [] }], summary: [{ startTime: 100, endTime: 0, condition: "CLEAR", precipitationChance: 0, precipitationIntensity: 0 }] }); + validateWeatherJSONV3(product); + assert.equal(product.metadata.reportedTime, undefined); + assert.equal(product.condition[0].endTime, undefined); + assert.equal(product.summary[0].endTime, undefined); + assert.equal(product.forecastStart, 0); + assert.equal(product.summary[0].precipitationIntensity, 0); +}); + +test("undefined provider values do not erase available Apple fields", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23, windDirection: undefined, humidity: null })); + const original = { currentWeather: { ...current(), humidity: 80, windDirection: 90 } }; + const updated = JSON.parse((await Response(request(), response(original))).body).currentWeather; + assert.equal(updated.temperature, 23); + assert.equal(updated.humidity, 80); + assert.equal(updated.windDirection, 90); +}); + +test("unserializable values and malformed Unicode reject one product without discarding healthy replacements", async () => { + settings({ DataSets: "weatherAlerts,currentWeather", "WeatherAlerts.Provider": "QWeather" }); + const body = { weatherAlerts: { metadata: metadata(), alerts: [{ description: "Apple warning" }] }, currentWeather: current() }; + for (const invalid of ["bad\ud800", 1n, [undefined]]) { + const injectors = { weatherAlerts: async input => ({ ...input, futureProviderField: invalid }), currentWeather: async input => ({ ...input, temperature: 23 }) }; + const updated = JSON.parse((await ResponseJSONV3(request("weatherAlerts,currentWeather"), response(body), injectors)).body); + assert.deepEqual(updated.weatherAlerts, body.weatherAlerts); + assert.equal(updated.currentWeather.temperature, 23); + } + assert.doesNotThrow(() => validateWeatherJSONV3({ description: "雨 🌧️" })); +}); + +test("unknown JSON arrays retain null and finite double values without native field coercion", async () => { + settings({ DataSets: "currentWeather" }); + for (const values of [[null], [1e100]]) { + const original = { currentWeather: { ...current(), futureProviderField: values } }; + const result = await ResponseJSONV3(request(), response(original), { currentWeather: async input => ({ ...input, temperature: 23 }) }); + const actual = JSON.parse(result.body).currentWeather; + assert.equal(actual.temperature, 23); + assert.deepEqual(actual.futureProviderField, values); + } +}); + +test("known native vectors still reject null entries and invalid float values", () => { + for (const invalid of [[null], [undefined], [1e100]]) assert.throws(() => validateWeatherJSONV3({ hours: invalid }, "forecastHourly")); +}); + +test("a null replacement product keeps the complete original response", async () => { + settings({ DataSets: "currentWeather" }); + const original = response({ currentWeather: current() }); + assert.strictEqual(await ResponseJSONV3(request(), original, { currentWeather: async () => null }), original); +}); diff --git a/tests/responseMemoryPaths.test.mjs b/tests/responseMemoryPaths.test.mjs new file mode 100644 index 00000000..2eea6fea --- /dev/null +++ b/tests/responseMemoryPaths.test.mjs @@ -0,0 +1,118 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "QWeatherWeb" }; +const [{ Response }, { Response: ResponseDev }, { default: WeatherKit2 }, { default: QWeather }, WK, { ByteBuffer }, fixtures] = await Promise.all([ + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("../src/class/QWeather.mjs"), + import("@nsringo/weatherkit"), + import("flatbuffers"), + import("./fixtures/processingMemoryFixture.mjs"), +]); +const request = products => { + const value = { url: `https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.39/119.499?country=CN&dataSets=${products}`, method: "GET", headers: {} }; + globalThis.$request = value; + return value; +}; +const response = body => ({ status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body }); + +for (const [name, processResponse] of [ + ["release", Response], + ["development", ResponseDev], +]) { + test(`${name}: failed hourly replacement preserves its future binary fields when current weather changes`, async () => { + const raw = fixtures.futureHourlyFixture(), + original = raw.slice(); + const savedNow = QWeather.prototype.WeatherNow, + savedHourly = QWeather.prototype.Hourly, + encode = WeatherKit2.encode; + let patchKeys; + QWeather.prototype.WeatherNow = async () => ({ metadata: { providerName: "Fixture", temporarilyUnavailable: false }, temperature: 29 }); + QWeather.prototype.Hourly = async () => undefined; + WeatherKit2.encode = function (source, patch) { + patchKeys = Object.keys(patch); + return encode.call(this, source, patch); + }; + try { + const result = await processResponse(request("currentWeather,forecastHourly"), response(raw)); + assert.deepEqual(patchKeys, ["currentWeather"]); + assert.equal(fixtures.readFutureHourly(result.body), fixtures.futureHourlyText); + assert.equal(WeatherKit2.decode(new ByteBuffer(result.body), ["currentWeather"]).currentWeather.temperature, 29); + assert.deepEqual(raw, original); + } finally { + QWeather.prototype.WeatherNow = savedNow; + QWeather.prototype.Hourly = savedHourly; + WeatherKit2.encode = encode; + } + }); + + for (const success of [false, true]) + test(`${name}: alerts are copied only after a usable provider result (${success})`, async () => { + const raw = fixtures.weatherFixture(0, 3), + originalBytes = raw.slice(); + const decode = WeatherKit2.decode, + savedWeb = QWeather.prototype.WeatherAlertWeb; + let readAlerts = 0, + original, + complete; + WeatherKit2.decode = function (...args) { + const data = decode.apply(this, args); + original = data.weatherAlerts; + const alerts = original.alerts; + Object.defineProperty(original, "alerts", { + enumerable: true, + get() { + readAlerts++; + return alerts; + }, + }); + return data; + }; + QWeather.prototype.WeatherAlertWeb = () => + new Promise(resolve => { + complete = resolve; + }); + try { + const pending = processResponse(request("weatherAlerts"), response(raw)); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(typeof complete, "function"); + assert.equal(readAlerts, 0, "Pending callbacks must not retain a deep copy of alerts"); + complete(success ? { metadata: { providerName: "Fixture", temporarilyUnavailable: false }, alerts: [{ areaId: "0", description: "Updated warning", responses: [], guidelines: [] }] } : undefined); + const result = await pending; + if (success) { + assert.equal(decode.call(WeatherKit2, new ByteBuffer(result.body), ["weatherAlerts"]).weatherAlerts.alerts[0].description, "Updated warning"); + assert.equal(original.alerts[0].description, "Apple warning 0"); + assert.notDeepEqual(result.body, raw); + } else { + assert.equal(readAlerts, 0); + assert.strictEqual(result.body, raw); + } + assert.deepEqual(raw, originalBytes); + } finally { + WeatherKit2.decode = decode; + QWeather.prototype.WeatherAlertWeb = savedWeb; + } + }); +} + +test("alert decode uses one schema view per record and preserves the full decoded payload", () => { + const raw = fixtures.weatherFixture(0, 48); + const expected = WeatherKit2.decode(new ByteBuffer(raw), ["weatherAlerts"]); + const alerts = WK.WeatherAlertCollectionData.prototype.alerts; + let calls = 0; + WK.WeatherAlertCollectionData.prototype.alerts = function (...args) { + calls++; + return alerts.apply(this, args); + }; + try { + assert.deepEqual(WeatherKit2.decode(new ByteBuffer(raw), ["weatherAlerts"]), expected); + assert.equal(calls, 48); + } finally { + WK.WeatherAlertCollectionData.prototype.alerts = alerts; + } +}); diff --git a/tests/severeWeatherPage.test.mjs b/tests/severeWeatherPage.test.mjs index 2a050a2b..94f1822d 100644 --- a/tests/severeWeatherPage.test.mjs +++ b/tests/severeWeatherPage.test.mjs @@ -44,7 +44,7 @@ const qWeatherAlertAPI = { issuedTime: "2026-08-02T09:48Z", effectiveTime: "2026-08-02T09:48Z", onsetTime: "2026-08-02T09:48Z", - expiresTime: "2026-08-03T09:48Z", + expiresTime: "2099-08-03T09:48Z", eventType: { name: "高温", code: "1009" }, severity: "severe", headline: "南京市气象台发布高温橙色预警信号。", @@ -150,22 +150,21 @@ test("WeatherAlerts standardization stays provider-agnostic", async () => { assert.doesNotMatch(source, /\bqWeather\b|\bQWeather\b/); }); -test("response WeatherAlert injection only selects and merges provider slot objects", async () => { +test("response WeatherAlert injection selects provider slot objects", async () => { for (const path of ["../src/process/Response.mjs", "../src/process/Response.dev.mjs"]) { const source = await readFile(new URL(path, import.meta.url), "utf8"); const injectWeatherAlerts = source.match(/async function InjectWeatherAlerts\([\s\S]*?(?=\n\/\*\*)/)?.[0] ?? ""; assert.match(source, /case "weatherAlerts": \{[\s\S]*body\.weatherAlerts = await InjectWeatherAlerts\(body\.weatherAlerts, Settings, enviroments\)/); - assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.colorfulClouds\.WeatherAlert\(\)/); - assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.qWeather\.WeatherAlert\(\)/); - assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.qWeather\.WeatherAlertWeb\(weatherAlerts\?\.detailsUrl\)/); - assert.match(injectWeatherAlerts, /if \(newWeatherAlerts\?\.metadata\) \{[\s\S]*weatherAlerts\.metadata = \{ \.\.\.weatherAlerts\?\.metadata, \.\.\.newWeatherAlerts\.metadata \}/); + assert.match(injectWeatherAlerts, /newWeatherAlerts = await \(enviroments\.weatherAlertsColorfulClouds \?\? enviroments\.colorfulClouds\)\.WeatherAlert\(\)/); + assert.match(injectWeatherAlerts, /newWeatherAlerts = await \(enviroments\.weatherAlertsQWeather \?\? enviroments\.qWeather\)\.WeatherAlert\(\)/); + assert.match(injectWeatherAlerts, /newWeatherAlerts = await \(enviroments\.weatherAlertsQWeather \?\? enviroments\.qWeather\)\.WeatherAlertWeb\(weatherAlerts\?\.detailsUrl\)/); assert.doesNotMatch(injectWeatherAlerts, /QWeather\.(?:ParseWeatherAlertPageURL|BuildAppleAlertDetailsURL|FetchWeatherAlertPage)|attributionUrl|Console\.debug/); } }); test("response provider logos are finalized inside Inject methods", async () => { - const injectMethods = ["InjectCurrentWeather", "InjectForecastDaily", "InjectForecastHourly", "InjectForecastNextHour", "InjectWeatherAlerts"]; + const injectMethods = ["InjectCurrentWeather", "InjectForecastDaily", "InjectForecastHourly", "InjectForecastNextHour"]; for (const path of ["../src/process/Response.mjs", "../src/process/Response.dev.mjs"]) { const source = await readFile(new URL(path, import.meta.url), "utf8"); const topLevel = source.slice(0, source.indexOf("async function InjectCurrentWeather")); @@ -206,7 +205,7 @@ test("QWeather HTML extraction is separated from WeatherAlert construction", asy assert.equal(alerts[0].description, "雷暴橙色预警"); assert.equal(alerts[0].effectiveTime, "2026-07-31T03:00:00.000Z"); assert.equal(alerts[0].eventOnsetTime, "2026-07-31T03:00:00.000Z"); - assert.equal(alerts[0].expireTime, "9999-12-31T23:59:59Z"); + assert.equal(alerts[0].expireTime, "9999-12-31T00:00:00Z"); assert.equal(alerts[0].eventSource, "CN"); assert.equal(alerts[0].importance, "high"); assert.equal(alerts[0].reportedAt, "2026-07-31T03:00:00.000Z"); @@ -252,7 +251,7 @@ test("QWeather WeatherAlertWeb returns a complete top-level slot object", async const weatherAlerts = await new QWeather({ country: "CN", language: "zh-CN", latitude: "32.115", longitude: "118.814" }, "test-token").WeatherAlertWeb(url); assert.equal(sourceUrl.toString(), url); - assert.deepEqual(weatherAlerts.metadata, { attributionUrl: url }); + assert.deepEqual(weatherAlerts.metadata, { temporarilyUnavailable: false, attributionUrl: url }); assert.equal(weatherAlerts.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=jianye-101190110&lang=zh-CN&party=qweather"); assert.equal(weatherAlerts.alerts.length, 1); assert.equal(weatherAlerts.alerts[0].areaId, "101190110"); @@ -285,7 +284,7 @@ test("QWeather Alert API is standardized by QWeather class", async () => { const originalFetch = globalThis.fetch; let sourceRequest; globalThis.fetch = async (input, init) => { - const requestUrl = typeof input === "string" ? input : input?.url ?? input; + const requestUrl = typeof input === "string" ? input : (input?.url ?? input); sourceRequest = { url: new URL(requestUrl), headers: new Headers(init?.headers ?? input?.headers ?? {}) }; return new Response(JSON.stringify(qWeatherAlertAPI), { headers: { "Content-Type": "application/json" } }); }; @@ -308,7 +307,7 @@ test("QWeather Alert API is standardized by QWeather class", async () => { } assert.equal(extracted.source, "南京市气象台"); assert.equal(extracted.areaName, "南京市"); - assert.deepEqual(extracted.metadata, { attributionUrl: "https://developer.qweather.com/attribution.html" }); + assert.deepEqual(extracted.metadata, { temporarilyUnavailable: false, attributionUrl: "https://developer.qweather.com/attribution.html" }); assert.equal(extracted.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=32.115,118.814&lang=de&party=QWeather"); assert.equal(extracted.alerts.length, 1); assert.equal(extracted.alerts[0].areaId, "320100"); @@ -318,20 +317,15 @@ test("QWeather Alert API is standardized by QWeather class", async () => { assert.deepEqual(extracted.alerts[0].responses, ["monitor"]); assert.equal(extracted.alerts[0].effectiveTime, "2026-08-02T09:48:00.000Z"); assert.equal(extracted.alerts[0].eventOnsetTime, "2026-08-02T09:48:00.000Z"); - assert.equal(extracted.alerts[0].eventEndTime, "2026-08-03T09:48:00.000Z"); - assert.equal(extracted.alerts[0].expireTime, "2026-08-03T09:48:00.000Z"); + assert.equal(extracted.alerts[0].eventEndTime, "2099-08-03T09:48:00.000Z"); + assert.equal(extracted.alerts[0].expireTime, "2099-08-03T09:48:00.000Z"); assert.equal(extracted.alerts[0].phenomenon, "Met"); assert.equal(extracted.alerts[0].source, "南京市气象台"); assert.equal(extracted.alerts[0].token, "1009"); assert.equal(extracted.alerts[0].reportedAt, "2026-08-02T09:48:00.000Z"); assert.equal(extracted.alerts[0].message, "南京市气象台2026年08月02日17时44分继续发布高温橙色预警信号:预计明天全市大部分地区的日最高气温可达37℃以上,请注意防暑降温。"); assert.equal(extracted.alerts[0].standard, ""); - assert.deepEqual(extracted.alerts[0].guidelines, [ - "有关部门和单位按照职责落实防暑降温保障措施;", - "尽量避免在高温时段进行户外活动;", - "对老、弱、病、幼人群提供防暑降温指导;", - "高温条件下作业人员应当缩短连续工作时间。", - ]); + assert.deepEqual(extracted.alerts[0].guidelines, ["有关部门和单位按照职责落实防暑降温保障措施;", "尽量避免在高温时段进行户外活动;", "对老、弱、病、幼人群提供防暑降温指导;", "高温条件下作业人员应当缩短连续工作时间。"]); } finally { globalThis.fetch = originalFetch; } @@ -386,11 +380,56 @@ test("QWeather event codes map to CAP phenomena", async () => { test("all documented QWeather event codes map to CAP categories", async () => { const originalFetch = globalThis.fetch; const documentedCodeRanges = [ - [1001, 1069], [1071, 1082], [1084, 1089], [1201, 1219], [1221, 1221], [1241, 1251], [1271, 1274], [1601, 1610], [1701, 1710], [1801, 1805], - [2001, 2007], [2029, 2033], [2050, 2054], [2070, 2085], [2100, 2109], [2111, 2111], [2120, 2135], [2150, 2150], [2152, 2168], [2190, 2193], - [2200, 2205], [2207, 2221], [2300, 2309], [2311, 2328], [2330, 2333], [2341, 2341], [2343, 2343], [2345, 2346], [2348, 2400], [2409, 2409], - [2411, 2426], [2501, 2502], [2521, 2532], [2550, 2554], [2581, 2581], [2601, 2620], [2641, 2641], [2713, 2713], [2722, 2723], [2743, 2743], - [2749, 2749], [2751, 2753], [2755, 2756], [2791, 2797], [2801, 2804], [2839, 2853], [2873, 2874], [3101, 3107], [3131, 3148], [9999, 9999], + [1001, 1069], + [1071, 1082], + [1084, 1089], + [1201, 1219], + [1221, 1221], + [1241, 1251], + [1271, 1274], + [1601, 1610], + [1701, 1710], + [1801, 1805], + [2001, 2007], + [2029, 2033], + [2050, 2054], + [2070, 2085], + [2100, 2109], + [2111, 2111], + [2120, 2135], + [2150, 2150], + [2152, 2168], + [2190, 2193], + [2200, 2205], + [2207, 2221], + [2300, 2309], + [2311, 2328], + [2330, 2333], + [2341, 2341], + [2343, 2343], + [2345, 2346], + [2348, 2400], + [2409, 2409], + [2411, 2426], + [2501, 2502], + [2521, 2532], + [2550, 2554], + [2581, 2581], + [2601, 2620], + [2641, 2641], + [2713, 2713], + [2722, 2723], + [2743, 2743], + [2749, 2749], + [2751, 2753], + [2755, 2756], + [2791, 2797], + [2801, 2804], + [2839, 2853], + [2873, 2874], + [3101, 3107], + [3131, 3148], + [9999, 9999], ]; const documentedCodes = documentedCodeRanges.flatMap(([start, end]) => Array.from({ length: end - start + 1 }, (_, index) => String(start + index))); const categories = new Set(["Geo", "Met", "Safety", "Security", "Rescue", "Fire", "Health", "Env", "Transport", "Infra", "CBRNE", "Other"]); @@ -500,13 +539,13 @@ test("updated Chinese headlines replace generic FlatBuffer titles", () => { assert.equal(appleAlerts[1].description, "暴雨橙色预警"); }); -test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", async () => { +test("ColorfulClouds fetches each realtime product independently and standardizes v2.6 alerts", async () => { const originalFetch = globalThis.fetch; let sourceRequest; let requestCount = 0; globalThis.fetch = async (input, init) => { requestCount++; - const requestUrl = typeof input === "string" ? input : input?.url ?? input; + const requestUrl = typeof input === "string" ? input : (input?.url ?? input); sourceRequest = { url: new URL(requestUrl), headers: new Headers(init?.headers ?? input?.headers ?? {}) }; return new Response(JSON.stringify(colorfulCloudsRealtimeAPI), { headers: { "Content-Type": "application/json" } }); }; @@ -528,12 +567,12 @@ test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", as assert.equal(sourceRequest.url.toString(), `https://api.caiyunapp.com/v2.6/test-token/116.3176,39.976/realtime?lang=${colorfulCloudsLanguage}&alert=true`); assert.equal(sourceRequest.headers.get("Referer"), "https://caiyunapp.com/"); - assert.equal(requestCount, index + 1); + assert.equal(requestCount, (index + 1) * 2); } assert.equal(extracted.source, "国家预警信息发布中心"); assert.equal(extracted.areaName, "北京市海淀区"); - assert.deepEqual(extracted.metadata, { attributionUrl: "https://www.caiyunapp.com/h5" }); + assert.deepEqual(extracted.metadata, { temporarilyUnavailable: false, attributionUrl: "https://www.caiyunapp.com/h5" }); assert.equal(extracted.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=39.976,116.3176&lang=de&party=ColorfulClouds"); assert.equal(extracted.alerts.length, 1); assert.equal(extracted.alerts[0].areaId, "110108"); @@ -560,13 +599,11 @@ test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", as } }); -test("only ColorfulClouds realtime requests and caches v2.6 alerts", async () => { +test("only ColorfulClouds realtime requests v2.6 alerts", async () => { const source = await readFile(new URL("../src/class/ColorfulClouds.mjs", import.meta.url), "utf8"); assert.match(source, /\/realtime\?[^\n]*alert=true/); for (const endpoint of ["minutely", "hourly", "daily"]) assert.doesNotMatch(source, new RegExp(`/${endpoint}\\?[^\\n]*alert=true`), endpoint); - assert.match(source, /switch \(body\?\.result\?\.realtime\?\.status\)[\s\S]*switch \(body\?\.result\?\.alert\?\.status\) \{\s*case "ok":\s*this\.#cache\.alert = body\.result\.alert;/); - assert.doesNotMatch(source, /result\?\.alert\?\.status === "ok"/); }); test("ColorfulClouds v2.6 alert codes map event names and severities", async () => { @@ -626,7 +663,7 @@ test("ColorfulClouds v3 CAP alerts remain available through WeatherAlertV3CAP", const originalFetch = globalThis.fetch; let sourceRequest; globalThis.fetch = async (input, init) => { - const requestUrl = typeof input === "string" ? input : input?.url ?? input; + const requestUrl = typeof input === "string" ? input : (input?.url ?? input); sourceRequest = { url: new URL(requestUrl), headers: new Headers(init?.headers ?? input?.headers ?? {}) }; return new Response(JSON.stringify(colorfulCloudsCAPAlertAPI), { headers: { "Content-Type": "application/json" } }); }; @@ -651,7 +688,7 @@ test("ColorfulClouds v3 CAP alerts remain available through WeatherAlertV3CAP", assert.equal(extracted.source, "NWS Los Angeles/Oxnard CA"); assert.equal(extracted.areaName, "Los Angeles"); - assert.deepEqual(extracted.metadata, { attributionUrl: "https://www.caiyunapp.com/h5" }); + assert.deepEqual(extracted.metadata, { temporarilyUnavailable: false, attributionUrl: "https://www.caiyunapp.com/h5" }); assert.equal(extracted.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=34.05,-118.25&lang=de&party=ColorfulClouds"); assert.equal(extracted.alerts.length, 1); assert.equal(extracted.alerts[0].areaId, "CAC037"); @@ -711,9 +748,7 @@ test("ColorfulClouds v3 CAP categories remain mapped to phenomena", async () => }); test("QWeather source extraction falls back to the English attribution label", () => { - const englishHtml = sourceHtml - .replace("建邺区气象台发布雷暴橙色预警信号。", "Thunderstorm orange warning.") - .replace("预警数据来源:国家预警信息发布中心", "Warning data source: National Early Warning Center"); + const englishHtml = sourceHtml.replace("建邺区气象台发布雷暴橙色预警信号。", "Thunderstorm orange warning.").replace("预警数据来源:国家预警信息发布中心", "Warning data source: National Early Warning Center"); assert.equal(QWeather.ExtractWeatherAlertPage(englishHtml).source, "National Early Warning Center"); }); @@ -818,12 +853,13 @@ test("Pages routes coordinate WeatherAlert identifiers through QWeather API for } }); -test("Pages returns an empty Apple-compatible array for native WeatherAlert identifiers", async () => { +test("Pages forwards native WeatherAlert identifiers to Apple", async () => { const originalFetch = globalThis.fetch; let fetchCount = 0; + const originalAlerts = [{ id: "35889ee6-fa82-5f9f-8e49-fad78c4f383a", description: "Original Apple warning" }]; globalThis.fetch = async () => { fetchCount++; - return new Response("[]", { headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify(originalAlerts), { headers: { "Content-Type": "application/json" } }); }; try { @@ -832,8 +868,8 @@ test("Pages returns an empty Apple-compatible array for native WeatherAlert iden env: {}, }); assert.equal(response.status, 200); - assert.deepEqual(await response.json(), []); - assert.equal(fetchCount, 0); + assert.deepEqual(await response.json(), originalAlerts); + assert.equal(fetchCount, 1); } finally { globalThis.fetch = originalFetch; } @@ -916,7 +952,7 @@ test("the request scripts force QWeatherWeb for page identifiers regardless of t } }); -test("the request scripts return an empty Apple-compatible array when the QWeather fetch fails", async () => { +test("the request scripts pass through when the QWeather fetch fails", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { throw new Error("QWeather unavailable"); @@ -930,10 +966,7 @@ test("the request scripts return an empty Apple-compatible array when the QWeath headers: { "Accept-Language": "zh-CN" }, }); - assert.equal($response.status, 200); - assert.equal($response.statusCode, 200); - assert.equal($response.headers["Content-Type"], "application/json"); - assert.deepEqual(JSON.parse($response.body), []); + assert.equal($response, undefined); } } finally { globalThis.fetch = originalFetch; @@ -1017,7 +1050,7 @@ test("the request scripts route coordinate identifiers through configured Colorf } }); -test("the request scripts return an empty Apple-compatible array for coordinate identifiers when WeatherKit is selected", async () => { +test("the request scripts pass coordinate identifiers through when WeatherKit is selected", async () => { const originalArgument = globalThis.$argument; const originalFetch = globalThis.fetch; let fetchCount = 0; @@ -1039,8 +1072,7 @@ test("the request scripts return an empty Apple-compatible array for coordinate headers: {}, }); - assert.equal($response.status, 200); - assert.deepEqual(JSON.parse($response.body), []); + assert.equal($response, undefined); } assert.equal(fetchCount, 0); } finally { @@ -1049,15 +1081,14 @@ test("the request scripts return an empty Apple-compatible array for coordinate } }); -test("the request scripts return an empty Apple-compatible array for non-QWeather identifiers", async () => { +test("the request scripts pass native Apple identifiers through", async () => { for (const handler of [processRequest, processRequestDev]) { const { $response } = await handler({ method: "GET", url: "https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=35889ee6-fa82-5f9f-8e49-fad78c4f383a", headers: {}, }); - assert.equal($response.status, 200); - assert.deepEqual(JSON.parse($response.body), []); + assert.equal($response, undefined); } }); diff --git a/tests/severeWeatherRewriteModules.test.mjs b/tests/severeWeatherRewriteModules.test.mjs index 4b5150c3..117b1e9c 100644 --- a/tests/severeWeatherRewriteModules.test.mjs +++ b/tests/severeWeatherRewriteModules.test.mjs @@ -1,3 +1,4 @@ +// Modified by hhhoratioxu, 2026-10-19: retain existing argument ordering and include the cache setting. import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; @@ -90,8 +91,8 @@ test("script module templates hook Apple weatherAlerts without QWeather page red const templates = ["surge.handlebars", "loon.handlebars", "quantumultx.handlebars", "stash.handlebars"]; for (const filename of templates) { const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); - assert.ok(content.includes(weatherAlertsPageHandlerPattern), filename); - assert.ok(content.includes(weatherAlertsCoordinateHandlerPattern), filename); + assert.ok(content.includes(filename === "loon.handlebars" ? weatherAlertsPageHandlerPattern.replace("[^#]*&ids=", "(?:[^#]*&)?ids=") : weatherAlertsPageHandlerPattern), filename); + assert.ok(content.includes(filename === "loon.handlebars" ? weatherAlertsCoordinateHandlerPattern.replace("[^#]*&ids=", "(?:[^#]*&)?ids=") : weatherAlertsCoordinateHandlerPattern), filename); assert.ok(!content.includes(mergedWeatherAlertsIdentifierPattern), filename); assert.match(content, /api\\\/v1\\\/weatherAlerts\\\?/); assert.ok(!content.includes(unsafeOpenEndedQuantifier), filename); @@ -111,8 +112,8 @@ test("script module templates hook Apple weatherAlerts without QWeather page red assert.ok(surge.includes(`pattern="${weatherAlertsCoordinateHandlerPattern}"`)); assert.doesNotMatch(surge, /weatherAlerts\.(?:html|api)\.request[^\n]+pattern=\^https/); assert.doesNotMatch(surge, /weatherAlerts\.(?:html|api)\.request[^\n]+requires-body/); - assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.bundle\.js[^\n]+weatherAlerts\.html\.request/); - assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.bundle\.js[^\n]+weatherAlerts\.api\.request/); + assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.loon\.bundle\.js[^\n]+weatherAlerts\.html\.request/); + assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.loon\.bundle\.js[^\n]+weatherAlerts\.api\.request/); assert.match(quantumultX, /weatherAlerts[^\n]+url script-echo-response[^\n]+request\.bundle\.js/); assert.doesNotMatch(quantumultX, /weatherAlerts[^\n]+script-request-header/); assert.match(stash, /match: [^\n]+weatherAlerts[\s\S]*?name: WeatherKit\.api\.v1\.weatherAlerts\.request[\s\S]*?type: request/); @@ -129,35 +130,34 @@ test("Stash weather-alert requests declare the required request type", async () }); test("Rewrite templates stay aligned with fixed Rewrite modules", async () => { - const fixedTemplates = ["loon.rewrite.handlebars", "stash.rewrite.handlebars"]; - for (const filename of rewriteTemplates) { + const fixedTemplates = ["loon.rewrite.handlebars", "stash.rewrite.handlebars"]; + for (const filename of rewriteTemplates) { const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); assert.ok(content.includes("(Rewrite)"), filename); assert.ok(content.includes(weatherAlertsPagePattern), filename); assert.ok(content.includes(weatherAlertsCoordinatePattern), filename); assert.ok(!content.includes(mergedWeatherAlertsIdentifierPattern), filename); - assert.doesNotMatch(content, /www\.qweather\.com/); - assert.doesNotMatch(content, /party=qweather/); - assert.match(content, /weatherkit\.apple\.com/); - assert.doesNotMatch(content, /weatherkit\.nanocat\.cloud/); - } - + assert.doesNotMatch(content, /www\.qweather\.com/); + assert.doesNotMatch(content, /party=qweather/); + assert.match(content, /weatherkit\.apple\.com/); + assert.doesNotMatch(content, /weatherkit\.nanocat\.cloud/); + } - const surge = await readFile(new URL("../template/surge.rewrite.handlebars", import.meta.url), "utf8"); - assert.ok(surge.includes("#!arguments = {{{arguments}}}")); - assert.ok(surge.includes("#!arguments-desc = {{{argumentsDesc}}}")); - assert.ok(surge.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); + const surge = await readFile(new URL("../template/surge.rewrite.handlebars", import.meta.url), "utf8"); + assert.ok(surge.includes("#!arguments = {{{arguments}}}")); + assert.ok(surge.includes("#!arguments-desc = {{{argumentsDesc}}}")); + assert.ok(surge.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); - const shadowrocket = await readFile(new URL("../template/shadowrocket.rewrite.handlebars", import.meta.url), "utf8"); - assert.ok(shadowrocket.includes("#!arguments = endpoint:weatherkit.pages.dev")); - assert.ok(shadowrocket.includes("#!arguments-desc = endpoint: [重写] 服务端点\\n")); - assert.ok(shadowrocket.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); + const shadowrocket = await readFile(new URL("../template/shadowrocket.rewrite.handlebars", import.meta.url), "utf8"); + assert.ok(shadowrocket.includes("#!arguments = endpoint:weatherkit.pages.dev")); + assert.ok(shadowrocket.includes("#!arguments-desc = endpoint: [重写] 服务端点\\n")); + assert.ok(shadowrocket.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); - for (const filename of fixedTemplates) { - const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); - assert.ok(content.includes("https://weatherkit.pages.dev/api/v1/weatherAlerts?$1"), filename); - assert.doesNotMatch(content, /\{\{\{endpoint\}\}\}/, filename); - } + for (const filename of fixedTemplates) { + const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); + assert.ok(content.includes("https://weatherkit.pages.dev/api/v1/weatherAlerts?$1"), filename); + assert.doesNotMatch(content, /\{\{\{endpoint\}\}\}/, filename); + } const loon = await readFile(new URL("../template/loon.rewrite.handlebars", import.meta.url), "utf8"); assert.match(loon, /^\[Rewrite\]$/m); @@ -176,11 +176,11 @@ test("Loon Rewrite modules use the legacy Rewrite section", async () => { }); test("Rewrite builder outputs use Rewrite names", async () => { - const content = await readFile(new URL("../arguments-builder.rewrite.config.ts", import.meta.url), "utf8"); - assert.match(content, /iRingo\.WeatherKit\.Rewrite\.(?:sgmodule|lpx|srmodule|stoverride|yaml)/); - assert.match(content, /key: "endpoint"[\s\S]*defaultValue: "weatherkit\.pages\.dev"/); - assert.match(content, /weather\.nanocat\.cloud", label: "Worker 版;需要代理"/); - assert.doesNotMatch(content, /Workers/); + const content = await readFile(new URL("../arguments-builder.rewrite.config.ts", import.meta.url), "utf8"); + assert.match(content, /iRingo\.WeatherKit\.Rewrite\.(?:sgmodule|lpx|srmodule|stoverride|yaml)/); + assert.match(content, /key: "endpoint"[\s\S]*defaultValue: "weatherkit\.pages\.dev"/); + assert.match(content, /weather\.nanocat\.cloud", label: "Worker 版;需要代理"/); + assert.doesNotMatch(content, /Workers/); assert.doesNotMatch(content, /\.workers\.handlebars/); }); @@ -207,7 +207,7 @@ test("Dev builds use dev templates, bundles, and output names", async () => { ...devTemplates.map(filename => readFile(new URL(`../template/${filename}`, import.meta.url), "utf8")), ]); - for (const output of ["sgmodule", "plugin", "snippet", "stoverride", "boxjs.json"]) { + for (const output of ["sgmodule", "lpx", "snippet", "stoverride", "boxjs.json"]) { assert.ok(config.includes(`iRingo.WeatherKit.dev.${output}`), output); } for (const template of devTemplates) { @@ -221,7 +221,7 @@ test("Dev builds use dev templates, bundles, and output names", async () => { assert.doesNotMatch(rollup, /\.\/dist\/(?:request|response)\.bundle\.js/); assert.match(sgmoduleTools, /\.\/dist\/response\.dev\.bundle\.js/); - for (const output of ["request.dev.bundle.js", "response.dev.bundle.js", "iRingo.WeatherKit.dev.sgmodule", "iRingo.WeatherKit.dev.plugin", "iRingo.WeatherKit.dev.snippet", "iRingo.WeatherKit.dev.stoverride"]) { + for (const output of ["request.dev.bundle.js", "response.dev.bundle.js", "iRingo.WeatherKit.dev.sgmodule", "iRingo.WeatherKit.dev.lpx", "iRingo.WeatherKit.dev.snippet", "iRingo.WeatherKit.dev.stoverride"]) { assert.ok(deployWorkflow.includes(`dist/${output}`), output); } for (const [index, template] of templates.entries()) { @@ -268,7 +268,7 @@ test("WeatherAlert provider settings expose web and user API choices", async () ]); assert.match(full, /key: "WeatherAlerts\.Provider"[\s\S]*defaultValue: "QWeatherWeb"[\s\S]*key: "WeatherKit"[\s\S]*key: "QWeatherWeb"[\s\S]*key: "QWeather"[\s\S]*key: "ColorfulClouds"/); - assert.match(full, /export const weatherAlerts = \[weatherAlertsProvider\]/); + assert.match(full, /export const weatherAlerts = \[weatherAlertsProvider, weatherAlertsLanguage\]/); assert.match(full, /export const airQuality = \[airQualityCurrentPollutantsProvider\]/); assert.match(full, /export const dataSets: Arg\[\] = \[[\s\S]*defaultValue: \["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"\]/); const releaseDataSets = full.slice(full.indexOf("export const dataSets:"), full.indexOf("export const dataSetsFull:")); @@ -285,7 +285,10 @@ test("WeatherAlert provider settings expose web and user API choices", async () assert.match(releaseDataSets, new RegExp(`key: "${key}", label: "${label}"`)); } assert.doesNotMatch(releaseDataSets, /locationInfo|trendComparison|weatherChange/); - assert.match(full, /export const dataSetsFull: Arg\[\] = \[[\s\S]*defaultValue: \["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "news", "weatherAlerts", "weatherChange", "trendComparison", "locationInfo"\][\s\S]*key: "weatherChange", label: "天气变化"[\s\S]*key: "trendComparison", label: "历史对比"/); + assert.match( + full, + /export const dataSetsFull: Arg\[\] = \[[\s\S]*defaultValue: \["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "news", "weatherAlerts", "weatherChange", "trendComparison", "locationInfo"\][\s\S]*key: "weatherChange", label: "天气变化"[\s\S]*key: "trendComparison", label: "历史对比"/, + ); assert.doesNotMatch(full, /export const dataSetsFull: Arg\[\] = \[[\s\S]*\.\.\.dataSets\[0\]/); assert.match(full, /key: "Weather\.Replace"[\s\S]*defaultValue: "CN"[\s\S]*type: "string"/); assert.match(full, /args: \[\.\.\.dataSetsFull/); @@ -294,16 +297,22 @@ test("WeatherAlert provider settings expose web and user API choices", async () assert.match(release, /args: \[\.\.\.dataSets,[^\]]*\.\.\.weatherAlerts,[^\]]*\.\.\.airQuality/); assert.match(dev, /import \{[^}]*airQualityFull[^}]*dataSetsFull[^}]*weatherFull[^}]*\} from "\.\/arguments-builder\.full\.config"/); assert.match(dev, /args: \[\.\.\.dataSetsFull, \.\.\.weatherFull, \.\.\.weatherAlerts, \.\.\.nextHourFull, \.\.\.airQualityFull, \.\.\.calculateFull, \.\.\.api, \.\.\.storage, \.\.\.logLevel\]/); - assert.match(database, /WeatherAlerts: \{ Provider: "QWeatherWeb" \}/); + assert.match(database, /WeatherAlerts: \{ Provider: "QWeatherWeb", Language: "Auto" \}/); assert.match(types, /WeatherAlerts\?: \{[\s\S]*Provider\?: "WeatherKit" \| "QWeatherWeb" \| "QWeather" \| "ColorfulClouds"/); assert.match(boxjs, /@iRingo\.WeatherKit\.Settings\.WeatherAlerts\.Provider[\s\S]*"val":\s*"QWeatherWeb"[\s\S]*"key":\s*"WeatherKit"/); const boxjsSettings = JSON.parse(boxjs); + const alertLanguage = boxjsSettings.find(({ id }) => id === "@iRingo.WeatherKit.Settings.WeatherAlerts.Language"); + assert.equal(alertLanguage.val, "Auto"); + assert.deepEqual(alertLanguage.items.map(({ key }) => key), ["Auto", "zh-Hans", "en"]); const dataSetsSetting = boxjsSettings.find(({ id }) => id === "@iRingo.WeatherKit.Settings.DataSets"); const weatherReplaceSetting = boxjsSettings.find(({ id }) => id === "@iRingo.WeatherKit.Settings.Weather.Replace"); assert.equal(dataSetsSetting.desc, "选择需要解析并处理的数据集;未选择的数据槽始终保持原样透传,不作修改。"); assert.equal(weatherReplaceSetting.type, "text"); assert.equal(weatherReplaceSetting.val, "CN"); - assert.equal(boxjsSettings.some(({ id }) => id === "@iRingo.WeatherKit.Settings.Storage"), false); + assert.equal( + boxjsSettings.some(({ id }) => id === "@iRingo.WeatherKit.Settings.Storage"), + false, + ); assert.equal( boxjsSettings.some(({ type, val }) => type === "text" && Array.isArray(val)), false, diff --git a/tests/surgeProviderRuntime.test.mjs b/tests/surgeProviderRuntime.test.mjs new file mode 100644 index 00000000..377c2902 --- /dev/null +++ b/tests/surgeProviderRuntime.test.mjs @@ -0,0 +1,37 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +globalThis.$environment = { "surge-version": "fixture" }; +const [{ default: fetchProvider }, { hasNativeProviderRuntime }] = await Promise.all([import("../src/function/fetchProvider.mjs"), import("../src/function/nativeProviderRuntime.mjs")]); +for (const milliseconds of [250, 499, 500, 501, 999, 1499, 5000]) + test(`Surge converts ${milliseconds} internal milliseconds to native seconds without rounding`, async () => { + globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.timeout, milliseconds / 1000); + callback(null, { status: 200, headers: {} }, "{}"); + }, + }; + assert.equal((await fetchProvider({ url: "https://provider.example/weather", timeout: milliseconds })).status, 200); + }); +test("Surge copies supported HTTP fields and caller headers without passing Loon fields", async () => { + const request = { url: "https://provider.example/weather", headers: { HOST: "old", "Content-Length": "1", Authorization: "fixture" }, node: "Loon-only", policy: "DIRECT", "auto-cookie": false }; + globalThis.$httpClient = { + get(outgoing, callback) { + assert.deepEqual(outgoing.headers, { Authorization: "fixture" }); + assert.equal(outgoing.policy, "DIRECT"); + assert.equal(outgoing.node, undefined); + assert.equal(outgoing["auto-cookie"], false); + callback(null, { status: 304, headers: {} }); + }, + }; + const actual = await fetchProvider(request); + assert.equal(actual.body, ""); + assert.equal(request.headers.HOST, "old"); +}); + +test("unrecognized clients use the shared HTTP adapter", () => { + const original = globalThis.$environment; + delete globalThis.$environment; + assert.equal(hasNativeProviderRuntime(), false); + globalThis.$environment = original; +}); diff --git a/tests/upstreamIntent.test.mjs b/tests/upstreamIntent.test.mjs new file mode 100644 index 00000000..16bf04e9 --- /dev/null +++ b/tests/upstreamIntent.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { readLoonRules } from "../scripts/verify-loon-rewrites.mjs"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$httpClient = { + get: () => { + throw new Error("Unexpected real provider request"); + }, +}; +const [{ Response }, { Response: ResponseDev }, { default: AirQuality }, { default: ColorfulClouds }, { default: QWeather }, { default: database }, { qs }] = await Promise.all([ + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/class/AirQuality.mjs"), + import("../src/class/ColorfulClouds.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/function/database.mjs"), + import("@nsnanocat/util/polyfill/qs.mjs"), +]); +const baseline = JSON.parse(await readFile(new URL("./fixtures/upstream-intent.json", import.meta.url))); +const metadata = () => ({ providerName: "Apple fixture", temporarilyUnavailable: false }); +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 product = () => ({ metadata: metadata(), index: 42, categoryIndex: 1, scale: "EPA_NowCast", previousDayComparison: "SIMILAR", pollutants: pollutants() }); +const settings = values => (globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI", ...values })); +const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=airQuality", method: "GET" }; +async function processAQ(processResponse, value) { + const result = await processResponse(request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ airQuality: value }) }); + return JSON.parse(result.body).airQuality; +} + +test("original defaults keep target selection, source selection and comparison controls", () => { + const s = database.WeatherKit.Settings; + assert.deepEqual(s.Weather, baseline.weather); + assert.deepEqual(s.AirQuality.Current.Index, baseline.airQualityIndex); + assert.deepEqual(s.AirQuality.Comparison, baseline.airQualityComparison); + assert.equal(s.NextHour.Provider, baseline.originalArgumentDefaults["NextHour.Provider"]); + assert.equal(s.WeatherAlerts.Provider, baseline.originalArgumentDefaults["WeatherAlerts.Provider"]); + for (const key of ["RequestCache", "Region", "ProviderFallback"]) assert.equal(Object.hasOwn(s, key), false); +}); + +test("native availability unlocks the same original capability set and preserves new Apple features", async () => { + const rules = readLoonRules(await readFile(new URL("../template/loon.handlebars", import.meta.url), "utf8")); + for (const apple of [[], ["weatherMaps", "currentWeather", "futureCapability", "currentWeather"]]) { + assert.deepEqual(rules.mergeAvailability(apple), [...new Set([...apple, ...baseline.capabilities])]); + } +}); + +for (const [name, processResponse] of [ + ["release", Response], + ["development", ResponseDev], +]) { + test(`${name}: selecting an algorithm does not override the original default target list`, async t => { + settings({}); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { + throw new Error("No missing pollutants"); + }); + const value = await processAQ(processResponse, product()); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.doesNotMatch(value.metadata.providerName, /指数:/); + }); + + test(`${name}: an explicit replacement target applies the selected algorithm`, async () => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast" }); + const value = await processAQ(processResponse, product()); + assert.equal(value.scale, "CA.AQHI"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.match(value.metadata.providerName, /指数:iRingo/); + }); + + test(`${name}: partial pollutant completion respects an empty or nonmatching target list`, async t => { + let calls = 0; + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { + calls++; + return { metadata: { providerName: "Supplement", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "HJ6332012", pollutants: pollutants() }; + }); + for (const target of [[], ["HJ6332012"]]) { + settings({ "AirQuality.Current.Index.Replace": target }); + const input = product(); + input.pollutants = input.pollutants.slice(0, 1); + const value = await processAQ(processResponse, input); + assert.equal(value.pollutants.length, 6); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.match(value.metadata.providerName, /污染物:Supplement/); + assert.doesNotMatch(value.metadata.providerName, /指数:/); + } + assert.equal(calls, 2); + }); + + test(`${name}: None preserves the Apple index after a valid pollutant supplement`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Calculate.Algorithm": "None", "AirQuality.Comparison.ReplaceWhenCurrentChange": true }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ metadata: { providerName: "Supplement", temporarilyUnavailable: false }, pollutants: pollutants() })); + const input = product(); + input.pollutants = input.pollutants.slice(0, 1); + const value = await processAQ(processResponse, input); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.equal(value.pollutants.length, 6); + }); + + test(`${name}: the original comparison switch keeps an existing trend without extra queries`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Comparison.ReplaceWhenCurrentChange": false }); + t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", () => { + throw new Error("An existing comparison must be kept"); + }); + const value = await processAQ(processResponse, product()); + assert.equal(value.scale, "CA.AQHI"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.doesNotMatch(value.metadata.providerName, /对比昨日/); + }); + + test(`${name}: explicit comparison replacement honors the selected independent source`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Comparison.ReplaceWhenCurrentChange": true, "AirQuality.Comparison.Yesterday.IndexProvider": "ColorfulCloudsUS" }); + let currentCalls = 0, + yesterdayCalls = 0; + t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", async usa => { + assert.equal(usa, true); + yesterdayCalls++; + return { metadata: { providerName: "Yesterday fixture", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "EPA_NowCast" }; + }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async usa => { + assert.equal(usa, true); + currentCalls++; + return { metadata: { providerName: "Current fixture", temporarilyUnavailable: false }, index: 20, categoryIndex: 1, scale: "EPA_NowCast" }; + }); + const value = await processAQ(processResponse, product()); + assert.equal(currentCalls, 1); + assert.equal(yesterdayCalls, 1); + assert.equal(value.previousDayComparison, AirQuality.CompareCategoryIndexes(1, 4)); + assert.match(value.metadata.providerName, /对比昨日/); + }); + + test(`${name}: a failed index source does not discard a successful pollutant supplement`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Current.Index.Provider": "QWeather" }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ metadata: { providerName: "Supplement", temporarilyUnavailable: false }, pollutants: pollutants() })); + t.mock.method(QWeather.prototype, "CurrentAirQuality", async () => { + throw new Error("Fixture failure"); + }); + const input = product(); + input.pollutants = input.pollutants.slice(0, 1); + const value = await processAQ(processResponse, input); + assert.equal(value.pollutants.length, 6); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + }); +} diff --git a/tests/weatherAlertExpiry.test.mjs b/tests/weatherAlertExpiry.test.mjs new file mode 100644 index 00000000..52d8955d --- /dev/null +++ b/tests/weatherAlertExpiry.test.mjs @@ -0,0 +1,133 @@ +// Synthetic public protocol fixtures. Time zones are changed only in child processes. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => { throw new Error("Shared storage writes are forbidden in this fixture"); } }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; +const [{ default: WeatherAlerts }, { activeProviderAlerts }, { Response }, { default: QWeather }, { default: WeatherKit2 }, { Console }] = await Promise.all([ + import("../src/class/WeatherAlerts.mjs"), + import("../src/function/providerRecovery.mjs"), + import("../src/process/Response.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; + +const legacy = "9999-12-31T23:59:59Z"; +const safe = "9999-12-31T00:00:00Z"; +const unknown = [["missing", undefined], ["null", null], ["legacy", legacy], ["safe", safe]]; +const genuine = [["future", "2099-06-01T01:02:03.456+08:00"], ["expired", "2000-06-01T01:02:03.456+08:00"]]; +const context = { identifier: "fixture-101000001", language: "en-GB", countryCode: "CN", attributionUrl: "https://fixture.example/alert" }; +const sourceAlert = fields => ({ description: "Fixture wind warning", eventName: "Wind warning", issuedTime: "2026-11-14T04:09:00Z", reportedAt: "2026-11-14T04:09:00Z", message: "Original notice", standard: "Definition", guidelines: ["Original guidance"], severity: "moderate", source: "Fixture issuer", ...fields }); +const build = fields => WeatherAlerts.Build({ areaName: "Fixture", source: "Fixture issuer", alerts: [sourceAlert(fields)] }, context)[0]; + +for (const [label, value] of unknown) { + test(`REST unknown expiry (${label}) uses the safe string and cannot invent an event end`, () => { + const input = { expireTime: value }; + const before = structuredClone(input); + const row = build(input); + assert.equal(row.expireTime, safe); + assert.equal(typeof row.expireTime, "string"); + assert.equal(Object.hasOwn(row, "eventEndTime"), false); + assert.deepEqual(input, before); + }); +} +for (const value of [legacy, safe]) { + test(`unknown explicit event end is omitted (${value})`, () => { + const row = build({ expireTime: value, eventEndTime: value }); + assert.equal(row.expireTime, safe); + assert.equal(Object.hasOwn(row, "eventEndTime"), false); + }); +} +for (const [label, value] of genuine) { + test(`REST retains the exact ${label} source expiry, including its offset and milliseconds`, () => { + const row = build({ expireTime: value }); + assert.equal(row.expireTime, value); + assert.equal(row.eventEndTime, value); + assert.equal(row.issuedTime, "2026-11-14T04:09:00Z"); + }); +} +test("a genuine event end remains distinct from an unknown notice expiry", () => { + const end = "2099-06-01T02:03:04.567+08:00"; + const row = build({ expireTime: legacy, eventEndTime: end }); + assert.equal(row.expireTime, safe); + assert.equal(row.eventEndTime, end); +}); +test("both sentinels are unknown during filtering, while a genuine end still determines availability", () => { + const afterSafeSentinel = Date.parse("9999-12-31T12:00:00Z"); + for (const value of [legacy, safe]) { + assert.equal(activeProviderAlerts([{ expireTime: value }], afterSafeSentinel).length, 1); + assert.equal(activeProviderAlerts([{ eventEndTime: value }], afterSafeSentinel).length, 1); + assert.equal(activeProviderAlerts([{ expireTime: value, eventEndTime: "2000-01-01T00:00:00Z" }], Date.parse("2026-11-14T00:00:00Z")).length, 0); + } + const future = { expireTime: genuine[0][1] }, expired = { expireTime: genuine[1][1] }; + assert.deepEqual(activeProviderAlerts([future, expired], Date.parse("2026-11-14T00:00:00Z")), [future]); +}); + +for (const zone of ["UTC", "Asia/Taipei", "Pacific/Kiritimati", "Etc/GMT+12"]) { + test(`${zone}: the existing Apple local-year guard clears unknown expiry and retains real times`, () => { + const script = ` + import assert from 'node:assert/strict'; + import WeatherAlerts from './src/class/WeatherAlerts.mjs'; + const values = [undefined, null, '${legacy}', '${safe}']; + const make = value => WeatherAlerts.Build({areaName:'Fixture', source:'Fixture', alerts:[{description:'Wind warning', issuedTime:'2026-11-14T04:09:00Z', reportedAt:'2026-11-14T04:09:00Z', message:'Original notice', guidelines:['Original guidance'], severity:'moderate', expireTime:value}]}, ${JSON.stringify(context)})[0]; + for (const value of values) { + const row = make(value); + const parsed = new Date(row.expireTime); + assert.equal(parsed.getFullYear(), 9999); + // Contract used by the actual cached Apple consumer, independent of our producer. + const consumedExpiry = parsed.getFullYear() === 9999 ? null : parsed; + assert.equal(consumedExpiry, null); + assert.equal(Object.hasOwn(row, 'eventEndTime'), false); + assert.equal(row.messages[0].text, 'Original notice'); + } + for (const value of ${JSON.stringify(genuine.map(([, value]) => value))}) { + const row = make(value); + assert.equal(row.expireTime, value); + const parsed = new Date(row.expireTime); + assert.notEqual(parsed.getFullYear(), 9999); + assert.equal(parsed.getTime(), Date.parse(value)); + } + console.log('EXPIRY_CHECK=' + JSON.stringify({zone:process.env.TZ, unknown:values.length, genuine:2})); + `; + const before = process.env.TZ; + const stdout = execFileSync(process.execPath, ["--input-type=module", "-e", script], { cwd: new URL("..", import.meta.url), encoding: "utf8", env: { ...process.env, TZ: zone } }); + assert.equal(process.env.TZ, before); + const result = JSON.parse(stdout.split("\n").find(line => line.startsWith("EXPIRY_CHECK=")).slice("EXPIRY_CHECK=".length)); + assert.deepEqual(result, { zone, unknown: 4, genuine: 2 }); + }); +} + +const realAppleTimes = { issuedTime: 1_794_628_800, effectiveTime: 1_794_629_000, eventOnsetTime: 1_794_629_200, eventEndTime: 1_794_632_000, expireTime: 1_794_633_000 }; +const nativeWarning = fields => ({ id: "00112233-4455-6677-8899-aabbccddeeff", description: "Fixture wind warning", phenomenon: "Wind", severity: "moderate", responses: [], ...fields }); +for (const version of ["v2", "v3"]) { + for (const populated of [false, true]) { + test(`${version}: unknown source times cannot replace genuine Apple times or manufacture end times (existing=${populated})`, async t => { + let supplied; + t.mock.method(QWeather.prototype, "WeatherAlert", async () => structuredClone(supplied)); + for (const [label, value] of unknown) { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "weatherAlerts", WeatherAlerts: { Provider: "QWeather" } }; + const request = globalThis.$request = { method: "GET", url: `https://weatherkit.apple.com/api/${version}/weather/en-US/31.2/121.5?country=CN&dataSets=weatherAlerts`, headers: {} }; + const warning = nativeWarning(populated ? realAppleTimes : {}); + const body = { weatherAlerts: { metadata: { temporarilyUnavailable: false, providerName: "National Early Warning Center" }, alerts: [warning] } }; + supplied = { metadata: { temporarilyUnavailable: false, providerName: "Fixture" }, alerts: [sourceAlert({ expireTime: value, eventEndTime: value, areaName: "Source area" })] }; + const response = version === "v2" + ? { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: WeatherKit2.encode(undefined, body) } + : { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; + const output = await Response(request, response); + const actual = version === "v2" ? WeatherKit2.decode(new ByteBuffer(output.body), ["weatherAlerts"]).weatherAlerts.alerts[0] : JSON.parse(output.body).weatherAlerts.alerts[0]; + assert.equal(actual.areaName, "Source area", `${version}/${label}: actual enrichment occurred`); + if (populated) { + for (const [key, expected] of Object.entries(realAppleTimes)) assert.equal(actual[key], expected, `${version}/${label}/${key}`); + } else { + assert.ok(actual.expireTime == null || actual.expireTime === 0, `${version}/${label}: no unknown expiry installed`); + assert.ok(actual.eventEndTime == null || actual.eventEndTime === 0, `${version}/${label}: no unknown end installed`); + } + } + }); + } +} diff --git a/tests/weatherAlertIdentifiersV3.test.mjs b/tests/weatherAlertIdentifiersV3.test.mjs new file mode 100644 index 00000000..da85201c --- /dev/null +++ b/tests/weatherAlertIdentifiersV3.test.mjs @@ -0,0 +1,91 @@ +// Synthetic UUID protocol fixtures; no captured alert payloads or device data. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$httpClient = { get: () => { throw new Error("Unexpected provider request"); } }; +const [{ default: ResponseJSONV3, adaptV3ClientProduct, adaptV3ProviderProduct, validateWeatherJSONV3 }, { Response }, { Response: ResponseDev }, { qs }] = await Promise.all([ + import("../src/process/ResponseJSONV3.mjs"), import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("@nsnanocat/util/polyfill/qs.mjs"), +]); +const bytes = () => [0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255]; +const uuid = "00112233-4455-6677-8899-aabbccddeeff"; +const alerts = () => ({ metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, alerts: [{ id: { bytes: bytes() }, description: "Fixture warning", detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=fixture", token: "fixture-warning", futureField: { retained: true } }], futureCollection: true }); +const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=currentWeather,weatherAlerts", method: "GET" }; +const response = body => ({ status: 200, headers: { "Content-Type": "application/json", ETag: "apple-original", "Content-Length": "1", "X-Apple": "retain" }, body: JSON.stringify(body) }); +const settings = overrides => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", "Weather.Provider": "WeatherKit", ...overrides }); + +test("WK3 alert UUID bytes become the same identifier in native string form", () => { + const product = alerts(), original = structuredClone(product); + product.alerts.push({ id: "existing-provider-id", description: "Existing string" }); + assert.equal(adaptV3ClientProduct(product, "weatherAlerts"), true); + assert.equal(product.alerts[0].id, uuid); + assert.equal(product.alerts[1].id, "existing-provider-id"); + assert.equal(product.alerts[0].description, original.alerts[0].description); + assert.equal(product.alerts[0].detailsUrl, original.alerts[0].detailsUrl); + assert.equal(product.alerts[0].token, original.alerts[0].token); + assert.deepEqual(product.alerts[0].futureField, original.alerts[0].futureField); + assert.equal(adaptV3ClientProduct(product, "weatherAlerts"), false); + validateWeatherJSONV3(product, "weatherAlerts"); +}); + +test("UUID conversion is limited to known alert IDs and accepts all sixteen octets", () => { + const other = alerts(); + assert.equal(adaptV3ClientProduct(other, "currentWeather"), false); + assert.deepEqual(other.alerts[0].id, { bytes: bytes() }); + const product = alerts();product.alerts[0].id = { bytes: Array(16).fill(0) }; + assert.equal(adaptV3ProviderProduct(product, "weatherAlerts").alerts[0].id, "00000000-0000-0000-0000-000000000000"); +}); + +test("malformed or unknown identifier objects are not coerced into fake strings", () => { + for (const id of [{ bytes: bytes().slice(0, 15) }, { bytes: [...bytes().slice(0, 15), 256] }, { bytes: [...bytes().slice(0, 15), -1] }, { bytes: [...bytes().slice(0, 15), 1.5] }, { bytes: bytes().map(String) }, { unknown: "future-id" }, 123]) { + const product = alerts();product.alerts[0].id = id; + assert.equal(adaptV3ClientProduct(product, "weatherAlerts"), false); + assert.deepEqual(product.alerts[0].id, id); + assert.throws(() => validateWeatherJSONV3(product, "weatherAlerts"), /alert identifier/); + } +}); + +for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { + for (const overrides of [{ DataSets: "" }, { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "WeatherKit" }]) { + test(`${name}: retained Apple alerts are readable even when source replacement is disabled: ${JSON.stringify(overrides)}`, async () => { + settings(overrides); + const body = { weatherAlerts: alerts(), currentWeather: { metadata: {}, conditionCode: "CLEAR", temperature: 18 }, futureProduct: { alerts: [{ id: { bytes: bytes() } }] } }; + const original = structuredClone(body); + const result = await processResponse(request, response(body)); + const actual = JSON.parse(result.body); + assert.equal(actual.weatherAlerts.alerts[0].id, uuid); + assert.equal(actual.weatherAlerts.alerts[0].description, "Fixture warning"); + assert.deepEqual(actual.futureProduct, original.futureProduct); + assert.deepEqual(actual.currentWeather, original.currentWeather); + assert.deepEqual(body, original); + assert.equal(result.headers.ETag, undefined); + assert.equal(result.headers["Cache-Control"], "no-store"); + }); + } +} + +test("failed warning replacement keeps normalized Apple IDs and the healthy product", async () => { + settings({ DataSets: "weatherAlerts,currentWeather", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "QWeatherWeb" }); + const body = { weatherAlerts: alerts(), currentWeather: { metadata: {}, conditionCode: "CLEAR", temperature: 18 } }; + const result = await ResponseJSONV3(request, response(body), { weatherAlerts: async () => { throw new Error("fixture source failure"); }, currentWeather: async input => ({ ...input, temperature: 20 }) }); + const actual = JSON.parse(result.body); + assert.equal(actual.weatherAlerts.alerts[0].id, uuid); + assert.equal(actual.weatherAlerts.alerts[0].description, "Fixture warning"); + assert.equal(actual.currentWeather.temperature, 20); +}); + +test("an invalid new provider ID cannot replace readable Apple warning data", async () => { + settings({ DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeatherWeb" }); + const result = await ResponseJSONV3(request, response({ weatherAlerts: alerts() }), { weatherAlerts: async input => ({ ...input, alerts: [{ ...input.alerts[0], id: { unknown: "provider-id" } }] }) }); + assert.equal(JSON.parse(result.body).weatherAlerts.alerts[0].id, uuid); +}); + +test("already native alert strings retain the original body and cache validators", async () => { + settings({ DataSets: "" }); + const product = alerts();product.alerts[0].id = uuid; + const original = response({ weatherAlerts: product });original.body = " " + original.body + "\n"; + const result = await ResponseJSONV3(request, original, {}); + assert.strictEqual(result, original); + assert.equal(result.headers.ETag, "apple-original"); +}); diff --git a/tests/weatherAlertLanguage.test.mjs b/tests/weatherAlertLanguage.test.mjs new file mode 100644 index 00000000..5141fe92 --- /dev/null +++ b/tests/weatherAlertLanguage.test.mjs @@ -0,0 +1,183 @@ +// Synthetic source responses test language routing; no translation service or live account is used. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$httpClient = { get() { throw new Error("Unexpected network request"); } }; +const [{ weatherAlertLanguage, weatherAlertParameters }, { default: createProviderEnvironment }, { GetWeatherAlerts }, { default: ProviderTransport }, { default: database }, { Console }] = await Promise.all([ + import("../src/function/weatherAlertLanguage.mjs"), + import("../src/function/createProviderEnvironment.mjs"), + import("../src/function/getWeatherAlerts.mjs"), + import("../src/class/ProviderTransport.mjs"), + import("../src/function/database.mjs"), + import("@nsnanocat/util"), +]); +const [{ Response }, { Response: ResponseDev }, { default: QWeather }] = await Promise.all([import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("../src/class/QWeather.mjs")]); +Console.logLevel = "OFF"; +const parameters = { latitude: 31.2, longitude: 121.5, country: "CN", version: "v1", language: "en-GB", weatherKitLanguage: "en-GB" }; +const settings = (language, provider = "QWeatherWeb") => ({ WeatherAlerts: { Provider: provider, ...(language === undefined ? {} : { Language: language }) }, API: {} }); +const html = english => `

Fixture

${english ? "Fixture Observatory issues yellow warning for heavy fog" : "测试市气象台发布大雾黄色预警"}

Issue date: 2026-11-14T00:24+08:00

${english ? "Original source fog notice." : "来源原文:请防范大雾。"}

`; + +test("missing, Auto and unsupported saved language settings retain the original parameter object", () => { + for (const value of [undefined, "Auto", "auto", "", null, false, {}, ["en"], "fr", "zh-Hant", ""]) { + assert.equal(weatherAlertLanguage(settings(value)), undefined); + assert.equal(weatherAlertParameters(parameters, settings(value)), parameters); + } + assert.equal(database.WeatherKit.Settings.WeatherAlerts.Language, "Auto"); +}); + +test("supported language aliases only override warning locale fields", () => { + for (const [value, expected] of [["zh-Hans", "zh-Hans"], ["zh_CN", "zh-Hans"], ["zh-CN", "zh-Hans"], ["en", "en"], ["en-US", "en"], ["en_GB", "en"]]) { + const actual = weatherAlertParameters(parameters, settings(value)); + assert.deepEqual(actual, { ...parameters, language: expected, weatherKitLanguage: expected }); + assert.notEqual(actual, parameters); + assert.equal(parameters.language, "en-GB"); + } +}); + +test("Auto shares existing lazy provider instances", () => { + const env = createProviderEnvironment(parameters, settings("Auto"), new ProviderTransport()); + assert.equal(env.weatherAlertsQWeather, env.qWeather); + assert.equal(env.weatherAlertsColorfulClouds, env.colorfulClouds); +}); + +test("explicit warning language is isolated from other weather and AQ provider instances", () => { + const env = createProviderEnvironment(parameters, settings("zh-Hans"), new ProviderTransport()); + assert.notEqual(env.weatherAlertsQWeather, env.qWeather); + assert.notEqual(env.weatherAlertsColorfulClouds, env.colorfulClouds); + assert.equal(env.qWeather.language, "en"); + assert.equal(env.colorfulClouds.language, "en_GB"); + assert.equal(env.weatherAlertsQWeather.language, "zh-hans"); + assert.equal(env.weatherAlertsColorfulClouds.language, "zh_CN"); + assert.equal(env.qWeather.weatherKitLanguage, "en-GB"); + assert.equal(env.weatherAlertsQWeather.weatherKitLanguage, "zh-Hans"); +}); + +for (const [preference, english] of [["zh-Hans", false], ["en", true]]) { + test(`${preference}: page details request a native source locale and preserve original text and attribution`, async t => { + const seen = []; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + seen.push(request); + callback(null, { status: 200, headers: { "Content-Type": "text/html;charset=UTF-8" } }, html(english)); + }); + const headers = { "Accept-Language": english ? "zh-CN" : "en-GB", "If-None-Match": "original-apple-validator" }; + const originalHeaders = structuredClone(headers); + const result = await GetWeatherAlerts("fixture-101010100", parameters, settings(preference), headers, new ProviderTransport()); + assert.equal(seen.length, 1); + assert.equal(new URL(seen[0].url).pathname.startsWith("/en/"), english); + assert.equal(seen[0].headers["Accept-Language"], preference); + assert.deepEqual(headers, originalHeaders); + assert.equal(result.status, 200); + assert.equal(result.headers["Cache-Control"], "no-store"); + const rows = JSON.parse(result.body); + assert.equal(rows.length, 1); + assert.equal(rows[0].messages[0].text, english ? "Original source fog notice." : "来源原文:请防范大雾。"); + assert.equal(rows[0].messages[0].language, "und", "requested language does not certify returned warning text"); + assert.equal(new URL(rows[0].attributionURL).hostname, "www.qweather.com"); + assert.ok(rows[0].source); + }); +} + +test("different language source URLs stay distinct and a shared transport reuses no completed alert data", async t => { + const URLs = []; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + URLs.push(request.url); + callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html(new URL(request.url).pathname.startsWith("/en/"))); + }); + const transport = new ProviderTransport(); + const zh = await GetWeatherAlerts("fixture-101010100", parameters, settings("zh-Hans"), {}, transport); + const en = await GetWeatherAlerts("fixture-101010100", parameters, settings("en"), {}, transport); + assert.equal(URLs.length, 2); + assert.notEqual(URLs[0], URLs[1]); + assert.notEqual(JSON.parse(zh.body)[0].messages[0].text, JSON.parse(en.body)[0].messages[0].text); +}); + +test("old settings and explicit Auto retain exactly the same detail response and request language", async t => { + const seen = []; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + seen.push(request); + callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html(true)); + }); + const a = await GetWeatherAlerts("fixture-101010100", parameters, settings(undefined), {}, new ProviderTransport()); + const b = await GetWeatherAlerts("fixture-101010100", parameters, settings("Auto"), {}, new ProviderTransport()); + assert.deepEqual(a, b); + assert.equal(a.headers["Cache-Control"], "max-age=0"); + assert.equal(JSON.parse(a.body)[0].messages[0].language, "en-GB"); + assert.equal(seen[0].url, seen[1].url); +}); + +test("an API returning another language keeps the source original instead of inventing English", async t => { + let requested; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + requested = request; + callback(null, { status: 200, headers: {} }, JSON.stringify({ metadata: {}, alerts: [{ id: "source-original", senderName: "原签发单位", issuedTime: "2026-11-14T00:00:00Z", effectiveTime: "2026-11-14T00:00:00Z", expireTime: "2099-01-01T00:00:00Z", headline: "来源中文预警", description: "该来源没有提供英文,保留此原文。", severity: "moderate", responseTypes: [] }] })); + }); + const result = await GetWeatherAlerts("31.2,121.5", parameters, settings("en", "QWeather"), {}, new ProviderTransport()); + assert.equal(new URL(requested.url).searchParams.get("lang"), "en"); + const row = JSON.parse(result.body)[0]; + assert.equal(row.messages[0].text, "该来源没有提供英文,保留此原文。"); + assert.equal(row.messages[0].language, "und"); + assert.equal(row.source, "原签发单位"); +}); + +for (const [name, status, body] of [["HTTP failure", 403, "blocked"], ["missing fields", 200, "unrecognized"], ["empty source body", 200, ""]]) { + test(`${name}: preferred language never manufactures an available response`, async t => { + let calls = 0; + t.mock.method(globalThis.$httpClient, "get", (_, callback) => { + calls++; + callback(null, { status, headers: { "Content-Type": "text/html" } }, body); + }); + assert.equal(await GetWeatherAlerts("fixture-101010100", parameters, settings("en"), {}, new ProviderTransport()), undefined); + assert.equal(calls, 1); + }); +} + +test("WeatherKit-selected coordinate details retain the Apple path for every preference", async () => { + for (const value of ["Auto", "zh-Hans", "en"]) assert.equal(await GetWeatherAlerts("31.2,121.5", parameters, settings(value, "WeatherKit"), {}, new ProviderTransport()), undefined); +}); + +test("missing coordinate parameters fall back without throwing or contacting a provider", async () => { + for (const value of [undefined, null, {}]) { + for (const preference of ["Auto", "en"]) assert.equal(await GetWeatherAlerts("31.2,121.5", value, settings(preference), undefined, new ProviderTransport()), undefined); + } +}); + +test("Caiyun language requests retain an untranslated original when that is what the source supplies", async t => { + let outgoing; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + outgoing = request; + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", result: { realtime: { status: "ok" }, alert: { status: "ok", content: [{ code: "0501", description: "原始中文预警,请保留原意。", pubtimestamp: 1794615840, source: "测试签发单位", regionId: "fixture-region", city: "测试区域" }] } } })); + }); + const result = await GetWeatherAlerts("31.2,121.5", parameters, settings("en", "ColorfulClouds"), {}, new ProviderTransport()); + assert.equal(new URL(outgoing.url).searchParams.get("lang"), "en_US"); + const row = JSON.parse(result.body)[0]; + assert.ok(row.messages.some(message => message.text === "原始中文预警,请保留原意。")); + assert.ok(row.messages.every(message => message.language === "und")); +}); + +for (const [name, process] of [["release", Response], ["development", ResponseDev]]) { + test(`${name}: WK3 weather and alerts use independent locales in the same response`, async t => { + const seen = []; + t.mock.method(QWeather.prototype, "WeatherNow", async function () { + seen.push(["weather", this.language]); + return { metadata: { providerName: "Fixture Weather", temporarilyUnavailable: false }, temperature: 22, conditionCode: "CLEAR" }; + }); + t.mock.method(QWeather.prototype, "WeatherAlertWeb", async function () { + seen.push(["alerts", this.weatherKitLanguage]); + return { metadata: { temporarilyUnavailable: false }, alerts: [], detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=fixture-101010100&lang=zh-Hans" }; + }); + const previous = globalThis.$argument; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather,weatherAlerts", Weather: { Provider: "QWeather", Replace: ["CN"] }, WeatherAlerts: { Provider: "QWeatherWeb", Language: "zh-Hans" } }; + try { + const response = await process({ method: "GET", url: "https://weatherkit.apple.com/api/v3/weather/en-GB/31.2/121.5?country=CN&dataSets=currentWeather,weatherAlerts", headers: {} }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLEAR" }, weatherAlerts: { metadata: { providerName: "National Early Warning Center", temporarilyUnavailable: true }, alerts: [] }, futureProduct: { keep: true } }) }); + assert.deepEqual(seen.sort(), [["alerts", "zh-Hans"], ["weather", "en"]]); + const body = JSON.parse(response.body); + assert.equal(body.currentWeather.temperature, 22); + assert.equal(body.weatherAlerts.metadata.temporarilyUnavailable, true); + assert.deepEqual(body.futureProduct, { keep: true }); + } finally { + globalThis.$argument = previous; + } + }); +} diff --git a/tests/weatherBoundaryRegressions.test.mjs b/tests/weatherBoundaryRegressions.test.mjs new file mode 100644 index 00000000..29d6af34 --- /dev/null +++ b/tests/weatherBoundaryRegressions.test.mjs @@ -0,0 +1,229 @@ +// All payloads are synthetic. Exercise shared release/development paths. +import assert from "node:assert/strict"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +const [{ default: ForecastNextHour }, { Response }, { Response: ResponseDev }, { Request }, { Request: RequestDev }, { default: WeatherKit2 }, { default: QWeather }, { default: ColorfulClouds }, { qs }, { Console }] = await Promise.all([ + import("../src/class/ForecastNextHour.mjs"), + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/process/Request.mjs"), + import("../src/process/Request.dev.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/class/ColorfulClouds.mjs"), + import("@nsnanocat/util/polyfill/qs.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; +const BASE = 1_800_000_000; +const minute = (index, rain, intensity = 1) => ({ startTime: BASE + index * 60, precipitationChance: rain ? 80 : 0, precipitationIntensity: rain ? intensity : 0 }); +const summary = minutes => ForecastNextHour.Summary(ForecastNextHour.Minute(minutes, "有小雨")); +const settings = overrides => (globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", "Weather.Provider": "WeatherKit", ...overrides })); +const request = (version = "v3", dataSets = "currentWeather") => (globalThis.$request = { method: "GET", url: `https://weatherkit.apple.com/api/${version}/weather/en-US/31.2/121.5?country=CN&dataSets=${dataSets}`, headers: {} }); +const headers = type => ({ "Content-Type": type, eTaG: '"apple"', "Last-Modified": "yesterday", "cOnTeNt-LeNgTh": "1", "Content-Encoding": "gzip", "Transfer-Encoding": "chunked", Digest: "old", "Cache-Control": "max-age=60", "X-Apple": "retain" }); + +test("a single minute still produces a summary and a condition", () => { + const value = summary([minute(0, true)]); + assert.equal(value.length, 1); + assert.equal(value[0].startTime, BASE); + assert.equal(value[0].endTime, 0); + assert.equal(ForecastNextHour.Condition(value)[0].forecastToken, "CONSTANT"); + assert.deepEqual(summary([]), []); +}); + +for (const rainAtEnd of [true, false]) + test(`a transition on the last minute is retained (${rainAtEnd ? "start" : "stop"})`, () => { + const values = summary([minute(0, !rainAtEnd), minute(1, !rainAtEnd), minute(2, rainAtEnd)]); + assert.deepEqual( + values.map(value => value.clear), + [rainAtEnd, !rainAtEnd], + ); + assert.equal(values[0].endTime, BASE + 120); + assert.equal(values[1].startTime, BASE + 120); + assert.equal(values[1].endTime, 0); + assert.equal(ForecastNextHour.Condition(values).length, 2); + }); + +test("the final minute contributes its probability, intensity and strongest condition", () => { + const value = summary([minute(0, true, 0.2), { ...minute(1, true, 20), precipitationChance: 100 }])[0]; + assert.equal(value.precipitationIntensity, 20); + assert.equal(value.precipitationChance, 100); + assert.equal(value.maxCondition, "HEAVY_RAIN"); +}); + +test("the existing 71-minute summary horizon excludes later samples", () => { + const value = summary(Array.from({ length: 72 }, (_, index) => minute(index, index === 71))); + assert.equal(value.length, 1); + assert.equal(value[0].clear, true); + assert.equal(value[0].endTime, 0); +}); + +for (const [name, processResponse, processRequest] of [ + ["release", Response, Request], + ["development", ResponseDev, RequestDev], +]) { + for (const product of ["forecastPeriodic", "weatherChanges"]) + test(`${name}: ${product}-only JSON still adapts known native fields without supplier work`, async () => { + settings({ DataSets: "" }); + globalThis.$httpClient = { + get() { + assert.fail("No supplier request is needed"); + }, + }; + const body = { [product]: { metadata: {}, forecasts: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 0.4, precipitationType: ["RAIN"] }] }] }, futureProduct: { conditionCode: ["UNKNOWN_FUTURE"] } }; + const input = { status: 200, headers: headers("application/json"), body: JSON.stringify(body) }; + const actual = await processResponse(request("v3", product), input); + const parsed = JSON.parse(actual.body); + assert.equal(parsed[product].forecasts[0].conditionCode, "RAIN"); + assert.equal(parsed[product].forecasts[0].precipitationAmount, 0.4); + assert.deepEqual(parsed.futureProduct, body.futureProduct); + assert.equal(actual.headers["Cache-Control"], "no-store"); + for (const key of ["eTaG", "Content-Encoding", "Transfer-Encoding", "cOnTeNt-LeNgTh"]) assert.equal(actual.headers[key], undefined); + }); + + test(`${name}: a changed WK2 body invalidates original HTTP validators and stale bodyBytes`, async t => { + settings({ "Weather.Provider": "QWeather" }); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "fixture" }, temperature: 23 })); + const bytes = WeatherKit2.encode(undefined, { + currentWeather: { + metadata: { temporarilyUnavailable: false }, + conditionCode: "CLEAR", + temperature: 18, + precipitationAmountNext1hByType: [], + precipitationAmountNext6hByType: [], + precipitationAmountNext24hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious6hByType: [], + precipitationAmountPrevious24hByType: [], + }, + }); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body: bytes, bodyBytes: bytes }; + const actual = await processResponse(request("v2"), input); + assert.equal(WeatherKit2.decode(new ByteBuffer(bytes), ["currentWeather"]).currentWeather.temperature, 18); + assert.equal(WeatherKit2.decode(new ByteBuffer(actual.body), ["currentWeather"]).currentWeather.temperature, 23); + assert.equal(actual.headers["Cache-Control"], "no-store"); + for (const key of ["eTaG", "Last-Modified", "Content-Encoding", "Transfer-Encoding", "cOnTeNt-LeNgTh", "Digest"]) assert.equal(actual.headers[key], undefined); + assert.equal(actual.headers["X-Apple"], "retain"); + assert.equal(actual.bodyBytes, undefined); + assert.equal(input.headers.eTaG, '"apple"'); + assert.strictEqual(input.body, bytes); + }); + + test(`${name}: unchanged WK2 output keeps its validators`, async () => { + settings({ DataSets: "" }); + const body = WeatherKit2.encode(undefined, {}); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body }; + assert.strictEqual((await processResponse(request("v2"), input)).body, body); + assert.equal(input.headers.eTaG, '"apple"'); + }); + + test(`${name}: a rejected WK2 product preserves the original body and validators`, async t => { + settings({ "Weather.Provider": "QWeather" }); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { temporarilyUnavailable: false }, temperature: 23, precipitationAmountNext1hByType: 42 })); + const body = WeatherKit2.encode(undefined, { currentWeather: { metadata: {}, temperature: 18 } }); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body }; + assert.strictEqual(await processResponse(request("v2"), input), input); + assert.strictEqual(input.body, body); + assert.equal(input.headers.eTaG, '"apple"'); + }); + + test(`${name}: provider-only WK2 current weather encodes absent optional precipitation vectors`, async t => { + settings({ "Weather.Provider": "QWeather" }); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { temporarilyUnavailable: false }, temperature: 23 })); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body: WeatherKit2.encode(undefined, {}) }; + const actual = await processResponse(request("v2"), input); + const product = WeatherKit2.decode(new ByteBuffer(actual.body), ["currentWeather"]).currentWeather; + assert.equal(product.temperature, 23); + assert.deepEqual(product.precipitationAmountNext1hByType, []); + assert.equal(actual.headers["Cache-Control"], "no-store"); + }); + + test(`${name}: native Apple warning IDs pass through with their conditional headers`, async () => { + settings(); + globalThis.$httpClient = { + get() { + assert.fail("Opaque Apple IDs must pass through"); + }, + }; + const input = { method: "GET", url: "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=00112233-4455-6677-8899-aabbccddeeff", headers: { "If-None-Match": '"apple"', "If-Modified-Since": "yesterday" } }; + const actual = await processRequest(input); + assert.equal(actual.$response, undefined); + assert.equal(actual.$request.headers["If-None-Match"], '"apple"'); + }); + + test(`${name}: WeatherKit-selected coordinate warning details pass through`, async () => { + settings({ "WeatherAlerts.Provider": "WeatherKit" }); + globalThis.$httpClient = { + get() { + assert.fail("WeatherKit source must pass through"); + }, + }; + const actual = await processRequest({ method: "GET", url: "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=31.2,121.5", headers: {} }); + assert.equal(actual.$response, undefined); + }); +} + +test("fractional QWeather hourly rainfall is not truncated to an integer", async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-13T00:00:00Z", hourly: [{ fxTime: "2026-11-13T01:00:00Z", temp: "18", precip: "0.4" }] })); + }, + }; + const value = await new QWeather({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture").Hourly(); + assert.equal(value.hours[0].precipitationIntensity, 0.4); + assert.equal(value.hours[0].precipitationAmount, 0.4); +}); + +test("invalid Caiyun temperature cannot replace healthy Apple current weather", async () => { + settings({ "Weather.Provider": "ColorfulClouds" }); + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", result: { realtime: { status: "ok", temperature: null, skycon: "RAIN", precipitation: { local: { intensity: 0 } }, wind: { direction: 90, speed: 2 } } } })); + }, + }; + const input = { status: 200, headers: { "Content-Type": "application/json", ETag: '"apple"' }, body: JSON.stringify({ currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLEAR" } }) }; + assert.strictEqual(await Response(request(), input), input); + const provider = new ColorfulClouds({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture"); + assert.equal((await provider.CurrentWeather()).metadata.temporarilyUnavailable, true); +}); + +for (const precipitation of [[], [null], [-1], ["bad"]]) + test(`invalid Caiyun minute measurements are unavailable: ${JSON.stringify(precipitation)}`, async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", server_time: BASE, result: { minutely: { status: "ok", precipitation_2h: precipitation, probability: [0.8, 0.8, 0.8, 0.8], description: "有小雨" } } })); + }, + }; + const provider = new ColorfulClouds({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture"); + assert.equal(await provider.Minutely(), undefined); + }); + +for (const row of [ + { fxTime: "invalid", precip: "0.2" }, + { fxTime: "2026-11-13T01:00:00Z", precip: "" }, + { fxTime: "2026-11-13T01:00:00Z", precip: "-1" }, +]) + test(`invalid QWeather minute measurements are unavailable: ${JSON.stringify(row)}`, async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-13T00:00:00Z", summary: "有小雨", minutely: [row] })); + }, + }; + assert.equal(await new QWeather({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture").Minutely(), undefined); + }); + +for (const [name, Provider] of [ + ["Caiyun", ColorfulClouds], + ["QWeather", QWeather], +]) + test(`${name} minute source retains its existing region restriction`, async () => { + globalThis.$httpClient = { + get() { + assert.fail("Unsupported regions require no supplier request"); + }, + }; + assert.equal(await new Provider({ country: "US", latitude: 40, longitude: -74, language: "en", version: "v2" }, "fixture").Minutely(), undefined); + }); diff --git a/tests/weatherKitSelectiveDecode.test.mjs b/tests/weatherKitSelectiveDecode.test.mjs index 671c099e..01f36d73 100644 --- a/tests/weatherKitSelectiveDecode.test.mjs +++ b/tests/weatherKitSelectiveDecode.test.mjs @@ -6,14 +6,13 @@ globalThis.$environment = { "surge-version": "test" }; globalThis.$persistentStore = { read: () => null, write: () => true }; globalThis.$argument = { LogLevel: "OFF", Storage: "database" }; -const [{ default: WeatherKit2 }, { FlatBufferRootProcessor }, { News, Weather }, { Response }, { Response: ResponseDev }, { Console }, { default: database }] = await Promise.all([ +const [{ default: WeatherKit2 }, { FlatBufferRootProcessor }, { News, Weather }, { Response }, { Response: ResponseDev }, { Console }] = await Promise.all([ import("../src/class/WeatherKit2.mjs"), import("@nsnanocat/flatbuffer-root"), import("@nsringo/weatherkit"), import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("@nsnanocat/util"), - import("../src/function/database.mjs"), ]); const supportedRootDataSets = Object.getOwnPropertyNames(Weather.prototype).filter(dataSet => !["constructor", "__init"].includes(dataSet)); @@ -290,7 +289,7 @@ test("response skips requested roots disabled by the user settings", async () => assert.equal(requestCount, 0); }); -test("response does not expand a partial request to all configured roots", async () => { +test("response decodes only eligible products from a partial request", async () => { const originalArgument = globalThis.$argument; const originalDecode = WeatherKit2.decode; let decodedRootNames; @@ -298,7 +297,7 @@ test("response does not expand a partial request to all configured roots", async DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], LogLevel: "OFF", Storage: "Argument", - Weather: { Replace: [] }, + Weather: { Replace: "[]" }, }; WeatherKit2.decode = (byteBuffer, rootNames) => { decodedRootNames = rootNames; @@ -306,22 +305,24 @@ test("response does not expand a partial request to all configured roots", async }; try { for (const handler of [Response, ResponseDev]) { - await handler( + const original = createWeatherRoot([0, 1]); + const output = await handler( { url: "https://weatherkit.apple.com/api/v2/weather/en-US/22.5431/114.0579?dataSets=airQuality,currentWeather", }, { - bodyBytes: createWeatherRoot([0, 1]), + bodyBytes: original, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, }, ); + assert.deepEqual(originalDecode.call(WeatherKit2, new ByteBuffer(output.body), ["currentWeather"]), originalDecode.call(WeatherKit2, new ByteBuffer(original), ["currentWeather"])); } } finally { WeatherKit2.decode = originalDecode; globalThis.$argument = originalArgument; } - assert.deepEqual(decodedRootNames, ["airQuality", "currentWeather"]); + assert.deepEqual(decodedRootNames, ["airQuality"]); }); test("response preserves requested roots when no dataSets are configured", async () => { @@ -361,7 +362,7 @@ test("response preserves requested roots when no dataSets are configured", async assert.equal(requestCount, 0); }); -test("response maps configured dataSets to FlatBuffer root names before decoding", async () => { +test("roots without an injector preserve the exact input without decoding", async () => { const originalArgument = globalThis.$argument; const originalDecode = WeatherKit2.decode; let decodedRootNames; @@ -371,22 +372,23 @@ test("response maps configured dataSets to FlatBuffer root names before decoding return originalDecode.call(WeatherKit2, byteBuffer, rootNames); }; try { - await Response( + const original = createWeatherRoot([7, 8]); + const output = await Response( { url: "https://weatherkit.apple.com/api/v2/weather/en-US/22.5431/114.0579?dataSets=weatherChange,trendComparison", }, { - bodyBytes: createWeatherRoot([7, 8]), + bodyBytes: original, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, }, ); + assert.strictEqual(output.body, original); } finally { WeatherKit2.decode = originalDecode; globalThis.$argument = originalArgument; } - assert.deepEqual(decodedRootNames, [database.WeatherKit.Configs.DataSets.weatherChange, database.WeatherKit.Configs.DataSets.trendComparison]); - assert.deepEqual(decodedRootNames, ["weatherChanges", "historicalComparisons"]); + assert.equal(decodedRootNames, undefined); }); test("response preserves the user-supplied QWeather Alert API path", async () => { @@ -657,6 +659,152 @@ test("development response preserves a disabled non-injection root as opaque dat assert.deepEqual(decoded.news, originalDecoded.news); }); +test("QWeather yesterday comparison works without an optional pollutant converter", async () => { + const [{ default: QWeather }, { default: AirQuality }] = await Promise.all([import("../src/class/QWeather.mjs"), import("../src/class/AirQuality.mjs")]); + const argument = globalThis.$argument, + grid = QWeather.GetLocationsGrid, + location = QWeather.GetLocationInfo, + yesterday = QWeather.prototype.YesterdayAirQuality, + current = QWeather.prototype.CurrentAirQuality; + let currentCalls = 0; + QWeather.GetLocationsGrid = async () => ({}); + QWeather.GetLocationInfo = () => ({ id: "fixture" }); + QWeather.prototype.YesterdayAirQuality = async () => ({ metadata: { providerName: "Fixture QWeather", temporarilyUnavailable: false }, categoryIndex: 3, index: 130, pollutants: [], scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale) }); + QWeather.prototype.CurrentAirQuality = async () => { + currentCalls++; + return { metadata: { providerName: "Fixture QWeather", temporarilyUnavailable: false }, categoryIndex: 2, index: 70 }; + }; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "airQuality", AirQuality: { Current: { Index: { Replace: "[]" } }, Comparison: { Yesterday: { IndexProvider: "QWeather" } } } }; + try { + for (const categoryIndex of [2, -1]) { + const original = WeatherKit2.encode(undefined, { + airQuality: { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + categoryIndex, + index: 70, + scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale), + pollutants: [{ pollutantType: "PM2_5", amount: 20, units: "MICROGRAMS_PER_CUBIC_METER" }], + previousDayComparison: "UNKNOWN", + }, + }); + for (const handler of [Response, ResponseDev]) { + const output = await handler({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=airQuality" }, { body: original, headers: { "Content-Type": "application/vnd.apple.flatbuffer" } }); + const result = WeatherKit2.decode(new ByteBuffer(output.body), ["airQuality"]); + assert.equal(result.airQuality.previousDayComparison, "BETTER"); + } + } + assert.equal(currentCalls, 2, "Invalid current category -1 fetches current data instead of producing a fake comparison"); + } finally { + globalThis.$argument = argument; + QWeather.GetLocationsGrid = grid; + QWeather.GetLocationInfo = location; + QWeather.prototype.YesterdayAirQuality = yesterday; + QWeather.prototype.CurrentAirQuality = current; + } +}); + +test("failed hourly replacement does not copy unused forecast fields and keeps exact Apple bytes", async () => { + const { default: QWeather } = await import("../src/class/QWeather.mjs"); + const originalArgument = globalThis.$argument, + decode = WeatherKit2.decode, + hourly = QWeather.prototype.Hourly; + let copiedFields = 0, + calls = 0; + const untouched = Object.freeze({ + forecastStart: 1, + get largeUnusedField() { + copiedFields++; + return Array(500).fill("fixture"); + }, + }); + const product = Object.freeze({ metadata: Object.freeze({ providerName: "Apple", temporarilyUnavailable: false }), hours: Object.freeze([untouched]) }); + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "forecastHourly", Weather: { Provider: "QWeather", Replace: "CN" } }; + WeatherKit2.decode = () => ({ forecastHourly: product }); + QWeather.prototype.Hourly = async () => { + calls++; + return undefined; + }; + try { + const raw = createWeatherRoot([3]); + for (const handler of [Response, ResponseDev]) { + const output = await handler({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=forecastHourly" }, { body: raw, headers: { "Content-Type": "application/vnd.apple.flatbuffer" } }); + assert.strictEqual(output.body, raw); + } + assert.equal(calls, 2); + assert.equal(copiedFields, 0); + } finally { + globalThis.$argument = originalArgument; + WeatherKit2.decode = decode; + QWeather.prototype.Hourly = hourly; + } +}); + +test("oversized binary weather stays opaque before settings, provider work or decoding", async () => { + const { MAX_WEATHER_BODY_BYTES } = await import("../src/function/weatherMemoryLimits.mjs"); + const decode = WeatherKit2.decode, + persistent = globalThis.$persistentStore; + WeatherKit2.decode = () => { + throw new Error("oversized input must not decode"); + }; + globalThis.$persistentStore = { + read: () => { + throw new Error("oversized input must not read settings"); + }, + }; + try { + const raw = new Uint8Array(MAX_WEATHER_BODY_BYTES + 1); + for (const handler of [Response, ResponseDev]) { + const original = { body: raw, headers: { "Content-Type": "application/vnd.apple.flatbuffer" } }; + const output = await handler({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=forecastHourly" }, original); + assert.strictEqual(output, original); + assert.strictEqual(output.body, raw); + } + } finally { + WeatherKit2.decode = decode; + globalThis.$persistentStore = persistent; + } +}); + +test("hourly decoding constructs one schema view per row instead of one per field", async () => { + const { HourlyForecastData } = await import("@nsringo/weatherkit"); + const hours = HourlyForecastData.prototype.hours; + let views = 0; + const raw = WeatherKit2.encode(undefined, { forecastHourly: { metadata: {}, hours: Array.from({ length: 273 }, (_, index) => ({ forecastStart: index, temperature: index, precipitationAmountByType: [] })) } }); + HourlyForecastData.prototype.hours = function (...args) { + views++; + return hours.apply(this, args); + }; + try { + const output = WeatherKit2.decode(new ByteBuffer(raw), ["forecastHourly"]); + assert.equal(output.forecastHourly.hours.length, 273); + assert.equal(output.forecastHourly.hours[272].temperature, 272); + assert.equal(views, 273); + } finally { + HourlyForecastData.prototype.hours = hours; + } +}); + +test("a forged vector length is rejected before reading rows and cannot discard another root", async () => { + const { HourlyForecastData } = await import("@nsringo/weatherkit"); + const hours = HourlyForecastData.prototype.hours, + length = HourlyForecastData.prototype.hoursLength; + let reads = 0; + HourlyForecastData.prototype.hoursLength = () => 1000000000; + HourlyForecastData.prototype.hours = () => { + reads++; + throw new Error("must not read an unbounded row"); + }; + try { + const output = WeatherKit2.decode(new ByteBuffer(createWeatherRoot([1, 3])), ["currentWeather", "forecastHourly"]); + assert.ok(output.currentWeather); + assert.equal(output.forecastHourly, undefined); + assert.equal(reads, 0); + } finally { + HourlyForecastData.prototype.hours = hours; + HourlyForecastData.prototype.hoursLength = length; + } +}); + function createWeatherRoot(presentSlots) { const builder = new Builder(256); const tables = new Map(presentSlots.map(slot => [slot, createEmptyTable(builder)])); diff --git a/tests/weatherMemoryPaths.test.mjs b/tests/weatherMemoryPaths.test.mjs new file mode 100644 index 00000000..a63c99b0 --- /dev/null +++ b/tests/weatherMemoryPaths.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import mergeForecastCopy from "../src/function/mergeForecastCopy.mjs"; + +const freeze = value => { + if (value && typeof value === "object") { + for (const item of Object.values(value)) freeze(item); + Object.freeze(value); + } + return value; +}; + +test("forecast replacement copies matching rows and preserves both nested input graphs", () => { + const vector = [{ precipitationType: "RAIN", expected: 1 }]; + const original = freeze([ + { forecastStart: 10, temperature: 20, precipitationAmountByType: vector, daytimeForecast: { temperatureMax: 24, cloudCover: 0.2 } }, + { forecastStart: 20, temperature: 21 }, + ]); + const provider = freeze([ + { forecastStart: 5, temperature: 8 }, + { forecastStart: 10, temperature: 22, daytimeForecast: { temperatureMax: 26 } }, + ]); + const merged = mergeForecastCopy(original, provider); + assert.notStrictEqual(merged, original); + assert.deepEqual(merged[0].daytimeForecast, { temperatureMax: 26, cloudCover: 0.2 }); + assert.strictEqual(merged[0].precipitationAmountByType, vector); + assert.strictEqual(merged[1], original[1]); + assert.equal(original[0].temperature, 20); + assert.deepEqual(provider[1].daytimeForecast, { temperatureMax: 26 }); +}); + +test("forecasts without overlapping timestamps keep the original array", () => { + const original = freeze([{ forecastStart: 10 }, { forecastStart: 20 }]); + assert.strictEqual(mergeForecastCopy(original, [{ forecastStart: 1 }, { forecastStart: 30 }]), original); + assert.strictEqual(mergeForecastCopy(original, []), original); +}); + +test("a forecast source that fails after one match cannot partially mutate Apple rows", () => { + const original = freeze([ + { forecastStart: 10, temperature: 20 }, + { forecastStart: 20, temperature: 21 }, + ]); + const bad = { + forecastStart: 20, + get temperature() { + throw new Error("fixture conversion failure"); + }, + }; + assert.throws(() => mergeForecastCopy(original, [{ forecastStart: 10, temperature: 99 }, bad]), /conversion failure/); + assert.deepEqual( + original.map(row => row.temperature), + [20, 21], + ); +}); diff --git a/tests/weatherPowerPaths.test.mjs b/tests/weatherPowerPaths.test.mjs new file mode 100644 index 00000000..05e94e8a --- /dev/null +++ b/tests/weatherPowerPaths.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderTransport from "../src/class/ProviderTransport.mjs"; +import createProviderEnvironment from "../src/function/createProviderEnvironment.mjs"; +test("provider constructors and credentials are accessed only on first use of that provider", () => { + let apiReads = 0; + const settings = { + get API() { + apiReads++; + return {}; + }, + }; + const runtime = new ProviderTransport(); + const env = createProviderEnvironment({ country: "CN", language: "zh-CN", latitude: 32.39, longitude: 119.499 }, settings, runtime); + assert.equal(apiReads, 0); + const first = env.qWeather; + assert.equal(apiReads, 2); + assert.strictEqual(env.qWeather, first); + assert.equal(apiReads, 2); + assert.equal(env.country, "CN"); + assert.strictEqual(env.transport, runtime); +});