From a50ddf16b11fe42059cfbb15fcc0795e715c58ab Mon Sep 17 00:00:00 2001 From: Patrick Yan Date: Thu, 5 Nov 2026 00:29:26 +0800 Subject: [PATCH 01/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E9=9A=94=E7=A6=BB=20WK2=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E4=B8=8E=E9=85=8D=E7=BD=AE=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/egern/wk3/class/AirQuality.mjs | 2002 +++++++++ src/egern/wk3/class/AirQualityScale.mjs | 876 ++++ src/egern/wk3/class/ColorfulClouds.mjs | 692 +++ src/egern/wk3/class/ForecastNextHour.mjs | 613 +++ src/egern/wk3/class/QWeather.mjs | 891 ++++ src/egern/wk3/class/SimplePrecisionMath.mjs | 29 + src/egern/wk3/class/Weather.mjs | 199 + src/egern/wk3/class/WeatherAlerts.mjs | 333 ++ src/egern/wk3/class/WeatherKit2.mjs | 1028 +++++ src/egern/wk3/function/buildSettings.mjs | 131 + src/egern/wk3/function/database.mjs | 207 + src/egern/wk3/function/parseWeatherKitURL.mjs | 25 + .../wk3/function/shouldReplaceWeatherKit.mjs | 53 + src/egern/wk3/proto/apple/wk2.js | 3971 +++++++++++++++++ src/egern/wk3/utils/Console.mjs | 17 + src/egern/wk3/utils/context.mjs | 3 + src/egern/wk3/utils/fetch.mjs | 147 + src/egern/wk3/utils/index.mjs | 4 + src/egern/wk3/utils/object.mjs | 50 + 19 files changed, 11271 insertions(+) create mode 100644 src/egern/wk3/class/AirQuality.mjs create mode 100644 src/egern/wk3/class/AirQualityScale.mjs create mode 100644 src/egern/wk3/class/ColorfulClouds.mjs create mode 100644 src/egern/wk3/class/ForecastNextHour.mjs create mode 100644 src/egern/wk3/class/QWeather.mjs create mode 100644 src/egern/wk3/class/SimplePrecisionMath.mjs create mode 100644 src/egern/wk3/class/Weather.mjs create mode 100644 src/egern/wk3/class/WeatherAlerts.mjs create mode 100644 src/egern/wk3/class/WeatherKit2.mjs create mode 100644 src/egern/wk3/function/buildSettings.mjs create mode 100644 src/egern/wk3/function/database.mjs create mode 100644 src/egern/wk3/function/parseWeatherKitURL.mjs create mode 100644 src/egern/wk3/function/shouldReplaceWeatherKit.mjs create mode 100644 src/egern/wk3/proto/apple/wk2.js create mode 100644 src/egern/wk3/utils/Console.mjs create mode 100644 src/egern/wk3/utils/context.mjs create mode 100644 src/egern/wk3/utils/fetch.mjs create mode 100644 src/egern/wk3/utils/index.mjs create mode 100644 src/egern/wk3/utils/object.mjs diff --git a/src/egern/wk3/class/AirQuality.mjs b/src/egern/wk3/class/AirQuality.mjs new file mode 100644 index 00000000..be6f0856 --- /dev/null +++ b/src/egern/wk3/class/AirQuality.mjs @@ -0,0 +1,2002 @@ +import { Console } from "../utils/index.mjs"; +import SimplePrecisionMath from "./SimplePrecisionMath.mjs"; + +export default class AirQuality { + static Name = "AirQuality"; + static Version = "3.1.0"; + static Author = "Virgil Clyne & Wordless Echo"; + + // Code by Claude + static #CeilByPrecision(a, b) { + // 获取 b 的小数位数 + const decimals = (b.toString().split(".")[1] || "").length; + + // 根据小数位数计算倍数 + const multiplier = 10 ** decimals; + + // 向上取整 + return Math.ceil(a * multiplier) / multiplier; + } + + /** + * 根据 AQI 数值定位其所属的分类等级(categoryIndex)。 + * + * 作用: + * - 在给定的 categories.ranges 中查找 index 落入的区间; + * - 返回该区间对应的 categoryIndex,供主污染物判断与展示层分级使用。 + * + * @param {number} index + * 待判定的 AQI 指数值。 + * + * @param {{ + * ranges?: Array<{ categoryIndex: number, indexes: [number, number] }> + * }} categories + * 分类配置对象。 + * - ranges[*].indexes 为 [min, max] 区间; + * - 使用 #CeilByPrecision 处理小数精度后再进行区间比较。 + * + * @returns {number} + * 命中的 categoryIndex;若 categories 无效或未命中区间,返回 -1。 + */ + + static CategoryIndex(index, categories) { + Console.debug("☑️ CategoryIndex", `index: ${index}`); + + const failedCategoryIndex = -1; + + if (!categories?.ranges || !Array.isArray(categories.ranges) || categories.ranges.length === 0) { + Console.debug("categories:", categories); + Console.error("GetPrimaryPollutant", "categories无效"); + return failedCategoryIndex; + } + + const range = categories.ranges.find(({ indexes }) => { + const [min, max] = indexes; + return AirQuality.#CeilByPrecision(index, min) >= min && AirQuality.#CeilByPrecision(index, max) <= max; + }); + if (!range) { + Console.error("CategoryIndex", `找不到index: ${index}对应的category`); + return failedCategoryIndex; + } + + Console.debug("✅ CategoryIndex", `categoryIndex: ${range.categoryIndex}`); + return range.categoryIndex; + } + + static CompareCategoryIndexes(todayCategoryIndex, yesterdayCategoryIndex) { + Console.debug("☑️ CompareCategoryIndexes", `todayCategoryIndex: ${todayCategoryIndex}`, `yesterdayCategoryIndex: ${yesterdayCategoryIndex}`); + + const { UNKNOWN, SAME, WORSE, BETTER } = AirQuality.Config.CompareCategoryIndexes; + const diff = Number(todayCategoryIndex) - Number(yesterdayCategoryIndex); + + if (Number.isNaN(diff)) { + Console.error("CompareCategoryIndexes", "categoryIndex无效"); + return UNKNOWN; + } else { + if (diff === 0) { + Console.debug("✅ CompareCategoryIndexes"); + return SAME; + } else if (diff > 0) { + Console.debug("✅ CompareCategoryIndexes"); + return WORSE; + } else { + Console.debug("✅ CompareCategoryIndexes"); + return BETTER; + } + } + } + + static ConvertUnits(pollutants, stpConversionFactors, pollutantScales) { + Console.debug("☑️ ConvertUnits"); + + const friendlyUnits = AirQuality.Config.Units.Friendly; + + const convertedPollutants = pollutants.map(pollutant => { + const pollutantScale = pollutantScales[pollutant.pollutantType]; + + if (!pollutantScale) { + Console.debug("ConvertUnits", `No scale for ${pollutant.pollutantType}, skip`); + return pollutant; + } + + Console.debug("ConvertUnits", `Converting ${pollutant.pollutantType}`); + const amount = AirQuality.ConvertUnit(pollutant.amount, pollutant.units, pollutantScale.units, stpConversionFactors?.[pollutant.pollutantType] || -1, pollutantScale?.stpConversionFactor || -1); + if (amount < 0) { + Console.error( + "ConvertUnits", + `无法转换${pollutant.pollutantType}的单位`, + `${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units} (STP conversion factor: ${stpConversionFactors?.[pollutant.pollutantType] || -1}) -> ${amount} ${friendlyUnits[pollutantScale.units] ?? pollutantScale.units} (STP conversion factor: ${pollutantScale?.stpConversionFactor || -1})`, + ); + return pollutant; + } + + Console.debug("ConvertUnits", `Converted ${pollutant.pollutantType}: ${amount} ${friendlyUnits[pollutantScale.units] ?? pollutantScale.units}`); + return { ...pollutant, amount, units: pollutantScale.units }; + }); + Console.debug("✅ ConvertUnits"); + return convertedPollutants; + } + + /** + * 修复污染物单位。 + * + * 说明: + * - 这是空气质量数据修复的统一入口(当前聚焦污染物单位修复); + * - 当前仅处理和风天气(QWeather)CO 单位场景:其 CO 原始单位为 mg/m3, + * 需转换为 WeatherKit 使用的 µg/m3; + * - 后续同类修复请统一收敛到本方法内,必要时可将本方法升级为 FixAirQuality。 + * + * @param {any} airQuality - 空气质量数据对象 + * @returns {any} 单位修复后的空气质量数据对象 + */ + static FixPollutantsUnits(airQuality) { + Console.debug("☑️ 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; + }); + break; + } + default: { + Console.info(`Provider ${airQuality?.metadata?.providerName} is no need to fix.`); + break; + } + } + Console.debug("✅ FixPollutantsUnits"); + return airQuality; + } + + static #GetStpConversionFactors(airQuality) { + Console.debug("☑️ GetStpConversionFactors"); + + switch (airQuality?.metadata?.providerName) { + case "和风天气": + case "BreezoMeter": + default: { + Console.debug("✅ GetStpConversionFactors", `STP conversion factors for ${airQuality?.metadata?.providerName}: US`); + return AirQuality.Config.STP_ConversionFactors.US; + } + } + } + + static Pollutants2AQI(airQuality, Settings, options = {}) { + Console.debug("☑️ Pollutants2AQI"); + const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm; + 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; + + switch (algorithm) { + case "None": { + return airQuality; + } + case "EU_EAQI": { + // PollutantsToEAQI + Console.debug("☑️ PollutantsToEAQI"); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange: false }); + Console.debug("✅ PollutantsToEAQI"); + break; + } + case "WAQI_InstantCast_US": { + // PollutantsToInstantCastUS + Console.debug("☑️ PollutantsToInstantCastUS", `allowOverRange: ${allowOverRange}`); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange }); + Console.debug("✅ PollutantsToInstantCastUS"); + break; + } + case "WAQI_InstantCast_CN": + case "WAQI_InstantCast_CN_25_DRAFT": { + // PollutantsToInstantCastCN12 / PollutantsToInstantCastCN25 / #PollutantsToInstantCastCN + Console.debug("☑️ PollutantsToInstantCastCN", `allowOverRange: ${allowOverRange}`, `forcePrimaryPollutant: ${forcePrimaryPollutant}`); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange }); + + const isNotAvailable = !forcePrimaryPollutant && airQuality.index <= 50; + if (isNotAvailable) { + Console.info("PollutantsToInstantCastCN", `Max index of pollutants ${airQuality.primaryPollutant} = ${airQuality.index} is <= 50, primaryPollutant will be set to NOT_AVAILABLE.`); + } + + airQuality = { + ...airQuality, + primaryPollutant: isNotAvailable ? "NOT_AVAILABLE" : airQuality.primaryPollutant, + }; + Console.debug("✅ PollutantsToInstantCastCN"); + break; + } + case "UBA": + default: { + // PollutantsToUBA + Console.debug("☑️ PollutantsToUBA"); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange: true }); + airQuality = { + ...airQuality, + index: airQuality.categoryIndex, + }; + Console.debug("✅ PollutantsToUBA"); + break; + } + } + + Console.debug("✅ Pollutants2AQI"); + return airQuality; + } + + static ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) { + const unitsMode = Settings?.AirQuality?.Current?.Pollutants?.Units?.Mode || "Scale"; + Console.debug("☑️ ConvertPollutants", `mode: ${unitsMode}`); + + const getScale = scaleName => { + const scales = AirQuality.Config.Scales; + switch (scaleName) { + case scales.HJ6332012.weatherKitScale.name: + return scales.WAQI_InstantCast_CN; + case scales.EPA_NowCast.weatherKitScale.name: + return scales.WAQI_InstantCast_US; + case scales.EU_EAQI.weatherKitScale.name: + return scales.EU_EAQI; + default: + return scales[scaleName]; + } + }; + + const getPollutantScales = pollutantScales => { + const { ugm3, ppb } = AirQuality.Config.Units.WeatherKit; + const { US, EU } = AirQuality.Config.STP_ConversionFactors; + const ugm3Scale = { + OZONE: { units: ugm3, stpConversionFactor: -1 }, + NO2: { units: ugm3, stpConversionFactor: -1 }, + SO2: { units: ugm3, stpConversionFactor: -1 }, + CO: { units: ugm3, stpConversionFactor: -1 }, + C6H6: { units: ugm3, stpConversionFactor: -1 }, + NH3: { units: ugm3, stpConversionFactor: -1 }, + NO: { units: ugm3, stpConversionFactor: -1 }, + }; + const usPpbScale = { + OZONE: { units: ppb, stpConversionFactor: US.OZONE }, + NO2: { units: ppb, stpConversionFactor: US.NO2 }, + SO2: { units: ppb, stpConversionFactor: US.SO2 }, + CO: { units: ppb, stpConversionFactor: US.CO }, + C6H6: { units: ppb, stpConversionFactor: US.C6H6 }, + NH3: { units: ppb, stpConversionFactor: US.NH3 }, + NO: { units: ppb, stpConversionFactor: US.NO }, + }; + const euPpbScale = { + OZONE: { units: ppb, stpConversionFactor: EU.OZONE }, + NO2: { units: ppb, stpConversionFactor: EU.NO2 }, + SO2: { units: ppb, stpConversionFactor: EU.SO2 }, + CO: { units: ppb, stpConversionFactor: EU.CO }, + C6H6: { units: ppb, stpConversionFactor: EU.C6H6 }, + NH3: { units: ppb, stpConversionFactor: EU.NH3 }, + NO: { units: ppb, stpConversionFactor: EU.NO }, + }; + + switch (unitsMode) { + case "ugm3": + return { ...ugm3Scale, ...pollutantScales }; + case "EU_ppb": + return { ...euPpbScale, ...pollutantScales }; + case "US_ppb": + return { ...usPpbScale, ...pollutantScales }; + case "Force_ugm3": + return ugm3Scale; + case "Force_EU_ppb": + return euPpbScale; + case "Force_US_ppb": + return usPpbScale; + case "Scale": + default: + return pollutantScales; + } + }; + + const replaceUnits = Settings?.AirQuality?.Current?.Pollutants?.Units?.Replace ?? []; + const isIndexInjected = needInjectIndex && injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable; + const scaleName = AirQuality.GetNameFromScale(isIndexInjected ? injectedIndex?.scale : airQuality?.scale); + + const pollutants = injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable ? injectedPollutants.pollutants : airQuality?.pollutants; + Console.debug("✅ ConvertPollutants"); + + const hasIndexedPollutants = Array.isArray(injectedIndex?.pollutants) && injectedIndex.pollutants.some(pollutant => Number.isFinite(Number(pollutant?.index))); + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider; + if (isIndexInjected && hasIndexedPollutants && indexProvider && indexProvider !== "WeatherKit" && !replaceUnits.includes(scaleName)) { + Console.info("ConvertPollutants", `Use pollutants converted by ${indexProvider}`); + return injectedIndex.pollutants; + } + + if (replaceUnits.includes(scaleName)) { + if (isIndexInjected && Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && unitsMode === "Scale") { + Console.info("ConvertPollutants", `Use pollutants from iRingo`); + return injectedIndex.pollutants; + } else { + const scale = getScale(scaleName); + if (!scale) { + Console.error("ConvertPollutants", `Unsupported scale name: ${scaleName}`); + return pollutants; + } + + return AirQuality.ConvertUnits(pollutants, AirQuality.#GetStpConversionFactors(airQuality), getPollutantScales(scale.pollutants)); + } + } else { + return pollutants; + } + } + + static ToWeatherKitScale = ({ name, version }) => `${name}.${version}`; + static GetNameFromScale(scale) { + if (!scale) return ""; + Console.debug("☑️ GetNameFromScale", `scale: ${scale}`); + + const lastDotIndex = scale.lastIndexOf("."); + if (lastDotIndex === -1) { + Console.warn("GetNameFromScale", `无法找到${scale}的版本号`); + return scale; + } + + const scaleName = scale.substring(0, lastDotIndex); + Console.debug("✅ GetNameFromScale", `scaleName: ${scaleName}`); + return scaleName; + } + + /** + * 根据“昨日对比指数来源”和当前空气质量 scale,选择用于昨日对比计算的算法。 + * - Calculate: 直接采用用户配置算法 + * - QWeather / ColorfulCloudsCN: 固定使用国标 InstantCast + * - WeatherKit: 根据 scale 自动映射到内置算法 + * @param {any} airQuality - 当前空气质量对象(用于读取 scale) + * @param {any} Settings - 全局设置对象 + * @returns {string} 选中的算法名;无法匹配时返回空字符串 + */ + static chooseAlogrithm(airQuality, Settings) { + Console.debug("☑️ chooseAlogrithm", `currentIndexProvider: ${Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider}`); + + switch (Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider) { + case "Calculate": { + Console.debug("✅ chooseAlogrithm", `algorithm: ${Settings?.AirQuality?.Calculate?.Algorithm}`); + return Settings?.AirQuality?.Calculate?.Algorithm; + } + case "QWeather": + case "ColorfulCloudsCN": { + Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_CN`); + return "WAQI_InstantCast_CN"; + } + case "WeatherKit": { + const currentScale = AirQuality.GetNameFromScale(airQuality?.scale); + const scales = AirQuality.Config.Scales; + + if (currentScale === scales.HJ6332012.weatherKitScale.name) { + Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_CN`); + return "WAQI_InstantCast_CN"; + } else if (currentScale === scales.EPA_NowCast.weatherKitScale.name) { + Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_US`); + return "WAQI_InstantCast_US"; + } else { + const supportedScales = [scales.EU_EAQI.weatherKitScale.name, scales.UBA.weatherKitScale.name]; + if (supportedScales.includes(currentScale)) { + Console.debug("✅ chooseAlogrithm", `algorithm: ${currentScale}`); + return currentScale; + } + + Console.error("chooseAlogrithm", "没有找到合适的内置算法"); + return ""; + } + } + default: { + Console.error("chooseAlogrithm", "未知的currentIndexProvider"); + return ""; + } + } + } + + /** + * 为 providerName 附加空气质量标准描述。 + * @param {any} airQuality - 空气质量对象 + * @param {any} Settings - 全局设置对象 + * @returns {string} 带标准描述的 providerName + */ + static appendScaleToProviderName(airQuality, Settings) { + const fullProviderName = airQuality?.metadata?.providerName; + if (!fullProviderName) return ""; + + const parts = fullProviderName.split("\n"); + const providerName = parts[0].trim(); + const suffix = parts.length > 1 ? `\n${parts.slice(1).join("\n")}` : ""; + + let scaleDesc = ""; + switch (providerName) { + case "和风天气": + scaleDesc = "(国标,12年2月版)"; + break; + case "彩云天气": { + const useUsa = airQuality?.scale === AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.EPA_NowCast.weatherKitScale); + scaleDesc = `(${useUsa ? "美标,18年9月版" : "国标,12年2月版"})`; + break; + } + case "iRingo": + switch (Settings?.AirQuality?.Calculate?.Algorithm) { + case "EU_EAQI": + scaleDesc = " (ETC HE Report 2024/17)"; + break; + case "WAQI_InstantCast_US": + scaleDesc = " (WAQI InstantCast with EPA-454/B-24-002)"; + break; + case "WAQI_InstantCast_CN": + scaleDesc = " (InstantCast with HJ 633—2012)"; + break; + case "WAQI_InstantCast_CN_25_DRAFT": + scaleDesc = " (InstantCast with HJ 633 2025 DRAFT)"; + break; + case "UBA": + default: + scaleDesc = " (FB001846)"; + break; + } + break; + } + return `${providerName}${scaleDesc}${suffix}`; + } + + /** + * 计算单个污染物在目标标准下的 index。 + * + * @param {{pollutantType: string, amount: number, units: string}} pollutant + * 单个污染物数据。 + * + * @param {string} pollutantType + * 污染物类型(如 OZONE/PM2_5/NO2)。 + * + * @param {{ + * units: string, + * ranges: { + * min: { indexes: [number, number], amounts: [number, number] }, + * max: { indexes: [number, number], amounts: [number, number] }, + * value: Array<{ indexes: [number, number], amounts: [number, number] }> + * } + * }} pollutantScale + * 该污染物在目标标准下的分段阈值与单位。 + * + * @param {Record} friendlyUnits + * 友好单位映射表(用于日志展示)。 + * + * @returns {number} + * 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。 + */ + static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits) { + if (!pollutantScale) { + Console.info("PollutantsToIndexes", `当前标准不使用污染物${pollutantType}计算指数,保留浓度并将index标记为-1`); + return -1; + } + + const { add, subtract, multiply, divide } = SimplePrecisionMath; + + Console.debug(`${pollutantType}: ${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units}`); + + const minValidAmount = pollutantScale.ranges.min.amounts[0]; + if (pollutant.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]; + if (isOverRange) { + Console.warn("PollutantsToIndexes", `检测到爆表指数!${pollutantType}:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}`); + Console.warn("PollutantsToIndexes", "注意身体!"); + } + + const { indexes, amounts } = 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; + }); + Console.debug("indexes:", indexes, "amounts:", 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; + } + + /** + * 将污染物浓度映射为对应标准下的污染物指数(index)。 + * + * 作用: + * 1) 按 pollutantScales 定义,逐个污染物查找浓度区间; + * 2) 使用 #ComputePollutantIndex 计算单污染物 index; + * 3) 返回用于后续主污染物判定的一组 { pollutantType, index }。 + * + * @param {Array<{pollutantType: string, amount: number, units: string}>} pollutants + * 污染物列表。 + * - amount 的单位由 units 字段表示(如 µg/m³、mg/m³、ppb、ppm)。 + * - 进入该方法前应已与 pollutantScales 的目标单位对齐。 + * + * @param {Record + * } + * }>} pollutantScales + * 指标标准定义,包含每种污染物的单位及分段阈值。 + * + * @returns {Array<{pollutantType: string, index: number}>} + * 每种标准污染物对应的 index 列表。 + * - 若标准所需污染物缺失,返回该污染物 index=-1。 + */ + static #PollutantsToIndexes(pollutants, pollutantScales) { + return pollutants.map(pollutant => { + const index = AirQuality.#ComputePollutantIndex(pollutant, pollutant.pollutantType, pollutantScales?.[pollutant.pollutantType], AirQuality.Config.Units.Friendly); + pollutant.index = index; + return pollutant; + }); + } + + static PollutantsToIndexes(pollutants, scaleName, options = {}) { + const scale = AirQuality.Config.Scales[scaleName]; + if (!scale) { + Console.error("PollutantsToIndexes", `Unsupported scale: ${scaleName}`); + return []; + } + const stpConversionFactors = options?.stpConversionFactors; + const sourcePollutants = Array.isArray(pollutants) ? pollutants.map(pollutant => ({ ...pollutant })) : []; + const convertedPollutants = stpConversionFactors + ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) + : sourcePollutants; + return AirQuality.#PollutantsToIndexes(convertedPollutants, scale.pollutants); + } + + static PrimaryPollutant(pollutants, categories) { + Console.debug("☑️ PrimaryPollutant"); + const failedPollutant = { pollutantType: "NOT_AVAILABLE", index: -1, categoryIndex: -1 }; + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + Console.debug("pollutants:", pollutants); + Console.error("PrimaryPollutant", "pollutants无效"); + return failedPollutant; + } + + const indexesWithCategory = pollutants + .filter(({ index }) => Number.isFinite(index) && index >= 0) + .map(pollutant => ({ + ...pollutant, + categoryIndex: AirQuality.CategoryIndex(pollutant.index, categories), + })) + .filter(({ categoryIndex }) => Number.isFinite(categoryIndex) && categoryIndex >= 0) + .sort((a, b) => b.index - a.index); + + if (indexesWithCategory.length === 0) { + Console.debug("pollutants:", pollutants, "indexesWithCategory:", indexesWithCategory); + Console.error("PrimaryPollutant", "找不到有效的index"); + return failedPollutant; + } + + 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.info("PrimaryPollutant", `检测到多个同级别的污染物,按最高指数选择主污染物,maxCategoryIndex:${maxCategoryIndex}`); + primaryPollutants.map(({ pollutantType, index }) => Console.info("GetPrimaryPollutants", `${pollutantType}:${index}`)); + } + + const primaryPollutant = primaryPollutants[0]; + Console.debug("✅ PrimaryPollutant", "primaryPollutant:", primaryPollutant); + return primaryPollutant; + } + + /** + * 将污染物浓度转换为统一 airQuality 结构。 + * + * 作用: + * 1) 按 scale 定义将污染物转换到目标单位(可选); + * 2) 计算各污染物指数并确定主污染物; + * 3) 产出 WeatherKit 风格 airQuality,并按需执行 index 上限裁剪。 + * + * @param {Array<{pollutantType: string, amount: number, units: string}>} pollutants + * 原始污染物数组。 + * - amount 的物理单位由每个元素的 units 字段决定(如 µg/m³、mg/m³、ppb、ppm)。 + * + * @param {{ + * weatherKitScale: {name: string, version: string, maxIndex?: number}, + * pollutants: Record, + * categories: {significantIndex: number, ranges: Array<{categoryIndex: number, indexes: number[]}>} + * }} scale + * AQI 标准配置。 + * - scale.pollutants[*].units 为目标单位。 + * - scale.weatherKitScale.maxIndex 为可选 index 上限。 + * + * @param {{ + * stpConversionFactors?: Record, + * allowOverRange?: boolean + * }} [options] + * 计算选项。 + * - stpConversionFactors:单位换算因子表(无量纲)。提供时会先执行单位转换。 + * - allowOverRange:是否允许 index 超出 maxIndex;默认 true。 + * + * @returns {{ + * index: number, + * isSignificant: boolean, + * categoryIndex: number, + * pollutants: Array<{pollutantType: string, amount: number, units: string}>, + * metadata: {providerName: string, temporarilyUnavailable: boolean}, + * primaryPollutant: string, + * scale: string + * } | {metadata: {providerName: string, temporarilyUnavailable: boolean}}} + * 返回标准化 airQuality;当输入无效时返回 temporarilyUnavailable 结果。 + */ + static #PollutantsToAirQuality(pollutants, scale, options = {}) { + Console.debug("☑️ PollutantsToAirQuality"); + const stpConversionFactors = options?.stpConversionFactors; + const allowOverRange = options?.allowOverRange ?? true; + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + Console.debug("pollutants:", pollutants); + Console.error("PollutantsToAirQuality", "pollutants无效"); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; + } + + let newPollutants = []; + if (stpConversionFactors) { + newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants); + } + + newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants); + Console.debug("PollutantsToAirQuality", "convertedPollutants:", newPollutants); + + const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories); + const maxIndex = scale?.weatherKitScale?.maxIndex; + const index = allowOverRange || !Number.isFinite(maxIndex) ? Math.round(primaryPollutant.index) : Math.min(Math.round(primaryPollutant.index), maxIndex); + Console.debug("✅ PollutantsToAirQuality"); + return { + index, + isSignificant: primaryPollutant.categoryIndex >= scale.categories.significantIndex, + categoryIndex: primaryPollutant.categoryIndex, + pollutants: newPollutants, + metadata: { providerName: "iRingo", temporarilyUnavailable: false }, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + } + + static Config = { + Scales: { + /** + * LQI (DE) - Der Luftqualitätsindex (LQI) des Umweltbundesamtes + * Germany Air Quality Index + * [Der Luftqualitätsindex - LQI | Umweltbundesamt]{@link https://www.umweltbundesamt.de/themen/luft/luftqualitaet/der-luftqualitaetsindex-lqi} + * [Überarbeitung des UBA LQI nach der Herausgabe der WHO AQG 2021]{@link https://www.umweltbundesamt.de/system/files/medien/11850/publikationen/08_2025_uug.pdf} + */ + UBA: { + weatherKitScale: { + name: "UBA", + version: "2414", + }, + // Indexes below for calculation only, not for display + categories: { + significantIndex: 4, // schlecht + ranges: [ + { categoryIndex: 1, indexes: [0, 0.99] }, // sehr gut + { categoryIndex: 2, indexes: [1, 1.99] }, // gut + { categoryIndex: 3, indexes: [2, 2.99] }, // mäßig + { categoryIndex: 4, indexes: [3, 3.99] }, // schlecht + { categoryIndex: 5, indexes: [4, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + pollutants: { + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 10] }, + max: { indexes: [3, 3.99], amounts: [61, 100] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 10] }, // sehr gut + { indexes: [1, 1.99], amounts: [11, 30] }, // gut + { indexes: [2, 2.99], amounts: [31, 60] }, // mäßig + { indexes: [3, 3.99], amounts: [61, 100] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [101, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 9] }, + max: { indexes: [3, 3.99], amounts: [55, 90] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 9] }, // sehr gut + { indexes: [1, 1.99], amounts: [10, 27] }, // gut + { indexes: [2, 2.99], amounts: [28, 54] }, // mäßig + { indexes: [3, 3.99], amounts: [55, 90] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [91, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 5] }, + max: { indexes: [3, 3.99], amounts: [31, 50] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 5] }, // sehr gut + { indexes: [1, 1.99], amounts: [6, 15] }, // gut + { indexes: [2, 2.99], amounts: [16, 30] }, // mäßig + { indexes: [3, 3.99], amounts: [31, 50] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [51, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 24] }, + max: { indexes: [3, 3.99], amounts: [145, 240] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 24] }, // sehr gut + { indexes: [1, 1.99], amounts: [25, 72] }, // gut + { indexes: [2, 2.99], amounts: [73, 144] }, // mäßig + { indexes: [3, 3.99], amounts: [145, 240] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [241, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 0.99], amounts: [0, 10] }, + max: { indexes: [3, 3.99], amounts: [61, 100] }, + value: [ + { indexes: [0, 0.99], amounts: [0, 10] }, // sehr gut + { indexes: [1, 1.99], amounts: [11, 30] }, // gut + { indexes: [2, 2.99], amounts: [31, 60] }, // mäßig + { indexes: [3, 3.99], amounts: [61, 100] }, // schlecht + { indexes: [4, Number.POSITIVE_INFINITY], amounts: [101, Number.POSITIVE_INFINITY] }, // sehr schlecht + ], + }, + }, + }, + }, + /** + * EAQI (EU) - European Air Quality Index + * [ETC HE Report 2024/17: EEA´s revision of the European air quality index bands]{@link https://www.eionet.europa.eu/etcs/etc-he/products/etc-he-products/etc-he-reports/etc-he-report-2024-17-eeas-revision-of-the-european-air-quality-index-bands} + */ + EU_EAQI: { + weatherKitScale: { + name: "EU.EAQI", + version: "2414", + maxIndex: 60, + }, + // Indexes below for calculation only, not for display + categories: { + significantIndex: 4, // Poor + ranges: [ + { categoryIndex: 1, indexes: [0, 9] }, // Good + { categoryIndex: 2, indexes: [10, 19] }, // Fair + { categoryIndex: 3, indexes: [20, 29] }, // Moderate + { categoryIndex: 4, indexes: [30, 39] }, // Poor + { categoryIndex: 5, indexes: [40, 49] }, // Very Poor + { categoryIndex: 6, indexes: [50, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + pollutants: { + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 5] }, + max: { indexes: [40, 49], amounts: [91, 140] }, + value: [ + { indexes: [0, 9], amounts: [0, 5] }, // Good + { indexes: [10, 19], amounts: [6, 15] }, // Fair + { indexes: [20, 29], amounts: [16, 50] }, // Moderate + { indexes: [30, 39], amounts: [51, 90] }, // Poor + { indexes: [40, 49], amounts: [91, 140] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [141, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 15] }, + max: { indexes: [40, 49], amounts: [196, 270] }, + value: [ + { indexes: [0, 9], amounts: [0, 15] }, // Good + { indexes: [10, 19], amounts: [16, 45] }, // Fair + { indexes: [20, 29], amounts: [46, 120] }, // Moderate + { indexes: [30, 39], amounts: [121, 195] }, // Poor + { indexes: [40, 49], amounts: [196, 270] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [271, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 60] }, + max: { indexes: [40, 49], amounts: [161, 180] }, + value: [ + { indexes: [0, 9], amounts: [0, 60] }, // Good + { indexes: [10, 19], amounts: [61, 100] }, // Fair + { indexes: [20, 29], amounts: [101, 120] }, // Moderate + { indexes: [30, 39], amounts: [121, 160] }, // Poor + { indexes: [40, 49], amounts: [161, 180] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [181, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 10] }, + max: { indexes: [40, 49], amounts: [101, 150] }, + value: [ + { indexes: [0, 9], amounts: [0, 10] }, // Good + { indexes: [10, 19], amounts: [11, 25] }, // Fair + { indexes: [20, 29], amounts: [26, 60] }, // Moderate + { indexes: [30, 39], amounts: [61, 100] }, // Poor + { indexes: [40, 49], amounts: [101, 150] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [151, Number.POSITIVE_INFINITY] }, // Extremely Poor + ], + }, + }, + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 9], amounts: [0, 20] }, + max: { indexes: [40, 49], amounts: [191, 275] }, + value: [ + { indexes: [0, 9], amounts: [0, 20] }, // Good + { indexes: [10, 19], amounts: [21, 40] }, // Fair + { indexes: [20, 29], amounts: [41, 125] }, // Moderate + { indexes: [30, 39], amounts: [126, 190] }, // Poor + { indexes: [40, 49], amounts: [191, 275] }, // Very Poor + { indexes: [50, Number.POSITIVE_INFINITY], amounts: [276, Number.MAX_VALUE] }, // Extremely Poor + ], + }, + }, + }, + }, + /** + * China AQI standard. + * [环境空气质量指数(AQI)技术规定(试行)]{@link https://www.mee.gov.cn/ywgz/fgbz/bz/bzwb/jcffbz/201203/t20120302_224166.shtml} + */ + HJ6332012: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, 500] }, // 严重污染 + ], + }, + pollutants: { + SO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [2101, 2620] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 475] }, + { indexes: [151, 200], amounts: [476, 800] }, + { indexes: [201, 300], amounts: [801, 1600] }, + { indexes: [301, 400], amounts: [1601, 2100] }, + { indexes: [401, 500], amounts: [2101, 2620] }, + ], + }, + }, + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [151, 200], amounts: [651, 800] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + // 二氧化硫(SO2)1小时平均浓度高于800 ug/m3的,不再进行其空气质量分指数计算,二氧化硫(SO2)空气质量分指数按24小时平均浓度计算的分指数报告。 + ], + }, + }, + NO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 40] }, + max: { indexes: [401, 500], amounts: [751, 940] }, + value: [ + { indexes: [0, 50], amounts: [0, 40] }, + { indexes: [51, 100], amounts: [41, 80] }, + { indexes: [101, 150], amounts: [81, 180] }, + { indexes: [151, 200], amounts: [181, 280] }, + { indexes: [201, 300], amounts: [281, 565] }, + { indexes: [301, 400], amounts: [566, 750] }, + { indexes: [401, 500], amounts: [751, 940] }, + ], + }, + }, + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + ], + }, + }, + PM10_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + ], + }, + }, + CO_24H: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 2] }, + max: { indexes: [401, 500], amounts: [49, 60] }, + value: [ + { indexes: [0, 50], amounts: [0, 2] }, + { indexes: [51, 100], amounts: [3, 4] }, + { indexes: [101, 150], amounts: [5, 14] }, + { indexes: [151, 200], amounts: [15, 24] }, + { indexes: [201, 300], amounts: [25, 36] }, + { indexes: [301, 400], amounts: [37, 48] }, + { indexes: [401, 500], amounts: [49, 60] }, + ], + }, + }, + CO: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5] }, + max: { indexes: [401, 500], amounts: [121, 150] }, + value: [ + { indexes: [0, 50], amounts: [0, 5] }, + { indexes: [51, 100], amounts: [6, 10] }, + { indexes: [101, 150], amounts: [11, 35] }, + { indexes: [151, 200], amounts: [36, 60] }, + { indexes: [201, 300], amounts: [61, 90] }, + { indexes: [301, 400], amounts: [91, 120] }, + { indexes: [401, 500], amounts: [121, 150] }, + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + ], + }, + }, + OZONE_8H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [201, 300], amounts: [266, 800] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 160] }, + { indexes: [101, 150], amounts: [161, 215] }, + { indexes: [151, 200], amounts: [216, 265] }, + { indexes: [201, 300], amounts: [266, 800] }, + // 臭氧(O3)8小时平均浓度值高于800 ug/m3的,不再进行其空气质量分指数计算,臭氧(O3)空气质量分指数按1小时平均浓度计算的分指数报告。 + ], + }, + }, + PM2_5_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, + { indexes: [51, 100], amounts: [36, 75] }, + { indexes: [101, 150], amounts: [76, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + ], + }, + }, + }, + }, + /** + * China AQI standard (2025 draft). + * [关于公开征求《环境空气质量标准(征求意见稿)》等3项生态环境标准意见的通知]{@link https://www.mee.gov.cn/xxgk2018/xxgk/xxgk06/202512/t20251215_1137858.html} + */ + HJ6332025_DRAFT: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [1, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, 500] }, // 严重污染 + ], + }, + pollutants: { + SO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [2101, 2620] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 475] }, + { indexes: [151, 200], amounts: [476, 800] }, + { indexes: [201, 300], amounts: [801, 1600] }, + { indexes: [301, 400], amounts: [1601, 2100] }, + { indexes: [401, 500], amounts: [2101, 2620] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + // SO2 1小时平均浓度值高于800 μg/m3的,IAQI按照200计。 + { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + ], + }, + }, + NO2_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 40] }, + max: { indexes: [401, 500], amounts: [751, 940] }, + value: [ + { indexes: [0, 50], amounts: [0, 40] }, + { indexes: [51, 100], amounts: [41, 80] }, + { indexes: [101, 150], amounts: [81, 180] }, + { indexes: [151, 200], amounts: [181, 280] }, + { indexes: [201, 300], amounts: [281, 565] }, + { indexes: [301, 400], amounts: [566, 750] }, + { indexes: [401, 500], amounts: [751, 940] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + ], + }, + }, + CO_24H: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 2] }, + max: { indexes: [401, 500], amounts: [49, 60] }, + value: [ + { indexes: [0, 50], amounts: [0, 2] }, + { indexes: [51, 100], amounts: [3, 4] }, + { indexes: [101, 150], amounts: [5, 14] }, + { indexes: [151, 200], amounts: [15, 24] }, + { indexes: [201, 300], amounts: [25, 36] }, + { indexes: [301, 400], amounts: [37, 48] }, + { indexes: [401, 500], amounts: [49, 60] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + CO: { + units: "MILLIGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5] }, + max: { indexes: [401, 500], amounts: [121, 150] }, + value: [ + { indexes: [0, 50], amounts: [0, 5] }, + { indexes: [51, 100], amounts: [6, 10] }, + { indexes: [101, 150], amounts: [11, 35] }, + { indexes: [151, 200], amounts: [36, 60] }, + { indexes: [201, 300], amounts: [61, 90] }, + { indexes: [301, 400], amounts: [91, 120] }, + { indexes: [401, 500], amounts: [121, 150] }, + ], + }, + }, + OZONE_8H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [300, 300], amounts: [801, Number.POSITIVE_INFINITY] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 160] }, + { indexes: [101, 150], amounts: [161, 215] }, + { indexes: [151, 200], amounts: [216, 265] }, + { indexes: [201, 300], amounts: [266, 800] }, + // O3 8小时平均浓度值高于800 μg/m3的,IAQI按照300计。 + { indexes: [300, 300], amounts: [801, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + ], + }, + }, + PM10_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 120] }, + { indexes: [101, 150], amounts: [121, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + ], + }, + }, + PM2_5_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 30] }, + { indexes: [51, 100], amounts: [31, 60] }, + { indexes: [101, 150], amounts: [61, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + ], + }, + }, + }, + }, + /** + * US AQI standard, not equal to NowCast. + * [Technical Assistance Document for the Reporting of Daily Air Quality – the Air Quality Index (AQI) (EPA 454/B-24-002, May 2024)]{@link https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf} + */ + EPA_NowCast: { + weatherKitScale: { + name: "EPA_NowCast", + version: "2414", + }, + categories: { + significantIndex: 3, // Unhealthy for Sensitive Groups + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // Good + { categoryIndex: 2, indexes: [51, 100] }, // Moderate + { categoryIndex: 3, indexes: [101, 150] }, // Unhealthy for Sensitive Groups + { categoryIndex: 4, indexes: [151, 200] }, // Unhealthy + { categoryIndex: 5, indexes: [201, 300] }, // Very Unhealthy + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + pollutants: { + OZONE_8H: { + units: "PARTS_PER_MILLION", + stpConversionFactor: 1.96189649169881, + ranges: { + min: { indexes: [0, 50], amounts: [0, 0.054] }, + max: { indexes: [201, 300], amounts: [0.106, 0.2] }, + value: [ + { indexes: [0, 50], amounts: [0, 0.054] }, // Good + { indexes: [51, 100], amounts: [0.055, 0.07] }, // Moderate + { indexes: [101, 150], amounts: [0.071, 0.085] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [0.086, 0.105] }, // Unhealthy + { indexes: [201, 300], amounts: [0.106, 0.2] }, // Very Unhealthy + // Note 2: + // 8-hour O3 values do not define higher AQI values (≥ 301). + // AQI values of 301 or higher are calculated with 1-hour O3 concentrations. + { indexes: [301, Number.POSITIVE_INFINITY], amounts: [0.201, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + OZONE: { + units: "PARTS_PER_MILLION", + stpConversionFactor: 1.96189649169881, + ranges: { + min: { indexes: [101, 150], amounts: [0.125, 0.164] }, + max: { indexes: [301, 500], amounts: [0.405, 0.604] }, + value: [ + // Note 1: + // Areas are generally required to report the AQI based on 8-hour O3 values. However, + // there are a small number of areas where an AQI based on 1-hour O3 values would be more precautionary. + // In these cases, in addition to calculating the 8-hour O3 index value, + // the 1-hour O3 value may be calculated, and the maximum of the two values reported. + { indexes: [101, 150], amounts: [0.125, 0.164] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [0.165, 0.204] }, // Unhealthy + { indexes: [201, 300], amounts: [0.205, 0.404] }, // Very Unhealthy + { indexes: [301, 500], amounts: [0.405, 0.604] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [0.605, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM2_5_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 9] }, + max: { indexes: [301, 500], amounts: [225.5, 325.4] }, + value: [ + { indexes: [0, 50], amounts: [0, 9] }, // Good + { indexes: [51, 100], amounts: [9.1, 35.4] }, // Moderate + { indexes: [101, 150], amounts: [35.5, 55.4] }, // Unhealthy for Sensitive Groups + // Note 3 of PM2.5 can be found in EPA 454/B-18-007: + // If a different SHL for PM2.5 is promulgated, these numbers will change accordingly. + // + // It is believed that they forgot to remove this note + { indexes: [151, 200], amounts: [55.5, 125.4] }, // Unhealthy + { indexes: [201, 300], amounts: [125.5, 225.4] }, // Very Unhealthy + { indexes: [301, 500], amounts: [225.5, 325.4] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [325.5, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM10_24H: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 54] }, + max: { indexes: [301, 500], amounts: [425, 604] }, + value: [ + { indexes: [0, 50], amounts: [0, 54] }, // Good + { indexes: [51, 100], amounts: [55, 154] }, // Moderate + { indexes: [101, 150], amounts: [155, 254] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [255, 354] }, // Unhealthy + { indexes: [201, 300], amounts: [355, 424] }, // Very Unhealthy + { indexes: [301, 500], amounts: [425, 604] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [605, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + CO_8H: { + units: "PARTS_PER_MILLION", + stpConversionFactor: 1.14491990608754, + ranges: { + min: { indexes: [0, 50], amounts: [0, 4.4] }, + max: { indexes: [301, 500], amounts: [30.5, 50.4] }, + value: [ + { indexes: [0, 50], amounts: [0, 4.4] }, // Good + { indexes: [51, 100], amounts: [4.5, 9.4] }, // Moderate + { indexes: [101, 150], amounts: [9.5, 12.4] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [12.5, 15.4] }, // Unhealthy + { indexes: [201, 300], amounts: [15.5, 30.4] }, // Very Unhealthy + { indexes: [301, 500], amounts: [30.5, 50.4] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50.5, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + SO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 2.618396263625692, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [301, 500], amounts: [605, 1004] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, // Good + { indexes: [51, 100], amounts: [36, 75] }, // Moderate + { indexes: [101, 150], amounts: [76, 185] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [186, 304] }, // Unhealthy + // Note 3 of SO2: + // 1-hour SO2 values do not define higher AQI values (≥ 200). + // AQI values of 200 or greater are calculated with 24-hour SO2 concentrations. + // + // It is believed that they forgot to remove this note + { indexes: [201, 300], amounts: [305, 604] }, // Very Unhealthy + { indexes: [301, 500], amounts: [605, 1004] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1005, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + NO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.880472698306222, + ranges: { + min: { indexes: [0, 50], amounts: [0, 53] }, + max: { indexes: [301, 500], amounts: [1250, 2049] }, + value: [ + { indexes: [0, 50], amounts: [0, 53] }, // Good + { indexes: [51, 100], amounts: [54, 100] }, // Moderate + { indexes: [101, 150], amounts: [101, 360] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [361, 649] }, // Unhealthy + { indexes: [201, 300], amounts: [650, 1249] }, // Very Unhealthy + { indexes: [301, 500], amounts: [1250, 2049] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [2050, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + }, + }, + /** + * WAQI InstantCast in EPA_NowCast. Basically is a method to calculate indexes based on 1-hour pollutants. + * [A Beginner's Guide to Air Quality Instant-Cast and Now-Cast.]{@link https://aqicn.org/faq/2015-03-15/air-quality-nowcast-a-beginners-guide/} + * [Ozone AQI Scale update]{@link https://aqicn.org/faq/2016-08-10/ozone-aqi-scale-update/} + */ + WAQI_InstantCast_US: { + weatherKitScale: { + name: "EPA_NowCast", + version: "2414", + maxIndex: 500, + }, + categories: { + significantIndex: 3, // Unhealthy for Sensitive Groups + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // Good + { categoryIndex: 2, indexes: [51, 100] }, // Moderate + { categoryIndex: 3, indexes: [101, 150] }, // Unhealthy for Sensitive Groups + { categoryIndex: 4, indexes: [151, 200] }, // Unhealthy + { categoryIndex: 5, indexes: [201, 300] }, // Very Unhealthy + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + // Eventually we will convert it to ppb + pollutants: { + // [Ozone AQI Scale update](https://aqicn.org/faq/2016-08-10/ozone-aqi-scale-update/) + OZONE: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.96189649169881, + ranges: { + min: { indexes: [0, 50], amounts: [0, 62.5] }, + max: { indexes: [301, 500], amounts: [405.1, 605] }, + value: [ + { indexes: [0, 50], amounts: [0, 62.5] }, // Good + { indexes: [51, 100], amounts: [62.6, 101.5] }, // Moderate + { indexes: [101, 150], amounts: [101.6, 152.5] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [152.6, 205] }, // Unhealthy + { indexes: [201, 300], amounts: [205.1, 405] }, // Very Unhealthy + { indexes: [301, 500], amounts: [405.1, 605] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [605.1, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 9] }, + max: { indexes: [301, 500], amounts: [225.5, 325.4] }, + value: [ + { indexes: [0, 50], amounts: [0, 9] }, // Good + { indexes: [51, 100], amounts: [9.1, 35.4] }, // Moderate + { indexes: [101, 150], amounts: [35.5, 55.4] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [55.5, 125.4] }, // Unhealthy + { indexes: [201, 300], amounts: [125.5, 225.4] }, // Very Unhealthy + { indexes: [301, 500], amounts: [225.5, 325.4] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [325.5, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 54] }, + max: { indexes: [301, 500], amounts: [425, 604] }, + value: [ + { indexes: [0, 50], amounts: [0, 54] }, // Good + { indexes: [51, 100], amounts: [55, 154] }, // Moderate + { indexes: [101, 150], amounts: [155, 254] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [255, 354] }, // Unhealthy + { indexes: [201, 300], amounts: [355, 424] }, // Very Unhealthy + { indexes: [301, 500], amounts: [425, 604] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [605, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + CO: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.144511152104645, + ranges: { + min: { indexes: [0, 50], amounts: [0, 4400] }, + max: { indexes: [301, 500], amounts: [30401, 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 + ], + }, + }, + SO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 2.618396263625692, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [301, 500], amounts: [605, 1004] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, // Good + { indexes: [51, 100], amounts: [36, 75] }, // Moderate + { indexes: [101, 150], amounts: [76, 185] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [186, 304] }, // Unhealthy + { indexes: [201, 300], amounts: [305, 604] }, // Very Unhealthy + { indexes: [301, 500], amounts: [605, 1004] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1005, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + NO2: { + units: "PARTS_PER_BILLION", + stpConversionFactor: 1.880472698306222, + ranges: { + min: { indexes: [0, 50], amounts: [0, 53] }, + max: { indexes: [301, 500], amounts: [1250, 2049] }, + value: [ + { indexes: [0, 50], amounts: [0, 53] }, // Good + { indexes: [51, 100], amounts: [54, 100] }, // Moderate + { indexes: [101, 150], amounts: [101, 360] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [361, 649] }, // Unhealthy + { indexes: [201, 300], amounts: [650, 1249] }, // Very Unhealthy + { indexes: [301, 500], amounts: [1250, 2049] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [2050, Number.POSITIVE_INFINITY] }, // Hazardous + ], + }, + }, + }, + }, + /** + * WAQI InstantCast in HJ6332012. + * Use 24-hour SO2 when > 800, 24-hour PM10 and PM2.5 as 1-hour. + * 1-hour for others. + */ + WAQI_InstantCast_CN: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + maxIndex: 500, + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [0, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // 严重污染 + ], + }, + pollutants: { + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [401, 500], amounts: [2101, 2620] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + { indexes: [201, 300], amounts: [801, 1600] }, + { indexes: [301, 400], amounts: [1601, 2100] }, + { indexes: [401, 500], amounts: [2101, 2620] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [2602, Number.POSITIVE_INFINITY] }, + ], + }, + }, + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [3841, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 150] }, + { indexes: [101, 150], amounts: [151, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [601, Number.POSITIVE_INFINITY] }, + ], + }, + }, + CO: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5000] }, + max: { indexes: [401, 500], amounts: [120001, 150000] }, + value: [ + { indexes: [0, 50], amounts: [0, 5000] }, + { indexes: [51, 100], amounts: [5001, 10000] }, + { indexes: [101, 150], amounts: [10001, 35000] }, + { indexes: [151, 200], amounts: [35001, 60000] }, + { indexes: [201, 300], amounts: [60001, 90000] }, + { indexes: [301, 400], amounts: [90001, 120000] }, + { indexes: [401, 500], amounts: [120001, 150000] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [150001, Number.POSITIVE_INFINITY] }, + ], + }, + }, + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1201, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 35] }, + { indexes: [51, 100], amounts: [36, 75] }, + { indexes: [101, 150], amounts: [76, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [501, Number.POSITIVE_INFINITY] }, + ], + }, + }, + }, + }, + WAQI_InstantCast_CN_25_DRAFT: { + weatherKitScale: { + name: "HJ6332012", + version: "2604", + maxIndex: 500, + }, + categories: { + significantIndex: 3, // 轻度污染 + ranges: [ + { categoryIndex: 1, indexes: [1, 50] }, // 优 + { categoryIndex: 2, indexes: [51, 100] }, // 良 + { categoryIndex: 3, indexes: [101, 150] }, // 轻度污染 + { categoryIndex: 4, indexes: [151, 200] }, // 中度污染 + { categoryIndex: 5, indexes: [201, 300] }, // 重度污染 + { categoryIndex: 6, indexes: [301, Number.POSITIVE_INFINITY] }, // 严重污染 + ], + }, + pollutants: { + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + SO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 150] }, + max: { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + value: [ + { indexes: [0, 50], amounts: [0, 150] }, + { indexes: [51, 100], amounts: [151, 500] }, + { indexes: [101, 150], amounts: [501, 650] }, + { indexes: [151, 200], amounts: [651, 800] }, + // SO2 1小时平均浓度值高于800 μg/m3的,IAQI按照200计。 + { indexes: [200, 200], amounts: [801, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + NO2: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 100] }, + max: { indexes: [401, 500], amounts: [3091, 3840] }, + value: [ + { indexes: [0, 50], amounts: [0, 100] }, + { indexes: [51, 100], amounts: [101, 200] }, + { indexes: [101, 150], amounts: [201, 700] }, + { indexes: [151, 200], amounts: [701, 1200] }, + { indexes: [201, 300], amounts: [1201, 2340] }, + { indexes: [301, 400], amounts: [2341, 3090] }, + { indexes: [401, 500], amounts: [3091, 3840] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [3841, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + CO: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 5000] }, + max: { indexes: [401, 500], amounts: [120001, 150000] }, + value: [ + { indexes: [0, 50], amounts: [0, 5000] }, + { indexes: [51, 100], amounts: [5001, 10000] }, + { indexes: [101, 150], amounts: [10001, 35000] }, + { indexes: [151, 200], amounts: [35001, 60000] }, + { indexes: [201, 300], amounts: [60001, 90000] }, + { indexes: [301, 400], amounts: [90001, 120000] }, + { indexes: [401, 500], amounts: [120001, 150000] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [150001, Number.POSITIVE_INFINITY] }, + ], + }, + }, + // SO2、NO2、CO和O3 1小时平均浓度限值仅用于实时报。 + OZONE: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 160] }, + max: { indexes: [401, 500], amounts: [1001, 1200] }, + value: [ + { indexes: [0, 50], amounts: [0, 160] }, + { indexes: [51, 100], amounts: [161, 200] }, + { indexes: [101, 150], amounts: [201, 300] }, + { indexes: [151, 200], amounts: [301, 400] }, + { indexes: [201, 300], amounts: [401, 800] }, + { indexes: [301, 400], amounts: [801, 1000] }, + { indexes: [401, 500], amounts: [1001, 1200] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [1201, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM10: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 50] }, + max: { indexes: [401, 500], amounts: [501, 600] }, + value: [ + { indexes: [0, 50], amounts: [0, 50] }, + { indexes: [51, 100], amounts: [51, 120] }, + { indexes: [101, 150], amounts: [121, 250] }, + { indexes: [151, 200], amounts: [251, 350] }, + { indexes: [201, 300], amounts: [351, 420] }, + { indexes: [301, 400], amounts: [421, 500] }, + { indexes: [401, 500], amounts: [501, 600] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [601, Number.POSITIVE_INFINITY] }, + ], + }, + }, + PM2_5: { + units: "MICROGRAMS_PER_CUBIC_METER", + stpConversionFactor: -1, + ranges: { + min: { indexes: [0, 50], amounts: [0, 35] }, + max: { indexes: [401, 500], amounts: [351, 500] }, + value: [ + { indexes: [0, 50], amounts: [0, 30] }, + { indexes: [51, 100], amounts: [31, 60] }, + { indexes: [101, 150], amounts: [61, 115] }, + { indexes: [151, 200], amounts: [116, 150] }, + { indexes: [201, 300], amounts: [151, 250] }, + { indexes: [301, 400], amounts: [251, 350] }, + { indexes: [401, 500], amounts: [351, 500] }, + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [501, Number.POSITIVE_INFINITY] }, + ], + }, + }, + }, + }, + }, + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + }, + CompareCategoryIndexes: { + BETTER: "BETTER", + SAME: "SAME", + UNKNOWN: "UNKNOWN", + WORSE: "WORSE", + }, + Units: { + WeatherKit: { + ugm3: "MICROGRAMS_PER_CUBIC_METER", + mgm3: "MILLIGRAMS_PER_CUBIC_METER", + ppb: "PARTS_PER_BILLION", + ppm: "PARTS_PER_MILLION", + }, + Friendly: { + MICROGRAMS_PER_CUBIC_METER: "μg/m3", + MILLIGRAMS_PER_CUBIC_METER: "mg/m3", + PARTS_PER_BILLION: "ppb", + PARTS_PER_MILLION: "ppm", + }, + }, + /** + * Standard Conditions for Temperature and Pressure + * [Ozone AQI: Using concentrations in milligrams or ppb?]{@link https://aqicn.org/faq/2015-09-06/ozone-aqi-using-concentrations-in-milligrams-or-ppb/} + * [Understanding Units of Measurement - Terrie K. Boguski, P.E. (CHSR)]{@link https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileid/14285} + * + * (amount * 12.187 * molecularWeight) / (temperatureInCelsius + 273.15) + * + * - 12.187 is the inverse of gas constant. + * - 273.15 is the 0 celsius in kelvin. + * - temperatureInCelsius is 25 in US, 20 in EU. + */ + STP_ConversionFactors: { + US: { + OZONE: 1.96189649169881, // 47.997 g/mol + SO2: 2.618396263625692, // 64.058 g/mol + NO2: 1.880472698306222, // 46.005 g/mol + CO: 1.14491990608754, // 28.01 g/mol + C6H6: 3.192940861982224, // 78.114 g/mol + NH3: 0.696148908267651, // 17.031 g/mol + NO: 1.226507201073285, // 30.006 g/mol + }, + EU: { + OZONE: 1.995358823128092, // 47.997 g/mol + SO2: 2.663055930411053, // 64.058 g/mol + NO2: 1.912546256182842, // 46.005 g/mol + CO: 1.164447791233157, // 28.01 g/mol + C6H6: 3.247400027289784, // 78.114 g/mol + NH3: 0.708022503837626, // 17.031 g/mol + NO: 1.247426648473478, // 30.006 g/mol + }, + }, + }; + + static ConvertUnit(amount, from, to, fromStpConversionFactor = -1, toStpConversionFactor = -1) { + Console.debug("☑️ ConvertUnit"); + const friendlyUnits = AirQuality.Config.Units.Friendly; + Console.debug("Convert", amount, "from", friendlyUnits[from], "with", fromStpConversionFactor, "to", friendlyUnits[to], "with", toStpConversionFactor); + if (amount < 0) { + Console.error("ConvertUnit", `Amount ${amount} < 0`); + return -1; + } + + const numAmount = Number(amount.toFixed(15)); + + const { ugm3, mgm3, ppb, ppm } = AirQuality.Config.Units.WeatherKit; + const ppx = [ppb, ppm]; + const units = [...ppx, ugm3, mgm3]; + if (!units.includes(from) || !units.includes(to)) { + Console.error("ConvertUnit", "不支持的单位", `from: ${from}`, `to: ${to}`); + return -1; + } + + 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); + } + + if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) { + Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`); + return -1; + } + + if (!isBothPpx && isPpxTo && toStpConversionFactor <= 0) { + Console.error("ConvertUnit", `toStpConversionFactor ${toStpConversionFactor} <= 0`); + return -1; + } + + const { multiply, divide } = SimplePrecisionMath; + switch (from) { + case ppm: + switch (to) { + case ppm: + Console.debug("✅ ConvertUnit"); + return numAmount; + case ppb: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 1000); + case mgm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, fromStpConversionFactor); + case ugm3: { + const inPpb = AirQuality.ConvertUnit(numAmount, from, ppb); + Console.debug("✅ ConvertUnit"); + return multiply(inPpb, fromStpConversionFactor); + } + default: + return -1; + } + case ppb: + switch (to) { + case ppb: + Console.debug("✅ ConvertUnit"); + return numAmount; + case ppm: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 0.001); + case mgm3: { + const inPpm = AirQuality.ConvertUnit(numAmount, from, ppm); + Console.debug("✅ ConvertUnit"); + return multiply(inPpm, fromStpConversionFactor); + } + case ugm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, fromStpConversionFactor); + default: + return -1; + } + case mgm3: + switch (to) { + case mgm3: + Console.debug("✅ ConvertUnit"); + return numAmount; + case ugm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 1000); + case ppm: + Console.debug("✅ ConvertUnit"); + return divide(numAmount, toStpConversionFactor); + case ppb: { + const inUgM3 = AirQuality.ConvertUnit(numAmount, from, ugm3); + Console.debug("✅ ConvertUnit"); + return divide(inUgM3, toStpConversionFactor); + } + default: + return -1; + } + case ugm3: + switch (to) { + case ugm3: + Console.debug("✅ ConvertUnit"); + return numAmount; + case mgm3: + Console.debug("✅ ConvertUnit"); + return multiply(numAmount, 0.001); + case ppm: { + const inMgM3 = AirQuality.ConvertUnit(numAmount, from, mgm3); + Console.debug("✅ ConvertUnit"); + return divide(inMgM3, toStpConversionFactor); + } + case ppb: + Console.debug("✅ ConvertUnit"); + return divide(numAmount, toStpConversionFactor); + default: + return -1; + } + default: + return -1; + } + } +} diff --git a/src/egern/wk3/class/AirQualityScale.mjs b/src/egern/wk3/class/AirQualityScale.mjs new file mode 100644 index 00000000..2ccd8f41 --- /dev/null +++ b/src/egern/wk3/class/AirQualityScale.mjs @@ -0,0 +1,876 @@ +/** + * AirQualityScale + * 提供空气质量标尺(AQI Scale)的本地构建能力, + * 用于替代 WeatherKit /api/v1/airQualityScale 接口返回。 + * + * 支持的标尺: + * - HJ6332012.* — 中国国标 AQI + * - EPA_NowCast.* — 美国 EPA AQI + * - HK.AQHI.* — 香港 AQHI + * - CN.AQHI.* — 中国 AQHI + * - EU.EAQI.* — 欧洲 EAQI + * - UBA.* — 德国 LQI + * + * 语言键说明: + * zh-Hans-CN 简体中文 + * zh-Hant-HK 繁体中文 + * en-US 英文(默认回退) + */ + +import { Console } from "../utils/index.mjs"; + +// ─── 多语言字符串常量 ────────────────────────────────────────────────────────── + +/** 空气质量标尺通用标签 */ +const SCALE_DISPLAY_LABEL = { + "zh-Hant-HK": "空氣質素", + "zh-Hant-TW": "空氣品質", + "zh-Hans-CN": "空气质量", + "en-US": "Air Quality", +}; + +// ─── HJ6332012 中国国标 AQI ──────────────────────────────────────────────────── + +const HJ6332012 = { + displayName: { + "zh-Hans-CN": "空气质量指数", + "zh-Hant-HK": "空氣質量指數", + "en-US": "Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "AQI", + "zh-Hant-HK": "AQI", + "en-US": "AQI", + }, + longDisplayName: { + "zh-Hans-CN": "中国 (HJ 633—2012)", + "zh-Hant-HK": "中國 (HJ 633—2012)", + "en-US": "China (HJ 633—2012)", + }, + categories: [ + { + range: [0, 50], + glyph: "aqi.low", + colors: ["#00E400"], + categoryName: { + "zh-Hans-CN": "优", + "zh-Hant-HK": "優", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "空气质量令人满意,基本无空气污染。", + "zh-Hant-HK": "空氣質量令人滿意,基本無空氣污染。", + "en-US": "Air quality is satisfactory, and air pollution poses little or no risk.", + }, + }, + { + range: [51, 100], + glyph: "aqi.low", + colors: ["#FFFF00"], + categoryName: { + "zh-Hans-CN": "良", + "zh-Hant-HK": "良", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "空气质量可接受,但某些污染物可能对极少数异常敏感人群健康有较弱影响。", + "zh-Hant-HK": "空氣質量可接受,但某些污染物可能對極少數異常敏感人群健康有較弱影響。", + "en-US": "Air quality is acceptable. However, there may be a risk for some people, particularly those who are unusually sensitive to air pollution.", + }, + }, + { + range: [101, 150], + glyph: "aqi.medium", + colors: ["#FF7E00"], + categoryName: { + "zh-Hans-CN": "轻度污染", + "zh-Hant-HK": "輕度污染", + "en-US": "Unhealthy for Sensitive Groups", + }, + recommendation: { + "zh-Hans-CN": "敏感人群症状有轻度加剧,健康人群出现刺激症状。建议儿童、老年人及心脏病、呼吸系统疾病患者应减少长时间、高强度的户外锻炼。", + "zh-Hant-HK": "敏感人群症狀有輕度加劇,健康人群出現刺激症狀。建議兒童、老年人及心臟病、呼吸系統疾病患者應減少長時間、高強度的戶外鍛煉。", + "en-US": "Members of sensitive groups may experience health effects. The general public is less likely to be affected. Children, the elderly, and people with heart or respiratory diseases should reduce prolonged or heavy outdoor exertion.", + }, + }, + { + range: [151, 200], + glyph: "aqi.high", + colors: ["#FF0000"], + categoryName: { + "zh-Hans-CN": "中度污染", + "zh-Hant-HK": "中度污染", + "en-US": "Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "进一步加剧敏感人群症状,可能对心脏和呼吸系统有影响。儿童、老年人及心脏病、呼吸系统疾病患者应避免长时间、高强度的户外锻炼,一般人群适量减少户外运动。", + "zh-Hant-HK": "進一步加劇敏感人群症狀,可能對心臟和呼吸系統有影響。兒童、老年人及心臟病、呼吸系統疾病患者應避免長時間、高強度的戶外鍛煉,一般人群適量減少戶外運動。", + "en-US": + "Some members of the general public may experience health effects; members of sensitive groups may experience more serious health effects. Children, the elderly, and people with heart or respiratory diseases should avoid prolonged or heavy outdoor exertion. Everyone else should reduce outdoor exertion.", + }, + }, + { + range: [201, 300], + glyph: "aqi.high", + colors: ["#99004C"], + categoryName: { + "zh-Hans-CN": "重度污染", + "zh-Hant-HK": "重度污染", + "en-US": "Very Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "心脏病和肺病患者症状显著加剧,运动耐受力降低,健康人群普遍出现症状。儿童、老年人和病人应停留在室内,避免体力消耗,一般人群应避免户外活动。", + "zh-Hant-HK": "心臟病和肺病症狀顯著加劇,運動耐受力降低,健康人群普遍出現症狀。兒童、老年人和病人應停留在室內,避免體力消耗,一般人群應避免戶外活動。", + "en-US": "Health warnings of emergency conditions. The entire population is more likely to be affected. Children, the elderly, and the ill should stay indoors. Everyone else should avoid outdoor exertion.", + }, + }, + { + range: [301, 500], + glyph: "aqi.high", + colors: ["#7E0023"], + categoryName: { + "zh-Hans-CN": "严重污染", + "zh-Hant-HK": "嚴重污染", + "en-US": "Hazardous", + }, + recommendation: { + "zh-Hans-CN": "健康人群运动耐受力降低,有明显强烈症状,提前出现某些疾病。儿童、老年人和病人应当留在室内,避免体力消耗。一般人群应避免户外活动。", + "zh-Hant-HK": "健康人群運動耐受力降低,有明顯強烈症狀,提前出現某些疾病。兒童、老年人和病人應當留在室內,避免體力消耗。一般人群應避免戶外活動。", + "en-US": "Health alert: everyone may experience more serious health effects. Children, the elderly, and the ill should remain indoors. Everyone else should avoid outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 0, color: "#00E400" }, + { location: 50, color: "#00E400" }, + { location: 51, color: "#FFFF00" }, + { location: 100, color: "#FFFF00" }, + { location: 101, color: "#FF7E00" }, + { location: 150, color: "#FF7E00" }, + { location: 151, color: "#FF0000" }, + { location: 200, color: "#FF0000" }, + { location: 201, color: "#99004C" }, + { location: 300, color: "#99004C" }, + { location: 301, color: "#7E0023" }, + ], + }, +}; + +// ─── EPA_NowCast 美国 AQI ────────────────────────────────────────────────────── + +const EPA_NOWCAST = { + displayName: { + "zh-Hans-CN": "空气质量指数", + "zh-Hant-HK": "空氣質量指數", + "en-US": "Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "AQI", + "zh-Hant-HK": "AQI", + "en-US": "AQI", + }, + longDisplayName: { + "zh-Hans-CN": "美国 (EPA NowCast)", + "zh-Hant-HK": "美國 (EPA NowCast)", + "en-US": "United States (EPA NowCast)", + }, + categories: [ + { + range: [0, 50], + glyph: "aqi.low", + colors: ["#00E400"], + categoryName: { + "zh-Hans-CN": "优", + "zh-Hant-HK": "優", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "空气质量令人满意,基本无空气污染。", + "zh-Hant-HK": "空氣質量令人滿意,基本無空氣污染。", + "en-US": "Air quality is satisfactory, and air pollution poses little or no risk.", + }, + }, + { + range: [51, 100], + glyph: "aqi.low", + colors: ["#FFFF00"], + categoryName: { + "zh-Hans-CN": "良", + "zh-Hant-HK": "良", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "空气质量可接受,但某些污染物可能对极少数异常敏感人群健康有较弱影响。", + "zh-Hant-HK": "空氣質量可接受,但某些污染物可能對極少數異常敏感人群健康有較弱影響。", + "en-US": "Air quality is acceptable. However, there may be a risk for some people, particularly those who are unusually sensitive to air pollution.", + }, + }, + { + range: [101, 150], + glyph: "aqi.medium", + colors: ["#FF7E00"], + categoryName: { + "zh-Hans-CN": "敏感人群不健康", + "zh-Hant-HK": "敏感人群不健康", + "en-US": "Unhealthy for Sensitive Groups", + }, + recommendation: { + "zh-Hans-CN": "敏感人群症状有轻度加剧,健康人群出现刺激症状。建议儿童、老年人及心脏病、呼吸系统疾病患者应减少户外活动。", + "zh-Hant-HK": "敏感人群症狀有輕度加劇,健康人群出現刺激症狀。建議兒童、老年人及心臟病、呼吸系統疾病患者應減少戶外活動。", + "en-US": "Members of sensitive groups may experience health effects. The general public is less likely to be affected.", + }, + }, + { + range: [151, 200], + glyph: "aqi.high", + colors: ["#FF0000"], + categoryName: { + "zh-Hans-CN": "不健康", + "zh-Hant-HK": "不健康", + "en-US": "Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "进一步加剧敏感人群症状,可能对心脏和呼吸系统有影响。一般人群应减少户外活动。", + "zh-Hant-HK": "進一步加劇敏感人群症狀,可能對心臟和呼吸系統有影響。一般人群應減少戶外活動。", + "en-US": "Some members of the general public may experience health effects; members of sensitive groups may experience more serious health effects.", + }, + }, + { + range: [201, 300], + glyph: "aqi.high", + colors: ["#99004C"], + categoryName: { + "zh-Hans-CN": "非常不健康", + "zh-Hant-HK": "非常不健康", + "en-US": "Very Unhealthy", + }, + recommendation: { + "zh-Hans-CN": "健康警告:所有人都可能出现更严重的健康影响。", + "zh-Hant-HK": "健康警告:所有人都可能出現更嚴重的健康影響。", + "en-US": "Health alert: everyone may experience more serious health effects.", + }, + }, + { + range: [301, 500], + glyph: "aqi.high", + colors: ["#7E0023"], + categoryName: { + "zh-Hans-CN": "危险", + "zh-Hant-HK": "危險", + "en-US": "Hazardous", + }, + recommendation: { + "zh-Hans-CN": "健康警告:所有人都可能出现严重的健康影响。应避免所有户外活动。", + "zh-Hant-HK": "健康警告:所有人都可能出現嚴重的健康影響。應避免所有戶外活動。", + "en-US": "Health warning of emergency conditions: everyone is more likely to be affected. Avoid all outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 0, color: "#00E400" }, + { location: 50, color: "#00E400" }, + { location: 51, color: "#FFFF00" }, + { location: 100, color: "#FFFF00" }, + { location: 101, color: "#FF7E00" }, + { location: 150, color: "#FF7E00" }, + { location: 151, color: "#FF0000" }, + { location: 200, color: "#FF0000" }, + { location: 201, color: "#99004C" }, + { location: 300, color: "#99004C" }, + { location: 301, color: "#7E0023" }, + ], + }, +}; + +// ─── EU EAQI 欧洲 ────────────────────────────────────────────────────────────── + +const EU_EAQI = { + displayName: { + "zh-Hans-CN": "欧洲空气质量指数", + "zh-Hant-HK": "歐洲空氣質量指數", + "en-US": "European Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "EAQI", + "zh-Hant-HK": "EAQI", + "en-US": "EAQI", + }, + longDisplayName: { + "zh-Hans-CN": "欧洲 (EAQI)", + "zh-Hant-HK": "歐洲 (EAQI)", + "en-US": "Europe (EAQI)", + }, + categories: [ + { + range: [0, 20], + glyph: "aqi.low", + colors: ["#1E88E5"], + categoryName: { + "zh-Hans-CN": "非常好", + "zh-Hant-HK": "非常好", + "en-US": "Very Good", + }, + recommendation: { + "zh-Hans-CN": "享受户外活动。", + "zh-Hant-HK": "享受戶外活動。", + "en-US": "Enjoy your usual outdoor activities.", + }, + }, + { + range: [21, 40], + glyph: "aqi.low", + colors: ["#00C853"], + categoryName: { + "zh-Hans-CN": "好", + "zh-Hant-HK": "好", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "享受户外活动。", + "zh-Hant-HK": "享受戶外活動。", + "en-US": "Enjoy your usual outdoor activities.", + }, + }, + { + range: [41, 60], + glyph: "aqi.medium", + colors: ["#FFEB3B"], + categoryName: { + "zh-Hans-CN": "中等", + "zh-Hant-HK": "中等", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应考虑减少户外活动。", + "zh-Hant-HK": "敏感人群應考慮減少戶外活動。", + "en-US": "Sensitive individuals should consider reducing outdoor exertion.", + }, + }, + { + range: [61, 80], + glyph: "aqi.high", + colors: ["#FF9800"], + categoryName: { + "zh-Hans-CN": "差", + "zh-Hant-HK": "差", + "en-US": "Poor", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应减少户外活动。", + "zh-Hant-HK": "敏感人群應減少戶外活動。", + "en-US": "Sensitive groups should reduce outdoor exertion.", + }, + }, + { + range: [81, 100], + glyph: "aqi.high", + colors: ["#F44336"], + categoryName: { + "zh-Hans-CN": "非常差", + "zh-Hant-HK": "非常差", + "en-US": "Very Poor", + }, + recommendation: { + "zh-Hans-CN": "所有人应减少户外活动。", + "zh-Hant-HK": "所有人應減少戶外活動。", + "en-US": "Everyone should reduce outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 0, color: "#1E88E5" }, + { location: 20, color: "#1E88E5" }, + { location: 21, color: "#00C853" }, + { location: 40, color: "#00C853" }, + { location: 41, color: "#FFEB3B" }, + { location: 60, color: "#FFEB3B" }, + { location: 61, color: "#FF9800" }, + { location: 80, color: "#FF9800" }, + { location: 81, color: "#F44336" }, + ], + }, +}; + +// ─── UBA 德国 LQI ────────────────────────────────────────────────────────────── + +const UBA = { + displayName: { + "zh-Hans-CN": "德国空气质量指数", + "zh-Hant-HK": "德國空氣質量指數", + "en-US": "German Air Quality Index", + }, + shortDisplayName: { + "zh-Hans-CN": "LQI", + "zh-Hant-HK": "LQI", + "en-US": "LQI", + }, + longDisplayName: { + "zh-Hans-CN": "德国 (UBA LQI)", + "zh-Hant-HK": "德國 (UBA LQI)", + "en-US": "Germany (UBA LQI)", + }, + categories: [ + { + range: [0, 0.99], + glyph: "aqi.low", + colors: ["#00C853"], + categoryName: { + "zh-Hans-CN": "非常好", + "zh-Hant-HK": "非常好", + "en-US": "Very Good", + }, + recommendation: { + "zh-Hans-CN": "可以自由进行户外活动。", + "zh-Hant-HK": "可以自由進行戶外活動。", + "en-US": "You can freely engage in outdoor activities.", + }, + }, + { + range: [1, 1.99], + glyph: "aqi.low", + colors: ["#64DD17"], + categoryName: { + "zh-Hans-CN": "好", + "zh-Hant-HK": "好", + "en-US": "Good", + }, + recommendation: { + "zh-Hans-CN": "可以自由进行户外活动。", + "zh-Hant-HK": "可以自由進行戶外活動。", + "en-US": "You can usually engage in outdoor activities without restriction.", + }, + }, + { + range: [2, 2.99], + glyph: "aqi.medium", + colors: ["#FFEB3B"], + categoryName: { + "zh-Hans-CN": "中等", + "zh-Hant-HK": "中等", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应减少户外活动。", + "zh-Hant-HK": "敏感人群應減少戶外活動。", + "en-US": "Sensitive individuals should reduce outdoor exertion.", + }, + }, + { + range: [3, 3.99], + glyph: "aqi.high", + colors: ["#FF9800"], + categoryName: { + "zh-Hans-CN": "差", + "zh-Hant-HK": "差", + "en-US": "Poor", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应避免户外活动。", + "zh-Hant-HK": "敏感人群應避免戶外活動。", + "en-US": "Sensitive groups should avoid outdoor exertion.", + }, + }, + { + range: [4, 4], + glyph: "aqi.high", + colors: ["#F44336"], + categoryName: { + "zh-Hans-CN": "非常差", + "zh-Hant-HK": "非常差", + "en-US": "Very Poor", + }, + recommendation: { + "zh-Hans-CN": "所有人应减少户外活动。", + "zh-Hant-HK": "所有人應減少戶外活動。", + "en-US": "Everyone should reduce outdoor exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 0, color: "#00C853" }, + { location: 1, color: "#64DD17" }, + { location: 2, color: "#FFEB3B" }, + { location: 3, color: "#FF9800" }, + { location: 4, color: "#F44336" }, + ], + }, +}; + +// ─── HK AQHI ─────────────────────────────────────────────────────────────────── + +const HK_AQHI = { + displayName: { + "zh-Hant-HK": "AQHI (HK)", + "zh-Hans-CN": "AQHI (HK)", + "en-US": "AQHI (HK)", + }, + shortDisplayName: { + "zh-Hant-HK": "AQHI", + "zh-Hans-CN": "AQHI", + "en-US": "AQHI", + }, + longDisplayName: { + "zh-Hant-HK": "香港 (AQHI)", + "zh-Hans-CN": "香港 (AQHI)", + "en-US": "Hong Kong (AQHI)", + }, + categories: [ + // 低 (1-3) + { + range: [1, 3], + glyph: "aqi.low", + colors: ["#04DE71", "#04DE71", "#58E156"], + categoryName: { + "zh-Hant-HK": "低", + "zh-Hans-CN": "低", + "en-US": "Low", + }, + recommendation: { + "zh-Hant-HK": "可如常活動。", + "zh-Hans-CN": "可如常活动。", + "en-US": "No response action is required.", + }, + }, + // 中 (4-6) + { + range: [4, 6], + glyph: "aqi.medium", + colors: ["#FFE620", "#FFBE10", "#FF9500"], + categoryName: { + "zh-Hant-HK": "中", + "zh-Hans-CN": "中", + "en-US": "Moderate", + }, + recommendation: { + "zh-Hant-HK": "一般可如常活動,但個別出現症狀的人士應考慮減少戶外體力消耗。", + "zh-Hans-CN": "一般可如常活动,但個別出现症状的人士应考虑减少户外体力消耗。", + "en-US": "No response action is normally required. Individuals who are experiencing symptoms are advised to consider reducing outdoor physical exertion.", + }, + }, + // 高 (7) + { + range: [7, 7], + glyph: "aqi.high", + colors: ["#FA114F"], + categoryName: { + "zh-Hant-HK": "高", + "zh-Hans-CN": "高", + "en-US": "High", + }, + recommendation: { + "zh-Hant-HK": "心臟病或呼吸系統疾病患者、兒童及長者應減少戶外體力消耗。", + "zh-Hans-CN": "心脏病或呼吸系统疾病患者、儿童及长者应减少户外体力消耗。", + "en-US": "People with existing heart or respiratory illnesses, Children and the elderly are advised to reduce outdoor physical exertion.", + }, + }, + // 甚高 (8-10) + { + range: [8, 10], + glyph: "aqi.high", + colors: ["#D11343", "#A91537", "#80172B"], + categoryName: { + "zh-Hant-HK": "甚高", + "zh-Hans-CN": "甚高", + "en-US": "Very High", + }, + recommendation: { + "zh-Hant-HK": "心臟病或呼吸系統疾病患者、兒童及長者應盡量減少戶外體力消耗。", + "zh-Hans-CN": "心脏病或呼吸系统疾病患者、儿童及长者应尽量减少户外体力消耗。", + "en-US": "People with existing heart or respiratory illnesses, Children and the elderly are advised to reduce to the minimum outdoor physical exertion.", + }, + }, + // 严重 (11) + { + range: [11, 11], + glyph: "aqi.high", + colors: ["#80172B"], + categoryName: { + "zh-Hant-HK": "嚴重", + "zh-Hans-CN": "严重", + "en-US": "Serious", + }, + recommendation: { + "zh-Hant-HK": "心臟病或呼吸系統疾病患者、兒童及長者應避免戶外體力消耗。", + "zh-Hans-CN": "心脏病或呼吸系统疾病患者、儿童及长者应避免户外体力消耗。", + "en-US": "People with existing heart or respiratory illnesses, Children and the elderly are advised to avoid outdoor physical exertion.", + }, + }, + ], + gradient: { + stops: [ + { location: 1, color: "#04DE71" }, + { location: 2.5, color: "#04DE71" }, + { location: 4, color: "#FFE620" }, + { location: 6, color: "#FF9500" }, + { location: 7, color: "#FA114F" }, + { location: 8, color: "#D11343" }, + { location: 9.5, color: "#80172B" }, + ], + }, +}; + +// ─── CN AQHI ─────────────────────────────────────────────────────────────────── + +const CN_AQHI = { + displayName: { + "zh-Hans-CN": "AQHI (CN)", + }, + shortDisplayName: { + "zh-Hans-CN": "AQHI", + }, + longDisplayName: { + "zh-Hans-CN": "中国 (AQHI)", + }, + categories: [ + { + range: [1, 1], + glyph: "aqi.low", + colors: ["#2094FA"], + categoryName: { "zh-Hans-CN": "极低" }, + recommendation: { "zh-Hans-CN": "适宜进行户外活动。" }, + }, + { + range: [2, 3], + glyph: "aqi.low", + colors: ["#04DE71", "#CCFF66"], + categoryName: { "zh-Hans-CN": "低" }, + recommendation: { "zh-Hans-CN": "正常进行户外活动。心肺疾病患者可遵照医嘱进行身体锻炼。" }, + }, + { + range: [4, 5], + glyph: "aqi.medium", + colors: ["#FFE620", "#FF9500"], + categoryName: { "zh-Hans-CN": "中" }, + recommendation: { "zh-Hans-CN": "心肺疾病患者应减少长时间、高强度的户外活动,并遵照医嘱进行身体锻炼。" }, + }, + { + range: [6, 10], + glyph: "aqi.high", + colors: ["#FA114F", "#DC1346", "#BD143D", "#9F1634", "#80172B"], + categoryName: { "zh-Hans-CN": "高" }, + recommendation: { "zh-Hans-CN": "心肺疾病患者、老人、儿童及孕妇应尽量减少户外活动。一般人群应减少长时间、高强度的户外活动。" }, + }, + { + range: [11, 11], + glyph: "aqi.high", + colors: ["#80172B"], + categoryName: { "zh-Hans-CN": "极高" }, + recommendation: { "zh-Hans-CN": "心肺疾病患者、老人、儿童及孕妇应避免户外活动。一般人群应尽量减少户外活动。" }, + }, + ], + gradient: { + stops: [ + { location: 1, color: "#2094FA" }, + { location: 2, color: "#04DE71" }, + { location: 3.5, color: "#CCFF66" }, + { location: 4, color: "#FFE620" }, + { location: 6, color: "#FA114F" }, + { location: 8, color: "#BD143D" }, + { location: 10, color: "#80172B" }, + ], + }, +}; + +// ─── 工具函数 ────────────────────────────────────────────────────────────────── + +/** + * 从三键 i18n map 中根据语言标签获取字符串。 + * @param {Record} map + * @param {string} lang + * @returns {string} + */ +function i18n(map, lang) { + return map[lang] ?? map["en-US"] ?? ""; +} + +/** + * 将请求语言映射为响应中的地区语言标签。 + * @param {string} language + * @returns {"zh-HK"|"zh-CN"|"zh-TW"|"en"} + */ +function normalizeScaleLanguage(language) { + if (/^zh-Hant-HK$/i.test(language)) return "zh-HK"; + if (/^zh-Hant/i.test(language)) return "zh-TW"; + if (/^zh/i.test(language)) return "zh-CN"; + return "en"; +} + +function parseScaleIdentity(scaleName) { + const [name, version] = `${scaleName || ""}`.split("."); + const parsedVersion = Number.parseInt(version, 10); + return { + name: name || scaleName, + version: Number.isFinite(parsedVersion) ? parsedVersion : 1, + }; +} + +// ─── AirQualityScale 类 ──────────────────────────────────────────────────────── + +export default class AirQualityScale { + /** + * 将 Apple WeatherKit BCP47 语言标签规范化为三键形式。 + * @param {string} language + * @returns {"zh-Hans-CN"|"zh-Hant-HK"|"zh-Hant-TW"|"en-US"} + */ + static normalizeLanguage(language) { + if (/zh-Hant-HK/i.test(language)) return "zh-Hant-HK"; + if (/^zh-Hant/i.test(language)) return "zh-Hant-TW"; + if (/^zh/i.test(language)) return "zh-Hans-CN"; + return "en-US"; + } + + /** + * 统一入口:根据 scaleName 构建对应的标尺响应。 + * @param {string} language - 请求的语言标签 + * @param {string} scaleName - 标尺名称,如 "HJ6332012.2604"、"EPA_NowCast.2604" + * @returns {{ status: number, headers: Record, body: string } | null} + */ + static buildScale(language, scaleName) { + Console.debug("☑️ AirQualityScale.buildScale", `language: ${language}`, `scaleName: ${scaleName}`); + + const scaleBaseName = scaleName?.split(".")?.[0]; + Console.debug(`scaleBaseName: ${scaleBaseName}`); + + let result = null; + switch (scaleBaseName) { + case "HJ6332012": + case "HJ6332025_DRAFT": + result = AirQualityScale.#buildGenericScale(language, scaleName, HJ6332012); + break; + case "EPA_NowCast": + result = AirQualityScale.#buildGenericScale(language, scaleName, EPA_NOWCAST); + break; + case "EU": + case "EAQI": + result = AirQualityScale.#buildGenericScale(language, scaleName, EU_EAQI); + break; + case "UBA": + result = AirQualityScale.#buildGenericScale(language, scaleName, UBA); + break; + case "HK": + result = AirQualityScale.#buildAQHIScale(language, scaleName, HK_AQHI); + break; + case "CN": + result = AirQualityScale.#buildAQHIScale(language, scaleName, CN_AQHI); + break; + default: + Console.warn("AirQualityScale.buildScale", `未知的 scale: ${scaleName},透传到 Apple`); + result = null; + break; + } + + Console.debug("✅ AirQualityScale.buildScale", result ? "已构建本地响应" : "透传"); + return result; + } + + /** + * 构建通用 AQI 标尺(HJ6332012、EPA_NowCast、EU_EAQI、UBA)。 + * @param {string} language + * @param {string} scaleName + * @param {object} scaleDef - 标尺定义常量 + * @returns {{ status: number, headers: Record, body: string }} + */ + static #buildGenericScale(language, scaleName, scaleDef) { + const normalizeLang = AirQualityScale.normalizeLanguage(language); + const scaleIdentity = parseScaleIdentity(scaleName); + + const categories = []; + for (const band of scaleDef.categories) { + const [min, max] = band.range; + // 对于整数范围(AQI 0-50, 51-100 等),每个 category 一个条目 + // 对于小数范围(UBA 0-0.99, 1-1.99 等),也每个 category 一个条目 + categories.push({ + categoryNumber: categories.length + 1, + range: [min, max === Number.POSITIVE_INFINITY ? 9999 : max], + color: band.colors[0], + categoryName: i18n(band.categoryName, normalizeLang), + recommendation: i18n(band.recommendation, normalizeLang), + glyph: band.glyph, + }); + } + + const scale = { + name: scaleIdentity.name, + displayName: i18n(scaleDef.displayName, normalizeLang), + shortDisplayName: i18n(scaleDef.shortDisplayName, normalizeLang), + longDisplayName: i18n(scaleDef.longDisplayName, normalizeLang), + displayLabel: i18n(SCALE_DISPLAY_LABEL, normalizeLang), + language: normalizeScaleLanguage(language), + version: scaleIdentity.version, + aqi: { + numerical: true, + ascending: true, + range: scaleDef === UBA ? [0, 4] : scaleDef === EU_EAQI ? [0, 100] : [0, 500], + categories, + gradient: scaleDef.gradient, + }, + }; + + return { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store, max-age=0", + }, + body: JSON.stringify(scale), + }; + } + + /** + * 构建 AQHI 标尺(HK、CN)。 + * @param {string} language + * @param {string} scaleName + * @param {object} scaleDef - AQHI 标尺定义常量 + * @returns {{ status: number, headers: Record, body: string }} + */ + static #buildAQHIScale(language, scaleName, scaleDef) { + const normalizeLang = AirQualityScale.normalizeLanguage(language); + const lang = normalizeLang === "zh-Hant-TW" ? "zh-Hant-HK" : normalizeLang; + const scaleIdentity = parseScaleIdentity(scaleName); + + const categories = []; + for (const band of scaleDef.categories) { + const [min, max] = band.range; + for (let idx = min; idx <= max; idx++) { + categories.push({ + categoryNumber: idx, + range: [idx, idx], + color: band.colors[idx - min], + categoryName: i18n(band.categoryName, lang), + recommendation: i18n(band.recommendation, lang), + glyph: band.glyph, + }); + } + } + + const scale = { + name: scaleIdentity.name, + displayName: i18n(scaleDef.displayName, lang), + shortDisplayName: i18n(scaleDef.shortDisplayName, lang), + longDisplayName: i18n(scaleDef.longDisplayName, lang), + displayLabel: i18n(SCALE_DISPLAY_LABEL, normalizeLang), + language: normalizeScaleLanguage(language), + version: scaleIdentity.version, + aqi: { + numerical: true, + ascending: true, + range: [1, 11], + categories, + gradient: scaleDef.gradient, + }, + }; + + return { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store, max-age=0", + }, + body: JSON.stringify(scale), + }; + } +} diff --git a/src/egern/wk3/class/ColorfulClouds.mjs b/src/egern/wk3/class/ColorfulClouds.mjs new file mode 100644 index 00000000..e439a2d0 --- /dev/null +++ b/src/egern/wk3/class/ColorfulClouds.mjs @@ -0,0 +1,692 @@ +import { Console, fetch } from "../utils/index.mjs"; +import AirQuality from "./AirQuality.mjs"; +import ForecastNextHour from "./ForecastNextHour.mjs"; +import Weather from "./Weather.mjs"; + +export default class ColorfulClouds { + constructor(parameters, token) { + this.Name = "ColorfulClouds"; + this.Version = "4.1.0"; + Console.debug(`🟧 ${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.version = parameters.version; + this.language = parameters.language; + this.latitude = parameters.latitude; + this.longitude = parameters.longitude; + this.country = parameters.country; + } + + #cache = { + realtimePromise: null, + yesterdayHourly: undefined, + }; + + #Config = { + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + }, + Availability: { + Minutely: [ + "CN", + "HK", + "MO", + "TW", + "IT", + "LT", + "MT", + "FR", + "SK", + "NO", + "BY", + "IS", + "CZ", + "SI", + "DE", + "ES", + "UA", + "DK", + "PL", + "FI", + "SE", + "HR", + "RU", + "RO", + "PT", + "EE", + "RS", + "AT", + "GR", + "HU", + "FJ", + "GU", + "MH", + "NC", + "TR", + "BH", + "SA", + "ID", + "IR", + "SG", + "OM", + "PH", + "IN", + "KH", + "CY", + "MY", + "VN", + "KW", + "TH", + "KR", + "KP", + "CA", + "BS", + "KY", + "MX", + "PA", + "MQ", + "CU", + "BM", + "PR", + "CW", + "GP", + "NI", + "BR", + "GF", + "CO", + "GY", + "PY", + "AR", + ], + AirQuality: ["CN", "HK", "MO"], + }, + }; + + async #RealTime() { + Console.debug("☑️ RealTime"); + + if (!this.#cache.realtimePromise) { + const request = { + url: `${this.endpoint}/realtime`, + headers: this.headers, + }; + this.#cache.realtimePromise = fetch(request) + .then(response => JSON.parse(response?.body ?? "{}")) + .then(body => { + if (body?.status === "ok" && body?.result?.realtime?.status === "ok") { + return body; + } + throw Error(JSON.stringify(body ?? {})); + }) + .catch(error => { + Console.error(`RealTime: ${error}`); + this.#cache.realtimePromise = null; + return {}; + }); + } else { + Console.debug("✅ RealTime", "Using cache promise"); + } + return this.#cache.realtimePromise; + } + + async Minutely() { + Console.debug("☑️ Minutely"); + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.Minutely.includes(this.country)) { + Console.warn("Minutely", `Unsupported country: ${this.country}`); + return; + } + + const request = { + url: `${this.endpoint}/minutely?unit=metric:v2`, + headers: this.headers, + }; + let forecastNextHour; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.status) { + case "ok": + switch (body?.result?.minutely?.status) { + case "ok": { + const timeStamp = (Date.now() / 1000) | 0; + const metadata = { + attributionUrl: "https://www.caiyunapp.com/h5", + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: body?.location?.[0], + longitude: body?.location?.[1], + + providerName: "彩云天气", + readTime: timeStamp, + reportedTime: body?.server_time, + temporarilyUnavailable: false, + sourceType: "MODELED", + }; + body.result.minutely.probability = body.result.minutely.probability.map(probability => Math.round(probability * 100)); + let minuteStemp = new Date(body?.server_time * 1000).setSeconds(0, 0); + minuteStemp = minuteStemp.valueOf() / 1000 - 60; + forecastNextHour = { + metadata: metadata, + condition: [], + forecastEnd: 0, + forecastStart: minuteStemp, + minutes: body?.result?.minutely?.precipitation_2h?.map((precipitationIntensity, index) => { + const minute = { + perceivedPrecipitationIntensity: 0, + precipitationChance: 0, + precipitationIntensity: precipitationIntensity, + startTime: minuteStemp + 60 * index, + }; + if (index < 30) minute.precipitationChance = body?.result?.minutely?.probability?.[0]; + else if (index < 60) minute.precipitationChance = body?.result?.minutely?.probability?.[1]; + else if (index < 90) minute.precipitationChance = body?.result?.minutely?.probability?.[2]; + else minute.precipitationChance = body?.result?.minutely?.probability?.[3]; + return minute; + }), + summary: [], + }; + forecastNextHour.minutes.length = Math.min(85, forecastNextHour.minutes.length); + forecastNextHour.forecastEnd = minuteStemp + 60 * forecastNextHour.minutes.length; + forecastNextHour.minutes = ForecastNextHour.Minute(forecastNextHour.minutes, body?.result?.minutely?.description, "mmph"); + forecastNextHour.summary = ForecastNextHour.Summary(forecastNextHour.minutes); + forecastNextHour.condition = ForecastNextHour.Condition(forecastNextHour.summary); + break; + } + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify({ status: body?.result?.minutely?.status, reason: body?.result?.minutely })); + } + break; + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify(body ?? {})); + } + } catch (error) { + Console.error(`Minutely: ${error}`); + } finally { + //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); + Console.debug("✅ Minutely"); + } + return forecastNextHour; + } + + async #Hourly(hourlysteps = 273, begin = undefined) { + Console.debug("☑️ Hourly", `hourlysteps: ${hourlysteps}`, `begin: ${begin}`); + const request = { + url: `${this.endpoint}/hourly?hourlysteps=${hourlysteps}`, + headers: this.headers, + }; + if (begin) request.url += `&begin=${begin}`; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.status) { + case "ok": + switch (body?.result?.hourly?.status) { + case "ok": { + Console.debug("✅ Hourly"); + return body; + } + case "error": + case undefined: + throw Error(JSON.stringify({ status: body?.result?.hourly?.status, reason: body?.result?.hourly })); + } + break; + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify(body ?? {})); + } + } catch (error) { + Console.error(`Hourly: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(this.airQuality, null, 2)}`); + Console.debug("✅ Hourly"); + } + return {}; + } + + async Daily(dailysteps = 10, begin = undefined) { + Console.debug("☑️ Daily"); + const request = { + url: `${this.endpoint}/daily?dailysteps=${dailysteps}`, + headers: this.headers, + }; + if (begin) request.url += `&begin=${begin}`; + let forecastDaily; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.status) { + case "ok": + switch (body?.result?.daily?.status) { + case "ok": { + const timeStamp = (Date.now() / 1000) | 0; + const metadata = { + attributionUrl: "https://www.caiyunapp.com/h5", + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, + latitude: body?.location?.[0], + longitude: body?.location?.[1], + + providerName: "彩云天气", + readTime: timeStamp, + reportedTime: body?.server_time, + temporarilyUnavailable: false, + sourceType: "STATION", + }; + forecastDaily = { + metadata: metadata, + days: [], + }; + for (let i = 0; i < dailysteps; i++) { + const timeStamp = (new Date(body?.result?.daily?.skycon?.[i]?.date).getTime() / 1000) | 0; + forecastDaily.days.push({ + forecastStart: timeStamp, + forecastEnd: timeStamp + 24 * 3600, // 24 hours + conditionCode: Weather.ConvertWeatherCode(body?.result?.daily?.skycon?.[i]?.value), + humidityMax: Math.round(body?.result?.daily?.humidity?.[i]?.max * 100), + humidityMin: Math.round(body?.result?.daily?.humidity?.[i]?.min * 100), + // maxUvIndex: Weather.ConvertDSWRF(body?.result?.daily?.dswrf?.[i]?.max), // ConvertDSWRF 转换不准确 + // moonPhase: "", // Not given + // moonrise: body?.result?.daily?.astro?.[i].sunset.time, // Not given + // moonset: body?.result?.daily?.astro?.[i].sunrise.time, // Not given + precipitationAmount: body?.result?.daily?.precipitation?.[i]?.avg, + // precipitationAmountByType: [], // Not given + precipitationChance: body?.result?.daily?.precipitation?.[i]?.probability, + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // solarMidnight: 0, // Not given + // solarNoon: 0, // Not given + //sunrise: body?.result?.daily?.astro?.[i].sunrise.time, // 未转换 + // sunriseAstronomical: 0, // Not given + // sunriseCivil: 0, // Not given + // sunriseNautical: 0, // Not given + //sunset: body?.result?.daily?.astro?.[i].sunset.time, // 未转换 + // sunsetAstronomical: 0, // Not given + // sunsetCivil: 0, // Not given + // sunsetNautical: 0, // Not given + temperatureMax: body?.result?.daily?.temperature?.[i]?.max, + // temperatureMaxTime: 0, // Not given + temperatureMin: body?.result?.daily?.temperature?.[i]?.min, + // temperatureMinTime: 0, // Not given + visibilityMax: body?.result?.daily?.visibility?.[i]?.max * 1000, + visibilityMin: body?.result?.daily?.visibility?.[i]?.min * 1000, + // windGustSpeedMax: 0, // Not given + windSpeedAvg: body?.result?.daily?.wind?.[i]?.avg?.speed, + windSpeedMax: body?.result?.daily?.wind?.[i]?.max?.speed, + daytimeForecast: { + forecastStart: timeStamp + 8 * 3600, // 8 hours + forecastEnd: timeStamp + 8 * 3600 + 12 * 3600, // 8 + 12 hours + cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(body?.result?.daily?.skycon_08h_20h?.[i]?.value), + // humidityMax: Math.round(body?.result?.daily?.humidity?.[i]?.max * 100), // Not given + // humidityMin: Math.round(body?.result?.daily?.humidity?.[i]?.min * 100), // Not given + precipitationAmount: body?.result?.daily?.precipitation_08h_20h?.[i]?.avg, + // precipitationAmountByType: [], // Not given + precipitationChance: body?.result?.daily?.precipitation_08h_20h?.[i]?.probability, + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + temperatureMax: body?.result?.daily?.temperature_08h_20h?.[i]?.max, + temperatureMin: body?.result?.daily?.temperature_08h_20h?.[i]?.min, + // visibilityMax: body?.result?.daily?.visibility?.[i]?.max * 1000, // Not given + // visibilityMin: body?.result?.daily?.visibility?.[i]?.min * 1000, // Not given + windDirection: body?.result?.daily?.wind_08h_20h?.[i]?.avg?.direction, + // windGustSpeedMax: 0, // Not given + windSpeed: body?.result?.daily?.wind_08h_20h?.[i]?.avg?.speed, + windSpeedMax: body?.result?.daily?.wind_08h_20h?.[i]?.max?.speed, + }, + overnightForecast: { + forecastStart: timeStamp + 20 * 3600, // 20 hours + forecastEnd: timeStamp + 20 * 3600 + 12 * 3600, // 20 + 12 hours + cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(body?.result?.daily?.skycon_20h_32h?.[i]?.value), + // humidityMax: Math.round(body?.result?.daily?.humidity?.[i]?.max * 100), // Not given + // humidityMin: Math.round(body?.result?.daily?.humidity?.[i]?.min * 100), // Not given + precipitationAmount: body?.result?.daily?.precipitation_20h_32h?.[i]?.avg, + // precipitationAmountByType: [], // Not given + precipitationChance: body?.result?.daily?.precipitation_20h_32h?.[i]?.probability, + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + temperatureMax: body?.result?.daily?.temperature_20h_32h?.[i]?.max, + temperatureMin: body?.result?.daily?.temperature_20h_32h?.[i]?.min, + // visibilityMax: body?.result?.daily?.visibility?.[i]?.max * 1000, // Not given + // visibilityMin: body?.result?.daily?.visibility?.[i]?.min * 1000, // Not given + windDirection: body?.result?.daily?.wind_20h_32h?.[i]?.avg?.direction, + // windGustSpeedMax: 0, // Not given + windSpeed: body?.result?.daily?.wind_20h_32h?.[i]?.avg?.speed, + windSpeedMax: body?.result?.daily?.wind_20h_32h?.[i]?.max?.speed, + }, + }); + } + break; + } + case "error": + case undefined: + throw Error(JSON.stringify({ status: body?.result?.daily?.status, reason: body?.result?.daily })); + } + break; + case "error": + case "failed": + case undefined: + throw Error(JSON.stringify(body ?? {})); + } + } catch (error) { + Console.error(`Daily: ${error}`); + } finally { + //Console.debug(`Daily: ${JSON.stringify(Daily, null, 2)}`); + Console.debug("✅ Daily"); + } + return forecastDaily; + } + + #Metadata(reportedTime, location = [this.latitude, this.longitude], temporarilyUnavailable = false) { + const timeStamp = Math.trunc(Date.now() / 1000); + const [latitude, longitude] = location; + return { + longitude, + providerName: "彩云天气", + reportedTime: reportedTime ?? timeStamp, + latitude, + expireTime: timeStamp + 60 * 60, + attributionUrl: "https://www.caiyunapp.com/h5", + temporarilyUnavailable, + readTime: timeStamp, + sourceType: "MODELED", + }; + } + + /** + * 创建 WeatherKit 格式的污染物对象 + * @link https://docs.caiyunapp.com/weather-api/v2/v2.6/1-realtime.html + * @param {Object} pollutantsObj - 污染物对象 + * @param {String} [scale] - AQI 标准(如 HJ6332012) + * @returns {Object} 修复后的污染物对象 + */ + #CreatePollutants(pollutantsObj = {}, scale = "") { + Console.debug("☑️ CreatePollutants"); + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = Object.entries(pollutantsObj) + .map(([name, amount]) => { + switch (name) { + case "co": + return { + amount: AirQuality.ConvertUnit(amount ?? -1, mgm3, ugm3), + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + index: scale === "HJ6332012" ? pollutantsObj.co_iaqi_chn : undefined, + }; + case "no": + case "no2": + case "so2": + case "o3": + case "nox": + case "pm25": + case "pm10": + return { + amount: amount ?? -1, + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + index: scale === "HJ6332012" ? pollutantsObj[`${name}_iaqi_chn`] : undefined, + }; + default: + return null; + } + }) + .filter(Boolean); + + Console.debug("✅ CreatePollutants"); + return pollutants; + } + + /** + * 获取当前空气质量并转换为 WeatherKit 风格结构。 + * + * 逻辑概览: + * 1) 校验国家/地区是否支持空气质量; + * 2) 拉取 realtime 数据并做可用性检查; + * 3) 构建通用空气质量基础结构(metadata/pollutants); + * 4) 按 useUsa 选择 US 或 CN 口径生成 index、categoryIndex 与 primaryPollutant。 + * + * @param {boolean} [useUsa=true] + * 是否使用美国 AQI 口径。 + * - true: 使用 `EPA_NowCast`(usa); + * - false: 使用 `HJ6332012`(chn)。 + * + * @param {boolean} [forcePrimaryPollutant=true] + * 在 CN 口径下是否强制展示主污染物。 + * - true: 始终展示计算得到的主污染物; + * - false: 当主污染物 index <= 50 时返回 `NOT_AVAILABLE`。 + * + * @returns {Promise<{ + * metadata: any, + * pollutants: Array<{amount: number, pollutantType: string, units: string}>, + * previousDayComparison: string, + * categoryIndex?: number, + * index?: number, + * isSignificant?: boolean, + * primaryPollutant?: string, + * scale?: string + * }>} + * 成功时返回完整空气质量对象;不可用时返回 temporarilyUnavailable 的降级对象。 + */ + async CurrentAirQuality(useUsa = true, forcePrimaryPollutant = true) { + Console.debug("☑️ CurrentAirQuality"); + // 统一失败兜底对象,任一关键步骤失败时直接返回。 + const failedAirQuality = { + metadata: this.#Metadata(undefined, undefined, true), + pollutants: [], + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + + // 可用性判断:当前数据源不支持该国家/地区时直接返回兜底结果。 + if (!this.#Config.Availability.AirQuality.includes(this.country)) { + Console.warn("CurrentAirQuality", `Unsupported country: ${this.country}`); + return failedAirQuality; + } + + // 拉取实时数据(内部已带缓存)。 + const realtime = await this.#RealTime(); + + // realtime 主体缺失,视为不可用。 + if (!realtime.result) { + Console.error("CurrentAirQuality", "无法获取realtime数据"); + return failedAirQuality; + } + + // 彩云在不支持位置时 usa 描述为空字符串。 + if (realtime.result.realtime.air_quality.description.usa === "") { + Console.error("CurrentAirQuality", `不支持的位置`); + return failedAirQuality; + } + + // 构建与算法无关的基础空气质量结构。 + const particularAirQuality = { + metadata: this.#Metadata(realtime.result.realtime.air_quality.obs_time, realtime.location), + pollutants: this.#CreatePollutants(realtime.result.realtime.air_quality, useUsa ? undefined : "HJ6332012"), + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + + // US 口径:直接使用 usa AQI,并按 EPA_NowCast 分类。 + if (useUsa) { + const scale = AirQuality.Config.Scales.WAQI_InstantCast_US; + const index = realtime.result.realtime.air_quality.aqi.usa; + const categoryIndex = AirQuality.CategoryIndex(index, scale.categories); + const indexedPollutants = AirQuality.PollutantsToIndexes(particularAirQuality.pollutants, "WAQI_InstantCast_US", { stpConversionFactors: AirQuality.Config.STP_ConversionFactors.US }); + const primaryPollutant = AirQuality.PrimaryPollutant(indexedPollutants, scale.categories); + + const airQuality = { + ...particularAirQuality, + pollutants: indexedPollutants, + categoryIndex, + index, + isSignificant: categoryIndex >= scale.categories.significantIndex, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + Console.debug("✅ CurrentAirQuality"); + return airQuality; + } else { + // CN 口径:使用 chn AQI,并基于分污染物 IAQI 判定主污染物。 + const scale = AirQuality.Config.Scales.HJ6332012; + const index = realtime.result.realtime.air_quality.aqi.chn; + const categoryIndex = AirQuality.CategoryIndex(index, scale.categories); + + const primaryPollutant = AirQuality.PrimaryPollutant(particularAirQuality.pollutants, scale.categories); + // 当不强制展示主污染物且整体空气质量较好(<=50)时,主污染物置为不可用。 + const isNotAvailable = !forcePrimaryPollutant && primaryPollutant.index <= 50; + if (isNotAvailable) { + Console.info("CurrentAirQuality", `Max index of pollutants ${primaryPollutant.pollutantType} = ${primaryPollutant.index} is <= 50, primaryPollutant will be set to NOT_AVAILABLE.`); + } + + const airQuality = { + ...particularAirQuality, + categoryIndex, + index, + isSignificant: categoryIndex >= scale.categories.significantIndex, + primaryPollutant: isNotAvailable ? "NOT_AVAILABLE" : primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + Console.debug("✅ CurrentAirQuality"); + return airQuality; + } + } + + async CurrentWeather() { + Console.debug("☑️ CurrentWeather"); + const realtime = await this.#RealTime(); + if (!realtime.result) { + Console.error("CurrentWeather", "无法获取realtime数"); + return { + metadata: this.#Metadata(undefined, undefined, true), + }; + } + + Console.debug("✅ CurrentWeather"); + return { + metadata: this.#Metadata(realtime.result.server_time, realtime.location), + cloudCover: Math.round(realtime.result.realtime.cloudrate * 100), + conditionCode: Weather.ConvertWeatherCode(realtime.result.realtime.skycon), + humidity: Math.round(realtime.result.realtime.humidity * 100), + // uvIndex: Weather.ConvertDSWRF(body?.result?.realtime?.dswrf), // ConvertDSWRF 转换不准确 + perceivedPrecipitationIntensity: realtime.result.realtime.precipitation.local.intensity, + pressure: realtime.result.realtime.pressure / 100, + temperature: realtime.result.realtime.temperature, + temperatureApparent: realtime.result.realtime.apparent_temperature, + visibility: realtime.result.realtime.visibility * 1000, + windDirection: realtime.result.realtime.wind.direction, + windSpeed: realtime.result.realtime.wind.speed, + }; + } + + /** + * 预取昨日小时数据,供 YesterdayAirQuality 使用 + * 与 WeatherKit fetch 并发,减少总体延迟 + * @returns {Promise} 昨日小时数据 Promise + */ + async prefetchYesterdayHourly() { + Console.debug("☑️ prefetchYesterdayHourly"); + this.#cache.yesterdayHourly = this.#Hourly(1, Math.trunc((Date.now() - 864e5) / 1000)); + const result = await this.#cache.yesterdayHourly; + Console.debug("✅ prefetchYesterdayHourly"); + return result; + } + + async YesterdayAirQuality(useUsa = true) { + Console.debug("☑️ YesterdayAirQuality"); + + const yesterdayHourly = await (this.#cache.yesterdayHourly ?? this.#Hourly(1, Math.trunc((Date.now() - 864e5) / 1000))); + const scale = useUsa ? AirQuality.Config.Scales.EPA_NowCast : AirQuality.Config.Scales.HJ6332012; + const particularAirQuality = { + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + pollutants: [], + primaryPollutant: "NOT_AVAILABLE", + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + + if (!yesterdayHourly.result) { + Console.error("YesterdayAirQuality", "无法获取hourly数据"); + return { + ...particularAirQuality, + categoryIndex: -1, + metadata: this.#Metadata(undefined, undefined, true), + }; + } + + const { usa, chn } = yesterdayHourly.result.hourly.air_quality.aqi[0].value; + if (usa === 0 && chn === 0) { + Console.warn("YesterdayAirQuality", "usa和chn的AQI值都为0,不支持的位置?"); + } + + const index = useUsa ? usa : chn; + const categoryIndex = AirQuality.CategoryIndex(index, scale.categories); + const isSignificant = categoryIndex >= scale.categories.significantIndex; + Console.debug(`index: ${index}`); + + Console.debug("✅ YesterdayAirQuality", `categoryIndex: ${categoryIndex}`); + return { + ...particularAirQuality, + index, + categoryIndex, + isSignificant, + metadata: this.#Metadata(yesterdayHourly.result.server_time, yesterdayHourly.location), + }; + } + + async ForecastHourly(hourlysteps, begin) { + Console.debug("☑️ ForecastHourly"); + const hourly = await this.#Hourly(hourlysteps, begin); + if (!hourly.result) { + Console.error("ForecastHourly", "Failed to get hourly data"); + return { + metadata: this.#Metadata(undefined, undefined, true), + }; + } + + Console.debug("✅ ForecastHourly"); + 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, + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(hourly.result.hourly.skycon[i].value), + // daylight: false, // Not given + forecastStart: (new Date(hourly.result.hourly.skycon[i].datetime).getTime() / 1000) | 0, + humidity: Math.round(hourly.result.hourly.humidity[i].value * 100), + // perceivedPrecipitationIntensity: "", // Not given + precipitationAmount: hourly.result.hourly.precipitation[i].value, + precipitationChance: hourly.result.hourly.precipitation[i].probability, + // precipitationIntensity: 0, // Not given + // precipitationType: "", // Not given + pressure: hourly.result.hourly.pressure[i].value / 100, + // pressureTrend: "", // Not given + // snowfallAmount: 0, // Not given + // snowfallIntensity: 0, // Not given + temperature: hourly.result.hourly.temperature[i].value, + temperatureApparent: hourly.result.hourly.apparent_temperature[i].value, + // temperatureDewPoint: 0, // Not given + // uvIndex: 0, // Not given + visibility: hourly.result.hourly.visibility[i].value * 1000, + windDirection: hourly.result.hourly.wind[i].direction, + // windGust: 0, // Not given + windSpeed: hourly.result.hourly.wind[i].speed, + })), + }; + } +} diff --git a/src/egern/wk3/class/ForecastNextHour.mjs b/src/egern/wk3/class/ForecastNextHour.mjs new file mode 100644 index 00000000..59f746ae --- /dev/null +++ b/src/egern/wk3/class/ForecastNextHour.mjs @@ -0,0 +1,613 @@ +import { Console } from "../utils/index.mjs"; + +export default class ForecastNextHour { + Name = "ForecastNextHour"; + Version = "v1.6.3"; + Author = "iRingo"; + + static #Configs = { + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + }, + WeatherCondition: { + 晴朗: "CLEAR", + 雨夹雪: "SLEET", + 小雨: "DRIZZLE", + 下雨: "RAIN", + 中雨: "RAIN", + 大雨: "HEAVY_RAIN", + 小雪: "FLURRIES", + 下雪: "SNOW", + 中雪: "SNOW", + 大雪: "HEAVY_SNOW", + 冰雹: "HAIL", + }, + PrecipitationType: { + 晴朗: "CLEAR", + 雨夹雪: "SLEET", + rain: "RAIN", + 雨: "RAIN", + snow: "SNOW", + 雪: "SNOW", + 冰雹: "HAIL", + }, + Precipitation: { + Level: { + INVALID: -1, + NO: 0, + LIGHT: 1, + MODERATE: 2, + HEAVY: 3, + EXTREME: 4, + }, + Range: { + /** + * [降水强度 | 彩云天气 API]{@link https://docs.caiyunapp.com/weather-api/v2/v2.6/tables/precip.html} + */ + radar: { + NO: [0, 0.031], + LIGHT: [0.031, 0.25], + MODERATE: [0.25, 0.35], + HEAVY: [0.35, 0.48], + EXTREME: [0.48, Number.MAX_VALUE], + }, + mmph: { + NO: [0, 0.08], + LIGHT: [0.08, 3.44], + MODERATE: [3.44, 11.33], + HEAVY: [11.33, 51.3], + EXTREME: [51.3, Number.MAX_VALUE], + }, + precipitation: { + NO: [0, 0.01], + LIGHT: [0.01, 0.6], + MODERATE: [0.6, 1.65], + HEAVY: [1.65, 8.0], + EXTREME: [8.0, 205.0], + }, + }, + }, + }; + + static WeatherCondition(sentence) { + Console.debug("☑️ WeatherCondition", `sentence: ${sentence}`); + let weatherCondition = "CLEAR"; + Object.keys(ForecastNextHour.#Configs.WeatherCondition).forEach(key => { + if (sentence.includes(key)) weatherCondition = ForecastNextHour.#Configs.WeatherCondition[key]; + }); + Console.debug(`✅ WeatherCondition: ${weatherCondition}`); + return weatherCondition; + } + + // 根据描述文本猜测降水类型 + static PrecipitationType(sentence) { + Console.debug("☑️ PrecipitationType", `sentence: ${sentence}`); + let precipitationType = "CLEAR"; + Object.keys(ForecastNextHour.#Configs.PrecipitationType).forEach(key => { + if (sentence.includes(key)) precipitationType = ForecastNextHour.#Configs.PrecipitationType[key]; + }); + Console.debug(`✅ PrecipitationType: ${precipitationType}`); + return precipitationType; + } + + static Minute(minutes = [], description = "", units = "mmph") { + Console.debug("☑️ Minute"); + const precipitationType = ForecastNextHour.PrecipitationType(description); + // refer: https://docs.caiyunapp.com/weather-api/v2/v2.6/tables/precip.html + + minutes = minutes.map((minute, _i) => { + // 根据precipitationIntensity来猜测生成perceivedPrecipitationIntensity + minute.precipitationIntensity = Math.trunc(minute.precipitationIntensity * 1000000) / 1000000; + minute.perceivedPrecipitationIntensity = ForecastNextHour.#ConvertPrecipitationIntensity(minute.precipitationIntensity, units); + // 然后根据perceivedPrecipitationIntensity和precipitationChance来猜测生成condition和summaryCondition + if (minute.perceivedPrecipitationIntensity > 2) { + // 大雨,强烈感知 + switch (precipitationType) { + case "RAIN": + minute.condition = "HEAVY_RAIN"; + break; + case "SNOW": + minute.condition = "HEAVY_SNOW"; + break; + default: + minute.condition = precipitationType; + break; + } + + minute.summaryCondition = precipitationType; + minute.clear = false; + } else if (minute.perceivedPrecipitationIntensity > 1) { + // 中雨,明显感知 + switch (precipitationType) { + case "RAIN": + minute.condition = "RAIN"; + break; + case "SNOW": + minute.condition = "SNOW"; + break; + default: + minute.condition = precipitationType; + break; + } + minute.summaryCondition = precipitationType; + minute.clear = false; + } else if (minute.perceivedPrecipitationIntensity > 0.1) { + // ❓ perceivedPrecipitationIntensity 小于 0.1, 苹果天气显示为无降水 + // 小雨,可以感知到 + switch (precipitationType) { + case "RAIN": + minute.condition = "DRIZZLE"; + break; + case "SNOW": + minute.condition = "FLURRIES"; + break; + default: + minute.condition = precipitationType; + break; + } + minute.summaryCondition = precipitationType; + minute.clear = false; + } else if (minute.perceivedPrecipitationIntensity > 0) { + // 可能降水 + switch (precipitationType) { + case "RAIN": + minute.condition = "POSSIBLE_DRIZZLE"; + break; + case "SNOW": + minute.condition = "POSSIBLE_FLURRIES"; + break; + default: + minute.condition = `POSSIBLE_${precipitationType}`; + break; + } + minute.summaryCondition = precipitationType; + minute.clear = false; + } else { + minute.condition = "CLEAR"; + minute.summaryCondition = "CLEAR"; + minute.clear = true; + } + //Console.debug(`minutes[${i}]`, JSON.stringify(minute, null, 2)); + return minute; + }); + + Console.debug("✅ Minute"); + return minutes; + } + + static Summary(minutes = []) { + Console.debug("☑️ Summary"); + const Summaries = []; + let Summary = { + condition: "CLEAR", + startTime: 0, + precipitationChance: 0, + precipitationIntensity: 0, + maxCondition: "", + clear: true, + }; + let conditionsCount = {}; + const Length = Math.min(71, minutes.length); + + // 根据当前段内每分钟的 conditions 频次,判定最具代表性的条件 + const getRepresentativeCondition = counts => { + const heavyRain = (counts["HEAVY_RAIN"] || 0) + (counts["HEAVY_SNOW"] || 0); + const moderateRain = (counts["RAIN"] || 0) + (counts["SNOW"] || 0); + const lightRain = (counts["DRIZZLE"] || 0) + (counts["FLURRIES"] || 0); + const possibleRain = (counts["POSSIBLE_DRIZZLE"] || 0) + (counts["POSSIBLE_FLURRIES"] || 0); + + const totalMinutes = Object.values(counts).reduce((a, b) => a + b, 0); + // 阈值:大雨至少需要累计 12 分钟(总时长的 20%),但若该段很短则按 20% 折算,且最少 3 分钟 + const threshold = Math.max(3, Math.min(12, Math.round(totalMinutes * 0.2))); + + if (heavyRain >= threshold) { + return counts["HEAVY_RAIN"] ? "HEAVY_RAIN" : "HEAVY_SNOW"; + } + if (heavyRain + moderateRain >= threshold) { + return counts["RAIN"] ? "RAIN" : "SNOW"; + } + if (heavyRain + moderateRain + lightRain >= threshold) { + return counts["DRIZZLE"] ? "DRIZZLE" : "FLURRIES"; + } + if (heavyRain + moderateRain + lightRain + possibleRain > 0) { + return counts["POSSIBLE_DRIZZLE"] ? "POSSIBLE_DRIZZLE" : "POSSIBLE_FLURRIES"; + } + return "CLEAR"; + }; + + for (let i = 0; i < Length; i++) { + const minute = minutes[i]; + const previousMinute = minutes[i - 1]; + + // 统计计数 + conditionsCount[minute.condition] = (conditionsCount[minute.condition] || 0) + 1; + + switch (i) { + case 0: // 第一个 + Summary.startTime = minute.startTime; + Summary.condition = minute.summaryCondition; + Summary.precipitationChance = minute.precipitationChance; + Summary.precipitationIntensity = minute.precipitationIntensity; + Summary.clear = minute.clear; + if (Length === 1) { + Summary.endTime = 0; + Summary.maxCondition = getRepresentativeCondition(conditionsCount); + Summaries.push({ ...Summary }); + } + break; + case Length - 1: // 最后一个 + Summary.endTime = 0; + Summary.clear = minute.clear; + Summary.maxCondition = getRepresentativeCondition(conditionsCount); + Console.debug(`Summaries[${i}]`, JSON.stringify({ ...minute, ...Summary }, null, 2)); + Summaries.push({ ...Summary }); + break; + default: // 中间 + if (minute.summaryCondition !== previousMinute.summaryCondition) { + // 扣除属于新段的当前 minute 统计值 + conditionsCount[minute.condition]--; + if (conditionsCount[minute.condition] === 0) delete conditionsCount[minute.condition]; + + Summary.endTime = minute.startTime; + Summary.maxCondition = getRepresentativeCondition(conditionsCount); + Console.debug(`Summaries[${i}]`, JSON.stringify({ ...previousMinute, ...Summary }, null, 2)); + Summaries.push({ ...Summary }); + + // 开始新的summary + conditionsCount = {}; + conditionsCount[minute.condition] = 1; + Summary = { + startTime: minute.startTime, + condition: minute.summaryCondition, + precipitationChance: minute.precipitationChance, + precipitationIntensity: minute.precipitationIntensity, + maxCondition: "", + clear: minute.clear, + }; + } else { + // 条件相同,更新最大值 + Summary.precipitationChance = Math.max(Summary.precipitationChance, minute.precipitationChance); + Summary.precipitationIntensity = Math.max(Summary.precipitationIntensity, minute.precipitationIntensity); + } + break; + } + } + Console.debug(`Summaries: ${JSON.stringify(Summaries, null, 2)}`); + Console.debug("✅ Summary"); + return Summaries; + } + + static Condition(summaries = []) { + Console.debug("☑️ Condition"); + const Conditions = []; + if (summaries.length > 4) { + summaries = summaries.slice(0, 4); + if (summaries[3]) { + summaries[3].endTime = 0; + } + } + // 先通过 summaries 定基调 + switch (summaries.map(summary => summary.clear).join("|")) { + case "true": { + // 全程 CLEAR, 无降水, 是 CLEAR + const CLEAR = summaries[0]; // CLEAR 时期 + // CLEAR 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR.maxCondition, + endCondition: CLEAR.maxCondition, + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR.startTime, + endTime: 0, // CLEAR 期间 + }); + break; + } + case "false": { + // 全程 RAIN, 有降水, 是 CONSTANT + const CONSTANT = summaries[0]; // CONSTANT 时期 + // CONSTANT 期间显示为 CONSTANT + Conditions.push({ + beginCondition: CONSTANT.maxCondition, + endCondition: CONSTANT.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: CONSTANT.startTime, // CONSTANT 期间 + endTime: 0, // CONSTANT 期间 + }); + break; + } + case "true|false": { + // 先 CLEAR 后降水, 是 START + const CLEAR = summaries[0]; // START 时期 + const START = summaries[1]; // CONSTANT 时期 + // CLEAR 期间显示为 START + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "START", + parameters: [{ date: START.startTime, type: "FIRST_AT" }], // 降水开始时 + startTime: CLEAR.startTime, // CLEAR 期间 + endTime: CLEAR.endTime, // CLEAR 期间 + }); + // START 期间显示为 CONSTANT + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: START.startTime, // START 期间 + endTime: 0, // START 期间 + }); + break; + } + case "false|true": { + // 先降水后 CLEAR, 是 STOP + const STOP = summaries[0]; // STOP 时期 + const CLEAR = summaries[1]; // CLEAR 时期 + // STOP 期间显示为 STOP + Conditions.push({ + beginCondition: STOP.maxCondition, + endCondition: STOP.maxCondition, + forecastToken: "STOP", + parameters: [{ date: STOP.endTime, type: "FIRST_AT" }], // 降水结束时 + startTime: STOP.startTime, // STOP 期间 + endTime: STOP.endTime, // STOP 期间 + }); + // CLEAR 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR.maxCondition, + endCondition: CLEAR.maxCondition, + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR.startTime, // CLEAR 期间 + endTime: 0, // CLEAR 期间 + }); + break; + } + case "false|true|false": { + // 先降水后 CLEAR, 再降水, 是 STOP_START + const STOP = summaries[0]; // STOP 时期 + const CLEAR = summaries[1]; // START 时期 + const START = summaries[2]; // START 时期 + // STOP 期间显示为 STOP_START + Conditions.push({ + beginCondition: STOP.maxCondition, // 第一次降水降水开始时 + endCondition: START.maxCondition, // 第一次降水降水结束时 + forecastToken: "STOP_START", + parameters: [ + { date: STOP.endTime, type: "FIRST_AT" }, // 第一次降水结束时 + { date: START.startTime, type: "SECOND_AT" }, // 第二次降水开始时 + ], + startTime: STOP.startTime, // STOP 期间 + endTime: STOP.endTime, // STOP 期间 + }); + // CLEAR 期间显示为 START + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "START", + parameters: [{ date: START.startTime, type: "FIRST_AT" }], + startTime: CLEAR.startTime, // CLEAR 期间 + endTime: CLEAR.endTime, // CLEAR 期间 + }); + // START 期间显示为 CONSTANT + Conditions.push({ + beginCondition: START.maxCondition, + endCondition: START.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: START.startTime, // START 期间 + endTime: 0, // START 期间 + }); + break; + } + case "true|false|true": { + // 先 CLEAR 后降水, 再 CLEAR, 是 START_STOP + const CLEAR1 = summaries[0]; // START_STOP 时期 + const STOP = summaries[1]; // STOP 时期 + const CLEAR2 = summaries[2]; // CLEAR 时期 + // CLEAR1 期间显示为 START_STOP + Conditions.push({ + beginCondition: STOP.maxCondition, // STOP 的开始天气 + endCondition: STOP.maxCondition, // STOP 的结束天气 + forecastToken: "START_STOP", + parameters: [ + { date: STOP.startTime, type: "FIRST_AT" }, + { date: STOP.endTime, type: "SECOND_AT" }, + ], + startTime: CLEAR1.startTime, // CLEAR1 期间 + endTime: CLEAR1.endTime, // CLEAR1 期间 + }); + // STOP 期间显示为 STOP + Conditions.push({ + beginCondition: STOP.maxCondition, // STOP 的开始天气 + endCondition: STOP.maxCondition, // STOP 的结束天气 + forecastToken: "STOP", + parameters: [{ date: STOP.endTime, type: "FIRST_AT" }], + startTime: STOP.startTime, // STOP 期间 + endTime: STOP.endTime, // STOP 期间 + }); + // CLEAR2 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR2.maxCondition, // CLEAR2 时期 + endCondition: CLEAR2.maxCondition, // CLEAR2 时期 + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR2.startTime, // CLEAR2 期间 + endTime: 0, // CLEAR2 期间 + }); + break; + } + case "false|true|false|true": { + // 先降水后 CLEAR, 再降水再 CLEAR, 是 STOP_START + START_STOP + const STOP1 = summaries[0]; // STOP 时期 1 + const CLEAR1 = summaries[1]; // CLEAR 时期 1 + const STOP2 = summaries[2]; // STOP 时期 2 + const CLEAR2 = summaries[3]; // CLEAR 时期 2 + // STOP1 期间显示为 STOP_START + Conditions.push({ + beginCondition: STOP1.maxCondition, // STOP1 的开始天气 + endCondition: STOP2.maxCondition, // STOP2 的开始天气 + forecastToken: "STOP_START", + parameters: [ + { date: STOP1.endTime, type: "FIRST_AT" }, // STOP1 结束 + { date: STOP2.startTime, type: "SECOND_AT" }, // STOP2 开始 + ], + startTime: STOP1.startTime, // STOP1 期间 + endTime: STOP1.endTime, // STOP1 期间 + }); + // CLEAR1 期间显示为 START_STOP + Conditions.push({ + beginCondition: STOP2.maxCondition, // STOP 时期 2 + endCondition: STOP2.maxCondition, // STOP 时期 2 + forecastToken: "START_STOP", + parameters: [ + { date: STOP2.startTime, type: "FIRST_AT" }, // STOP2 开始 + { date: STOP2.endTime, type: "SECOND_AT" }, // STOP2 结束 + ], + startTime: CLEAR1.startTime, // CLEAR1 期间 + endTime: CLEAR1.endTime, // CLEAR1 期间 + }); + // STOP2 期间显示为 STOP + Conditions.push({ + beginCondition: STOP2.maxCondition, // STOP 时期 + endCondition: STOP2.maxCondition, // STOP 时期 + forecastToken: "STOP", + parameters: [{ date: STOP2.endTime, type: "FIRST_AT" }], + startTime: STOP2.startTime, // STOP2 期间 + endTime: STOP2.endTime, // STOP2 期间 + }); + // CLEAR2 期间显示为 CLEAR + Conditions.push({ + beginCondition: CLEAR2.maxCondition, // CLEAR 时期 + endCondition: CLEAR2.maxCondition, // CLEAR 时期 + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR2.startTime, // CLEAR2 期间 + endTime: 0, // CLEAR2 期间 + }); + break; + } + case "true|false|true|false": { + // 先 CLEAR 后降水, 再 CLEAR 再降水, 是 START_STOP + STOP_START + const CLEAR1 = summaries[0]; // CLEAR1 时期 + const START1 = summaries[1]; // START1 时期 + const CLEAR2 = summaries[2]; // CLEAR2 时期 + const START2 = summaries[3]; // START2 时期 + // CLEAR1 期间显示为 START_STOP + Conditions.push({ + beginCondition: START1.maxCondition, // START1 的开始天气 + endCondition: START1.maxCondition, // START1 的结束天气 + forecastToken: "START_STOP", + parameters: [ + { date: START1.startTime, type: "FIRST_AT" }, // CLEAR1 结束 + { date: START1.endTime, type: "SECOND_AT" }, // CLEAR2 开始 + ], + startTime: CLEAR1.startTime, // CLEAR1 期间 + endTime: CLEAR1.endTime, // CLEAR1 期间 + }); + // START1 期间显示为 STOP_START + Conditions.push({ + beginCondition: START1.maxCondition, // START1 的结束天气 + endCondition: START2.maxCondition, // START2 的开始天气 + forecastToken: "STOP_START", + parameters: [ + { date: START1.endTime, type: "FIRST_AT" }, // START1 结束 + { date: START2.startTime, type: "SECOND_AT" }, // START2 开始 + ], + startTime: START1.startTime, // START1 期间 + endTime: START1.endTime, // START1 期间 + }); + // CLEAR2 期间显示为 START + Conditions.push({ + beginCondition: START2.maxCondition, // START2.beginCondition, // START2 的开始天气 + endCondition: START2.maxCondition, // START2 的开始天气 + forecastToken: "START", + parameters: [{ date: START2.startTime, type: "FIRST_AT" }], + startTime: CLEAR2.startTime, // CLEAR2 期间 + endTime: CLEAR2.endTime, // CLEAR2 期间 + }); + // START2 期间显示为 CONSTANT + Conditions.push({ + beginCondition: START2.maxCondition, // START2 时期 + endCondition: START2.maxCondition, // START2 时期 + forecastToken: "CONSTANT", + parameters: [], + startTime: START2.startTime, // START2 期间 + endTime: 0, // START2 期间 + }); + break; + } + default: { + // 兜底逻辑:如果前面的模式都匹配不上,则寻找是否有降水段 + const rainSummary = summaries.find(summary => !summary.clear); + if (rainSummary) { + Conditions.push({ + beginCondition: rainSummary.maxCondition, + endCondition: rainSummary.maxCondition, + forecastToken: "CONSTANT", + parameters: [], + startTime: rainSummary.startTime, + endTime: 0, + }); + } else if (summaries.length > 0) { + const CLEAR = summaries[0]; + Conditions.push({ + beginCondition: CLEAR.maxCondition, + endCondition: CLEAR.maxCondition, + forecastToken: "CLEAR", + parameters: [], + startTime: CLEAR.startTime, + endTime: 0, + }); + } + break; + } + } + Console.debug(`Conditions: ${JSON.stringify(Conditions, null, 2)}`); + Console.debug("✅ Condition"); + return Conditions; + } + + static #ConvertPrecipitationIntensity(precipitationIntensity, units = "mmph") { + //Console.debug("☑️ ConvertPrecipitationIntensity"); + //Console.debug(`precipitationIntensity: ${precipitationIntensity}`, `units: ${units}`); + const Range = ForecastNextHour.#Configs.Precipitation.Range[units]; + let perceivedPrecipitationIntensity = 0; + + if (precipitationIntensity === 0) { + // 无降水 + perceivedPrecipitationIntensity = 0; + } else if (precipitationIntensity > Range.NO[0] && precipitationIntensity <= Range.NO[1]) { + // 轻微降水,可能感知不到 + perceivedPrecipitationIntensity = 0; // 轻微降水通常感知不到 + } else if (precipitationIntensity > Range.LIGHT[0] && precipitationIntensity <= Range.LIGHT[1]) { + // 小雨,可以感知到 + // 根据强度计算感知强度,在0-1之间 + perceivedPrecipitationIntensity = Math.min(1, (precipitationIntensity - Range.LIGHT[0]) / (Range.LIGHT[1] - Range.LIGHT[0])); + } else if (precipitationIntensity > Range.MODERATE[0] && precipitationIntensity <= Range.MODERATE[1]) { + // 中雨,明显感知 + // 根据强度计算感知强度,在1-2之间 + perceivedPrecipitationIntensity = 1 + Math.min(1, (precipitationIntensity - Range.MODERATE[0]) / (Range.MODERATE[1] - Range.MODERATE[0])); + } else if (precipitationIntensity > Range.HEAVY[0]) { + // 大雨,强烈感知 + // 根据强度计算感知强度,在2-3之间 + perceivedPrecipitationIntensity = 2 + Math.min(1, (precipitationIntensity - Range.HEAVY[0]) / (Range.HEAVY[1] - Range.HEAVY[0])); + } + + // 使用Math.trunc保留一位小数(性能最快,截断不四舍五入) + perceivedPrecipitationIntensity = Math.trunc(perceivedPrecipitationIntensity * 1000) / 1000; + + //Console.debug(`perceivedPrecipitationIntensity: ${perceivedPrecipitationIntensity}`); + //Console.debug(`✅ ConvertPrecipitationIntensity`); + return perceivedPrecipitationIntensity; + } +} diff --git a/src/egern/wk3/class/QWeather.mjs b/src/egern/wk3/class/QWeather.mjs new file mode 100644 index 00000000..bf174ad8 --- /dev/null +++ b/src/egern/wk3/class/QWeather.mjs @@ -0,0 +1,891 @@ +import AirQuality from "../class/AirQuality.mjs"; +import { Console, fetch } from "../utils/index.mjs"; +import ForecastNextHour from "./ForecastNextHour.mjs"; +import Weather from "./Weather.mjs"; + +export default class QWeather { + constructor(parameters, token, host = "devapi.qweather.com") { + this.Name = "QWeather"; + this.Version = "5.1.0"; + Console.debug(`🟧 ${this.Name} v${this.Version}`); + this.endpoint = `https://${host}`; + this.headers = { "X-QW-Api-Key": token }; + this.version = parameters.version; + this.language = parameters.language; + this.latitude = parameters.latitude; + this.longitude = parameters.longitude; + this.country = parameters.country; + } + + #cache = { + airQualityCurrentPromise: null, + historicalAir: null, + }; + + #Config = { + Pollutants: { + co: "CO", + no: "NO", + no2: "NO2", + so2: "SO2", + o3: "OZONE", + nox: "NOX", + pm25: "PM2_5", + pm2p5: "PM2_5", + pm10: "PM10", + other: "NOT_AVAILABLE", + na: "NOT_AVAILABLE", + undefined: "NOT_AVAILABLE", + null: "NOT_AVAILABLE", + }, + Units: { + "μg/m3": "MICROGRAMS_PER_CUBIC_METER", + "mg/m3": "MILLIGRAMS_PER_CUBIC_METER", + ppb: "PARTS_PER_BILLION", + ppm: "PARTS_PER_MILLION", + }, + Availability: { + Minutely: ["CN", "HK", "MO"], + AirQuality: ["AD", "BE", "BG", "CA", "CN", "HR", "CZ", "DK", "FI", "FR", "DE", "GI", "GR", "HK", "HU", "IE", "JP", "KR", "LV", "LT", "MO", "MT", "NL", "MK", "NO", "PL", "PT", "RO", "RS", "SG", "SK", "SI", "ES", "SE", "CH", "TW", "TH", "GB", "US"], + }, + }; + + static async GetLocationsGrid(qweatherCache, setCache) { + Console.debug("☑️ GetLocationsGrid"); + const locationsGrid = qweatherCache?.locationsGrid; + // cache within 30 days + if (locationsGrid?.lastUpdated && locationsGrid.lastUpdated + 30 * 24 * 60 * 60 * 1000 > Date.now()) { + Console.debug("✅ GetLocationsGrid", "Cache found!"); + return locationsGrid.data; + } + + Console.info("⚠️ GetLocationsGrid", "Cache not found or stale, fetching..."); + try { + 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) { + Console.debug("✅ GetLocationsGrid", "Cache not modified"); + setCache({ ...qweatherCache, locationsGrid: { ...locationsGrid, lastUpdated: Date.now() } }); + return locationsGrid.data; + } + + const newLocationsGrid = JSON.parse(response.body); + const etag = typeof response.headers?.get === "function" ? response.headers.get("ETag") : response.headers?.etag || response.headers?.ETag; + setCache({ + ...qweatherCache, + locationsGrid: { etag, lastUpdated: Date.now(), data: newLocationsGrid }, + }); + Console.debug("✅ GetLocationsGrid"); + return newLocationsGrid; + } catch (error) { + Console.error("GetLocationsGrid error:", error); + return locationsGrid?.data || null; + } + } + + // Codes by Claude AI + static GetLocationInfo(locationsGrid, latitude, longitude) { + Console.debug("☑️ GetLocationInfo"); + + if (!locationsGrid) { + Console.warn("GetLocationInfo", "No locationsGrid provided"); + return null; + } + + const { gridSize, grid } = locationsGrid; + + // Haversine距离计算 + const distance = (lat1, lng1, lat2, lng2) => { + const R = 6371; // 地球半径(km) + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + }; + + const getNearbyGridKeys = (lat, lng, radius = 2) => { + const centerX = Math.floor(lng / gridSize); + const centerY = Math.floor(lat / gridSize); + const keys = []; + + for (let dx = -radius; dx <= radius; dx++) { + for (let dy = -radius; dy <= radius; dy++) { + keys.push(`${centerX + dx},${centerY + dy}`); + } + } + + return keys; + }; + + const findNearestFast = (lat, lng) => { + const keys = getNearbyGridKeys(lat, lng, 2); + let nearest = null; + let minDist = Number.POSITIVE_INFINITY; + + for (const key of keys) { + const locations = grid[key]; + if (!locations) continue; + + for (const loc of locations) { + const dist = distance(lat, lng, loc.latitude, loc.longitude); + if (dist < minDist) { + minDist = dist; + nearest = loc; + } + } + } + + return nearest; + }; + + const nearest = findNearestFast(latitude, longitude); + Console.debug("✅ GetLocationInfo"); + return nearest; + } + + async GeoAPI(path = "city/lookup") { + Console.debug("☑️ GeoAPI"); + const request = { + url: `${this.endpoint}/geo/v2/${path}?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let metadata; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": + metadata = { + attributionUrl: body?.location?.[0]?.fxLink, + latitude: body?.location?.[0]?.lat, + longitude: body?.location?.[0]?.lon, + providerName: "和风天气", + locationID: body?.location?.[0]?.id, + }; + break; + default: + throw Error(body?.code); + } + } catch (error) { + Console.error(`GeoAPI: ${error}`); + } finally { + Console.debug("metadata:", metadata); + Console.debug("✅ GeoAPI"); + } + return metadata; + } + + async WeatherNow() { + Console.debug("☑️ WeatherNow"); + const request = { + url: `${this.endpoint}/v7/weather/now?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let currentWeather; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + currentWeather = { + metadata: { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: (new Date(body?.now?.pubTime).getTime() / 1000) | 0, + temporarilyUnavailable: false, + sourceType: "STATION", + }, + cloudCover: Number.parseInt(body?.now?.cloud, 10), + conditionCode: Weather.ConvertWeatherCode(body?.now?.text), + humidity: Number.parseInt(body?.now?.humidity, 10), + perceivedPrecipitationIntensity: Number.parseFloat(body?.now?.precip), + pressure: Number.parseFloat(body?.now?.pressure), + temperature: Number.parseFloat(body?.now?.temp), + temperatureApparent: Number.parseFloat(body?.now?.feelsLike), + temperatureDewPoint: Number.parseFloat(body?.now?.dew), + visibility: Number.parseFloat(body?.now?.vis) * 1000, + windDirection: Number.parseInt(body?.now?.wind360, 10), + windSpeed: Number.parseFloat(body?.now?.windSpeed), + }; + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`WeatherNow: ${error}`); + } finally { + //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); + Console.debug("✅ WeatherNow"); + } + return currentWeather; + } + + async #AirQualityCurrent() { + Console.debug("☑️ AirQualityCurrent"); + + if (!this.#cache.airQualityCurrentPromise) { + const request = { + url: `${this.endpoint}/airquality/v1/current/${this.latitude}/${this.longitude}`, + headers: this.headers, + }; + this.#cache.airQualityCurrentPromise = fetch(request) + .then(response => JSON.parse(response?.body ?? "{}")) + .then(body => { + if (body?.error === undefined) { + return body; + } + throw Error(JSON.stringify(body?.error, null, 2)); + }) + .catch(error => { + Console.error(`AirQualityCurrent: ${error}`); + this.#cache.airQualityCurrentPromise = null; + return {}; + }); + } else { + Console.debug("✅ AirQualityCurrent", "Using cache promise"); + } + return this.#cache.airQualityCurrentPromise; + } + + async Minutely() { + Console.debug("☑️ Minutely"); + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.Minutely.includes(this.country)) { + Console.warn("Minutely", `Unsupported country: ${this.country}`); + return; + } + + const request = { + url: `${this.endpoint}/v7/minutely/5m?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let forecastNextHour; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + let minuteStemp = new Date(body?.updateTime).setSeconds(0, 0); + minuteStemp = minuteStemp.valueOf() / 1000; + forecastNextHour = { + metadata: { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: timeStamp, + temporarilyUnavailable: false, + sourceType: "MODELED", + }, + condition: [], + forecastEnd: 0, + forecastStart: minuteStemp, + minutes: body?.minutely + ?.map((minutely, _index) => { + const minute = { + perceivedPrecipitationIntensity: 0, + precipitationChance: 0, + precipitationIntensity: Number.parseFloat(minutely.precip) * 12, + startTime: new Date(minutely.fxTime) / 1000, + }; + let minutes = [{ ...minute }, { ...minute }, { ...minute }, { ...minute }, { ...minute }]; + minutes = minutes.map((minute, index) => { + minute.startTime = minute.startTime + index * 60; + return minute; + }); + return minutes; + }) + .flat(Number.POSITIVE_INFINITY), + summary: [], + }; + forecastNextHour.minutes.length = Math.min(85, forecastNextHour.minutes.length); + forecastNextHour.forecastEnd = minuteStemp + 60 * forecastNextHour.minutes.length; + forecastNextHour.minutes = ForecastNextHour.Minute(forecastNextHour.minutes, body?.summary, "mmph"); + forecastNextHour.summary = ForecastNextHour.Summary(forecastNextHour.minutes); + forecastNextHour.condition = ForecastNextHour.Condition(forecastNextHour.summary); + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`Minutely: ${error}`); + } finally { + //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); + Console.debug("✅ Minutely"); + } + return forecastNextHour; + } + + async Hourly(hours = 168) { + Console.debug("☑️ Hourly", `host: ${this.host}`); + const request = { + url: `${this.endpoint}/v7/weather/${hours}h?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let forecastHourly; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + forecastHourly = { + metadata: { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: new Date(body?.updateTime), + temporarilyUnavailable: false, + sourceType: "STATION", + }, + hours: body?.hourly?.map(hourly => { + return { + cloudCover: Number.parseInt(hourly?.cloud, 10), + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(hourly?.text), + // daylight: false, // Not given + forecastStart: (new Date(hourly?.fxTime).getTime() / 1000) | 0, + humidity: Number.parseInt(hourly?.humidity, 10), + // perceivedPrecipitationIntensity: "", // Not given + precipitationAmount: Number.parseFloat(hourly?.precip), + precipitationChance: Number.parseInt(hourly?.pop, 10), + precipitationIntensity: Number.parseInt(hourly?.precip, 10), + // precipitationType: "", // Not given + pressure: Number.parseFloat(hourly?.pressure), + // pressureTrend: "", // Not given + // snowfallAmount: 0, // Not given + // snowfallIntensity: 0, // Not given + temperature: Number.parseFloat(hourly?.temp), + // temperatureApparent: 0, // Not given + temperatureDewPoint: Number.parseFloat(hourly?.dew), + // uvIndex: 0, // Not given + // visibility: 0, // Not given + windDirection: Number.parseInt(hourly?.wind360, 10), + // windGust: 0, // Not given + windSpeed: Number.parseFloat(hourly?.windSpeed), + }; + }), + }; + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`Hourly: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(forecastHourly, null, 2)}`); + Console.debug("✅ Hourly"); + } + return forecastHourly; + } + + async Daily(days = 10) { + Console.debug("☑️ Daily", `host: ${this.host}`); + const request = { + url: `${this.endpoint}/v7/weather/${days}d?location=${this.longitude},${this.latitude}`, + headers: this.headers, + }; + let forecastDaily; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + const timeStamp = (Date.now() / 1000) | 0; + const metadata = { + attributionUrl: body?.fxLink, + expireTime: timeStamp + 60 * 60, + language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, + latitude: this.latitude, + longitude: this.longitude, + + providerName: "和风天气", + readTime: timeStamp, + reportedTime: new Date(body?.updateTime), + temporarilyUnavailable: false, + sourceType: "STATION", + }; + const timezoneOffset = metadata.reportedTime.getTimezoneOffset(); + forecastDaily = { + metadata: metadata, + days: body?.daily?.map(daily => { + const timeStamp = ((Date.parse(daily?.fxDate) / 1000) | 0) + timezoneOffset * 60; // 本地转 Unix 时间戳 + return { + forecastStart: timeStamp, + forecastEnd: timeStamp + 24 * 3600, // 24 hours + // conditionCode: Weather.ConvertWeatherCode(daily?.textDay), // Not given (用白天数据代替) + // humidity 用一整天的数据代替 + // humidityMax: daily?.humidity, // Not Accurate + // humidityMin: daily?.humidity, // Not Accurate + maxUvIndex: Number.parseInt(daily?.uvIndex, 10), + moonPhase: Weather.ConvertMoonPhase(daily?.moonPhase), + moonrise: this.#ConvertTimeStamp(daily?.fxDate, daily?.moonrise), + moonset: this.#ConvertTimeStamp(daily?.fxDate, daily?.moonset), + precipitationAmount: Number.parseFloat(daily?.precip), + // precipitationAmountByType: [], // Not given + // precipitationChance: 0, // Not given + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // solarMidnight: 0, // Not given + // solarNoon: 0, // Not given + sunrise: this.#ConvertTimeStamp(daily?.fxDate, daily?.sunrise), + // sunriseAstronomical: 0, // Not given + // sunriseCivil: 0, // Not given + // sunriseNautical: 0, // Not given + sunset: this.#ConvertTimeStamp(daily?.fxDate, daily?.sunset), + // sunsetAstronomical: 0, // Not given + // sunsetCivil: 0, // Not given + // sunsetNautical: 0, // Not given + temperatureMax: Number.parseFloat(daily?.tempMax), + // temperatureMaxTime: 0, // Not given + temperatureMin: Number.parseFloat(daily?.tempMin), + // temperatureMinTime: 0, // Not given + // visibilityMax: 0, // Not given + // visibilityMin: 0, // Not given + // windGustSpeedMax: 0, // Not given + windSpeedAvg: (Number.parseFloat(daily?.windSpeedDay) * 7 + Number.parseFloat(daily?.windSpeedNight) * 17) / 24, // 加权平均:白天7小时,晚上17小时 + // windSpeedMax: 0, // Not given + daytimeForecast: { + forecastStart: timeStamp + 7 * 3600, // 7 hours + forecastEnd: timeStamp + 7 * 3600 + 12 * 3600, // 7 + 12 hours + // cloudCover: 0, // Not given + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(daily?.textDay), + // humidity 用一整天的数据代替 + // humidityMax: daily?.humidity, // Not Accurate + // humidityMin: daily?.humidity, // Not Accurate + precipitationAmount: Number.parseFloat(daily?.precip), + // precipitationAmountByType: [], // Not given + // precipitationChance: 0, // Not given + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // temperatureMax: 0, // Not given + // temperatureMin: 0, // Not given + // visibility 用一整天的数据代替 + // visibilityMax: 0, // Not given + // visibilityMin: 0, // Not given + windDirection: Number.parseInt(daily?.wind360Day, 10), + // windGustSpeedMax: 0, // Not given + windSpeed: Number.parseFloat(daily?.windSpeedDay), + // windSpeedMax: 0, // Not given + }, + overnightForecast: { + forecastStart: timeStamp + 19 * 3600, // 19 hours + forecastEnd: timeStamp + 19 * 3600 + 12 * 3600, // 19 + 12 hours + // cloudCover: 0, // Not given + // cloudCoverHighAltPct: 0, // Not given + // cloudCoverLowAltPct: 0, // Not given + // cloudCoverMidAltPct: 0, // Not given + conditionCode: Weather.ConvertWeatherCode(daily?.textNight), + // humidity 用一整天的数据代替 + // humidityMax: daily?.humidity, // Not Accurate + // humidityMin: daily?.humidity, // Not Accurate + precipitationAmount: Number.parseFloat(daily?.precip), + // precipitationAmountByType: [], // Not given + // precipitationChance: 0, // Not given + // precipitationType: "", // Not given + // snowfallAmount: 0, // Not given + // temperatureMax: 0, // Not given + // temperatureMin: 0, // Not given + // visibility 用一整天的数据代替 + // visibilityMax: 0, // Not given + // visibilityMin: 0, // Not given + windDirection: Number.parseInt(daily?.wind360Night, 10), + // windGustSpeedMax: 0, // Not given + windSpeed: Number.parseFloat(daily?.windSpeedNight), + // windSpeedMax: 0, // Not given + }, + }; + }), + }; + break; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`Daily: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(forecastDaily, null, 2)}`); + Console.debug("✅ Daily"); + } + return forecastDaily; + } + + async #HistoricalAir(locationID = new Number(), date) { + if (!date) { + const d = new Date(Date.now() - 86400000); + date = d.getFullYear() + String(d.getMonth() + 1).padStart(2, "0") + String(d.getDate()).padStart(2, "0"); + } + Console.debug("☑️ HistoricalAir", `locationID: ${locationID}`, `date: ${date}`); + const request = { + url: `${this.endpoint}/v7/historical/air?location=${locationID}&date=${date}`, + headers: this.headers, + }; + try { + const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + switch (body?.code) { + case "200": { + return body; + } + case "204": + case "400": + case "401": + case "402": + case "403": + case "404": + case "429": + case "500": + case undefined: + throw Error(body?.code); + } + } catch (error) { + Console.error(`HistoricalAir: ${error}`); + } finally { + //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); + Console.debug("✅ HistoricalAir"); + } + return {}; + } + + #Metadata(attributionUrl = `https://www.qweather.com/`, sourceType = "MODELED", temporarilyUnavailable = false) { + const timeStamp = Date.now() / 1000; + return { + longitude: this.longitude, + providerName: "和风天气", + + reportedTime: timeStamp, + latitude: this.latitude, + expireTime: timeStamp + 60 * 60, + attributionUrl, + temporarilyUnavailable, + readTime: timeStamp, + sourceType, + }; + } + + /** + * 创建 WeatherKit 格式污染物对象(airquality/v1/current 数据源)。 + * @link https://dev.qweather.com/docs/airquality/ + * @param {Array<{ + * code: string, + * concentration: { value: number, unit: string }, + * subIndexes?: Array<{ code: string, aqi: number }> + * }>} pollutantsObj - 原始污染物数组。 + * @param {string} [scaleCode] - 目标指数口径 code(如 cn-mee / us-epa)。 + * @returns {Array<{ + * pollutantType: string, + * amount: number, + * units: string, + * index?: number + * }>} 转换后的污染物数组。 + */ + #CreatePollutants(pollutantsObj, scaleCode) { + Console.debug("☑️ CreatePollutants"); + Console.debug("pollutantsObj:", pollutantsObj); + + // TODO: what is ppmC? https://dev.qweather.com/docs/resource/air-info/#pollutants + const pollutants = pollutantsObj + .filter(pollutant => pollutant.concentration.unit !== "ppmC") + .map(({ code, concentration, subIndexes = [] }) => { + const { value, unit } = concentration; + const normalizedUnit = unit.replace("³", "3"); + const pollutantType = this.#Config.Pollutants[code]; + const indexObj = subIndexes.find(subIndex => subIndex.code === scaleCode); + if (scaleCode && !indexObj) Console.warn("CreatePollutants", `No index for ${pollutantType} was found for required scale`); + + const friendlyUnits = AirQuality.Config.Units.Friendly; + const { ugm3, mgm3, ppb, ppm } = AirQuality.Config.Units.WeatherKit; + switch (normalizedUnit) { + case friendlyUnits.MILLIGRAMS_PER_CUBIC_METER: + return { pollutantType, amount: AirQuality.ConvertUnit(value, mgm3, ugm3), units: ugm3, index: scaleCode ? (indexObj?.aqi ?? -1) : undefined }; + case friendlyUnits.PARTS_PER_MILLION: + return { pollutantType, amount: AirQuality.ConvertUnit(value, ppm, ppb), units: ppb, index: scaleCode ? (indexObj?.aqi ?? -1) : undefined }; + default: + return { pollutantType, amount: value, units: this.#Config.Units[normalizedUnit], index: scaleCode ? (indexObj?.aqi ?? -1) : undefined }; + } + }); + + Console.debug("✅ CreatePollutants"); + return pollutants; + } + + /** + * 创建 WeatherKit 格式污染物对象(v7/air/now 与 historical/air 数据源)。 + * @link https://dev.qweather.com/docs/resource/unit/ + * @param {Object} pollutantsObj - v7 接口返回的污染物键值对象。 + * @returns {Array<{amount: number, pollutantType: string, units: string}>} 转换后的污染物数组。 + */ + #CreatePollutantsV7(pollutantsObj) { + Console.debug("☑️ CreatePollutantsV7"); + Console.debug("pollutantsObj:", pollutantsObj); + + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = Object.entries(pollutantsObj) + .map(([name, amount]) => { + const parsedAmount = Number.parseFloat(amount); + switch (name) { + case "co": + return { + amount: AirQuality.ConvertUnit(parsedAmount ?? -1, mgm3, ugm3), + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + }; + case "no": + case "no2": + case "so2": + case "o3": + case "nox": + case "pm25": + case "pm10": + return { + amount: parsedAmount ?? -1, + pollutantType: this.#Config.Pollutants[name], + units: ugm3, + }; + default: + return null; + } + }) + .filter(Boolean); + + Console.debug("✅ CreatePollutantsV7"); + return pollutants; + } + + async CurrentAirQuality(forcePrimaryPollutant = true) { + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.AirQuality.includes(this.country)) { + Console.warn("CurrentAirQuality", `Unsupported country: ${this.country}`); + return { + metadata: this.#Metadata(`https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, undefined, true), + pollutants: [], + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + } + + const findSupportedIndex = indexes => { + Console.debug("☑️ findSupportedIndex"); + + const supportedCodes = ["cn-mee", "cn-mee-1h", "eu-eea", "us-epa", "us-epa-nc"]; + for (const index of indexes) { + if (supportedCodes.includes(index.code)) { + Console.debug("✅ indexCodeToScale", `index.code: ${index.code}`); + return index; + } + } + + return {}; + }; + + const indexCodeToScale = code => { + Console.debug("☑️ indexCodeToScale", `code: ${code}`); + + const { HJ6332012, EPA_NowCast, EU_EAQI } = AirQuality.Config.Scales; + switch (code) { + // We don't need calcualtion so they are same + case "cn-mee": + case "cn-mee-1h": + Console.debug("✅ indexCodeToScale", "HJ6332012"); + return HJ6332012; + case "us-epa": + case "us-epa-nc": + Console.debug("✅ indexCodeToScale", "EPA_NowCast"); + return EPA_NowCast; + case "eu-eea": + Console.debug("✅ indexCodeToScale", "EU_EAQI"); + return EU_EAQI; + default: + Console.error("indexCodeToScale", "不支持的code"); + return {}; + } + }; + + Console.debug("☑️ CurrentAirQuality"); + const airQualityCurrent = await this.#AirQualityCurrent(); + if (!Array.isArray(airQualityCurrent.pollutants)) { + Console.error("AirQuality", "Failed to get current air quality data."); + return { + metadata: this.#Metadata( + // TODO: &lang=zh + `https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, + undefined, + true, + ), + pollutants: [], + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + } + + const supportedIndex = findSupportedIndex(airQualityCurrent.indexes); + const scale = indexCodeToScale(supportedIndex?.code); + + const particularAirQuality = { + metadata: this.#Metadata( + // TODO: &lang=zh + `https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, + ), + pollutants: this.#CreatePollutants(airQualityCurrent.pollutants, supportedIndex?.code), + previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + + if (!supportedIndex?.code || !scale?.categories) { + Console.error("AirQuality", "No supported index found", `airQualityCurrent.indexes[].code = ${JSON.stringify(airQualityCurrent.indexes?.map(({ code }) => code))}`); + return { + ...particularAirQuality, + index: -1, + isSignificant: false, + categoryIndex: -1, + primaryPollutant: "NOT_AVAILABLE", + scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale), + }; + } + + const categoryIndex = Number.parseInt(supportedIndex.level, 10); + const apiPrimaryPollutant = this.#Config.Pollutants[supportedIndex.primaryPollutant?.code] || "NOT_AVAILABLE"; + Console.debug(`apiPrimaryPollutant: ${apiPrimaryPollutant}`); + + if (!forcePrimaryPollutant && apiPrimaryPollutant === "NOT_AVAILABLE") { + Console.info("CurrentAirQuality", "Max index of pollutants is <= 50, primaryPollutant will be NOT_AVAILABLE."); + } + + const airQuality = { + metadata: this.#Metadata( + // TODO: &lang=zh + `https://www.qweather.com/air/a/${this.latitude},${this.longitude}?from=AppleWeatherService`, + ), + categoryIndex, + index: supportedIndex.aqi, + isSignificant: categoryIndex >= scale.categories.significantIndex, + ...particularAirQuality, + primaryPollutant: apiPrimaryPollutant, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + + if (airQuality.primaryPollutant === "NOT_AVAILABLE") { + const calculatedPrimaryPollutant = AirQuality.PrimaryPollutant(particularAirQuality.pollutants, scale.categories); + const isNotAvailable = !forcePrimaryPollutant && calculatedPrimaryPollutant.index <= 50; + if (isNotAvailable) { + Console.info("CurrentAirQuality", `Max index of pollutants ${calculatedPrimaryPollutant.pollutantType} = ${calculatedPrimaryPollutant.index} is <= 50, primaryPollutant will be set to NOT_AVAILABLE.`); + } + if (!isNotAvailable) airQuality.primaryPollutant = calculatedPrimaryPollutant.pollutantType; + } + + Console.debug("✅ CurrentAirQuality"); + return airQuality; + } + + async prefetchYesterdayAirQuality(locationInfo) { + Console.debug("☑️ prefetchYesterdayAirQuality"); + if (locationInfo && locationInfo.id && locationInfo.id.length === 9) { + this.#cache.historicalAir = this.#HistoricalAir(locationInfo.id); + const result = await this.#cache.historicalAir; + Console.debug("✅ prefetchYesterdayAirQuality"); + return result; + } + Console.warn("prefetchYesterdayAirQuality", "Invalid locationInfo"); + return null; + } + + async YesterdayAirQuality(locationInfo) { + Console.debug("☑️ YesterdayAirQuality", "locationInfo:", locationInfo); + const failedAirQuality = { + metadata: this.#Metadata(undefined, undefined, true), + categoryIndex: -1, + pollutants: [], + }; + + // 判断可用性:当前数据源不支持这个国家/地区 + if (!this.#Config.Availability.AirQuality.includes(this.country)) { + Console.warn("YesterdayAirQuality", `Unsupported country: ${this.country}`); + return failedAirQuality; + } + + // Some locationID at Hong Kong and Macau with length 9 is supported + if (!locationInfo || locationInfo.iso === "TW" || locationInfo.id.length !== 9) { + Console.error("YesterdayAirQuality", "Unsupported location"); + return failedAirQuality; + } + + const historicalAir = await (this.#cache.historicalAir ?? this.#HistoricalAir(locationInfo.id)); + if (!historicalAir.airHourly) { + Console.error("YesterdayAirQuality", `Failed to get HistoricalAir(${locationInfo.id})`); + return failedAirQuality; + } + + const hour = new Date().getHours(); + const categoryIndex = Number.parseInt(historicalAir.airHourly[hour].level, 10); + const index = Number.parseInt(historicalAir.airHourly[hour].aqi, 10); + const pollutants = this.#CreatePollutantsV7(historicalAir.airHourly[hour]); + Console.debug(`hour: ${hour}`, `index: ${index}`); + + Console.debug("✅ YesterdayAirQuality", "pollutants:", pollutants, "categoryIndex:", categoryIndex); + return { + metadata: this.#Metadata(historicalAir.fxLink), + categoryIndex, + index, + pollutants, + primaryPollutant: this.#Config.Pollutants[historicalAir.airHourly[hour].primary] || "NOT_AVAILABLE", + scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale), + }; + } + + #ConvertTimeStamp(fxDate, time) { + const dateTime = `${fxDate}T${time}:00+08:00`; + return (new Date(dateTime).getTime() / 1000) | 0; + } +} diff --git a/src/egern/wk3/class/SimplePrecisionMath.mjs b/src/egern/wk3/class/SimplePrecisionMath.mjs new file mode 100644 index 00000000..1c7d4379 --- /dev/null +++ b/src/egern/wk3/class/SimplePrecisionMath.mjs @@ -0,0 +1,29 @@ +export default class SimplePrecisionMath { + /** + * 乘法 + */ + static multiply(a, b, _decimals = 15) { + return a * b; + } + + /** + * 除法 + */ + static divide(a, b, _decimals = 15) { + return b === 0 ? 0 : a / b; + } + + /** + * 加法 + */ + static add(a, b, _decimals = 15) { + return a + b; + } + + /** + * 减法 + */ + static subtract(a, b, _decimals = 15) { + return a - b; + } +} diff --git a/src/egern/wk3/class/Weather.mjs b/src/egern/wk3/class/Weather.mjs new file mode 100644 index 00000000..b0c00a12 --- /dev/null +++ b/src/egern/wk3/class/Weather.mjs @@ -0,0 +1,199 @@ +import { Console } from "../utils/index.mjs"; + +export default class Weather { + static Name = "Weather"; + static Version = "0.3.0"; + static Author = "Virgil Clyne & 001"; + + /** + * 将 DSWRF(W/m²)估算为 UV Index(整数) + * @param {number} dswrf - 向下短波辐射通量 + * @param {number} k - UV 占比系数,可选,默认 0.04 + * @returns {number} UV Index(四舍五入为整数) + */ + static ConvertDSWRF(dswrf, k = 0.04) { + Console.debug("☑️ ConvertDSWRF"); + const uvIndex = Math.round((Math.max(dswrf, 0) * k) / 0.025); // 估算 UV Index + //Console.debug(`UV Index: ${uvIndex}`); + Console.debug("✅ ConvertDSWRF"); + // 限制结果在 0~11,并四舍五入为整数 + return Math.min(uvIndex, 11); + } + + /** + * 将新的气象数组合并到原始气象数组 + * @param {array} to - 原始气象数组 + * @param {array} from - 新的气象数组 + * @returns {array} 原始气象数组 + */ + static mergeForecast(to = [], from = []) { + let i = 0, + j = 0; + while (i < to.length) { + const forecastStart = to[i].forecastStart; + const newForecastStart = j < from.length ? from[j].forecastStart : Number.POSITIVE_INFINITY; + + if (forecastStart === newForecastStart) { + //Console.debug(`${i}: ${newForecastStart} -> ${forecastStart}`); + // 原地把 from[j] 的字段合入 to[i](A 冲突字段保留 or 被覆盖,看你需要) + if (Object.hasOwn(from[j], "daytimeForecast")) from[j].daytimeForecast = { ...to[i].daytimeForecast, ...from[j].daytimeForecast }; + if (Object.hasOwn(from[j], "overnightForecast")) from[j].overnightForecast = { ...to[i].overnightForecast, ...from[j].overnightForecast }; + if (Object.hasOwn(from[j], "restOfDayForecast")) from[j].restOfDayForecast = { ...to[i].restOfDayForecast, ...from[j].restOfDayForecast }; + Object.assign(to[i], from[j]); // 或者:Object.assign(to[i], {/* 自定义映射 */}) + i++; + j++; + } else if (newForecastStart < forecastStart) { + //Console.debug(`${j}: ${newForecastStart} -> X`); + j++; // 让 from 追上 to + } else { + //Console.debug(`${i}: X -> ${forecastStart}`); + i++; // to 无匹配,保留 to[i] + } + } + return to; // 可选:返回同一个引用 + } + + static ConvertWeatherCode(skycon) { + switch (skycon) { + // 晴天 + case "晴": + case "CLEAR_DAY": + case "CLEAR_NIGHT": + return "CLEAR"; + + // 多云相关 + case "多云": + case "PARTLY_CLOUDY_DAY": + case "PARTLY_CLOUDY_NIGHT": + return "PARTLY_CLOUDY"; + case "少云": + return "MOSTLY_CLEAR"; + case "晴间多云": + return "PARTLY_CLOUDY"; + case "阴": + case "CLOUDY": + return "CLOUDY"; + + // 风相关 + case "WIND": + return "WINDY"; + + // 雾霾相关 + case "薄雾": + case "雾": + case "浓雾": + case "强浓雾": + case "大雾": + case "特强浓雾": + case "FOG": + return "FOGGY"; + case "霾": + case "中度霾": + case "重度霾": + case "严重霾": + case "LIGHT_HAZE": + case "MODERATE_HAZE": + case "HEAVY_HAZE": + return "HAZE"; + + // 沙尘相关(Apple 缺失 DUST/SAND 定义,暂用 HAZE 代替) + case "扬沙": + case "浮尘": + case "沙尘暴": + case "强沙尘暴": + case "DUST": + case "SAND": + return "HAZE"; + + // 降雨相关 + case "小雨": + case "毛毛雨/细雨": + case "LIGHT_RAIN": + return "DRIZZLE"; + case "雨": + case "阵雨": + case "中雨": + case "小到中雨": + case "MODERATE_RAIN": + return "RAIN"; + case "大雨": + case "中到大雨": + return "HEAVY_RAIN"; + case "暴雨": + case "大暴雨": + case "强降雨": + case "特大暴雨": + case "大到暴雨": + case "暴雨到大暴雨": + case "大暴雨到特大暴雨": + case "极端降雨": + case "HEAVY_RAIN": + return "HEAVY_RAIN"; + case "雷阵雨": + case "强雷阵雨": + case "STORM_RAIN": + case "雷阵雨伴有冰雹": + return "THUNDERSTORMS"; + + // 降雪相关 + case "小雪": + case "LIGHT_SNOW": + return "FLURRIES"; + case "雪": + case "阵雪": + case "中雪": + case "小到中雪": + case "MODERATE_SNOW": + return "SNOW"; + case "大雪": + case "中到大雪": + case "HEAVY_SNOW": + return "HEAVY_SNOW"; + case "暴雪": + case "大到暴雪": + case "STORM_SNOW": + return "BLIZZARD"; + + // 雨雪混合 + case "雨夹雪": + case "雨雪天气": + case "阵雨夹雪": + case "冻雨": + return "FREEZING_DRIZZLE"; + + // 温度相关 + case "热": + case "冷": + + // 未知 + case "未知": + default: + Console.debug(`skycon: ${skycon}`); + return null; + } + } + + static ConvertMoonPhase(moonPhase) { + switch (moonPhase) { + case "新月": + return "NEW"; + case "蛾眉月": + return "WAXING_CRESCENT"; + case "上弦月": + return "FIRST_QUARTER"; + case "盈凸月": + return "WAXING_GIBBOUS"; + case "满月": + return "FULL"; + case "亏凸月": + return "WANING_GIBBOUS"; + case "下弦月": + return "THIRD_QUARTER"; + case "残月": + return "WANING_CRESCENT"; + default: + Console.debug(`moonPhase: ${moonPhase}`); + return null; + } + } +} diff --git a/src/egern/wk3/class/WeatherAlerts.mjs b/src/egern/wk3/class/WeatherAlerts.mjs new file mode 100644 index 00000000..13680f98 --- /dev/null +++ b/src/egern/wk3/class/WeatherAlerts.mjs @@ -0,0 +1,333 @@ +/** Pure subset adapted from NSRingo/WeatherKit v3.3.2 (Apache-2.0). + * REST details JSON only: do not pass its lowercase enum tokens to WK3 wire. + * Existing provider classes intentionally remain unchanged. */ +export default class WeatherAlerts { + /** No Apple authorization/cookies are forwarded to QWeather. fetchImpl may + * be the fork's fetch wrapper; both standard Response and body responses work. + * Routing must intercept region identifiers before proxying to Apple. */ + static async FetchWeatherAlertPage(identifier, language = "zh-CN", fetchImpl = globalThis.fetch) { + const url = WeatherAlerts.BuildWeatherAlertPageURL(identifier, language); + const response = await fetchImpl(url.toString(), { redirect: "error", headers: { Accept: "text/html" } }); + const status = response.status ?? response.statusCode; + if (status !== 200) throw new Error("QWeather alert page unavailable"); + const headers = response.headers; + const contentType = typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"]; + if (!/text\/html/i.test(contentType ?? "")) throw new TypeError("Expected QWeather HTML"); + const html = typeof response.text === "function" ? await response.text() : response.body; + if (typeof html !== "string" || new TextEncoder().encode(html).length > 2 * 1024 * 1024) throw new RangeError("Invalid QWeather page size"); + return WeatherAlerts.ExtractWeatherAlertPage(html); + } + + static async DetailsForQWeather(identifier, language = "zh-CN", fetchImpl = globalThis.fetch) { + const extracted = await WeatherAlerts.FetchWeatherAlertPage(identifier, language, fetchImpl); + return WeatherAlerts.Build(extracted, { identifier, language, attributionUrl: WeatherAlerts.BuildWeatherAlertPageURL(identifier, language), countryCode: "CN", eventSource: "CN" }); + } + + static Build(extracted, context) { + const contextAreaId = context.identifier.match(/-(\d+)$/)?.[1]; + return extracted.alerts.map((alert, precedence) => { + const uid = WeatherAlerts.#StableUUID(`${context.identifier}:${alert.identifier ?? precedence}`); + const messages = []; + for (const text of [alert.message, alert.standard, alert.guidelines?.filter(Boolean).join("\n")]) { + if (text) messages.push({ language: context.language, text }); + } + if (!messages.length && alert.description) messages.push({ language: context.language, text: alert.description }); + const responses = WeatherAlerts.#BuildResponses(alert.guidelines, alert.responses); + 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 eventOnsetTime = alert.eventOnsetTime ?? effectiveTime; + const eventEndTime = alert.eventEndTime ?? (alert.expireTime ? expireTime : undefined); + const source = alert.source || extracted.source || ""; + const importance = alert.importance || WeatherAlerts.#ImportanceFromSeverity(alert.severity); + const phenomenon = alert.phenomenon; + const description = WeatherAlerts.#NormalizeWeatherAlertTitle(alert.description, alert.eventName); + return { + id: uid, + ...(areaId ? { areaId } : {}), + ...(areaName ? { areaName } : {}), + attributionURL: context.attributionUrl.toString(), + certainty: alert.certainty || "unknown", + countryCode: context.countryCode ?? "", + description, + detailsUrl: `#${uid}`, + effectiveTime, + ...(eventEndTime ? { eventEndTime } : {}), + ...(eventOnsetTime ? { eventOnsetTime } : {}), + eventSource: context.eventSource ?? context.countryCode ?? "", + expireTime, + issuedTime: alert.issuedTime, + ...(importance ? { importance } : {}), + messages, + name: "WeatherAlert", + ...(phenomenon ? { phenomenon } : {}), + precedence, + responses, + ...(alert.significance ? { significance: alert.significance } : {}), + reportedAt: alert.reportedAt, + severity: alert.severity, + source, + ...(alert.token ? { token: alert.token } : {}), + urgency: alert.urgency || "unknown", + }; + }); + } + + static ParseWeatherAlertPageURL(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "www.qweather.com") return undefined; + if (url.search !== "?from=AppleWeatherService" || url.hash) return undefined; + return decodeURIComponent(url.pathname).match(/^\/{1,2}(?:en\/)?severe-weather\/([^/]+)\.html$/)?.[1]; + } catch { + return undefined; + } + } + + static BuildWeatherAlertPageURL(identifier, language = "zh-CN", includeAppleSource = true) { + identifier = identifier?.trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*-\d+$/.test(identifier ?? "")) throw new TypeError("Invalid QWeather location identifier"); + const url = new URL("https://www.qweather.com"); + url.pathname = language.toLowerCase().startsWith("en") ? `/en/severe-weather/${identifier}.html` : `/severe-weather/${identifier}.html`; + if (includeAppleSource) url.searchParams.set("from", "AppleWeatherService"); + return url; + } + + static BuildAppleAlertDetailsURL(identifier, language = "zh-CN") { + return `https://weatherkit.apple.com/alertDetails/index.html?lang=${encodeURIComponent(language)}&ids=${encodeURIComponent(identifier)}&party=qweather`; + } + + static ExtractWeatherAlertPage(html) { + const sourceHtml = String(html ?? ""); + let city = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /]*class=["'][^"']*c-submenu__location[^"']*["'][^>]*>([\s\S]*?)<\/h1>/i); + if (!city) { + const titleText = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /([\s\S]*?)<\/title>/i); + city = titleText.replace(/\s*(?:severe weather warning|灾害预警|天气预警).*$/i, "").trim(); + } + const administration = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /<span[^>]*class=["'][^"']*c-submenu__location-adm[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); + const dataSource = WeatherAlerts.#FirstWeatherAlertPageMatch(sourceHtml, /<a[^>]*class=["'][^"']*data-source__txt[^"']*["'][^>]*>([\s\S]*?)<\/a>/i) + .replace(/^(?:预警数据来源|Warning data source)\s*[::]\s*/i, "") + .trim(); + const starts = Array.from(sourceHtml.matchAll(/<div[^>]*class=["']([^"']*\bc-city-warning-events\b[^"']*)["'][^>]*>/gi), match => ({ + index: match.index, + contentStart: match.index + match[0].length, + className: match[1], + })); + const alerts = []; + + for (const [index, start] of starts.entries()) { + let end = index + 1 < starts.length ? starts[index + 1].index : sourceHtml.length; + const nearby = sourceHtml.indexOf('<div class="c-city-warning-around">', start.contentStart); + if (nearby !== -1 && nearby < end) end = nearby; + const block = sourceHtml.slice(start.contentStart, end); + const description = WeatherAlerts.#FirstWeatherAlertPageMatch(block, /<h3[^>]*>([\s\S]*?)<\/h3>/i); + const issueText = WeatherAlerts.#FirstWeatherAlertPageMatch(block, /<p[^>]*>\s*((?:Issue\s+date|发布\s*日期)\s*[::][\s\S]*?)<\/p>/i) + .replace(/^(?:Issue\s+date|发布\s*日期)\s*[::]\s*/i, "") + .trim(); + const message = WeatherAlerts.#FirstWeatherAlertPageMatch(block, /<p[^>]*class=["'][^"']*warning-events__txt[^"']*["'][^>]*>([\s\S]*?)<\/p>/i); + const standardBlock = block.match(/<div[^>]*class=["'][^"']*warning-explain[^"']*["'][^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? ""; + const standard = WeatherAlerts.#FirstWeatherAlertPageMatch(standardBlock, /<h4[^>]*>[\s\S]*?<\/h4>\s*<p[^>]*>([\s\S]*?)<\/p>/i); + const guideBlock = block.match(/<div[^>]*class=["'][^"']*warning-defense__txt[^"']*["'][^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? ""; + const guidelines = Array.from(guideBlock.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi), match => + WeatherAlerts.#DecodeWeatherAlertPageHTML(match[1]) + .replace(/^\s*\d+[.、]\s*/, "") + .trim(), + ).filter(Boolean); + const normalizedIssueText = issueText && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(issueText) ? `${issueText.replace(" ", "T")}+08:00` : issueText; + const issueDate = new Date(normalizedIssueText); + if ((!description && !message) || Number.isNaN(issueDate.getTime())) continue; + const headline = description || message; + const parsedHeadline = WeatherAlerts.#ParseWeatherAlertPageHeadline(headline); + const source = parsedHeadline.source || dataSource || "QWeather"; + + const severitySource = `${start.className} ${description}`.toLowerCase(); + let severity = "unknown"; + switch (true) { + case /warning--red|红色|\bred\b/.test(severitySource): + severity = "extreme"; + break; + case /warning--orange|橙色|\borange\b/.test(severitySource): + severity = "severe"; + break; + case /warning--yellow|黄色|\byellow\b/.test(severitySource): + severity = "moderate"; + break; + case /warning--blue|蓝色|\bblue\b/.test(severitySource): + severity = "minor"; + break; + } + + alerts.push({ + description: headline, + ...(parsedHeadline.eventName ? { eventName: parsedHeadline.eventName } : {}), + guidelines, + issuedTime: issueDate.toISOString(), + message, + reportedAt: issueDate.toISOString(), + severity, + source, + standard, + }); + } + + return { + alerts, + areaName: city || administration, + source: alerts.find(alert => alert?.source)?.source || dataSource || "QWeather", + }; + } + + static #StableUUID(value) { + const words = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35]; + for (let index = 0; index < words.length; index++) { + for (let offset = 0; offset < value.length; offset++) { + words[index] ^= value.charCodeAt(offset) + index; + words[index] = Math.imul(words[index], 0x01000193); + } + } + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + for (let index = 0; index < words.length; index++) view.setUint32(index * 4, words[index]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + static #BuildResponses(guidelines, preferredResponses = []) { + const responses = WeatherAlerts.#NormalizeResponses(preferredResponses); + if (responses.length) return responses; + + const inferredResponses = []; + for (const guideline of guidelines ?? []) { + const response = WeatherAlerts.#ResponseFromGuideline(guideline); + if (response && !inferredResponses.includes(response)) inferredResponses.push(response); + } + return inferredResponses.length ? inferredResponses : (guidelines?.length ? ["monitor"] : []); + } + + static #ImportanceFromSeverity(severity) { + switch (String(severity ?? "").trim().toLowerCase()) { + case "extreme": + case "severe": + return "high"; + case "minor": + return "low"; + case "moderate": + default: + return "normal"; + } + } + + static #NormalizeWeatherAlertTitle(description, eventName = "") { + const title = String(description ?? "").trim(); + const fallback = WeatherAlerts.#TrimWeatherAlertTitle(eventName); + if (!title) return fallback; + + const chinese = title.match(/^.+?(?:发布|更新|变更)\s*[::]?\s*(.+)$/); + if (chinese?.[1]) return WeatherAlerts.#TrimWeatherAlertTitle(chinese[1]); + + const issued = title.match(/^(.+?)\s+issued\b\s*[::]?\s*(.+)$/i); + if (issued?.[1] && issued?.[2]) { + const titleBeforeIssued = WeatherAlerts.#TrimWeatherAlertTitle(issued[1]); + const fallbackMatchesPrefix = Boolean(fallback) && WeatherAlerts.#NormalizeAlertMatchText(fallback) === WeatherAlerts.#NormalizeAlertMatchText(titleBeforeIssued); + return fallbackMatchesPrefix ? fallback : WeatherAlerts.#FormatTranslatedEnglishAlertTitle(issued[2]) || fallback; + } + + const issues = title.match(/^.+?\s+issues?\b\s*[::]?\s*(.+)$/i); + return issues?.[1] ? WeatherAlerts.#FormatTranslatedEnglishAlertTitle(issues[1]) || fallback : WeatherAlerts.#TrimWeatherAlertTitle(title) || fallback; + } + + static #FirstWeatherAlertPageMatch(source, pattern, fallback = "") { + const match = source.match(pattern); + return match ? WeatherAlerts.#DecodeWeatherAlertPageHTML(match[1]) : fallback; + } + + static #DecodeWeatherAlertPageHTML(value) { + const normalized = String(value ?? "") + .replace(/<br\s*\/?\s*>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/ | /gi, " ") + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number))) + .replace(/&#x([0-9a-f]+);/gi, (_, number) => String.fromCodePoint(Number.parseInt(number, 16))); + return normalized.replace(/"/gi, '"').replace(/'/gi, "'").replace(/</gi, "<").replace(/>/gi, ">").replace(/&/gi, "&") + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + static #ParseWeatherAlertPageHeadline(description) { + const title = String(description ?? "").trim(); + const chinese = title.match(/^(.+?)发布\s*[::]?\s*(.+)$/); + if (chinese?.[1] && chinese?.[2]) return { eventName: chinese[2].trim(), source: chinese[1].trim() }; + + const cap = title.match(/^(.+?)\s+issued\b[\s\S]*\s+by\s+(.+)$/i); + if (cap?.[1] && cap?.[2]) return { eventName: cap[1].trim(), source: cap[2].trim() }; + + const issued = title.match(/^(.+?)\s+issued\b\s*[::]?\s*(.+)$/i); + if (issued?.[1] && issued?.[2]) { + const context = issued[2].trim(); + const capContext = /^(?:for\b|\d{1,2}[/:.-]|\p{L}+\s+(?:\d{1,2}\b|at\b|until\b))/iu.test(context); + return capContext ? { eventName: issued[1].trim(), source: "" } : { eventName: context, source: issued[1].trim() }; + } + + const issues = title.match(/^(.+?)\s+issues?\b\s*[::]?\s*(.+)$/i); + if (issues?.[1] && issues?.[2]) return { eventName: issues[2].trim(), source: issues[1].trim() }; + return { eventName: title, source: "" }; + } + + static #NormalizeResponses(responses) { + const normalized = []; + for (const response of responses ?? []) { + const token = String(response ?? "").trim(); + if (token && !normalized.includes(token)) normalized.push(token); + } + return normalized; + } + + static #ResponseFromGuideline(guideline) { + const text = String(guideline ?? "").trim().toLowerCase(); + if (!text) return null; + const compact = text.replace(/\s+/g, ""); + + if (/撤离|疏散|转移|离开/.test(text) || compact.includes("evacuat")) return "evacuate"; + if (/就地|躲避|避难|避险|避风|室内|躲到|进入室内|待在室内/.test(text) || compact.includes("takeshelter") || compact.includes("seekshelter")) return "shelter"; + if (/执行|实施|预案|计划/.test(text) || compact.includes("execute") || compact.includes("carryout") || compact.includes("implement")) return "execute"; + if (/准备|防范|防护|备好|做好.*准备/.test(text) || compact.includes("prepare") || compact.includes("preparations")) return "prepare"; + if (/远离|避免|不要|切勿|勿|别/.test(text) || compact.includes("avoid") || compact.includes("stayaway") || compact.includes("keepaway") || compact.includes("donot") || compact.includes("dont")) return "avoid"; + if (/密切关注|持续关注|关注|留意|监测|观察|跟踪/.test(text) || compact.includes("monitor") || compact.includes("watch") || compact.includes("followup")) return "monitor"; + if (/评估|检查/.test(text) || compact.includes("assess") || compact.includes("inspect")) return "assess"; + if (/解除|恢复正常/.test(text) || compact.includes("allclear")) return "allClear"; + if (/无需|不需|无须/.test(text) || compact.includes("none")) return "none"; + return null; + } + + static #TrimWeatherAlertTitle(title) { + return String(title ?? "") + .trim() + .replace(/\s*[\[【][^\]】]+[\]】]\s*$/u, "") + .replace(/\s*[。..]+\s*$/gu, "") + .replace(/预警信号$/u, "预警") + .replace(/預警信號$/u, "預警"); + } + + static #NormalizeAlertMatchText(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, "") + .replace(/预警信号|预警|警报|报告/g, ""); + } + + static #FormatTranslatedEnglishAlertTitle(title) { + return WeatherAlerts.#TrimWeatherAlertTitle(title) + .replace(/^(?:a|an|the)\s+/i, "") + .replace(/\b\p{L}/gu, character => character.toUpperCase()); + } + +} diff --git a/src/egern/wk3/class/WeatherKit2.mjs b/src/egern/wk3/class/WeatherKit2.mjs new file mode 100644 index 00000000..e77cd4dd --- /dev/null +++ b/src/egern/wk3/class/WeatherKit2.mjs @@ -0,0 +1,1028 @@ +/** + * Product codecs ported from NSRingo/WeatherKit v3.3.2 (Apache-2.0). + * https://github.com/NSRingo/WeatherKit/blob/d3a218e2203d6d5e02630a0e41e0dd089be32d0f/src/class/WeatherKit2.mjs + * Local changes: use bundled models/root processor and expose standalone WK3 codecs. + */ +import { FlatBufferRootProcessor } from "./FlatBufferRootProcessor.mjs"; +import * as WK2 from "../proto/apple/wk2.js"; +import database from "../function/database.mjs"; + +/** + * 使用 Weather 根模型和产品 codec 配置通用根表处理器。 + * Configure the generic root-table processor with the Weather model and product codecs. + */ +const WeatherKit2 = new FlatBufferRootProcessor({ + codecs: { + airQuality: { decode: decodeAirQuality, encode: encodeAirQuality, tableClass: WK2.AirQuality }, + currentWeather: { decode: decodeCurrentWeather, encode: encodeCurrentWeather, tableClass: WK2.CurrentWeatherData }, + forecastDaily: { decode: decodeForecastDaily, encode: encodeForecastDaily, tableClass: WK2.DailyForecastData }, + forecastHourly: { decode: decodeForecastHourly, encode: encodeForecastHourly, tableClass: WK2.HourlyForecastData }, + forecastNextHour: { decode: decodeForecastNextHour, encode: encodeForecastNextHour, tableClass: WK2.NextHourForecastData }, + historicalComparisons: { decode: decodeHistoricalComparisons, encode: encodeHistoricalComparisons, tableClass: WK2.HistoricalComparison }, + locationInfo: { decode: decodeLocationInfo, encode: encodeLocationInfo, tableClass: WK2.LocationInfo }, + news: { decode: decodeNews, encode: encodeNews, tableClass: WK2.News }, + weatherAlerts: { decode: decodeWeatherAlerts, encode: encodeWeatherAlerts, tableClass: WK2.WeatherAlertCollectionData }, + weatherChanges: { decode: decodeWeatherChanges, encode: encodeWeatherChanges, tableClass: WK2.WeatherChanges }, + }, + configurableRootNames: database.WeatherKit.Settings.DataSets, + name: "WeatherKit2", + rootClass: WK2.Weather, +}); + +export default WeatherKit2; + +// Standalone product-table codecs for the WK3 response patcher. +export { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour }; + +function encodeAirQuality(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const pollutantsOffset = WK2.AirQuality.createPollutantsVector( + builder, + data.pollutants.map(p => WK2.Pollutant.createPollutant(builder, WK2.PollutantType[p.pollutantType], p.amount, WK2.UnitType[p.units])), + ); + const scaleOffset = builder.createString(data.scale); + return WK2.AirQuality.createAirQuality(builder, metadataOffset, data.categoryIndex, data.index, data.isSignificant, pollutantsOffset, WK2.ComparisonTrend[data.previousDayComparison], WK2.PollutantType[data.primaryPollutant], scaleOffset); +} + +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)), + ); + 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)), + ); + 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)), + ); + 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)), + ); + 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)), + ); + 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)), + ); + return WK2.CurrentWeatherData.createCurrentWeatherData( + builder, + metadataOffset, + data.asOf, + data.cloudCover, + data.cloudCoverLowAltPct, + data.cloudCoverMidAltPct, + data.cloudCoverHighAltPct, + WK2.WeatherCondition[data.conditionCode], + data.daylight, + data.humidity, + data.perceivedPrecipitationIntensity, + data.precipitationAmount1h, + data.precipitationAmount6h, + data.precipitationAmount24h, + data.precipitationAmountNext1h, + data.precipitationAmountNext6h, + data.precipitationAmountNext24h, + precipitationAmountNext1hByTypeOffset, + precipitationAmountNext6hByTypeOffset, + precipitationAmountNext24hByTypeOffset, + precipitationAmountPrevious1hByTypeOffset, + precipitationAmountPrevious6hByTypeOffset, + precipitationAmountPrevious24hByTypeOffset, + data.precipitationIntensity, + data.pressure, + WK2.PressureTrend[data.pressureTrend], + data.snowfallAmount1h, + data.snowfallAmount6h, + data.snowfallAmount24h, + data.snowfallAmountNext1h, + data.snowfallAmountNext6h, + data.snowfallAmountNext24h, + data.temperature, + data.temperatureApparent, + data.unknown34, + data.temperatureDewPoint, + data.uvIndex, + data.visibility, + data.windDirection, + data.windGust, + data.windSpeed, + ); +} + +function encodeForecastDaily(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const daysOffsets = data.days.map(day => { + const Offsets = {}; + Offsets.precipitationAmountByTypeOffest = WK2.DayWeatherConditions.createPrecipitationAmountByTypeVector( + builder, + day.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + if (day.daytimeForecast) { + Offsets.daytimeForecastPrecipitationAmountByTypeOffest = WK2.DayPartForecast.createPrecipitationAmountByTypeVector( + builder, + day.daytimeForecast.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + Offsets.daytimeForecastOffset = WK2.DayPartForecast.createDayPartForecast( + builder, + day.daytimeForecast.forecastStart, + day.daytimeForecast.forecastEnd, + day.daytimeForecast.cloudCover, + day.daytimeForecast.cloudCoverLowAltPct, + day.daytimeForecast.cloudCoverMidAltPct, + day.daytimeForecast.cloudCoverHighAltPct, + WK2.WeatherCondition[day.daytimeForecast.conditionCode], + day.daytimeForecast.humidity, + day.daytimeForecast.humidityMax, + day.daytimeForecast.humidityMin, + day.daytimeForecast.precipitationAmount, + Offsets.daytimeForecastPrecipitationAmountByTypeOffest, + day.daytimeForecast.precipitationChance, + WK2.PrecipitationType[day.daytimeForecast.precipitationType], + day.daytimeForecast.snowfallAmount, + day.daytimeForecast.temperatureMax, + day.daytimeForecast.temperatureMin, + day.daytimeForecast.visibilityMax, + day.daytimeForecast.visibilityMin, + day.daytimeForecast.windDirection, + day.daytimeForecast.windGustSpeedMax, + day.daytimeForecast.windSpeed, + day.daytimeForecast.windSpeedMax, + day.daytimeForecast.precipitationIntensityMax, + day.daytimeForecast.perceivedPrecipitationIntensityMax, + day.daytimeForecast.uvIndexMin, + day.daytimeForecast.uvIndexMax, + day.daytimeForecast.temperatureApparentMin, + day.daytimeForecast.temperatureApparentMax, + day.daytimeForecast.daylight, + ); + } + if (day.overnightForecast) { + Offsets.overnightForecastPrecipitationAmountByTypeOffest = WK2.DayPartForecast.createPrecipitationAmountByTypeVector( + builder, + day.overnightForecast.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + Offsets.overnightForecastOffset = WK2.DayPartForecast.createDayPartForecast( + builder, + day.overnightForecast.forecastStart, + day.overnightForecast.forecastEnd, + day.overnightForecast.cloudCover, + day.overnightForecast.cloudCoverLowAltPct, + day.overnightForecast.cloudCoverMidAltPct, + day.overnightForecast.cloudCoverHighAltPct, + WK2.WeatherCondition[day.overnightForecast.conditionCode], + day.overnightForecast.humidity, + day.overnightForecast.humidityMax, + day.overnightForecast.humidityMin, + day.overnightForecast.precipitationAmount, + Offsets.overnightForecastPrecipitationAmountByTypeOffest, + day.overnightForecast.precipitationChance, + WK2.PrecipitationType[day.overnightForecast.precipitationType], + day.overnightForecast.snowfallAmount, + day.overnightForecast.temperatureMax, + day.overnightForecast.temperatureMin, + day.overnightForecast.visibilityMax, + day.overnightForecast.visibilityMin, + day.overnightForecast.windDirection, + day.overnightForecast.windGustSpeedMax, + day.overnightForecast.windSpeed, + day.overnightForecast.windSpeedMax, + day.overnightForecast.precipitationIntensityMax, + day.overnightForecast.perceivedPrecipitationIntensityMax, + day.overnightForecast.uvIndexMin, + day.overnightForecast.uvIndexMax, + day.overnightForecast.temperatureApparentMin, + day.overnightForecast.temperatureApparentMax, + day.overnightForecast.daylight, + ); + } + if (day.restOfDayForecast) { + Offsets.restOfDayForecastPrecipitationAmountByTypeOffest = WK2.DayPartForecast.createPrecipitationAmountByTypeVector( + builder, + day.restOfDayForecast.precipitationAmountByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + ); + Offsets.restOfDayForecastOffset = WK2.DayPartForecast.createDayPartForecast( + builder, + day.restOfDayForecast.forecastStart, + day.restOfDayForecast.forecastEnd, + day.restOfDayForecast.cloudCover, + day.restOfDayForecast.cloudCoverLowAltPct, + day.restOfDayForecast.cloudCoverMidAltPct, + day.restOfDayForecast.cloudCoverHighAltPct, + WK2.WeatherCondition[day.restOfDayForecast.conditionCode], + day.restOfDayForecast.humidity, + day.restOfDayForecast.humidityMax, + day.restOfDayForecast.humidityMin, + day.restOfDayForecast.precipitationAmount, + Offsets.restOfDayForecastPrecipitationAmountByTypeOffest, + day.restOfDayForecast.precipitationChance, + WK2.PrecipitationType[day.restOfDayForecast.precipitationType], + day.restOfDayForecast.snowfallAmount, + day.restOfDayForecast.temperatureMax, + day.restOfDayForecast.temperatureMin, + day.restOfDayForecast.visibilityMax, + day.restOfDayForecast.visibilityMin, + day.restOfDayForecast.windDirection, + day.restOfDayForecast.windGustSpeedMax, + day.restOfDayForecast.windSpeed, + day.restOfDayForecast.windSpeedMax, + day.restOfDayForecast.precipitationIntensityMax, + day.restOfDayForecast.perceivedPrecipitationIntensityMax, + day.restOfDayForecast.uvIndexMin, + day.restOfDayForecast.uvIndexMax, + day.restOfDayForecast.temperatureApparentMin, + day.restOfDayForecast.temperatureApparentMax, + day.restOfDayForecast.daylight, + ); + } + WK2.DayWeatherConditions.startDayWeatherConditions(builder); + WK2.DayWeatherConditions.addForecastStart(builder, day.forecastStart); + WK2.DayWeatherConditions.addForecastEnd(builder, day.forecastEnd); + WK2.DayWeatherConditions.addConditionCode(builder, WK2.WeatherCondition[day.conditionCode]); + WK2.DayWeatherConditions.addHumidityMax(builder, day.humidityMax); + WK2.DayWeatherConditions.addHumidityMin(builder, day.humidityMin); + WK2.DayWeatherConditions.addMaxUvIndex(builder, day.maxUvIndex); + WK2.DayWeatherConditions.addMoonPhase(builder, WK2.MoonPhase[day.moonPhase]); + WK2.DayWeatherConditions.addMoonrise(builder, day.moonrise); + WK2.DayWeatherConditions.addMoonset(builder, day.moonset); + WK2.DayWeatherConditions.addPrecipitationAmount(builder, day.precipitationAmount); + WK2.DayWeatherConditions.addPrecipitationAmountByType(builder, Offsets.precipitationAmountByTypeOffest); + WK2.DayWeatherConditions.addPrecipitationChance(builder, day.precipitationChance); + WK2.DayWeatherConditions.addPrecipitationType(builder, WK2.PrecipitationType[day.precipitationType]); + WK2.DayWeatherConditions.addSnowfallAmount(builder, day.snowfallAmount); + WK2.DayWeatherConditions.addSolarMidnight(builder, day.solarMidnight); + WK2.DayWeatherConditions.addSolarNoon(builder, day.solarNoon); + WK2.DayWeatherConditions.addSunrise(builder, day.sunrise); + WK2.DayWeatherConditions.addSunriseCivil(builder, day.sunriseCivil); + WK2.DayWeatherConditions.addSunriseNautical(builder, day.sunriseNautical); + WK2.DayWeatherConditions.addSunriseAstronomical(builder, day.sunriseAstronomical); + WK2.DayWeatherConditions.addSunset(builder, day.sunset); + WK2.DayWeatherConditions.addSunsetCivil(builder, day.sunsetCivil); + WK2.DayWeatherConditions.addSunsetNautical(builder, day.sunsetNautical); + WK2.DayWeatherConditions.addSunsetAstronomical(builder, day.sunsetAstronomical); + WK2.DayWeatherConditions.addTemperatureMax(builder, day.temperatureMax); + WK2.DayWeatherConditions.addTemperatureMaxTime(builder, day.temperatureMaxTime); + WK2.DayWeatherConditions.addTemperatureMin(builder, day.temperatureMin); + WK2.DayWeatherConditions.addTemperatureMinTime(builder, day.temperatureMinTime); + WK2.DayWeatherConditions.addVisibilityMax(builder, day.visibilityMax); + WK2.DayWeatherConditions.addVisibilityMin(builder, day.visibilityMin); + WK2.DayWeatherConditions.addWindGustSpeedMax(builder, day.windGustSpeedMax); + WK2.DayWeatherConditions.addWindSpeedAvg(builder, day.windSpeedAvg); + WK2.DayWeatherConditions.addWindSpeedMax(builder, day.windSpeedMax); + if (day.daytimeForecast) WK2.DayWeatherConditions.addDaytimeForecast(builder, Offsets.daytimeForecastOffset); + if (day.overnightForecast) WK2.DayWeatherConditions.addOvernightForecast(builder, Offsets.overnightForecastOffset); + if (day.restOfDayForecast) WK2.DayWeatherConditions.addRestOfDayForecast(builder, Offsets.restOfDayForecastOffset); + return WK2.DayWeatherConditions.endDayWeatherConditions(builder); + }); + const daysOffset = WK2.DailyForecastData.createDaysVector(builder, daysOffsets); + return WK2.DailyForecastData.createDailyForecastData(builder, metadataOffset, daysOffset); +} + +function encodeForecastHourly(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const hoursOffsets = data.hours.map(hour => + WK2.HourWeatherConditions.createHourWeatherConditions( + builder, + hour.forecastStart, + hour.cloudCover, + hour.cloudCoverLowAltPct, + hour.cloudCoverMidAltPct, + hour.cloudCoverHighAltPct, + WK2.WeatherCondition[hour.conditionCode], + hour.daylight, + hour.humidity, + hour.perceivedPrecipitationIntensity, + hour.precipitationAmount, + hour.precipitationIntensity, + hour.precipitationChance, + WK2.PrecipitationType[hour.precipitationType], + hour.pressure, + WK2.PressureTrend[hour.pressureTrend], + hour.snowfallAmount, + hour.snowfallIntensity, + hour.temperature, + hour.temperatureApparent, + hour.unknown20, + hour.temperatureDewPoint, + hour.uvIndex, + hour.visibility, + hour.windDirection, + hour.windGust, + hour.windSpeed, + ), + ); + const hoursOffset = WK2.HourlyForecastData.createHoursVector(builder, hoursOffsets); + return WK2.HourlyForecastData.createHourlyForecastData(builder, metadataOffset, hoursOffset); +} + +function encodeForecastNextHour(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + // 防御第三方数据:未知枚举传给 FlatBuffer 会静默编码为 CLEAR。 + // Defend against third-party data: unknown enums would otherwise be silently encoded as CLEAR. + const conditionOffsets = data.condition + .filter(condition => [condition.beginCondition, condition.endCondition].every(value => typeof WK2.ConditionType[value] === "number")) + .map(condition => { + const parametersOffsets = condition.parameters.map(parameter => WK2.Parameter.createParameter(builder, WK2.ParameterType[parameter.type], parameter.date)); + const parametersOffset = WK2.Condition.createParametersVector(builder, parametersOffsets); + return WK2.Condition.createCondition(builder, condition.startTime, condition.endTime, WK2.ForecastToken[condition.forecastToken], WK2.ConditionType[condition.beginCondition], WK2.ConditionType[condition.endCondition], parametersOffset); + }); + const conditionOffset = WK2.NextHourForecastData.createConditionVector(builder, conditionOffsets); + const summaryOffsets = data.summary.map(summary => WK2.ForecastPeriodSummary.createForecastPeriodSummary(builder, summary.startTime, summary.endTime, WK2.PrecipitationType[summary.condition], summary.precipitationChance, summary.precipitationIntensity)); + const summaryOffset = WK2.NextHourForecastData.createSummaryVector(builder, summaryOffsets); + const minutesOffsets = data.minutes.map(minute => WK2.ForecastMinute.createForecastMinute(builder, minute.startTime, minute.precipitationChance, minute.precipitationIntensity, minute.perceivedPrecipitationIntensity)); + const minutesOffset = WK2.NextHourForecastData.createMinutesVector(builder, minutesOffsets); + return WK2.NextHourForecastData.createNextHourForecastData(builder, metadataOffset, conditionOffset, summaryOffset, data.forecastStart, data.forecastEnd, minutesOffset); +} + +function encodeNews(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const placementsOffsets = data.placements.map(placement => { + const articlesOffsets = placement.articles.map(article => { + const alertIdsOffsets = article.alertIds.map(alertId => createUuidOffset(builder, alertId)).filter(Boolean); + const alertIdsOffset = WK2.Articles.createAlertIdsVector(builder, alertIdsOffsets); + const headlineOverrideOffset = builder.createString(article.headlineOverride); + const idOffset = builder.createString(article.id); + const localeOffset = builder.createString(article.locale); + const phenomenaOffset = WK2.Articles.createPhenomenaVector( + builder, + article.phenomena.map(phenomena => builder.createString(phenomena)), + ); + const supportedStorefrontsOffset = WK2.Articles.createSupportedStorefrontsVector( + builder, + article.supportedStorefronts.map(supportedStorefront => builder.createString(supportedStorefront)), + ); + return WK2.Articles.createArticles(builder, idOffset, supportedStorefrontsOffset, alertIdsOffset, phenomenaOffset, headlineOverrideOffset, localeOffset); + }); + const articlesOffset = WK2.Placement.createArticlesVector(builder, articlesOffsets); + return WK2.Placement.createPlacement(builder, placement.priority, articlesOffset, WK2.PlacementType[placement.placement]); + }); + const placementsOffset = WK2.News.createPlacementsVector(builder, placementsOffsets); + return WK2.News.createNews(builder, metadataOffset, placementsOffset); +} + +function encodeWeatherAlerts(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const alertsOffsets = data.alerts.map(alert => { + const responsesOffsets = alert.responses.map(response => WK2.ResponseType[response]); + const responsesOffset = WK2.WeatherAlertSummary.createResponsesVector(builder, responsesOffsets); + const idOffset = createUuidOffset(builder, alert.id); + const areaIdOffset = builder.createString(alert.areaId); + const areaNameOffset = builder.createString(alert.areaName); + const attributionUrlOffset = builder.createString(alert.attributionUrl); + const countryCodeOffset = builder.createString(alert.countryCode); + const descriptionOffset = builder.createString(alert.description); + const tokenOffset = builder.createString(alert.token); + const detailsUrlOffset = builder.createString(alert.detailsUrl); + const phenomenonOffset = builder.createString(alert.phenomenon); + const sourceOffset = builder.createString(alert.source); + const eventSourceOffset = builder.createString(alert.eventSource); + return WK2.WeatherAlertSummary.createWeatherAlertSummary( + builder, + idOffset, + areaIdOffset, + areaNameOffset, + attributionUrlOffset, + countryCodeOffset, + descriptionOffset, + tokenOffset, + alert.effectiveTime, + alert.expireTime, + alert.issuedTime, + alert.eventOnsetTime, + alert.eventEndTime, + detailsUrlOffset, + phenomenonOffset, + WK2.Severity[alert.severity], + WK2.SignificanceType[alert.significance], + sourceOffset, + eventSourceOffset, + WK2.Urgency[alert.urgency], + WK2.Certainty[alert.certainty], + WK2.ImportanceType[alert.importance], + responsesOffset, + alert.unknown23, + alert.unknown24, + alert.unknown25, + alert.unknown26, + ); + }); + const alertsOffset = WK2.WeatherAlertCollectionData.createAlertsVector(builder, alertsOffsets); + const detailsUrlOffset = builder.createString(data.detailsUrl); + return WK2.WeatherAlertCollectionData.createWeatherAlertCollectionData(builder, metadataOffset, detailsUrlOffset, alertsOffset); +} + +function encodeWeatherChanges(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const changesOffsets = data.changes.map(change => WK2.Change.createChange(builder, change.forecastStart, change.forecastEnd, WK2.Direction[change.maxTemperatureChange], WK2.Direction[change.minTemperatureChange], WK2.Direction[change.dayPrecipitationChange], WK2.Direction[change.nightPrecipitationChange])); + const changesOffset = WK2.WeatherChanges.createChangesVector(builder, changesOffsets); + return WK2.WeatherChanges.createWeatherChanges(builder, metadataOffset, data.forecastStart, data.forecastEnd, changesOffset); +} + +function encodeHistoricalComparisons(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const comparisonsOffsets = data.comparisons.map(comparison => WK2.Comparison.createComparison(builder, WK2.ComparisonType[comparison.condition], comparison.currentValue, comparison.baselineValue, WK2.Deviation[comparison.deviation], comparison.baselineType, comparison.baselineStartDate)); + const comparisonsOffset = WK2.HistoricalComparison.createComparisonsVector(builder, comparisonsOffsets); + return WK2.HistoricalComparison.createHistoricalComparison(builder, metadataOffset, comparisonsOffset); +} + +function encodeLocationInfo(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + return WK2.LocationInfo.createLocationInfo(builder, metadataOffset, builder.createString(data.preciseName), builder.createString(data.countryCode), builder.createString(data.timeZone), builder.createString(data.primaryName)); +} + +function decodeAirQuality(data) { + const AirQualityData = data; + data = { + metadata: decodeMetadata(AirQualityData.metadata()), + categoryIndex: AirQualityData.categoryIndex(), + index: AirQualityData.index(), + isSignificant: AirQualityData.isSignificant(), + pollutants: [], + previousDayComparison: WK2.ComparisonTrend[AirQualityData.previousDayComparison()], + primaryPollutant: WK2.PollutantType[AirQualityData.primaryPollutant()], + scale: AirQualityData.scale(), + }; + for (let i = 0; i < AirQualityData.pollutantsLength(); i++) + data.pollutants.push({ + amount: AirQualityData.pollutants(i).amount(), + pollutantType: WK2.PollutantType[AirQualityData.pollutants(i).pollutantType()], + units: WK2.UnitType[AirQualityData.pollutants(i).units()], + }); + return data; +} + +function decodeCurrentWeather(data) { + const CurrentWeatherData = data; + data = { + metadata: decodeMetadata(CurrentWeatherData.metadata()), + asOf: CurrentWeatherData.asOf(), + cloudCover: CurrentWeatherData.cloudCover(), + cloudCoverHighAltPct: CurrentWeatherData.cloudCoverHighAltPct(), + cloudCoverLowAltPct: CurrentWeatherData.cloudCoverLowAltPct(), + cloudCoverMidAltPct: CurrentWeatherData.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[CurrentWeatherData.conditionCode()], + daylight: CurrentWeatherData.daylight(), + humidity: CurrentWeatherData.humidity(), + perceivedPrecipitationIntensity: CurrentWeatherData.perceivedPrecipitationIntensity(), + precipitationAmount1h: CurrentWeatherData.precipitationAmount1h(), + precipitationAmount24h: CurrentWeatherData.precipitationAmount24h(), + precipitationAmount6h: CurrentWeatherData.precipitationAmount6h(), + precipitationAmountNext1h: CurrentWeatherData.precipitationAmountNext1h(), + precipitationAmountNext1hByType: [], + precipitationAmountNext24h: CurrentWeatherData.precipitationAmountNext24h(), + precipitationAmountNext24hByType: [], + precipitationAmountNext6h: CurrentWeatherData.precipitationAmountNext6h(), + precipitationAmountNext6hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious24hByType: [], + precipitationAmountPrevious6hByType: [], + precipitationIntensity: CurrentWeatherData.precipitationIntensity(), + pressure: CurrentWeatherData.pressure(), + pressureTrend: WK2.PressureTrend[CurrentWeatherData.pressureTrend()], + snowfallAmount1h: CurrentWeatherData.snowfallAmount1h(), + snowfallAmount24h: CurrentWeatherData.snowfallAmount24h(), + snowfallAmount6h: CurrentWeatherData.snowfallAmount6h(), + snowfallAmountNext1h: CurrentWeatherData.snowfallAmountNext1h(), + snowfallAmountNext24h: CurrentWeatherData.snowfallAmountNext24h(), + snowfallAmountNext6h: CurrentWeatherData.snowfallAmountNext6h(), + temperature: CurrentWeatherData.temperature(), + temperatureApparent: CurrentWeatherData.temperatureApparent(), + unknown34: CurrentWeatherData.unknown34(), + temperatureDewPoint: CurrentWeatherData.temperatureDewPoint(), + uvIndex: CurrentWeatherData.uvIndex(), + visibility: CurrentWeatherData.visibility(), + windDirection: CurrentWeatherData.windDirection(), + windGust: CurrentWeatherData.windGust(), + windSpeed: CurrentWeatherData.windSpeed(), + }; + for (let i = 0; i < CurrentWeatherData.precipitationAmountNext1hByTypeLength(); i++) + data.precipitationAmountNext1hByType.push({ + expected: CurrentWeatherData.precipitationAmountNext1hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext1hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountNext24hByTypeLength(); i++) + data.precipitationAmountNext24hByType.push({ + expected: CurrentWeatherData.precipitationAmountNext24hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext24hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountNext6hByTypeLength(); i++) + data.precipitationAmountNext6hByType.push({ + expected: CurrentWeatherData.precipitationAmountNext6hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext6hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious1hByTypeLength(); i++) + data.precipitationAmountPrevious1hByType.push({ + expected: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious1hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious24hByTypeLength(); i++) + data.precipitationAmountPrevious24hByType.push({ + expected: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious24hByType(i).precipitationType()], + }); + for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious6hByTypeLength(); i++) + data.precipitationAmountPrevious6hByType.push({ + expected: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expected(), + expectedSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expectedSnow(), + maximumSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).maximumSnow(), + minimumSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).minimumSnow(), + precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious6hByType(i).precipitationType()], + }); + return data; +} + +function decodeForecastDaily(data) { + const DailyForecastData = data; + data = { + metadata: decodeMetadata(DailyForecastData.metadata()), + days: [], + }; + for (let i = 0; i < DailyForecastData.daysLength(); 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(), + 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(), + }; + for (let j = 0; j < DailyForecastData.days(i).precipitationAmountByTypeLength(); 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()], + }); + if (DailyForecastData.days(i).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(), + 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(), + }; + for (let j = 0; j < DailyForecastData.days(i).daytimeForecast().precipitationAmountByTypeLength(); 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()], + }); + } + if (DailyForecastData.days(i).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(), + 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(), + }; + for (let j = 0; j < DailyForecastData.days(i).overnightForecast().precipitationAmountByTypeLength(); 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()], + }); + } + if (DailyForecastData.days(i).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(), + 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(), + }; + for (let j = 0; j < DailyForecastData.days(i).restOfDayForecast().precipitationAmountByTypeLength(); 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()], + }); + } + data.days.push(day); + } + return data; +} + +function decodeForecastHourly(data) { + const HourlyForecastData = data; + data = { + metadata: decodeMetadata(HourlyForecastData.metadata()), + hours: [], + }; + for (let i = 0; i < HourlyForecastData.hoursLength(); 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(), + }); + return data; +} + +function decodeForecastNextHour(data) { + const NextHourForecastData = data; + data = { + metadata: decodeMetadata(NextHourForecastData.metadata()), + condition: [], + forecastEnd: NextHourForecastData.forecastEnd(), + forecastStart: NextHourForecastData.forecastStart(), + minutes: [], + summary: [], + }; + for (let i = 0; i < NextHourForecastData.conditionLength(); i++) { + const condition = { + beginCondition: WK2.ConditionType[NextHourForecastData.condition(i).beginCondition()], + endCondition: WK2.ConditionType[NextHourForecastData.condition(i).endCondition()], + endTime: NextHourForecastData.condition(i).endTime(), + forecastToken: WK2.ForecastToken[NextHourForecastData.condition(i).forecastToken()], + parameters: [], + startTime: NextHourForecastData.condition(i).startTime(), + }; + for (let j = 0; j < NextHourForecastData.condition(i).parametersLength(); 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++) + 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++) + data.summary.push({ + condition: WK2.PrecipitationType[NextHourForecastData.summary(i).condition()], + endTime: NextHourForecastData.summary(i).endTime(), + precipitationChance: NextHourForecastData.summary(i).precipitationChance(), + precipitationIntensity: NextHourForecastData.summary(i).precipitationIntensity(), + startTime: NextHourForecastData.summary(i).startTime(), + }); + return data; +} + +function decodeNews(data) { + const NewsData = data; + data = { + metadata: decodeMetadata(NewsData.metadata()), + placements: [], + }; + for (let i = 0; i < NewsData.placementsLength(); 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++) { + const article = { + alertIds: [], + headlineOverride: NewsData.placements(i).articles(j).headlineOverride(), + id: NewsData.placements(i).articles(j).id(), + locale: NewsData.placements(i).articles(j).locale(), + 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)); + placement.articles.push(article); + } + data.placements.push(placement); + } + return data; +} + +function decodeWeatherAlerts(data) { + const WeatherAlertCollectionData = data; + data = { + metadata: decodeMetadata(WeatherAlertCollectionData.metadata()), + alerts: [], + detailsUrl: WeatherAlertCollectionData.detailsUrl(), + }; + for (let i = 0; i < WeatherAlertCollectionData.alertsLength(); 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(), + 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(), + }; + //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)]); + data.alerts.push(alert); + } + return data; +} + +function decodeWeatherChanges(data) { + const WeatherChangesData = data; + data = { + metadata: decodeMetadata(WeatherChangesData.metadata()), + changes: [], + forecastEnd: WeatherChangesData.forecastEnd(), + forecastStart: WeatherChangesData.forecastStart(), + }; + for (let i = 0; i < WeatherChangesData.changesLength(); i++) { + const change = { + dayPrecipitationChange: WK2.Direction[WeatherChangesData.changes(i).dayPrecipitationChange()], + forecastEnd: WeatherChangesData.changes(i).forecastEnd(), + forecastStart: WeatherChangesData.changes(i).forecastStart(), + maxTemperatureChange: WK2.Direction[WeatherChangesData.changes(i).maxTemperatureChange()], + minTemperatureChange: WK2.Direction[WeatherChangesData.changes(i).minTemperatureChange()], + nightPrecipitationChange: WK2.Direction[WeatherChangesData.changes(i).nightPrecipitationChange()], + }; + data.changes.push(change); + } + return data; +} + +function decodeHistoricalComparisons(data) { + const HistoricalComparisonsData = data; + data = { + metadata: decodeMetadata(HistoricalComparisonsData.metadata()), + comparisons: [], + }; + for (let i = 0; i < HistoricalComparisonsData.comparisonsLength(); i++) { + const comparison = { + baselineStartDate: HistoricalComparisonsData.comparisons(i).baselineStartDate(), + baselineType: HistoricalComparisonsData.comparisons(i).baselineType(), + baselineValue: HistoricalComparisonsData.comparisons(i).baselineValue(), + condition: WK2.ComparisonType[HistoricalComparisonsData.comparisons(i).condition()], + currentValue: HistoricalComparisonsData.comparisons(i).currentValue(), + deviation: WK2.Deviation[HistoricalComparisonsData.comparisons(i).deviation()], + }; + data.comparisons.push(comparison); + } + return data; +} + +function decodeLocationInfo(data) { + const LocationInfoData = data; + data = { + metadata: decodeMetadata(LocationInfoData.metadata()), + countryCode: LocationInfoData.countryCode(), + preciseName: LocationInfoData.preciseName(), + primaryName: LocationInfoData.primaryName(), + timeZone: LocationInfoData.timeZone(), + }; + return data; +} + +function encodeMetadata(builder, metadata) { + if (!metadata) return 0; + return WK2.Metadata.createMetadata( + builder, + builder.createString(metadata.attributionUrl), + metadata.expireTime, + builder.createString(metadata.language), + metadata.latitude, + metadata.longitude, + builder.createString(metadata.providerLogo), + builder.createString(metadata.providerName), + metadata.readTime, + metadata.reportedTime, + metadata.temporarilyUnavailable, + WK2.SourceType[metadata.sourceType], + metadata.unknown11, + metadata.unknown12, + metadata.unknown13, + metadata.unknown14, + metadata.unknown15, + ); +} + +function decodeMetadata(data) { + if (!data) return undefined; + data = { + attributionUrl: data.attributionUrl(), + expireTime: data.expireTime(), + language: data.language(), + latitude: data.latitude(), + longitude: data.longitude(), + providerLogo: data.providerLogo(), + providerName: data.providerName(), + readTime: data.readTime(), + reportedTime: data.reportedTime(), + temporarilyUnavailable: data.temporarilyUnavailable(), + sourceType: WK2.SourceType[data.sourceType()], + }; + return data; +} + +function toUuidBytes(uuid) { + if (uuid === null || uuid === undefined) return null; + if (uuid instanceof Uint8Array || Array.isArray(uuid)) return uuid; + if (typeof uuid === "string") { + const bytesObject = uuidStringToBytesObject(uuid); + return bytesObject ? bytesObject.bytes : null; + } + if (typeof uuid === "object") { + if (uuid.bytes instanceof Uint8Array || Array.isArray(uuid.bytes)) return uuid.bytes; + if (typeof uuid.bytesArray === "function") return uuid.bytesArray(); + } + return null; +} + +function uuidStringToBytesObject(uuidString) { + if (typeof uuidString !== "string") return null; + const hex = uuidString.replace(/-/g, "").toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(hex)) return null; + const bytes = []; + for (let index = 0; index < hex.length; index += 2) bytes.push(Number.parseInt(hex.slice(index, index + 2), 16)); + return { bytes }; +} + +function uuidBytesObjectToString(uuid) { + const bytes = toUuidBytes(uuid); + if (!bytes || bytes.length !== 16) return null; + const hex = Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function createUuidOffset(builder, uuid) { + const bytes = toUuidBytes(uuid); + if (!bytes || bytes.length === 0) return 0; + const idBytesOffset = WK2.UUID.createBytesVector(builder, bytes); + return WK2.UUID.createUUID(builder, idBytesOffset); +} + +function decodeUuid(uuid) { + return uuidBytesObjectToString(uuid); +} diff --git a/src/egern/wk3/function/buildSettings.mjs b/src/egern/wk3/function/buildSettings.mjs new file mode 100644 index 00000000..1babd891 --- /dev/null +++ b/src/egern/wk3/function/buildSettings.mjs @@ -0,0 +1,131 @@ +import { Console, get, set, merge } from "../utils/index.mjs"; + +/** + * 将字符串值转换为数字(如果是纯数字字符串) + * @param {string} string + * @returns {string|number} + */ +function string2number(string) { + if (/^\d+$/.test(string)) return Number.parseInt(string, 10); + return string; +} + +/** + * 将逗号分隔的字符串转为数组 + * @param {string|number|boolean|any} value + * @returns {any[]} + */ +function value2array(value) { + switch (typeof value) { + case "string": + return value.split(","); + case "number": + case "boolean": + return [value]; + default: + return value || []; + } +} + +/** + * 深度遍历对象,对每个叶子节点执行回调函数 + * @param {object} o + * @param {function} c + * @returns {object} + */ +function traverseObject(o, c) { + for (const t in o) { + const n = o[t]; + o[t] = "object" === typeof n && null !== n ? traverseObject(n, c) : c(t, n); + } + return o; +} + +/** + * 将 Settings 中特定路径的字符串值规范化为数组 + * @param {object} Settings + * @param {string} path - 点分隔路径,如 "Weather.Replace" + */ +function string2array(Settings, path) { + const setting = get(Settings, path); + if (!Array.isArray(setting)) set(Settings, path, setting ? [setting] : []); +} + +/** + * 构建请求级配置(纯函数,无全局副作用) + * + * 替代原来的 setENV → getStorage 调用链。 + * 每次请求独立构建,不读写任何模块级或全局状态。 + * + * @param {object} database - 默认数据库配置(database.mjs) + * @param {object} [queryArguments={}] - 请求级参数(URL query 或 base64 配置解码后的对象) + * @returns {{ Settings: object, Configs: object }} + */ +export default function buildSettings(database, queryArguments = {}) { + // 1. 从 database 深拷贝合并默认 Settings(Default → WeatherKit) + const Settings = merge( + {}, + database?.Default?.Settings, + database?.WeatherKit?.Settings, + ); + + // 2. 合并请求级参数(请求参数优先级最高) + if (queryArguments && typeof queryArguments === "object") { + merge(Settings, queryArguments); + } + + // 3. 规范化需要为数组的字段 + string2array(Settings, "Weather.Replace"); + string2array(Settings, "AirQuality.Current.Index.Replace"); + string2array(Settings, "AirQuality.Current.Pollutants.Units.Replace"); + + // 4. 类型转换:字符串 "true"/"false"/"[]" → 原生类型;逗号分隔 → 数组;纯数字字符串 → number + traverseObject(Settings, (_key, value) => { + switch (typeof value) { + case "string": + switch (value) { + case "true": + case "false": + case "[]": + return JSON.parse(value); + default: + if (value.includes(",")) return value2array(value).map(item => string2number(item)); + return string2number(value); + } + default: + return value; + } + }); + + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider; + const calculateAlgorithm = Settings?.AirQuality?.Calculate?.Algorithm; + const indexReplace = Settings?.AirQuality?.Current?.Index?.Replace; + const yesterday = Settings?.AirQuality?.Comparison?.Yesterday; + const shouldReplaceChinaScaleForNonCNIndex = indexProvider === "ColorfulCloudsUS" + || (indexProvider === "Calculate" && ["WAQI_InstantCast_US", "EU_EAQI", "UBA"].includes(calculateAlgorithm)); + if (shouldReplaceChinaScaleForNonCNIndex && Array.isArray(indexReplace) && !indexReplace.includes("HJ6332012")) { + indexReplace.unshift("HJ6332012"); + } + if (yesterday && indexProvider === "ColorfulCloudsUS" && yesterday.IndexProvider === "ColorfulCloudsCN") { + yesterday.IndexProvider = "ColorfulCloudsUS"; + } else if (yesterday && indexProvider === "Calculate" && shouldReplaceChinaScaleForNonCNIndex && yesterday.IndexProvider === "ColorfulCloudsCN") { + yesterday.IndexProvider = "Calculate"; + } + + // 5. 构建 Configs(深拷贝合并,并将 Locale/i18n 转为 Map) + const Configs = merge( + {}, + database?.Default?.Configs, + database?.WeatherKit?.Configs, + ); + if (Configs.Locale) Configs.Locale = new Map(Configs.Locale); + if (Configs.i18n) { + for (const type in Configs.i18n) { + Configs.i18n[type] = new Map(Configs.i18n[type]); + } + } + + Console.info("[buildSettings] Settings:", JSON.stringify(Settings, null, 2)); + + return { Settings, Configs }; +} diff --git a/src/egern/wk3/function/database.mjs b/src/egern/wk3/function/database.mjs new file mode 100644 index 00000000..d8d0dba8 --- /dev/null +++ b/src/egern/wk3/function/database.mjs @@ -0,0 +1,207 @@ +export default { + WeatherKit: { + Settings: { + DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "locationInfo", "news", "historicalComparisons", "weatherAlerts", "weatherChanges"], + Weather: { Replace: ["CN"], ReplaceDaily: false, ReplaceHourly: false, Provider: "ColorfulClouds" }, + NextHour: { Provider: "ColorfulClouds" }, + AirQuality: { + Current: { Pollutants: { Provider: "ColorfulClouds", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: ["HJ6332012"], Provider: "ColorfulCloudsCN", ForceCNPrimaryPollutants: true } }, + Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { PollutantsProvider: "ColorfulCloudsCN", IndexProvider: "ColorfulCloudsCN" } }, + Calculate: { Algorithm: "WAQI_InstantCast_CN", AllowOverRange: true }, + }, + API: { QWeather: { Token: null, Host: "devapi.qweather.com" }, ColorfulClouds: { Token: null } }, + EdgeCache: false, + }, + Configs: { + Availability: { + v1: ["currentWeather", "dailyForecast", "hourlyForecast", "minuteForecast", "weatherAlerts"], + v2: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastPeriodic", "historicalComparisons", "weatherChanges", "forecastNextHour", "weatherAlerts", "weatherAlertNotifications", "news"], + }, + }, + }, + Default: { + Settings: { LogLevel: "INFO" }, + Configs: { + Storefront: { + AE: "143481", + AF: "143610", + AG: "143540", + AI: "143538", + AL: "143575", + AM: "143524", + AO: "143564", + AR: "143505", + AT: "143445", + AU: "143460", + AZ: "143568", + BA: "143612", + BB: "143541", + BD: "143490", + BE: "143446", + BF: "143578", + BG: "143526", + BH: "143559", + BJ: "143576", + BM: "143542", + BN: "143560", + BO: "143556", + BR: "143503", + BS: "143539", + BT: "143577", + BW: "143525", + BY: "143565", + BZ: "143555", + CA: "143455", + CD: "143613", + CG: "143582", + CH: "143459", + CI: "143527", + CL: "143483", + CM: "143574", + CN: "143465", + CO: "143501", + CR: "143495", + CV: "143580", + CY: "143557", + CZ: "143489", + DE: "143443", + DK: "143458", + DM: "143545", + DO: "143508", + DZ: "143563", + EC: "143509", + EE: "143518", + EG: "143516", + ES: "143454", + FI: "143447", + FJ: "143583", + FM: "143591", + FR: "143442", + GA: "143614", + GB: "143444", + GD: "143546", + GF: "143615", + GH: "143573", + GM: "143584", + GR: "143448", + GT: "143504", + GW: "143585", + GY: "143553", + HK: "143463", + HN: "143510", + HR: "143494", + HU: "143482", + ID: "143476", + IE: "143449", + IL: "143491", + IN: "143467", + IQ: "143617", + IS: "143558", + IT: "143450", + JM: "143511", + JO: "143528", + JP: "143462", + KE: "143529", + KG: "143586", + KH: "143579", + KN: "143548", + KP: "143466", + KR: "143466", + KW: "143493", + KY: "143544", + KZ: "143517", + TC: "143552", + TD: "143581", + TJ: "143603", + TH: "143475", + TM: "143604", + TN: "143536", + TO: "143608", + TR: "143480", + TT: "143551", + TW: "143470", + TZ: "143572", + LA: "143587", + LB: "143497", + LC: "143549", + LI: "143522", + LK: "143486", + LR: "143588", + LT: "143520", + LU: "143451", + LV: "143519", + LY: "143567", + MA: "143620", + MD: "143523", + ME: "143619", + MG: "143531", + MK: "143530", + ML: "143532", + MM: "143570", + MN: "143592", + MO: "143515", + MR: "143590", + MS: "143547", + MT: "143521", + MU: "143533", + MV: "143488", + MW: "143589", + MX: "143468", + MY: "143473", + MZ: "143593", + NA: "143594", + NE: "143534", + NG: "143561", + NI: "143512", + NL: "143452", + NO: "143457", + NP: "143484", + NR: "143606", + NZ: "143461", + OM: "143562", + PA: "143485", + PE: "143507", + PG: "143597", + PH: "143474", + PK: "143477", + PL: "143478", + PT: "143453", + PW: "143595", + PY: "143513", + QA: "143498", + RO: "143487", + RS: "143500", + RU: "143469", + RW: "143621", + SA: "143479", + SB: "143601", + SC: "143599", + SE: "143456", + SG: "143464", + SI: "143499", + SK: "143496", + SL: "143600", + SN: "143535", + SR: "143554", + ST: "143598", + SV: "143506", + SZ: "143602", + UA: "143492", + UG: "143537", + US: "143441", + UY: "143514", + UZ: "143566", + VC: "143550", + VE: "143502", + VG: "143543", + VN: "143471", + VU: "143609", + XK: "143624", + YE: "143571", + ZA: "143472", + ZM: "143622", + ZW: "143605", + }, + }, + }, +}; diff --git a/src/egern/wk3/function/parseWeatherKitURL.mjs b/src/egern/wk3/function/parseWeatherKitURL.mjs new file mode 100644 index 00000000..9cb2f568 --- /dev/null +++ b/src/egern/wk3/function/parseWeatherKitURL.mjs @@ -0,0 +1,25 @@ +import { Console } from "../utils/index.mjs"; + +export default function parseWeatherKitURL(url = new URL($request.url)) { + Console.debug("☑️ parseWeatherKitURL"); + // NSRingo/WeatherKit v3.3.2: preserve script subtags and normalize the region. + const WeatherKitRegExp = /^\/api\/(?<version>v1|v2|v3)\/(availability|weather)\/(?<locale>[A-Z0-9]+(?:-[A-Z0-9]+)*)\/(?<latitude>-?\d+\.?\d*)\/(?<longitude>-?\d+\.?\d*)$/i; + const Parameters = url?.pathname.match(WeatherKitRegExp)?.groups; + const localeParts = Parameters?.locale?.split("-") || []; + let localeCountry; + if (localeParts.length > 1 && /^[A-Z]{2}$/i.test(localeParts.at(-1))) localeCountry = localeParts.pop().toUpperCase(); + const result = { + version: Parameters?.version, + language: localeParts.join("-") || undefined, + latitude: Parameters?.latitude, + longitude: Parameters?.longitude, + country: url?.searchParams?.get("country")?.toUpperCase() || localeCountry, + dataSets: url?.searchParams?.get("dataSets")?.split(",") || [], + }; + //log(JSON.stringify(result, null, 2)); + //const LanguageParameters = result.language.match(LanguageRegExp)?.groups; + //result.language = LanguageParameters.language; + //result.country = result.country || LanguageParameters.country + Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, `🟧latitude: ${result.latitude} 🟧longitude: ${result.longitude}`); + return result; +} diff --git a/src/egern/wk3/function/shouldReplaceWeatherKit.mjs b/src/egern/wk3/function/shouldReplaceWeatherKit.mjs new file mode 100644 index 00000000..7444db67 --- /dev/null +++ b/src/egern/wk3/function/shouldReplaceWeatherKit.mjs @@ -0,0 +1,53 @@ +const HONG_KONG_BOUNDS = { + minLatitude: 22.10, + maxLatitude: 22.45, + minLongitude: 113.80, + maxLongitude: 114.45, +}; + +const MACAU_BOUNDS = { + minLatitude: 22.05, + maxLatitude: 22.25, + minLongitude: 113.45, + maxLongitude: 113.65, +}; + +const TAIWAN_BOUNDS = { + minLatitude: 21.75, + maxLatitude: 25.50, + minLongitude: 119.20, + maxLongitude: 122.10, +}; + +function inBounds(latitude, longitude, bounds) { + return latitude >= bounds.minLatitude + && latitude <= bounds.maxLatitude + && longitude >= bounds.minLongitude + && longitude <= bounds.maxLongitude; +} + +export function isChinaMainland(parameters = {}) { + if (parameters.country !== "CN") return false; + + const latitude = Number(parameters.latitude); + const longitude = Number(parameters.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return false; + + if (latitude < 18 || latitude > 54 || longitude < 73 || longitude > 135) return false; + if (inBounds(latitude, longitude, HONG_KONG_BOUNDS)) return false; + if (inBounds(latitude, longitude, MACAU_BOUNDS)) return false; + if (inBounds(latitude, longitude, TAIWAN_BOUNDS)) return false; + + return true; +} + +export function isHongKong(parameters = {}) { + if (!["HK", "CN"].includes(parameters.country)) return false; + const latitude = Number(parameters.latitude), longitude = Number(parameters.longitude); + return Number.isFinite(latitude) && Number.isFinite(longitude) && inBounds(latitude, longitude, HONG_KONG_BOUNDS); +} + +export default function shouldReplaceWeatherKit(parameters = {}, Settings = {}) { + if (!Settings?.Weather?.Replace?.includes(parameters.country)) return false; + return isChinaMainland(parameters); +} diff --git a/src/egern/wk3/proto/apple/wk2.js b/src/egern/wk3/proto/apple/wk2.js new file mode 100644 index 00000000..35ddc181 --- /dev/null +++ b/src/egern/wk3/proto/apple/wk2.js @@ -0,0 +1,3971 @@ +// src/proto/apple/wk2.js +// Auto-generated from response.bundle.js (v3.1.0) +// This module contains the Flatbuffer schema definitions for Apple WeatherKit2 +// +// NOTE: This file was reconstructed from a minified bundle. +// Variable names are shortened but logic is preserved. + +// ============================================================ +// Enum definitions +// ============================================================ + +export const StringEncodingType = { + UTF8_BYTES: 1, + UTF16_STRING: 2, +}; + +export const ComparisonTrend = { + UNKNOWN: 0, + MUCH_WORSE: 1, + WORSE: 2, + SAME: 3, + BETTER: 4, + MUCH_BETTER: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "MUCH_WORSE", + 2: "WORSE", + 3: "SAME", + 4: "BETTER", + 5: "MUCH_BETTER", +}; + +export const SourceType = { + APPLE_INTERNAL: 0, + MODELED: 1, + STATION: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + + // Reverse mapping (number → string) + 0: "APPLE_INTERNAL", + 1: "MODELED", + 2: "STATION", + 3: "UNKNOWN3", + 4: "UNKNOWN4", +}; + +export const PollutantType = { + NOT_AVAILABLE: 0, + C6H6: 1, + NH3: 2, + NMHC: 3, + NO: 4, + NO2: 5, + NOX: 6, + OZONE: 7, + PM2_5: 8, + SO2: 9, + PM10: 10, + CO: 11, + UNKNOWN12: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "NOT_AVAILABLE", + 1: "C6H6", + 2: "NH3", + 3: "NMHC", + 4: "NO", + 5: "NO2", + 6: "NOX", + 7: "OZONE", + 8: "PM2_5", + 9: "SO2", + 10: "PM10", + 11: "CO", + 12: "UNKNOWN12", + 13: "UNKNOWN13", +}; + +export const UnitType = { + PARTS_PER_BILLION: 0, + MICROGRAMS_PER_CUBIC_METER: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + + // Reverse mapping (number → string) + 0: "PARTS_PER_BILLION", + 1: "MICROGRAMS_PER_CUBIC_METER", + 2: "UNKNOWN2", + 3: "UNKNOWN3", +}; + +export const ComparisonType = { + UNKNOWN0: 0, + TEMPERATURE_MAX: 1, + PRECIPITATION: 2, + UNKNOWN3: 3, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "TEMPERATURE_MAX", + 2: "PRECIPITATION", + 3: "UNKNOWN3", +}; + +export const Deviation = { + UNKNOWN: 0, + OBSERVED: 1, + LIKELY: 2, + POSSIBLE: 3, + UNLIKELY: 4, + UNKNOWN5: 5, +}; + +export const Direction = { + STEADY: 0, + INC: 1, + DEC: 2, + UNKNOWN3: 3, + + // Reverse mapping (number → string) + 0: "STEADY", + 1: "INC", + 2: "DEC", + 3: "UNKNOWN3", +}; + +export const ComparisonType2 = { + UNKNOWN0: 0, + TEMPERATURE_MAX: 1, + PRECIPITATION: 2, + TEMPERATURE_MIN: 3, + SNOWFALL: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "TEMPERATURE_MAX", + 2: "PRECIPITATION", + 3: "TEMPERATURE_MIN", + 4: "SNOWFALL", + 5: "UNKNOWN5", +}; + +export const Deviation2 = { + MUCHHIGHER: 0, + HIGHER: 1, + NORMAL: 2, + LOWER: 3, + MUCHLOWER: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "MUCHHIGHER", + 1: "HIGHER", + 2: "NORMAL", + 3: "LOWER", + 4: "MUCHLOWER", + 5: "UNKNOWN5", +}; + +export const ConditionType = { + CLEAR: 0, + UNKNOWN1: 1, + UNKNOWN2: 2, + SLEET: 3, + POSSIBLE_SLEET: 4, + HEAVY_RAIN: 5, + RAIN: 6, + DRIZZLE: 7, + POSSIBLE_DRIZZLE: 8, + HEAVY_SNOW: 9, + SNOW: 10, + UNKNOWN11: 11, + UNKNOWN12: 12, + UNKNOWN13: 13, + UNKNOWN14: 14, + UNKNOWN15: 15, + UNKNOWN16: 16, + UNKNOWN17: 17, + UNKNOWN18: 18, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "UNKNOWN1", + 2: "UNKNOWN2", + 3: "SLEET", + 4: "POSSIBLE_SLEET", + 5: "HEAVY_RAIN", + 6: "RAIN", + 7: "DRIZZLE", + 8: "POSSIBLE_DRIZZLE", + 9: "HEAVY_SNOW", + 10: "SNOW", + 11: "UNKNOWN11", + 12: "UNKNOWN12", + 13: "UNKNOWN13", + 14: "UNKNOWN14", + 15: "UNKNOWN15", + 16: "UNKNOWN16", + 17: "UNKNOWN17", + 18: "UNKNOWN18", +}; + +export const ForecastToken = { + CLEAR: 0, + START: 1, + STOP: 2, + START_STOP: 3, + STOP_START: 4, + CONSTANT: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "START", + 2: "STOP", + 3: "START_STOP", + 4: "STOP_START", + 5: "CONSTANT", + 6: "UNKNOWN6", + 7: "UNKNOWN7", +}; + +export const ParameterType = { + FIRST_AT: 0, + SECOND_AT: 1, + UNKNOWN2: 2, + + // Reverse mapping (number → string) + 0: "FIRST_AT", + 1: "SECOND_AT", + 2: "UNKNOWN2", +}; + +export const PrecipitationType = { + CLEAR: 0, + RAIN: 1, + SNOW: 2, + SLEET: 3, + HAIL: 4, + MIXED: 5, + UNKNOWN6: 6, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "RAIN", + 2: "SNOW", + 3: "SLEET", + 4: "HAIL", + 5: "MIXED", + 6: "UNKNOWN6", +}; + +export const PressureTrend = { + RISING: 0, + FALLING: 1, + STEADY: 2, + UNDEFINED: 3, + + // Reverse mapping (number → string) + 0: "RISING", + 1: "FALLING", + 2: "STEADY", + 3: "UNDEFINED", +}; + +export const WeatherCondition = { + CLEAR: 0, + BLIZZARD: 1, + PRECIPITATION: 2, + SLEET: 3, + BREEZY: 4, + CLOUDY: 5, + DRIZZLE: 6, + FLURRIES: 7, + FOGGY: 8, + BLOWING_DUST: 9, + BLOWING_SNOW: 10, + FREEZING_RAIN: 11, + FRIGID: 12, + HAZE: 13, + HEAVY_RAIN: 14, + HEAVY_SNOW: 15, + HAIL: 16, + HOT: 17, + ISOLATED_THUNDERSTORMS: 18, + MOSTLY_CLEAR: 19, + MOSTLY_CLOUDY: 20, + PARTLY_CLOUDY: 21, + RAIN: 22, + HURRICANE: 23, + SCATTERED_THUNDERSTORMS: 24, + UNKNOWN25: 25, + SNOW: 26, + SMOKY: 27, + UNKNOWN28: 28, + FREEZING_DRIZZLE: 29, + THUNDERSTORMS: 30, + STRONG_STORMS: 31, + WINDY: 32, + WINTRY_MIX: 33, + SUN_FLURRIES: 34, + SUN_SHOWERS: 35, + UNKNOWN36: 36, + TROPICAL_STORM: 37, + UNKNOWN38: 38, + UNKNOWN39: 39, + UNKNOWN40: 40, + UNKNOWN41: 41, + UNKNOWN42: 42, + UNKNOWN43: 43, + UNKNOWN44: 44, + + // Reverse mapping (number → string) + 0: "CLEAR", + 1: "BLIZZARD", + 2: "PRECIPITATION", + 3: "SLEET", + 4: "BREEZY", + 5: "CLOUDY", + 6: "DRIZZLE", + 7: "FLURRIES", + 8: "FOGGY", + 9: "BLOWING_DUST", + 10: "BLOWING_SNOW", + 11: "FREEZING_RAIN", + 12: "FRIGID", + 13: "HAZE", + 14: "HEAVY_RAIN", + 15: "HEAVY_SNOW", + 16: "HAIL", + 17: "HOT", + 18: "ISOLATED_THUNDERSTORMS", + 19: "MOSTLY_CLEAR", + 20: "MOSTLY_CLOUDY", + 21: "PARTLY_CLOUDY", + 22: "RAIN", + 23: "HURRICANE", + 24: "SCATTERED_THUNDERSTORMS", + 25: "UNKNOWN25", + 26: "SNOW", + 27: "SMOKY", + 28: "UNKNOWN28", + 29: "FREEZING_DRIZZLE", + 30: "THUNDERSTORMS", + 31: "STRONG_STORMS", + 32: "WINDY", + 33: "WINTRY_MIX", + 34: "SUN_FLURRIES", + 35: "SUN_SHOWERS", + 36: "UNKNOWN36", + 37: "TROPICAL_STORM", + 38: "UNKNOWN38", + 39: "UNKNOWN39", + 40: "UNKNOWN40", + 41: "UNKNOWN41", + 42: "UNKNOWN42", + 43: "UNKNOWN43", + 44: "UNKNOWN44", +}; + +export const MoonPhase = { + NEW: 0, + WAXING_CRESCENT: 1, + FIRST_QUARTER: 2, + WAXING_GIBBOUS: 3, + FULL: 4, + WANING_GIBBOUS: 5, + THIRD_QUARTER: 6, + WANING_CRESCENT: 7, + UNKNOWN8: 8, + + // Reverse mapping (number → string) + 0: "NEW", + 1: "WAXING_CRESCENT", + 2: "FIRST_QUARTER", + 3: "WAXING_GIBBOUS", + 4: "FULL", + 5: "WANING_GIBBOUS", + 6: "THIRD_QUARTER", + 7: "WANING_CRESCENT", + 8: "UNKNOWN8", +}; + +export const PlacementType = { + UNKNOWN0: 0, + AIR_QUALITY_DETAILS: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + TEMPERATURE_FEELS_LIKE_DETAILS: 9, + UNKNOWN10: 10, + TRENDS: 11, + UV_DETAILS: 12, + UNKNOWN13: 13, + WIND_DETAILS: 14, + UNKNOWN15: 15, + UNKNOWN16: 16, + UNKNOWN17: 17, + UNKNOWN18: 18, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "AIR_QUALITY_DETAILS", + 2: "UNKNOWN2", + 3: "UNKNOWN3", + 4: "UNKNOWN4", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "TEMPERATURE_FEELS_LIKE_DETAILS", + 10: "UNKNOWN10", + 11: "TRENDS", + 12: "UV_DETAILS", + 13: "UNKNOWN13", + 14: "WIND_DETAILS", + 15: "UNKNOWN15", + 16: "UNKNOWN16", + 17: "UNKNOWN17", + 18: "UNKNOWN18", +}; + +export const ResponseType = { + UNKNOWN0: 0, + SHELTER: 1, + EVACUATE: 2, + PREPARE: 3, + EXECUTE: 4, + AVOID: 5, + MONITOR: 6, + ASSESS: 7, + ALLCLEAR: 8, + NONE: 9, + UNKNOWN10: 10, + + // Reverse mapping (number → string) + 0: "UNKNOWN0", + 1: "SHELTER", + 2: "EVACUATE", + 3: "PREPARE", + 4: "EXECUTE", + 5: "AVOID", + 6: "MONITOR", + 7: "ASSESS", + 8: "ALLCLEAR", + 9: "NONE", + 10: "UNKNOWN10", +}; + +export const Severity = { + UNKNOWN: 0, + EXTREME: 1, + SEVERE: 2, + MODERATE: 3, + MINOR: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + UNKNOWN9: 9, + STATEMENT: 10, + WARNING: 11, + ADVISORY: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "EXTREME", + 2: "SEVERE", + 3: "MODERATE", + 4: "MINOR", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "UNKNOWN9", + 10: "STATEMENT", + 11: "WARNING", + 12: "ADVISORY", + 13: "UNKNOWN13", +}; + +export const SignificanceType = { + UNKNOWN: 0, + UNKNOWN1: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + UNKNOWN9: 9, + STATEMENT: 10, + WARNING: 11, + ADVISORY: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "UNKNOWN1", + 2: "UNKNOWN2", + 3: "UNKNOWN3", + 4: "UNKNOWN4", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "UNKNOWN9", + 10: "STATEMENT", + 11: "WARNING", + 12: "ADVISORY", + 13: "UNKNOWN13", +}; + +export const Urgency = { + UNKNOWN: 0, + IMMEDIATE: 1, + EXPECTED: 2, + FUTURE: 3, + PAST: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "IMMEDIATE", + 2: "EXPECTED", + 3: "FUTURE", + 4: "PAST", + 5: "UNKNOWN5", +}; + +export const Certainty = { + UNKNOWN: 0, + OBSERVED: 1, + LIKELY: 2, + POSSIBLE: 3, + UNLIKELY: 4, + UNKNOWN5: 5, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "OBSERVED", + 2: "LIKELY", + 3: "POSSIBLE", + 4: "UNLIKELY", + 5: "UNKNOWN5", +}; + +export const ImportanceType = { + UNKNOWN: 0, + UNKNOWN1: 1, + UNKNOWN2: 2, + UNKNOWN3: 3, + UNKNOWN4: 4, + UNKNOWN5: 5, + UNKNOWN6: 6, + UNKNOWN7: 7, + UNKNOWN8: 8, + UNKNOWN9: 9, + STATEMENT: 10, + WARNING: 11, + ADVISORY: 12, + UNKNOWN13: 13, + + // Reverse mapping (number → string) + 0: "UNKNOWN", + 1: "UNKNOWN1", + 2: "UNKNOWN2", + 3: "UNKNOWN3", + 4: "UNKNOWN4", + 5: "UNKNOWN5", + 6: "UNKNOWN6", + 7: "UNKNOWN7", + 8: "UNKNOWN8", + 9: "UNKNOWN9", + 10: "STATEMENT", + 11: "WARNING", + 12: "ADVISORY", + 13: "UNKNOWN13", +}; + +// ============================================================ +// Flatbuffer classes +// ============================================================ + +// ByteBuffer typed-array helpers (from response.bundle.js) +const int32 = new Int32Array(2); +const float32 = new Float32Array(int32.buffer); +const float64 = new Float64Array(int32.buffer); +const isLittleEndian = new Uint16Array(new Uint8Array([1, 0]).buffer)[0] === 1; + +// ByteBuffer +export class ByteBuffer { + constructor(bytes) { + (this.bytes_ = bytes), (this.position_ = 0), (this.text_decoder_ = new TextDecoder()); + } + static allocate(size) { + return new ByteBuffer(new Uint8Array(size)); + } + clear() { + this.position_ = 0; + } + bytes() { + return this.bytes_; + } + position() { + return this.position_; + } + setPosition(pos) { + this.position_ = pos; + } + capacity() { + return this.bytes_.length; + } + readInt8(offset) { + return (this.readUint8(offset) << 24) >> 24; + } + readUint8(offset) { + return this.bytes_[offset]; + } + readInt16(offset) { + return (this.readUint16(offset) << 16) >> 16; + } + readUint16(offset) { + return this.bytes_[offset] | (this.bytes_[offset + 1] << 8); + } + readInt32(offset) { + return this.bytes_[offset] | (this.bytes_[offset + 1] << 8) | (this.bytes_[offset + 2] << 16) | (this.bytes_[offset + 3] << 24); + } + readUint32(offset) { + return this.readInt32(offset) >>> 0; + } + readInt64(offset) { + return BigInt.asIntN(64, BigInt(this.readUint32(offset)) + (BigInt(this.readUint32(offset + 4)) << BigInt(32))); + } + readUint64(offset) { + return BigInt.asUintN(64, BigInt(this.readUint32(offset)) + (BigInt(this.readUint32(offset + 4)) << BigInt(32))); + } + readFloat32(offset) { + return (int32["0"] = this.readInt32(offset)), float32["0"]; + } + readFloat64(offset) { + return (int32[+!isLittleEndian] = this.readInt32(offset)), (int32[+!!isLittleEndian] = this.readInt32(offset + 4)), float64["0"]; + } + writeInt8(offset, value) { + this.bytes_[offset] = value; + } + writeUint8(offset, value) { + this.bytes_[offset] = value; + } + writeInt16(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8); + } + writeUint16(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8); + } + writeInt32(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8), (this.bytes_[offset + 2] = value >> 16), (this.bytes_[offset + 3] = value >> 24); + } + writeUint32(offset, value) { + (this.bytes_[offset] = value), (this.bytes_[offset + 1] = value >> 8), (this.bytes_[offset + 2] = value >> 16), (this.bytes_[offset + 3] = value >> 24); + } + writeInt64(offset, value) { + this.writeInt32(offset, Number(BigInt.asIntN(32, value))), this.writeInt32(offset + 4, Number(BigInt.asIntN(32, value >> BigInt(32)))); + } + writeUint64(offset, value) { + this.writeUint32(offset, Number(BigInt.asUintN(32, value))), this.writeUint32(offset + 4, Number(BigInt.asUintN(32, value >> BigInt(32)))); + } + writeFloat32(offset, value) { + (float32["0"] = value), this.writeInt32(offset, int32["0"]); + } + writeFloat64(offset, value) { + (float64["0"] = value), this.writeInt32(offset, int32[+!isLittleEndian]), this.writeInt32(offset + 4, int32[+!!isLittleEndian]); + } + getBufferIdentifier() { + if (this.bytes_.length < this.position_ + 4 + 4) throw Error("FlatBuffers: ByteBuffer is too short to contain an identifier."); + let t = ""; + for (let e = 0; e < 4; e++) t += String.fromCharCode(this.readInt8(this.position_ + 4 + e)); + return t; + } + __offset(table, field) { + const i = table - this.readInt32(table); + return field < this.readInt16(i) ? this.readInt16(i + field) : 0; + } + __union(obj, offset) { + return (obj.bb_pos = offset + this.readInt32(offset)), (obj.bb = this), obj; + } + __string(offset, encoding) { + offset += this.readInt32(offset); + const a = this.readInt32(offset); + offset += 4; + const s = this.bytes_.subarray(offset, offset + a); + return encoding === StringEncodingType.UTF8_BYTES ? s : this.text_decoder_.decode(s); + } + __union_with_string(unionOrStr, offset) { + return "string" === typeof unionOrStr ? this.__string(offset) : this.__union(unionOrStr, offset); + } + __indirect(offset) { + return offset + this.readInt32(offset); + } + __vector(offset) { + return offset + this.readInt32(offset) + 4; + } + __vector_len(offset) { + return this.readInt32(offset + this.readInt32(offset)); + } + __has_identifier(id) { + if (4 !== id.length) throw Error("FlatBuffers: file identifier must be length 4"); + for (let e = 0; e < 4; e++) if (id.charCodeAt(e) !== this.readInt8(this.position() + 4 + e)) return !1; + return !0; + } + createScalarList(listFunc, length) { + const i = []; + for (let a = 0; a < length; ++a) { + const value = listFunc(a); + null !== value && i.push(value); + } + return i; + } + createObjList(listFunc, length) { + const i = []; + for (let a = 0; a < length; ++a) { + const item = listFunc(a); + null !== item && i.push(item.unpack()); + } + return i; + } +} + +// Builder +export class Builder { + constructor(initialSize) { + let e; + (this.minalign = 1), + (this.vtable = null), + (this.vtable_in_use = 0), + (this.isNested = !1), + (this.object_start = 0), + (this.vtables = []), + (this.vector_num_elems = 0), + (this.force_defaults = !1), + (this.string_maps = null), + (this.text_encoder = new TextEncoder()), + (e = initialSize || 1024), + (this.bb = ByteBuffer.allocate(e)), + (this.space = e); + } + clear() { + this.bb.clear(), (this.space = this.bb.capacity()), (this.minalign = 1), (this.vtable = null), (this.vtable_in_use = 0), (this.isNested = !1), (this.object_start = 0), (this.vtables = []), (this.vector_num_elems = 0), (this.force_defaults = !1), (this.string_maps = null); + } + forceDefaults(enabled) { + this.force_defaults = enabled; + } + dataBuffer() { + return this.bb; + } + asUint8Array() { + return this.bb.bytes().subarray(this.bb.position(), this.bb.position() + this.offset()); + } + prep(align, len) { + align > this.minalign && (this.minalign = align); + const i = (~(this.bb.capacity() - this.space + len) + 1) & (align - 1); + for (; this.space < i + align + len; ) { + const align = this.bb.capacity(); + (this.bb = Builder.growByteBuffer(this.bb)), (this.space += this.bb.capacity() - align); + } + this.pad(i); + } + pad(value) { + for (let e = 0; e < value; e++) this.bb.writeInt8(--this.space, 0); + } + writeInt8(value) { + this.bb.writeInt8((this.space -= 1), value); + } + writeInt16(value) { + this.bb.writeInt16((this.space -= 2), value); + } + writeInt32(value) { + this.bb.writeInt32((this.space -= 4), value); + } + writeInt64(value) { + this.bb.writeInt64((this.space -= 8), value); + } + writeFloat32(value) { + this.bb.writeFloat32((this.space -= 4), value); + } + writeFloat64(value) { + this.bb.writeFloat64((this.space -= 8), value); + } + addInt8(value) { + this.prep(1, 0), this.writeInt8(value); + } + addInt16(value) { + this.prep(2, 0), this.writeInt16(value); + } + addInt32(value) { + this.prep(4, 0), this.writeInt32(value); + } + addInt64(value) { + this.prep(8, 0), this.writeInt64(value); + } + addFloat32(value) { + this.prep(4, 0), this.writeFloat32(value); + } + addFloat64(value) { + this.prep(8, 0), this.writeFloat64(value); + } + addFieldInt8(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt8(value), this.slot(id)); + } + addFieldInt16(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt16(value), this.slot(id)); + } + addFieldInt32(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt32(value), this.slot(id)); + } + addFieldInt64(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addInt64(value), this.slot(id)); + } + addFieldFloat32(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addFloat32(value), this.slot(id)); + } + addFieldFloat64(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addFloat64(value), this.slot(id)); + } + addFieldOffset(id, value, defaultValue) { + (this.force_defaults || value !== defaultValue) && (this.addOffset(value), this.slot(id)); + } + addFieldStruct(id, value, defaultValue) { + value !== defaultValue && (this.nested(value), this.slot(id)); + } + nested(value) { + if (value !== this.offset()) throw TypeError("FlatBuffers: struct must be serialized inline."); + } + notNested() { + if (this.isNested) throw TypeError("FlatBuffers: object serialization must not be nested."); + } + slot(value) { + null !== this.vtable && (this.vtable[value] = this.offset()); + } + offset() { + return this.bb.capacity() - this.space; + } + static growByteBuffer(bb) { + const e = bb.capacity(); + if (0xc0000000 & e) throw Error("FlatBuffers: cannot grow buffer beyond 2 gigabytes."); + const i = e << 1, + a = ByteBuffer.allocate(i); + return a.setPosition(i - e), a.bytes().set(bb.bytes(), i - e), a; + } + addOffset(value) { + this.prep(4, 0), this.writeInt32(this.offset() - value + 4); + } + startObject(fieldCount) { + this.notNested(), null == this.vtable && (this.vtable = []), (this.vtable_in_use = fieldCount); + for (let e = 0; e < fieldCount; e++) this.vtable[e] = 0; + (this.isNested = !0), (this.object_start = this.offset()); + } + endObject() { + if (null == this.vtable || !this.isNested) throw Error("FlatBuffers: endObject called without startObject"); + this.addInt32(0); + let t = this.offset(), + e = this.vtable_in_use - 1; + for (; e >= 0 && 0 === this.vtable[e]; e--); + const i = e + 1; + for (; e >= 0; e--) this.addInt16(0 !== this.vtable[e] ? t - this.vtable[e] : 0); + this.addInt16(t - this.object_start); + const a = (i + 2) * 2; + this.addInt16(a); + let s = 0, + n = this.space; + t: for (e = 0; e < this.vtables.length; e++) { + const t = this.bb.capacity() - this.vtables[e]; + if (a === this.bb.readInt16(t)) { + for (let e = 2; e < a; e += 2) if (this.bb.readInt16(n + e) !== this.bb.readInt16(t + e)) continue t; + s = this.vtables[e]; + break; + } + } + return s ? ((this.space = this.bb.capacity() - t), this.bb.writeInt32(this.space, s - t)) : (this.vtables.push(this.offset()), this.bb.writeInt32(this.bb.capacity() - t, this.offset() - t)), (this.isNested = !1), t; + } + finish(root, fileId, sizePrefix) { + const a = 4 * !!sizePrefix; + if (fileId) { + if ((this.prep(this.minalign, 8 + a), 4 !== fileId.length)) throw TypeError("FlatBuffers: file identifier must be length 4"); + for (let root = 3; root >= 0; root--) this.writeInt8(fileId.charCodeAt(root)); + } + this.prep(this.minalign, 4 + a), this.addOffset(root), a && this.addInt32(this.bb.capacity() - this.space), this.bb.setPosition(this.space); + } + finishSizePrefixed(root, fileId) { + this.finish(root, fileId, !0); + } + requiredField(table, field) { + const i = this.bb.capacity() - table, + a = i - this.bb.readInt32(i); + if (!(field < this.bb.readInt16(a) && 0 !== this.bb.readInt16(a + field))) throw TypeError(`FlatBuffers: field ${field} must be set`); + } + startVector(elemSize, numElems, alignment) { + this.notNested(), (this.vector_num_elems = numElems), this.prep(4, elemSize * numElems), this.prep(alignment, elemSize * numElems); + } + endVector() { + return this.writeInt32(this.vector_num_elems), this.offset(); + } + createSharedString(str) { + if (!str) return 0; + if ((this.string_maps || (this.string_maps = new Map()), this.string_maps.has(str))) return this.string_maps.get(str); + const e = this.createString(str); + return this.string_maps.set(str, e), e; + } + createString(str) { + let e; + return null == str ? 0 : ((e = str instanceof Uint8Array ? str : this.text_encoder.encode(str)), this.addInt8(0), this.startVector(1, e.length, 1), this.bb.setPosition((this.space -= e.length)), this.bb.bytes().set(e, this.space), this.endVector()); + } + createByteVector(bytes) { + return null == bytes ? 0 : (this.startVector(1, bytes.length, 1), this.bb.setPosition((this.space -= bytes.length)), this.bb.bytes().set(bytes, this.space), this.endVector()); + } + createObjectOffset(obj) { + return null === obj ? 0 : "string" === typeof obj ? this.createString(obj) : obj.pack(this); + } + createObjectOffsetList(list) { + const e = []; + for (let i = 0; i < list.length; ++i) { + const a = list[i]; + if (null !== a) e.push(this.createObjectOffset(a)); + else throw TypeError("FlatBuffers: Argument for createObjectOffsetList cannot contain null."); + } + return e; + } + createStructOffsetList(list, func) { + return func(this, list.length), this.createObjectOffsetList(list.slice().reverse()), this.endVector(); + } +} + +// Metadata +export class Metadata { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsMetadata(bb, obj) { + return (obj || new Metadata()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsMetadata(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Metadata()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + attributionUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + expireTime() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + language(encoding) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + latitude() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + longitude() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + providerLogo(encoding) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + providerName(encoding) { + const e = this.bb.__offset(this.bb_pos, 16); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + readTime() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + reportedTime() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + temporarilyUnavailable() { + const t = this.bb.__offset(this.bb_pos, 22); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + sourceType() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readInt8(this.bb_pos + t) : SourceType.APPLE_INTERNAL; + } + static startMetadata(builder) { + builder.startObject(11); + } + static addAttributionUrl(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addExpireTime(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addLanguage(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addLatitude(builder, value) { + builder.addFieldFloat32(3, value, 0); + } + static addLongitude(builder, value) { + builder.addFieldFloat32(4, value, 0); + } + static addProviderLogo(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static addProviderName(builder, value) { + builder.addFieldOffset(6, value, 0); + } + static addReadTime(builder, value) { + builder.addFieldInt32(7, value, 0); + } + static addReportedTime(builder, value) { + builder.addFieldInt32(8, value, 0); + } + static addTemporarilyUnavailable(builder, value) { + builder.addFieldInt8(9, +value, 0); + } + static addSourceType(builder, value) { + builder.addFieldInt8(10, value, SourceType.APPLE_INTERNAL); + } + static endMetadata(builder) { + return builder.endObject(); + } + static createMetadata(builder, e, i, a, s, n, r, o, d, c, l, u) { + return ( + Metadata.startMetadata(builder), + Metadata.addAttributionUrl(builder, e), + Metadata.addExpireTime(builder, i), + Metadata.addLanguage(builder, a), + Metadata.addLatitude(builder, s), + Metadata.addLongitude(builder, n), + Metadata.addProviderLogo(builder, r), + Metadata.addProviderName(builder, o), + Metadata.addReadTime(builder, d), + Metadata.addReportedTime(builder, c), + Metadata.addTemporarilyUnavailable(builder, l), + Metadata.addSourceType(builder, u), + Metadata.endMetadata(builder) + ); + } +} + +// Pollutant +export class Pollutant { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsPollutant(bb, obj) { + return (obj || new Pollutant()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsPollutant(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Pollutant()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + pollutantType() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : PollutantType.NOT_AVAILABLE; + } + amount() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + units() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : UnitType.PARTS_PER_BILLION; + } + static startPollutant(builder) { + builder.startObject(3); + } + static addPollutantType(builder, value) { + builder.addFieldInt8(0, value, PollutantType.NOT_AVAILABLE); + } + static addAmount(builder, value) { + builder.addFieldFloat32(1, value, 0); + } + static addUnits(builder, value) { + builder.addFieldInt8(2, value, UnitType.PARTS_PER_BILLION); + } + static endPollutant(builder) { + return builder.endObject(); + } + static createPollutant(builder, e, i, a) { + return Pollutant.startPollutant(builder), Pollutant.addPollutantType(builder, e), Pollutant.addAmount(builder, i), Pollutant.addUnits(builder, a), Pollutant.endPollutant(builder); + } +} + +// AirQuality +export class AirQuality { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsAirQuality(bb, obj) { + return (obj || new AirQuality()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsAirQuality(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new AirQuality()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + categoryIndex() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + index() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + isSignificant() { + const t = this.bb.__offset(this.bb_pos, 10); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + pollutants(index, optional) { + const i = this.bb.__offset(this.bb_pos, 12); + return i ? (optional || new Pollutant()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + pollutantsLength() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + previousDayComparison() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : ComparisonTrend.UNKNOWN; + } + primaryPollutant() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : PollutantType.NOT_AVAILABLE; + } + scale(encoding) { + const e = this.bb.__offset(this.bb_pos, 18); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + static startAirQuality(builder) { + builder.startObject(8); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addCategoryIndex(builder, value) { + builder.addFieldInt8(1, value, 0); + } + static addIndex(builder, value) { + builder.addFieldInt16(2, value, 0); + } + static addIsSignificant(builder, value) { + builder.addFieldInt8(3, +value, 0); + } + static addPollutants(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static createPollutantsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPollutantsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPreviousDayComparison(builder, value) { + builder.addFieldInt8(5, value, ComparisonTrend.UNKNOWN); + } + static addPrimaryPollutant(builder, value) { + builder.addFieldInt8(6, value, PollutantType.NOT_AVAILABLE); + } + static addScale(builder, value) { + builder.addFieldOffset(7, value, 0); + } + static endAirQuality(builder) { + return builder.endObject(); + } + static createAirQuality(builder, e, i, a, s, n, r, o, d) { + return ( + AirQuality.startAirQuality(builder), + AirQuality.addMetadata(builder, e), + AirQuality.addCategoryIndex(builder, i), + AirQuality.addIndex(builder, a), + AirQuality.addIsSignificant(builder, s), + AirQuality.addPollutants(builder, n), + AirQuality.addPreviousDayComparison(builder, r), + AirQuality.addPrimaryPollutant(builder, o), + AirQuality.addScale(builder, d), + AirQuality.endAirQuality(builder) + ); + } +} + +// UUID +export class UUID { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsUUID(bb, obj) { + return (obj || new UUID()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsUUID(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new UUID()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + bytes(encoding) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? this.bb.readUint8(this.bb.__vector(this.bb_pos + e) + encoding) : 0; + } + bytesLength() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + bytesArray() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? new Uint8Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + t), this.bb.__vector_len(this.bb_pos + t)) : null; + } + static startUUID(builder) { + builder.startObject(1); + } + static addBytes(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static createBytesVector(builder, data) { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) builder.addInt8(data[i]); + return builder.endVector(); + } + static startBytesVector(builder, numElems) { + builder.startVector(1, numElems, 1); + } + static endUUID(builder) { + return builder.endObject(); + } + static createUUID(builder, value) { + return UUID.startUUID(builder), UUID.addBytes(builder, value), UUID.endUUID(builder); + } +} + +// Articles +export class Articles { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsArticles(bb, obj) { + return (obj || new Articles()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsArticles(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Articles()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + id(encoding) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + supportedStorefronts(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? this.bb.__string(this.bb.__vector(this.bb_pos + i) + 4 * index, optional) : null; + } + supportedStorefrontsLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + alertIds(index, optional) { + const i = this.bb.__offset(this.bb_pos, 8); + return i ? (optional || new UUID()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + alertIdsLength() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + phenomena(index, optional) { + const i = this.bb.__offset(this.bb_pos, 10); + return i ? this.bb.__string(this.bb.__vector(this.bb_pos + i) + 4 * index, optional) : null; + } + phenomenaLength() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + headlineOverride(encoding) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + locale(encoding) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + static startArticles(builder) { + builder.startObject(6); + } + static addId(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addSupportedStorefronts(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createSupportedStorefrontsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startSupportedStorefrontsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addAlertIds(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static createAlertIdsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startAlertIdsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPhenomena(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static createPhenomenaVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPhenomenaVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addHeadlineOverride(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static addLocale(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static endArticles(builder) { + return builder.endObject(); + } + static createArticles(builder, e, i, a, s, n, r) { + return Articles.startArticles(builder), Articles.addId(builder, e), Articles.addSupportedStorefronts(builder, i), Articles.addAlertIds(builder, a), Articles.addPhenomena(builder, s), Articles.addHeadlineOverride(builder, n), Articles.addLocale(builder, r), Articles.endArticles(builder); + } +} + +// Change +export class Change { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsChange(bb, obj) { + return (obj || new Change()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsChange(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Change()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + maxTemperatureChange() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + minTemperatureChange() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + dayPrecipitationChange() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + nightPrecipitationChange() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : Direction.STEADY; + } + static startChange(builder) { + builder.startObject(6); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addMaxTemperatureChange(builder, value) { + builder.addFieldInt8(2, value, Direction.STEADY); + } + static addMinTemperatureChange(builder, value) { + builder.addFieldInt8(3, value, Direction.STEADY); + } + static addDayPrecipitationChange(builder, value) { + builder.addFieldInt8(4, value, Direction.STEADY); + } + static addNightPrecipitationChange(builder, value) { + builder.addFieldInt8(5, value, Direction.STEADY); + } + static endChange(builder) { + return builder.endObject(); + } + static createChange(builder, e, i, a, s, n, r) { + return ( + Change.startChange(builder), Change.addForecastStart(builder, e), Change.addForecastEnd(builder, i), Change.addMaxTemperatureChange(builder, a), Change.addMinTemperatureChange(builder, s), Change.addDayPrecipitationChange(builder, n), Change.addNightPrecipitationChange(builder, r), Change.endChange(builder) + ); + } +} + +// Comparison +export class Comparison { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsComparison(bb, obj) { + return (obj || new Comparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsComparison(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Comparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + condition() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : ComparisonType2.UNKNOWN0; + } + currentValue() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + baselineValue() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + deviation() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : Deviation2.MUCHHIGHER; + } + baselineType() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + baselineStartDate() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + static startComparison(builder) { + builder.startObject(6); + } + static addCondition(builder, value) { + builder.addFieldInt8(0, value, ComparisonType2.UNKNOWN0); + } + static addCurrentValue(builder, value) { + builder.addFieldFloat32(1, value, 0); + } + static addBaselineValue(builder, value) { + builder.addFieldFloat32(2, value, 0); + } + static addDeviation(builder, value) { + builder.addFieldInt8(3, value, Deviation2.MUCHHIGHER); + } + static addBaselineType(builder, value) { + builder.addFieldInt32(4, value, 0); + } + static addBaselineStartDate(builder, value) { + builder.addFieldInt32(5, value, 0); + } + static endComparison(builder) { + return builder.endObject(); + } + static createComparison(builder, e, i, a, s, n, r) { + return ( + Comparison.startComparison(builder), + Comparison.addCondition(builder, e), + Comparison.addCurrentValue(builder, i), + Comparison.addBaselineValue(builder, a), + Comparison.addDeviation(builder, s), + Comparison.addBaselineType(builder, n), + Comparison.addBaselineStartDate(builder, r), + Comparison.endComparison(builder) + ); + } +} + +// Parameter +export class Parameter { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsParameter(bb, obj) { + return (obj || new Parameter()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsParameter(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Parameter()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + type() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : ParameterType.FIRST_AT; + } + date() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + static startParameter(builder) { + builder.startObject(2); + } + static addType(builder, value) { + builder.addFieldInt8(0, value, ParameterType.FIRST_AT); + } + static addDate(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static endParameter(builder) { + return builder.endObject(); + } + static createParameter(builder, e, i) { + return Parameter.startParameter(builder), Parameter.addType(builder, e), Parameter.addDate(builder, i), Parameter.endParameter(builder); + } +} + +// Condition +export class Condition { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsCondition(bb, obj) { + return (obj || new Condition()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsCondition(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Condition()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + startTime() { + const builder = this.bb.__offset(this.bb_pos, 4); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + endTime() { + const builder = this.bb.__offset(this.bb_pos, 6); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + forecastToken() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : ForecastToken.CLEAR; + } + beginCondition() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : ConditionType.CLEAR; + } + endCondition() { + const builder = this.bb.__offset(this.bb_pos, 12); + return builder ? this.bb.readInt8(this.bb_pos + builder) : ConditionType.CLEAR; + } + parameters(index, optional) { + const i = this.bb.__offset(this.bb_pos, 14); + return i ? (optional || new Parameter()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + parametersLength() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startCondition(builder) { + builder.startObject(6); + } + static addStartTime(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addEndTime(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addForecastToken(builder, value) { + builder.addFieldInt8(2, value, ForecastToken.CLEAR); + } + static addBeginCondition(builder, value) { + builder.addFieldInt8(3, value, ConditionType.CLEAR); + } + static addEndCondition(builder, value) { + builder.addFieldInt8(4, value, ConditionType.CLEAR); + } + static addParameters(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static createParametersVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startParametersVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endCondition(builder) { + return builder.endObject(); + } + static createCondition(builder, e, i, a, s, n, r) { + return Condition.startCondition(builder), Condition.addStartTime(builder, e), Condition.addEndTime(builder, i), Condition.addForecastToken(builder, a), Condition.addBeginCondition(builder, s), Condition.addEndCondition(builder, n), Condition.addParameters(builder, r), Condition.endCondition(builder); + } +} + +// PrecipitationAmountByType +export class PrecipitationAmountByType { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsPrecipitationAmountByType(bb, obj) { + return (obj || new PrecipitationAmountByType()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsPrecipitationAmountByType(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new PrecipitationAmountByType()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + expected() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + minimumSnow() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + maximumSnow() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + expectedSnow() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startPrecipitationAmountByType(builder) { + builder.startObject(5); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(0, value, PrecipitationType.CLEAR); + } + static addExpected(builder, value) { + builder.addFieldFloat32(1, value, 0); + } + static addMinimumSnow(builder, value) { + builder.addFieldFloat32(2, value, 0); + } + static addMaximumSnow(builder, value) { + builder.addFieldFloat32(3, value, 0); + } + static addExpectedSnow(builder, value) { + builder.addFieldFloat32(4, value, 0); + } + static endPrecipitationAmountByType(builder) { + return builder.endObject(); + } + static createPrecipitationAmountByType(builder, e, i, a, s, n) { + return ( + PrecipitationAmountByType.startPrecipitationAmountByType(builder), + PrecipitationAmountByType.addPrecipitationType(builder, e), + PrecipitationAmountByType.addExpected(builder, i), + PrecipitationAmountByType.addMinimumSnow(builder, a), + PrecipitationAmountByType.addMaximumSnow(builder, s), + PrecipitationAmountByType.addExpectedSnow(builder, n), + PrecipitationAmountByType.endPrecipitationAmountByType(builder) + ); + } +} + +// CurrentWeatherData +export class CurrentWeatherData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsCurrentWeatherData(bb, obj) { + return (obj || new CurrentWeatherData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsCurrentWeatherData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new CurrentWeatherData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + asOf() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + cloudCover() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverLowAltPct() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverMidAltPct() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverHighAltPct() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + daylight() { + const t = this.bb.__offset(this.bb_pos, 18); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + humidity() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount1h() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount6h() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount24h() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext1h() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext6h() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext24h() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountNext1hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 36); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountNext1hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountNext6hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 38); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountNext6hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountNext24hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 40); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountNext24hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountPrevious1hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 42); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountPrevious1hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountPrevious6hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 44); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountPrevious6hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationAmountPrevious24hByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 46); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountPrevious24hByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + pressure() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + pressureTrend() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readInt8(this.bb_pos + t) : PressureTrend.RISING; + } + snowfallAmount1h() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmount6h() { + const t = this.bb.__offset(this.bb_pos, 56); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmount24h() { + const t = this.bb.__offset(this.bb_pos, 58); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmountNext1h() { + const t = this.bb.__offset(this.bb_pos, 60); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmountNext6h() { + const t = this.bb.__offset(this.bb_pos, 62); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallAmountNext24h() { + const t = this.bb.__offset(this.bb_pos, 64); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperature() { + const t = this.bb.__offset(this.bb_pos, 66); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureApparent() { + const t = this.bb.__offset(this.bb_pos, 68); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + unknown34() { + const t = this.bb.__offset(this.bb_pos, 70); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureDewPoint() { + const t = this.bb.__offset(this.bb_pos, 72); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + uvIndex() { + const t = this.bb.__offset(this.bb_pos, 74); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + visibility() { + const t = this.bb.__offset(this.bb_pos, 76); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windDirection() { + const t = this.bb.__offset(this.bb_pos, 78); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + windGust() { + const t = this.bb.__offset(this.bb_pos, 80); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeed() { + const t = this.bb.__offset(this.bb_pos, 82); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startCurrentWeatherData(builder) { + builder.startObject(40); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addAsOf(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addCloudCover(builder, value) { + builder.addFieldInt8(2, value, 0); + } + static addCloudCoverLowAltPct(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addCloudCoverMidAltPct(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addCloudCoverHighAltPct(builder, value) { + builder.addFieldInt8(5, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(6, value, WeatherCondition.CLEAR); + } + static addDaylight(builder, value) { + builder.addFieldInt8(7, +value, 0); + } + static addHumidity(builder, value) { + builder.addFieldInt8(8, value, 0); + } + static addPerceivedPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(9, value, 0); + } + static addPrecipitationAmount1h(builder, value) { + builder.addFieldFloat32(10, value, 0); + } + static addPrecipitationAmount6h(builder, value) { + builder.addFieldFloat32(11, value, 0); + } + static addPrecipitationAmount24h(builder, value) { + builder.addFieldFloat32(12, value, 0); + } + static addPrecipitationAmountNext1h(builder, value) { + builder.addFieldFloat32(13, value, 0); + } + static addPrecipitationAmountNext6h(builder, value) { + builder.addFieldFloat32(14, value, 0); + } + static addPrecipitationAmountNext24h(builder, value) { + builder.addFieldFloat32(15, value, 0); + } + static addPrecipitationAmountNext1hByType(builder, value) { + builder.addFieldOffset(16, value, 0); + } + static createPrecipitationAmountNext1hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountNext1hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountNext6hByType(builder, value) { + builder.addFieldOffset(17, value, 0); + } + static createPrecipitationAmountNext6hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountNext6hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountNext24hByType(builder, value) { + builder.addFieldOffset(18, value, 0); + } + static createPrecipitationAmountNext24hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountNext24hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountPrevious1hByType(builder, value) { + builder.addFieldOffset(19, value, 0); + } + static createPrecipitationAmountPrevious1hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountPrevious1hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountPrevious6hByType(builder, value) { + builder.addFieldOffset(20, value, 0); + } + static createPrecipitationAmountPrevious6hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountPrevious6hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationAmountPrevious24hByType(builder, value) { + builder.addFieldOffset(21, value, 0); + } + static createPrecipitationAmountPrevious24hByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountPrevious24hByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(22, value, 0); + } + static addPressure(builder, value) { + builder.addFieldFloat32(23, value, 0); + } + static addPressureTrend(builder, value) { + builder.addFieldInt8(24, value, PressureTrend.RISING); + } + static addSnowfallAmount1h(builder, value) { + builder.addFieldFloat32(25, value, 0); + } + static addSnowfallAmount6h(builder, value) { + builder.addFieldFloat32(26, value, 0); + } + static addSnowfallAmount24h(builder, value) { + builder.addFieldFloat32(27, value, 0); + } + static addSnowfallAmountNext1h(builder, value) { + builder.addFieldFloat32(28, value, 0); + } + static addSnowfallAmountNext6h(builder, value) { + builder.addFieldFloat32(29, value, 0); + } + static addSnowfallAmountNext24h(builder, value) { + builder.addFieldFloat32(30, value, 0); + } + static addTemperature(builder, value) { + builder.addFieldFloat32(31, value, 0); + } + static addTemperatureApparent(builder, value) { + builder.addFieldFloat32(32, value, 0); + } + static addUnknown34(builder, value) { + builder.addFieldFloat32(33, value, 0); + } + static addTemperatureDewPoint(builder, value) { + builder.addFieldFloat32(34, value, 0); + } + static addUvIndex(builder, value) { + builder.addFieldInt8(35, value, 0); + } + static addVisibility(builder, value) { + builder.addFieldFloat32(36, value, 0); + } + static addWindDirection(builder, value) { + builder.addFieldInt16(37, value, 0); + } + static addWindGust(builder, value) { + builder.addFieldFloat32(38, value, 0); + } + static addWindSpeed(builder, value) { + builder.addFieldFloat32(39, value, 0); + } + static endCurrentWeatherData(builder) { + return builder.endObject(); + } + static createCurrentWeatherData(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v, S, O, F, w, P, E, M, R, U, W, L, D, B, $) { + return ( + CurrentWeatherData.startCurrentWeatherData(builder), + CurrentWeatherData.addMetadata(builder, e), + CurrentWeatherData.addAsOf(builder, i), + CurrentWeatherData.addCloudCover(builder, a), + CurrentWeatherData.addCloudCoverLowAltPct(builder, s), + CurrentWeatherData.addCloudCoverMidAltPct(builder, n), + CurrentWeatherData.addCloudCoverHighAltPct(builder, r), + CurrentWeatherData.addConditionCode(builder, o), + CurrentWeatherData.addDaylight(builder, d), + CurrentWeatherData.addHumidity(builder, c), + CurrentWeatherData.addPerceivedPrecipitationIntensity(builder, l), + CurrentWeatherData.addPrecipitationAmount1h(builder, u), + CurrentWeatherData.addPrecipitationAmount6h(builder, p), + CurrentWeatherData.addPrecipitationAmount24h(builder, b), + CurrentWeatherData.addPrecipitationAmountNext1h(builder, h), + CurrentWeatherData.addPrecipitationAmountNext6h(builder, m), + CurrentWeatherData.addPrecipitationAmountNext24h(builder, _), + CurrentWeatherData.addPrecipitationAmountNext1hByType(builder, y), + CurrentWeatherData.addPrecipitationAmountNext6hByType(builder, f), + CurrentWeatherData.addPrecipitationAmountNext24hByType(builder, g), + CurrentWeatherData.addPrecipitationAmountPrevious1hByType(builder, N), + CurrentWeatherData.addPrecipitationAmountPrevious6hByType(builder, C), + CurrentWeatherData.addPrecipitationAmountPrevious24hByType(builder, x), + CurrentWeatherData.addPrecipitationIntensity(builder, A), + CurrentWeatherData.addPressure(builder, I), + CurrentWeatherData.addPressureTrend(builder, T), + CurrentWeatherData.addSnowfallAmount1h(builder, v), + CurrentWeatherData.addSnowfallAmount6h(builder, S), + CurrentWeatherData.addSnowfallAmount24h(builder, O), + CurrentWeatherData.addSnowfallAmountNext1h(builder, F), + CurrentWeatherData.addSnowfallAmountNext6h(builder, w), + CurrentWeatherData.addSnowfallAmountNext24h(builder, P), + CurrentWeatherData.addTemperature(builder, E), + CurrentWeatherData.addTemperatureApparent(builder, M), + CurrentWeatherData.addUnknown34(builder, R), + CurrentWeatherData.addTemperatureDewPoint(builder, U), + CurrentWeatherData.addUvIndex(builder, W), + CurrentWeatherData.addVisibility(builder, L), + CurrentWeatherData.addWindDirection(builder, D), + CurrentWeatherData.addWindGust(builder, B), + CurrentWeatherData.addWindSpeed(builder, $), + CurrentWeatherData.endCurrentWeatherData(builder) + ); + } +} + +// DayPartForecast +export class DayPartForecast { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsDayPartForecast(bb, obj) { + return (obj || new DayPartForecast()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsDayPartForecast(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new DayPartForecast()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + cloudCover() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverLowAltPct() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverMidAltPct() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverHighAltPct() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + humidity() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + humidityMax() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + humidityMin() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationAmount() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 26); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + snowfallAmount() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMax() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMin() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMax() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMin() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windDirection() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + windGustSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeed() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationIntensityMax() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensityMax() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + uvIndexMin() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + uvIndexMax() { + const t = this.bb.__offset(this.bb_pos, 56); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + temperatureApparentMin() { + const t = this.bb.__offset(this.bb_pos, 58); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureApparentMax() { + const t = this.bb.__offset(this.bb_pos, 60); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + daylight() { + const t = this.bb.__offset(this.bb_pos, 62); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + static startDayPartForecast(builder) { + builder.startObject(30); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addCloudCover(builder, value) { + builder.addFieldInt8(2, value, 0); + } + static addCloudCoverLowAltPct(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addCloudCoverMidAltPct(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addCloudCoverHighAltPct(builder, value) { + builder.addFieldInt8(5, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(6, value, WeatherCondition.CLEAR); + } + static addHumidity(builder, value) { + builder.addFieldInt8(7, value, 0); + } + static addHumidityMax(builder, value) { + builder.addFieldInt8(8, value, 0); + } + static addHumidityMin(builder, value) { + builder.addFieldInt8(9, value, 0); + } + static addPrecipitationAmount(builder, value) { + builder.addFieldFloat32(10, value, 0); + } + static addPrecipitationAmountByType(builder, value) { + builder.addFieldOffset(11, value, 0); + } + static createPrecipitationAmountByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(12, value, 0); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(13, value, PrecipitationType.CLEAR); + } + static addSnowfallAmount(builder, value) { + builder.addFieldFloat32(14, value, 0); + } + static addTemperatureMax(builder, value) { + builder.addFieldFloat32(15, value, 0); + } + static addTemperatureMin(builder, value) { + builder.addFieldFloat32(16, value, 0); + } + static addVisibilityMax(builder, value) { + builder.addFieldFloat32(17, value, 0); + } + static addVisibilityMin(builder, value) { + builder.addFieldFloat32(18, value, 0); + } + static addWindDirection(builder, value) { + builder.addFieldInt16(19, value, 0); + } + static addWindGustSpeedMax(builder, value) { + builder.addFieldFloat32(20, value, 0); + } + static addWindSpeed(builder, value) { + builder.addFieldFloat32(21, value, 0); + } + static addWindSpeedMax(builder, value) { + builder.addFieldFloat32(22, value, 0); + } + static addPrecipitationIntensityMax(builder, value) { + builder.addFieldFloat32(23, value, 0); + } + static addPerceivedPrecipitationIntensityMax(builder, value) { + builder.addFieldFloat32(24, value, 0); + } + static addUvIndexMin(builder, value) { + builder.addFieldInt8(25, value, 0); + } + static addUvIndexMax(builder, value) { + builder.addFieldInt8(26, value, 0); + } + static addTemperatureApparentMin(builder, value) { + builder.addFieldFloat32(27, value, 0); + } + static addTemperatureApparentMax(builder, value) { + builder.addFieldFloat32(28, value, 0); + } + static addDaylight(builder, value) { + builder.addFieldInt8(29, +value, 0); + } + static endDayPartForecast(builder) { + return builder.endObject(); + } + static createDayPartForecast(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v, S, O, F, w) { + return ( + DayPartForecast.startDayPartForecast(builder), + DayPartForecast.addForecastStart(builder, e), + DayPartForecast.addForecastEnd(builder, i), + DayPartForecast.addCloudCover(builder, a), + DayPartForecast.addCloudCoverLowAltPct(builder, s), + DayPartForecast.addCloudCoverMidAltPct(builder, n), + DayPartForecast.addCloudCoverHighAltPct(builder, r), + DayPartForecast.addConditionCode(builder, o), + DayPartForecast.addHumidity(builder, d), + DayPartForecast.addHumidityMax(builder, c), + DayPartForecast.addHumidityMin(builder, l), + DayPartForecast.addPrecipitationAmount(builder, u), + DayPartForecast.addPrecipitationAmountByType(builder, p), + DayPartForecast.addPrecipitationChance(builder, b), + DayPartForecast.addPrecipitationType(builder, h), + DayPartForecast.addSnowfallAmount(builder, m), + DayPartForecast.addTemperatureMax(builder, _), + DayPartForecast.addTemperatureMin(builder, y), + DayPartForecast.addVisibilityMax(builder, f), + DayPartForecast.addVisibilityMin(builder, g), + DayPartForecast.addWindDirection(builder, N), + DayPartForecast.addWindGustSpeedMax(builder, C), + DayPartForecast.addWindSpeed(builder, x), + DayPartForecast.addWindSpeedMax(builder, A), + DayPartForecast.addPrecipitationIntensityMax(builder, I), + DayPartForecast.addPerceivedPrecipitationIntensityMax(builder, T), + DayPartForecast.addUvIndexMin(builder, v), + DayPartForecast.addUvIndexMax(builder, S), + DayPartForecast.addTemperatureApparentMin(builder, O), + DayPartForecast.addTemperatureApparentMax(builder, F), + DayPartForecast.addDaylight(builder, w), + DayPartForecast.endDayPartForecast(builder) + ); + } +} + +// DayWeatherConditions +export class DayWeatherConditions { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsDayWeatherConditions(bb, obj) { + return (obj || new DayWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsDayWeatherConditions(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new DayWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + humidityMax() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + humidityMin() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + maxUvIndex() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + moonPhase() { + const t = this.bb.__offset(this.bb_pos, 16); + return t ? this.bb.readInt8(this.bb_pos + t) : MoonPhase.NEW; + } + moonrise() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + moonset() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + precipitationAmount() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmountByType(index, optional) { + const i = this.bb.__offset(this.bb_pos, 24); + return i ? (optional || new PrecipitationAmountByType()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + precipitationAmountByTypeLength() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + snowfallAmount() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + solarMidnight() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + solarNoon() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunrise() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunriseCivil() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunriseNautical() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunriseAstronomical() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunset() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunsetCivil() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunsetNautical() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + sunsetAstronomical() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + temperatureMax() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMaxTime() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + temperatureMin() { + const t = this.bb.__offset(this.bb_pos, 56); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureMinTime() { + const t = this.bb.__offset(this.bb_pos, 58); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + windGustSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 60); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeedAvg() { + const t = this.bb.__offset(this.bb_pos, 62); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeedMax() { + const t = this.bb.__offset(this.bb_pos, 64); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMax() { + const t = this.bb.__offset(this.bb_pos, 66); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + visibilityMin() { + const t = this.bb.__offset(this.bb_pos, 68); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + overnightForecast(optional) { + const e = this.bb.__offset(this.bb_pos, 70); + return e ? (optional || new DayPartForecast()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + daytimeForecast(optional) { + const e = this.bb.__offset(this.bb_pos, 72); + return e ? (optional || new DayPartForecast()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + restOfDayForecast(optional) { + const e = this.bb.__offset(this.bb_pos, 74); + return e ? (optional || new DayPartForecast()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + static startDayWeatherConditions(builder) { + builder.startObject(36); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(2, value, WeatherCondition.CLEAR); + } + static addHumidityMax(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addHumidityMin(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addMaxUvIndex(builder, value) { + builder.addFieldInt8(5, value, 0); + } + static addMoonPhase(builder, value) { + builder.addFieldInt8(6, value, MoonPhase.NEW); + } + static addMoonrise(builder, value) { + builder.addFieldInt32(7, value, 0); + } + static addMoonset(builder, value) { + builder.addFieldInt32(8, value, 0); + } + static addPrecipitationAmount(builder, value) { + builder.addFieldFloat32(9, value, 0); + } + static addPrecipitationAmountByType(builder, value) { + builder.addFieldOffset(10, value, 0); + } + static createPrecipitationAmountByTypeVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPrecipitationAmountByTypeVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(11, value, 0); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(12, value, PrecipitationType.CLEAR); + } + static addSnowfallAmount(builder, value) { + builder.addFieldFloat32(13, value, 0); + } + static addSolarMidnight(builder, value) { + builder.addFieldInt32(14, value, 0); + } + static addSolarNoon(builder, value) { + builder.addFieldInt32(15, value, 0); + } + static addSunrise(builder, value) { + builder.addFieldInt32(16, value, 0); + } + static addSunriseCivil(builder, value) { + builder.addFieldInt32(17, value, 0); + } + static addSunriseNautical(builder, value) { + builder.addFieldInt32(18, value, 0); + } + static addSunriseAstronomical(builder, value) { + builder.addFieldInt32(19, value, 0); + } + static addSunset(builder, value) { + builder.addFieldInt32(20, value, 0); + } + static addSunsetCivil(builder, value) { + builder.addFieldInt32(21, value, 0); + } + static addSunsetNautical(builder, value) { + builder.addFieldInt32(22, value, 0); + } + static addSunsetAstronomical(builder, value) { + builder.addFieldInt32(23, value, 0); + } + static addTemperatureMax(builder, value) { + builder.addFieldFloat32(24, value, 0); + } + static addTemperatureMaxTime(builder, value) { + builder.addFieldInt32(25, value, 0); + } + static addTemperatureMin(builder, value) { + builder.addFieldFloat32(26, value, 0); + } + static addTemperatureMinTime(builder, value) { + builder.addFieldInt32(27, value, 0); + } + static addWindGustSpeedMax(builder, value) { + builder.addFieldFloat32(28, value, 0); + } + static addWindSpeedAvg(builder, value) { + builder.addFieldFloat32(29, value, 0); + } + static addWindSpeedMax(builder, value) { + builder.addFieldFloat32(30, value, 0); + } + static addVisibilityMax(builder, value) { + builder.addFieldFloat32(31, value, 0); + } + static addVisibilityMin(builder, value) { + builder.addFieldFloat32(32, value, 0); + } + static addOvernightForecast(builder, value) { + builder.addFieldOffset(33, value, 0); + } + static addDaytimeForecast(builder, value) { + builder.addFieldOffset(34, value, 0); + } + static addRestOfDayForecast(builder, value) { + builder.addFieldOffset(35, value, 0); + } + static endDayWeatherConditions(builder) { + return builder.endObject(); + } +} + +// DailyForecastData +export class DailyForecastData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsDailyForecastData(bb, obj) { + return (obj || new DailyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsDailyForecastData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new DailyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + days(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new DayWeatherConditions()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + daysLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startDailyForecastData(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addDays(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createDaysVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startDaysVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endDailyForecastData(builder) { + return builder.endObject(); + } + static createDailyForecastData(builder, e, i) { + return DailyForecastData.startDailyForecastData(builder), DailyForecastData.addMetadata(builder, e), DailyForecastData.addDays(builder, i), DailyForecastData.endDailyForecastData(builder); + } +} + +// ForecastMinute +export class ForecastMinute { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsForecastMinute(bb, obj) { + return (obj || new ForecastMinute()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsForecastMinute(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new ForecastMinute()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + startTime() { + const builder = this.bb.__offset(this.bb_pos, 4); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startForecastMinute(builder) { + builder.startObject(4); + } + static addStartTime(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(1, value, 0); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(2, value, 0); + } + static addPerceivedPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(3, value, 0); + } + static endForecastMinute(builder) { + return builder.endObject(); + } + static createForecastMinute(builder, e, i, a, s) { + return ForecastMinute.startForecastMinute(builder), ForecastMinute.addStartTime(builder, e), ForecastMinute.addPrecipitationChance(builder, i), ForecastMinute.addPrecipitationIntensity(builder, a), ForecastMinute.addPerceivedPrecipitationIntensity(builder, s), ForecastMinute.endForecastMinute(builder); + } +} + +// ForecastPeriodSummary +export class ForecastPeriodSummary { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsForecastPeriodSummary(bb, obj) { + return (obj || new ForecastPeriodSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsForecastPeriodSummary(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new ForecastPeriodSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + startTime() { + const builder = this.bb.__offset(this.bb_pos, 4); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + endTime() { + const builder = this.bb.__offset(this.bb_pos, 6); + return builder ? this.bb.readUint32(this.bb_pos + builder) : 0; + } + condition() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startForecastPeriodSummary(builder) { + builder.startObject(5); + } + static addStartTime(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addEndTime(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addCondition(builder, value) { + builder.addFieldInt8(2, value, PrecipitationType.CLEAR); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(4, value, 0); + } + static endForecastPeriodSummary(builder) { + return builder.endObject(); + } + static createForecastPeriodSummary(builder, e, i, a, s, n) { + return ( + ForecastPeriodSummary.startForecastPeriodSummary(builder), + ForecastPeriodSummary.addStartTime(builder, e), + ForecastPeriodSummary.addEndTime(builder, i), + ForecastPeriodSummary.addCondition(builder, a), + ForecastPeriodSummary.addPrecipitationChance(builder, s), + ForecastPeriodSummary.addPrecipitationIntensity(builder, n), + ForecastPeriodSummary.endForecastPeriodSummary(builder) + ); + } +} + +// HistoricalComparison +export class HistoricalComparison { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsHistoricalComparison(bb, obj) { + return (obj || new HistoricalComparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsHistoricalComparison(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new HistoricalComparison()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + comparisons(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Comparison()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + comparisonsLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startHistoricalComparison(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addComparisons(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createComparisonsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startComparisonsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endHistoricalComparison(builder) { + return builder.endObject(); + } + static createHistoricalComparison(builder, e, i) { + return HistoricalComparison.startHistoricalComparison(builder), HistoricalComparison.addMetadata(builder, e), HistoricalComparison.addComparisons(builder, i), HistoricalComparison.endHistoricalComparison(builder); + } +} + +// HourWeatherConditions +export class HourWeatherConditions { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsHourWeatherConditions(bb, obj) { + return (obj || new HourWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsHourWeatherConditions(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new HourWeatherConditions()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + cloudCover() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverLowAltPct() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverMidAltPct() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + cloudCoverHighAltPct() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + conditionCode() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.readInt8(this.bb_pos + t) : WeatherCondition.CLEAR; + } + daylight() { + const t = this.bb.__offset(this.bb_pos, 16); + return !!t && !!this.bb.readInt8(this.bb_pos + t); + } + humidity() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + perceivedPrecipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationAmount() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationIntensity() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + precipitationChance() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + precipitationType() { + const t = this.bb.__offset(this.bb_pos, 28); + return t ? this.bb.readInt8(this.bb_pos + t) : PrecipitationType.CLEAR; + } + pressure() { + const t = this.bb.__offset(this.bb_pos, 30); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + pressureTrend() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readInt8(this.bb_pos + t) : PressureTrend.RISING; + } + snowfallAmount() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + snowfallIntensity() { + const t = this.bb.__offset(this.bb_pos, 36); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperature() { + const t = this.bb.__offset(this.bb_pos, 38); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureApparent() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + unknown20() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + temperatureDewPoint() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + uvIndex() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + visibility() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windDirection() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readInt16(this.bb_pos + t) : 0; + } + windGust() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + windSpeed() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readFloat32(this.bb_pos + t) : 0; + } + static startHourWeatherConditions(builder) { + builder.startObject(26); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(0, value, 0); + } + static addCloudCover(builder, value) { + builder.addFieldInt8(1, value, 0); + } + static addCloudCoverLowAltPct(builder, value) { + builder.addFieldInt8(2, value, 0); + } + static addCloudCoverMidAltPct(builder, value) { + builder.addFieldInt8(3, value, 0); + } + static addCloudCoverHighAltPct(builder, value) { + builder.addFieldInt8(4, value, 0); + } + static addConditionCode(builder, value) { + builder.addFieldInt8(5, value, WeatherCondition.CLEAR); + } + static addDaylight(builder, value) { + builder.addFieldInt8(6, +value, 0); + } + static addHumidity(builder, value) { + builder.addFieldInt8(7, value, 0); + } + static addPerceivedPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(8, value, 0); + } + static addPrecipitationAmount(builder, value) { + builder.addFieldFloat32(9, value, 0); + } + static addPrecipitationIntensity(builder, value) { + builder.addFieldFloat32(10, value, 0); + } + static addPrecipitationChance(builder, value) { + builder.addFieldInt8(11, value, 0); + } + static addPrecipitationType(builder, value) { + builder.addFieldInt8(12, value, PrecipitationType.CLEAR); + } + static addPressure(builder, value) { + builder.addFieldFloat32(13, value, 0); + } + static addPressureTrend(builder, value) { + builder.addFieldInt8(14, value, PressureTrend.RISING); + } + static addSnowfallAmount(builder, value) { + builder.addFieldFloat32(15, value, 0); + } + static addSnowfallIntensity(builder, value) { + builder.addFieldFloat32(16, value, 0); + } + static addTemperature(builder, value) { + builder.addFieldFloat32(17, value, 0); + } + static addTemperatureApparent(builder, value) { + builder.addFieldFloat32(18, value, 0); + } + static addUnknown20(builder, value) { + builder.addFieldFloat32(19, value, 0); + } + static addTemperatureDewPoint(builder, value) { + builder.addFieldFloat32(20, value, 0); + } + static addUvIndex(builder, value) { + builder.addFieldInt8(21, value, 0); + } + static addVisibility(builder, value) { + builder.addFieldFloat32(22, value, 0); + } + static addWindDirection(builder, value) { + builder.addFieldInt16(23, value, 0); + } + static addWindGust(builder, value) { + builder.addFieldFloat32(24, value, 0); + } + static addWindSpeed(builder, value) { + builder.addFieldFloat32(25, value, 0); + } + static endHourWeatherConditions(builder) { + return builder.endObject(); + } + static createHourWeatherConditions(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v) { + return ( + HourWeatherConditions.startHourWeatherConditions(builder), + HourWeatherConditions.addForecastStart(builder, e), + HourWeatherConditions.addCloudCover(builder, i), + HourWeatherConditions.addCloudCoverLowAltPct(builder, a), + HourWeatherConditions.addCloudCoverMidAltPct(builder, s), + HourWeatherConditions.addCloudCoverHighAltPct(builder, n), + HourWeatherConditions.addConditionCode(builder, r), + HourWeatherConditions.addDaylight(builder, o), + HourWeatherConditions.addHumidity(builder, d), + HourWeatherConditions.addPerceivedPrecipitationIntensity(builder, c), + HourWeatherConditions.addPrecipitationAmount(builder, l), + HourWeatherConditions.addPrecipitationIntensity(builder, u), + HourWeatherConditions.addPrecipitationChance(builder, p), + HourWeatherConditions.addPrecipitationType(builder, b), + HourWeatherConditions.addPressure(builder, h), + HourWeatherConditions.addPressureTrend(builder, m), + HourWeatherConditions.addSnowfallAmount(builder, _), + HourWeatherConditions.addSnowfallIntensity(builder, y), + HourWeatherConditions.addTemperature(builder, f), + HourWeatherConditions.addTemperatureApparent(builder, g), + HourWeatherConditions.addUnknown20(builder, N), + HourWeatherConditions.addTemperatureDewPoint(builder, C), + HourWeatherConditions.addUvIndex(builder, x), + HourWeatherConditions.addVisibility(builder, A), + HourWeatherConditions.addWindDirection(builder, I), + HourWeatherConditions.addWindGust(builder, T), + HourWeatherConditions.addWindSpeed(builder, v), + HourWeatherConditions.endHourWeatherConditions(builder) + ); + } +} + +// HourlyForecastData +export class HourlyForecastData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsHourlyForecastData(bb, obj) { + return (obj || new HourlyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsHourlyForecastData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new HourlyForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + hours(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new HourWeatherConditions()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + hoursLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startHourlyForecastData(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addHours(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createHoursVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startHoursVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endHourlyForecastData(builder) { + return builder.endObject(); + } + static createHourlyForecastData(builder, e, i) { + return HourlyForecastData.startHourlyForecastData(builder), HourlyForecastData.addMetadata(builder, e), HourlyForecastData.addHours(builder, i), HourlyForecastData.endHourlyForecastData(builder); + } +} + +// LocationInfo +export class LocationInfo { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsLocationInfo(bb, obj) { + return (obj || new LocationInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsLocationInfo(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new LocationInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + preciseName(encoding) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + countryCode(encoding) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + timeZone(encoding) { + const e = this.bb.__offset(this.bb_pos, 10); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + primaryName(encoding) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + static startLocationInfo(builder) { + builder.startObject(5); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addPreciseName(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addCountryCode(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addTimeZone(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static addPrimaryName(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static endLocationInfo(builder) { + return builder.endObject(); + } + static createLocationInfo(builder, e, i, a, s, n) { + return LocationInfo.startLocationInfo(builder), LocationInfo.addMetadata(builder, e), LocationInfo.addPreciseName(builder, i), LocationInfo.addCountryCode(builder, a), LocationInfo.addTimeZone(builder, s), LocationInfo.addPrimaryName(builder, n), LocationInfo.endLocationInfo(builder); + } +} + +// Placement +export class Placement { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsPlacement(bb, obj) { + return (obj || new Placement()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsPlacement(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Placement()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + priority() { + const t = this.bb.__offset(this.bb_pos, 4); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + articles(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Articles()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + articlesLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + placement() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readInt8(this.bb_pos + t) : PlacementType.UNKNOWN0; + } + static startPlacement(builder) { + builder.startObject(3); + } + static addPriority(builder, value) { + builder.addFieldInt8(0, value, 0); + } + static addArticles(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createArticlesVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startArticlesVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addPlacement(builder, value) { + builder.addFieldInt8(2, value, PlacementType.UNKNOWN0); + } + static endPlacement(builder) { + return builder.endObject(); + } + static createPlacement(builder, e, i, a) { + return Placement.startPlacement(builder), Placement.addPriority(builder, e), Placement.addArticles(builder, i), Placement.addPlacement(builder, a), Placement.endPlacement(builder); + } +} + +// News +export class News { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsNews(bb, obj) { + return (obj || new News()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsNews(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new News()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + placements(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Placement()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + placementsLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startNews(builder) { + builder.startObject(2); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addPlacements(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createPlacementsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startPlacementsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endNews(builder) { + return builder.endObject(); + } + static createNews(builder, e, i) { + return News.startNews(builder), News.addMetadata(builder, e), News.addPlacements(builder, i), News.endNews(builder); + } +} + +// NextHourForecastData +export class NextHourForecastData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsNextHourForecastData(bb, obj) { + return (obj || new NextHourForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsNextHourForecastData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new NextHourForecastData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + condition(index, optional) { + const i = this.bb.__offset(this.bb_pos, 6); + return i ? (optional || new Condition()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + conditionLength() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + summary(index, optional) { + const i = this.bb.__offset(this.bb_pos, 8); + return i ? (optional || new ForecastPeriodSummary()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + summaryLength() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 12); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + minutes(index, optional) { + const i = this.bb.__offset(this.bb_pos, 14); + return i ? (optional || new ForecastMinute()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + minutesLength() { + const t = this.bb.__offset(this.bb_pos, 14); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startNextHourForecastData(builder) { + builder.startObject(6); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addCondition(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static createConditionVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startConditionVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addSummary(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static createSummaryVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startSummaryVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(3, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(4, value, 0); + } + static addMinutes(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static createMinutesVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startMinutesVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endNextHourForecastData(builder) { + return builder.endObject(); + } + static createNextHourForecastData(builder, e, i, a, s, n, r) { + return ( + NextHourForecastData.startNextHourForecastData(builder), + NextHourForecastData.addMetadata(builder, e), + NextHourForecastData.addCondition(builder, i), + NextHourForecastData.addSummary(builder, a), + NextHourForecastData.addForecastStart(builder, s), + NextHourForecastData.addForecastEnd(builder, n), + NextHourForecastData.addMinutes(builder, r), + NextHourForecastData.endNextHourForecastData(builder) + ); + } +} + +// WeatherAlertSummary +export class WeatherAlertSummary { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeatherAlertSummary(bb, obj) { + return (obj || new WeatherAlertSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeatherAlertSummary(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new WeatherAlertSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + id(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new UUID()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + areaId(encoding) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + areaName(encoding) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + attributionUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 10); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + countryCode(encoding) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + description(encoding) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + token(encoding) { + const e = this.bb.__offset(this.bb_pos, 16); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + effectiveTime() { + const t = this.bb.__offset(this.bb_pos, 18); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + expireTime() { + const t = this.bb.__offset(this.bb_pos, 20); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + issuedTime() { + const t = this.bb.__offset(this.bb_pos, 22); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + eventOnsetTime() { + const t = this.bb.__offset(this.bb_pos, 24); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + eventEndTime() { + const t = this.bb.__offset(this.bb_pos, 26); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + detailsUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 28); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + phenomenon(encoding) { + const e = this.bb.__offset(this.bb_pos, 30); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + severity() { + const t = this.bb.__offset(this.bb_pos, 32); + return t ? this.bb.readInt8(this.bb_pos + t) : Severity.UNKNOWN; + } + significance() { + const t = this.bb.__offset(this.bb_pos, 34); + return t ? this.bb.readInt8(this.bb_pos + t) : SignificanceType.UNKNOWN; + } + source(encoding) { + const e = this.bb.__offset(this.bb_pos, 36); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + eventSource(encoding) { + const e = this.bb.__offset(this.bb_pos, 38); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + urgency() { + const t = this.bb.__offset(this.bb_pos, 40); + return t ? this.bb.readInt8(this.bb_pos + t) : Urgency.UNKNOWN; + } + certainty() { + const t = this.bb.__offset(this.bb_pos, 42); + return t ? this.bb.readInt8(this.bb_pos + t) : Certainty.UNKNOWN; + } + importance() { + const t = this.bb.__offset(this.bb_pos, 44); + return t ? this.bb.readInt8(this.bb_pos + t) : ImportanceType.NORMAL; + } + responses(encoding) { + const e = this.bb.__offset(this.bb_pos, 46); + return e ? this.bb.readInt8(this.bb.__vector(this.bb_pos + e) + encoding) : 0; + } + responsesLength() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + responsesArray() { + const t = this.bb.__offset(this.bb_pos, 46); + return t ? new Int8Array(this.bb.bytes().buffer, this.bb.bytes().byteOffset + this.bb.__vector(this.bb_pos + t), this.bb.__vector_len(this.bb_pos + t)) : null; + } + unknown23() { + const t = this.bb.__offset(this.bb_pos, 48); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + unknown24() { + const t = this.bb.__offset(this.bb_pos, 50); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + unknown25() { + const t = this.bb.__offset(this.bb_pos, 52); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + unknown26() { + const t = this.bb.__offset(this.bb_pos, 54); + return t ? this.bb.readInt8(this.bb_pos + t) : 0; + } + static startWeatherAlertSummary(builder) { + builder.startObject(26); + } + static addId(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addAreaId(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addAreaName(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addAttributionUrl(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static addCountryCode(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static addDescription(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static addToken(builder, value) { + builder.addFieldOffset(6, value, 0); + } + static addEffectiveTime(builder, value) { + builder.addFieldInt32(7, value, 0); + } + static addExpireTime(builder, value) { + builder.addFieldInt32(8, value, 0); + } + static addIssuedTime(builder, value) { + builder.addFieldInt32(9, value, 0); + } + static addEventOnsetTime(builder, value) { + builder.addFieldInt32(10, value, 0); + } + static addEventEndTime(builder, value) { + builder.addFieldInt32(11, value, 0); + } + static addDetailsUrl(builder, value) { + builder.addFieldOffset(12, value, 0); + } + static addPhenomenon(builder, value) { + builder.addFieldOffset(13, value, 0); + } + static addSeverity(builder, value) { + builder.addFieldInt8(14, value, Severity.UNKNOWN); + } + static addSignificance(builder, value) { + builder.addFieldInt8(15, value, SignificanceType.UNKNOWN); + } + static addSource(builder, value) { + builder.addFieldOffset(16, value, 0); + } + static addEventSource(builder, value) { + builder.addFieldOffset(17, value, 0); + } + static addUrgency(builder, value) { + builder.addFieldInt8(18, value, Urgency.UNKNOWN); + } + static addCertainty(builder, value) { + builder.addFieldInt8(19, value, Certainty.UNKNOWN); + } + static addImportance(builder, value) { + builder.addFieldInt8(20, value, ImportanceType.NORMAL); + } + static addResponses(builder, value) { + builder.addFieldOffset(21, value, 0); + } + static createResponsesVector(builder, data) { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) builder.addInt8(data[i]); + return builder.endVector(); + } + static startResponsesVector(builder, numElems) { + builder.startVector(1, numElems, 1); + } + static addUnknown23(builder, value) { + builder.addFieldInt8(22, value, 0); + } + static addUnknown24(builder, value) { + builder.addFieldInt8(23, value, 0); + } + static addUnknown25(builder, value) { + builder.addFieldInt8(24, value, 0); + } + static addUnknown26(builder, value) { + builder.addFieldInt8(25, value, 0); + } + static endWeatherAlertSummary(builder) { + return builder.endObject(); + } + static createWeatherAlertSummary(builder, e, i, a, s, n, r, o, d, c, l, u, p, b, h, m, _, y, f, g, N, C, x, A, I, T, v) { + return ( + WeatherAlertSummary.startWeatherAlertSummary(builder), + WeatherAlertSummary.addId(builder, e), + WeatherAlertSummary.addAreaId(builder, i), + WeatherAlertSummary.addAreaName(builder, a), + WeatherAlertSummary.addAttributionUrl(builder, s), + WeatherAlertSummary.addCountryCode(builder, n), + WeatherAlertSummary.addDescription(builder, r), + WeatherAlertSummary.addToken(builder, o), + WeatherAlertSummary.addEffectiveTime(builder, d), + WeatherAlertSummary.addExpireTime(builder, c), + WeatherAlertSummary.addIssuedTime(builder, l), + WeatherAlertSummary.addEventOnsetTime(builder, u), + WeatherAlertSummary.addEventEndTime(builder, p), + WeatherAlertSummary.addDetailsUrl(builder, b), + WeatherAlertSummary.addPhenomenon(builder, h), + WeatherAlertSummary.addSeverity(builder, m), + WeatherAlertSummary.addSignificance(builder, _), + WeatherAlertSummary.addSource(builder, y), + WeatherAlertSummary.addEventSource(builder, f), + WeatherAlertSummary.addUrgency(builder, g), + WeatherAlertSummary.addCertainty(builder, N), + WeatherAlertSummary.addImportance(builder, C), + WeatherAlertSummary.addResponses(builder, x), + WeatherAlertSummary.addUnknown23(builder, A), + WeatherAlertSummary.addUnknown24(builder, I), + WeatherAlertSummary.addUnknown25(builder, T), + WeatherAlertSummary.addUnknown26(builder, v), + WeatherAlertSummary.endWeatherAlertSummary(builder) + ); + } +} + +// WeatherAlertCollectionData +export class WeatherAlertCollectionData { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeatherAlertCollectionData(bb, obj) { + return (obj || new WeatherAlertCollectionData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeatherAlertCollectionData(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new WeatherAlertCollectionData()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + detailsUrl(encoding) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? this.bb.__string(this.bb_pos + e, encoding) : null; + } + alerts(index, optional) { + const i = this.bb.__offset(this.bb_pos, 8); + return i ? (optional || new WeatherAlertSummary()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + alertsLength() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startWeatherAlertCollectionData(builder) { + builder.startObject(3); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addDetailsUrl(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addAlerts(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static createAlertsVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startAlertsVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endWeatherAlertCollectionData(builder) { + return builder.endObject(); + } + static createWeatherAlertCollectionData(builder, e, i, a) { + return WeatherAlertCollectionData.startWeatherAlertCollectionData(builder), WeatherAlertCollectionData.addMetadata(builder, e), WeatherAlertCollectionData.addDetailsUrl(builder, i), WeatherAlertCollectionData.addAlerts(builder, a), WeatherAlertCollectionData.endWeatherAlertCollectionData(builder); + } +} + +// WeatherChanges +export class WeatherChanges { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeatherChanges(bb, obj) { + return (obj || new WeatherChanges()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeatherChanges(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new WeatherChanges()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + metadata(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new Metadata()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastStart() { + const t = this.bb.__offset(this.bb_pos, 6); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + forecastEnd() { + const t = this.bb.__offset(this.bb_pos, 8); + return t ? this.bb.readUint32(this.bb_pos + t) : 0; + } + changes(index, optional) { + const i = this.bb.__offset(this.bb_pos, 10); + return i ? (optional || new Change()).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + i) + 4 * index), this.bb) : null; + } + changesLength() { + const t = this.bb.__offset(this.bb_pos, 10); + return t ? this.bb.__vector_len(this.bb_pos + t) : 0; + } + static startWeatherChanges(builder) { + builder.startObject(4); + } + static addMetadata(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addForecastStart(builder, value) { + builder.addFieldInt32(1, value, 0); + } + static addForecastEnd(builder, value) { + builder.addFieldInt32(2, value, 0); + } + static addChanges(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static createChangesVector(builder, data) { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + static startChangesVector(builder, numElems) { + builder.startVector(4, numElems, 4); + } + static endWeatherChanges(builder) { + return builder.endObject(); + } + static createWeatherChanges(builder, e, i, a, s) { + return WeatherChanges.startWeatherChanges(builder), WeatherChanges.addMetadata(builder, e), WeatherChanges.addForecastStart(builder, i), WeatherChanges.addForecastEnd(builder, a), WeatherChanges.addChanges(builder, s), WeatherChanges.endWeatherChanges(builder); + } +} + +// Weather +export class Weather { + bb = null; + bb_pos = 0; + __init(pos, bb) { + return (this.bb_pos = pos), (this.bb = bb), this; + } + static getRootAsWeather(bb, obj) { + return (obj || new Weather()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + static getSizePrefixedRootAsWeather(bb, obj) { + return bb.setPosition(bb.position() + 4), (obj || new Weather()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + } + airQuality(optional) { + const e = this.bb.__offset(this.bb_pos, 4); + return e ? (optional || new AirQuality()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + currentWeather(optional) { + const e = this.bb.__offset(this.bb_pos, 6); + return e ? (optional || new CurrentWeatherData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastDaily(optional) { + const e = this.bb.__offset(this.bb_pos, 8); + return e ? (optional || new DailyForecastData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastHourly(optional) { + const e = this.bb.__offset(this.bb_pos, 10); + return e ? (optional || new HourlyForecastData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + forecastNextHour(optional) { + const e = this.bb.__offset(this.bb_pos, 12); + return e ? (optional || new NextHourForecastData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + news(optional) { + const e = this.bb.__offset(this.bb_pos, 14); + return e ? (optional || new News()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + weatherAlerts(optional) { + const e = this.bb.__offset(this.bb_pos, 16); + return e ? (optional || new WeatherAlertCollectionData()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + weatherChanges(optional) { + const e = this.bb.__offset(this.bb_pos, 18); + return e ? (optional || new WeatherChanges()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + historicalComparisons(optional) { + const e = this.bb.__offset(this.bb_pos, 20); + return e ? (optional || new HistoricalComparison()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + locationInfo(optional) { + const e = this.bb.__offset(this.bb_pos, 22); + return e ? (optional || new LocationInfo()).__init(this.bb.__indirect(this.bb_pos + e), this.bb) : null; + } + static startWeather(builder) { + builder.startObject(10); + } + static addAirQuality(builder, value) { + builder.addFieldOffset(0, value, 0); + } + static addCurrentWeather(builder, value) { + builder.addFieldOffset(1, value, 0); + } + static addForecastDaily(builder, value) { + builder.addFieldOffset(2, value, 0); + } + static addForecastHourly(builder, value) { + builder.addFieldOffset(3, value, 0); + } + static addForecastNextHour(builder, value) { + builder.addFieldOffset(4, value, 0); + } + static addNews(builder, value) { + builder.addFieldOffset(5, value, 0); + } + static addWeatherAlerts(builder, value) { + builder.addFieldOffset(6, value, 0); + } + static addWeatherChanges(builder, value) { + builder.addFieldOffset(7, value, 0); + } + static addHistoricalComparisons(builder, value) { + builder.addFieldOffset(8, value, 0); + } + static addLocationInfo(builder, value) { + builder.addFieldOffset(9, value, 0); + } + static endWeather(builder) { + return builder.endObject(); + } + static finishWeatherBuffer(builder, table) { + builder.finish(table); + } + static finishSizePrefixedWeatherBuffer(builder, table) { + builder.finish(table, void 0, !0); + } +} + +// ============================================================ +// Module exports +// ============================================================ + +export default { + StringEncodingType, + ComparisonTrend, + SourceType, + PollutantType, + UnitType, + ComparisonType, + Deviation, + Direction, + ComparisonType2, + Deviation2, + ConditionType, + ForecastToken, + ParameterType, + PrecipitationType, + PressureTrend, + WeatherCondition, + MoonPhase, + PlacementType, + ResponseType, + Severity, + SignificanceType, + Urgency, + ByteBuffer, + Builder, + Metadata, + Pollutant, + AirQuality, + UUID, + Articles, + Change, + Comparison, + Parameter, + Condition, + PrecipitationAmountByType, + CurrentWeatherData, + DayPartForecast, + DayWeatherConditions, + DailyForecastData, + ForecastMinute, + ForecastPeriodSummary, + HistoricalComparison, + HourWeatherConditions, + HourlyForecastData, + LocationInfo, + Placement, + News, + NextHourForecastData, + WeatherAlertSummary, + WeatherAlertCollectionData, + WeatherChanges, + Weather, +}; diff --git a/src/egern/wk3/utils/Console.mjs b/src/egern/wk3/utils/Console.mjs new file mode 100644 index 00000000..7caefd77 --- /dev/null +++ b/src/egern/wk3/utils/Console.mjs @@ -0,0 +1,17 @@ +const LEVELS = { OFF: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4, ALL: 5 }; + +// 日志级别在部署时固定,不支持运行时修改(避免跨请求污染) +// 如需调整,修改此处或在 database.mjs 中同步调整 LogLevel 默认值 +const LEVEL = LEVELS.INFO; + +const fmt = msg => + msg.map(m => (typeof m === "object" && m !== null ? JSON.stringify(m, null, 2) : m)); + +export const Console = { + debug: (...msg) => { if (LEVEL >= LEVELS.DEBUG) console.debug(...fmt(msg)); }, + info: (...msg) => { if (LEVEL >= LEVELS.INFO) console.info(...fmt(msg)); }, + warn: (...msg) => { if (LEVEL >= LEVELS.WARN) console.warn(...fmt(msg)); }, + error: (...msg) => { if (LEVEL >= LEVELS.ERROR) console.error(...fmt(msg)); }, + log: (...msg) => { if (LEVEL > LEVELS.OFF) console.log(...fmt(msg)); }, + exception: (...msg) => { if (LEVEL >= LEVELS.ERROR) console.error(...fmt(msg)); }, +}; diff --git a/src/egern/wk3/utils/context.mjs b/src/egern/wk3/utils/context.mjs new file mode 100644 index 00000000..8cbfe0c9 --- /dev/null +++ b/src/egern/wk3/utils/context.mjs @@ -0,0 +1,3 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export const requestContext = new AsyncLocalStorage(); diff --git a/src/egern/wk3/utils/fetch.mjs b/src/egern/wk3/utils/fetch.mjs new file mode 100644 index 00000000..64a509a3 --- /dev/null +++ b/src/egern/wk3/utils/fetch.mjs @@ -0,0 +1,147 @@ +import { requestContext } from "./context.mjs"; + +export async function fetch(resource, options = {}) { + if (typeof resource === "string") { + resource = { url: resource, ...options }; + } else { + resource = { ...options, ...resource }; + } + if (!resource.method) { + resource.method = "GET"; + if (resource.body || resource.bodyBytes) { + resource.method = "POST"; + } + } + const { url, headers, method, body, bodyBytes, timeout } = resource; + const fetchOptions = { + method, + headers: headers ? { ...headers } : {}, + }; + const methodUpper = (method || "GET").toUpperCase(); + if (methodUpper !== "GET" && methodUpper !== "HEAD") { + if (bodyBytes) { + fetchOptions.body = bodyBytes; + } else if (body) { + fetchOptions.body = body; + } + } + // 移除不必要的 header + delete fetchOptions.headers?.Host; + delete fetchOptions.headers?.[":authority"]; + delete fetchOptions.headers?.["Content-Length"]; + delete fetchOptions.headers?.["content-length"]; + + let timeoutId; + if (timeout) { + const controller = new AbortController(); + fetchOptions.signal = controller.signal; + const ms = timeout > 500 ? timeout : timeout * 1000; + timeoutId = setTimeout(() => controller.abort(), ms); + } + + const isCF = typeof globalThis.caches !== "undefined" && typeof globalThis.caches.default !== "undefined"; + const isGet = methodUpper === "GET" || methodUpper === "HEAD"; + const isThirdPartyWeather = isGet && (url.includes("api.caiyunapp.com") || url.includes("qweather.com") || url.includes("qweather")); + + let cacheKey; + let cache; + + const store = requestContext.getStore(); + const settings = store && typeof store === "object" && "Settings" in store ? store.Settings : undefined; + const isEdgeCacheEnabled = settings?.EdgeCache === true; + + if (isCF && isThirdPartyWeather && isEdgeCacheEnabled) { + try { + cache = globalThis.caches.default; + cacheKey = new Request(url, { method: "GET" }); + const cachedResponse = await cache.match(cacheKey); + if (cachedResponse) { + const responseHeaders = {}; + cachedResponse.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const cachedContentType = cachedResponse.headers.get("content-type") || ""; + const isBinary = cachedContentType.includes("flatbuffer") || cachedContentType.includes("protobuf") || cachedContentType.includes("octet-stream"); + + let cachedBody = ""; + let cachedBodyBytes = null; + if (isBinary) { + cachedBodyBytes = await cachedResponse.arrayBuffer(); + } else { + cachedBody = await cachedResponse.text(); + } + + if (timeoutId) clearTimeout(timeoutId); + return { + ok: cachedResponse.ok, + status: cachedResponse.status, + statusCode: cachedResponse.status, + statusText: cachedResponse.statusText, + body: cachedBody, + bodyBytes: cachedBodyBytes, + headers: { ...responseHeaders, "x-ir-cache": "HIT" }, + }; + } + } catch (cacheErr) { + console.error("Cache match error:", cacheErr); + } + } + + try { + const response = await globalThis.fetch(url, fetchOptions); + const responseHeaders = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const contentType = response.headers.get("content-type") || ""; + const isBinary = contentType.includes("flatbuffer") || contentType.includes("protobuf") || contentType.includes("octet-stream"); + + let responseBody = ""; + let responseBodyBytes = null; + + if (isBinary) { + responseBodyBytes = await response.arrayBuffer(); + } else { + responseBody = await response.text(); + } + + const result = { + ok: response.ok, + status: response.status, + statusCode: response.status, + statusText: response.statusText, + body: responseBody, + bodyBytes: responseBodyBytes, + headers: { ...responseHeaders, "x-ir-cache": "MISS" }, + }; + + if (cache && cacheKey && response.ok) { + try { + const cacheBody = isBinary ? responseBodyBytes : responseBody; + const cfResponse = new globalThis.Response(cacheBody, { + status: response.status, + statusText: response.statusText, + headers: { + ...responseHeaders, + "Cache-Control": "public, max-age=180", + }, + }); + const cachePromise = cache.put(cacheKey, cfResponse).catch(putErr => console.error("Cache put error:", putErr)); + const ctx = store?.executionCtx; + if (ctx && typeof ctx.waitUntil === "function") { + ctx.waitUntil(cachePromise); + } + } catch (putErr) { + console.error("Cache put error:", putErr); + } + } + + return result; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} diff --git a/src/egern/wk3/utils/index.mjs b/src/egern/wk3/utils/index.mjs new file mode 100644 index 00000000..f6b25259 --- /dev/null +++ b/src/egern/wk3/utils/index.mjs @@ -0,0 +1,4 @@ +export * from "./Console.mjs"; +export * from "./context.mjs"; +export * from "./fetch.mjs"; +export * from "./object.mjs"; diff --git a/src/egern/wk3/utils/object.mjs b/src/egern/wk3/utils/object.mjs new file mode 100644 index 00000000..7d20f943 --- /dev/null +++ b/src/egern/wk3/utils/object.mjs @@ -0,0 +1,50 @@ +// 按点路径(如 "a.b.c" 或 ["a","b","c"])拆分为数组 +function toPath(value) { + if (Array.isArray(value)) return value; + return value.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean); +} + +function isPlainObject(value) { + if (value === null || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} + +// 深度合并多个对象到 target(数组做浅拷贝,避免共享引用) +export function merge(target, ...sources) { + if (target === null || target === undefined) return target; + for (const source of sources) { + if (source === null || source === undefined) continue; + for (const key of Object.keys(source)) { + const src = source[key]; + const dst = target[key]; + if (isPlainObject(src)) { + target[key] = merge(isPlainObject(dst) ? dst : {}, src); + } else if (Array.isArray(src)) { + target[key] = src.length > 0 ? [...src] : (dst !== undefined ? dst : []); + } else if (src !== undefined) { + target[key] = src; + } + } + } + return target; +} + +// 按点路径读取对象属性 +export function get(object, path, defaultValue) { + const keys = toPath(path); + const result = keys.reduce((acc, key) => Object(acc)[key], object); + return result === undefined ? defaultValue : result; +} + +// 按点路径写入对象属性 +export function set(object, path, value) { + const keys = toPath(path); + keys.slice(0, -1).reduce((acc, key, i) => { + if (Object(acc[key]) !== acc[key]) { + acc[key] = /^\d+$/.test(keys[i + 1]) ? [] : {}; + } + return acc[key]; + }, object)[keys[keys.length - 1]] = value; + return object; +} From 737b26d176f4f95517682ba3d795dd5d8854bc39 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:29:41 +0800 Subject: [PATCH 02/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=8A=A0=E5=85=A5=20WK3=20=E6=A0=B9=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E7=A9=BA=E6=B0=94=E8=B4=A8=E9=87=8F=E7=BC=96=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wk3/class/FlatBufferRootProcessor.mjs | 480 ++++++++++++++++++ src/egern/wk3/class/WeatherKit3.mjs | 135 +++++ 2 files changed, 615 insertions(+) create mode 100644 src/egern/wk3/class/FlatBufferRootProcessor.mjs create mode 100644 src/egern/wk3/class/WeatherKit3.mjs diff --git a/src/egern/wk3/class/FlatBufferRootProcessor.mjs b/src/egern/wk3/class/FlatBufferRootProcessor.mjs new file mode 100644 index 00000000..e1196ace --- /dev/null +++ b/src/egern/wk3/class/FlatBufferRootProcessor.mjs @@ -0,0 +1,480 @@ +/** + * Vendored from NSRingo/WeatherKit v3.3.1, packages/flatbuffer-root (Apache-2.0). + * https://github.com/NSRingo/WeatherKit/blob/47fa3f2c1fa4807558f3fb0050286ff4e8a08c5b/packages/flatbuffer-root/src/FlatBufferRootProcessor.mjs + * Local change: use the fork's Console utility instead of @nsnanocat/util. + */ +import { Console } from "../utils/index.mjs"; +import { Builder, ByteBuffer } from "flatbuffers"; + +const ROOT_OFFSET_SIZE = 4; +const VTABLE_HEADER_SIZE = 4; +const VTABLE_ENTRY_SIZE = 2; +const OFFSET_FIELD_SIZE = 4; +const MAX_SCALAR_ALIGNMENT = 8; + +/** + * @typedef {object} SlotBinary + * @property {Uint8Array} bytes 二进制 arena / Binary arena. + * @property {number} tablePosition 产品表相对 arena 起点的位置 / Product table position relative to the arena start. + */ + +/** + * @typedef {object} RootSlotFailure + * @property {number} id 根 slot ID / Root slot ID. + * @property {Error} error 读取失败 / Read failure. + */ + +/** + * @typedef {object} RootSlotDictionary + * @property {number} slotCount 根表声明的 slot 数量 / Slot count declared by the root table. + * @property {number} presentSlotCount 根表中实际存在的 slot 数量 / Number of physically present root slots. + * @property {Map<number, SlotBinary>} slots 有效 slot 字典 / Valid slot dictionary. + * @property {RootSlotFailure[]} failures 可隔离的 slot 读取失败 / Isolated slot read failures. + */ + +/** + * @typedef {object} FlatBufferRootCodec + * @property {new () => {__init(position: number, byteBuffer: ByteBuffer): unknown}} tableClass 产品表生成类 / Generated product table class. + * @property {(table: unknown) => unknown} decode 产品表解码器 / Product table decoder. + * @property {(builder: Builder, json: unknown) => number} encode 产品表编码器 / Product table encoder. + */ + +/** + * @typedef {object} FlatBufferRootProcessorOptions + * @property {string} name 处理器名称和日志前缀 / Processor name and log prefix. + * @property {Function} rootClass FlatBuffers 生成的根表类 / Generated FlatBuffers root table class. + * @property {Readonly<Record<string, FlatBufferRootCodec>>} codecs 按根 accessor 名称注册的 codec / Codecs keyed by root accessor name. + * @property {readonly string[]} configurableRootNames 受业务开关控制的根名称 / Root names controlled by business settings. + */ + +/** + * 使用生成的根表类配置 FlatBuffer 根 slot 编解码流程。 + * Configure FlatBuffer root-slot encoding and decoding with a generated root table class. + */ +export class FlatBufferRootProcessor { + #name; + #fields; + #fieldsById; + #fieldsByName; + #configurableRootNames; + + /** + * 创建并验证根表处理器配置。 + * Create and validate a root table processor configuration. + * @param {FlatBufferRootProcessorOptions} options - 根表模型、codec 和运行期策略 / Root model, codecs, and runtime policy. + */ + constructor(options) { + if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("FlatBufferRootProcessor options must be an object"); + + try { + if (typeof options.name !== "string" || options.name.length === 0) throw new TypeError("FlatBufferRootProcessor name must be a non-empty string"); + if (typeof options.rootClass !== "function" || !options.rootClass.prototype || typeof options.rootClass.prototype.__init !== "function") { + throw new TypeError(`${options.name} rootClass must be a generated FlatBuffers table class`); + } + if (!options.codecs || typeof options.codecs !== "object" || Array.isArray(options.codecs)) { + throw new TypeError(`${options.name} codecs must be an object`); + } + if (!Array.isArray(options.configurableRootNames)) throw new TypeError(`${options.name} configurableRootNames must be an array`); + + const rootNames = Object.getOwnPropertyNames(options.rootClass.prototype).filter(name => !["constructor", "__init"].includes(name)); + for (const rootName of rootNames) { + if (typeof options.rootClass.prototype[rootName] !== "function") throw new TypeError(`${options.name} root accessor ${rootName} must be a function`); + const suffix = `${rootName[0].toUpperCase()}${rootName.slice(1)}`; + if (typeof options.rootClass[`add${suffix}`] !== "function") throw new TypeError(`${options.name} root accessor ${rootName} has no matching add${suffix}`); + } + + const rootNameSet = new Set(rootNames); + const codecs = new Map(); + for (const rootName of Object.keys(options.codecs)) { + if (!rootNameSet.has(rootName)) throw new RangeError(`${options.name} codec ${rootName} is not a root accessor`); + const codec = options.codecs[rootName]; + if (!codec || typeof codec !== "object" || Array.isArray(codec)) throw new TypeError(`${options.name} codec ${rootName} must be an object`); + if (typeof codec.tableClass !== "function" || !codec.tableClass.prototype || typeof codec.tableClass.prototype.__init !== "function") { + throw new TypeError(`${options.name} codec ${rootName} tableClass must be a generated FlatBuffers table class`); + } + if (typeof codec.decode !== "function") throw new TypeError(`${options.name} codec ${rootName} must provide decode`); + if (typeof codec.encode !== "function") throw new TypeError(`${options.name} codec ${rootName} must provide encode`); + codecs.set(rootName, Object.freeze({ decode: codec.decode, encode: codec.encode, tableClass: codec.tableClass })); + } + + const configurableRootNames = new Set(); + for (const rootName of options.configurableRootNames) { + if (typeof rootName !== "string" || rootName.length === 0) throw new TypeError(`${options.name} configurableRootNames must contain non-empty strings`); + if (!rootNameSet.has(rootName)) throw new RangeError(`${options.name} configurable root ${rootName} is not a root accessor`); + if (configurableRootNames.has(rootName)) throw new RangeError(`${options.name} configurable root ${rootName} is duplicated`); + configurableRootNames.add(rootName); + } + + const fields = rootNames.map((name, id) => Object.freeze({ codec: codecs.get(name), id, name })); + this.#name = options.name; + this.#fields = Object.freeze(fields); + this.#fieldsById = new Map(fields.map(field => [field.id, field])); + this.#fieldsByName = new Map(fields.map(field => [field.name, field])); + this.#configurableRootNames = configurableRootNames; + } catch (error) { + Console.error(`${typeof options.name === "string" && options.name ? options.name : "FlatBufferRootProcessor"}.configure`, error); + throw error; + } + } + + /** + * 根据处理器配置过滤受业务开关控制的根名称。 + * Filter root names controlled by the processor's business configuration. + * @param {string[]} requestedRootNames - 原请求中的根名称 / Root names from the original request. + * @param {string[]} enabledRootNames - 当前启用的根名称 / Currently enabled root names. + * @returns {string[]} 保持原顺序的过滤结果 / Filtered names preserving their original order. + */ + filterRootNames(requestedRootNames, enabledRootNames) { + try { + this.#assertStringArray(requestedRootNames, "requestedRootNames"); + this.#assertStringArray(enabledRootNames, "enabledRootNames"); + const enabled = new Set(enabledRootNames); + return requestedRootNames.filter(rootName => !this.#configurableRootNames.has(rootName) || enabled.has(rootName)); + } catch (error) { + Console.error(`${this.#name}.filterRootNames`, error); + throw error; + } + } + + /** + * 按物理 slot 顺序解码选中的根产品。 + * Decode selected root products in physical slot order. + * @param {ByteBuffer} byteBuffer - 原始 FlatBuffer / Source FlatBuffer. + * @param {string[]} [rootNames=[]] - 要解码的根名称 / Root names to decode. + * @returns {Record<string, unknown>} 已成功解码的根对象 / Successfully decoded root objects. + */ + decode(byteBuffer, rootNames = []) { + try { + this.#assertStringArray(rootNames, "rootNames"); + } catch (error) { + Console.error(`${this.#name}.decode`, error); + throw error; + } + + const requested = new Set(rootNames); + const json = {}; + let dictionary; + try { + dictionary = this.#readRootSlots(byteBuffer); + } catch (error) { + Console.error(`${this.#name}.decode`, error); + throw error; + } + const slotReport = this.#logSlotDictionary("decode", dictionary); + + const presentIds = new Set([...dictionary.slots.keys(), ...dictionary.failures.map(failure => failure.id)]); + const unknownRequested = [...requested].filter(name => { + const field = this.#fieldsByName.get(name); + return !field || !field.codec; + }); + const missing = [...requested].filter(name => { + const field = this.#fieldsByName.get(name); + if (!field) return false; + return Boolean(field.codec) && !presentIds.has(field.id); + }); + const failures = dictionary.failures + .filter(({ id }) => { + const field = this.#fieldsById.get(id); + if (!field) return false; + return Boolean(field.codec) && requested.has(field.name); + }) + .map(({ id, error }) => `${this.#slotLabel(id)}(${error.message})`); + let parsed = 0; + + for (const [id, frame] of [...dictionary.slots].sort(([left], [right]) => left - right)) { + const field = this.#fieldsById.get(id); + if (!field || !field.codec || !requested.has(field.name)) continue; + + try { + const table = new field.codec.tableClass().__init(frame.tablePosition, new ByteBuffer(frame.bytes)); + json[field.name] = field.codec.decode(table); + parsed++; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${this.#slotLabel(id)}(${message})`); + } + } + + const unknown = slotReport.unknownLabels; + const message = `${this.#name}.decode.parse:已知 ${slotReport.knownCount}/${dictionary.presentSlotCount},解析 ${parsed}/${dictionary.presentSlotCount},失败 ${failures.length}/${dictionary.presentSlotCount},未知 ${unknown.length}/${dictionary.presentSlotCount};失败=[${failures.join(", ")}];未知=[${unknown.join(", ")}];请求未知=[${unknownRequested.join(", ")}];缺失=[${missing.join(", ")}]`; + if (failures.length || unknown.length || unknownRequested.length) Console.warn(message); + else Console.debug(message); + return json; + } + + /** + * 独立编译 patch slot,并与原始根表合并组装。 + * Compile patch slots independently and merge them into the source root table. + * @param {ByteBuffer | undefined} [byteBuffer=undefined] - 原始 FlatBuffer;省略时创建新根表 / Source FlatBuffer; omit to create a new root table. + * @param {Record<string, unknown>} [patch={}] - 按根名称组织的实际修改 / Actual changes keyed by root name. + * @returns {Uint8Array} 完整 FlatBuffer / Complete FlatBuffer. + */ + encode(byteBuffer = undefined, patch = {}) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) { + const error = new TypeError(`${this.#name}.encode patch must be an object`); + Console.error(`${this.#name}.encode`, error); + throw error; + } + + const patchKeys = Object.keys(patch); + const compiled = new Map(); + const failures = []; + const unknown = []; + let known = 0; + + for (const name of patchKeys) { + const field = this.#fieldsByName.get(name); + if (!field || !field.codec) { + unknown.push(name); + continue; + } + known++; + + try { + const data = patch[name]; + if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError(`${name} must be an object`); + + const builder = new Builder(); + const tableOffset = field.codec.encode(builder, data); + if (!Number.isSafeInteger(tableOffset) || tableOffset <= 0) throw new Error(`${name} codec did not produce a table`); + builder.finish(tableOffset); + + const bytes = builder.asUint8Array(); + const frameBuffer = new ByteBuffer(bytes); + const tablePosition = frameBuffer.position() + frameBuffer.readUint32(frameBuffer.position()); + compiled.set(field.id, { bytes, tablePosition }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${this.#slotLabel(field.id)}(${message})`); + } + } + + const compileMessage = `${this.#name}.encode.compile:已知 ${known}/${patchKeys.length},编译 ${compiled.size}/${patchKeys.length},失败 ${failures.length}/${patchKeys.length},未知 ${unknown.length}/${patchKeys.length};失败=[${failures.join(", ")}];未知=[${unknown.join(", ")}]`; + if (failures.length || unknown.length) Console.warn(compileMessage); + else Console.debug(compileMessage); + + let sourceDictionary; + if (byteBuffer !== undefined) { + try { + sourceDictionary = this.#readRootSlots(byteBuffer); + } catch (error) { + Console.error(`${this.#name}.encode`, error); + throw error; + } + } else { + sourceDictionary = { failures: [], presentSlotCount: 0, slotCount: 0, slots: new Map() }; + } + this.#logSlotDictionary("encode", sourceDictionary); + + if (byteBuffer !== undefined && compiled.size === 0) { + return byteBuffer.bytes().subarray(byteBuffer.position(), byteBuffer.capacity()); + } + + const slots = new Map(sourceDictionary.slots); + for (const [id, frame] of compiled) slots.set(id, frame); + + try { + const rawBody = this.#writeRootSlots( + { + failures: [], + presentSlotCount: slots.size, + slotCount: sourceDictionary.slotCount, + slots, + }, + this.#fields.length, + ); + Console.debug(`${this.#name}.encode.assemble:输出 ${slots.size} 个 slot,替换 ${compiled.size} 个 slot`); + return rawBody; + } catch (error) { + Console.error(`${this.#name}.encode`, error); + throw error; + } + } + + /** + * 校验字符串数组 API 契约。 + * Validate a string-array API contract. + * @param {unknown} value - 待校验值 / Value to validate. + * @param {string} label - 参数名称 / Parameter name. + * @returns {void} + */ + #assertStringArray(value, label) { + if (!Array.isArray(value) || value.some(item => typeof item !== "string")) throw new TypeError(`${this.#name}.${label} must be an array of strings`); + } + + /** + * 顺序扫描根 vtable,并让所有有效 slot 共享原始 arena。 + * Scan the root vtable sequentially while sharing the source arena across valid slots. + * @param {ByteBuffer} source - 原始 FlatBuffer / Source FlatBuffer. + * @returns {RootSlotDictionary} 根 slot 字典 / Root slot dictionary. + */ + #readRootSlots(source) { + if (!source || typeof source.position !== "function" || typeof source.capacity !== "function" || typeof source.bytes !== "function") { + throw new TypeError(`${this.#name} source must be a FlatBuffers ByteBuffer`); + } + + const sourceStart = source.position(); + const sourceEnd = source.capacity(); + this.#ensureRange(sourceStart, ROOT_OFFSET_SIZE, sourceStart, sourceEnd, "root offset"); + + const rootPosition = sourceStart + source.readUint32(sourceStart); + const rootTable = this.#inspectTable(source, rootPosition, sourceStart, sourceEnd, "root table"); + const slotCount = (rootTable.vtableLength - VTABLE_HEADER_SIZE) / VTABLE_ENTRY_SIZE; + const bytes = source.bytes().subarray(sourceStart, sourceEnd); + const slots = new Map(); + const failures = []; + let presentSlotCount = 0; + + for (let id = 0; id < slotCount; id++) { + const fieldOffset = source.readUint16(rootTable.vtablePosition + VTABLE_HEADER_SIZE + id * VTABLE_ENTRY_SIZE); + if (fieldOffset === 0) continue; + presentSlotCount++; + + try { + if (fieldOffset < ROOT_OFFSET_SIZE || fieldOffset + OFFSET_FIELD_SIZE > rootTable.objectLength) { + throw new Error(`root slot ${id} has an invalid field offset: ${fieldOffset}`); + } + + const fieldPosition = rootPosition + fieldOffset; + this.#ensureRange(fieldPosition, OFFSET_FIELD_SIZE, sourceStart, sourceEnd, `root slot ${id}`); + const relativeOffset = source.readUint32(fieldPosition); + if (relativeOffset === 0) throw new Error(`root slot ${id} has a null table offset`); + + const tablePosition = fieldPosition + relativeOffset; + this.#inspectTable(source, tablePosition, sourceStart, sourceEnd, `root slot ${id} table`); + slots.set(id, { bytes, tablePosition: tablePosition - sourceStart }); + } catch (error) { + failures.push({ id, error: error instanceof Error ? error : new Error(String(error)) }); + } + } + + return { failures, presentSlotCount, slotCount, slots }; + } + + /** + * 将唯一 arena 嵌入一次,并重新组装根表。 + * Embed each unique arena once and reassemble the root table. + * @param {RootSlotDictionary} dictionary - 待组装字典 / Dictionary to assemble. + * @param {number} schemaSlotCount - 当前模型的根 slot 数 / Root slot count of the current model. + * @returns {Uint8Array} 完整 FlatBuffer / Complete FlatBuffer. + */ + #writeRootSlots(dictionary, schemaSlotCount) { + if (!dictionary || !(dictionary.slots instanceof Map)) throw new TypeError("FlatBuffer root slot dictionary must contain a slots Map"); + if (!Number.isSafeInteger(dictionary.slotCount) || dictionary.slotCount < 0) throw new RangeError(`Invalid FlatBuffer root slot count: ${dictionary.slotCount}`); + if (!Number.isSafeInteger(schemaSlotCount) || schemaSlotCount < 0) throw new RangeError(`Invalid FlatBuffer schema slot count: ${schemaSlotCount}`); + + const builder = new Builder(); + const arenaOffsets = new Map(); + const tableOffsets = new Map(); + let slotCount = Math.max(dictionary.slotCount, schemaSlotCount); + + for (const [id, frame] of [...dictionary.slots].sort(([left], [right]) => left - right)) { + if (!Number.isSafeInteger(id) || id < 0) throw new RangeError(`Invalid FlatBuffer root slot: ${id}`); + if (!frame || !(frame.bytes instanceof Uint8Array)) throw new TypeError(`FlatBuffer root slot ${id} must contain Uint8Array bytes`); + if (!Number.isSafeInteger(frame.tablePosition)) throw new RangeError(`Invalid FlatBuffer root slot ${id} table position: ${frame.tablePosition}`); + + const frameBuffer = new ByteBuffer(frame.bytes); + this.#inspectTable(frameBuffer, frame.tablePosition, 0, frame.bytes.length, `root slot ${id} table`); + + let arenaStartOffset = arenaOffsets.get(frame.bytes); + if (arenaStartOffset === undefined) { + builder.startVector(1, frame.bytes.length, MAX_SCALAR_ALIGNMENT); + for (let index = frame.bytes.length - 1; index >= 0; index--) builder.writeInt8(frame.bytes[index]); + arenaStartOffset = builder.endVector() - OFFSET_FIELD_SIZE; + arenaOffsets.set(frame.bytes, arenaStartOffset); + } + + tableOffsets.set(id, arenaStartOffset - frame.tablePosition); + slotCount = Math.max(slotCount, id + 1); + } + + builder.startObject(slotCount); + for (const [id, offset] of [...tableOffsets].sort(([left], [right]) => left - right)) builder.addFieldOffset(id, offset, 0); + const root = builder.endObject(); + builder.finish(root); + return builder.asUint8Array(); + } + + /** + * 校验 table 与 vtable 边界。 + * Validate table and vtable boundaries. + * @param {ByteBuffer} source - FlatBuffer / FlatBuffer. + * @param {number} tablePosition - table 位置 / Table position. + * @param {number} sourceStart - arena 起点 / Arena start. + * @param {number} sourceEnd - arena 终点 / Arena end. + * @param {string} label - 错误标签 / Error label. + * @returns {{objectLength: number, vtableLength: number, vtablePosition: number}} table 元数据 / Table metadata. + */ + #inspectTable(source, tablePosition, sourceStart, sourceEnd, label) { + this.#ensureRange(tablePosition, ROOT_OFFSET_SIZE, sourceStart, sourceEnd, label); + const vtablePosition = tablePosition - source.readInt32(tablePosition); + this.#ensureRange(vtablePosition, VTABLE_HEADER_SIZE, sourceStart, sourceEnd, `${label} vtable`); + + const vtableLength = source.readUint16(vtablePosition); + const objectLength = source.readUint16(vtablePosition + VTABLE_ENTRY_SIZE); + if (vtableLength < VTABLE_HEADER_SIZE || vtableLength % VTABLE_ENTRY_SIZE !== 0) { + throw new Error(`${label} has an invalid vtable length: ${vtableLength}`); + } + if (objectLength < ROOT_OFFSET_SIZE) throw new Error(`${label} has an invalid object length: ${objectLength}`); + + this.#ensureRange(vtablePosition, vtableLength, sourceStart, sourceEnd, `${label} vtable`); + this.#ensureRange(tablePosition, objectLength, sourceStart, sourceEnd, label); + return { objectLength, vtableLength, vtablePosition }; + } + + /** + * 校验二进制读取范围。 + * Validate a binary read range. + * @param {number} position - 起点 / Start position. + * @param {number} length - 长度 / Length. + * @param {number} start - arena 起点 / Arena start. + * @param {number} end - arena 终点 / Arena end. + * @param {string} label - 错误标签 / Error label. + * @returns {void} + */ + #ensureRange(position, length, start, end, label) { + if (!Number.isSafeInteger(position) || !Number.isSafeInteger(length) || position < start || length < 0 || position + length > end) { + throw new Error(`${label} is outside the FlatBuffer`); + } + } + + /** + * 汇总 slot 字典读取结果。 + * Summarize a root-slot dictionary read. + * @param {"encode" | "decode"} operation - 操作名称 / Operation name. + * @param {RootSlotDictionary} dictionary - 根 slot 字典 / Root slot dictionary. + * @returns {{knownCount: number, unknownLabels: string[]}} 后续解析统计 / Statistics for the parse phase. + */ + #logSlotDictionary(operation, dictionary) { + const ids = [...dictionary.slots.keys(), ...dictionary.failures.map(failure => failure.id)].sort((left, right) => left - right); + const knownCount = ids.filter(id => { + const field = this.#fieldsById.get(id); + if (!field) return false; + return Boolean(field.codec); + }).length; + const unknownLabels = ids + .filter(id => { + const field = this.#fieldsById.get(id); + return !field || !field.codec; + }) + .map(id => this.#slotLabel(id)); + const failureLabels = dictionary.failures.map(({ id, error }) => `${this.#slotLabel(id)}(${error.message})`); + const message = `${this.#name}.${operation}.slots:已知 ${knownCount}/${dictionary.presentSlotCount},读取 ${dictionary.slots.size}/${dictionary.presentSlotCount},失败 ${failureLabels.length}/${dictionary.presentSlotCount},未知 ${unknownLabels.length}/${dictionary.presentSlotCount};失败=[${failureLabels.join(", ")}];未知=[${unknownLabels.join(", ")}]`; + if (failureLabels.length || unknownLabels.length) Console.warn(message); + else Console.debug(message); + return { knownCount, unknownLabels }; + } + + /** + * 将 slot ID 格式化为模型名称或物理编号。 + * Format a slot ID as a model name or physical identifier. + * @param {number} id - 根 slot ID / Root slot ID. + * @returns {string} 日志标签 / Log label. + */ + #slotLabel(id) { + const field = this.#fieldsById.get(id); + return field ? `${field.name}#${id}` : `slot#${id}`; + } +} + +export default FlatBufferRootProcessor; diff --git a/src/egern/wk3/class/WeatherKit3.mjs b/src/egern/wk3/class/WeatherKit3.mjs new file mode 100644 index 00000000..ef9eb70a --- /dev/null +++ b/src/egern/wk3/class/WeatherKit3.mjs @@ -0,0 +1,135 @@ +import { ByteBuffer } from "flatbuffers"; +import { AirQuality, Metadata, Pollutant } from "../proto/apple/wk2.js"; + +/** WK3.Weather, lib-rc-3.0.0-b66: observed root positions, not WK2 order. + * 4: metadata + series[1] + periods[120], each period spans 7200 seconds. + * 5: six-slot nextHour; the sole available sample has 83 timestamp-only minutes. + * 14: article placements (titles/language/IDs), not minute precipitation. + * No whole-root or nextHour encoder is provided here. + */ +export const WK3_ROOT_SLOTS = { airQuality: 0, forecastPeriodic: 4, forecastNextHour: 5, news: 14 }; + +// Only the AQ/metadata/pollutant physical layouts were verified against WK2. +// Reuse those generated accessors/builders WITHOUT its symbolic enum mappings. +// This does not imply that current/daily/hourly or the WK2 root is compatible. +export class WK3AirQualityTable extends AirQuality {} + +/** Raw wire enum integer. In particular "UNKNOWN13" must not become WK2's 13. */ +function rawEnum(value = 0) { + if (!Number.isInteger(value) || value < 0 || value > 255) throw new TypeError("WK3 enum must be a raw integer from 0 to 255; symbolic WK2 names are unsupported"); + return value; +} +function unsigned(value = 0, max = 4294967295) { + if (!Number.isInteger(value) || value < 0 || value > max) throw new RangeError("WK3 unsigned integer out of range"); + return value; +} +function finite(value = 0) { + if (typeof value !== "number" || !Number.isFinite(Math.fround(value))) throw new TypeError("WK3 finite float32 required"); + return value; +} +function decodeMetadata(table) { + if (!table) return null; + return { + attributionUrl: table.attributionUrl(), expireTime: table.expireTime(), language: table.language(), + latitude: table.latitude(), longitude: table.longitude(), providerLogo: table.providerLogo(), + providerName: table.providerName(), readTime: table.readTime(), reportedTime: table.reportedTime(), + temporarilyUnavailable: table.temporarilyUnavailable(), sourceType: table.sourceType() & 255, + }; +} +function encodeMetadata(builder, data) { + if (!data) return 0; + return Metadata.createMetadata( + builder, builder.createString(data.attributionUrl), unsigned(data.expireTime), builder.createString(data.language), + finite(data.latitude), finite(data.longitude), builder.createString(data.providerLogo), builder.createString(data.providerName), + unsigned(data.readTime), unsigned(data.reportedTime), data.temporarilyUnavailable ?? false, rawEnum(data.sourceType), + ); +} +/** Decode the eight-slot AQ table, preserving enums as numbers. No pollutant + * names or unit meanings are inferred. Use on the verified b66 AQ layout only. */ +export function decodeAirQuality(table) { + return { + metadata: decodeMetadata(table.metadata()), + categoryIndex: table.categoryIndex() & 255, + index: table.index() & 65535, + isSignificant: table.isSignificant(), + pollutants: Array.from({ length: table.pollutantsLength() }, (_, i) => { + const p = table.pollutants(i); + return { pollutantType: p.pollutantType() & 255, amount: p.amount(), units: p.units() & 255 }; + }), + previousDayComparison: table.previousDayComparison() & 255, + primaryPollutant: table.primaryPollutant() & 255, + scale: table.scale(), + }; +} +/** Build an AQ child only, returning its builder offset (do not finish a + * WK2 Weather root). Pass through decoded primaryPollutant/pollutants when + * editing index/categoryIndex/scale/comparison. No WK3 pollutant mapping has + * been verified, so provider/WK2 symbolic enum strings are rejected. + */ +export function encodeAirQuality(builder, data) { + const metadataOffset = encodeMetadata(builder, data.metadata); + const pollutants = data.pollutants == null ? 0 : AirQuality.createPollutantsVector(builder, + data.pollutants.map(p => Pollutant.createPollutant(builder, rawEnum(p.pollutantType), finite(p.amount), rawEnum(p.units)))); + const scale = builder.createString(data.scale); + return AirQuality.createAirQuality(builder, metadataOffset, rawEnum(data.categoryIndex), unsigned(data.index, 65535), + data.isSignificant ?? false, pollutants, rawEnum(data.previousDayComparison), rawEnum(data.primaryPollutant), scale); +} +// Verified by paired Apple v3 JSON / WK3 b66 responses, not WK2 enum arithmetic. +// Only observed names are mapped; unsupported provider names fail closed. +export const WK3_AQ_POLLUTANTS = { NOT_AVAILABLE: 1, NO: 5, NO2: 6, NOX: 7, OZONE: 8, PM2_5: 9, SO2: 10, PM10: 11, CO: 12 }; +export const WK3_AQ_UNITS = { PARTS_PER_BILLION: 1, MICROGRAMS_PER_CUBIC_METER: 2 }; +export const WK3_AQ_SOURCES = { MODELED: 2, STATION: 3 }; + +/** Build a self-consistent US AQ child from one provider. Do not carry China's + * primary pollutant / yesterday comparison into a new scale. UNKNOWN=0 was + * verified against Apple JSON; yesterday injection remains unsupported. + */ +export function providerToWK3USAirQuality(data) { + const map = (dictionary, name) => { + if (!Object.hasOwn(dictionary, name)) throw new TypeError(`Unsupported WK3 AQ enum: ${name}`); + return dictionary[name]; + }; + if (!["EPA_NowCast.2414", "EPA_NowCast.2604"].includes(data?.scale)) throw new TypeError("Unsupported EPA_NowCast scale version"); + if (!Number.isInteger(data.index)) throw new TypeError("Missing integer AQI"); + const index = unsigned(data.index, 500); + const category = index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; + if (data.categoryIndex !== category || data.isSignificant !== (category >= 3)) throw new TypeError("Inconsistent EPA AQI category/significance"); + if (!data.metadata || data.metadata.temporarilyUnavailable || !data.metadata.providerName) throw new TypeError("Missing AQ provider metadata"); + if (!Array.isArray(data.pollutants) || !data.pollutants.length) throw new TypeError("Missing provider pollutants"); + const seen = new Set(); + const pollutants = data.pollutants.map(p => { + const pollutantType = map(WK3_AQ_POLLUTANTS, p.pollutantType); + if (pollutantType === 1 || seen.has(pollutantType)) throw new TypeError("Invalid or duplicate pollutant"); + seen.add(pollutantType); + if (typeof p.amount !== "number") throw new TypeError("Missing pollutant concentration"); + const amount = finite(p.amount); + if (amount < 0) throw new RangeError("Negative pollutant concentration"); + return { pollutantType, amount, units: map(WK3_AQ_UNITS, p.units) }; + }); + const primaryPollutant = map(WK3_AQ_POLLUTANTS, data.primaryPollutant); + if (primaryPollutant !== 1 && !seen.has(primaryPollutant)) throw new TypeError("Primary pollutant missing from provider data"); + return { + metadata: { ...data.metadata, sourceType: map(WK3_AQ_SOURCES, data.metadata.sourceType) }, + index, categoryIndex: category, isSignificant: category >= 3, pollutants, + // Upstream providers still emit .2414 (Apple now returns 404). Both use + // the same six AQI bands; .2604 is the observed live display scale, not + // a claim that the provider implements Apple's concentration algorithm. + primaryPollutant, previousDayComparison: 0, scale: "EPA_NowCast.2604", + }; +} + +export const airQualityCodec = { tableClass: WK3AirQualityTable, decode: decodeAirQuality, encode: encodeAirQuality }; +export const WK3_CODECS = { airQuality: airQualityCodec }; + +/** Convenience reader for a complete WK3 root. Missing AQ returns null. + * Valid FlatBuffers are expected, like the generated table accessors. + * The processor can instead initialize WK3AirQualityTable directly from a + * preserved child arena using __init(position, ByteBuffer). + */ +export function decodeWK3DataSet(bytes, name = "airQuality") { + if (name !== "airQuality") throw new RangeError(`Unsupported WK3 data set ${name}`); + const bb = bytes instanceof ByteBuffer ? bytes : new ByteBuffer(bytes); + const start = bb.position(), root = start + bb.readUint32(start); + const offset = bb.__offset(root, 4 + 2 * WK3_ROOT_SLOTS.airQuality); + return offset ? decodeAirQuality(new WK3AirQualityTable().__init(bb.__indirect(root + offset), bb)) : null; +} From ad0091e77ec00ad4c3caf3fa541d149e1c523c2a Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:29:41 +0800 Subject: [PATCH 03/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=8A=A0=E5=85=A5=E6=98=A8=E6=97=A5=E6=AF=94=E8=BE=83?= =?UTF-8?q?=E5=8F=8A=E5=88=86=E9=92=9F=E9=99=8D=E6=B0=B4=E7=BC=96=E8=A7=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/egern/wk3/class/WK3Comparison.mjs | 85 +++++++++++ src/egern/wk3/class/WK3NextHour.mjs | 198 ++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 src/egern/wk3/class/WK3Comparison.mjs create mode 100644 src/egern/wk3/class/WK3NextHour.mjs diff --git a/src/egern/wk3/class/WK3Comparison.mjs b/src/egern/wk3/class/WK3Comparison.mjs new file mode 100644 index 00000000..2ce5705a --- /dev/null +++ b/src/egern/wk3/class/WK3Comparison.mjs @@ -0,0 +1,85 @@ +/** WK3 b66 comparison enums verified with paired Apple v3 JSON/FlatBuffer. + * 2026-11-04: US San Francisco + Chicago BETTER=4; Denver WORSE=2; + * Toronto BETTER=4 independently. AQ index/category/scale/provider/reportedTime + * matched in each pair. Earlier paired samples verified UNKNOWN=0, SAME=3. + * These are NOT WK2 ComparisonTrend values. Private responses are not fixtures. + */ +export const WK3_COMPARISON = { UNKNOWN: 0, WORSE: 2, SAME: 3, BETTER: 4 }; + +function standard(scale) { + // Both observed versions have the same six category boundaries. This is + // category compatibility, NOT equivalence of concentration/AQI algorithms. + if (/^EPA_NowCast\.(2414|2604)$/.test(scale)) return "US"; + if (/^HJ6332012\.(2414|2604)$/.test(scale)) return "CN"; + return null; +} +function category(index) { + return index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; +} +function validAQ(data) { + if (!data?.metadata || data.metadata.temporarilyUnavailable || !standard(data.scale)) return false; + if (!Number.isInteger(data.categoryIndex) || data.categoryIndex < 1 || data.categoryIndex > 6) return false; + // A category alone is sufficient; if AQI is supplied it must agree. + return data.index === undefined || (Number.isInteger(data.index) && data.index >= 0 && data.index <= 500 && category(data.index) === data.categoryIndex); +} +function matchingSource(currentProvider, yesterdayProvider, scale) { + if (typeof currentProvider !== "string" || !currentProvider.trim() || currentProvider !== yesterdayProvider) return false; + // A bare Calculate does not identify either its source or its algorithm. + // For precomputed data use e.g. Calculate:QWeather:WAQI_InstantCast_US. + if (currentProvider === "Calculate") return false; + const expected = { ColorfulCloudsUS: "US", ColorfulCloudsCN: "CN", QWeather: "CN" }[currentProvider]; + return !expected || expected === standard(scale); +} + +/** Compare categories (not numeric AQIs) only within one explicitly identified + * provider AND standard. Equal categories mean SAME even if AQI numbers differ. + * Unknown/mismatched/unavailable data returns the verified WK3 UNKNOWN value. + * Provider identity must describe the actual index source, not a display label. + * Calculated pairs must already use the same source AND algorithm on both days. + */ +export function computeWK3Comparison(current, yesterday, { currentProvider, yesterdayProvider } = {}) { + if (!validAQ(current) || !validAQ(yesterday) || standard(current.scale) !== standard(yesterday.scale) + || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return WK3_COMPARISON.UNKNOWN; + if (current.categoryIndex < yesterday.categoryIndex) return WK3_COMPARISON.BETTER; + if (current.categoryIndex > yesterday.categoryIndex) return WK3_COMPARISON.WORSE; + return WK3_COMPARISON.SAME; +} + +/** Return a new AQ object with a raw WK3 previousDayComparison, leaving index, + * pollutants, metadata, and input untouched. Never compare today's provider A + * with yesterday's provider B, nor fetch B's current AQ as a substitute for A. + * + * sources is the existing environments object ({ colorfulClouds, qWeather }); + * yesterday optionally supplies already fetched/converted data or its Promise. + * QWeather locationInfo must be supplied by the caller (reuse locationsGrid). + * No logging of exceptions: provider errors may contain private URLs/locations. + * Any failure clears a potentially stale/CN comparison to UNKNOWN, not the AQ. + */ +export async function injectWK3Comparison(current, { + currentProvider, yesterdayProvider = currentProvider, sources = {}, yesterday, locationInfo, +} = {}) { + const result = { ...current, previousDayComparison: WK3_COMPARISON.UNKNOWN }; + try { + // Attach a rejection handler even when mismatched inputs skip fetching. + const prefetched = yesterday === undefined ? undefined : Promise.resolve(yesterday); + prefetched?.catch(() => {}); + if (!validAQ(current) || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return result; + let previous = prefetched; + if (previous === undefined) { + switch (yesterdayProvider) { + case "ColorfulCloudsUS": + case "ColorfulCloudsCN": + previous = sources.colorfulClouds?.YesterdayAirQuality(yesterdayProvider === "ColorfulCloudsUS"); + break; + case "QWeather": + if (locationInfo) previous = sources.qWeather?.YesterdayAirQuality(locationInfo); + break; + // WeatherKit or calculated data requires explicitly supplied yesterday. + } + } + result.previousDayComparison = computeWK3Comparison(current, await previous, { currentProvider, yesterdayProvider }); + } catch { + // Do not affect otherwise usable current AQ if provider acquisition fails. + } + return result; +} diff --git a/src/egern/wk3/class/WK3NextHour.mjs b/src/egern/wk3/class/WK3NextHour.mjs new file mode 100644 index 00000000..df7679ba --- /dev/null +++ b/src/egern/wk3/class/WK3NextHour.mjs @@ -0,0 +1,198 @@ +import { ByteBuffer } from "flatbuffers"; +import { Metadata } from "../proto/apple/wk2.js"; + +/** Apple WK3 b66, paired original JSON/FlatBuffer responses (2026-11-04). + * Root 5; nextHour[0..5] = metadata, condition[], summary[], start, end, minutes[]. + * Unlike WK2, summary.condition is an enum vector. Only singleton vectors are + * verified: u8 vs u16 element width cannot be distinguished from those samples; + * both have identical singleton bytes. Multi-element vectors fail closed. + * Miami / Manchester / Atlanta confirmed nonzero minutes and summaries, tokens + * START/STOP/START_STOP/CONSTANT, FIRST_AT/SECOND_AT parameters. No WK2 enum reuse. + * Raw codec preserves numeric enums; symbolic provider conversion fails closed. + * Unknown fields require arena preservation by the caller, NOT decode/reencode. + */ +export const WK3_NEXT_HOUR_ROOT_SLOT = 5; +export const WK3_NEXT_HOUR_ENUMS = { + condition: { CLEAR: 1, RAIN: 7, DRIZZLE: 8 }, + precipitation: { CLEAR: 1, RAIN: 2 }, + forecastToken: { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, CONSTANT: 6 }, + parameterType: { FIRST_AT: 1, SECOND_AT: 2 }, + sourceType: { MODELED: 2, STATION: 3 }, +}; +const uint = (n = 0, max = 0xffffffff) => { + if (!Number.isInteger(n) || n < 0 || n > max) throw new RangeError("WK3 unsigned integer out of range"); + return n; +}; +const byte = n => uint(n, 255); +const float = (n = 0) => { + if (typeof n !== "number" || !Number.isFinite(Math.fround(n)) || n < 0) throw new TypeError("WK3 nonnegative finite float32 required"); + return n; +}; +const chance = n => uint(n, 100); +function range(bb, p, length) { + if (!Number.isSafeInteger(p) || p < 0 || !Number.isSafeInteger(length) || length < 0 || p + length > bb.capacity()) throw new RangeError("Truncated WK3 nextHour"); +} +class Table { + __init(p, bb) { this.bb_pos = p; this.bb = bb; return this; } + check(slots) { + range(this.bb, this.bb_pos, 4); + const v = this.bb_pos - this.bb.readInt32(this.bb_pos); + range(this.bb, v, 4); + const length = this.bb.readUint16(v), size = this.bb.readUint16(v + 2); + if (length < 4 || length % 2 || (length - 4) / 2 > slots || size < 4) throw new TypeError("Unsupported WK3 nextHour table layout"); + range(this.bb, v, length); range(this.bb, this.bb_pos, size); + for (let i = 4; i < length; i += 2) { + const o = this.bb.readUint16(v + i); + if (o && (o < 4 || o >= size)) throw new RangeError("Invalid WK3 field offset"); + } + return this; + } + field(i, width = 4) { + const o = this.bb.__offset(this.bb_pos, 4 + 2 * i); + if (!o) return 0; + const v = this.bb_pos - this.bb.readInt32(this.bb_pos); + if (o + width > this.bb.readUint16(v + 2)) throw new RangeError("Invalid WK3 scalar width"); + range(this.bb, this.bb_pos + o, width); return this.bb_pos + o; + } + scalar(i, kind = "readUint32") { + const p = this.field(i, kind === "readUint8" ? 1 : 4); + return p ? this.bb[kind](p) : 0; + } + child(i) { + const p = this.field(i); if (!p) return null; + const target = p + this.bb.readUint32(p); range(this.bb, target, 4); + return new Table().__init(target, this.bb); + } + vector(i, bytes = false) { + const f = this.field(i); if (!f) return []; + const p = f + this.bb.readUint32(f); range(this.bb, p, 4); + const n = this.bb.readUint32(p); + if (bytes && n !== 1) throw new TypeError("Unverified WK3 multi-condition summary vector"); + range(this.bb, p + 4, n * (bytes ? 1 : 4)); + return Array.from({ length: n }, (_, k) => { + if (bytes) return this.bb.readUint8(p + 4 + k); + const f = p + 4 + k * 4, target = f + this.bb.readUint32(f); range(this.bb, target, 4); + return new Table().__init(target, this.bb); + }); + } +} +export class WK3NextHourTable extends Table {} +function metadataDecode(t) { + if (!t) return null; + t.check(11); + const m = new Metadata().__init(t.bb_pos, t.bb); + return { attributionUrl: m.attributionUrl(), expireTime: m.expireTime(), language: m.language(), latitude: m.latitude(), longitude: m.longitude(), providerLogo: m.providerLogo(), providerName: m.providerName(), readTime: m.readTime(), reportedTime: m.reportedTime(), temporarilyUnavailable: m.temporarilyUnavailable(), sourceType: m.sourceType() & 255 }; +} +function parameterDecode(t) { t.check(2); return { type: t.scalar(0, "readUint8"), date: t.scalar(1) }; } +function conditionDecode(t) { + t.check(6); + return { startTime: t.scalar(0), endTime: t.scalar(1), forecastToken: t.scalar(2, "readUint8"), beginCondition: t.scalar(3, "readUint8"), endCondition: t.scalar(4, "readUint8"), parameters: t.vector(5).map(parameterDecode) }; +} +function summaryDecode(t) { + t.check(5); + return { startTime: t.scalar(0), endTime: t.scalar(1), condition: t.vector(2, true), precipitationChance: t.scalar(3, "readUint8"), precipitationIntensity: t.scalar(4, "readFloat32") }; +} +function minuteDecode(t) { + t.check(4); + return { startTime: t.scalar(0), precipitationChance: t.scalar(1, "readUint8"), precipitationIntensity: t.scalar(2, "readFloat32"), perceivedPrecipitationIntensity: t.scalar(3, "readFloat32") }; +} +export function decodeNextHour(table) { + const t = new WK3NextHourTable().__init(table.bb_pos, table.bb).check(6); + return { metadata: metadataDecode(t.child(0)), condition: t.vector(1).map(conditionDecode), summary: t.vector(2).map(summaryDecode), forecastStart: t.scalar(3), forecastEnd: t.scalar(4), minutes: t.vector(5).map(minuteDecode) }; +} +export function decodeWK3NextHour(bytes) { + const bb = bytes instanceof ByteBuffer ? bytes : new ByteBuffer(bytes), start = bb.position(); + range(bb, start, 4); + const root = new Table().__init(start + bb.readUint32(start), bb); + // Root may have arbitrary future slots; only inspect root 5. + root.check(32765); + const t = root.child(WK3_NEXT_HOUR_ROOT_SLOT); + return t ? decodeNextHour(t) : null; +} +function vector(b, entries, bytes = false) { + if (bytes && entries.length !== 1) throw new TypeError("Unverified WK3 multi-condition summary vector"); + b.startVector(bytes ? 1 : 4, entries.length, bytes ? 1 : 4); + for (let i = entries.length - 1; i >= 0; i--) bytes ? b.addInt8(byte(entries[i])) : b.addOffset(entries[i]); + return b.endVector(); +} +function string(b, value) { + if (value == null) return 0; + if (typeof value !== "string") throw new TypeError("WK3 metadata string required"); + return b.createString(value); +} +function metadataEncode(b, m) { + if (!m) return 0; + const signedFloat = n => { if (typeof n !== "number" || !Number.isFinite(Math.fround(n))) throw new TypeError("WK3 metadata finite float required"); return n; }; + return Metadata.createMetadata(b, string(b, m.attributionUrl), uint(m.expireTime), string(b, m.language), signedFloat(m.latitude ?? 0), signedFloat(m.longitude ?? 0), string(b, m.providerLogo), string(b, m.providerName), uint(m.readTime), uint(m.reportedTime), m.temporarilyUnavailable ?? false, byte(m.sourceType)); +} +function parameterEncode(b, p) { + const type = byte(p.type), date = uint(p.date); + b.startObject(2); b.addFieldInt8(0, type, 0); b.addFieldInt32(1, date, 0); return b.endObject(); +} +function conditionEncode(b, c) { + const start = uint(c.startTime), end = uint(c.endTime), token = byte(c.forecastToken), begin = byte(c.beginCondition), finish = byte(c.endCondition); + const parameters = vector(b, (c.parameters ?? []).map(p => parameterEncode(b, p))); + b.startObject(6); b.addFieldInt32(0, start, 0); b.addFieldInt32(1, end, 0); b.addFieldInt8(2, token, 0); b.addFieldInt8(3, begin, 0); b.addFieldInt8(4, finish, 0); b.addFieldOffset(5, parameters, 0); return b.endObject(); +} +function summaryEncode(b, s) { + if (!Array.isArray(s.condition)) throw new TypeError("WK3 summary.condition must be an enum array, not a WK2 scalar"); + const start = uint(s.startTime), end = uint(s.endTime), probability = chance(s.precipitationChance), intensity = float(s.precipitationIntensity), conditions = vector(b, s.condition, true); + b.startObject(5); b.addFieldInt32(0, start, 0); b.addFieldInt32(1, end, 0); b.addFieldOffset(2, conditions, 0); b.addFieldInt8(3, probability, 0); b.addFieldFloat32(4, intensity, 0); return b.endObject(); +} +function minuteEncode(b, m) { + const start = uint(m.startTime), probability = chance(m.precipitationChance), intensity = float(m.precipitationIntensity), perceived = float(m.perceivedPrecipitationIntensity); + b.startObject(4); b.addFieldInt32(0, start, 0); b.addFieldInt8(1, probability, 0); b.addFieldFloat32(2, intensity, 0); b.addFieldFloat32(3, perceived, 0); return b.endObject(); +} +/** Return a CHILD offset. Caller finishes/appends/merges root 5 atomically. */ +export function encodeNextHour(b, data) { + const start = uint(data.forecastStart), end = uint(data.forecastEnd), metadata = metadataEncode(b, data.metadata); + const conditions = vector(b, (data.condition ?? []).map(c => conditionEncode(b, c))); + const summaries = vector(b, (data.summary ?? []).map(s => summaryEncode(b, s))); + const minutes = vector(b, (data.minutes ?? []).map(m => minuteEncode(b, m))); + b.startObject(6); b.addFieldOffset(0, metadata, 0); b.addFieldOffset(1, conditions, 0); b.addFieldOffset(2, summaries, 0); b.addFieldInt32(3, start, 0); b.addFieldInt32(4, end, 0); b.addFieldOffset(5, minutes, 0); return b.endObject(); +} +export const nextHourCodec = { tableClass: WK3NextHourTable, decode: decodeNextHour, encode: encodeNextHour }; + +function mapEnum(group, name) { + const dictionary = WK3_NEXT_HOUR_ENUMS[group]; + if (typeof name !== "string" || !Object.hasOwn(dictionary, name)) throw new TypeError(`Unsupported WK3 nextHour ${group}: ${name}`); + return dictionary[name]; +} +function requiredNumber(n, validate) { + if (typeof n !== "number") throw new TypeError("Missing provider nextHour number"); + return validate(n); +} +/** Convert ColorfulClouds.Minutely()/QWeather.Minutely() normalized output. + * Their precipitationIntensity is mm/h and precipitationChance is percent. + * Caller MUST explicitly attest units; no conversion of raw five-minute totals, + * probability fractions, radar dBZ, or guessing conditions from text here. + * Existing provider perceived intensity/condition/summary logic is not rederived. + * Unsupported symbolic enums, missing data, or inconsistent timelines throw; + * caller catches and keeps the ORIGINAL child arena (including unknown fields). + */ +export function providerToWK3NextHour(data, { intensityUnit, chanceUnit } = {}) { + if (intensityUnit !== "mm/h" || chanceUnit !== "percent") throw new TypeError("Explicit mm/h and percent units required"); + if (!data?.metadata?.providerName || data.metadata.temporarilyUnavailable) throw new TypeError("Missing available nextHour provider metadata"); + if (!Array.isArray(data.minutes) || !data.minutes.length || !Array.isArray(data.condition) || !data.condition.length || !Array.isArray(data.summary) || !data.summary.length) throw new TypeError("Missing nextHour minutes/condition/summary"); + const forecastStart = requiredNumber(data.forecastStart, uint), forecastEnd = requiredNumber(data.forecastEnd, uint); + const minutes = data.minutes.map((m, i) => { + const startTime = requiredNumber(m.startTime, uint); + if (startTime !== forecastStart + i * 60) throw new TypeError("Noncontiguous nextHour minutes"); + return { startTime, precipitationChance: requiredNumber(m.precipitationChance, chance), precipitationIntensity: requiredNumber(m.precipitationIntensity, float), perceivedPrecipitationIntensity: requiredNumber(m.perceivedPrecipitationIntensity, float) }; + }); + if (forecastEnd !== forecastStart + minutes.length * 60) throw new TypeError("Inconsistent nextHour forecastEnd"); + const segment = s => ({ startTime: requiredNumber(s.startTime, uint), endTime: s.endTime == null ? 0 : requiredNumber(s.endTime, uint) }); + const summary = data.summary.map(s => ({ ...segment(s), condition: (Array.isArray(s.condition) ? s.condition : [s.condition]).map(c => mapEnum("precipitation", c)), precipitationChance: requiredNumber(s.precipitationChance, chance), precipitationIntensity: requiredNumber(s.precipitationIntensity, float) })); + if (summary.some(s => s.condition.length !== 1)) throw new TypeError("Unverified WK3 multi-condition summary vector"); + const condition = data.condition.map(c => { + if (!Array.isArray(c.parameters)) throw new TypeError("Missing condition parameters"); + return { ...segment(c), forecastToken: mapEnum("forecastToken", c.forecastToken), beginCondition: mapEnum("condition", c.beginCondition), endCondition: mapEnum("condition", c.endCondition), parameters: c.parameters.map(p => ({ type: mapEnum("parameterType", p.type), date: requiredNumber(p.date, uint) })) }; + }); + for (const segments of [summary, condition]) { + for (let i = 0; i < segments.length; i++) { + const s = segments[i], next = segments[i + 1]; + if (s.startTime !== (i === 0 ? forecastStart : segments[i - 1].endTime) || (next ? s.endTime !== next.startTime || s.endTime <= s.startTime || s.endTime >= forecastEnd : s.endTime !== 0)) throw new TypeError("Inconsistent nextHour segment timeline"); + } + } + return { metadata: { ...data.metadata, sourceType: mapEnum("sourceType", data.metadata.sourceType) }, forecastStart, forecastEnd, minutes, summary, condition }; +} From 5124813b499fe0e11beb2913d7cd47de67832131 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:29:57 +0800 Subject: [PATCH 04/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E8=A1=A5=E5=85=A8=E5=A4=A9=E6=B0=94=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E7=95=99=E5=8E=9F=E7=94=9F=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/egern/wk3/class/NativeAlertDetails.mjs | 112 ++++++++++ src/egern/wk3/class/WK3Alerts.mjs | 239 +++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 src/egern/wk3/class/NativeAlertDetails.mjs create mode 100644 src/egern/wk3/class/WK3Alerts.mjs diff --git a/src/egern/wk3/class/NativeAlertDetails.mjs b/src/egern/wk3/class/NativeAlertDetails.mjs new file mode 100644 index 00000000..5c9a4a46 --- /dev/null +++ b/src/egern/wk3/class/NativeAlertDetails.mjs @@ -0,0 +1,112 @@ +import WeatherAlerts from "./WeatherAlerts.mjs"; + +const PREFIX = "wk3_"; +const MAX_IDS = 16384, MAX_JSON_BYTES = 12000, MAX_ALERTS = 32; +const REGION = /^[a-zA-Z0-9][a-zA-Z0-9_-]*-\d{9}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Recognizes the reserved namespace, including malformed payloads, so callers + * can reject them locally instead of forwarding them to Apple. */ +export function isNativeAlertDetailsID(ids) { + return typeof ids === "string" && ids.startsWith(PREFIX); +} + +function languageTag(value) { + if (typeof value !== "string" || value.length > 64 || !/^[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*$/.test(value)) throw new TypeError("Invalid alert language"); + return value; +} +function exactKeys(value, keys) { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) throw new TypeError("Invalid native alert payload shape"); +} +function validatePayload(payload) { + exactKeys(payload, ["region", "alerts"]); + if (typeof payload.region !== "string" || payload.region.length > 128 || !REGION.test(payload.region)) throw new TypeError("Invalid QWeather region"); + if (!Array.isArray(payload.alerts) || !payload.alerts.length || payload.alerts.length > MAX_ALERTS) throw new RangeError("Invalid native alert count"); + const ids = new Set(); + for (const alert of payload.alerts) { + exactKeys(alert, ["id", "description", "issuedTime", "severity"]); + if (typeof alert.id !== "string" || !UUID.test(alert.id) || ids.has(alert.id)) throw new TypeError("Invalid or duplicate Apple UUID"); + ids.add(alert.id); + if (typeof alert.description !== "string" || !alert.description.trim() || alert.description.length > 512) throw new TypeError("Invalid alert description"); + if (!Number.isInteger(alert.issuedTime) || alert.issuedTime <= 0 || alert.issuedTime > 0xffffffff) throw new TypeError("Invalid alert issuedTime"); + if (!Number.isInteger(alert.severity) || alert.severity < 0 || alert.severity > 255) throw new TypeError("Invalid WK3 severity"); + } + return payload; +} +function uuidFromBytes(bytes) { + if (!(Array.isArray(bytes) || bytes instanceof Uint8Array) || bytes.length !== 16 || !Array.from(bytes).every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255)) throw new TypeError("Expected 16 Apple UUID bytes"); + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} +function base64url(bytes) { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Build once from the original/enriched WK3 summaries; assign this SAME URL to + * the collection and every summary. The payload contains no location/auth data + * beyond the public region identifier and the minimum event matching fields. */ +export function buildNativeAlertDetailsURL({ identifier, language = "zh-CN", alerts, timezone }) { + languageTag(language); + if (timezone !== undefined && (typeof timezone !== "string" || !/^[A-Za-z0-9_+/-]{1,128}$/.test(timezone))) throw new TypeError("Invalid alert timezone"); + if (!Array.isArray(alerts) || !alerts.length || alerts.length > MAX_ALERTS) throw new RangeError("Invalid native alert count"); + const payload = validatePayload({ region: identifier, alerts: alerts.map(alert => ({ + id: uuidFromBytes(alert?.id?.bytes), + description: alert.description, + issuedTime: alert.issuedTime, + severity: alert.severity, + })) }); + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + if (bytes.length > MAX_JSON_BYTES) throw new RangeError("Native alert payload too long"); + const ids = PREFIX + base64url(bytes); + if (ids.length > MAX_IDS) throw new RangeError("Native alert ids too long"); + const url = new URL("https://weatherkit.apple.com/alertDetails/index.html"); + url.searchParams.set("lang", language); + url.searchParams.set("ids", ids); + url.searchParams.set("party", "qweather"); + if (timezone !== undefined) url.searchParams.set("timezone", timezone); + return url.toString(); +} +function parseIDs(ids) { + if (!isNativeAlertDetailsID(ids) || ids.length > MAX_IDS) throw new TypeError("Invalid native alert ids"); + const encoded = ids.slice(PREFIX.length); + if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) throw new TypeError("Invalid native alert base64url"); + const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - encoded.length % 4) % 4)); + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); + if (bytes.length > MAX_JSON_BYTES || base64url(bytes) !== encoded) throw new TypeError("Invalid native alert encoding"); + return validatePayload(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); +} +function titleParts(value) { + if (typeof value !== "string") return null; + const text = value.normalize("NFKC").trim().toLowerCase().replace(/\s+/g, "").replace(/[。.]+$/, ""); + const match = text.match(/^(.+?)(?:(蓝|黄|橙|红)色)?预警(?:信号)?$/); + return match ? { text: match[1], color: match[2] } : { text, color: undefined }; +} +function sameTitle(original, source) { + const a = titleParts(original), b = titleParts(source); + return a && b && a.text === b.text && (!a.color || !b.color || a.color === b.color); +} + +/** Returns only the requested original events, in their original order. Source + * order never establishes identity. Missing/ambiguous/unverified matches throw + * atomically; the route should return an empty/error result, never fake details. + * fetchImpl receives only WeatherAlerts' public HTML request (no Apple headers). + * This is a bounded, public locator, not a signed token or authorization grant. */ +export async function resolveNativeAlertDetails(ids, language = "zh-CN", fetchImpl = globalThis.fetch) { + const payload = parseIDs(ids); + languageTag(language); + // Paired Apple JSON / WK3: Shanghai MINOR=4; Benxi/Shenyang MODERATE=3. + // Other integers still have no verified mapping and are not guessed. + const severities = { 3: "moderate", 4: "minor" }; + if (payload.alerts.some(alert => !Object.hasOwn(severities, alert.severity))) throw new TypeError("Unverified WK3 severity"); + const sources = await WeatherAlerts.DetailsForQWeather(payload.region, language, fetchImpl); + if (!Array.isArray(sources)) throw new TypeError("Invalid QWeather details"); + const matches = payload.alerts.map(alert => { + const candidates = sources.filter(source => source && source.severity === severities[alert.severity] + && sameTitle(alert.description, source.description) + && typeof source.issuedTime === "string" && Date.parse(source.issuedTime) === alert.issuedTime * 1000); + if (candidates.length !== 1) throw new Error("Missing or ambiguous native alert match"); + return candidates[0]; + }); + if (new Set(matches).size !== matches.length) throw new Error("Ambiguous shared native alert match"); + return matches.map((source, index) => ({ ...source, id: payload.alerts[index].id, detailsUrl: `#${payload.alerts[index].id}` })); +} diff --git a/src/egern/wk3/class/WK3Alerts.mjs b/src/egern/wk3/class/WK3Alerts.mjs new file mode 100644 index 00000000..4ceda1cc --- /dev/null +++ b/src/egern/wk3/class/WK3Alerts.mjs @@ -0,0 +1,239 @@ +import { Builder, ByteBuffer } from "flatbuffers"; +import WeatherAlerts from "./WeatherAlerts.mjs"; +import { buildNativeAlertDetailsURL } from "./NativeAlertDetails.mjs"; + +/** Verified against paired Apple v3 JSON / WK3 b66 responses (2026-11-04): + * Shanghai wind alert and Miami Coastal Flood Statement. NOT a WK2 codec. + * Root slot 9; collection: metadata=0, detailsUrl=1, alerts=2. + * Area-name slot 2 and slots >=22 of summaries remain UNVERIFIED. + * Only observed enum tokens are translated. Unknown raw integers round-trip. + */ +export const WK3_ALERTS_ROOT_SLOT = 9; +export const WK3_ALERT_ENUMS = { + severity: { UNKNOWN: 0, MODERATE: 3, MINOR: 4 }, + significance: { UNKNOWN: 0, STATEMENT: 10 }, + urgency: { UNKNOWN: 0, EXPECTED: 2 }, + certainty: { UNKNOWN: 0, LIKELY: 2 }, + importance: { LOW: 2 }, + responses: { MONITOR: 7 }, +}; +const TEXT = { attributionUrl: 3, countryCode: 4, description: 5, token: 6, detailsUrl: 12, phenomenon: 13, source: 16, eventSource: 17 }; +const TIMES = { effectiveTime: 7, expireTime: 8, issuedTime: 9, eventOnsetTime: 10, eventEndTime: 11 }; +const ENUMS = { severity: 14, significance: 15, urgency: 18, certainty: 19, importance: 20 }; +const SOURCE = Symbol("WK3Alerts.originalArena"); +const uint = (v, max = 0xffffffff) => { + if (!Number.isInteger(v) || v < 0 || v > max) throw new RangeError("Expected unsigned wire integer"); + return v; +}; +function input(bytes) { + if (bytes instanceof ByteBuffer) return bytes.bytes().subarray(bytes.position()); + if (!(bytes instanceof Uint8Array)) throw new TypeError("Expected Uint8Array or ByteBuffer"); + return bytes; +} +function reader(bytes) { + const b = input(bytes), v = new DataView(b.buffer, b.byteOffset, b.byteLength); + const check = (p, n) => { if (!Number.isInteger(p) || p < 0 || p + n > b.length) throw new RangeError("Invalid WK3 offset"); }; + const u16 = p => { check(p, 2); return v.getUint16(p, true); }; + const u32 = p => { check(p, 4); return v.getUint32(p, true); }; + const indirect = p => { const q = p + u32(p); check(q, 4); return q; }; + const field = (t, i) => { + check(t, 4); const vt = t - v.getInt32(t, true), n = u16(vt), size = u16(vt + 2); + check(vt, n); check(t, size); + if (n < 4 || n % 2 || size < 4) throw new RangeError("Invalid WK3 table"); + const o = 4 + i * 2 < n ? u16(vt + 4 + i * 2) : 0; + if (o && (o < 4 || o >= size)) throw new RangeError("Invalid WK3 field"); + return o ? t + o : 0; + }; + const string = p => { + if (!p) return null; const q = indirect(p), n = u32(q); check(q + 4, n + 1); + if (b[q + 4 + n]) throw new RangeError("Invalid WK3 string"); + return new TextDecoder("utf-8", { fatal: true }).decode(b.subarray(q + 4, q + 4 + n)); + }; + const vector = (p, width) => { if (!p) return { start: 0, length: 0 }; const q = indirect(p), length = u32(q); check(q + 4, length * width); return { start: q + 4, length }; }; + return { b, v, check, u16, u32, indirect, field, string, vector }; +} +function collection(r) { const root = r.indirect(0), f = r.field(root, WK3_ALERTS_ROOT_SLOT); return f ? r.indirect(f) : 0; } +function summary(r, t) { + const data = {}; + const id = r.field(t, 0); + if (id) { + const f = r.field(r.indirect(id), 0), vector = r.vector(f, 1); + if (vector.length !== 16) throw new RangeError("Expected 16 UUID bytes"); + data.id = { bytes: Array.from(r.b.subarray(vector.start, vector.start + 16)) }; + } else data.id = null; + const area = r.field(t, 1); data.areaId = r.string(area); + for (const [k, i] of Object.entries(TEXT)) data[k] = r.string(r.field(t, i)); + for (const [k, i] of Object.entries(TIMES)) { const p = r.field(t, i); data[k] = p ? r.u32(p) : 0; } + for (const [k, i] of Object.entries(ENUMS)) { const p = r.field(t, i); data[k] = p ? r.b[p] : 0; } + const responses = r.vector(r.field(t, 21), 1); + data.responses = Array.from(r.b.subarray(responses.start, responses.start + responses.length)); + return data; +} +/** Read raw numeric enums, never WK2 symbolic mappings. Opaque source is kept + * for codec encoding. The input must not be mutated after decoding. */ +export function decodeWK3Alerts(bytes) { + const r = reader(bytes), c = collection(r); if (!c) return null; + const vector = r.vector(r.field(c, 2), 4); + const data = { detailsUrl: r.string(r.field(c, 1)), alerts: Array.from({ length: vector.length }, (_, i) => summary(r, r.indirect(vector.start + i * 4))) }; + Object.defineProperty(data, SOURCE, { value: r.b, enumerable: true }); + return data; +} +function hasUnverifiedFields(r, t) { + const vt = t - r.v.getInt32(t, true), count = (r.u16(vt) - 4) / 2; + const idField = r.field(t, 0); + if (idField) { + const id = r.indirect(idField), idVtable = id - r.v.getInt32(id, true), idCount = (r.u16(idVtable) - 4) / 2; + if (Array.from({ length: Math.max(0, idCount - 1) }, (_, i) => r.field(id, i + 1)).some(Boolean)) return true; + } + return Boolean(r.field(t, 2)) || Array.from({ length: Math.max(0, count - 22) }, (_, i) => r.field(t, i + 22)).some(Boolean); +} +function encodeSummary(data) { + const b = new Builder(), strings = {}; + for (const [k, i] of Object.entries({ areaId: 1, ...TEXT })) { + if (data[k] != null) { if (typeof data[k] !== "string") throw new TypeError(`Expected string ${k}`); strings[i] = b.createString(data[k]); } + } + let id = 0; + if (data.id) { + if (!Array.isArray(data.id.bytes) || data.id.bytes.length !== 16) throw new TypeError("Expected UUID byte array"); + b.startVector(1, 16, 1); for (const byte of [...data.id.bytes].reverse()) b.addInt8(uint(byte, 255)); + const vector = b.endVector(); b.startObject(1); b.addFieldOffset(0, vector, 0); id = b.endObject(); + } + if (!Array.isArray(data.responses)) throw new TypeError("Expected raw responses array"); + b.startVector(1, data.responses.length, 1); + for (const response of [...data.responses].reverse()) b.addInt8(uint(response, 255)); + const responses = b.endVector(); + b.startObject(22); b.addFieldOffset(0, id, 0); + for (const [i, offset] of Object.entries(strings)) b.addFieldOffset(Number(i), offset, 0); + for (const [k, i] of Object.entries(TIMES)) b.addFieldInt32(i, uint(data[k]), 0); + for (const [k, i] of Object.entries(ENUMS)) b.addFieldInt8(i, uint(data[k], 255), 0); + b.addFieldOffset(21, responses, 0); b.finish(b.endObject()); return b.asUint8Array(); +} +/** Replace existing summaries only. Never invent IDs, remove/add/reorder alerts, + * rebuild the weather root, touch AQ, or discard unverified summary fields. + * Unchanged data returns the identical input view; failures throw atomically. + * Use in a try/catch and retain rawBody on failure. */ +export function patchWK3Alerts(bytes, alerts, options = {}) { + const r = reader(bytes), c = collection(r); + if (!c) { if (alerts?.length) throw new RangeError("Apple response has no alerts collection"); return r.b; } + const vector = r.vector(r.field(c, 2), 4); + if (!Array.isArray(alerts) || alerts.length !== vector.length) throw new RangeError("Cannot add/remove WK3 alerts"); + const replacements = []; + for (let i = 0; i < alerts.length; i++) { + const p = vector.start + i * 4, t = r.indirect(p), before = summary(r, t), after = alerts[i]; + if (!after || JSON.stringify(after.id) !== JSON.stringify(before.id)) throw new TypeError("Cannot change WK3 alert identity/order"); + const known = new Set(["id", "areaId", ...Object.keys(TEXT), ...Object.keys(TIMES), ...Object.keys(ENUMS), "responses"]); + if (Object.keys(after).some(k => !known.has(k))) throw new TypeError("Unverified WK3 alert field"); + if (JSON.stringify(after) === JSON.stringify(before)) continue; + if (hasUnverifiedFields(r, t)) throw new RangeError("Unverified WK3 summary fields; preserve original"); + replacements.push({ p, bytes: encodeSummary(after) }); + } + let out = r.b; + for (const replacement of replacements) { + const start = Math.ceil(out.length / 8) * 8, next = new Uint8Array(start + replacement.bytes.length); + next.set(out); next.set(replacement.bytes, start); + new DataView(next.buffer).setUint32(replacement.p, start + new DataView(replacement.bytes.buffer, replacement.bytes.byteOffset).getUint32(0, true) - replacement.p, true); + out = next; + } + if (options.detailsUrl !== undefined) { + const p = r.field(c, 1); + if (!p || typeof options.detailsUrl !== "string") throw new TypeError("Expected existing collection URL field"); + if (options.detailsUrl !== r.string(p)) { + const value = new TextEncoder().encode(options.detailsUrl), start = Math.ceil(out.length / 4) * 4; + const next = new Uint8Array(start + value.length + 5); next.set(out); next.set(value, start + 4); + const v = new DataView(next.buffer); v.setUint32(start, value.length, true); v.setUint32(p, start - p, true); out = next; + } + } + return out; +} +/** Conservative provider enrichment: keeps Apple identity, URLs and all + * non-empty fields; unsupported symbolic enums are not guessed or written. + * Caller supplies unambiguously matched source records in Apple array order. */ +export function enrichWK3Alerts(original, matchedSources) { + if (!Array.isArray(matchedSources) || original.alerts.length !== matchedSources.length) throw new RangeError("Expected one matched source per Apple alert"); + return original.alerts.map((alert, i) => { + const source = matchedSources[i]; if (!source) return alert; + const out = { ...alert }; + if (source.description) out.description = source.description; + for (const key of ["source", "areaId", "phenomenon", "token"]) if (!out[key] && source[key]) out[key] = source[key]; + for (const key of Object.keys(TIMES)) { + if (out[key] || source[key] == null) continue; + const time = typeof source[key] === "number" ? source[key] : Date.parse(source[key]) / 1000; + if (Number.isInteger(time) && time > 0 && time <= 0xffffffff) out[key] = time; + } + if (!out.eventEndTime && out.expireTime) out.eventEndTime = out.expireTime; + if (!out.eventOnsetTime && out.effectiveTime) out.eventOnsetTime = out.effectiveTime; + for (const key of Object.keys(ENUMS)) { + if (out[key]) continue; + const token = String(source[key] ?? "").toUpperCase(), value = WK3_ALERT_ENUMS[key][token]; + if (value !== undefined) out[key] = value; + } + return out; + }); +} +/** FlatBufferRootProcessor-compatible codec. Requires a decoded source arena; + * not a from-scratch weather/schema encoder. Root accessor must be slot 9. */ +export class WK3AlertsTable { __init(position, bb) { this.bb_pos = position; this.bb = bb; return this; } } +export const wk3AlertsCodec = { + tableClass: WK3AlertsTable, + decode(table) { + // The processor passes an arena with an arbitrary child table position. + const source = table.bb.bytes(), wrapper = new Uint8Array(40 + source.length); wrapper.set(source, 40); + const v = new DataView(wrapper.buffer); + v.setUint32(0, 28, true); v.setUint16(4, 24, true); v.setUint16(6, 8, true); v.setUint16(26, 4, true); + v.setInt32(28, 24, true); v.setUint32(32, 40 + table.bb_pos - 32, true); + return decodeWK3Alerts(wrapper); + }, + encode(builder, data) { + if (!data?.[SOURCE]) throw new TypeError("WK3 alerts codec requires decoded source arena"); + const bytes = patchWK3Alerts(data[SOURCE], data.alerts, { detailsUrl: data.detailsUrl }), r = reader(bytes), c = collection(r); + builder.prep(8, bytes.length); builder.pad(bytes.length); + builder.dataBuffer().bytes().set(bytes, builder.space); + return builder.offset() - c; + }, +}; + +/** No API credential required. Complements Apple's existing CN alerts only; + * returns the original view on missing collection/page, ambiguity or failure. + * Details link is opt-in: enable ONLY after routing /api/v1/weatherAlerts region + * identifiers to WeatherAlerts.DetailsForQWeather(). Otherwise keep QWeather URL. + */ +export async function patchWK3QWeatherAlerts(bytes, { language = "zh-CN", timezone, fetchImpl = globalThis.fetch, rewriteDetails = false } = {}) { + const raw = input(bytes); + try { + const original = decodeWK3Alerts(raw); + if (!original?.alerts.length) return raw; + const identifier = WeatherAlerts.ParseWeatherAlertPageURL(original.detailsUrl); + if (!identifier) return raw; + const extracted = await WeatherAlerts.FetchWeatherAlertPage(identifier, language, fetchImpl); + if (!extracted.alerts.length) return raw; + const context = { identifier, language, countryCode: "CN", eventSource: "CN", attributionUrl: original.detailsUrl }; + const sources = WeatherAlerts.Build(extracted, context), used = new Set(); + const matched = original.alerts.map(alert => { + // Never map solely by index: require title/phenomenon, same observed + // severity and a compatible issue time; ambiguity leaves it intact. + const candidates = sources.map((source, index) => ({ source, index })).filter(({ source, index }) => { + if (used.has(index) || !alert.description || !source.description.includes(alert.description)) return false; + const severity = WK3_ALERT_ENUMS.severity[String(source.severity).toUpperCase()]; + if (severity === undefined || severity !== alert.severity) return false; + const issued = Date.parse(source.issuedTime) / 1000; + return !alert.issuedTime || issued === alert.issuedTime; + }); + if (candidates.length !== 1) return null; + used.add(candidates[0].index); return candidates[0].source; + }); + if (!matched.some(Boolean)) return raw; + let alerts = enrichWK3Alerts(original, matched); + // Source publication platform is not the issuer. With an exact match, + // prefer the observed page's actual issuing meteorological office. + alerts = alerts.map((alert, i) => matched[i] && /^(国家预警信息发布中心|QWeather|和风天气)$/.test(alert.source ?? "") ? { ...alert, source: matched[i].source } : alert); + const options = {}; + if (rewriteDetails) { + // Do not send unrelated/generated IDs to the native renderer. All + // events must have exact matches before replacing the shared URL. + if (matched.some(source => !source)) return patchWK3Alerts(raw, alerts); + const url = buildNativeAlertDetailsURL({ identifier, language, timezone, alerts }); + alerts = alerts.map(alert => ({ ...alert, detailsUrl: url })); options.detailsUrl = url; + } + return patchWK3Alerts(raw, alerts, options); + } catch { return raw; } +} From 7927282c75030082b1f0a8a25fba29806c7a58a4 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:29:57 +0800 Subject: [PATCH 05/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E5=85=A5=20WK3=20=E7=A9=BA=E6=B0=94=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E3=80=81=E9=99=8D=E6=B0=B4=E4=B8=8E=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/egern/wk3/process/Response.mjs | 1226 ++++++++++++++++++++++++++++ 1 file changed, 1226 insertions(+) create mode 100644 src/egern/wk3/process/Response.mjs diff --git a/src/egern/wk3/process/Response.mjs b/src/egern/wk3/process/Response.mjs new file mode 100644 index 00000000..992cf40d --- /dev/null +++ b/src/egern/wk3/process/Response.mjs @@ -0,0 +1,1226 @@ +import * as flatbuffers from "flatbuffers"; +import AirQuality from "../class/AirQuality.mjs"; +import AirQualityScale from "../class/AirQualityScale.mjs"; +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; +import Weather from "../class/Weather.mjs"; +import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../class/WeatherKit2.mjs"; +import { decodeWK3DataSet, encodeAirQuality as encodeWK3AirQuality, providerToWK3USAirQuality } from "../class/WeatherKit3.mjs"; +import { injectWK3Comparison } from "../class/WK3Comparison.mjs"; +import { patchWK3QWeatherAlerts } from "../class/WK3Alerts.mjs"; +import { decodeWK3NextHour, encodeNextHour as encodeWK3NextHour, providerToWK3NextHour } from "../class/WK3NextHour.mjs"; +import * as WK2 from "../proto/apple/wk2.js"; +import database from "../function/database.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import buildSettings from "../function/buildSettings.mjs"; +import shouldReplaceWeatherKit, { isHongKong } from "../function/shouldReplaceWeatherKit.mjs"; +import { Console } from "../utils/index.mjs"; +/***************** Processing *****************/ +export async function Response($request, $response, context = {}) { + // 解构预取数据(从 Hono.js 并发预取传入) + const { preFetched = {}, enviroments: preEnviroments, parameters: preParameters, Settings: preSettings, Configs: preConfigs } = context; + // 解构URL + const url = new URL($request.url); + Console.debug("url:", url.toJSON()); + // 获取连接参数 + const PATHs = url.pathname.split("/").filter(Boolean); + Console.debug("PATHs:", PATHs); + // 解析格式 + const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]; + Console.debug("FORMAT:", FORMAT); + // 打印 Apple 原始响应日志 + Console.debug("[Apple Response]", url.pathname, `status: ${$response.status}`, `content-type: ${FORMAT}`); + if (url.pathname.startsWith("/api/v1/airQualityScale/") || url.pathname.startsWith("/api/v2/airQualityScale/")) { + try { + const preview = typeof $response.body === "string" ? $response.body : $response.bodyBytes ? new TextDecoder().decode(new Uint8Array($response.bodyBytes)) : "(binary)"; + Console.log(`[Apple Response Body] ${url.pathname}`, preview.length > 2000 ? `${preview.slice(0, 2000)}...` : preview); + } catch (e) { + Console.log(`[Apple Response Body] ${url.pathname}`, "(无法解析)", e.message); + } + } + /** + * 设置 + * @type {{Settings: import('../types').Settings}} + */ + const { Settings, Configs } = preSettings ? { Settings: preSettings, Configs: preConfigs } : buildSettings(database); + const requestHeaders = Object.fromEntries(Object.entries($request.headers || {}).map(([key, value]) => [key.toLowerCase(), Array.isArray(value) ? value.join(",") : `${value}`])); + const userAgent = requestHeaders["user-agent"] || ""; + const schemaVersion = requestHeaders["x-schema-version"] || ""; + const isIOS27WeatherFlatBuffer = /WeatherKit_Weather_iOS_Version 27/i.test(userAgent) || /^lib-rc-2\./i.test(schemaVersion); + + // 创建空数据 + let body = {}; + // airQualityScale 请求:Apple 200 直接透传,404 则本地构建 + if (url.pathname.startsWith("/api/v1/airQualityScale/") || url.pathname.startsWith("/api/v2/airQualityScale/")) { + if ($response.status === 200) { + Console.log(`[proxy] airQualityScale Apple 返回 200,透传: ${url.pathname}`); + return $response; + } + // Apple 返回 404 或其他错误,本地构建 + const pathParts = url.pathname.split("/").filter(Boolean); + const language = pathParts[3] ?? "en"; + const scaleName = pathParts[4] ?? ""; + const localScale = AirQualityScale.buildScale(language, scaleName); + if (localScale) { + Console.log(`[proxy] airQualityScale Apple 返回 ${$response.status},本地构建: ${scaleName}`); + $response.status = 200; + $response.headers = { ...$response.headers, ...localScale.headers }; + $response.body = localScale.body; + return $response; + } + } + // 格式判断 + switch (FORMAT) { + case undefined: // 视为无body + break; + case "application/x-www-form-urlencoded": + case "text/plain": + default: + break; + case "application/x-mpegURL": + case "application/x-mpegurl": + case "application/vnd.apple.mpegurl": + case "audio/mpegurl": + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + break; + case "text/vtt": + case "application/vtt": + break; + case "text/json": + case "application/json": + body = JSON.parse($response.body); + Console.debug(`[Apple Response] ${url.pathname}`, body); + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v1/availability/") || url.pathname.startsWith("/api/v2/availability/")) { + body = Configs?.Availability?.v2; + } + break; + } + $response.body = JSON.stringify(body); + break; + case "application/vnd.apple.flatbuffer": + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + case "application/grpc": + case "application/grpc+proto": + case "application/octet-stream": { + let rawBody = $response.bodyBytes ? new Uint8Array($response.bodyBytes) : ($response.body ?? new Uint8Array()); + switch (FORMAT) { + case "application/vnd.apple.flatbuffer": { + if (url.hostname !== "weatherkit.apple.com" || !/^\/api\/v[23]\/weather\//.test(url.pathname) || $response.status !== 200) break; + const parsedParameters = preParameters || parseWeatherKitURL(url); + const parameters = { ...parsedParameters, dataSets: parsedParameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)) }; + const shouldReplace = shouldReplaceWeatherKit(parameters, Settings); + if (!shouldReplace && url.pathname.startsWith("/api/v3/weather/") && isHongKong(parameters) + && parameters.dataSets.includes("airQuality") && !decodeWK3DataSet(rawBody)) { + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds({ ...parameters, country: "HK" }, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + parameters, country: "HK", + }; + rawBody = await PatchWK3HongKongMissingAirQuality(rawBody, Settings, enviroments, preFetched); + break; + } + if (!shouldReplace) { + Console.log(`[proxy] ${parameters.country}/${parameters.latitude},${parameters.longitude} 无需替换,直接跳过 FlatBuffer 编解码。`); + break; + } + if (url.pathname.startsWith("/api/v3/weather/")) { + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings?.API?.QWeather?.Token, Settings?.API?.QWeather?.Host), + parameters, + country: parameters.country, + }; + if (parameters.dataSets.includes("airQuality")) rawBody = await PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched); + if (parameters.dataSets.includes("forecastNextHour")) rawBody = await PatchWK3NextHour(rawBody, Settings, enviroments, preFetched); + if (parameters.dataSets.includes("weatherAlerts")) { + const patched = await patchWK3QWeatherAlerts(rawBody, { language: parameters.language, timezone: url.searchParams.get("timezone") || undefined, rewriteDetails: true }); + Console.info("WK3 alerts patch", patched === rawBody ? "无可补全的匹配预警,保留原始信息。" : "已补全国内预警;苹果原生详情页保留原事件 ID。"); + rawBody = patched; + } + break; + } + if (isIOS27WeatherFlatBuffer && url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v2/weather/")) { + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings?.API?.QWeather?.Token, Settings?.API?.QWeather?.Host), + parameters, + country: parameters.country, + }; + rawBody = await PatchIOS27WeatherFlatBuffer(rawBody, Settings, enviroments, preFetched, parameters); + break; + } + + // 解析FlatBuffer + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v2/weather/")) { + const dataSets = parameters.dataSets.filter(dataSet => ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour"].includes(dataSet)); + body = WeatherKit2.decode(ByteBuffer, dataSets); + // 优先使用 Hono.js 预构建的环境实例,避免重复创建 + const enviroments = preEnviroments || { + colorfulClouds: new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings?.API?.QWeather?.Token, Settings?.API?.QWeather?.Host), + parameters, + country: parameters.country, + }; + + const allSections = ["currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "airQuality"]; + + await Promise.all( + dataSets.map(async dataSet => { + switch (dataSet) { + case "airQuality": { + body.airQuality = await InjectAirQuality(body.airQuality, Settings, enviroments, preFetched); + break; + } + case "currentWeather": { + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments, preFetched.currentWeather); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments, preFetched.forecastDaily); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments, preFetched.forecastHourly); + break; + } + case "forecastNextHour": { + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments, preFetched.forecastNextHour); + break; + } + default: + break; + } + }), + ); + + // 去掉所有 providerLogo + allSections.forEach(s => { + if (body?.[s]?.metadata?.providerLogo) { + body[s].metadata.providerLogo = undefined; + } + }); + rawBody = WeatherKit2.encode(ByteBuffer, body); + break; + } + break; + } + break; + } + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + break; + case "application/grpc": + case "application/grpc+proto": + break; + case "application/octet-stream": + break; + } + // 写入二进制数据 + $response.body = rawBody; + break; + } + } + return $response; +} + +// WK3 enums differ from WK2. Same-scale updates retain the original narrow patch; +// CN -> US uses independently observed mappings and a complete provider AQ child. +async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = {}) { + try { + const original = decodeWK3DataSet(rawBody); + if (!original) return rawBody; + const scaleName = AirQuality.GetNameFromScale(original.scale); + const replace = Settings?.AirQuality?.Current?.Index?.Replace || []; + if (!replace.includes(scaleName)) return rawBody; + let injected; + if (preFetched.index) { + injected = await preFetched.index; + } else if (Settings?.AirQuality?.Current?.Index?.Provider === "Calculate") { + const pollutants = preFetched.pollutants ? await preFetched.pollutants : await InjectPollutants(Settings, enviroments); + if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return rawBody; + injected = await InjectIndex(pollutants, Settings, enviroments); + if (injected?.metadata) injected = { ...injected, metadata: { ...pollutants.metadata, ...injected.metadata } }; + } else { + injected = await InjectIndex(undefined, Settings, enviroments); + } + if (!injected?.metadata || injected.metadata.temporarilyUnavailable) return rawBody; + const targetScale = AirQuality.GetNameFromScale(injected.scale); + if (!scaleName || targetScale !== scaleName) { + // CN -> US only. Replace the entire AQ child with one provider's + // consistent units/primary/source, leaving all other root arenas intact. + if (scaleName !== "HJ6332012" || targetScale !== "EPA_NowCast") return rawBody; + const root = readUint32LE(rawBody, 0); + const field = getTableFieldOffset(rawBody, root, 0); + const table = root + field + readUint32LE(rawBody, root + field); + const vtable = table - readInt32LE(rawBody, table); + // Do not discard future AQ fields when replacing the known child. + if (readUint16LE(rawBody, vtable) > 20) return rawBody; + const replacement = await injectWK3Comparison(providerToWK3USAirQuality(injected), { + currentProvider: Settings?.AirQuality?.Current?.Index?.Provider, + yesterdayProvider: Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider, + sources: enviroments, + yesterday: preFetched.yesterdayAirQuality, + }); + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3AirQuality(builder, replacement)); + const patched = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); + Console.info("WK3 AQ patch", `国标转美标: ${original.index} -> ${replacement.index} (${replacement.scale}); 同步污染物、单位、首要污染物及来源,昨日对比=${replacement.previousDayComparison}。`); + return patched; + } + if (!Number.isInteger(injected.index) || injected.index < 0 || injected.index > 32767 + || !Number.isInteger(injected.categoryIndex) || injected.categoryIndex < 0 || injected.categoryIndex > 127) return rawBody; + const root = readUint32LE(rawBody, 0); + const field = getTableFieldOffset(rawBody, root, 0); + const table = root + field + readUint32LE(rawBody, root + field); + // Omitted scalar slots cannot be written in place. Preserve the original + // rather than rebuilding a schema with unverified fields or enum defaults. + if (!getTableFieldOffset(rawBody, table, 1) || !getTableFieldOffset(rawBody, table, 2)) return rawBody; + const patched = new Uint8Array(rawBody); + writeTableInt8IfPresent(patched, table, 1, injected.categoryIndex); + writeTableInt16IfPresent(patched, table, 2, injected.index); + Console.info("WK3 AQ patch", `同标准 AQI: ${original.index} -> ${injected.index} (${scaleName}); 保留原始污染物、昨日对比与全部预报槽。`); + return patched; + } catch (error) { + Console.warn("WK3 AQ patch", "指数替换失败,保留 Apple 原始二进制响应。", error?.message ?? error); + return rawBody; + } +} + +async function PatchWK3HongKongMissingAirQuality(rawBody, Settings, enviroments, preFetched = {}) { + if (Settings?.AirQuality?.Current?.Index?.Provider !== "ColorfulCloudsUS") return rawBody; + try { + const injected = preFetched.index ? await preFetched.index : await enviroments.colorfulClouds.CurrentAirQuality(true, Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants); + const replacement = await injectWK3Comparison(providerToWK3USAirQuality(injected), { + currentProvider: "ColorfulCloudsUS", + yesterdayProvider: Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider, + sources: enviroments, yesterday: preFetched.yesterdayAirQuality, + }); + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3AirQuality(builder, replacement)); + const patched = rebuildWeatherRootWithAddedTable(rawBody, 0, builder.asUint8Array()); + Console.info("WK3 HK AQ patch", `补入香港美标 AQI ${replacement.index}; 昨日对比=${replacement.previousDayComparison},其他天气原样保留。`); + return patched; + } catch (error) { + Console.warn("WK3 HK AQ patch", "数据源不可用,保持原响应。", error?.message ?? error); + return rawBody; + } +} + +async function PatchWK3NextHour(rawBody, Settings, enviroments, preFetched = {}) { + if (Settings?.NextHour?.Provider === "WeatherKit") return rawBody; + try { + // Inspect before replacing: unknown future fields retain their arena. + decodeWK3NextHour(rawBody); + const data = await InjectForecastNextHour(undefined, Settings, enviroments, preFetched.forecastNextHour); + const nextHour = providerToWK3NextHour(data, { intensityUnit: "mm/h", chanceUnit: "percent" }); + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3NextHour(builder, nextHour)); + const root = readUint32LE(rawBody, 0); + const patched = getTableFieldOffset(rawBody, root, 5) + ? appendFlatBufferTableToWeatherRoot(rawBody, 5, builder.asUint8Array()) + : rebuildWeatherRootWithAddedTable(rawBody, 5, builder.asUint8Array()); + Console.info("WK3 nextHour patch", ...formatNextHourSummary(data)); + return patched; + } catch (error) { + Console.warn("WK3 nextHour patch", "分钟降水替换失败,保留原响应。", error?.message ?? error); + return rawBody; + } +} + +async function PatchIOS27WeatherFlatBuffer(rawBody, Settings, enviroments, preFetched = {}, parameters = {}) { + let patchedBody = rawBody; + if (parameters.dataSets?.includes("airQuality")) { + patchedBody = await PatchIOS27AirQualityFlatBuffer(patchedBody, Settings, enviroments, preFetched); + } + if (parameters.dataSets?.includes("forecastNextHour")) { + patchedBody = await PatchIOS27ForecastNextHourFlatBuffer(patchedBody, Settings, enviroments, preFetched); + } + return patchedBody; +} + +async function PatchIOS27AirQualityFlatBuffer(rawBody, Settings, enviroments, preFetched = {}) { + try { + if (!getTableFieldOffset(rawBody, readUint32LE(rawBody, 0), 0)) { + Console.info("FlatBuffer AQ patch", "iOS 27 原始响应没有 airQuality 字段,保持 Apple 原始二进制响应。"); + return rawBody; + } + + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + const WeatherData = WK2.Weather.getRootAsWeather(ByteBuffer); + const originalAirQualityData = WeatherData?.airQuality(); + if (!originalAirQualityData) { + Console.info("FlatBuffer AQ patch", "iOS 27 原始响应 airQuality 为空,保持 Apple 原始二进制响应。"); + return rawBody; + } + + const originalAirQuality = decodeAirQuality(originalAirQualityData); + const patchedAirQuality = await InjectAirQuality(originalAirQuality, Settings, enviroments, preFetched); + const comparisonOffset = getTableFieldOffset(rawBody, originalAirQualityData.bb_pos, 5); + let patchedBody; + let patchMode = "known-fields"; + if (!comparisonOffset && patchedAirQuality?.previousDayComparison && patchedAirQuality.previousDayComparison !== AirQuality.Config.CompareCategoryIndexes.UNKNOWN) { + const builder = new flatbuffers.Builder(); + const patchedAirQualityOffset = encodeAirQuality(builder, patchedAirQuality); + builder.finish(patchedAirQualityOffset); + patchedBody = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); + patchMode = "replace-airQuality-table"; + } else { + patchedBody = patchIOS27AirQualityKnownFields(rawBody, originalAirQualityData.bb_pos, patchedAirQuality); + } + Console.info( + "FlatBuffer AQ patch", + patchMode === "replace-airQuality-table" + ? "检测到 iOS 27 WK2.Weather,原始 airQuality 缺少 previousDayComparison 字段,替换 airQuality 子表以写入昨日对比。" + : "检测到 iOS 27 WK2.Weather,仅修改 airQuality 已知字段并保留 Apple 原始未知字段。", + `index: ${patchedAirQuality?.index}`, + `primaryPollutant: ${patchedAirQuality?.primaryPollutant}`, + `scale: ${patchedAirQuality?.scale}`, + `previousDayComparison: ${patchedAirQuality?.previousDayComparison}`, + ); + return patchedBody; + } catch (error) { + Console.warn("FlatBuffer AQ patch", "替换 iOS 27 airQuality 失败,已回退为 Apple 原始二进制响应。", error?.message ?? error); + return rawBody; + } +} + +function patchIOS27AirQualityKnownFields(rawBody, airQualityTablePosition, airQuality) { + const patchedBody = new Uint8Array(rawBody); + + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 1, airQuality?.categoryIndex); + writeTableInt16IfPresent(patchedBody, airQualityTablePosition, 2, airQuality?.index); + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 3, airQuality?.isSignificant ? 1 : 0); + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 5, WK2.ComparisonTrend[airQuality?.previousDayComparison]); + writeTableInt8IfPresent(patchedBody, airQualityTablePosition, 6, WK2.PollutantType[airQuality?.primaryPollutant]); + + let bodyWithAppendedData = patchedBody; + bodyWithAppendedData = appendFlatBufferObjectToTableField(bodyWithAppendedData, airQualityTablePosition, 7, createStringBuffer(airQuality?.scale)); + return bodyWithAppendedData; +} + +function createMetadataBuffer(metadata = {}) { + const builder = new flatbuffers.Builder(); + const metadataOffset = WK2.Metadata.createMetadata( + builder, + builder.createString(metadata?.attributionUrl), + metadata?.expireTime, + builder.createString(metadata?.language), + metadata?.latitude, + metadata?.longitude, + builder.createString(metadata?.providerLogo), + builder.createString(metadata?.providerName), + metadata?.readTime, + metadata?.reportedTime, + metadata?.temporarilyUnavailable, + WK2.SourceType[metadata?.sourceType], + ); + builder.finish(metadataOffset); + return builder.asUint8Array(); +} + +function createPollutantsVectorBuffer(pollutants = []) { + const builder = new flatbuffers.Builder(); + const pollutantOffsets = pollutants.map(pollutant => + WK2.Pollutant.createPollutant( + builder, + WK2.PollutantType[pollutant?.pollutantType], + pollutant?.amount, + WK2.UnitType[pollutant?.units], + ), + ); + const pollutantsOffset = WK2.AirQuality.createPollutantsVector(builder, pollutantOffsets); + builder.finish(pollutantsOffset); + return builder.asUint8Array(); +} + +function createStringBuffer(value) { + const builder = new flatbuffers.Builder(); + const stringOffset = builder.createString(value ?? ""); + builder.finish(stringOffset); + return builder.asUint8Array(); +} + +async function PatchIOS27ForecastNextHourFlatBuffer(rawBody, Settings, enviroments, preFetched = {}) { + try { + if (!getTableFieldOffset(rawBody, readUint32LE(rawBody, 0), 4)) { + const patchedNextHour = await InjectForecastNextHour(undefined, Settings, enviroments, preFetched.forecastNextHour); + if (patchedNextHour?.metadata) { + const builder = new flatbuffers.Builder(); + const patchedNextHourOffset = encodeForecastNextHour(builder, patchedNextHour); + builder.finish(patchedNextHourOffset); + const patchedBody = rebuildWeatherRootWithAddedTable(rawBody, 4, builder.asUint8Array()); + Console.info( + "FlatBuffer nextHour patch", + "iOS 27 原始响应没有 forecastNextHour 字段,已重建 Weather 根表并补入 forecastNextHour,原始子表保持复用。", + ...formatNextHourSummary(patchedNextHour), + ); + return patchedBody; + } + Console.info( + "FlatBuffer nextHour patch", + "iOS 27 原始响应没有 forecastNextHour 字段,且没有可用的下一小时降水数据,保持 Apple 原始二进制响应。", + ...formatNextHourSummary(patchedNextHour), + ); + return rawBody; + } + + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + const WeatherData = WK2.Weather.getRootAsWeather(ByteBuffer); + const originalNextHourData = WeatherData?.forecastNextHour(); + if (!originalNextHourData) { + Console.info("FlatBuffer nextHour patch", "iOS 27 原始响应 forecastNextHour 为空,保持 Apple 原始二进制响应。"); + return rawBody; + } + + const originalNextHour = decodeForecastNextHour(originalNextHourData); + const patchedNextHour = await InjectForecastNextHour(originalNextHour, Settings, enviroments, preFetched.forecastNextHour); + if (!patchedNextHour?.metadata) { + Console.info("FlatBuffer nextHour patch", "没有可用的下一小时降水数据,保持 Apple 原始 forecastNextHour。", ...formatNextHourSummary(patchedNextHour)); + return rawBody; + } + + const builder = new flatbuffers.Builder(); + const patchedNextHourOffset = encodeForecastNextHour(builder, patchedNextHour); + builder.finish(patchedNextHourOffset); + const patchedBody = appendFlatBufferTableToWeatherRoot(rawBody, 4, builder.asUint8Array()); + Console.info( + "FlatBuffer nextHour patch", + "检测到 iOS 27 WK2.Weather,仅替换 forecastNextHour 字段并保留其余 Apple 原始二进制字段。", + ...formatNextHourSummary(patchedNextHour), + ); + return patchedBody; + } catch (error) { + Console.warn("FlatBuffer nextHour patch", "替换 iOS 27 forecastNextHour 失败,已回退为 Apple 原始二进制响应。", error?.message ?? error); + return rawBody; + } +} + +function formatNextHourSummary(forecastNextHour) { + const minutes = forecastNextHour?.minutes ?? []; + const intensities = minutes.map(minute => minute?.precipitationIntensity ?? 0); + return [ + `minutes: ${minutes.length}`, + `summary: ${forecastNextHour?.summary?.length ?? 0}`, + `condition: ${forecastNextHour?.condition?.length ?? 0}`, + `rainyMinutes: ${intensities.filter(intensity => intensity > 0).length}`, + `maxIntensity: ${intensities.length ? Math.max(...intensities) : 0}`, + `firstToken: ${forecastNextHour?.condition?.[0]?.forecastToken ?? "UNKNOWN"}`, + ]; +} + +function appendFlatBufferTableToWeatherRoot(rawBody, weatherFieldIndex, tableBuffer) { + const rootTable = readUint32LE(rawBody, 0); + const fieldOffset = getTableFieldOffset(rawBody, rootTable, weatherFieldIndex); + if (!fieldOffset) return rawBody; + + const tableRoot = readUint32LE(tableBuffer, 0); + const appendAt = Math.ceil(rawBody.length / 4) * 4; + const patchedBody = new Uint8Array(appendAt + tableBuffer.length); + patchedBody.set(rawBody, 0); + patchedBody.set(tableBuffer, appendAt); + + const fieldAddress = rootTable + fieldOffset; + const newTableAddress = appendAt + tableRoot; + writeUint32LE(patchedBody, fieldAddress, newTableAddress - fieldAddress); + return patchedBody; +} + +function rebuildWeatherRootWithAddedTable(rawBody, weatherFieldIndex, tableBuffer) { + const originalRoot = readUint32LE(rawBody, 0); + const originalVtable = originalRoot - readInt32LE(rawBody, originalRoot); + const originalVtableLength = readUint16LE(rawBody, originalVtable); + const originalFieldCount = Math.max(0, (originalVtableLength - 4) / 2); + const fieldCount = Math.max(10, originalFieldCount, weatherFieldIndex + 1); + const newVtableLength = 4 + fieldCount * 2; + const newObjectLength = 4 + fieldCount * 4; + const newVtablePosition = 4; + const newRootPosition = Math.ceil((newVtablePosition + newVtableLength) / 4) * 4; + // Keep the preserved arena aligned for unknown int64/double fields, even + // when the root vtable contains an odd number of slots (WK3 has 15). + const shiftedRawPosition = Math.ceil((newRootPosition + newObjectLength) / 8) * 8; + const tableBufferPosition = Math.ceil((shiftedRawPosition + rawBody.length) / 8) * 8; + const tableRoot = readUint32LE(tableBuffer, 0); + const patchedBody = new Uint8Array(tableBufferPosition + tableBuffer.length); + + patchedBody.set(rawBody, shiftedRawPosition); + patchedBody.set(tableBuffer, tableBufferPosition); + + writeUint32LE(patchedBody, 0, newRootPosition); + writeUint16LE(patchedBody, newVtablePosition, newVtableLength); + writeUint16LE(patchedBody, newVtablePosition + 2, newObjectLength); + writeUint32LE(patchedBody, newRootPosition, newRootPosition - newVtablePosition); + + for (let fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++) { + const fieldSlot = newVtablePosition + 4 + fieldIndex * 2; + const newFieldOffset = 4 + fieldIndex * 4; + let fieldValue; + + if (fieldIndex === weatherFieldIndex) { + const newFieldAddress = newRootPosition + newFieldOffset; + const newTableAddress = tableBufferPosition + tableRoot; + fieldValue = newTableAddress - newFieldAddress; + } else { + const originalFieldOffset = getTableFieldOffset(rawBody, originalRoot, fieldIndex); + if (!originalFieldOffset) continue; + + const originalFieldAddress = originalRoot + originalFieldOffset; + const originalValue = readUint32LE(rawBody, originalFieldAddress); + const originalTarget = originalFieldAddress + originalValue; + if (originalValue && originalTarget > originalFieldAddress && originalTarget < rawBody.length) { + const newFieldAddress = newRootPosition + newFieldOffset; + fieldValue = shiftedRawPosition + originalTarget - newFieldAddress; + } else { + fieldValue = originalValue; + } + } + + writeUint16LE(patchedBody, fieldSlot, newFieldOffset); + writeUint32LE(patchedBody, newRootPosition + newFieldOffset, fieldValue); + } + + return patchedBody; +} + +function readUint32LE(bytes, offset) { + return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0; +} + +function readInt32LE(bytes, offset) { + const value = readUint32LE(bytes, offset); + return value > 0x7fffffff ? value - 0x100000000 : value; +} + +function readUint16LE(bytes, offset) { + return bytes[offset] | (bytes[offset + 1] << 8); +} + +function writeUint32LE(bytes, offset, value) { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; + bytes[offset + 3] = (value >>> 24) & 0xff; +} + +function writeUint16LE(bytes, offset, value) { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; +} + +function writeTableInt8IfPresent(bytes, tablePosition, fieldIndex, value) { + if (value === undefined || value === null) return; + const fieldOffset = getTableFieldOffset(bytes, tablePosition, fieldIndex); + if (!fieldOffset) return; + bytes[tablePosition + fieldOffset] = value & 0xff; +} + +function writeTableInt16IfPresent(bytes, tablePosition, fieldIndex, value) { + if (value === undefined || value === null) return; + const fieldOffset = getTableFieldOffset(bytes, tablePosition, fieldIndex); + if (!fieldOffset) return; + writeUint16LE(bytes, tablePosition + fieldOffset, value); +} + +function appendFlatBufferObjectToTableField(rawBody, tablePosition, fieldIndex, objectBuffer) { + if (!objectBuffer?.length) return rawBody; + const fieldOffset = getTableFieldOffset(rawBody, tablePosition, fieldIndex); + if (!fieldOffset) return rawBody; + + const objectRoot = readUint32LE(objectBuffer, 0); + const patchedBody = new Uint8Array(rawBody.length + objectBuffer.length); + patchedBody.set(rawBody, 0); + patchedBody.set(objectBuffer, rawBody.length); + + const fieldAddress = tablePosition + fieldOffset; + const newObjectAddress = rawBody.length + objectRoot; + writeUint32LE(patchedBody, fieldAddress, newObjectAddress - fieldAddress); + return patchedBody; +} + +function getTableFieldOffset(bytes, tablePosition, fieldIndex) { + if (!Number.isFinite(tablePosition) || tablePosition <= 0 || tablePosition + 4 > bytes.length) return 0; + const vtablePosition = tablePosition - readInt32LE(bytes, tablePosition); + if (vtablePosition < 0 || vtablePosition + 4 > bytes.length) return 0; + const vtableLength = readUint16LE(bytes, vtablePosition); + const fieldSlot = 4 + fieldIndex * 2; + if (fieldSlot + 2 > vtableLength || vtablePosition + fieldSlot + 2 > bytes.length) return 0; + return readUint16LE(bytes, vtablePosition + fieldSlot); +} + +/** + * 注入当前天气数据 + * @param {any} currentWeather - 当前天气数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @param {Promise<any>} [preFetchedData] - 预取的数据 + * @returns {Promise<any>} 注入后的当前天气数据 + */ +async function InjectCurrentWeather(currentWeather, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectCurrentWeather"); + if (!shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.warn("InjectCurrentWeather", `Unreplaced location: ${enviroments.country}`); + Console.debug("✅ InjectCurrentWeather"); + return currentWeather; + } + let newCurrentWeather; + if (preFetchedData) { + newCurrentWeather = await preFetchedData; + Console.info("InjectCurrentWeather", "使用预取数据"); + } else { + switch (Settings?.Weather?.Provider) { + case "WeatherKit": + default: + break; + case "QWeather": { + newCurrentWeather = await enviroments.qWeather.WeatherNow(); + break; + } + case "ColorfulClouds": { + newCurrentWeather = await enviroments.colorfulClouds.CurrentWeather(); + break; + } + } + } + if (newCurrentWeather?.metadata) { + newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; + currentWeather = { ...currentWeather, ...newCurrentWeather }; + } + Console.debug("✅ InjectCurrentWeather"); + return currentWeather; +} + +/** + * 注入每日天气预报数据 + * @param {any} forecastDaily - 每日预报数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @param {Promise<any>} [preFetchedData] - 预取的数据 + * @returns {Promise<any>} 注入后的每日预报数据 + */ +async function InjectForecastDaily(forecastDaily, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectForecastDaily"); + const replaceDaily = Settings?.Weather?.ReplaceDaily ?? true; + if (!replaceDaily || !shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectForecastDaily", `Unreplaced or skipped location: ${enviroments.country}`); + Console.debug("✅ InjectForecastDaily"); + return forecastDaily; + } + let newForecastDaily; + if (preFetchedData) { + newForecastDaily = await preFetchedData; + Console.info("InjectForecastDaily", "使用预取数据"); + } else { + switch (Settings?.Weather?.Provider) { + case "WeatherKit": + default: + break; + case "QWeather": { + newForecastDaily = await enviroments.qWeather.Daily(); + break; + } + case "ColorfulClouds": { + const dailysteps = forecastDaily.days?.length || 11; + const begin = forecastDaily.days?.[0]?.forecastStart || undefined; + newForecastDaily = await enviroments.colorfulClouds.Daily(dailysteps, begin); + break; + } + } + } + if (newForecastDaily?.metadata) { + forecastDaily.metadata = { ...forecastDaily?.metadata, ...newForecastDaily.metadata }; + Weather.mergeForecast(forecastDaily?.days, newForecastDaily?.days); + } + Console.debug("✅ InjectForecastDaily"); + return forecastDaily; +} + +/** + * 注入小时天气预报数据 + * @param {any} forecastHourly - 小时预报数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @returns {Promise<any>} 注入后的小时预报数据 + */ +async function InjectForecastHourly(forecastHourly, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectForecastHourly"); + const replaceHourly = Settings?.Weather?.ReplaceHourly ?? true; + if (!replaceHourly || !shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectForecastHourly", `Unreplaced or skipped location: ${enviroments.country}`); + Console.debug("✅ InjectForecastHourly"); + return forecastHourly; + } + let newForecastHourly; + if (preFetchedData) { + newForecastHourly = await preFetchedData; + Console.info("InjectForecastHourly", "使用预取数据"); + } else { + switch (Settings?.Weather?.Provider) { + case "WeatherKit": + default: + break; + case "QWeather": { + newForecastHourly = await enviroments.qWeather.Hourly(); + break; + } + case "ColorfulClouds": { + Console.debug("✅ InjectForecastHourly ColorfulClouds"); + const hourlysteps = forecastHourly.hours?.length || 273; + const begin = forecastHourly.hours?.[0]?.forecastStart || undefined; + newForecastHourly = await enviroments.colorfulClouds.ForecastHourly(hourlysteps, begin); + break; + } + } + } + if (newForecastHourly?.metadata) { + forecastHourly.metadata = { ...forecastHourly?.metadata, ...newForecastHourly.metadata }; + forecastHourly.hours = Weather.mergeForecast(forecastHourly?.hours, newForecastHourly?.hours); + } + Console.debug("✅ InjectForecastHourly"); + return forecastHourly; +} + +/** + * 注入下一小时天气预报数据 + * @param {any} forecastNextHour - 下一小时预报数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @param {Promise<any>} [preFetchedData] - 预取的数据 + * @returns {Promise<any>} 注入后的下一小时预报数据 + */ +async function InjectForecastNextHour(forecastNextHour, Settings, enviroments, preFetchedData) { + Console.debug("☑️ InjectForecastNextHour"); + if (!shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectForecastNextHour", `Unreplaced location: ${enviroments.country}`); + Console.debug("✅ InjectForecastNextHour"); + return forecastNextHour; + } + + // if (forecastNextHour) { + // Console.debug("✅ InjectForecastNextHour"); + // return forecastNextHour; + // } + + let newForecastNextHour; + if (preFetchedData) { + newForecastNextHour = await preFetchedData; + Console.info("InjectForecastNextHour", "使用预取数据"); + } else { + switch (Settings?.NextHour?.Provider) { + case "WeatherKit": + break; + case "QWeather": { + newForecastNextHour = await enviroments.qWeather.Minutely(); + break; + } + case "ColorfulClouds": + default: { + newForecastNextHour = await enviroments.colorfulClouds.Minutely(); + break; + } + } + } + if (newForecastNextHour?.metadata) { + const isClear = newForecastNextHour?.condition?.[0]?.forecastToken === "CLEAR" || newForecastNextHour?.condition?.length === 0; + if (isClear) { + Console.info("InjectForecastNextHour", "未来一小时无降水,保留下一小时预报数据"); + newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; + forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; + } else { + newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; + forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; + } + } + Console.debug("✅ InjectForecastNextHour"); + return forecastNextHour; +} + +/** + * 注入并合并空气质量数据(污染物、指数、昨日对比) + * @param {any} airQuality - WeatherKit 原始空气质量对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 各数据源实例与定位信息 + * @param {Object} [preFetched={}] - 预取的数据(含 pollutants、index、locationsGrid 等) + * @returns {Promise<any>} 合并后的空气质量对象 + */ +async function InjectAirQuality(airQuality, Settings, enviroments, preFetched = {}) { + if (!shouldReplaceWeatherKit(enviroments.parameters, Settings)) { + Console.info("InjectAirQuality", `Unreplaced location: ${enviroments.country}`); + return AirQuality.FixPollutantsUnits(airQuality); + } + + // Step1. 修复污染物单位 + airQuality = AirQuality.FixPollutantsUnits(airQuality); + + // Step2. 判断原始污染物是否为空,并在需要时注入污染物数据 + const isPollutantEmpty = !Array.isArray(airQuality?.pollutants) || airQuality.pollutants.length === 0; + const needReplacePollutants = Settings?.AirQuality?.Current?.Pollutants?.Provider && Settings.AirQuality.Current.Pollutants.Provider !== "WeatherKit"; + const shouldInjectPollutants = isPollutantEmpty || needReplacePollutants; + const injectedPollutants = shouldInjectPollutants ? (preFetched.pollutants ? await preFetched.pollutants : await InjectPollutants(Settings, enviroments)) : airQuality; + if (shouldInjectPollutants && preFetched.pollutants) Console.info("InjectAirQuality", "污染物使用预取数据"); + const needPollutants = shouldInjectPollutants && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); + + // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 + const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.includes(AirQuality.GetNameFromScale(airQuality?.scale)); + let injectedIndex = injectedPollutants; + if (needInjectIndex) { + if (preFetched.index) { + injectedIndex = await preFetched.index; + Console.info("InjectAirQuality", "指数使用预取数据"); + } else { + injectedIndex = await InjectIndex(injectedPollutants, Settings, enviroments); + } + } + + // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const forceInjectComparison = needInjectIndex || Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange; + const previousDayComparison = forceInjectComparison ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const needInjectComparison = forceInjectComparison || previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + let injectedComparison = { ...injectedIndex, previousDayComparison: weatherKitComparison }; + if (needInjectComparison) { + try { + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, enviroments, preFetched); + } catch (error) { + Console.warn("InjectAirQuality", "昨日对比获取失败,使用 UNKNOWN 继续返回当前空气质量。", error?.message ?? error); + injectedComparison = { ...injectedIndex, previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN }; + } + } + + // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 + const weatherKitMetadata = airQuality?.metadata; + const pollutantMetadata = injectedPollutants?.metadata; + const indexMetadata = injectedIndex?.metadata; + const comparisonMetadata = injectedComparison?.metadata; + + const pName = needPollutants && pollutantMetadata && !pollutantMetadata.temporarilyUnavailable ? pollutantMetadata.providerName : null; + const iName = needInjectIndex && indexMetadata && !indexMetadata.temporarilyUnavailable ? indexMetadata.providerName : null; + const cName = needInjectComparison && comparisonMetadata && !comparisonMetadata.temporarilyUnavailable ? comparisonMetadata.providerName : null; + + const providers = []; + if (pName && pName === iName && (!cName || pName === cName)) { + // 如果各环节提供商都一致(或者没开启部分注入),则合并展示,去除多余的“污染物”、“指数”前缀 + providers.push(AirQuality.appendScaleToProviderName(injectedIndex, Settings)); + } else { + if ((!needInjectIndex || !needPollutants || !needInjectComparison) && weatherKitMetadata?.providerName && !weatherKitMetadata.temporarilyUnavailable) { + providers.push(weatherKitMetadata.providerName); + } + if (pName) providers.push(`污染物:${pName}`); + if (iName) providers.push(`指数:${AirQuality.appendScaleToProviderName(injectedIndex, Settings)}`); + if (cName) providers.push(`对比昨日:${cName}`); + } + + const activeMetadata = injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable && injectedIndex.metadata.attributionUrl ? injectedIndex.metadata : pollutantMetadata && !pollutantMetadata.temporarilyUnavailable && pollutantMetadata.attributionUrl ? pollutantMetadata : null; + const providerName = providers.filter(Boolean).join("\n") || weatherKitMetadata?.providerName || indexMetadata?.providerName || pollutantMetadata?.providerName || "WeatherKit-Proxy"; + + // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison + airQuality = { + ...airQuality, + ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? injectedIndex : {}), + metadata: { + ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), + providerName, + ...(activeMetadata ? { attributionUrl: activeMetadata.attributionUrl } : {}), + }, + pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, + }; + Console.info( + "InjectAirQuality summary", + `index: ${airQuality?.index}`, + `categoryIndex: ${airQuality?.categoryIndex}`, + `primaryPollutant: ${airQuality?.primaryPollutant}`, + `scale: ${airQuality?.scale}`, + `previousDayComparison: ${airQuality?.previousDayComparison}`, + `pollutants: ${airQuality?.pollutants?.length ?? 0}`, + `temporarilyUnavailable: ${airQuality?.metadata?.temporarilyUnavailable === true}`, + ); + Console.debug("airQuality:", airQuality); + return airQuality; +} + +async function InjectPollutants(Settings, enviroments) { + Console.debug("☑️ InjectPollutants"); + + switch (Settings?.AirQuality?.Current?.Pollutants?.Provider) { + case "QWeather": { + const currentAirQuality = await enviroments.qWeather.CurrentAirQuality(); + Console.debug("✅ InjectPollutants"); + return currentAirQuality; + } + case "ColorfulClouds": + default: { + const currentAirQuality = await enviroments.colorfulClouds.CurrentAirQuality(); + Console.debug("✅ InjectPollutants"); + return currentAirQuality; + } + } +} + +/** + * 注入空气质量数据 + * @param {any} airQuality - 空气质量数据对象 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {any} enviroments - 环境变量 + * @returns {Promise<any>} 注入后的空气质量数据 + */ +async function InjectIndex(airQuality, Settings, enviroments) { + Console.debug("☑️ InjectIndex"); + + switch (Settings?.AirQuality?.Current?.Index?.Provider) { + case "QWeather": { + const currentAirQuality = await enviroments.qWeather.CurrentAirQuality(Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants); + Console.debug("✅ InjectIndex"); + return currentAirQuality; + } + case "ColorfulCloudsUS": + case "ColorfulCloudsCN": { + const currentAirQuality = await enviroments.colorfulClouds.CurrentAirQuality(Settings.AirQuality.Current.Index.Provider === "ColorfulCloudsUS", Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants); + Console.debug("✅ InjectIndex"); + return currentAirQuality; + } + case "Calculate": + default: { + const currentAirQuality = AirQuality.Pollutants2AQI(airQuality, Settings); + Console.debug("✅ InjectIndex"); + return currentAirQuality; + } + } +} + +async function InjectComparison(airQuality, currentIndexProvider, Settings, enviroments, preFetched = {}) { + Console.debug("☑️ InjectComparison"); + + const { UNKNOWN } = AirQuality.Config.CompareCategoryIndexes; + + /** + * HJ 633—2012 + * [环境空气质量指数(AQI)技术规定(试行)_中华人民共和国生态环境部]{@link https://www.mee.gov.cn/ywgz/fgbz/bz/bzwb/jcffbz/201203/t20120302_224166.shtml} + */ + const isHJ6332012 = (currentIndexProvider, currentScale, Settings) => { + Console.debug("☑️ isHJ6332012", `currentIndexProvider: ${currentIndexProvider}`); + const currentScaleName = AirQuality.GetNameFromScale(currentScale); + if (currentScaleName === AirQuality.Config.Scales.HJ6332012.weatherKitScale.name) { + Console.debug("✅ isHJ6332012", true); + return true; + } + + switch (currentIndexProvider) { + case "Calculate": { + Console.debug(`Settings?.AirQuality?.Calculate?.Algorithm: ${Settings?.AirQuality?.Calculate?.Algorithm}`); + const result = Settings?.AirQuality?.Calculate?.Algorithm === "WAQI_InstantCast_CN"; + Console.debug("✅ isHJ6332012", result); + return result; + } + case "QWeather": + case "ColorfulCloudsCN": { + Console.debug("✅ isHJ6332012", true); + return true; + } + case "WeatherKit": { + const result = AirQuality.GetNameFromScale(currentScale) === AirQuality.Config.Scales.HJ6332012.weatherKitScale.name; + Console.debug("✅ isHJ6332012", result); + return result; + } + default: { + Console.debug("✅ isHJ6332012", false); + return false; + } + } + }; + /** + * EPA 454/B-18-007 + * [Technical Assistance Document for the Reporting of Daily Air Quality – the Air Quality Index (AQI)]{@link https://www.airnow.gov/sites/default/files/2020-05/aqi-technical-assistance-document-sept2018.pdf} + */ + const isEPA454_B18007 = (currentIndexProvider, currentScale) => { + Console.debug("☑️ isEPA454_B18007", `currentIndexProvider: ${currentIndexProvider}`); + const currentScaleName = AirQuality.GetNameFromScale(currentScale); + if (currentScaleName === AirQuality.Config.Scales.EPA_NowCast.weatherKitScale.name) { + Console.debug("✅ isEPA454_B18007", true); + return true; + } + + switch (currentIndexProvider) { + case "WAQI": + case "ColorfulCloudsUS": { + Console.debug("✅ isEPA454_B18007", true); + return true; + } + default: { + Console.debug("✅ isEPA454_B18007", false); + return false; + } + } + }; + + const colorfulCloudsComparison = async (useUsa, currentCategoryIndex) => { + Console.debug("☑️ colorfulCloudsComparison", `currentCategoryIndex: ${currentCategoryIndex}`); + const yesterdayAirQuality = await enviroments.colorfulClouds.YesterdayAirQuality(useUsa); + + const getMetadata = (temporarilyUnavailable = false) => ({ + ...yesterdayAirQuality.metadata, + providerName: yesterdayAirQuality.metadata.providerName, + temporarilyUnavailable, + }); + + if (!yesterdayAirQuality.metadata.temporarilyUnavailable) { + if (currentCategoryIndex) { + const comparisonAirQuality = { + ...yesterdayAirQuality, + metadata: getMetadata(false), + previousDayComparison: AirQuality.CompareCategoryIndexes(currentCategoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ colorfulCloudsComparison"); + return comparisonAirQuality; + } else { + const colorfulCloudsCurrent = await enviroments.colorfulClouds.CurrentAirQuality(useUsa); + if (!colorfulCloudsCurrent.metadata.temporarilyUnavailable) { + Console.debug(`colorfulCloudsCurrent?.index: ${colorfulCloudsCurrent?.index}`); + const comparisonAirQuality = { + ...yesterdayAirQuality, + metadata: getMetadata(false), + previousDayComparison: AirQuality.CompareCategoryIndexes(colorfulCloudsCurrent.categoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ colorfulCloudsComparison"); + return comparisonAirQuality; + } + } + } + + Console.error("colorfulCloudsComparison", `无法从彩云天气获取${yesterdayAirQuality.metadata.temporarilyUnavailable ? "昨日" : "今日"}的空气质量数据`); + return { + ...yesterdayAirQuality, + metadata: getMetadata(true), + previousDayComparison: UNKNOWN, + }; + }; + const qweatherComparison = async (currentCategoryIndex, pollutantsToAirQuality) => { + Console.debug("☑️ qweatherComparison", `currentCategoryIndex: ${currentCategoryIndex}`); + const locationsGrid = preFetched?.locationsGrid + ?? await QWeather.GetLocationsGrid(undefined, () => {}); + const { latitude, longitude } = enviroments.qWeather; + const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); + + const yesterdayQWeather = await enviroments.qWeather.YesterdayAirQuality(locationInfo); + + const getMetadata = (indexProvider, temporarilyUnavailable = false) => ({ + ...yesterdayQWeather.metadata, + providerName: indexProvider, + temporarilyUnavailable, + }); + + if (!yesterdayQWeather.metadata.temporarilyUnavailable) { + const airQualityFromPollutants = pollutantsToAirQuality ? pollutantsToAirQuality(yesterdayQWeather) : undefined; + const yesterdayAirQuality = airQualityFromPollutants + ? { + ...airQualityFromPollutants, + metadata: { + ...airQualityFromPollutants.metadata, + providerName: airQualityFromPollutants.metadata.providerName, + }, + } + : { + ...yesterdayQWeather, + metadata: { + ...yesterdayQWeather.metadata, + providerName: yesterdayQWeather.metadata.providerName, + }, + }; + + if (currentCategoryIndex) { + const comparisonAirQuality = { + ...yesterdayQWeather, + metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), + previousDayComparison: AirQuality.CompareCategoryIndexes(currentCategoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ qweatherComparison"); + return comparisonAirQuality; + } else { + const qweatherCurrent = await enviroments.qWeather.CurrentAirQuality(); + if (!qweatherCurrent.metadata.temporarilyUnavailable) { + Console.debug(`qweatherCurrent?.index: ${qweatherCurrent?.index}`); + + const comparisonAirQuality = { + ...yesterdayQWeather, + metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), + previousDayComparison: AirQuality.CompareCategoryIndexes(qweatherCurrent.categoryIndex, yesterdayAirQuality.categoryIndex), + }; + Console.debug("✅ qweatherComparison"); + return comparisonAirQuality; + } + } + } + + Console.error("qweatherComparison", `无法从和风天气获取${yesterdayQWeather.metadata.temporarilyUnavailable ? "昨日" : "今日"}空气质量数据`); + return { + ...yesterdayQWeather, + metadata: getMetadata(yesterdayQWeather.metadata.providerName, true), + previousDayComparison: UNKNOWN, + }; + }; + + switch (Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider) { + case "Calculate": { + const algorithm = AirQuality.chooseAlogrithm(airQuality, Settings); + const PollutantsProvider = Settings?.AirQuality?.Comparison?.Yesterday?.PollutantsProvider; + Console.debug(`Settings?.AirQuality?.Comparison?.Yesterday?.PollutantsProvider: ${PollutantsProvider}`); + + if (algorithm !== "") { + switch (PollutantsProvider) { + case "ColorfulCloudsCN": { + if (algorithm === "WAQI_InstantCast_US") { + const comparisonAirQuality = await colorfulCloudsComparison(true, airQuality?.categoryIndex); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + const comparisonAirQuality = await colorfulCloudsComparison(false, isHJ6332012(currentIndexProvider, airQuality?.scale, Settings) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + case "QWeather": + default: { + const pollutantsToAirQuality = airQuality => AirQuality.Pollutants2AQI(airQuality, Settings, { algorithm }); + const comparisonAirQuality = await qweatherComparison(airQuality?.categoryIndex, pollutantsToAirQuality); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + } + } + + Console.error("InjectComparison", "不支持今日空气质量的标准"); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, previousDayComparison: UNKNOWN }; + } + case "QWeather": { + const comparisonAirQuality = await qweatherComparison(isHJ6332012(currentIndexProvider, airQuality?.scale, Settings) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + case "ColorfulCloudsCN": { + // Use injected AQI or ColorfulClouds AQI depends on data source + const comparisonAirQuality = colorfulCloudsComparison(false, isHJ6332012(currentIndexProvider, airQuality?.scale, Settings) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + case "ColorfulCloudsUS": + default: { + const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider, airQuality?.scale) ? airQuality?.categoryIndex : undefined); + Console.debug("✅ InjectComparison"); + return comparisonAirQuality; + } + } +} From 7c437e8686558688a6a939cbcbe39fdc3eede260 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:42:27 +0800 Subject: [PATCH 06/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E5=85=A5=E6=9C=AC=E6=9C=BA=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E4=B8=8E=E5=8F=82=E6=95=B0=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/egern/entry/index.mjs | 42 +++++++++++++++++++++ src/egern/entry/parameters.mjs | 27 ++++++++++++++ src/egern/entry/request.mjs | 43 +++++++++++++++++++++ src/egern/entry/response.mjs | 67 +++++++++++++++++++++++++++++++++ src/egern/entry/runtime.mjs | 68 ++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 src/egern/entry/index.mjs create mode 100644 src/egern/entry/parameters.mjs create mode 100644 src/egern/entry/request.mjs create mode 100644 src/egern/entry/response.mjs create mode 100644 src/egern/entry/runtime.mjs diff --git a/src/egern/entry/index.mjs b/src/egern/entry/index.mjs new file mode 100644 index 00000000..1297cedb --- /dev/null +++ b/src/egern/entry/index.mjs @@ -0,0 +1,42 @@ +import database from "../wk3/function/database.mjs"; +import buildSettings from "../wk3/function/buildSettings.mjs"; +import { get, set, merge } from "../wk3/utils/object.mjs"; + +export function configuration(ctx) { + // SETTINGS is the same nested Settings object accepted by buildSettings. + const overrides = ctx.env?.SETTINGS ? JSON.parse(ctx.env.SETTINGS) : {}; + const dotted = {}; + for (const [key, value] of Object.entries(ctx.env ?? {})) { + if (key.includes(".") || key === "DataSets" || key === "LogLevel") set(dotted, key, value); + } + merge(overrides, dotted); + // The existing merge deliberately preserves defaults for an empty array. + // Its string "[]" convention clears them during buildSettings normalization. + function emptyArrays(value) { + if (Array.isArray(value)) return value.length ? value : "[]"; + if (value && typeof value === "object") for (const key of Object.keys(value)) value[key] = emptyArrays(value[key]); + return value; + } + const result = buildSettings(database, emptyArrays(overrides)); + // Module parameters are strings. Normalize only list-valued settings here; + // never split tokens/hosts or change the shared cloud settings parser. + for (const path of ["DataSets", "Weather.Replace", "AirQuality.Current.Index.Replace", "AirQuality.Current.Pollutants.Units.Replace"]) { + const value = get(dotted, path) ?? get(overrides, path); + if (value === undefined) continue; + let list = value; + if (typeof list === "string") { + const text = list.trim(); + list = text.startsWith("[") ? JSON.parse(text) : text.split(",").map(v => v.trim()).filter(Boolean); + } + if (!Array.isArray(list) || list.some(v => typeof v !== "string" || !v.trim())) throw new TypeError(`Invalid list setting: ${path}`); + set(result.Settings, path, list.map(v => v.trim())); + } + for (const path of ["API.ColorfulClouds.Token", "API.QWeather.Token", "API.QWeather.Host"]) { + const value = get(dotted, path) ?? get(overrides, path); + if (value !== undefined) set(result.Settings, path, String(value) || (path.endsWith(".Host") ? "devapi.qweather.com" : null)); + } + if (result.Settings.WeatherAlerts?.Provider === "WeatherKit") { + result.Settings.DataSets = result.Settings.DataSets.filter(name => name !== "weatherAlerts"); + } + return result; +} diff --git a/src/egern/entry/parameters.mjs b/src/egern/entry/parameters.mjs new file mode 100644 index 00000000..9ce613ac --- /dev/null +++ b/src/egern/entry/parameters.mjs @@ -0,0 +1,27 @@ +// Parameter keys follow the fork's settings and upstream argument templates. +// Defaults reproduce the existing local module; controls do not expand WK3 support. +export const localParameters = [ + { key: "DataSets", name: "[数据集] 解析与处理范围", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", description: "逗号分隔;填 [] 全部透传。WK3 实际支持空气质量、分钟降水与预警,其余保持苹果数据。" }, + { key: "Weather.Replace", name: "[天气] 替换范围", value: "CN", description: "逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。" }, + { key: "Weather.Provider", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WK2 天气数据源;WK3 当前/逐日/逐小时天气仍透传。" }, + { key: "Weather.ReplaceDaily", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "用于 WK2;WK3 逐日预报保持苹果数据。" }, + { key: "Weather.ReplaceHourly", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "用于 WK2;WK3 逐小时预报保持苹果数据。" }, + { key: "WeatherAlerts.Provider", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["QWeatherWeb", "WeatherKit"], description: "QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。" }, + { key: "NextHour.Provider", name: "[分钟降水] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。" }, + { key: "AirQuality.Current.Index.Provider", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate"], description: "默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。" }, + { key: "AirQuality.Current.Index.Replace", name: "[今日空气指数] 替换范围(原始标准)", value: "HJ6332012", description: "逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。" }, + { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", name: "[今日空气指数] 强制国标主要污染物", value: "true", boolean: true, description: "沿用原项目:国标指数≤50时仍显示主要污染物。" }, + { key: "AirQuality.Current.Pollutants.Provider", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], description: "用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。" }, + { key: "AirQuality.Current.Pollutants.Units.Replace", name: "[污染物单位] 替换原标准", value: "[]", description: "默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。" }, + { key: "AirQuality.Current.Pollutants.Units.Mode", name: "[污染物单位] 转换模式", value: "Scale", options: ["Scale", "ugm3", "EU_ppb", "US_ppb", "Force_ugm3", "Force_EU_ppb", "Force_US_ppb"], description: "Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。" }, + { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", name: "[昨日对比] 今日变化时重算", value: "true", boolean: true, description: "WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。" }, + { key: "AirQuality.Comparison.Yesterday.IndexProvider", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate", "WeatherKit"], description: "默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。" }, + { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", name: "[昨日污染物] 数据源", value: "ColorfulCloudsCN", options: ["ColorfulCloudsCN", "QWeather"], description: "原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。" }, + { key: "AirQuality.Calculate.Algorithm", name: "[内置算法] 计算公式", value: "WAQI_InstantCast_CN", options: ["None", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "WAQI_InstantCast_US", "EU_EAQI", "UBA"], description: "沿用当前默认(当前直接彩云美标时不使用此算法)。WK3 仅支持已验证的目标标准,其他目标透传。" }, + { key: "AirQuality.Calculate.AllowOverRange", name: "[内置算法] 允许指数超标", value: "true", boolean: true, description: "原项目计算开关;WK3 完整美标写入仍校验指数0–500。" }, + { key: "API.ColorfulClouds.Token", name: "[API] 彩云令牌", value: "", description: "留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。" }, + { key: "API.QWeather.Host", name: "[API] 和风主机", value: "devapi.qweather.com", description: "使用和风 API 数据源时填写控制台主机名。" }, + { key: "API.QWeather.Token", name: "[API] 和风令牌", value: "", description: "使用和风 API 数据源时需要;网页预警补全不需要。" }, + { key: "LogLevel", name: "[调试] 日志等级", value: "INFO", options: ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "ALL"], description: "默认 INFO,与当前脚本一致;只控制本机日志。" }, + { key: "HTTP_TIMEOUT_MS", name: "[网络] 单次请求超时(毫秒)", value: "8000", description: "默认8000毫秒;脚本总体超时仍为30秒。" }, +]; diff --git a/src/egern/entry/request.mjs b/src/egern/entry/request.mjs new file mode 100644 index 00000000..1b9e069e --- /dev/null +++ b/src/egern/entry/request.mjs @@ -0,0 +1,43 @@ +import { configuration } from "./index.mjs"; +import { webFetch } from "egern:runtime"; +import WeatherAlerts from "../wk3/class/WeatherAlerts.mjs"; +import AirQualityScale from "../wk3/class/AirQualityScale.mjs"; +import { headerObject, responseHeaders } from "./runtime.mjs"; +import { isNativeAlertDetailsID, resolveNativeAlertDetails } from "../wk3/class/NativeAlertDetails.mjs"; + +export async function handleRequest(ctx) { + const url = new URL(ctx.request.url); + if (url.hostname !== "weatherkit.apple.com") return; + if (/^\/api\/v[12]\/airQualityScale\//.test(url.pathname)) { + // Keep the existing Apple-first behavior while moving this responsibility + // to the upstream request category. Native ctx.http does not re-enter scripts. + try { + const upstream = await webFetch(url.toString(), { headers: headerObject(ctx.request.headers) }); + if (upstream.status === 200) return ctx.respond({ status: 200, headers: responseHeaders(upstream.headers), body: await upstream.text() }); + const parts = url.pathname.split("/").filter(Boolean); + const fallback = AirQualityScale.buildScale(parts[3] ?? "en", parts[4] ?? ""); + if (fallback) return ctx.respond(fallback); + return ctx.respond({ status: upstream.status, headers: responseHeaders(upstream.headers), body: await upstream.text() }); + } catch { + // A failed script-side request lets the ordinary Apple request proceed. + return; + } + } + if (!/^\/api\/v[12]\/weatherAlerts$/.test(url.pathname)) return; + const { Settings } = configuration(ctx); + if (!Settings.DataSets.includes("weatherAlerts")) return; + const ids = url.searchParams.get("ids") ?? ""; + const language = url.searchParams.get("lang") ?? "zh-CN"; + const native = isNativeAlertDetailsID(ids); + if (!native && !/^[a-zA-Z0-9][a-zA-Z0-9_-]*-\d{9}$/.test(ids)) return; + try { + const alerts = native + ? await resolveNativeAlertDetails(ids, language, webFetch) + : await WeatherAlerts.DetailsForQWeather(ids, language, webFetch); + return ctx.respond({ status: 200, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, body: JSON.stringify(alerts) }); + } catch { + // Reserved locators must never be forwarded to Apple; region IDs retain + // the cloud route's pass-through behavior on public provider failure. + if (native) return ctx.respond({ status: 200, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }, body: "[]" }); + } +} diff --git a/src/egern/entry/response.mjs b/src/egern/entry/response.mjs new file mode 100644 index 00000000..a1d86635 --- /dev/null +++ b/src/egern/entry/response.mjs @@ -0,0 +1,67 @@ +import { configuration } from "./index.mjs"; +import { headerObject, responseHeaders } from "./runtime.mjs"; +import { Response as processResponse } from "../wk3/process/Response.mjs"; +import parseWeatherKitURL from "../wk3/function/parseWeatherKitURL.mjs"; +import shouldReplaceWeatherKit from "../wk3/function/shouldReplaceWeatherKit.mjs"; +import ColorfulClouds from "../wk3/class/ColorfulClouds.mjs"; +import QWeather from "../wk3/class/QWeather.mjs"; + +async function processingContext(ctx, url, Settings, Configs) { + const parsed = parseWeatherKitURL(url); + const parameters = { ...parsed, dataSets: parsed.dataSets.filter(name => Settings.DataSets.includes(name)) }; + const enviroments = { + colorfulClouds: new ColorfulClouds(parameters, Settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), + qWeather: new QWeather(parameters, Settings.API?.QWeather?.Token, Settings.API?.QWeather?.Host), + parameters, country: parameters.country, + }; + const preFetched = {}; + if (shouldReplaceWeatherKit(parameters, Settings)) { + const attempt = promise => Promise.resolve(promise).catch(() => undefined); + if (parameters.dataSets.includes("forecastNextHour") && Settings.NextHour.Provider !== "WeatherKit") { + preFetched.forecastNextHour = attempt(Settings.NextHour.Provider === "QWeather" ? enviroments.qWeather.Minutely() : enviroments.colorfulClouds.Minutely()); + } + if (parameters.dataSets.includes("airQuality")) { + const indexProvider = Settings.AirQuality.Current.Index.Provider; + if (indexProvider === "QWeather") preFetched.index = attempt(enviroments.qWeather.CurrentAirQuality(Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants)); + else if (indexProvider === "ColorfulCloudsUS" || indexProvider === "ColorfulCloudsCN") preFetched.index = attempt(enviroments.colorfulClouds.CurrentAirQuality(indexProvider === "ColorfulCloudsUS", Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants)); + const yesterdayProvider = Settings.AirQuality.Comparison.Yesterday.IndexProvider; + if (yesterdayProvider === "QWeather") { + const cacheKey = "weatherkit-local.qweather-grid"; + const cache = ctx.storage?.getJSON(cacheKey); + const grid = await QWeather.GetLocationsGrid(cache, value => ctx.storage?.setJSON(cacheKey, value)); + preFetched.locationsGrid = grid; + const locationInfo = QWeather.GetLocationInfo(grid, parameters.latitude, parameters.longitude); + preFetched.yesterdayAirQuality = attempt(enviroments.qWeather.prefetchYesterdayAirQuality(locationInfo).then(() => enviroments.qWeather.YesterdayAirQuality(locationInfo))); + } else if (yesterdayProvider === "ColorfulCloudsUS" || yesterdayProvider === "ColorfulCloudsCN") { + preFetched.yesterdayAirQuality = attempt(enviroments.colorfulClouds.prefetchYesterdayHourly().then(() => enviroments.colorfulClouds.YesterdayAirQuality(yesterdayProvider === "ColorfulCloudsUS"))); + } + } + } + return { Settings, Configs, parameters, enviroments, preFetched }; +} + +export async function handleResponse(ctx) { + const url = new URL(ctx.request.url); + if (url.hostname !== "weatherkit.apple.com") return; + if (/^\/api\/v[12]\/availability\//.test(url.pathname)) { + const { Configs } = configuration(ctx); + return { status: 200, headers: responseHeaders(ctx.response.headers, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }), body: JSON.stringify(Configs?.Availability?.v2 ?? []) }; + } + if (!/^\/api\/(?:v[23]\/weather\/|v[12]\/airQualityScale\/)/.test(url.pathname)) return; + const headers = headerObject(ctx.response.headers); + const binary = /flatbuffer|protobuf|octet-stream/i.test(headers["content-type"] ?? ""); + const originalBody = binary ? new Uint8Array(await ctx.response.arrayBuffer()) : await ctx.response.text(); + const originalStatus = ctx.response.status; + try { + const { Settings, Configs } = configuration(ctx); + const context = await processingContext(ctx, url, Settings, Configs); + const request = { url: url.toString(), method: ctx.request.method, headers: headerObject(ctx.request.headers) }; + // Binary is kept in body, not bodyBytes: Response writes its patch to body. + const response = { status: originalStatus, headers, body: originalBody }; + const processed = await processResponse(request, response, context); + return { status: processed.status ?? originalStatus, headers: responseHeaders(ctx.response.headers, processed.headers), body: processed.body ?? originalBody }; + } catch { + // The input stream was consumed; explicitly restore its original bytes. + return { status: originalStatus, headers: responseHeaders(ctx.response.headers, headers), body: originalBody }; + } +} diff --git a/src/egern/entry/runtime.mjs b/src/egern/entry/runtime.mjs new file mode 100644 index 00000000..6b8542aa --- /dev/null +++ b/src/egern/entry/runtime.mjs @@ -0,0 +1,68 @@ +// Egern native API only. A fresh runtime is bound to each bundled invocation. +export function headerObject(headers = {}) { + const result = {}; + const add = (name, value) => { + if (value !== undefined && value !== null) result[String(name).toLowerCase()] = value; + }; + if (typeof headers.forEach === "function") headers.forEach((value, name) => add(name, value)); + else if (typeof headers.entries === "function") for (const [name, value] of headers.entries()) add(name, value); + else for (const [name, value] of Object.entries(headers)) if (typeof value !== "function") add(name, value); + // Egern documents get/property access, but does not promise iteration. + for (const name of ["content-type", "content-encoding", "etag", "user-agent", "x-schema-version"]) { + add(name, typeof headers.get === "function" ? headers.get(name) : headers[name]); + } + return result; +} + +export function responseHeaders(original = {}, updates = {}) { + const removable = new Set(["content-length", "transfer-encoding", "content-encoding", "etag", "content-md5"]); + if (typeof original.set === "function" && typeof original.delete === "function") { + for (const [name, value] of Object.entries(updates)) { + if (!removable.has(name.toLowerCase()) && value !== undefined) original.set(name, value); + } + for (const name of removable) original.delete(name); + return original; + } + const headers = { ...headerObject(original), ...headerObject(updates) }; + for (const name of removable) delete headers[name]; + return headers; +} + +export function createRuntime(ctx) { + const configuredTimeout = Number(ctx.env?.HTTP_TIMEOUT_MS); + const defaultTimeout = configuredTimeout > 0 ? configuredTimeout : 8000; + async function send(resource, options = {}, legacy = false) { + const request = typeof resource === "string" || resource instanceof URL + ? { url: String(resource), ...options } : { ...options, ...resource }; + const method = (request.method || (request.body !== undefined || request.bodyBytes !== undefined ? "POST" : "GET")).toLowerCase(); + const headers = headerObject(request.headers); + for (const name of ["host", ":authority", "content-length"]) delete headers[name]; + let timeout = Number(request.timeout) || defaultTimeout; + if (legacy && request.timeout && timeout <= 500) timeout *= 1000; + const nativeOptions = { headers, timeout, credentials: request.credentials ?? "omit" }; + for (const name of ["policy", "policyDescriptor", "redirect", "insecureTls"]) { + if (request[name] !== undefined) nativeOptions[name] = request[name]; + } + if (method !== "get" && method !== "head") { + const body = request.bodyBytes ?? request.body; + if (body !== undefined && body !== null) nativeOptions.body = body instanceof ArrayBuffer ? new Uint8Array(body) : body; + } + if (typeof ctx.http?.[method] !== "function") throw new TypeError(`Unsupported Egern HTTP method: ${method}`); + return ctx.http[method](request.url, nativeOptions); + } + return { + webFetch: (resource, options) => send(resource, options), + async fetch(resource, options) { + const response = await send(resource, options, true); + const headers = headerObject(response.headers); + const status = response.status; + const binary = /flatbuffer|protobuf|octet-stream/i.test(headers["content-type"] ?? ""); + return { + ok: status >= 200 && status < 300, status, statusCode: status, + statusText: response.statusText ?? "", headers, + body: binary ? "" : await response.text(), + bodyBytes: binary ? await response.arrayBuffer() : undefined, + }; + }, + }; +} From fc5f0a550eea6666f2823f85bdd87ac5d44ab5f3 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:42:27 +0800 Subject: [PATCH 07/42] =?UTF-8?q?=E6=9E=84=E5=BB=BA=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E4=BD=BF=E7=94=A8=E4=B8=8A=E6=B8=B8=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=20Rollup=20=E5=B7=A5=E5=85=B7=E6=89=93=E5=8C=85=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-egern.mjs | 72 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/build-egern.mjs diff --git a/scripts/build-egern.mjs b/scripts/build-egern.mjs new file mode 100644 index 00000000..59fbc4fa --- /dev/null +++ b/scripts/build-egern.mjs @@ -0,0 +1,72 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; +import { rollup } from "rollup"; +import nodeResolve from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const runtimePath = path.join(root, "src/egern/entry/runtime.mjs"); +const replacements = { + "console": 'const levels={OFF:0,ERROR:1,WARN:2,INFO:3,DEBUG:4,ALL:5};let settings={};try{settings=JSON.parse(ctx.env?.SETTINGS||"{}")}catch{}const level=levels[ctx.env?.LogLevel??settings.LogLevel??"INFO"]??3;const fmt=a=>a.map(x=>typeof x==="object"&&x!==null?JSON.stringify(x,null,2):x);export const Console=Object.fromEntries([["debug",4],["info",3],["warn",2],["error",1],["log",1],["exception",1]].map(([k,n])=>[k,(...a)=>{if(level>=n)console[k==="exception"?"error":k](...fmt(a))}]));', +}; + +function egernPlugin() { + const virtual = new Map([ + ["egern:runtime", "export const webFetch = __egernRuntime.webFetch;"], + [path.join(root, "src/egern/wk3/utils/context.mjs"), "export const requestContext = undefined;"], + [path.join(root, "src/egern/wk3/utils/fetch.mjs"), "export const fetch = __egernRuntime.fetch;"], + [path.join(root, "src/egern/wk3/utils/Console.mjs"), replacements.console], + ]); + return { + name: "egern-native-runtime", + resolveId(source, importer) { + if (source === "egern:runtime") return source; + if (!importer || !/(?:^|\/)\b(?:Console|context|fetch)\.mjs$/.test(source)) return null; + const resolved = path.resolve(path.dirname(importer), source); + return virtual.has(resolved) ? resolved : null; + }, + load(id) { + if (id === "egern:runtime") return virtual.get(id); + return virtual.get(id) ?? null; + }, + transform(code) { + return { code: code.replace(/\bglobalThis\.fetch\b/g, "__egernRuntime.webFetch"), map: null }; + }, + }; +} + +async function bundle(input, name, minify) { + const plugins = [egernPlugin(), nodeResolve({ browser: true, preferBuiltins: false })]; + if (minify) plugins.push(terser({ maxWorkers: 1 })); + const build = await rollup({ input, plugins, onwarn(warning, warn) { warn(warning); } }); + try { + const generated = await build.generate({ format: "iife", name, inlineDynamicImports: true, sourcemap: false }); + return generated.output.find(file => file.type === "chunk").code; + } finally { + await build.close(); + } +} + +export async function buildEgern({ outdir, minify = true } = {}) { + const runtime = await bundle(runtimePath, "__egernSupport", minify); + const results = {}; + for (const [name, handler] of [["request", "handleRequest"], ["response", "handleResponse"]]) { + const input = path.join(root, `src/egern/entry/${name}.mjs`); + const business = await bundle(input, "__egernBusiness", minify); + const code = `// Generated for Egern native ctx API. No runtime imports.\nexport default async function(ctx) {\n${runtime}\nconst __egernRuntime = __egernSupport.createRuntime(ctx);\n${business}\nreturn __egernBusiness.${handler}(ctx);\n}\n`; + const inputs = [input]; + results[name] = { code, bytes: Buffer.byteLength(code), inputs }; + if (outdir) { + await mkdir(outdir, { recursive: true }); + await writeFile(path.join(outdir, `${name}.bundle.js`), code); + } + } + return results; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const outdir = path.resolve(process.argv[2] || path.join(root, "dist/egern")); + const results = await buildEgern({ outdir }); + for (const [name, result] of Object.entries(results)) console.log(`${path.join(outdir, `${name}.bundle.js`)} ${result.bytes} bytes`); +} From 48a7af764bc7dbf9625646283405e5e7d96ee9fd Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:43:13 +0800 Subject: [PATCH 08/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E7=94=9F=E6=88=90=E4=BA=94=E7=B1=BB=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=8F=8A=E5=8F=82=E6=95=B0=E5=8C=96=E6=9C=AC=E6=9C=BA=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/iRingo.WeatherKit.WK3.yaml | 261 +++++++++++++++++++++++++++++ scripts/build-egern-module.mjs | 28 ++++ template/egern.wk3.handlebars | 100 +++++++++++ 3 files changed, 389 insertions(+) create mode 100644 modules/iRingo.WeatherKit.WK3.yaml create mode 100644 scripts/build-egern-module.mjs create mode 100644 template/egern.wk3.handlebars diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml new file mode 100644 index 00000000..40afb959 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -0,0 +1,261 @@ +# Egern local execution; configurable WK3 module +name: 'WeatherKit-Local-WK3' +description: 'Egern 本机处理:模块内调整参数;默认彩云美标、分钟降水、同源昨日对比、苹果原生预警' +author: meme +homepage: https://github.com/meme-lau/weatherkit-proxy +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +dns: {} +rules: +- and: + match: + - domain_suffix: + match: weatherkit.apple.com + - protocol: + match: QUIC + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - ip_cidr: + match: 139.178.128.0/18 + no_resolve: true + - ip_cidr: + match: 144.178.0.0/19 + no_resolve: true + - ip_cidr: + match: 144.178.36.0/22 + no_resolve: true + - ip_cidr: + match: 144.178.48.0/20 + no_resolve: true + - ip_cidr: + match: 17.0.0.0/8 + no_resolve: true + - ip_cidr: + match: 192.35.50.0/24 + no_resolve: true + - ip_cidr: + match: 198.183.17.0/24 + no_resolve: true + - ip_cidr: + match: 205.180.175.0/24 + no_resolve: true + - ip_cidr6: + match: 2403:300::/32 + no_resolve: true + - ip_cidr6: + match: 2620:149::/32 + no_resolve: true + - ip_cidr6: + match: 2a01:b740::/32 + no_resolve: true + - ip_cidr: + match: 63.92.224.0/19 + no_resolve: true + - ip_cidr: + match: 65.199.22.0/23 + no_resolve: true + - protocol: + match: UDP + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - asn: + match: '714' + no_resolve: true + - asn: + match: '6185' + no_resolve: true + - protocol: + match: QUIC + policy: REJECT-DROP +- domain_suffix: + match: weatherkit.apple.com + policy: DIRECT +- domain: + match: weather-analytics-events.apple.com + policy: REJECT-DROP +- domain_suffix: + match: tthr.apple.com + policy: REJECT-DROP +- domain: + match: tether.edge.apple + policy: REJECT-DROP +compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_CN","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} +compat_arguments_desc: "DataSets: [数据集] 解析与处理范围\n 默认 airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges;逗号分隔;填 [] 全部透传。WK3 实际支持空气质量、分钟降水与预警,其余保持苹果数据。\nWeather.Replace: [天气] 替换范围\n 默认 CN;逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。\nWeather.Provider: [天气] 数据源\n 默认 ColorfulClouds;WK2 天气数据源;WK3 当前/逐日/逐小时天气仍透传。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n 默认 false;用于 WK2;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n 默认 false;用于 WK2;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n 默认 QWeatherWeb;QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。\nNextHour.Provider: [分钟降水] 数据源\n 默认 ColorfulClouds;WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 默认 ColorfulCloudsUS;默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换范围(原始标准)\n 默认 HJ6332012;逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制国标主要污染物\n 默认 true;沿用原项目:国标指数≤50时仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 默认 ColorfulClouds;用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。\nAirQuality.Current.Pollutants.Units.Replace: [污染物单位] 替换原标准\n 默认 [];默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。\nAirQuality.Current.Pollutants.Units.Mode: [污染物单位] 转换模式\n 默认 Scale;Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [昨日对比] 今日变化时重算\n 默认 true;WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 默认 ColorfulCloudsUS;默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 默认 ColorfulCloudsCN;原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。\nAirQuality.Calculate.Algorithm: [内置算法] 计算公式\n 默认 WAQI_InstantCast_CN;沿用当前默认(当前直接彩云美标时不使用此算法)。WK3 仅支持已验证的目标标准,其他目标透传。\nAirQuality.Calculate.AllowOverRange: [内置算法] 允许指数超标\n 默认 true;原项目计算开关;WK3 完整美标写入仍校验指数0–500。\nAPI.ColorfulClouds.Token: [API] 彩云令牌\n 默认 留空;留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。\nAPI.QWeather.Host: [API] 和风主机\n 默认 devapi.qweather.com;使用和风 API 数据源时填写控制台主机名。\nAPI.QWeather.Token: [API] 和风令牌\n 默认 留空;使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO;默认 INFO,与当前脚本一致;只控制本机日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认 8000;默认8000毫秒;脚本总体超时仍为30秒。" +env_schema: {"DataSets":{"name":"[数据集] 解析与处理范围","description":"逗号分隔;填 [] 全部透传。WK3 实际支持空气质量、分钟降水与预警,其余保持苹果数据。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"WK2 天气数据源;WK3 当前/逐日/逐小时天气仍透传。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"用于 WK2;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"用于 WK2;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。","default_value":"QWeatherWeb","options":["QWeatherWeb","WeatherKit"]},"NextHour.Provider":{"name":"[分钟降水] 数据源","description":"WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换范围(原始标准)","description":"逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制国标主要污染物","description":"沿用原项目:国标指数≤50时仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[污染物单位] 替换原标准","description":"默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[污染物单位] 转换模式","description":"Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[昨日对比] 今日变化时重算","description":"WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[内置算法] 计算公式","description":"沿用当前默认(当前直接彩云美标时不使用此算法)。WK3 仅支持已验证的目标标准,其他目标透传。","default_value":"WAQI_InstantCast_CN","options":["None","WAQI_InstantCast_CN","WAQI_InstantCast_CN_25_DRAFT","WAQI_InstantCast_US","EU_EAQI","UBA"]},"AirQuality.Calculate.AllowOverRange":{"name":"[内置算法] 允许指数超标","description":"原项目计算开关;WK3 完整美标写入仍校验指数0–500。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云令牌","description":"留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风主机","description":"使用和风 API 数据源时填写控制台主机名。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风令牌","description":"使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO,与当前脚本一致;只控制本机日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} +scriptings: +- http_response: + name: WeatherKit.api.availability.response + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/availability/' + script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-response.js?v=upstream-groups-1 + timeout: 30 + body_required: true + binary_body: false + max_size: 2097152 + update_interval: 86400 +- http_request: + name: WeatherKit.api.airQualityScale.request + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/airQualityScale/' + script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 + timeout: 30 + body_required: false + binary_body: false + update_interval: 86400 +- http_request: + name: WeatherKit.api.weatherAlerts.html.request + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' + script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 + timeout: 30 + body_required: false + binary_body: false + update_interval: 86400 +- http_request: + name: WeatherKit.api.weatherAlerts.api.request + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)' + script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 + timeout: 30 + body_required: false + binary_body: false + update_interval: 86400 +- http_response: + name: WeatherKit.api.weather.response + env: + DataSets: '{{{DataSets}}}' + Weather.Replace: '{{{Weather.Replace}}}' + Weather.Provider: '{{{Weather.Provider}}}' + Weather.ReplaceDaily: '{{{Weather.ReplaceDaily}}}' + Weather.ReplaceHourly: '{{{Weather.ReplaceHourly}}}' + WeatherAlerts.Provider: '{{{WeatherAlerts.Provider}}}' + NextHour.Provider: '{{{NextHour.Provider}}}' + AirQuality.Current.Index.Provider: '{{{AirQuality.Current.Index.Provider}}}' + AirQuality.Current.Index.Replace: '{{{AirQuality.Current.Index.Replace}}}' + AirQuality.Current.Index.ForceCNPrimaryPollutants: '{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}' + AirQuality.Current.Pollutants.Provider: '{{{AirQuality.Current.Pollutants.Provider}}}' + AirQuality.Current.Pollutants.Units.Replace: '{{{AirQuality.Current.Pollutants.Units.Replace}}}' + AirQuality.Current.Pollutants.Units.Mode: '{{{AirQuality.Current.Pollutants.Units.Mode}}}' + AirQuality.Comparison.ReplaceWhenCurrentChange: '{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}' + AirQuality.Comparison.Yesterday.IndexProvider: '{{{AirQuality.Comparison.Yesterday.IndexProvider}}}' + AirQuality.Comparison.Yesterday.PollutantsProvider: '{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}' + AirQuality.Calculate.Algorithm: '{{{AirQuality.Calculate.Algorithm}}}' + AirQuality.Calculate.AllowOverRange: '{{{AirQuality.Calculate.AllowOverRange}}}' + API.ColorfulClouds.Token: '{{{API.ColorfulClouds.Token}}}' + API.QWeather.Host: '{{{API.QWeather.Host}}}' + API.QWeather.Token: '{{{API.QWeather.Token}}}' + LogLevel: '{{{LogLevel}}}' + HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' + match: '^https://weatherkit\.apple\.com/api/v[23]/weather/' + script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-response.js?v=upstream-groups-1 + timeout: 30 + body_required: true + binary_body: true + max_size: 2097152 + update_interval: 86400 + +mitm: + hostnames: + includes: + - weatherkit.apple.com diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs new file mode 100644 index 00000000..01951865 --- /dev/null +++ b/scripts/build-egern-module.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {localParameters} from '../src/egern/entry/parameters.mjs'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +export const localScriptGroups = [ + { type:'http_response', name:'WeatherKit.api.availability.response', match:'^https://weatherkit\\.apple\\.com/api/v[12]/availability/', body:true, binary:false, file:'response' }, + { type:'http_request', name:'WeatherKit.api.airQualityScale.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/airQualityScale/', body:false, binary:false, file:'request' }, + { type:'http_request', name:'WeatherKit.api.weatherAlerts.html.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/weatherAlerts\\?(?:[^#]*&)?ids=[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)', body:false, binary:false, file:'request' }, + { type:'http_request', name:'WeatherKit.api.weatherAlerts.api.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/weatherAlerts\\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\\.[0-9]+)?,-?[0-9]+(?:\\.[0-9]+)?)(?:&|$)', body:false, binary:false, file:'request' }, + { type:'http_response', name:'WeatherKit.api.weather.response', match:'^https://weatherkit\\.apple\\.com/api/v[23]/weather/', body:true, binary:true, file:'response' }, +]; +export function renderLocalModule(){ + const template=fs.readFileSync(path.join(root,'template/egern.wk3.handlebars'),'utf8'); + const q=JSON.stringify; + const compat=Object.fromEntries(localParameters.map(p=>[p.key,p.value])); + const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:p.description,default_value:p.value,...(p.boolean?{options:['true','false']}:p.options?{options:p.options}:{})}])); + const description=localParameters.map(p=>`${p.key}: ${p.name}\n 默认 ${p.value||'留空'};${p.description}`).join('\n'); + // JSON objects are valid YAML. Quote individual substitution values using + // YAML single quotes: literal double quotes in array syntax remain safe. + let env=' env:\n';for(const p of localParameters)env+=` ${p.key}: '{{{${p.key}}}}'\n`; + const base='https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw'; + const scripts=localScriptGroups.map(g=>`- ${g.type}:\n name: ${g.name}\n${env} match: '${g.match}'\n script_url: ${base}/weatherkit-local-${g.file}.js?v=upstream-groups-1\n timeout: 30\n body_required: ${g.body}\n binary_body: ${g.binary}\n${g.body?' max_size: 2097152\n':''} update_interval: 86400\n`).join(''); + return template.replace('__WK3_COMPAT_ARGUMENTS__',()=>q(compat)).replace('__WK3_ARGUMENT_DESCRIPTION__',()=>q(description)).replace('__WK3_ENV_SCHEMA__',()=>q(schema)).replace('__WK3_SCRIPTINGS__',()=>scripts.trimEnd()); +} +if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ + const dest=process.argv[2]||path.join(root,'modules/iRingo.WeatherKit.WK3.yaml');fs.mkdirSync(path.dirname(dest),{recursive:true});const text=renderLocalModule();fs.writeFileSync(dest,text);console.log(dest,Buffer.byteLength(text),'bytes',localParameters.length,'parameters'); +} diff --git a/template/egern.wk3.handlebars b/template/egern.wk3.handlebars new file mode 100644 index 00000000..455e1227 --- /dev/null +++ b/template/egern.wk3.handlebars @@ -0,0 +1,100 @@ +# Egern local execution; configurable WK3 module +name: 'WeatherKit-Local-WK3' +description: 'Egern 本机处理:模块内调整参数;默认彩云美标、分钟降水、同源昨日对比、苹果原生预警' +author: meme +homepage: https://github.com/meme-lau/weatherkit-proxy +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +dns: {} +rules: +- and: + match: + - domain_suffix: + match: weatherkit.apple.com + - protocol: + match: QUIC + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - ip_cidr: + match: 139.178.128.0/18 + no_resolve: true + - ip_cidr: + match: 144.178.0.0/19 + no_resolve: true + - ip_cidr: + match: 144.178.36.0/22 + no_resolve: true + - ip_cidr: + match: 144.178.48.0/20 + no_resolve: true + - ip_cidr: + match: 17.0.0.0/8 + no_resolve: true + - ip_cidr: + match: 192.35.50.0/24 + no_resolve: true + - ip_cidr: + match: 198.183.17.0/24 + no_resolve: true + - ip_cidr: + match: 205.180.175.0/24 + no_resolve: true + - ip_cidr6: + match: 2403:300::/32 + no_resolve: true + - ip_cidr6: + match: 2620:149::/32 + no_resolve: true + - ip_cidr6: + match: 2a01:b740::/32 + no_resolve: true + - ip_cidr: + match: 63.92.224.0/19 + no_resolve: true + - ip_cidr: + match: 65.199.22.0/23 + no_resolve: true + - protocol: + match: UDP + - dest_port: + match: '443' + policy: REJECT-NO-DROP +- and: + match: + - or: + match: + - asn: + match: '714' + no_resolve: true + - asn: + match: '6185' + no_resolve: true + - protocol: + match: QUIC + policy: REJECT-DROP +- domain_suffix: + match: weatherkit.apple.com + policy: DIRECT +- domain: + match: weather-analytics-events.apple.com + policy: REJECT-DROP +- domain_suffix: + match: tthr.apple.com + policy: REJECT-DROP +- domain: + match: tether.edge.apple + policy: REJECT-DROP +compat_arguments: __WK3_COMPAT_ARGUMENTS__ +compat_arguments_desc: __WK3_ARGUMENT_DESCRIPTION__ +env_schema: __WK3_ENV_SCHEMA__ +scriptings: +__WK3_SCRIPTINGS__ + +mitm: + hostnames: + includes: + - weatherkit.apple.com From 3cfbba6392ee9793cfa6c2584ca9c04e8b43505e Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:44:55 +0800 Subject: [PATCH 09/42] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E8=A6=86=E7=9B=96=20WK3=20=E6=9C=AC=E6=9C=BA=E5=A4=84?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E6=A8=A1=E5=9D=97=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/egern-wk3/egern-local.test.mjs | 169 +++++++++++++++++ tests/egern-wk3/egern-parameters.test.mjs | 29 +++ tests/egern-wk3/native-alert-details.test.mjs | 167 +++++++++++++++++ tests/egern-wk3/wk2-selective.test.mjs | 171 ++++++++++++++++++ tests/egern-wk3/wk3-alerts.test.mjs | 114 ++++++++++++ tests/egern-wk3/wk3-codec.test.mjs | 73 ++++++++ tests/egern-wk3/wk3-comparison.test.mjs | 141 +++++++++++++++ .../wk3-features-integration.test.mjs | 20 ++ tests/egern-wk3/wk3-hk-aq.test.mjs | 15 ++ tests/egern-wk3/wk3-next-hour.test.mjs | 112 ++++++++++++ 10 files changed, 1011 insertions(+) create mode 100644 tests/egern-wk3/egern-local.test.mjs create mode 100644 tests/egern-wk3/egern-parameters.test.mjs create mode 100644 tests/egern-wk3/native-alert-details.test.mjs create mode 100644 tests/egern-wk3/wk2-selective.test.mjs create mode 100644 tests/egern-wk3/wk3-alerts.test.mjs create mode 100644 tests/egern-wk3/wk3-codec.test.mjs create mode 100644 tests/egern-wk3/wk3-comparison.test.mjs create mode 100644 tests/egern-wk3/wk3-features-integration.test.mjs create mode 100644 tests/egern-wk3/wk3-hk-aq.test.mjs create mode 100644 tests/egern-wk3/wk3-next-hour.test.mjs diff --git a/tests/egern-wk3/egern-local.test.mjs b/tests/egern-wk3/egern-local.test.mjs new file mode 100644 index 00000000..bf58d635 --- /dev/null +++ b/tests/egern-wk3/egern-local.test.mjs @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { buildEgern } from "../../scripts/build-egern.mjs"; +import { createRuntime } from "../../src/egern/entry/runtime.mjs"; +import { encodeAirQuality, decodeWK3DataSet } from "../../src/egern/wk3/class/WeatherKit3.mjs"; +import { decodeWK3NextHour } from "../../src/egern/wk3/class/WK3NextHour.mjs"; +import { decodeWK3Alerts } from "../../src/egern/wk3/class/WK3Alerts.mjs"; +import vm from "node:vm"; +import WeatherKit2 from "../../src/egern/wk3/class/WeatherKit2.mjs"; + +const bundles = await buildEgern(); +const load = async code => (await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`)).default; +const request = await load(bundles.request.code), response = await load(bundles.response.code); +const issued = 1893456000; +const html = '<div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00:00+08:00</p><p class="warning-events__txt">注意防范大风。</p></div>'; +const json = data => new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }); +const webResponse = bytes => new Response(bytes, { headers: { "content-type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather", "content-length": String(bytes.length), "content-encoding": "gzip", "x-keep": "original", etag: "old" } }); +const settings = extra => ({ SETTINGS: JSON.stringify({ DataSets: ["airQuality"], AirQuality: { Current: { Index: { Provider: "ColorfulCloudsUS", Replace: ["HJ6332012"] } }, Comparison: { Yesterday: { IndexProvider: "WeatherKit" } } }, ...extra }) }); +function context(bytes, datasets = "airQuality", extra = {}) { + return { env: settings(), request: { method: "GET", url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=${datasets}`, headers: new Headers({ authorization: "synthetic-apple", "x-schema-version": "lib-rc-3.0-b66" }) }, response: webResponse(bytes), http: { async get() { throw new Error("unexpected network"); } }, ...extra }; +} +function source() { + const b = new Builder(), aq = encodeAirQuality(b, { metadata: { sourceType: 3, providerName: "Synthetic Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); + const text = b.createString("preserve opaque future" ); b.startObject(1); b.addFieldOffset(0, text, 0); const opaque = b.endObject(); + b.startObject(15); b.addFieldOffset(0, aq, 0); b.addFieldOffset(14, opaque, 0); b.finish(b.endObject()); return b.asUint8Array(); +} +function provider(index = 75, yesterday = 125) { + return async function(url, options) { + assert.equal(options.timeout, 8000); assert.equal(options.credentials, "omit"); assert.equal(options.headers.authorization, undefined); + if (url.includes("/realtime")) return json({ status: "ok", location: [31, 121], result: { realtime: { status: "ok", air_quality: { description: { usa: "moderate" }, aqi: { usa: index, chn: 60 }, obs_time: issued, pm25: 12, pm10: 20, co: 0.4, no2: 10, so2: 5, o3: 40 } } } }); + if (url.includes("/hourly")) return json({ status: "ok", location: [31, 121], result: { server_time: issued, hourly: { status: "ok", air_quality: { aqi: [{ value: { usa: yesterday, chn: 90 } }] } } } }); + if (url.includes("/minutely")) return json({ status: "ok", server_time: 2000000040, location: [31, 121], result: { minutely: { status: "ok", description: "小雨", probability: [0.7, 0.7, 0.7, 0.7], precipitation_2h: Array(60).fill(1.5) } } }); + throw new Error(`Unexpected synthetic path: ${url}`); + }; +} + +test("bundles have one native ESM export and no cloud runtime, runtime imports or global mutation", () => { + for (const { code, inputs } of Object.values(bundles)) { + assert.match(code, /export default async function\(ctx\)/); + assert.doesNotMatch(code, /node:async_hooks|AsyncLocalStorage|caches\.default|waitUntil|globalThis\.fetch|\beval\(|new Function|\$done\(/); + assert.doesNotMatch(code, /\bimport\s*(?:\(|["'{*])/); + assert.ok(!inputs.some(path => /node_modules\/hono\/|src\/Hono\.js/.test(path))); + } +}); + +test("HTTP adapter respects native methods, body bytes, milliseconds and response shape", async () => { + const calls = []; + const runtime = createRuntime({ env: { HTTP_TIMEOUT_MS: "2500" }, http: { async post(url, options) { calls.push({ url, options }); return json({ ok: true }); }, async get() { return new Response(new Uint8Array([0, 255, 127]), { status: 200, headers: { "content-type": "application/octet-stream" } }); } } }); + const bytes = new Uint8Array([9, 0, 255, 8]).subarray(1, 3); + const result = await runtime.fetch({ url: "https://synthetic.invalid", bodyBytes: bytes, timeout: 4, headers: { Host: "wrong", "Content-Length": "99" } }); + assert.deepEqual(JSON.parse(result.body), { ok: true }); assert.equal(result.ok, true); assert.equal(result.statusCode, 200); + assert.equal(calls[0].options.timeout, 4000); assert.deepEqual(calls[0].options.body, bytes); assert.deepEqual(calls[0].options.headers, {}); + const binary = await runtime.fetch("https://synthetic.invalid/binary"); assert.deepEqual(new Uint8Array(binary.bodyBytes), new Uint8Array([0, 255, 127])); + assert.equal(binary.body, ""); +}); + +test("availability follows upstream response stage; UUID details and Apple native page pass through", async () => { + const ctx = { env: {}, request: { url: "https://weatherkit.apple.com/api/v2/availability/zh-Hans/31/121" }, response: new Response('["currentWeather"]', { headers: { 'content-type':'application/json' } }), http: {} }; + assert.equal(await request(ctx), undefined); + const reply = await response(ctx); assert.equal(reply.status, 200); assert.ok(JSON.parse(reply.body).includes("airQuality")); assert.ok(JSON.parse(reply.body).includes("forecastNextHour")); + for (const url of ["https://weatherkit.apple.com/alertDetails/index.html?ids=wk3_x", "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=00010203-0405-0607-0809-0a0b0c0d0e0f"]) assert.equal(await request({ ...ctx, request: { url } }), undefined); +}); + +test("binary response reuses real WK3 provider conversion and clears stale transport headers", async () => { + const bytes = source(), before = bytes.slice(); + const out = await response(context(bytes, "airQuality", { http: { get: provider(87) } })); + assert.ok(out.body instanceof Uint8Array); assert.equal(decodeWK3DataSet(out.body).index, 87); assert.equal(decodeWK3DataSet(out.body).scale, "EPA_NowCast.2604"); + assert.equal(out.headers.get("x-keep"), "original"); assert.equal(out.headers.get("content-length"), null); assert.equal(out.headers.get("content-encoding"), null); assert.equal(out.headers.get("etag"), null); + assert.deepEqual(bytes, before); +}); + +test("AQ, actual provider minutes and yesterday comparison patch together, preserving future arena", async () => { + const bytes = source(); + const env = settings({ DataSets: ["airQuality", "forecastNextHour"], NextHour: { Provider: "ColorfulClouds" }, AirQuality: { Current: { Index: { Provider: "ColorfulCloudsUS", Replace: ["HJ6332012"] } }, Comparison: { Yesterday: { IndexProvider: "ColorfulCloudsUS" } } } }); + const out = await response(context(bytes, "airQuality,forecastNextHour", { env, http: { get: provider() } })); + const aq = decodeWK3DataSet(out.body), rain = decodeWK3NextHour(out.body); + assert.equal(aq.index, 75); assert.equal(aq.previousDayComparison, 4); assert.equal(rain.minutes.length, 60); assert.equal(rain.minutes[0].precipitationIntensity, 1.5); + const bb = new ByteBuffer(out.body), root = bb.readUint32(0); assert.ok(bb.__offset(root, 32)); +}); + +test("same imported module keeps concurrent ctx HTTP and Settings isolated", async () => { + let enter, release; const entered = new Promise(resolve => { enter = resolve; }); const blocked = new Promise(resolve => { release = resolve; }); + const bytes = source(), calls = []; + const first = response(context(bytes, "airQuality", { http: { async get(url, options) { calls.push("first"); enter(); await blocked; return provider(75)(url, options); } } })); + await entered; + const disabled = await response(context(bytes, "airQuality", { env: { SETTINGS: '{"DataSets":[]}' } })); + const secondEnv = settings({ API: { ColorfulClouds: { Token: "synthetic-second" } } }); + const second = await response(context(bytes, "airQuality", { env: secondEnv, http: { async get(url, options) { assert.ok(url.includes("/synthetic-second/")); calls.push("second"); return provider(125)(url, options); } } })); + release(); const firstResult = await first; + assert.deepEqual(disabled.body, bytes); + assert.equal(decodeWK3DataSet(firstResult.body).index, 75); assert.equal(decodeWK3DataSet(second.body).index, 125); assert.deepEqual(calls, ["first", "second"]); + assert.equal(globalThis.fetch.name, "fetch"); +}); + +test("consumed binary input is restored on invalid config or provider failure; other paths untouched", async () => { + const bytes = source(); + for (const extra of [{ env: { SETTINGS: "{" } }, { http: { async get() { throw new Error("offline"); } } }]) { + const out = await response(context(bytes, "airQuality", extra)); assert.deepEqual(out.body, bytes); assert.equal(out.status, 200); + } + let read = false; + const unrelated = { request: { url: "https://weatherkit.apple.com/alertDetails/index.html" }, response: { arrayBuffer() { read = true; } } }; + assert.equal(await response(unrelated), undefined); assert.equal(read, false); +}); + +function alertSource() { + const b = new Builder(), details = b.createString("https://www.qweather.com/severe-weather/test-101010100.html?from=AppleWeatherService"), title = b.createString("大风"), source = b.createString("国家预警信息发布中心"); + b.startVector(1, 16, 1); for (let i = 15; i >= 0; i--) b.addInt8(i); const idbytes = b.endVector(); b.startObject(1); b.addFieldOffset(0, idbytes, 0); const id = b.endObject(); + b.startObject(22); b.addFieldOffset(0, id, 0); b.addFieldOffset(5, title, 0); b.addFieldOffset(16, source, 0); b.addFieldOffset(12, details, 0); b.addFieldInt8(14, 4, 0); b.addFieldInt32(9, issued, 0); const alert = b.endObject(); + b.startVector(4, 1, 4); b.addOffset(alert); const alerts = b.endVector(); b.startObject(4); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); const collection = b.endObject(); b.startObject(18); b.addFieldOffset(9, collection, 0); b.finish(b.endObject()); return b.asUint8Array(); +} + +test("unchanged Response default fetch binds to ctx; native Apple details complete with original UUID", async () => { + const calls = []; + const http = { async get(url, options) { calls.push({ url, options }); return new Response(html, { headers: { "content-type": "text/html" } }); } }; + const ctx = context(alertSource(), "weatherAlerts", { env: settings({ DataSets: ["weatherAlerts"] }), http }); + const out = await response(ctx), alerts = decodeWK3Alerts(out.body); + assert.equal(alerts.alerts[0].description, "大风蓝色预警"); + const details = new URL(alerts.detailsUrl); assert.equal(details.hostname, "weatherkit.apple.com"); assert.match(details.searchParams.get("ids"), /^wk3_/); + const reply = await request({ env: {}, request: { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?" + details.searchParams, headers: ctx.request.headers }, http, respond: value => value }); + const data = JSON.parse(reply.body); assert.equal(data.length, 1); assert.equal(data[0].id, "00010203-0405-0607-0809-0a0b0c0d0e0f"); assert.equal(data[0].detailsUrl, "#" + data[0].id); assert.match(data[0].messages[0].text, /注意防范/); + assert.equal(calls.length, 2); for (const call of calls) { assert.equal(call.options.headers.authorization, undefined); assert.equal(call.options.redirect, "error"); assert.equal(call.options.credentials, "omit"); } +}); + +test("malformed reserved native locator replies empty without upstream; disabled details pass through", async () => { + const ctx = { env: {}, request: { url: "https://weatherkit.apple.com/api/v2/weatherAlerts?ids=wk3_!" }, http: { async get() { throw new Error("must not fetch"); } }, respond: value => value }; + assert.equal((await request(ctx)).body, "[]"); + assert.equal(await request({ ...ctx, env: { SETTINGS: '{"DataSets":[]}' } }), undefined); +}); + +test("bundle runs in restricted host without Node, Fetch globals, ALS or Cloudflare globals", async () => { + const host = vm.createContext({ URL, TextEncoder, TextDecoder, atob, btoa, console: { log() {}, info() {}, debug() {}, warn() {}, error() {} } }); + const compiled = vm.runInContext(`(${bundles.response.code.replace("export default ", "")})`, host); + const ctx = context(source(), "airQuality", { http: { get: provider(75) } }); + const out = await compiled(ctx); + assert.equal(decodeWK3DataSet(out.body).index, 75); + for (const name of ["process", "Buffer", "fetch", "Request", "Response", "Headers", "caches", "AbortController", "setTimeout"]) assert.equal(vm.runInContext(`typeof ${name}`, host), "undefined"); +}); + +test("minimal official Headers methods preserve unknown headers and malformed body bytes", async () => { + const native = new Headers({ "content-type": "application/vnd.apple.flatbuffer", "x-unlisted": "keep", "content-encoding": "gzip", "content-length": "4" }); + const documentedHeaders = { get: name => native.get(name), set: (name, value) => native.set(name, value), delete: name => native.delete(name) }; + const bytes = new Uint8Array([255, 255, 255, 255]); + const ctx = context(bytes, "airQuality", { env: { SETTINGS: '{"DataSets":[]}' }, response: { status: 200, headers: documentedHeaders, async arrayBuffer() { return bytes.buffer; } } }); + const out = await response(ctx); assert.deepEqual(out.body, bytes); assert.equal(out.headers.get("x-unlisted"), "keep"); assert.equal(out.headers.get("content-length"), null); +}); + +test("airQualityScale fallback returns local JSON without third-party weather requests", async () => { + const ctx = context(new Uint8Array(), "", { request: { url: "https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EPA_NowCast.2604", headers: {} }, response: new Response("{}", { status: 404, headers: { "content-type": "application/json" } }) }); + const out = await response(ctx); assert.equal(out.status, 200); assert.equal(typeof out.body, "string"); assert.ok(JSON.parse(out.body)); +}); + +test("v2 selective codec and early iOS27 binary route run through native response adapter", async () => { + const bytes = WeatherKit2.encode(undefined, { airQuality: { metadata: { sourceType: "MODELED", providerName: "Synthetic" }, scale: "HJ6332012.2604", index: 52, categoryIndex: 2, pollutants: [{ pollutantType: "PM2_5", amount: 10, units: "MICROGRAMS_PER_CUBIC_METER" }], primaryPollutant: "PM2_5", previousDayComparison: "SAME" } }); + for (const ios27 of [false, true]) { + const ctx = context(bytes, "airQuality", { env: settings({ AirQuality: { Current: { Index: { Provider: "WeatherKit", Replace: [] }, Pollutants: { Provider: "WeatherKit" } }, Comparison: { ReplaceWhenCurrentChange: false, Yesterday: { IndexProvider: "WeatherKit" } } } }) }); + ctx.request.url = ctx.request.url.replace("/v3/", "/v2/"); if (ios27) ctx.request.headers.set("user-agent", "WeatherKit_Weather_iOS_Version 27.2"); + const out = await response(ctx); assert.equal(WeatherKit2.decode(new ByteBuffer(out.body), ["airQuality"]).airQuality.index, 52); + } +}); + +test('upstream scale request category preserves Apple-first result and local fallback',async()=>{ + const u='https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EPA_NowCast.2604'; + for(const status of [200,404]){ + let calls=0;const result=await request({env:{},request:{url:u,headers:{Authorization:'synthetic-apple'}},http:{async get(url,options){calls++;assert.equal(url,u);assert.equal(options.headers.authorization,'synthetic-apple');return new Response('{"name":"Apple original"}',{status,headers:{'content-type':'application/json','x-original':'keep'}})}},respond:x=>x}); + assert.equal(calls,1);assert.equal(result.status,200);const j=JSON.parse(result.body);if(status===200){assert.equal(j.name,'Apple original');assert.equal(result.headers.get('x-original'),'keep');}else assert.equal(j.name,'EPA_NowCast'); + } + let responded=false;assert.equal(await request({env:{},request:{url:u,headers:{}},http:{async get(){throw Error('offline')}},respond(){responded=true}}),undefined);assert.equal(responded,false); +}); diff --git a/tests/egern-wk3/egern-parameters.test.mjs b/tests/egern-wk3/egern-parameters.test.mjs new file mode 100644 index 00000000..34fc7672 --- /dev/null +++ b/tests/egern-wk3/egern-parameters.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm'; +import {configuration}from '../../src/egern/entry/index.mjs';import {localParameters}from '../../src/egern/entry/parameters.mjs';import {renderLocalModule,localScriptGroups}from '../../scripts/build-egern-module.mjs';import {buildEgern}from '../../scripts/build-egern.mjs'; +const defaults=()=>Object.fromEntries(localParameters.map(p=>[p.key,p.value])); +const value=(o,path)=>path.split('.').reduce((a,k)=>a?.[k],o); +test('all original restored keys reach Settings with current US/minute/yesterday defaults',()=>{ + const {Settings:s}=configuration({env:defaults()});assert.equal(s.AirQuality.Current.Index.Provider,'ColorfulCloudsUS');assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider,'ColorfulCloudsUS');assert.equal(s.NextHour.Provider,'ColorfulClouds');assert.equal(s.WeatherAlerts.Provider,'QWeatherWeb');assert.deepEqual(s.Weather.Replace,['CN']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,[]);assert.equal(s.Weather.ReplaceDaily,false);assert.equal(s.Weather.ReplaceHourly,false);assert.equal(s.AirQuality.Calculate.Algorithm,'WAQI_InstantCast_CN');assert.equal(s.API.QWeather.Host,'devapi.qweather.com');assert.equal(s.API.QWeather.Token,null); + for(const p of localParameters){if(p.key==='HTTP_TIMEOUT_MS')continue;assert.notEqual(value(s,p.key),undefined,p.key);} +}); +test('dotted edits override JSON defaults and list values do not turn into nested arrays',()=>{ + const s=configuration({env:{SETTINGS:JSON.stringify({AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS'}}}}),DataSets:'airQuality, forecastNextHour','Weather.Replace':'CN,HK','AirQuality.Current.Index.Provider':'ColorfulCloudsCN','AirQuality.Current.Index.Replace':'["HJ6332012","EPA_NowCast"]','AirQuality.Current.Pollutants.Units.Replace':'EPA_NowCast,UBA','Weather.ReplaceDaily':'true'}}).Settings; + assert.deepEqual(s.DataSets,['airQuality','forecastNextHour']);assert.deepEqual(s.Weather.Replace,['CN','HK']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012','EPA_NowCast']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,['EPA_NowCast','UBA']);assert.equal(s.AirQuality.Current.Index.Provider,'ColorfulCloudsCN');assert.equal(s.Weather.ReplaceDaily,true); +}); +test('explicit empty lists clear selections while malformed lists fail before processing',()=>{ + const s=configuration({env:{...defaults(),DataSets:'[]','Weather.Replace':'[]','AirQuality.Current.Index.Replace':'[]'}}).Settings;assert.deepEqual(s.DataSets,[]);assert.deepEqual(s.Weather.Replace,[]);assert.deepEqual(s.AirQuality.Current.Index.Replace,[]); + assert.throws(()=>configuration({env:{DataSets:'[1]'}}),/Invalid list/);assert.throws(()=>configuration({env:{DataSets:'['}})); +}); +test('WeatherKit alert source disables both request and response alert handling dataset',()=>{ + const s=configuration({env:{...defaults(),'WeatherAlerts.Provider':'WeatherKit'}}).Settings;assert.ok(!s.DataSets.includes('weatherAlerts'));assert.ok(s.DataSets.includes('forecastNextHour')); +}); +test('API numeric strings and comma values survive without coercion or accidental disclosure',()=>{ + const s=configuration({env:{...defaults(),'API.ColorfulClouds.Token':'001234','API.QWeather.Token':'example,synthetic','API.QWeather.Host':''}}).Settings;assert.equal(s.API.ColorfulClouds.Token,'001234');assert.equal(s.API.QWeather.Token,'example,synthetic');assert.equal(s.API.QWeather.Host,'devapi.qweather.com'); +}); +test('template declares every placeholder and binds identical parameters to both scripts',()=>{ + const m=renderLocalModule();const line=prefix=>JSON.parse(m.split('\n').find(s=>s.startsWith(prefix)).slice(prefix.length));const args=line('compat_arguments: '),schema=line('env_schema: ');assert.equal(Object.keys(args).length,localParameters.length);assert.deepEqual(Object.keys(args),Object.keys(schema));const refs=[...m.matchAll(/\{\{\{([^}]+)\}\}\}/g)].map(x=>x[1]);for(const p of localParameters){assert.equal(refs.filter(x=>x===p.key).length,localScriptGroups.length);assert.equal(args[p.key],p.value);assert.equal(schema[p.key].default_value,p.value);} + assert.ok(!m.includes('url_rewrites:')&&!m.includes('vercel.app'));assert.match(m,/binary_body: true/);assert.match(m,/hostnames:\n includes:/); +}); +test('actual built script obeys DataSets=[] and LogLevel OFF, preserving binary bytes with no network',async()=>{ + const {response}=await buildEgern();let logs=0,network=0;const host=vm.createContext({URL,TextEncoder,TextDecoder,atob,btoa,console:Object.fromEntries(['log','info','warn','error','debug'].map(k=>[k,()=>logs++]))});const handler=vm.runInContext('('+response.code.replace('export default ','')+')',host);const bytes=new Uint8Array([255,255,255,255]);const out=await handler({env:{...defaults(),DataSets:'[]',LogLevel:'OFF'},request:{url:'https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality',headers:{}},response:new Response(bytes,{headers:{'content-type':'application/vnd.apple.flatbuffer'}}),http:{get(){network++;throw Error('unexpected network')}}});assert.deepEqual(Array.from(out.body),Array.from(bytes));assert.equal(network,0);assert.equal(logs,0); +}); diff --git a/tests/egern-wk3/native-alert-details.test.mjs b/tests/egern-wk3/native-alert-details.test.mjs new file mode 100644 index 00000000..f9672ec8 --- /dev/null +++ b/tests/egern-wk3/native-alert-details.test.mjs @@ -0,0 +1,167 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import WeatherAlerts from "../../src/egern/wk3/class/WeatherAlerts.mjs"; +import { buildNativeAlertDetailsURL, resolveNativeAlertDetails, isNativeAlertDetailsID } from "../../src/egern/wk3/class/NativeAlertDetails.mjs"; + +// Synthetic public HTML and UUID bytes only; no Apple sessions or coordinates. +const identifier = "synthetic-101010100"; +const issuedTime = Date.parse("2030-01-01T00:00:00Z") / 1000; +const uuid = "00010203-0405-0607-0809-0a0b0c0d0e0f"; +function alert(description = "大风", seed = 0, extra = {}) { + return { id: { bytes: Array.from({ length: 16 }, (_, i) => i + seed) }, description, issuedTime, severity: 4, ...extra }; +} +function block(title = "大风蓝色预警", time = "2030-01-01T08:00:00+08:00", color = "blue") { + return `<div class="c-city-warning-events warning--${color}"><h3>测试区气象台发布${title}</h3><p>发布日期:${time}</p><p class="warning-events__txt">注意防范${title}。</p><div class="warning-defense__txt"><p>1. 停止高空作业。</p></div></div>`; +} +const htmlFetch = html => async () => new Response(html, { headers: { "content-type": "text/html" } }); +function idsFor(alerts = [alert()], options = {}) { + return new URL(buildNativeAlertDetailsURL({ identifier, alerts, ...options })).searchParams.get("ids"); +} +function encodePayload(payload) { + return "wk3_" + Buffer.from(JSON.stringify(payload)).toString("base64url"); +} +function readPayload(ids) { + return JSON.parse(Buffer.from(ids.slice(4), "base64url").toString("utf8")); +} +const validPayload = () => readPayload(idsFor()); + +test("native Apple page URL carries a minimal UTF-8 event mapping and optional timezone", () => { + const original = alert("大风蓝色预警 🌬", 0, { latitude: 1, apiKey: "excluded", source: "excluded" }); + const before = structuredClone(original); + const url = new URL(buildNativeAlertDetailsURL({ identifier, language: "zh-Hans", alerts: [original], timezone: "Asia/Shanghai" })); + assert.equal(url.origin, "https://weatherkit.apple.com"); + assert.equal(url.pathname, "/alertDetails/index.html"); + assert.equal(url.searchParams.get("party"), "qweather"); + assert.equal(url.searchParams.get("lang"), "zh-Hans"); + assert.equal(url.searchParams.get("timezone"), "Asia/Shanghai"); + const ids = url.searchParams.get("ids"); + assert.match(ids, /^wk3_[A-Za-z0-9_-]+$/); + assert.deepEqual(readPayload(ids), { region: identifier, alerts: [{ id: uuid, description: original.description, issuedTime, severity: 4 }] }); + assert.deepEqual(original, before); + assert.equal(new URL(buildNativeAlertDetailsURL({ identifier, alerts: [alert()] })).searchParams.has("timezone"), false); + assert.equal(isNativeAlertDetailsID("wk3_!"), true); + assert.equal(isNativeAlertDetailsID(identifier), false); + assert.equal(isNativeAlertDetailsID(null), false); +}); + +test("single event keeps every REST field, Apple UUID and fragment with matching message language", async () => { + const fetchImpl = htmlFetch(block()); + const source = await WeatherAlerts.DetailsForQWeather(identifier, "zh-Hans", fetchImpl); + const result = await resolveNativeAlertDetails(idsFor(), "zh-Hans", fetchImpl); + assert.notEqual(source[0].id, uuid); + assert.deepEqual(result, [{ ...source[0], id: uuid, detailsUrl: `#${uuid}` }]); + assert.equal(result[0].description, "大风蓝色预警"); + assert.equal(result[0].severity, "minor"); + assert.equal(result[0].effectiveTime, "2030-01-01T00:00:00.000Z"); + assert.equal(result[0].source, "测试区气象台"); + assert.ok(result[0].messages.length); + assert.ok(result[0].messages.every(message => message.language === "zh-Hans")); + assert.equal(result[0].name, "WeatherAlert"); + assert.equal(result[0].responses[0], "monitor"); +}); + +test("multiple events match by title and issuance across source reorder, excluding unrequested events", async () => { + const originals = [alert(), alert("暴雨蓝色预警", 16), alert("大风", 32, { issuedTime: issuedTime + 3600 })]; + const fetchImpl = htmlFetch(block("大风蓝色预警", "2030-01-01T09:00:00+08:00") + block("雷电蓝色预警") + block("暴雨蓝色预警") + block()); + const result = await resolveNativeAlertDetails(idsFor(originals), "zh-CN", fetchImpl); + assert.deepEqual(result.map(item => item.id), readPayload(idsFor(originals)).alerts.map(item => item.id)); + assert.deepEqual(result.map(item => item.description), ["大风蓝色预警", "暴雨蓝色预警", "大风蓝色预警"]); + assert.deepEqual(result.map(item => Date.parse(item.issuedTime) / 1000), [issuedTime, issuedTime, issuedTime + 3600]); + assert.ok(result.every(item => item.detailsUrl === `#${item.id}`)); +}); + +test("missing, expired, different issuance, unrelated substring and severity never fall back by index", async () => { + for (const html of ["", block("暴雨蓝色预警"), block("雷暴大风蓝色预警"), block("大风蓝色预警", "2030-01-01T08:01:00+08:00"), block("大风黄色预警", undefined, "yellow")]) { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", htmlFetch(html)), /Missing or ambiguous/); + } + await assert.rejects(resolveNativeAlertDetails(idsFor([alert("大风黄色预警")]), "zh-CN", htmlFetch(block())), /Missing or ambiguous/); + // A full title's exact event name is accepted; a prefix is not a match. + await assert.rejects(resolveNativeAlertDetails(idsFor([alert("风")]), "zh-CN", htmlFetch(block())), /Missing or ambiguous/); +}); + +test("ambiguity is atomic: duplicate source or two originals sharing a candidate fails", async () => { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", htmlFetch(block() + block())), /ambiguous/); + await assert.rejects(resolveNativeAlertDetails(idsFor([alert(), alert("大风蓝色预警", 16)]), "zh-CN", htmlFetch(block())), /Ambiguous shared/); + await assert.rejects(resolveNativeAlertDetails(idsFor([alert(), alert("暴雨", 16)]), "zh-CN", htmlFetch(block())), /Missing or ambiguous/); +}); + +test("source failures propagate and only fixed public QWeather requests receive fetch", async () => { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", async () => { throw new Error("offline"); }), /offline/); + for (const response of [new Response("no", { status: 503 }), new Response("{}", { headers: { "content-type": "application/json" } })]) { + await assert.rejects(resolveNativeAlertDetails(idsFor(), "zh-CN", async () => response)); + } + await resolveNativeAlertDetails(idsFor(), "en-US", async (url, options) => { + assert.equal(url, `https://www.qweather.com/en/severe-weather/${identifier}.html?from=AppleWeatherService`); + assert.deepEqual(options, { redirect: "error", headers: { Accept: "text/html" } }); + return htmlFetch(block())(); + }); +}); + +test("malformed payloads, SSRF regions and extra fields are rejected before any fetch", async () => { + let calls = 0; + const neverFetch = async () => { calls++; throw new Error("must not fetch"); }; + const malformed = [undefined, "", identifier, "wk3_", "wk3_!", "wk3_a", "wk3_e30=", "wk3__w", "wk3_" + "a".repeat(16384)]; + for (const mutate of [ + p => { p.region = "https://evil.invalid/a-101010100"; }, + p => { p.region = "../synthetic-101010100"; }, + p => { p.region = "synthetic-123"; }, + p => { p.region = "a".repeat(129) + "-101010100"; }, + p => { p.alerts = []; }, + p => { p.alerts = Array.from({ length: 33 }, () => p.alerts[0]); }, + p => { p.alerts.push({ ...p.alerts[0] }); }, + p => { p.alerts[0].id = "not-a-uuid"; }, + p => { p.alerts[0].id = uuid.toUpperCase(); }, + p => { p.alerts[0].description = " "; }, + p => { p.alerts[0].description = "x".repeat(513); }, + p => { p.alerts[0].issuedTime = 0; }, + p => { p.alerts[0].issuedTime = "2030-01-01T00:00:00Z"; }, + p => { p.alerts[0].issuedTime = 1.5; }, + p => { p.alerts[0].issuedTime = 0x100000000; }, + p => { p.alerts[0].severity = "minor"; }, + p => { p.alerts[0].severity = 256; }, + p => { delete p.alerts[0].description; }, + p => { p.latitude = 1; }, + p => { p.alerts[0].authorization = "excluded"; }, + ]) { + const payload = validPayload(); mutate(payload); malformed.push(encodePayload(payload)); + } + malformed.push(encodePayload(null), encodePayload([])); + malformed.push("wk3_" + Buffer.from("not JSON").toString("base64url")); + // Noncanonical unused base64 bits decode to valid JSON, but are rejected. + const emptyObject = Buffer.from("{}").toString("base64url"); + malformed.push("wk3_" + emptyObject.slice(0, -1) + "1"); + for (const ids of malformed) await assert.rejects(resolveNativeAlertDetails(ids, "zh-CN", neverFetch)); + await assert.rejects(resolveNativeAlertDetails(idsFor(), "en?bad", neverFetch), /language/); + assert.equal(calls, 0); +}); + +test("builder validates UUID bytes, region, count, field size, locale and timezone", () => { + const build = options => buildNativeAlertDetailsURL({ identifier, alerts: [alert()], ...options }); + for (const bytes of [[], Array(16).fill(-1), Array(16).fill(256), Array(16).fill(1.5), Array(16).fill("1"), Array(16)]) { + assert.throws(() => build({ alerts: [alert("大风", 0, { id: { bytes } })] }), /UUID/); + } + assert.doesNotThrow(() => build({ alerts: [alert("大风", 0, { id: { bytes: new Uint8Array(16) } })] })); + for (const options of [ + { identifier: "https://evil.invalid" }, { alerts: [] }, { alerts: null }, + { alerts: [alert(), alert()] }, { alerts: [alert("x".repeat(513))] }, + { alerts: [alert("大风", 0, { issuedTime: 0 })] }, + { language: "zh-CN&ids=other" }, { timezone: "Asia/Shanghai?x=1" }, { timezone: 1 }, + ]) assert.throws(() => build(options)); + assert.throws(() => build({ alerts: Array.from({ length: 32 }, (_, i) => alert("风".repeat(500), i)) }), /too long/); +}); + +test("unverified WK3 severity integers are never guessed or added to the wire mapping", async () => { + for (const severity of [0, 1, 2, 5, 231]) { + await assert.rejects(resolveNativeAlertDetails(idsFor([alert("大风", 0, { severity })]), "zh-CN", async () => { assert.fail("must not fetch"); }), /Unverified WK3 severity/); + } +}); + +test('paired MODERATE=3 supports reordered yellow/yellow and yellow/blue event groups', async()=>{ + for(const originals of [[alert('寒潮黄色预警',0,{severity:3}),alert('大风蓝色预警',16)], [alert('大风',0,{severity:3}),alert('寒潮',16,{severity:3})]]){ + const page=originals.slice().reverse().map(a=>block(a.description.includes('寒潮')?'寒潮黄色预警':a.severity===3?'大风黄色预警':'大风蓝色预警',undefined,a.severity===3?'yellow':'blue')).join(''); + const result=await resolveNativeAlertDetails(idsFor(originals),'zh-CN',htmlFetch(page)); + assert.deepEqual(result.map(a=>a.severity),originals.map(a=>a.severity===3?'moderate':'minor')); + assert.deepEqual(result.map(a=>a.id),readPayload(idsFor(originals)).alerts.map(a=>a.id)); + assert.ok(result.every(a=>a.messages.length&&a.detailsUrl==='#'+a.id)); + } +}); diff --git a/tests/egern-wk3/wk2-selective.test.mjs b/tests/egern-wk3/wk2-selective.test.mjs new file mode 100644 index 00000000..d74eb0e2 --- /dev/null +++ b/tests/egern-wk3/wk2-selective.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Builder, ByteBuffer } from "flatbuffers"; +import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../../src/egern/wk3/class/WeatherKit2.mjs"; +import * as WK2 from "../../src/egern/wk3/proto/apple/wk2.js"; + +// All fixtures are synthetic FlatBuffers, with no HAR or user response data. +const airQuality = { + categoryIndex: 2, index: 42, isSignificant: true, pollutants: [], + previousDayComparison: "SAME", primaryPollutant: "PM2_5", scale: "SYNTHETIC", +}; +const nextHour = { condition: [], minutes: [], summary: [], forecastStart: 100, forecastEnd: 200 }; + +function slotTable(buffer, id) { + const root = buffer.position() + buffer.readUint32(buffer.position()); + const field = buffer.__offset(root, 4 + id * 2); + assert.notEqual(field, 0, `root slot ${id} must exist`); + return buffer.__indirect(root + field); +} + +// Unknown slot #12 is a real table with a string and nested table containing a +// 64-bit scalar. Keeping its arena intact must preserve every relative pointer. +function fixture() { + const builder = new Builder(); + const label = builder.createString("future product\u0000\u5929\u6c14"); + builder.startObject(1); + builder.addFieldInt64(0, 0x123456789abcdefn, 0n); + const nested = builder.endObject(); + builder.startObject(2); + builder.addFieldOffset(0, label, 0); + builder.addFieldOffset(1, nested, 0); + const future = builder.endObject(); + const aq = encodeAirQuality(builder, airQuality); + const current = WK2.CurrentWeatherData.createCurrentWeatherData(builder, 0, 123); + builder.startObject(13); + builder.addFieldOffset(0, aq, 0); + builder.addFieldOffset(1, current, 0); + builder.addFieldOffset(12, future, 0); + builder.finish(builder.endObject()); + return builder.asUint8Array(); +} + +function readFuture(buffer) { + const table = slotTable(buffer, 12); + const labelField = buffer.__offset(table, 4); + const nestedField = buffer.__offset(table, 6); + const nested = buffer.__indirect(table + nestedField); + const valueField = buffer.__offset(nested, 4); + assert.equal((nested + valueField) % 8, 0, "64-bit scalar remains aligned"); + return { label: buffer.__string(table + labelField), value: buffer.readInt64(nested + valueField) }; +} + +function tableBytes(buffer, id) { + const table = slotTable(buffer, id); + const vtable = table - buffer.readInt32(table); + return buffer.bytes().slice(table, table + buffer.readUint16(vtable + 2)); +} + +test("only selected products are decoded, even with an unreadable other codec", () => { + const buffer = new ByteBuffer(fixture()); + const original = WK2.CurrentWeatherData.prototype.asOf; + let calls = 0; + WK2.CurrentWeatherData.prototype.asOf = function () { calls++; throw new Error("unselected codec called"); }; + try { + const decoded = WeatherKit2.decode(buffer, ["airQuality"]); + assert.deepEqual(Object.keys(decoded), ["airQuality"]); + assert.equal(decoded.airQuality.index, 42); + assert.deepEqual(WeatherKit2.decode(buffer), {}); + assert.equal(calls, 0); + // Positive control: selecting the same codec actually invokes it. + assert.deepEqual(WeatherKit2.decode(buffer, ["currentWeather"]), {}); + assert.equal(calls, 1); + } finally { + WK2.CurrentWeatherData.prototype.asOf = original; + } +}); + +test("patch preserves a real unknown root slot, nested offsets and untouched known table bytes", () => { + const bytes = fixture(); + const before = new ByteBuffer(bytes); + const snapshot = bytes.slice(); + const updated = WeatherKit2.encode(before, { airQuality: { ...airQuality, index: 87 } }); + const after = new ByteBuffer(updated); + assert.deepEqual(readFuture(after), readFuture(before)); + assert.deepEqual(readFuture(after), { label: "future product\u0000\u5929\u6c14", value: 0x123456789abcdefn }); + assert.deepEqual(tableBytes(after, 1), tableBytes(before, 1)); + assert.equal(WK2.Weather.getRootAsWeather(after).currentWeather().asOf(), 123); + assert.equal(WeatherKit2.decode(after, ["airQuality"]).airQuality.index, 87); + assert.deepEqual(bytes, snapshot, "source buffer is not mutated"); + // A second patch must keep the same unknown data alive as arenas accumulate. + const twice = new ByteBuffer(WeatherKit2.encode(after, { airQuality: { ...airQuality, index: 90 } })); + assert.deepEqual(readFuture(twice), readFuture(before)); + assert.equal(WeatherKit2.decode(twice, ["airQuality"]).airQuality.index, 90); +}); + +test("empty or rejected patches preserve the original bytes exactly", () => { + const source = fixture(); + assert.deepEqual(WeatherKit2.encode(new ByteBuffer(source)), source); + assert.deepEqual(WeatherKit2.encode(new ByteBuffer(source), { futureProduct: {} }), source); + assert.deepEqual(WeatherKit2.encode(new ByteBuffer(source), { airQuality: null }), source); +}); + +test("source ByteBuffer position is respected when preserving an unknown slot", () => { + const source = fixture(); + const prefixed = new Uint8Array(source.length + 8); + prefixed.set(source, 8); + const buffer = new ByteBuffer(prefixed); + buffer.setPosition(8); + const output = WeatherKit2.encode(buffer, { airQuality: { ...airQuality, index: 21 } }); + assert.equal(buffer.position(), 8); + assert.deepEqual(readFuture(new ByteBuffer(output)), readFuture(new ByteBuffer(source))); + assert.equal(WeatherKit2.decode(new ByteBuffer(output), ["airQuality"]).airQuality.index, 21); + assert.deepEqual(WeatherKit2.encode(buffer, {}), source); +}); + +test("new roots and adding previously absent products use the patch API", () => { + const source = WeatherKit2.encode(undefined, { airQuality }); + const output = WeatherKit2.encode(new ByteBuffer(source), { forecastNextHour: nextHour }); + const decoded = WeatherKit2.decode(new ByteBuffer(output), ["forecastNextHour", "airQuality"]); + assert.deepEqual(Object.keys(decoded), ["airQuality", "forecastNextHour"], "physical slot order"); + assert.equal(decoded.airQuality.index, 42); + assert.deepEqual(decoded.forecastNextHour, { metadata: undefined, ...nextHour }); +}); + +test("standalone AQ and next-hour exports accept generated tables and produce builder offsets", () => { + for (const [encode, decode, Model, data] of [ + [encodeAirQuality, decodeAirQuality, WK2.AirQuality, airQuality], + [encodeForecastNextHour, decodeForecastNextHour, WK2.NextHourForecastData, nextHour], + ]) { + const builder = new Builder(); + const offset = encode(builder, data); + assert.ok(Number.isSafeInteger(offset) && offset > 0); + builder.finish(offset); + const buffer = new ByteBuffer(builder.asUint8Array()); + const table = new Model().__init(buffer.readUint32(0), buffer); + assert.deepEqual(decode(table), { metadata: undefined, ...data }); + } +}); + +test("all ten upstream product codecs round-trip through the bundled generated models", () => { + const patch = { + airQuality, + currentWeather: { + asOf: 123, + precipitationAmountNext1hByType: [], precipitationAmountNext6hByType: [], + precipitationAmountNext24hByType: [], precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious6hByType: [], precipitationAmountPrevious24hByType: [], + }, + forecastDaily: { days: [] }, + forecastHourly: { hours: [] }, + forecastNextHour: nextHour, + news: { placements: [] }, + weatherAlerts: { alerts: [], detailsUrl: "https://example.invalid/alerts" }, + weatherChanges: { changes: [], forecastStart: 100, forecastEnd: 200 }, + historicalComparisons: { comparisons: [] }, + locationInfo: { preciseName: "Test", countryCode: "SG", timeZone: "Asia/Singapore", primaryName: "Test" }, + }; + const decoded = WeatherKit2.decode(new ByteBuffer(WeatherKit2.encode(undefined, patch)), Object.keys(patch)); + assert.deepEqual(Object.keys(decoded), Object.keys(patch)); + assert.equal(decoded.currentWeather.asOf, 123); + assert.equal(decoded.weatherAlerts.detailsUrl, patch.weatherAlerts.detailsUrl); + assert.equal(decoded.locationInfo.timeZone, "Asia/Singapore"); + const roundTrip = WeatherKit2.decode(new ByteBuffer(WeatherKit2.encode(undefined, decoded)), Object.keys(patch)); + assert.deepEqual(roundTrip, decoded); +}); + +test("invalid selectors and patch containers reject the old API shape", () => { + assert.throws(() => WeatherKit2.decode(new ByteBuffer(fixture()), "airQuality"), TypeError); + assert.throws(() => WeatherKit2.encode(undefined, []), TypeError); + assert.throws(() => WeatherKit2.encode(new Builder(), { airQuality }), TypeError); +}); diff --git a/tests/egern-wk3/wk3-alerts.test.mjs b/tests/egern-wk3/wk3-alerts.test.mjs new file mode 100644 index 00000000..0bc34f7a --- /dev/null +++ b/tests/egern-wk3/wk3-alerts.test.mjs @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { decodeWK3Alerts, patchWK3Alerts, enrichWK3Alerts, patchWK3QWeatherAlerts, WK3_ALERT_ENUMS, WK3AlertsTable, wk3AlertsCodec } from "../../src/egern/wk3/class/WK3Alerts.mjs"; +import WeatherAlerts from "../../src/egern/wk3/class/WeatherAlerts.mjs"; + +// All fixtures synthetic. No HAR, Apple response bytes, credentials or real location. +const html = `<h1 class="c-submenu__location">测试区</h1><div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00+08:00</p><p class="warning-events__txt">注意防范大风。</p><div class="warning-defense__txt"><p>1. 停止高空作业。</p></div></div>`; +const issued = Date.parse("2030-01-01T00:00:00Z") / 1000; +function fixture({ unknown = false, missing = false, empty = false, rawSeverity = 4 } = {}) { + const b = new Builder(); + const aq = b.createString("AQ-UNKNOWN-PRESERVE"), future = b.createString("FUTURE-WEATHER-PRESERVE"); + const details = b.createString("https://www.qweather.com/severe-weather/test-101010100.html?from=AppleWeatherService"); + const description = b.createString("大风"), source = b.createString("国家预警信息发布中心"); + b.startVector(1, 16, 1); for (let i = 15; i >= 0; i--) b.addInt8(i); const idbytes = b.endVector(); + b.startObject(1); b.addFieldOffset(0, idbytes, 0); const id = b.endObject(); + b.startVector(1, 1, 1); b.addInt8(7); const responses = b.endVector(); + b.startObject(unknown ? 23 : 22); b.addFieldOffset(0, id, 0); b.addFieldOffset(5, description, 0); b.addFieldOffset(16, source, 0); + b.addFieldOffset(12, details, 0); b.addFieldInt8(14, rawSeverity, 0); b.addFieldOffset(21, responses, 0); b.addFieldInt32(9, issued, 0); + if (unknown) b.addFieldInt32(22, 123456, 0); const alert = b.endObject(); + b.startVector(4, empty ? 0 : 1, 4); if (!empty) b.addOffset(alert); const alerts = b.endVector(); + b.startObject(4); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); b.addFieldInt32(3, 987654, 0); const collection = b.endObject(); + b.startObject(18); b.addFieldOffset(0, aq, 0); b.addFieldOffset(17, future, 0); if (!missing) b.addFieldOffset(9, collection, 0); + b.finish(b.endObject()); return b.asUint8Array(); +} +function alertVectorPointer(bytes) { + const bb = new ByteBuffer(bytes), root = bb.readUint32(0), col = bb.__indirect(root + bb.__offset(root, 22)); + return bb.__vector(col + bb.__offset(col, 8)); +} +const fetchPage = async () => new Response(html, { headers: { "Content-Type": "text/html" } }); +test("observed WK3 enums are explicit, not WK2 arithmetic", () => { + assert.equal(WK3_ALERT_ENUMS.severity.MINOR, 4); assert.equal(WK3_ALERT_ENUMS.significance.STATEMENT, 10); + assert.equal(WK3_ALERT_ENUMS.responses.MONITOR, 7); assert.equal(WK3_ALERT_ENUMS.severity.SEVERE, undefined); + const d = decodeWK3Alerts(fixture({ rawSeverity: 231 })); assert.equal(d.alerts[0].severity, 231); + assert.deepEqual(d.alerts[0].responses, [7]); assert.deepEqual(d.alerts[0].id.bytes, Array.from({ length: 16 }, (_, i) => i)); +}); +test("patch only changes vector pointer and appends child; entire weather survives", () => { + const bytes = fixture(), snapshot = bytes.slice(), d = decodeWK3Alerts(bytes), pointer = alertVectorPointer(bytes); + const out = patchWK3Alerts(bytes, [{ ...d.alerts[0], description: "大风蓝色预警", eventEndTime: issued + 3600 }]); + assert.deepEqual(bytes, snapshot); assert.ok(out.length > bytes.length); + for (let i = 0; i < bytes.length; i++) if (i < pointer || i >= pointer + 4) assert.equal(out[i], bytes[i]); + assert.equal(decodeWK3Alerts(out).alerts[0].description, "大风蓝色预警"); + assert.equal(decodeWK3Alerts(out).alerts[0].eventEndTime, issued + 3600); +}); +test("no-op/missing/empty collections preserve original view", () => { + const bytes = fixture(); assert.strictEqual(patchWK3Alerts(bytes, decodeWK3Alerts(bytes).alerts), bytes); + const empty = fixture({ empty: true }); assert.strictEqual(patchWK3Alerts(empty, []), empty); + assert.equal(decodeWK3Alerts(fixture({ missing: true })), null); +}); +test("unknown summary fields and unsupported enum strings fail atomically", () => { + const bytes = fixture({ unknown: true }), d = decodeWK3Alerts(bytes); + assert.throws(() => patchWK3Alerts(bytes, [{ ...d.alerts[0], description: "changed" }]), /Unverified/); + const b = fixture(), a = decodeWK3Alerts(b).alerts[0]; + assert.throws(() => patchWK3Alerts(b, [{ ...a, severity: "MINOR" }]), /wire integer/); + assert.throws(() => patchWK3Alerts(b, [{ ...a, areaName: "unverified" }]), /Unverified/); + assert.throws(() => patchWK3Alerts(b, []), /add\/remove/); + assert.throws(() => patchWK3Alerts(b, [{ ...a, id: null }]), /identity/); +}); +test("unobserved provider enum names are not injected", () => { + const d = decodeWK3Alerts(fixture()); + const [a] = enrichWK3Alerts(d, [{ severity: "severe", urgency: "immediate", eventEndTime: "2030-01-02T00:00:00Z" }]); + assert.equal(a.severity, 4); assert.equal(a.urgency, 0); assert.equal(a.eventEndTime, issued + 86400); +}); +test("ByteBuffer positions, views and malformed offsets", () => { + const bytes = fixture(), padded = new Uint8Array(bytes.length + 8); padded.set(bytes, 8); + const bb = new ByteBuffer(padded); bb.setPosition(8); + assert.deepEqual(decodeWK3Alerts(bb).alerts, decodeWK3Alerts(padded.subarray(8)).alerts); + assert.throws(() => decodeWK3Alerts(new Uint8Array([255, 255, 255, 255])), /offset/); +}); +test("codec embeds existing opaque collection arena without WK2 tables", () => { + const bytes = fixture(), bb = new ByteBuffer(bytes), root = bb.readUint32(0), col = bb.__indirect(root + bb.__offset(root, 22)); + const d = wk3AlertsCodec.decode(new WK3AlertsTable().__init(col, bb)); d.alerts[0].description = "modified"; + const b = new Builder(); const offset = wk3AlertsCodec.encode(b, d); b.finish(offset); + const out = b.asUint8Array(), childbb = new ByteBuffer(out); + const reread = wk3AlertsCodec.decode(new WK3AlertsTable().__init(childbb.readUint32(0), childbb)); + assert.equal(reread.alerts[0].description, "modified"); + assert.throws(() => wk3AlertsCodec.encode(new Builder(), { alerts: [] }), /source arena/); +}); +test("QWeather pure parser produces REST details with lowercase tokens", async () => { + const alerts = await WeatherAlerts.DetailsForQWeather("test-101010100", "zh-CN", fetchPage); + assert.equal(alerts[0].description, "大风蓝色预警"); assert.equal(alerts[0].source, "测试区气象台"); + assert.equal(alerts[0].severity, "minor"); assert.equal(alerts[0].effectiveTime, "2030-01-01T00:00:00.000Z"); + assert.match(alerts[0].messages.map(m => m.text).join("\n"), /注意防范/); + assert.equal(alerts[0].name, "WeatherAlert"); assert.equal(alerts[0].detailsUrl, `#${alerts[0].id}`); + assert.equal(WeatherAlerts.ParseWeatherAlertPageURL("https://evil.invalid/severe-weather/test-1.html?from=AppleWeatherService"), undefined); + assert.throws(() => WeatherAlerts.BuildWeatherAlertPageURL("../evil"), /identifier/); +}); +test("domestic enrichment matches event, severity, issuance; details rewrite opt-in", async () => { + const bytes = fixture(), out = await patchWK3QWeatherAlerts(bytes, { fetchImpl: fetchPage }); + const d = decodeWK3Alerts(out); assert.equal(d.alerts[0].description, "大风蓝色预警"); + assert.equal(d.alerts[0].source, "测试区气象台"); assert.equal(d.detailsUrl, decodeWK3Alerts(bytes).detailsUrl); + const rewrite = decodeWK3Alerts(await patchWK3QWeatherAlerts(bytes, { fetchImpl: fetchPage, rewriteDetails: true })); + assert.equal(new URL(rewrite.detailsUrl).hostname, 'weatherkit.apple.com'); + assert.match(new URL(rewrite.detailsUrl).searchParams.get('ids'), /^wk3_/); + assert.equal(rewrite.alerts[0].detailsUrl, rewrite.detailsUrl); + assert.strictEqual(await patchWK3QWeatherAlerts(bytes, { fetchImpl: async () => { throw new Error("offline"); } }), bytes); + assert.strictEqual(await patchWK3QWeatherAlerts(bytes, { fetchImpl: async () => new Response(html + html, { headers: { "Content-Type": "text/html" } }) }), bytes); +}); +test("HTML fetch sends no Apple authorization and fails on status/content type", async () => { + await WeatherAlerts.FetchWeatherAlertPage("test-101010100", "zh-CN", async (url, options) => { + assert.ok(url.startsWith("https://www.qweather.com/")); assert.deepEqual(options.headers, { Accept: "text/html" }); + return fetchPage(); + }); + await assert.rejects(() => WeatherAlerts.FetchWeatherAlertPage("test-101010100", "zh-CN", async () => new Response("no", { status: 500 })), /unavailable/); + await assert.rejects(() => WeatherAlerts.FetchWeatherAlertPage("test-101010100", "zh-CN", async () => new Response("{}", { headers: { "content-type": "application/json" } })), /HTML/); +}); + +test('production Response routes to Apple native details while preserving summary UUID', async()=>{ + const {Response:processResponse}=await import('../../src/egern/wk3/process/Response.mjs'); + const {default:database}=await import('../../src/egern/wk3/function/database.mjs'); + const {default:buildSettings}=await import('../../src/egern/wk3/function/buildSettings.mjs'); + const saved=globalThis.fetch;globalThis.fetch=fetchPage; + try{const bytes=fixture(),before=decodeWK3Alerts(bytes);const out=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database).Settings,Configs:{}});const after=decodeWK3Alerts(out.body);assert.equal(after.alerts[0].description,'大风蓝色预警');assert.equal(new URL(after.detailsUrl).origin,'https://weatherkit.apple.com');assert.equal(new URL(after.detailsUrl).pathname,'/alertDetails/index.html');assert.equal(after.alerts[0].detailsUrl,after.detailsUrl);assert.deepEqual(after.alerts[0].id,before.alerts[0].id);const u=new URL(after.detailsUrl);const {resolveNativeAlertDetails}=await import('../../src/egern/wk3/class/NativeAlertDetails.mjs');const details=await resolveNativeAlertDetails(u.searchParams.get('ids'),u.searchParams.get('lang'),fetchPage);assert.equal(details.length,1);assert.equal(details[0].id,'00010203-0405-0607-0809-0a0b0c0d0e0f');assert.equal(details[0].detailsUrl,'#'+details[0].id);assert.match(details[0].messages[0].text,/注意防范/);}finally{globalThis.fetch=saved;} +}); diff --git a/tests/egern-wk3/wk3-codec.test.mjs b/tests/egern-wk3/wk3-codec.test.mjs new file mode 100644 index 00000000..3da3ab7f --- /dev/null +++ b/tests/egern-wk3/wk3-codec.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { WK3_CODECS, WK3_ROOT_SLOTS, decodeWK3DataSet, encodeAirQuality, decodeAirQuality, WK3AirQualityTable } from "../../src/egern/wk3/class/WeatherKit3.mjs"; + +// Synthetic only. No HAR bytes, provider URLs, coordinates or authorization. +function finish(builder, offset) { builder.finish(offset); return builder.asUint8Array(); } +function root(data) { + const b = new Builder(), offset = data ? encodeAirQuality(b, data) : 0; + b.startObject(15); if (offset) b.addFieldOffset(0, offset, 0); + return finish(b, b.endObject()); +} +function child(bytes) { const bb = new ByteBuffer(bytes); return new WK3AirQualityTable().__init(bb.readUint32(0), bb); } +const aq = { + metadata: { providerName: "Synthetic", sourceType: 3, expireTime: 2000000000, temporarilyUnavailable: false }, + categoryIndex: 2, index: 42, isSignificant: true, + pollutants: [{ pollutantType: 12, amount: 0.5, units: 2 }, { pollutantType: 231, amount: 16.25, units: 202 }], + previousDayComparison: 3, primaryPollutant: 11, scale: "SYNTHETIC", +}; + +test("AQ keeps raw enums, float32 amounts and metadata", () => { + const actual = decodeWK3DataSet(root(aq)); + assert.deepEqual(actual.pollutants, aq.pollutants); + assert.equal(actual.primaryPollutant, 11); + assert.equal(actual.metadata.sourceType, 3); + assert.equal(actual.metadata.expireTime, 2000000000); + assert.equal(actual.index, 42); + assert.equal(actual.isSignificant, true); + const b = new Builder(), bytes = finish(b, encodeAirQuality(b, actual)); + assert.deepEqual(decodeAirQuality(child(bytes)), actual); +}); +test("index/category/scale edits preserve source primary and pollutant integers", () => { + const actual = decodeWK3DataSet(root(aq)); + const patch = { ...actual, index: 75, categoryIndex: 3, scale: "ANOTHER_SYNTHETIC", previousDayComparison: 4 }; + const b = new Builder(), bytes = finish(b, encodeAirQuality(b, patch)); + assert.deepEqual(decodeAirQuality(child(bytes)), patch); + assert.equal(patch.primaryPollutant, actual.primaryPollutant); + assert.deepEqual(patch.pollutants, actual.pollutants); +}); +test("root positions explicitly separate periodic, nextHour and articles", () => { + assert.deepEqual(WK3_ROOT_SLOTS, { airQuality: 0, forecastPeriodic: 4, forecastNextHour: 5, news: 14 }); + assert.deepEqual(Object.keys(WK3_CODECS), ["airQuality"]); + assert.throws(() => decodeWK3DataSet(root(), "forecastNextHour"), /Unsupported/); +}); +test("missing AQ and omitted fields use generated wire defaults", () => { + assert.equal(decodeWK3DataSet(root()), null); + const actual = decodeWK3DataSet(root({})); + assert.equal(actual.metadata, null); + assert.equal(actual.scale, null); + assert.equal(actual.index, 0); + assert.equal(actual.isSignificant, false); + assert.deepEqual(actual.pollutants, []); +}); +test("WK2 enum strings including UNKNOWN13 are rejected", () => { + for (const name of ["CO", "UNKNOWN13", "PM2_5"]) assert.throws(() => encodeAirQuality(new Builder(), { primaryPollutant: name }), /raw integer/); + assert.throws(() => encodeAirQuality(new Builder(), { pollutants: [{ pollutantType: "NO2" }] }), /raw integer/); + assert.throws(() => encodeAirQuality(new Builder(), { metadata: { sourceType: "STATION" } }), /raw integer/); + assert.throws(() => encodeAirQuality(new Builder(), { pollutants: [{ pollutantType: 6, units: "UNKNOWN2" }] }), /raw integer/); + for (const value of [-1, 256, 1.5, NaN, Infinity]) assert.throws(() => encodeAirQuality(new Builder(), { categoryIndex: value })); + assert.throws(() => encodeAirQuality(new Builder(), { index: 65536 })); + assert.throws(() => encodeAirQuality(new Builder(), { pollutants: [{ amount: 1e100 }] })); +}); +test("raw 13 is preserved as a number without claiming a pollutant name", () => { + const actual = decodeWK3DataSet(root({ primaryPollutant: 13, pollutants: [{ pollutantType: 13, units: 2, amount: 10 }] })); + assert.equal(actual.primaryPollutant, 13); + assert.deepEqual(actual.pollutants, [{ pollutantType: 13, units: 2, amount: 10 }]); +}); +test("ByteBuffer position and Uint8Array view offsets are honored", () => { + const bytes = root(aq), padded = new Uint8Array(bytes.length + 8); padded.set(bytes, 8); + const bb = new ByteBuffer(padded); bb.setPosition(8); + assert.deepEqual(decodeWK3DataSet(bb).pollutants, aq.pollutants); + assert.deepEqual(decodeWK3DataSet(padded.subarray(8)).pollutants, aq.pollutants); +}); diff --git a/tests/egern-wk3/wk3-comparison.test.mjs b/tests/egern-wk3/wk3-comparison.test.mjs new file mode 100644 index 00000000..56f90590 --- /dev/null +++ b/tests/egern-wk3/wk3-comparison.test.mjs @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { WK3AirQualityTable, encodeAirQuality, decodeAirQuality } from "../../src/egern/wk3/class/WeatherKit3.mjs"; +import { WK3_COMPARISON, computeWK3Comparison, injectWK3Comparison } from "../../src/egern/wk3/class/WK3Comparison.mjs"; + +const aq = (categoryIndex = 2, scale = "EPA_NowCast.2604") => ({ + metadata: { providerName: "Synthetic", temporarilyUnavailable: false }, + index: [0, 25, 75, 125, 175, 250, 350][categoryIndex], categoryIndex, scale, + previousDayComparison: 2, pollutants: [], primaryPollutant: 1, +}); +const us = { currentProvider: "ColorfulCloudsUS", yesterdayProvider: "ColorfulCloudsUS" }; + +test("independently observed WK3 enums survive actual AQ FlatBuffer codec", () => { + // Sanitized paired Apple v3 JSON/FlatBuffer observations, 2026-11-04. + // BETTER observed in two US requests and one Canadian request; WORSE in US. + const observed = [["UNKNOWN", 0], ["SAME", 3], ["BETTER", 4], ["WORSE", 2]]; + for (const [json, wire] of observed) { + assert.equal(WK3_COMPARISON[json], wire); + const b = new Builder(); + b.finish(encodeAirQuality(b, { ...aq(), previousDayComparison: wire })); + const bb = new ByteBuffer(b.asUint8Array()); + const table = new WK3AirQualityTable().__init(bb.readUint32(0), bb); + assert.equal(decodeAirQuality(table).previousDayComparison, wire); + } +}); + +test("category trend uses verified BETTER=4 WORSE=2 SAME=3, not AQI arithmetic", () => { + assert.equal(computeWK3Comparison(aq(1), aq(2), us), 4); + assert.equal(computeWK3Comparison(aq(3), aq(2), us), 2); + assert.equal(computeWK3Comparison({ ...aq(2), index: 51 }, { ...aq(2), index: 99 }, us), 3); + // The two observed scale versions share category boundaries. + assert.equal(computeWK3Comparison(aq(2), aq(2, "EPA_NowCast.2414"), us), 3); + const cn = { currentProvider: "ColorfulCloudsCN", yesterdayProvider: "ColorfulCloudsCN" }; + assert.equal(computeWK3Comparison(aq(2, "HJ6332012.2604"), aq(3, "HJ6332012.2414"), cn), 4); +}); + +test("missing, unavailable, invalid or inconsistent categories fail closed", () => { + const bad = [null, {}, { ...aq(), metadata: undefined }, { ...aq(), metadata: { temporarilyUnavailable: true } }, + ...[undefined, null, -1, 0, 7, 2.5, "2", NaN, Infinity].map(categoryIndex => ({ ...aq(), categoryIndex })), + ...["EPA_NowCast.9999", "EPA_NowCast", "EU.2604", "HJ6332012.9999", undefined].map(scale => ({ ...aq(), scale })), + ...[-1, 501, NaN, Infinity, "75", null, 200].map(index => ({ ...aq(), index }))]; + for (const value of bad) { + assert.equal(computeWK3Comparison(aq(), value, us), 0); + assert.equal(computeWK3Comparison(value, aq(), us), 0); + } + assert.equal(computeWK3Comparison({ ...aq(), index: undefined }, aq(), us), 3); +}); + +test("same category never permits cross-standard or cross-provider comparison", () => { + const a = aq(), cn = aq(2, "HJ6332012.2604"); + assert.equal(computeWK3Comparison(a, cn, us), 0); + assert.equal(computeWK3Comparison(cn, cn, us), 0); // US provider cannot claim CN. + assert.equal(computeWK3Comparison(a, a, { currentProvider: "QWeather", yesterdayProvider: "QWeather" }), 0); + assert.equal(computeWK3Comparison(a, a, { ...us, yesterdayProvider: "WeatherKit" }), 0); + assert.equal(computeWK3Comparison(a, a), 0); + assert.equal(computeWK3Comparison(a, a, { currentProvider: "", yesterdayProvider: "" }), 0); + assert.equal(computeWK3Comparison(a, a, { currentProvider: "Calculate", yesterdayProvider: "Calculate" }), 0); + assert.equal(computeWK3Comparison(a, a, { currentProvider: "Calculate:QWeather:US", yesterdayProvider: "Calculate:ColorfulClouds:US" }), 0); +}); + +test("ColorfulClouds US fetch uses YesterdayAirQuality(true) and never fetches another current AQ", async () => { + const original = aq(1), snapshot = structuredClone(original), calls = []; + const sources = { colorfulClouds: { + YesterdayAirQuality: async useUsa => { calls.push(useUsa); return aq(2, "EPA_NowCast.2414"); }, + CurrentAirQuality: async () => { throw new Error("Must not substitute another current index"); }, + } }; + const result = await injectWK3Comparison(original, { ...us, sources }); + assert.deepEqual(calls, [true]); + assert.equal(result.previousDayComparison, 4); + assert.deepEqual(original, snapshot); + assert.notEqual(result, original); + assert.equal(result.metadata, original.metadata); + assert.equal(result.pollutants, original.pollutants); + assert.equal(result.index, original.index); + assert.equal(result.scale, original.scale); +}); + +test("CN selection uses YesterdayAirQuality(false), without relabeling its standard", async () => { + const options = { currentProvider: "ColorfulCloudsCN", sources: { colorfulClouds: { + YesterdayAirQuality: async useUsa => { assert.equal(useUsa, false); return aq(1, "HJ6332012.2414"); }, + } } }; + assert.equal((await injectWK3Comparison(aq(3, "HJ6332012.2604"), options)).previousDayComparison, 2); + assert.equal((await injectWK3Comparison(aq(3), options)).previousDayComparison, 0); +}); + +test("mismatched configured provider never triggers fallback to another provider", async () => { + let calls = 0; + const sources = { colorfulClouds: { YesterdayAirQuality: async () => { calls++; return aq(); } } }; + const result = await injectWK3Comparison(aq(), { currentProvider: "WeatherKit", yesterdayProvider: "ColorfulCloudsUS", sources }); + assert.equal(result.previousDayComparison, 0); + assert.equal(calls, 0); + assert.equal((await injectWK3Comparison(aq(), { currentProvider: "WeatherKit", sources })).previousDayComparison, 0); +}); + +test("already fetched data and promises bypass network and preserve caller source identity", async () => { + let calls = 0; + const sources = { colorfulClouds: { YesterdayAirQuality: () => { calls++; throw new Error("No network needed"); } } }; + for (const yesterday of [aq(3), Promise.resolve(aq(3))]) { + assert.equal((await injectWK3Comparison(aq(2), { ...us, yesterday, sources })).previousDayComparison, 4); + } + const provider = "Calculate:QWeather:WAQI_InstantCast_US"; + assert.equal((await injectWK3Comparison(aq(3), { currentProvider: provider, yesterday: aq(1) })).previousDayComparison, 2); + assert.equal((await injectWK3Comparison(aq(2), { ...us, yesterday: null, sources })).previousDayComparison, 0); + assert.equal(calls, 0); +}); + +test("QWeather receives caller-resolved locationInfo only for its own CN standard", async () => { + const locationInfo = { id: "synthetic" }; let calls = 0; + const options = { currentProvider: "QWeather", locationInfo, sources: { qWeather: { + YesterdayAirQuality: async location => { calls++; assert.equal(location, locationInfo); return aq(2, "HJ6332012.2414"); }, + } } }; + assert.equal((await injectWK3Comparison(aq(2, "HJ6332012.2604"), options)).previousDayComparison, 3); + assert.equal((await injectWK3Comparison(aq(), options)).previousDayComparison, 0); + assert.equal((await injectWK3Comparison(aq(2, "HJ6332012.2604"), { ...options, locationInfo: undefined })).previousDayComparison, 0); + assert.equal(calls, 1); +}); + +test("provider errors, rejected prefetch and unsupported data clear stale comparison, keeping AQ usable", async () => { + const original = aq(); + const scenarios = [ + { sources: {} }, + { sources: { colorfulClouds: { YesterdayAirQuality: async () => { throw new Error("private provider failure"); } } } }, + { sources: { colorfulClouds: { YesterdayAirQuality: () => { throw new Error("sync failure"); } } } }, + { yesterday: Promise.reject(new Error("prefetch failed")) }, + { yesterday: { ...aq(), metadata: { temporarilyUnavailable: true } } }, + { yesterday: aq(2, "HJ6332012.2414") }, + { yesterday: { ...aq(), categoryIndex: -1 } }, + ]; + for (const scenario of scenarios) { + const result = await injectWK3Comparison(original, { ...us, ...scenario }); + assert.deepEqual(result, { ...original, previousDayComparison: 0 }); + } + assert.equal(original.previousDayComparison, 2); +}); + +test("rejected prefetch is consumed even if mismatch skips comparison", async () => { + const result = await injectWK3Comparison(aq(), { ...us, yesterdayProvider: "QWeather", yesterday: Promise.reject(new Error("already dispatched")) }); + assert.equal(result.previousDayComparison, 0); + await new Promise(resolve => setImmediate(resolve)); +}); diff --git a/tests/egern-wk3/wk3-features-integration.test.mjs b/tests/egern-wk3/wk3-features-integration.test.mjs new file mode 100644 index 00000000..b34f2064 --- /dev/null +++ b/tests/egern-wk3/wk3-features-integration.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder,ByteBuffer } from 'flatbuffers'; +import { Response as processResponse } from '../../src/egern/wk3/process/Response.mjs'; +import { encodeAirQuality,decodeWK3DataSet } from '../../src/egern/wk3/class/WeatherKit3.mjs'; +import { encodeNextHour,providerToWK3NextHour,decodeWK3NextHour } from '../../src/egern/wk3/class/WK3NextHour.mjs'; +import database from '../../src/egern/wk3/function/database.mjs'; +import buildSettings from '../../src/egern/wk3/function/buildSettings.mjs'; +const start=2000000040; +function rain(){return {metadata:{sourceType:'MODELED',providerName:'Synthetic',latitude:0,longitude:0},forecastStart:start,forecastEnd:start+180,minutes:[0,1,2].map(i=>({startTime:start+i*60,precipitationChance:70,precipitationIntensity:1.5,perceivedPrecipitationIntensity:1.2})),summary:[{startTime:start,endTime:0,condition:'RAIN',precipitationChance:70,precipitationIntensity:1.5}],condition:[{startTime:start,endTime:0,forecastToken:'CONSTANT',beginCondition:'RAIN',endCondition:'RAIN',parameters:[]}]};} +const aq={metadata:{sourceType:3,providerName:'Synthetic Apple'},scale:'HJ6332012.2604',index:60,categoryIndex:2,isSignificant:true,primaryPollutant:11,pollutants:[],previousDayComparison:2}; +const us={metadata:{sourceType:'MODELED',providerName:'Synthetic US'},scale:'EPA_NowCast.2414',index:75,categoryIndex:2,isSignificant:false,primaryPollutant:'PM2_5',pollutants:[{pollutantType:'PM2_5',amount:12,units:'MICROGRAMS_PER_CUBIC_METER'}]}; +function source({next=false,futureNext=false}={}){const b=new Builder();let n=0;if(next)n=encodeNextHour(b,providerToWK3NextHour(rain(),{intensityUnit:'mm/h',chanceUnit:'percent'}));if(futureNext){b.startObject(7);b.addFieldInt32(6,10,0);n=b.endObject();}const a=encodeAirQuality(b,aq),marker=b.createString('preserved periodic/news/future');b.startObject(1);b.addFieldOffset(0,marker,0);const o=b.endObject();b.startObject(15);b.addFieldOffset(0,a,0);if(n)b.addFieldOffset(5,n,0);for(const i of [4,14])b.addFieldOffset(i,o,0);b.finish(b.endObject());return b.asUint8Array();} +function child(bytes,slot){const bb=new ByteBuffer(bytes),r=bb.readUint32(0),o=bb.__offset(r,4+2*slot);return o?bb.__indirect(r+o):null;} +async function patch(bytes,{datasets='airQuality,forecastNextHour',prefetched={},settings={},country='CN'}={}){const Settings=buildSettings(database,{AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS',Replace:['HJ6332012']}},Comparison:{Yesterday:{IndexProvider:'ColorfulCloudsUS'}}},...settings}).Settings;return (await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/en/31/121?country=${country}&dataSets=${datasets}`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings,Configs:{},enviroments:{parameters:{country,latitude:31,longitude:121},country},preFetched:{index:Promise.resolve(us),forecastNextHour:Promise.resolve(rain()),...prefetched}})).body;} +test('missing nextHour root 5 is added while complete original arena remains byte-exact and aligned',async()=>{const bytes=source(),copy=bytes.slice(),out=await patch(bytes,{datasets:'forecastNextHour'});assert.equal(decodeWK3NextHour(out).minutes[2].precipitationIntensity,1.5);const shift=child(out,4)-child(bytes,4);assert.equal(shift%8,0);assert.deepEqual(out.slice(shift,shift+bytes.length),bytes);assert.deepEqual(decodeWK3DataSet(out),decodeWK3DataSet(bytes));assert.equal(child(out,14)-child(bytes,14),shift);assert.deepEqual(bytes,copy);}); +test('existing nextHour replacement preserves root4,root14 and current AQ',async()=>{const bytes=source({next:true}),out=await patch(bytes,{datasets:'forecastNextHour'});assert.equal(child(out,4),child(bytes,4));assert.equal(child(out,14),child(bytes,14));assert.deepEqual(decodeWK3DataSet(out),decodeWK3DataSet(bytes));const root=new ByteBuffer(bytes).readUint32(0),f=root+new ByteBuffer(bytes).__offset(root,14);for(let i=0;i<bytes.length;i++)if(i<f||i>=f+4)assert.equal(out[i],bytes[i]);}); +test('AQ yesterday improvements, equality and deterioration reach actual WK3 child alongside rain',async()=>{for(const [index,cat,expected] of [[125,3,4],[80,2,3],[25,1,2]]){const bytes=source(),out=await patch(bytes,{prefetched:{yesterdayAirQuality:Promise.resolve({...us,index,categoryIndex:cat})}});const a=decodeWK3DataSet(out);assert.equal(a.scale,'EPA_NowCast.2604');assert.equal(a.previousDayComparison,expected);assert.equal(decodeWK3NextHour(out).minutes.length,3);}}); +test('yesterday failure never blocks current US AQ or successful minutes',async()=>{const out=await patch(source(),{prefetched:{yesterdayAirQuality:Promise.resolve(undefined)}});assert.equal(decodeWK3DataSet(out).previousDayComparison,0);assert.equal(decodeWK3DataSet(out).index,75);assert.equal(decodeWK3NextHour(out).minutes.length,3);}); +test('minute failure, unknown future schema, WeatherKit selection and overseas preserve original minute arena',async()=>{for(const bytes of [source(),source({futureNext:true})]){assert.deepEqual(await patch(bytes,{datasets:'forecastNextHour',prefetched:{forecastNextHour:Promise.resolve(undefined)}}),bytes);assert.deepEqual(await patch(bytes,{datasets:'forecastNextHour',settings:{NextHour:{Provider:'WeatherKit'}}}),bytes);assert.deepEqual(await patch(bytes,{datasets:'forecastNextHour',country:'US'}),bytes);}assert.deepEqual(await patch(source({futureNext:true}),{datasets:'forecastNextHour'}),source({futureNext:true}));}); diff --git a/tests/egern-wk3/wk3-hk-aq.test.mjs b/tests/egern-wk3/wk3-hk-aq.test.mjs new file mode 100644 index 00000000..44121da1 --- /dev/null +++ b/tests/egern-wk3/wk3-hk-aq.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import { Response as processResponse } from '../../src/egern/wk3/process/Response.mjs'; +import { decodeWK3DataSet, encodeAirQuality } from '../../src/egern/wk3/class/WeatherKit3.mjs'; +import buildSettings from '../../src/egern/wk3/function/buildSettings.mjs'; +import database from '../../src/egern/wk3/function/database.mjs'; +import { isHongKong } from '../../src/egern/wk3/function/shouldReplaceWeatherKit.mjs'; +const provider={metadata:{providerName:'Synthetic HK',sourceType:'MODELED'},scale:'EPA_NowCast.2414',index:72,categoryIndex:2,isSignificant:false,primaryPollutant:'PM2_5',pollutants:[{pollutantType:'PM2_5',amount:15,units:'MICROGRAMS_PER_CUBIC_METER'}]}; +function root(aq=false){const b=new Builder(),a=aq?encodeAirQuality(b,{scale:'HK.2604',index:3,categoryIndex:2,metadata:{providerName:'Apple',sourceType:3},primaryPollutant:1,pollutants:[]}):0;const text=b.createString('keep weather/alerts/news/unknown');b.startObject(1);b.addFieldOffset(0,text,0);const o=b.endObject();b.startObject(18);if(a)b.addFieldOffset(0,a,0);for(const slot of[1,2,3,9,17])b.addFieldOffset(slot,o,0);b.finish(b.endObject());return b.asUint8Array();} +async function run(bytes,{country='HK',lat=22.278,lon=114.171,dataSets='airQuality,currentWeather,weatherAlerts',result=provider,settings={}}={}){return (await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/zh-Hans-SG/${lat}/${lon}?country=${country}&dataSets=${dataSets}`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database,{AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS',Replace:['HJ6332012']}},Comparison:{Yesterday:{IndexProvider:'ColorfulCloudsUS'}}},...settings}).Settings,Configs:{},enviroments:{},preFetched:{index:Promise.resolve(result),yesterdayAirQuality:Promise.resolve({...provider,index:125,categoryIndex:3})}})).body;} +test('HK without AQ gets verified US child while original entire arena stays byte exact and 8-byte aligned',async()=>{const bytes=root(),out=await run(bytes),a=decodeWK3DataSet(out);assert.equal(a.index,72);assert.equal(a.scale,'EPA_NowCast.2604');assert.equal(a.previousDayComparison,4);assert.equal(a.metadata.providerName,'Synthetic HK');const b=new ByteBuffer(bytes),n=new ByteBuffer(out),r=b.readUint32(0),nr=n.readUint32(0);let shift;for(const slot of[1,2,3,9,17]){const t=b.__indirect(r+b.__offset(r,4+2*slot)),nt=n.__indirect(nr+n.__offset(nr,4+2*slot));shift??=nt-t;assert.equal(nt-t,shift);}assert.equal(shift%8,0);assert.deepEqual(out.slice(shift,shift+bytes.length),bytes);}); +test('HK labeled CN still recognized geographically; false HK labels elsewhere never inject',async()=>{assert.ok(isHongKong({country:'CN',latitude:22.278,longitude:114.171}));assert.equal(decodeWK3DataSet(await run(root(),{country:'CN'})).index,72);for(const args of[{country:'US'},{country:'SG'},{country:'HK',lat:31,lon:121},{country:'MO',lat:22.19,lon:113.54}]){const bytes=root();assert.deepEqual(await run(bytes,args),bytes);}}); +test('existing Apple AQ, request exclusion, disabled dataset and non-US provider remain unchanged',async()=>{const existing=root(true);assert.deepEqual(await run(existing),existing);for(const options of[{dataSets:'currentWeather,weatherAlerts'},{settings:{DataSets:'[]'}},{settings:{AirQuality:{Current:{Index:{Provider:'WeatherKit'}}}}}]){const bytes=root();assert.deepEqual(await run(bytes,options),bytes);}}); +test('unavailable, incomplete and invalid HK provider output never creates synthetic usable AQ',async()=>{for(const result of[undefined,{...provider,metadata:{temporarilyUnavailable:true}},{...provider,pollutants:[]},{...provider,index:NaN}]){const bytes=root();const opts={result:result===undefined?null:result};assert.deepEqual(await run(bytes,opts),bytes);}}); diff --git a/tests/egern-wk3/wk3-next-hour.test.mjs b/tests/egern-wk3/wk3-next-hour.test.mjs new file mode 100644 index 00000000..e3a4cf7e --- /dev/null +++ b/tests/egern-wk3/wk3-next-hour.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import { WK3NextHourTable, WK3_NEXT_HOUR_ENUMS, decodeNextHour, decodeWK3NextHour, encodeNextHour, providerToWK3NextHour, nextHourCodec } from '../../src/egern/wk3/class/WK3NextHour.mjs'; + +const units = { intensityUnit: 'mm/h', chanceUnit: 'percent' }; +// Fixed public London coordinate; synthetic values, not a captured user request. +function provider() { + const start = 2000000040; + return { metadata: { providerName: 'Synthetic London', latitude: 51.5072, longitude: -0.1276, sourceType: 'MODELED', reportedTime: start, temporarilyUnavailable: false }, forecastStart: start, forecastEnd: start + 180, + minutes: [0, 1, 2].map((i) => ({ startTime: start + i * 60, precipitationChance: [0, 50, 80][i], precipitationIntensity: [0, 0.25, 1.5][i], perceivedPrecipitationIntensity: [0, 0.5, 1.2][i] })), + summary: [{ startTime: start, endTime: start + 60, condition: 'CLEAR', precipitationChance: 0, precipitationIntensity: 0 }, { startTime: start + 60, endTime: 0, condition: 'RAIN', precipitationChance: 80, precipitationIntensity: 1.5 }], + condition: [{ startTime: start, endTime: start + 60, forecastToken: 'START', beginCondition: 'DRIZZLE', endCondition: 'DRIZZLE', parameters: [{ type: 'FIRST_AT', date: start + 60 }] }, { startTime: start + 60, endTime: 0, forecastToken: 'CONSTANT', beginCondition: 'RAIN', endCondition: 'RAIN', parameters: [] }] }; +} +function root(data) { + const b = new Builder(), child = encodeNextHour(b, data); + b.startObject(15); b.addFieldOffset(5, child, 0); b.finish(b.endObject()); + return b.asUint8Array(); +} +function numericClose(a, b, path = '') { + if (a && typeof a === 'object') { for (const key of Object.keys(a)) numericClose(a[key], b[key], `${path}.${key}`); } + else if (typeof a === 'number') assert.ok(Math.abs(a - b) <= Math.max(1e-5, Math.abs(a) * 1e-6), `${path}: ${a} != ${b}`); + else assert.equal(a, b, path); +} + +test('provider conversion is independent from WK2 and maps only original-paired enums', () => { + const data = provider(), out = providerToWK3NextHour(data, units); + assert.deepEqual(out.summary.map(s => s.condition), [[1], [2]]); + assert.deepEqual(out.condition.map(c => c.forecastToken), [2, 6]); + assert.deepEqual(out.condition.map(c => c.beginCondition), [8, 7]); + assert.deepEqual(out.condition[0].parameters, [{ type: 1, date: data.forecastStart + 60 }]); + assert.equal(out.metadata.sourceType, 2); + assert.equal(out.minutes[2].precipitationIntensity, 1.5); + assert.equal(data.summary[0].condition, 'CLEAR'); + assert.deepEqual(WK3_NEXT_HOUR_ENUMS.forecastToken, { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, CONSTANT: 6 }); +}); +test('root 5 codec round-trips nonzero chance/intensity/perceived and summary enum vectors', () => { + const data = providerToWK3NextHour(provider(), units), bytes = root(data), copy = bytes.slice(); + const out = decodeWK3NextHour(bytes); + numericClose(data, out); assert.deepEqual(bytes, copy); + assert.equal(out.minutes[2].precipitationChance, 80); + assert.equal(out.summary[1].precipitationIntensity, 1.5); + assert.deepEqual(out.summary[1].condition, [2]); + assert.equal(nextHourCodec.tableClass, WK3NextHourTable); + assert.equal(nextHourCodec.encode, encodeNextHour); +}); +test('raw codec round-trips unknown enum integers without guessing meanings', () => { + const data = providerToWK3NextHour(provider(), units); + data.summary[1].condition = [231]; + data.condition[0].forecastToken = 212; data.condition[0].beginCondition = 254; + data.condition[0].parameters[0].type = 223; + const out = decodeWK3NextHour(root(data)); + assert.deepEqual(out.summary[1].condition, [231]); + assert.equal(out.condition[0].forecastToken, 212); + assert.equal(out.condition[0].beginCondition, 254); + assert.equal(out.condition[0].parameters[0].type, 223); +}); +test('singleton summary encoding has zero padding compatible with u8/u16 readers', () => { + const bytes = root(providerToWK3NextHour(provider(), units)), bb = new ByteBuffer(bytes); + const r = bb.readUint32(0), t = bb.__indirect(r + bb.__offset(r, 14)); + const vector = bb.__vector(t + bb.__offset(t, 8)); + for (let i = 0; i < 2; i++) { + const summary = bb.__indirect(vector + 4 * i), f = summary + bb.__offset(summary, 8); + const p = bb.__vector(f); + assert.equal(bb.__vector_len(f), 1); + assert.equal(bb.readUint16(p), i + 1); + assert.equal(bb.readUint8(p + 1), 0); + } +}); +test('unverified multi-condition summary vectors fail closed in both directions', () => { + const data = providerToWK3NextHour(provider(), units); + data.summary[0].condition = [1, 2]; + assert.throws(() => root(data), /Unverified/); + const p = provider(); p.summary[0].condition = ['CLEAR', 'RAIN']; + assert.throws(() => providerToWK3NextHour(p, units), /Unverified/); + const bytes = root(providerToWK3NextHour(provider(), units)), bb = new ByteBuffer(bytes); + const r = bb.readUint32(0), t = bb.__indirect(r + bb.__offset(r, 14)); + const vector = bb.__vector(t + bb.__offset(t, 8)), summary = bb.__indirect(vector); + const conditions = bb.__vector(summary + bb.__offset(summary, 8)); + bb.writeUint32(conditions - 4, 2); + assert.throws(() => decodeWK3NextHour(bytes), /Unverified/); +}); +test('child codec plugs into isolated arena reader and missing root 5 returns null', () => { + const b = new Builder(), offset = nextHourCodec.encode(b, providerToWK3NextHour(provider(), units)); b.finish(offset); + const bb = new ByteBuffer(b.asUint8Array()); + const out = nextHourCodec.decode(new nextHourCodec.tableClass().__init(bb.readUint32(0), bb)); + assert.equal(out.minutes.length, 3); + const empty = new Builder(); empty.startObject(1); empty.finish(empty.endObject()); + assert.equal(decodeWK3NextHour(empty.asUint8Array()), null); +}); +test('raw codec rejects WK2 summary scalar, invalid floats/probabilities and symbolic enum arithmetic', () => { + for (const change of [d => d.summary[0].condition = 1, d => d.minutes[0].precipitationIntensity = -1, d => d.minutes[0].precipitationIntensity = NaN, d => d.minutes[0].perceivedPrecipitationIntensity = Infinity, d => d.minutes[0].precipitationChance = 101, d => d.condition[0].forecastToken = 'START', d => d.summary[0].condition = [256]]) { + const data = providerToWK3NextHour(provider(), units); change(data); + assert.throws(() => encodeNextHour(new Builder(), data)); + } +}); +test('provider conversion fails closed for unknown enums, absent units, missing or inconsistent data', () => { + assert.throws(() => providerToWK3NextHour(provider())); + assert.throws(() => providerToWK3NextHour(provider(), { ...units, chanceUnit: 'fraction' })); + assert.throws(() => providerToWK3NextHour(provider(), { ...units, intensityUnit: 'mm/5min' })); + for (const change of [d => d.minutes[0].precipitationChance = 0.5, d => d.minutes[0].precipitationIntensity = undefined, d => d.minutes[0].perceivedPrecipitationIntensity = undefined, d => d.minutes[1].startTime++, d => d.forecastEnd++, d => d.condition[0].forecastToken = 'START_STOP_START', d => d.condition[0].beginCondition = 'HEAVY_RAIN', d => d.condition[0].parameters[0].type = 'THIRD_AT', d => d.condition[0].parameters[0].type = 'NO_RAIN', d => d.condition[0].parameters[0].type = 'BOTH', d => d.summary[1].condition = 'SNOW', d => d.summary[0].condition = [], d => d.summary[0].endTime++, d => d.metadata.sourceType = 'UNKNOWN', d => d.metadata.temporarilyUnavailable = true, d => d.minutes = [], d => d.summary = []]) { + const data = provider(); change(data); + assert.throws(() => providerToWK3NextHour(data, units)); + } +}); +test('unsupported future child layout and truncation reject instead of emitting guessed data', () => { + const b = new Builder(); b.startObject(7); b.addFieldInt32(6, 42, 0); b.finish(b.endObject()); + const bb = new ByteBuffer(b.asUint8Array()); + assert.throws(() => decodeNextHour(new WK3NextHourTable().__init(bb.readUint32(0), bb)), /Unsupported/); + assert.throws(() => decodeWK3NextHour(new Uint8Array(3)), /Truncated/); + assert.throws(() => decodeWK3NextHour(root(providerToWK3NextHour(provider(), units)).subarray(0, 24))); +}); From e2edf92d4506d777f0e66db823306d44de6aea53 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:54:37 +0800 Subject: [PATCH 10/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88=E7=A9=BA?= =?UTF-8?q?=E6=B0=94=E8=B4=A8=E9=87=8F=EF=BC=89=EF=BC=9A=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20AQHI=20=E7=B3=BB=E5=88=97=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/egern/wk3/class/AirQuality.mjs | 404 ++++++++++++++++++++++++- tests/egern-wk3/aq-algorithms.test.mjs | 58 ++++ 2 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 tests/egern-wk3/aq-algorithms.test.mjs diff --git a/src/egern/wk3/class/AirQuality.mjs b/src/egern/wk3/class/AirQuality.mjs index be6f0856..c357ebfa 100644 --- a/src/egern/wk3/class/AirQuality.mjs +++ b/src/egern/wk3/class/AirQuality.mjs @@ -215,6 +215,20 @@ export default class AirQuality { Console.debug("✅ PollutantsToInstantCastCN"); break; } + case "CN_DEATH_AQHI": + case "CA_AQHI": + case "HK_AQHI": + case "CN_DEATH_HK_AQHI": { + airQuality = AirQuality.#PollutantsToAQHI(airQuality.pollutants, scale, { stpConversionFactors }); + airQuality = { ...airQuality, index: airQuality.categoryIndex }; + break; + } + case "AQHI_Multi_CN": + case "AQHI_Multi_CN_HK": { + airQuality = AirQuality.#PollutantsToAQHIMulti(airQuality.pollutants, scale, { stpConversionFactors }); + airQuality = { ...airQuality, index: airQuality.categoryIndex }; + break; + } case "UBA": default: { // PollutantsToUBA @@ -333,7 +347,7 @@ export default class AirQuality { } } - static ToWeatherKitScale = ({ name, version }) => `${name}.${version}`; + static ToWeatherKitScale = ({ name, version }) => (version ? `${name}.${version}` : name); static GetNameFromScale(scale) { if (!scale) return ""; Console.debug("☑️ GetNameFromScale", `scale: ${scale}`); @@ -669,6 +683,168 @@ export default class AirQuality { }; } + static #PollutantsToAQHI(pollutants, scale, options = {}) { + Console.info("☑️ PollutantsToAQHI"); + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + 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³) + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + + // 按 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; + } + return p.amount; + }; + + const { betas } = scale; + const ar = (beta, concentration) => { + if (concentration === null || concentration < 0) return 0; + return (Math.exp(beta * concentration) - 1) * 100; + }; + + const arNO2 = ar(betas.NO2 || 0, getAmount("NO2")); + const arSO2 = ar(betas.SO2 || 0, getAmount("SO2")); + const arO3 = ar(betas.O3 || 0, getAmount("OZONE")); + const arPM10 = ar(betas.PM10 || 0, getAmount("PM10")); + const arPM2_5 = ar(betas.PM2_5 || 0, getAmount("PM2_5")); + const arPM = Math.max(arPM10, arPM2_5); + + const totalAR = arNO2 + arSO2 + arO3 + arPM; + 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 => { + switch (p.pollutantType) { + case "NO2": + p.index = arNO2; + break; + case "SO2": + p.index = arSO2; + break; + case "OZONE": + p.index = arO3; + break; + case "PM10": + p.index = arPM10; + break; + case "PM2_5": + p.index = arPM2_5; + break; + default: + p.index = -1; + } + }); + + // 将 %AR 映射到 1–11 的 categoryIndex (即 AQHI 指数) + const categoryIndex = AirQuality.CategoryIndex(totalAR, scale.categories); + + // 以最大 %AR 对应的污染物作为主污染物 + const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 }); + + Console.info("✅ PollutantsToAQHI", `AQHI: ${categoryIndex}, primaryPollutant: ${primaryPollutant.pollutantType}`); + return { + index: totalAR, + isSignificant: categoryIndex >= scale.categories.significantIndex, + categoryIndex, + pollutants: convertedPollutants, + metadata: { providerName: "iRingo", temporarilyUnavailable: false }, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + } + + static #PollutantsToAQHIMulti(pollutants, scale, options = {}) { + Console.info("☑️ PollutantsToAQHIMulti"); + + if (!Array.isArray(pollutants) || pollutants.length === 0) { + 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; + + 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; + } + return p.amount; + }; + + const { beta, weights } = scale; + const contributions = { + OZONE: (weights.O3 || 0) * Math.max(getAmount("OZONE") || 0, 0), + NO2: (weights.NO2 || 0) * Math.max(getAmount("NO2") || 0, 0), + PM10: (weights.PM10 || 0) * Math.max(getAmount("PM10") || 0, 0), + PM2_5: (weights.PM2_5 || 0) * Math.max(getAmount("PM2_5") || 0, 0), + SO2: (weights.SO2 || 0) * Math.max(getAmount("SO2") || 0, 0), + }; + // When risk(PM 10) > 1.2 * risk(PM 2.5) + const usePM10 = contributions.PM10 > 0 && (contributions.PM2_5 <= 0 || contributions.PM10 > contributions.PM2_5 * 1.2); + const pmContribution = usePM10 ? contributions.PM10 : contributions.PM2_5; + const totalWeighted = contributions.OZONE + contributions.NO2 + contributions.SO2 + pmContribution; + const totalRisk = (beta || 0) * totalWeighted; + + Console.info( + "PollutantsToAQHIMulti", + `risk: O3=${contributions.OZONE.toFixed(4)}, NO2=${contributions.NO2.toFixed(4)}, PM10=${contributions.PM10.toFixed(4)}, PM2.5=${contributions.PM2_5.toFixed(4)}, usePM10=${usePM10}, SO2=${contributions.SO2.toFixed(4)}, totalWeighted=${totalWeighted.toFixed(4)}, totalRisk=${totalRisk.toFixed(4)}`, + ); + + convertedPollutants.forEach(p => { + switch (p.pollutantType) { + case "OZONE": + p.index = contributions.OZONE; + break; + case "NO2": + p.index = contributions.NO2; + break; + case "PM10": + p.index = contributions.PM10; + break; + case "PM2_5": + p.index = contributions.PM2_5; + break; + case "SO2": + p.index = contributions.SO2; + break; + default: + p.index = -1; + } + }); + + const categoryIndex = AirQuality.CategoryIndex(totalRisk, scale.categories); + const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 }); + + Console.info("✅ PollutantsToAQHIMulti", `AQHI: ${categoryIndex}, primaryPollutant: ${primaryPollutant.pollutantType}`); + return { + index: totalRisk, + isSignificant: categoryIndex >= scale.categories.significantIndex, + categoryIndex, + pollutants: convertedPollutants, + metadata: { providerName: "iRingo", temporarilyUnavailable: false }, + primaryPollutant: primaryPollutant.pollutantType, + scale: AirQuality.ToWeatherKitScale(scale.weatherKitScale), + }; + } + static Config = { Scales: { /** @@ -1810,6 +1986,232 @@ export default class AirQuality { }, }, }, +HK_AQHI: { + weatherKitScale: { + name: "HK.AQHI", + }, + categories: { + significantIndex: 7, // 高 (AQHI 7 及以上) + ranges: [ + { categoryIndex: 1, indexes: [0, 1.87] }, + { categoryIndex: 2, indexes: [1.88, 3.73] }, + { categoryIndex: 3, indexes: [3.74, 5.6] }, + { categoryIndex: 4, indexes: [5.61, 7.46] }, + { categoryIndex: 5, indexes: [7.47, 9.33] }, + { categoryIndex: 6, indexes: [9.34, 11.2] }, + { categoryIndex: 7, indexes: [11.21, 12.81] }, + { categoryIndex: 8, indexes: [12.82, 14.94] }, + { categoryIndex: 9, indexes: [14.95, 17.08] }, + { categoryIndex: 10, indexes: [17.09, 19.21] }, + { categoryIndex: 11, indexes: [19.22, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µg/m³ + betas: { + NO2: 0.0004462559, + SO2: 0.0001393235, + O3: 0.0004888034, + PM10: 0.0002821751, + PM2_5: 0.0002180567, + }, + // pollutants 仅声明目标单位(均为 µg/m³),供 ConvertUnits 使用 + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM10: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + /** + * Canada Air Quality Health Index (AQHI). + * [10.1016/j.atmosenv.2024.120473]{@link https://doi.org/10.1016/j.atmosenv.2024.120473} + */ + CA_AQHI: { + weatherKitScale: { + name: "CA.AQHI", + }, + categories: { + significantIndex: 7, // High Health Risk (AQHI > 7) + ranges: [ + { categoryIndex: 1, indexes: [0, 1.04] }, + { categoryIndex: 2, indexes: [1.05, 2.08] }, + { categoryIndex: 3, indexes: [2.09, 3.12] }, + { categoryIndex: 4, indexes: [3.13, 4.16] }, + { categoryIndex: 5, indexes: [4.17, 5.2] }, + { categoryIndex: 6, indexes: [5.21, 6.24] }, + { categoryIndex: 7, indexes: [6.25, 7.28] }, + { categoryIndex: 8, indexes: [7.29, 8.32] }, + { categoryIndex: 9, indexes: [8.33, 9.36] }, + { categoryIndex: 10, indexes: [9.37, 10.4] }, + { categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µ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 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + /** + * China Air Quality Health Index (AQHI). + * [10.1016/j.atmosenv.2024.120473]{@link https://doi.org/10.1016/j.atmosenv.2024.120473} + */ + CN_DEATH_AQHI: { + weatherKitScale: { + name: "CN.AQHI", + }, + categories: { + significantIndex: 6, // 四级 (AQHI 6 及以上) + // Categories in AQHI: 一级:0~1,二级:2~3.5,三级:3.6~5,四级:6~10,五级:11 + ranges: [ + { categoryIndex: 1, indexes: [0, 2.004] }, + { categoryIndex: 2, indexes: [2.005, 4.008] }, + // 3: 4.009~6.012 + { categoryIndex: 3, indexes: [4.009, 7.018] }, + // 4: 6.013~8.016 + { categoryIndex: 4, indexes: [7.018, 8.016] }, + { categoryIndex: 5, indexes: [8.017, 10.02] }, + { categoryIndex: 6, indexes: [10.021, 12.024] }, + { categoryIndex: 7, indexes: [12.025, 14.028] }, + { categoryIndex: 8, indexes: [14.029, 16.032] }, + { categoryIndex: 9, indexes: [16.033, 18.036] }, + { categoryIndex: 10, indexes: [18.037, 20.04] }, + { categoryIndex: 11, indexes: [20.041, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µg/m³ + betas: { + NO2: 0.0009, + SO2: 0.00059, + O3: 0.00024, + PM2_5: 0.00022, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + CN_DEATH_HK_AQHI: { + weatherKitScale: { + name: "CN.AQHI", + }, + categories: { + significantIndex: 6, // 四级 (AQHI 6 及以上) + // Categories in AQHI: 一级:0~1,二级:2~3.5,三级:3.6~5,四级:6~10,五级:11 + ranges: [ + { categoryIndex: 1, indexes: [0, 2.004] }, + { categoryIndex: 2, indexes: [2.005, 4.008] }, + // 3: 4.009~6.012 + { categoryIndex: 3, indexes: [4.009, 7.018] }, + // 4: 6.013~8.016 + { categoryIndex: 4, indexes: [7.018, 8.016] }, + { categoryIndex: 5, indexes: [8.017, 10.02] }, + { categoryIndex: 6, indexes: [10.021, 12.024] }, + { categoryIndex: 7, indexes: [12.025, 14.028] }, + { categoryIndex: 8, indexes: [14.029, 16.032] }, + { categoryIndex: 9, indexes: [16.033, 18.036] }, + { categoryIndex: 10, indexes: [18.037, 20.04] }, + { categoryIndex: 11, indexes: [20.041, Number.POSITIVE_INFINITY] }, + ], + }, + // β 系数(回归系数),浓度单位 µg/m³ + betas: { + NO2: 0.0009, + SO2: 0.00059, + O3: 0.00024, + PM10: 0.0002821751, + PM2_5: 0.0002180567, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM10: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + AQHI_Multi_CN: { + weatherKitScale: { + name: "CA.AQHI", + }, + categories: { + significantIndex: 7, + // 以 WHO AQG 2021: PM2.5 24h=15, O3=100, NO2=25, SO2=40 对应的风险值校准 AQHI=3。 + ranges: [ + { categoryIndex: 1, indexes: [0, 5.8453] }, + { categoryIndex: 2, indexes: [5.8454, 11.6906] }, + { categoryIndex: 3, indexes: [11.6907, 17.536] }, + { categoryIndex: 4, indexes: [17.5361, 23.3812] }, + { categoryIndex: 5, indexes: [23.3813, 29.2265] }, + { categoryIndex: 6, indexes: [29.2266, 35.0718] }, + { categoryIndex: 7, indexes: [35.0719, 40.9171] }, + { categoryIndex: 8, indexes: [40.9172, 46.7624] }, + { categoryIndex: 9, indexes: [46.7625, 52.6077] }, + { categoryIndex: 10, indexes: [52.6078, 58.453] }, + { categoryIndex: 11, indexes: [58.4531, Number.POSITIVE_INFINITY] }, + ], + }, + beta: 0.369991, + weights: { + O3: 0.271273, + NO2: 0.390781, + PM2_5: 0.120648, + SO2: 0.217224, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, + AQHI_Multi_CN_HK: { + weatherKitScale: { + name: "CA.AQHI", + }, + categories: { + significantIndex: 7, + // 以 WHO AQG 2021: PM2.5 24h=15, O3=100, NO2=25, SO2=40 对应的风险值校准 AQHI=3。 + ranges: [ + { categoryIndex: 1, indexes: [0, 5.8453] }, + { categoryIndex: 2, indexes: [5.8454, 11.6906] }, + { categoryIndex: 3, indexes: [11.6907, 17.536] }, + { categoryIndex: 4, indexes: [17.5361, 23.3812] }, + { categoryIndex: 5, indexes: [23.3813, 29.2265] }, + { categoryIndex: 6, indexes: [29.2266, 35.0718] }, + { categoryIndex: 7, indexes: [35.0719, 40.9171] }, + { categoryIndex: 8, indexes: [40.9172, 46.7624] }, + { categoryIndex: 9, indexes: [46.7625, 52.6077] }, + { categoryIndex: 10, indexes: [52.6078, 58.453] }, + { categoryIndex: 11, indexes: [58.4531, Number.POSITIVE_INFINITY] }, + ], + }, + beta: 0.369991, + weights: { + O3: 0.271273, + NO2: 0.390781, + // Risk from HK AQHI: HK_AQHI_RR(PM10) / HK_AQHI_RR(PM2.5) * AQHI_MULTI_CN_RR(PM2.5) + PM10: 0.156123, + PM2_5: 0.120648, + SO2: 0.217224, + }, + pollutants: { + NO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + SO2: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + OZONE: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM10: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, + }, + }, }, Pollutants: { co: "CO", diff --git a/tests/egern-wk3/aq-algorithms.test.mjs b/tests/egern-wk3/aq-algorithms.test.mjs new file mode 100644 index 00000000..7295e307 --- /dev/null +++ b/tests/egern-wk3/aq-algorithms.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { default: AirQuality } = await import("../../src/egern/wk3/class/AirQuality.mjs"); + +const pollutants = [ + { pollutantType: "PM2_5", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "PM10", amount: 50, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "OZONE", amount: 100, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "NO2", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }, + { pollutantType: "SO2", amount: 40, units: "MICROGRAMS_PER_CUBIC_METER" }, +]; +const input = { metadata: { providerName: "test" }, pollutants }; +const algorithms = [ + "None", "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", +]; + +test("WK3 exposes all twelve upstream AQ algorithms without silent UBA fallback", () => { + for (const algorithm of algorithms) { + if (algorithm === "None") { + assert.equal(AirQuality.Pollutants2AQI(input, {}, { algorithm }), input); + continue; + } + assert.ok(AirQuality.Config.Scales[algorithm], `missing scale: ${algorithm}`); + const result = AirQuality.Pollutants2AQI(input, {}, { algorithm }); + assert.equal(result.metadata?.temporarilyUnavailable, false, algorithm); + assert.ok(Number.isFinite(result.index), `${algorithm}: index`); + assert.ok(Number.isFinite(result.categoryIndex), `${algorithm}: categoryIndex`); + assert.equal(result.scale, AirQuality.ToWeatherKitScale(AirQuality.Config.Scales[algorithm].weatherKitScale), `${algorithm}: scale`); + } +}); + +test("AQHI algorithms use their distinct upstream risk/index calculation", () => { + const expected = { + HK_AQHI: ["HK.AQHI", 5], + CA_AQHI: ["CA.AQHI", 9], + CN_DEATH_AQHI: ["CN.AQHI", 4], + CN_DEATH_HK_AQHI: ["CN.AQHI", 5], + AQHI_Multi_CN: ["CA.AQHI", 4], + AQHI_Multi_CN_HK: ["CA.AQHI", 4], + }; + for (const [algorithm, [scale, categoryIndex]] of Object.entries(expected)) { + const result = AirQuality.Pollutants2AQI(input, {}, { algorithm }); + assert.equal(result.scale, scale, algorithm); + assert.equal(result.categoryIndex, categoryIndex, algorithm); + assert.equal(result.index, categoryIndex, `${algorithm}: AQHI index mirrors category`); + } +}); + +test("unknown algorithm behavior remains the existing UBA fallback", () => { + const expected = AirQuality.Pollutants2AQI(input, {}, { algorithm: "UBA" }); + const actual = AirQuality.Pollutants2AQI(input, {}, { algorithm: "not-a-supported-algorithm" }); + assert.equal(actual.index, expected.index); + assert.equal(actual.categoryIndex, expected.categoryIndex); + assert.equal(actual.scale, expected.scale); +}); From 88f1a067e309ea55735fd4e9c43ab9e35be6bd37 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:54:54 +0800 Subject: [PATCH 11/42] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=88Egern=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E7=B2=BE=E7=AE=80=E6=A8=A1=E5=9D=97=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=B9=B6=E5=BC=80=E6=94=BE=E7=A9=BA=E6=B0=94=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E7=AE=97=E6=B3=95=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/iRingo.WeatherKit.WK3.yaml | 14 ++++++-------- scripts/build-egern-module.mjs | 2 +- src/egern/entry/parameters.mjs | 12 ++++++------ template/egern.wk3.handlebars | 10 ++++------ 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml index 40afb959..ab705435 100644 --- a/modules/iRingo.WeatherKit.WK3.yaml +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -1,10 +1,8 @@ -# Egern local execution; configurable WK3 module -name: 'WeatherKit-Local-WK3' -description: 'Egern 本机处理:模块内调整参数;默认彩云美标、分钟降水、同源昨日对比、苹果原生预警' -author: meme -homepage: https://github.com/meme-lau/weatherkit-proxy +name: 'WeatherKit-WK3' +description: '本机 WK3:美标 AQ、分钟雨、昨日对比、原生预警' +author: WeatherKit-WK3 contributors +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png -dns: {} rules: - and: match: @@ -89,8 +87,8 @@ rules: match: tether.edge.apple policy: REJECT-DROP compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_CN","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} -compat_arguments_desc: "DataSets: [数据集] 解析与处理范围\n 默认 airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges;逗号分隔;填 [] 全部透传。WK3 实际支持空气质量、分钟降水与预警,其余保持苹果数据。\nWeather.Replace: [天气] 替换范围\n 默认 CN;逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。\nWeather.Provider: [天气] 数据源\n 默认 ColorfulClouds;WK2 天气数据源;WK3 当前/逐日/逐小时天气仍透传。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n 默认 false;用于 WK2;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n 默认 false;用于 WK2;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n 默认 QWeatherWeb;QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。\nNextHour.Provider: [分钟降水] 数据源\n 默认 ColorfulClouds;WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 默认 ColorfulCloudsUS;默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换范围(原始标准)\n 默认 HJ6332012;逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制国标主要污染物\n 默认 true;沿用原项目:国标指数≤50时仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 默认 ColorfulClouds;用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。\nAirQuality.Current.Pollutants.Units.Replace: [污染物单位] 替换原标准\n 默认 [];默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。\nAirQuality.Current.Pollutants.Units.Mode: [污染物单位] 转换模式\n 默认 Scale;Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [昨日对比] 今日变化时重算\n 默认 true;WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 默认 ColorfulCloudsUS;默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 默认 ColorfulCloudsCN;原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。\nAirQuality.Calculate.Algorithm: [内置算法] 计算公式\n 默认 WAQI_InstantCast_CN;沿用当前默认(当前直接彩云美标时不使用此算法)。WK3 仅支持已验证的目标标准,其他目标透传。\nAirQuality.Calculate.AllowOverRange: [内置算法] 允许指数超标\n 默认 true;原项目计算开关;WK3 完整美标写入仍校验指数0–500。\nAPI.ColorfulClouds.Token: [API] 彩云令牌\n 默认 留空;留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。\nAPI.QWeather.Host: [API] 和风主机\n 默认 devapi.qweather.com;使用和风 API 数据源时填写控制台主机名。\nAPI.QWeather.Token: [API] 和风令牌\n 默认 留空;使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO;默认 INFO,与当前脚本一致;只控制本机日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认 8000;默认8000毫秒;脚本总体超时仍为30秒。" -env_schema: {"DataSets":{"name":"[数据集] 解析与处理范围","description":"逗号分隔;填 [] 全部透传。WK3 实际支持空气质量、分钟降水与预警,其余保持苹果数据。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"WK2 天气数据源;WK3 当前/逐日/逐小时天气仍透传。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"用于 WK2;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"用于 WK2;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。","default_value":"QWeatherWeb","options":["QWeatherWeb","WeatherKit"]},"NextHour.Provider":{"name":"[分钟降水] 数据源","description":"WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换范围(原始标准)","description":"逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制国标主要污染物","description":"沿用原项目:国标指数≤50时仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[污染物单位] 替换原标准","description":"默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[污染物单位] 转换模式","description":"Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[昨日对比] 今日变化时重算","description":"WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[内置算法] 计算公式","description":"沿用当前默认(当前直接彩云美标时不使用此算法)。WK3 仅支持已验证的目标标准,其他目标透传。","default_value":"WAQI_InstantCast_CN","options":["None","WAQI_InstantCast_CN","WAQI_InstantCast_CN_25_DRAFT","WAQI_InstantCast_US","EU_EAQI","UBA"]},"AirQuality.Calculate.AllowOverRange":{"name":"[内置算法] 允许指数超标","description":"原项目计算开关;WK3 完整美标写入仍校验指数0–500。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云令牌","description":"留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风主机","description":"使用和风 API 数据源时填写控制台主机名。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风令牌","description":"使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO,与当前脚本一致;只控制本机日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} +compat_arguments_desc: "DataSets: [数据集] 解析与处理范围\n 逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。\nWeather.Provider: [天气] 数据源\n WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。\nNextHour.Provider: [分钟降水] 数据源\n WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换范围(原始标准)\n 逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制国标主要污染物\n 沿用原项目:国标指数≤50时仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。\nAirQuality.Current.Pollutants.Units.Replace: [污染物单位] 替换原标准\n 默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。\nAirQuality.Current.Pollutants.Units.Mode: [污染物单位] 转换模式\n Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [昨日对比] 今日变化时重算\n WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。\nAirQuality.Calculate.Algorithm: [内置算法] 计算公式\n 上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。\nAirQuality.Calculate.AllowOverRange: [内置算法] 允许指数超标\n 沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。\nAPI.ColorfulClouds.Token: [API] 彩云令牌\n 留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。\nAPI.QWeather.Host: [API] 和风主机\n 使用和风 API 数据源时填写控制台主机名。\nAPI.QWeather.Token: [API] 和风令牌\n 使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO,与当前脚本一致;只控制本机日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" +env_schema: {"DataSets":{"name":"[数据集] 解析与处理范围","description":"逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。","default_value":"QWeatherWeb","options":["QWeatherWeb","WeatherKit"]},"NextHour.Provider":{"name":"[分钟降水] 数据源","description":"WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换范围(原始标准)","description":"逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制国标主要污染物","description":"沿用原项目:国标指数≤50时仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[污染物单位] 替换原标准","description":"默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[污染物单位] 转换模式","description":"Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[昨日对比] 今日变化时重算","description":"WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[内置算法] 计算公式","description":"上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。","default_value":"WAQI_InstantCast_CN","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[内置算法] 允许指数超标","description":"沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云令牌","description":"留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风主机","description":"使用和风 API 数据源时填写控制台主机名。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风令牌","description":"使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO,与当前脚本一致;只控制本机日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} scriptings: - http_response: name: WeatherKit.api.availability.response diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs index 01951865..f3a3a7d2 100644 --- a/scripts/build-egern-module.mjs +++ b/scripts/build-egern-module.mjs @@ -15,7 +15,7 @@ export function renderLocalModule(){ const q=JSON.stringify; const compat=Object.fromEntries(localParameters.map(p=>[p.key,p.value])); const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:p.description,default_value:p.value,...(p.boolean?{options:['true','false']}:p.options?{options:p.options}:{})}])); - const description=localParameters.map(p=>`${p.key}: ${p.name}\n 默认 ${p.value||'留空'};${p.description}`).join('\n'); + const description=localParameters.map(p=>`${p.key}: ${p.name}\n ${p.description}`).join('\n'); // JSON objects are valid YAML. Quote individual substitution values using // YAML single quotes: literal double quotes in array syntax remain safe. let env=' env:\n';for(const p of localParameters)env+=` ${p.key}: '{{{${p.key}}}}'\n`; diff --git a/src/egern/entry/parameters.mjs b/src/egern/entry/parameters.mjs index 9ce613ac..6548bd6e 100644 --- a/src/egern/entry/parameters.mjs +++ b/src/egern/entry/parameters.mjs @@ -1,11 +1,11 @@ // Parameter keys follow the fork's settings and upstream argument templates. // Defaults reproduce the existing local module; controls do not expand WK3 support. export const localParameters = [ - { key: "DataSets", name: "[数据集] 解析与处理范围", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", description: "逗号分隔;填 [] 全部透传。WK3 实际支持空气质量、分钟降水与预警,其余保持苹果数据。" }, + { key: "DataSets", name: "[数据集] 解析与处理范围", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", description: "逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。" }, { key: "Weather.Replace", name: "[天气] 替换范围", value: "CN", description: "逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。" }, - { key: "Weather.Provider", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WK2 天气数据源;WK3 当前/逐日/逐小时天气仍透传。" }, - { key: "Weather.ReplaceDaily", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "用于 WK2;WK3 逐日预报保持苹果数据。" }, - { key: "Weather.ReplaceHourly", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "用于 WK2;WK3 逐小时预报保持苹果数据。" }, + { key: "Weather.Provider", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。" }, + { key: "Weather.ReplaceDaily", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐日预报保持苹果数据。" }, + { key: "Weather.ReplaceHourly", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐小时预报保持苹果数据。" }, { key: "WeatherAlerts.Provider", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["QWeatherWeb", "WeatherKit"], description: "QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。" }, { key: "NextHour.Provider", name: "[分钟降水] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。" }, { key: "AirQuality.Current.Index.Provider", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate"], description: "默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。" }, @@ -17,8 +17,8 @@ export const localParameters = [ { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", name: "[昨日对比] 今日变化时重算", value: "true", boolean: true, description: "WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。" }, { key: "AirQuality.Comparison.Yesterday.IndexProvider", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate", "WeatherKit"], description: "默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。" }, { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", name: "[昨日污染物] 数据源", value: "ColorfulCloudsCN", options: ["ColorfulCloudsCN", "QWeather"], description: "原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。" }, - { key: "AirQuality.Calculate.Algorithm", name: "[内置算法] 计算公式", value: "WAQI_InstantCast_CN", options: ["None", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "WAQI_InstantCast_US", "EU_EAQI", "UBA"], description: "沿用当前默认(当前直接彩云美标时不使用此算法)。WK3 仅支持已验证的目标标准,其他目标透传。" }, - { key: "AirQuality.Calculate.AllowOverRange", name: "[内置算法] 允许指数超标", value: "true", boolean: true, description: "原项目计算开关;WK3 完整美标写入仍校验指数0–500。" }, + { key: "AirQuality.Calculate.Algorithm", name: "[内置算法] 计算公式", value: "WAQI_InstantCast_CN", options: ["None", "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"], description: "上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。" }, + { key: "AirQuality.Calculate.AllowOverRange", name: "[内置算法] 允许指数超标", value: "true", boolean: true, description: "沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。" }, { key: "API.ColorfulClouds.Token", name: "[API] 彩云令牌", value: "", description: "留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。" }, { key: "API.QWeather.Host", name: "[API] 和风主机", value: "devapi.qweather.com", description: "使用和风 API 数据源时填写控制台主机名。" }, { key: "API.QWeather.Token", name: "[API] 和风令牌", value: "", description: "使用和风 API 数据源时需要;网页预警补全不需要。" }, diff --git a/template/egern.wk3.handlebars b/template/egern.wk3.handlebars index 455e1227..63b7f8ef 100644 --- a/template/egern.wk3.handlebars +++ b/template/egern.wk3.handlebars @@ -1,10 +1,8 @@ -# Egern local execution; configurable WK3 module -name: 'WeatherKit-Local-WK3' -description: 'Egern 本机处理:模块内调整参数;默认彩云美标、分钟降水、同源昨日对比、苹果原生预警' -author: meme -homepage: https://github.com/meme-lau/weatherkit-proxy +name: 'WeatherKit-WK3' +description: '本机 WK3:美标 AQ、分钟雨、昨日对比、原生预警' +author: WeatherKit-WK3 contributors +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png -dns: {} rules: - and: match: From 66ec032d946bcf998ef638ff5398a78dba04c8b7 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:55:18 +0800 Subject: [PATCH 12/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88WK3=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E7=94=9F=E6=88=90=E5=90=84=E5=B9=B3=E5=8F=B0=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=BD=AF=E4=BB=B6=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-wk3-modules.mjs | 69 +++++++++++++++ template/wk3.loon.handlebars | 20 +++++ template/wk3.quantumultx.handlebars | 17 ++++ template/wk3.stash.handlebars | 17 ++++ template/wk3.surge.handlebars | 19 ++++ tests/wk3-modules.test.mjs | 129 ++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+) create mode 100644 scripts/build-wk3-modules.mjs create mode 100644 template/wk3.loon.handlebars create mode 100644 template/wk3.quantumultx.handlebars create mode 100644 template/wk3.stash.handlebars create mode 100644 template/wk3.surge.handlebars create mode 100644 tests/wk3-modules.test.mjs diff --git a/scripts/build-wk3-modules.mjs b/scripts/build-wk3-modules.mjs new file mode 100644 index 00000000..19b0cccc --- /dev/null +++ b/scripts/build-wk3-modules.mjs @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { localParameters } from '../src/egern/entry/parameters.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const parameters = localParameters; +export const platforms = { surge: 'sgmodule', loon: 'lpx', stash: 'stoverride', quantumultx: 'snippet' }; +export const release = version => `https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v${version}`; +export const groups = [ + { name: 'availability.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/`, stage: 'response', body: true }, + { name: 'airQualityScale.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/`, stage: 'request' }, + { name: 'weatherAlerts.html.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)`, stage: 'request', echo: true }, + { name: 'weatherAlerts.api.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)`, stage: 'request', echo: true }, + { name: 'weather.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/`, stage: 'response', body: true, binary: true }, +]; +const label = group => `WeatherKit.api.${group.name}`; +const script = (group, platform) => `wk3-${platform}-${group.stage}.bundle.js`; +const url = (group, version, platform) => `${release(version)}/${script(group, platform)}`; +const surgeArgument = parameters.map(({ key }) => `${key}="{{{${key}}}}"`).join('&'); +const loonArgument = `[${parameters.map(({ key }) => `{${key}}`).join(',')}]`; +const expand = (text, replacements) => { + for (const [key, value] of Object.entries(replacements)) text = text.replaceAll(`__WK3_${key}__`, value); + if (/__WK3_[A-Z_]+__/.test(text)) throw new Error('Unrendered WK3 template placeholder'); + return text; +}; +const defaults = parameters.map(({ key, value }) => `${key}:${JSON.stringify(value)}`).join(','); +const description = parameters.map(({ key, name, description: desc }) => `${key}: ${name};${desc}`.replaceAll('\n', ' ')).join('\\n'); +const loonArguments = parameters.map(({ key, value, boolean, options, name, description: desc }) => { + const kind = boolean ? `switch,${value}` : options ? `select,${[value, ...options.filter(v => v !== value)].map(v => JSON.stringify(v)).join(',')}` : `input,${JSON.stringify(value)}`; + return `${key} = ${kind},tag=${name},desc=${desc.replaceAll(/[\r\n,]/g, ',')}`; +}).join('\n'); +const quoteYaml = text => `'${text.replaceAll("'", "''")}'`; +const stashArgument = quoteYaml(JSON.stringify(Object.fromEntries(parameters.map(({ key, value }) => [key, value])))); + +export function renderWK3Module(platform, version) { + if (!Object.hasOwn(platforms, platform)) throw new Error(`Unsupported platform: ${platform}`); + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error('Specify an explicit release version, e.g. 3.3.2'); + const template = fs.readFileSync(path.join(root, `template/wk3.${platform}.handlebars`), 'utf8'); + const rows = groups.map(group => { + const tag = label(group), target = url(group, version, platform); + if (platform === 'surge') return `${tag} = type=http-${group.stage}, pattern=${group.pattern.includes('(?:') ? `"${group.pattern}"` : group.pattern}${group.body ? ', requires-body=1' : ''}${group.binary ? ', binary-body-mode=1' : ''}, engine=webview, script-path=${target}, argument=${surgeArgument}`; + if (platform === 'loon') return `http-${group.stage} ${group.pattern}${group.body ? ' requires-body=1,' : ''}${group.binary ? ' binary-body-mode=1,' : ''} script-path=${target}, tag=${tag}, argument=${loonArgument}`; + if (platform === 'stash') return ` - match: ${quoteYaml(group.pattern)}\n name: ${tag}\n type: ${group.stage}${group.body ? '\n require-body: true' : ''}${group.binary ? '\n binary-mode: true' : ''}\n argument: ${stashArgument}`; + const action = group.echo ? 'script-echo-response' : group.stage === 'request' ? 'script-request-header' : 'script-response-body'; + return `# ${tag}${group.binary ? ' (binary response requires device validation)' : ''}\n${group.pattern} url ${action} ${target}`; + }).join('\n'); + const providers = groups.map(group => ` ${label(group)}:\n url: ${url(group, version, platform)}\n interval: 86400`).join('\n'); + return expand(template, { VERSION: version, SCRIPTS: rows, PROVIDERS: providers, ARGUMENTS: platform === 'loon' ? loonArguments : defaults, ARGUMENT_DESC: description }); +} + +export function buildWK3Modules({ outdir, version } = {}) { + if (!outdir) throw new Error('Specify --outdir to avoid changing existing modules'); + const outputs = {}; + for (const [platform, extension] of Object.entries(platforms)) { + const file = path.join(outdir, `iRingo.WeatherKit.WK3.${extension}`); + const text = renderWK3Module(platform, version); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + outputs[platform] = file; + } + return outputs; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const readFlag = name => { const index = process.argv.indexOf(name); return index === -1 ? undefined : process.argv[index + 1]; }; + const outputs = buildWK3Modules({ outdir: readFlag('--outdir'), version: readFlag('--version') }); + for (const [platform, file] of Object.entries(outputs)) console.log(`${platform}: ${file}`); +} diff --git a/template/wk3.loon.handlebars b/template/wk3.loon.handlebars new file mode 100644 index 00000000..40a9c289 --- /dev/null +++ b/template/wk3.loon.handlebars @@ -0,0 +1,20 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 本机 WK3:空气质量算法可选、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = __WK3_VERSION__ + +[Argument] +__WK3_ARGUMENTS__ + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +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 + +[Script] +__WK3_SCRIPTS__ + +[MITM] +hostname = weatherkit.apple.com diff --git a/template/wk3.quantumultx.handlebars b/template/wk3.quantumultx.handlebars new file mode 100644 index 00000000..ab1a26aa --- /dev/null +++ b/template/wk3.quantumultx.handlebars @@ -0,0 +1,17 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 本机 WK3:空气质量算法依运行时默认值、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = __WK3_VERSION__ +# QuantumultX 本地脚本不传模块参数;使用运行时默认值。 + +#[filter_local] +host, weather-analytics-events.apple.com, reject +host-suffix, tthr.apple.com, reject +host, tether.edge.apple, reject + +#[rewrite_local] +__WK3_SCRIPTS__ + +#[mitm] +hostname = weatherkit.apple.com diff --git a/template/wk3.stash.handlebars b/template/wk3.stash.handlebars new file mode 100644 index 00000000..9b1a2ab7 --- /dev/null +++ b/template/wk3.stash.handlebars @@ -0,0 +1,17 @@ +name: WeatherKit-Local-WK3 +desc: 本机 WK3:空气质量算法可选、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +version: "__WK3_VERSION__" +rules: + - DOMAIN,weather-analytics-events.apple.com,REJECT-DROP + - 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 +http: + mitm: + - weatherkit.apple.com + script: +__WK3_SCRIPTS__ +script-providers: +__WK3_PROVIDERS__ diff --git a/template/wk3.surge.handlebars b/template/wk3.surge.handlebars new file mode 100644 index 00000000..dca3388d --- /dev/null +++ b/template/wk3.surge.handlebars @@ -0,0 +1,19 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 本机 WK3:空气质量算法可选、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = __WK3_VERSION__ +#!arguments = __WK3_ARGUMENTS__ +#!arguments-desc = __WK3_ARGUMENT_DESC__ + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP,extended-matching +DOMAIN,tether.edge.apple,REJECT-DROP,extended-matching +AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP + +[Script] +__WK3_SCRIPTS__ + +[MITM] +hostname = %APPEND% weatherkit.apple.com diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs new file mode 100644 index 00000000..ac845a38 --- /dev/null +++ b/tests/wk3-modules.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { localParameters } from '../src/egern/entry/parameters.mjs'; +import { buildWK3Modules, groups, platforms, release, renderWK3Module } from '../scripts/build-wk3-modules.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const base = release('3.3.2'); +const rows = (platform, text) => { + if (platform === 'surge') return text.split('\n').filter(line => /^WeatherKit\.api\./.test(line)); + if (platform === 'loon') return text.split('\n').filter(line => /^http-(?:request|response) /.test(line)); + if (platform === 'stash') return text.split('\n').filter(line => /^ - match: /.test(line)); + return text.split('\n').filter(line => /^\^https/.test(line)); +}; + +const examples = [ + ['https://weatherkit.apple.com/api/v1/availability/en_US', 0], + ['https://weatherkit.apple.com/api/v2/availability/en_US', 0], + ['https://weatherkit.apple.com/api/v1/airQualityScale/en_US', 1], + ['https://weatherkit.apple.com/api/v2/airQualityScale/en_US', 1], + ['https://weatherkit.apple.com/api/v1/weatherAlerts?ids=CN-123456789', 2], + ['https://weatherkit.apple.com/api/v2/weatherAlerts?language=zh&ids=CN-123456789', 2], + ['https://weatherkit.apple.com/api/v1/weatherAlerts?ids=31.2,121.5', 3], + ['https://weatherkit.apple.com/api/v2/weatherAlerts?ids=wk3_123', 3], + ['https://weatherkit.apple.com/api/v2/weatherAlerts?ids=wk3_foo-123456789', 3], + ['https://weatherkit.apple.com/api/v2/weather/en_US/31/121', 4], + ['https://weatherkit.apple.com/api/v3/weather/en_US/31/121', 4], +]; + +test('five disjoint WK3 routing groups cover the supported API versions', () => { + assert.equal(groups.length, 5); + for (const [url, expected] of examples) { + assert.deepEqual(groups.flatMap(({ pattern }, i) => new RegExp(pattern).test(url) ? [i] : []), [expected], url); + } + for (const url of ['https://weatherkit.apple.com/api/v1/weather/en_US/', 'https://weatherkit.apple.com/api/v3/availability/', 'https://example.com/api/v3/weather/', 'https://weatherkit.apple.com/api/v1/weatherAlerts?ids=garbage']) { + assert.equal(groups.some(({ pattern }) => new RegExp(pattern).test(url)), false, url); + } +}); + +for (const platform of Object.keys(platforms)) test(`${platform}: structure, five groups, versioned scripts and MITM`, () => { + const text = renderWK3Module(platform, '3.3.2'); + const rules = rows(platform, text); + assert.equal(rules.length, 5); + assert.match(text, /weatherkit\.apple\.com/); + assert.match(text, /weather-analytics-events\.apple\.com/); + assert.match(text, /tthr\.apple\.com/); + assert.match(text, /tether\.edge\.apple/); + assert.match(text, /当前\/逐日\/逐小时天气保持苹果原始数据/); + assert.doesNotMatch(text, /__WK3_|releases\/latest|shadowrocket|NSRingo\/WeatherKit\/releases|gist\.githubusercontent/); + for (const [index, group] of groups.entries()) { + assert.ok(rules[index].includes(group.pattern), `${platform} ${group.name} regex`); + const url = `${base}/wk3-${platform}-${group.stage}.bundle.js`; + if (platform === 'stash') { + const provider = text.split(` WeatherKit.api.${group.name}:\n`)[1]?.split(' interval: 86400')[0]; + assert.ok(provider?.includes(`url: ${url}`), `${platform} ${group.name} provider URL`); + } else assert.ok(rules[index].includes(url), `${platform} ${group.name} URL`); + } + assert.equal(text.split(`wk3-${platform}-request.bundle.js`).length - 1, 3); + assert.equal(text.split(`wk3-${platform}-response.bundle.js`).length - 1, 2); + assert.equal((text.match(/v3\.3\.2\//g) ?? []).length, 5); + if (platform === 'surge') { + assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"WAQI_InstantCast_CN"/m); + assert.match(rules[4], /requires-body=1, binary-body-mode=1/); + assert.match(rules[0], /requires-body=1/); + assert.ok(rules.slice(1, 4).every(line => !line.includes('binary-body-mode=1'))); + assert.match(rules[0], /argument=DataSets="\{\{\{DataSets\}\}\}"&/); + assert.match(text, /hostname = %APPEND% weatherkit\.apple\.com/); + } + if (platform === 'loon') { + const args = text.split('[Argument]\n')[1].split('\n[Rule]')[0].trim().split('\n'); + assert.equal(args.length, 23); + for (const { key } of localParameters) assert.ok(args.some(line => line.startsWith(`${key} = `)), key); + for (const key of ['Weather.ReplaceDaily', 'Weather.ReplaceHourly']) assert.match(args.find(line => line.startsWith(`${key} =`)), /switch,false/); + assert.match(args.find(line => line.startsWith('Weather.Provider =')), /select,"ColorfulClouds","WeatherKit","QWeather"/); + const algorithms = args.find(line => line.startsWith('AirQuality.Calculate.Algorithm =')); + const officialOrder = ['None', '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']; + assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"WAQI_InstantCast_CN",/); + assert.deepEqual(algorithms.split(',tag=')[0].match(/"[^"]+"/g).map(value => JSON.parse(value)), + ['WAQI_InstantCast_CN', ...officialOrder.filter(value => value !== 'WAQI_InstantCast_CN')]); + assert.match(rules[4], /requires-body=1, binary-body-mode=1/); + assert.match(rules[0], /requires-body=1/); + assert.match(rules[0], /argument=\[\{DataSets\},/); + } + if (platform === 'stash') { + const scripts = text.split(' script:\n')[1].split('script-providers:')[0]; + const providers = text.split('script-providers:\n')[1]; + assert.equal((scripts.match(/^ name:/gm) ?? []).length, 5); + assert.equal((scripts.match(/^ type: request$/gm) ?? []).length, 3); + assert.equal((scripts.match(/^ type: response$/gm) ?? []).length, 2); + assert.equal((scripts.match(/^ require-body: true$/gm) ?? []).length, 2); + assert.equal((scripts.match(/^ binary-mode: true$/gm) ?? []).length, 1); + const argumentsJSON = [...scripts.matchAll(/^ argument: '([^']+)'$/gm)].map(([, value]) => JSON.parse(value)); + assert.equal(argumentsJSON.length, 5); + for (const values of argumentsJSON) { + assert.equal(Object.keys(values).length, 23); + assert.equal(values['AirQuality.Calculate.Algorithm'], 'WAQI_InstantCast_CN'); + assert.equal(values['AirQuality.Current.Index.Provider'], 'ColorfulCloudsUS'); + } + for (const group of groups) assert.match(providers, new RegExp(`^ WeatherKit\\.api\\.${group.name.replaceAll('.', '\\.')}:[\\s\\S]*? interval: 86400`, 'm')); + } + if (platform === 'quantumultx') { + assert.match(text, /#\[filter_local\]/); + assert.match(text, /#\[rewrite_local\]/); + assert.match(text, /#\[mitm\]/); + assert.equal((rules.join('\n').match(/script-response-body/g) ?? []).length, 2); + assert.equal((rules.join('\n').match(/script-request-header/g) ?? []).length, 1); + assert.equal((rules.join('\n').match(/script-echo-response/g) ?? []).length, 2); + assert.doesNotMatch(text, /binary-body-mode=1|binary-mode: true/); + } +}); + +test('explicit CLI release and output location; no implicit latest or shadowrocket', () => { + assert.throws(() => renderWK3Module('shadowrocket', '3.3.2'), /Unsupported platform/); + assert.throws(() => renderWK3Module('surge', 'latest'), /explicit release version/); + assert.throws(() => buildWK3Modules({ version: '3.3.2' }), /--outdir/); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wk3-modules-')); + try { + const cli = spawnSync(process.execPath, [path.join(root, 'scripts/build-wk3-modules.mjs'), '--version', '3.3.2', '--outdir', dir], { encoding: 'utf8' }); + assert.equal(cli.status, 0, cli.stderr); + assert.deepEqual(fs.readdirSync(dir).sort(), Object.values(platforms).map(ext => `iRingo.WeatherKit.WK3.${ext}`).sort()); + for (const [platform, ext] of Object.entries(platforms)) { + assert.equal(fs.readFileSync(path.join(dir, `iRingo.WeatherKit.WK3.${ext}`), 'utf8'), renderWK3Module(platform, '3.3.2')); + } + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); From a89b7a84d2087edb324b647adbe5765eed665144 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:55:39 +0800 Subject: [PATCH 13/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=88WK3=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E9=80=82=E9=85=8D=E5=B9=B3=E5=8F=B0=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=B9=B6=E9=AA=8C=E8=AF=81=E4=BA=8C=E8=BF=9B?= =?UTF-8?q?=E5=88=B6=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-wk3-platform.mjs | 67 +++++++++++++ src/platform/wk3-adapter.mjs | 115 +++++++++++++++++++++ tests/wk3-platform-adapter.test.mjs | 149 ++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 scripts/build-wk3-platform.mjs create mode 100644 src/platform/wk3-adapter.mjs create mode 100644 tests/wk3-platform-adapter.test.mjs diff --git a/scripts/build-wk3-platform.mjs b/scripts/build-wk3-platform.mjs new file mode 100644 index 00000000..4bc120d5 --- /dev/null +++ b/scripts/build-wk3-platform.mjs @@ -0,0 +1,67 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { mkdir, writeFile } from "node:fs/promises"; +import { rollup } from "rollup"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const platforms = new Set(["surge", "loon", "stash", "quantumultx"]); +const logModule = 'const levels={OFF:0,ERROR:1,WARN:2,INFO:3,DEBUG:4,ALL:5};let settings={};try{settings=JSON.parse(globalThis.$environment?.SETTINGS||globalThis.$env?.SETTINGS||"{}")}catch{}const level=levels[globalThis.$environment?.LogLevel??globalThis.$env?.LogLevel??settings.LogLevel??"INFO"]??3;const fmt=a=>a.map(x=>typeof x==="object"&&x!==null?JSON.stringify(x,null,2):x);export const Console=Object.fromEntries([["debug",4],["info",3],["warn",2],["error",1],["log",1],["exception",1]].map(([k,n])=>[k,(...a)=>{if(level>=n)console[k==="exception"?"error":k](...fmt(a))}]));'; +function runtimePlugin() { + const virtual = new Map([ + ["wk3:runtime", 'let runtime; export const setRuntime=value=>{runtime=value}; export const webFetch=(...args)=>runtime.webFetch(...args); export const runtimeFetch=(...args)=>runtime.fetch(...args);'], + [path.join(root, "src/egern/wk3/utils/context.mjs"), "export const requestContext=undefined;"], + [path.join(root, "src/egern/wk3/utils/fetch.mjs"), 'export { runtimeFetch as fetch } from "wk3:runtime";'], + [path.join(root, "src/egern/wk3/utils/Console.mjs"), logModule], + ]); + return { + name: "wk3-platform-runtime", + resolveId(source, importer) { + if (source === "egern:runtime" || source === "wk3:runtime") return "wk3:runtime"; + if (!importer || !/(?:^|\/)(?:Console|context|fetch)\.mjs$/.test(source)) return null; + const resolved = path.resolve(path.dirname(importer), source); + return virtual.has(resolved) ? resolved : null; + }, + load(id) { return virtual.get(id) ?? null; }, + transform(code, id) { + if (!code.includes("globalThis.fetch") || id === "wk3:runtime") return null; + return { code: `import { webFetch as __wk3WebFetch } from "wk3:runtime";\n${code.replace(/\bglobalThis\.fetch\b/g, "__wk3WebFetch")}`, map: null }; + }, + }; +} + +export async function buildPlatform(platform, { outdir, minify = true } = {}) { + if (!platforms.has(platform)) throw new TypeError(`Unverified platform: ${platform}`); + const files = {}; + for (const [name, handlerName] of [["request", "handleRequest"], ["response", "handleResponse"]]) { + const entry = `virtual:wk3-${name}`; + const plugin = { + name: "wk3-platform-entry", + resolveId(id) { return id === entry ? id : null; }, + load(id) { + if (id !== entry) return null; + return `import { setRuntime } from "wk3:runtime";\nimport { runPlatform } from ${JSON.stringify(path.join(root, "src/platform/wk3-adapter.mjs"))};\nimport { ${handlerName} } from ${JSON.stringify(path.join(root, `src/egern/entry/${name}.mjs`))};\nimport { createRuntime } from ${JSON.stringify(path.join(root, "src/egern/entry/runtime.mjs"))};\nconst __bytes=v=>typeof v===\"string\"?Uint8Array.from(v,c=>c.charCodeAt(0)&255).buffer:v instanceof ArrayBuffer?v:ArrayBuffer.isView(v)?v.buffer.slice(v.byteOffset,v.byteOffset+v.byteLength):new Uint8Array(v).buffer;const __wk3Runtime=createRuntime({env:globalThis.$environment??globalThis.$env??{},http:globalThis.$httpClient?{get:(u,o)=>new Promise((r,j)=>$httpClient.get({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))),post:(u,o)=>new Promise((r,j)=>$httpClient.post({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)})))}:{get:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>__bytes(x.body)}},post:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>new TextEncoder().encode(x.body).buffer}}}});\nsetRuntime(__wk3Runtime);runPlatform(async ctx=>${handlerName}(ctx),"${name}",globalThis,"${platform === "quantumultx" ? "Quantumult X" : platform}");`; + }, + }; + const plugins = [plugin, runtimePlugin(), nodeResolve({ browser: true, preferBuiltins: false })]; + if (minify) plugins.push(terser({ maxWorkers: 1 })); + const build = await rollup({ input: entry, plugins }); + try { + const generated = await build.generate({ format: "iife", name: "__WK3", inlineDynamicImports: true, sourcemap: false }); + const chunk = generated.output.find(file => file.type === "chunk"); + const code = chunk.code.replace(/\nexport \{[^}]*\};?\s*$/, "\n"); + files[name] = { code, bytes: Buffer.byteLength(code) }; + if (outdir) { await mkdir(outdir, { recursive: true }); await writeFile(path.join(outdir, `wk3-${platform}-${name}.bundle.js`), code); } + } finally { await build.close(); } + } + return files; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const platform = process.argv[2]; + if (!platform) throw new Error(`Usage: node scripts/build-wk3-platform.mjs <${[...platforms].join("|")}> [outdir]`); + const outdir = path.resolve(process.argv[3] ?? path.join(root, "dist/platforms", platform)); + const files = await buildPlatform(platform, { outdir }); + for (const [name, file] of Object.entries(files)) console.log(`${path.join(outdir, `wk3-${platform}-${name}.bundle.js`)} ${file.bytes} bytes`); +} diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs new file mode 100644 index 00000000..3a1bd0c0 --- /dev/null +++ b/src/platform/wk3-adapter.mjs @@ -0,0 +1,115 @@ +import { localParameters } from "../egern/entry/parameters.mjs"; + +export async function runPlatform(handler, stage, globals = globalThis, platformOverride) { + const platform = platformOverride ?? (typeof globals.$platform === "string" ? globals.$platform : "generic"); + const request = globals.$request ?? {}; + const sourceResponse = globals.$response ?? {}; + const headers = value => { + const result = {}; + if (value && typeof value.forEach === "function") value.forEach((v, k) => { result[k] = v; }); + else for (const [key, v] of Object.entries(value ?? {})) if (typeof v !== "function") result[key] = v; + return result; + }; + const bytesFromBinaryString = value => Uint8Array.from(value, char => char.charCodeAt(0) & 255); + const binaryStringFromBytes = value => { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let out = ""; + for (let i = 0; i < bytes.length; i += 8192) out += String.fromCharCode(...bytes.subarray(i, i + 8192)); + return out; + }; + const statusOf = response => { + const raw = response.status ?? response.statusCode ?? 200; + if (typeof raw === "number") return raw; + const match = String(raw).match(/\b(\d{3})\b/); + return match ? Number(match[1]) : 200; + }; + const argument = globals.$argument; + let argumentValues = {}; + const parameterKeys = localParameters.map(parameter => parameter.key); + const assignArgument = value => { + if (Array.isArray(value)) { + const booleanKeys = new Set(localParameters.filter(parameter => parameter.boolean).map(parameter => parameter.key)); + for (let i = 0; i < Math.min(value.length, parameterKeys.length); i++) if (value[i] !== undefined) argumentValues[parameterKeys[i]] = booleanKeys.has(parameterKeys[i]) ? value[i] === true || value[i] === "true" : value[i]; + } else if (value && typeof value === "object") Object.assign(argumentValues, value); + }; + if (argument && typeof argument === "object") assignArgument(argument); + else if (typeof argument === "string") { + try { assignArgument(JSON.parse(argument)); } catch { + const query = argument.match(/(?:^|&)\s*([^=&]+)\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^&]*))/g); + if (query?.length) { + for (const field of query) { + const match = field.replace(/^&/, "").match(/^([^=]+)=(?:\"([^\"]*)\"|'([^']*)'|([^&]*))$/); + if (match) { + const key = decodeURIComponent(match[1]).trim(); + const raw = match[2] ?? match[3] ?? match[4] ?? ""; + argumentValues[key] = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1).split(",").map(v => v.trim().replace(/^['\"]|['\"]$/g, "")) : raw === "true" ? true : raw === "false" ? false : decodeURIComponent(raw); + } + } + } else { + const pairs = []; + let start = 0, depth = 0, quote = ""; + for (let i = 0; i <= argument.length; i++) { + const char = argument[i]; + if (quote) { if (char === quote && argument[i - 1] !== "\\\\") quote = ""; } + else if (char === "\"" || char === "'") quote = char; + else if (char === "[" || char === "{") depth++; + else if (char === "]" || char === "}") depth--; + else if ((char === "," || i === argument.length) && depth === 0) { pairs.push(argument.slice(start, i)); start = i + 1; } + } + for (const pair of pairs) { + const index = pair.indexOf(":"); + if (index <= 0) continue; + const key = pair.slice(0, index).trim(); + const raw = pair.slice(index + 1).trim(); + if (raw.startsWith("[") && raw.endsWith("]")) { + const values = raw.slice(1, -1).split(",").map(value => value.trim()).filter(Boolean); + argumentValues[key] = values.map(value => value.replace(/^['\"]|['\"]$/g, "")); + } + else if (raw === "true" || raw === "false") argumentValues[key] = raw === "true"; + else argumentValues[key] = raw.replace(/^(?:\"([\\s\\S]*)\"|'([\\s\\S]*)')$/, (_, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted); + } + } + } + } + const ctx = { + env: { ...(globals.$environment ?? globals.$env ?? {}), ...argumentValues }, + request: { ...request, headers: headers(request.headers) }, + respond(value) { return value; }, + }; + if (stage === "response") { + const body = sourceResponse.bodyBytes ?? sourceResponse.body ?? ""; + const contentType = headers(sourceResponse.headers)["content-type"] ?? headers(sourceResponse.headers)["Content-Type"] ?? ""; + const isBinary = /flatbuffer|protobuf|octet-stream/i.test(contentType); + const binaryInput = typeof body === "string" ? (isBinary ? bytesFromBinaryString(body) : new TextEncoder().encode(body)) : body instanceof ArrayBuffer ? new Uint8Array(body) : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : body; + const binary = binaryInput instanceof Uint8Array ? binaryInput : new TextEncoder().encode(String(body)); + ctx.response = { + status: statusOf(sourceResponse), headers: headers(sourceResponse.headers), + body, + bodyBytes: isBinary ? binary : undefined, + async text() { return new TextDecoder().decode(binary); }, + async arrayBuffer() { return binary.slice().buffer; }, + }; + } + const done = globals.$done; + if (stage === "response" && platform === "Quantumult X" && /(?:flatbuffer|protobuf|octet-stream)/i.test(headers(sourceResponse.headers)["content-type"] ?? headers(sourceResponse.headers)["Content-Type"] ?? "")) return done?.(); + let result; + try { result = await handler(ctx); } + catch (error) { (globals.console ?? console).error(error); result = undefined; } + if (typeof done !== "function") return result; + if (result === undefined || result === null) return done(); + const bodyToPlatform = body => { + if (body instanceof ArrayBuffer) return binaryStringFromBytes(new Uint8Array(body)); + if (ArrayBuffer.isView(body)) return binaryStringFromBytes(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); + return body; + }; + const response = { + status: platform === "Quantumult X" ? `HTTP/1.1 ${result.status ?? 200} ${result.statusText ?? "OK"}` : result.status ?? 200, + headers: { ...headers(result.headers) }, body: bodyToPlatform(result.body ?? ""), + }; + for (const key of Object.keys(response.headers)) if (key.toLowerCase() === "content-length" || key.toLowerCase() === "content-encoding" || key.toLowerCase() === "transfer-encoding") delete response.headers[key]; + if (platform === "Quantumult X") { + delete response.statusText; + return done(response); + } + return done({ response }); +} diff --git a/tests/wk3-platform-adapter.test.mjs b/tests/wk3-platform-adapter.test.mjs new file mode 100644 index 00000000..c493500e --- /dev/null +++ b/tests/wk3-platform-adapter.test.mjs @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { Builder } from "flatbuffers"; +import { encodeAirQuality, decodeWK3DataSet } from "../src/egern/wk3/class/WeatherKit3.mjs"; +import { runPlatform } from "../src/platform/wk3-adapter.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { renderWK3Module } from "../scripts/build-wk3-modules.mjs"; + +const platforms = ["surge", "loon", "stash", "quantumultx"]; +const bytes = [0, 1, 127, 128, 255]; +const binary = String.fromCharCode(...bytes); + +for (const platform of platforms) { + test(`${platform}: $request/$response/$done, binary and Header contract`, async () => { + let doneCalls = 0, returned; + const globals = { + $platform: platform === "quantumultx" ? "Quantumult X" : platform, + $request: { url: "https://weatherkit.apple.com/api/v2/weather/x", method: "GET", headers: { "X-Request": "kept" } }, + $response: { status: 206, headers: { "content-type": "application/octet-stream", "content-length": "5", "content-encoding": "gzip", "x-original": "yes" }, body: binary }, + $done(value) { doneCalls++; returned = value; }, console: { error(error) { throw error; } }, + }; + let observed; + const binaryUnsupported = platform === "quantumultx"; + await runPlatform(async ctx => { + observed = ctx; + assert.equal(ctx.request.url, globals.$request.url); + assert.equal(ctx.request.headers["X-Request"], "kept"); + assert.equal(ctx.response.status, 206); + assert.deepEqual(Array.from(new Uint8Array(await ctx.response.arrayBuffer())), bytes); + return { status: 201, headers: { "Content-Type": binaryUnsupported ? "application/json" : "application/octet-stream", "Content-Length": "5", "Content-Encoding": "gzip", "X-Patched": "yes" }, body: binaryUnsupported ? JSON.stringify({ ok: true }) : new Uint8Array(bytes) }; + }, "response", globals); + assert.equal(doneCalls, 1); + if (binaryUnsupported) { + assert.equal(observed, undefined, "Quantumult X binary handler must not run"); + assert.equal(returned, undefined, "Quantumult X raw binary response is passed through"); + } else { + assert.ok(observed); + const result = returned.response; + assert.equal(result.status, platform === "quantumultx" ? "HTTP/1.1 201 OK" : 201); + assert.equal(result.headers["X-Patched"], "yes"); + assert.ok(!Object.keys(result.headers).some(k => ["content-length", "content-encoding", "transfer-encoding"].includes(k.toLowerCase()))); + if (platform === "quantumultx") assert.equal(result.body, "{\"ok\":true}"); + else assert.deepEqual(Array.from(result.body, c => c.charCodeAt(0)), bytes); + } + + doneCalls = 0; + await runPlatform(async ctx => { assert.equal(ctx.request.url, globals.$request.url); return undefined; }, "request", globals); + assert.equal(doneCalls, 1); + assert.equal(returned, undefined); + }); +} + +test("binary bodyBytes takes precedence over an empty text body", async () => { + let returned; + const globals = { + $request: { url: "https://weatherkit.apple.com/api/v3/weather/x", headers: {} }, + $response: { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: "", bodyBytes: new Uint8Array(bytes) }, + $done(value) { returned = value; }, + }; + await runPlatform(async ctx => { + assert.deepEqual(Array.from(new Uint8Array(await ctx.response.arrayBuffer())), bytes); + return { status: 200, headers: {}, body: new Uint8Array(bytes) }; + }, "response", globals, "surge"); + assert.deepEqual(Array.from(returned.response.body, c => c.charCodeAt(0)), bytes); +}); + +test("all verified platforms produce executable request and response bundles", async () => { + for (const platform of platforms) { + const bundles = await buildPlatform(platform, { minify: false }); + for (const stage of ["request", "response"]) { + const calls = []; + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, console, + $platform: platform === "quantumultx" ? "Quantumult X" : platform, + $environment: {}, $request: { url: "https://elsewhere.invalid/pass-through", method: "GET", headers: {} }, + $response: { status: 503, headers: { "content-type": "text/plain" }, body: "unchanged" }, + $done(value) { calls.push(value); }, + }; + vm.runInNewContext(bundles[stage].code, globals, { timeout: 30000 }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(calls.length, 1, `${platform} ${stage}`); + if (stage === "request") assert.equal(calls[0], undefined); + } + } +}); + +test("Surge bundle patches WK3 AQ using platform HTTP callbacks", async () => { + const builder = new Builder(); + const aq = encodeAirQuality(builder, { metadata: { sourceType: 3, providerName: "Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); + builder.startObject(15); builder.addFieldOffset(0, aq, 0); builder.finish(builder.endObject()); + const original = builder.asUint8Array(); + const bundles = await buildPlatform("surge", { minify: false }); + const calls = [], requests = [], payload = String.fromCharCode(...original); + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, console, + $argument: 'AirQuality.Current.Index.Provider="ColorfulCloudsUS"&AirQuality.Current.Index.Replace="HJ6332012"&AirQuality.Comparison.Yesterday.IndexProvider="WeatherKit"&DataSets="airQuality"', + $request: { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality", headers: {} }, + $response: { status: 200, headers: { "content-type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: payload }, + $httpClient: { get(options, done) { + requests.push(options.url); + if (!options.url.includes("/realtime")) throw new Error(`Unexpected source: ${options.url}`); + done(null, { status: 200, headers: { "content-type": "application/json" } }, JSON.stringify({ status: "ok", location: [31, 121], result: { realtime: { status: "ok", air_quality: { description: { usa: "moderate" }, aqi: { usa: 87, chn: 60 }, obs_time: 1893456000, pm25: 12, pm10: 20, co: 0.4, no2: 10, so2: 5, o3: 40 } } } })); + } }, + $done(value) { calls.push(value); }, + }; + vm.runInNewContext(bundles.response.code, globals, { timeout: 30000 }); + await new Promise(resolve => setTimeout(resolve, 300)); + assert.equal(calls.length, 1); + assert.ok(requests.some(url => url.includes("/realtime")), `No provider request: ${JSON.stringify(requests)}`); + const result = calls[0].response; + assert.equal(decodeWK3DataSet(Uint8Array.from(result.body, char => char.charCodeAt(0))).index, 87); +}); + +test("unknown platform is refused instead of advertised as supported", async () => { + await assert.rejects(buildPlatform("shadowrocket", { minify: false }), /Unverified platform/); +}); + +test("platform arguments preserve nested list settings and booleans", async () => { + let captured; + const globals = { + $environment: { "Weather.Provider": "ColorfulClouds" }, + $argument: 'DataSets:["airQuality","forecastNextHour"],Weather.Replace:["CN","HK"],Weather.Provider:QWeather,AirQuality.Calculate.Algorithm:WAQI_InstantCast_CN_25_DRAFT,AirQuality.Calculate.AllowOverRange:false', + $request: { url: "https://weatherkit.apple.com/api/v2/weather/x", headers: {} }, + $done() {}, + }; + let env; + await runPlatform(async ctx => { env = ctx.env; }, "request", globals, "surge"); + assert.deepEqual(env.DataSets, ["airQuality", "forecastNextHour"]); + assert.deepEqual(env["Weather.Replace"], ["CN", "HK"]); + assert.equal(env["Weather.Provider"], "QWeather"); + assert.equal(env["AirQuality.Calculate.Algorithm"], "WAQI_InstantCast_CN_25_DRAFT"); + assert.equal(env["AirQuality.Calculate.AllowOverRange"], false); + + const surge = renderWK3Module("surge", "3.3.2"); + const argumentTemplate = surge.split("\n").find(line => line.startsWith("WeatherKit.api.weather.response =")).split("argument=")[1]; + const actualArgument = argumentTemplate.replaceAll(/\{\{\{([^}]+)\}\}\}/g, (_, key) => key === "Weather.Provider" ? "QWeather" : key === "AirQuality.Calculate.Algorithm" ? "HK_AQHI" : ""); + await runPlatform(async ctx => { env = ctx.env; }, "request", { ...globals, $argument: actualArgument }, "surge"); + assert.equal(env["Weather.Provider"], "QWeather"); + assert.equal(env["AirQuality.Calculate.Algorithm"], "HK_AQHI"); + + let loonEnv; + const loon = { $argument: '["airQuality,forecastNextHour","CN,HK","QWeather","false","false","QWeatherWeb","ColorfulClouds","ColorfulCloudsUS","HJ6332012","true","ColorfulClouds","[]","Scale","true","ColorfulCloudsUS","ColorfulCloudsCN","WAQI_InstantCast_CN_25_DRAFT","false"]', $request: globals.$request, $done() {} }; + await runPlatform(async ctx => { loonEnv = ctx.env; }, "request", loon, "loon"); + assert.equal(loonEnv.DataSets, "airQuality,forecastNextHour"); + assert.equal(loonEnv["Weather.Provider"], "QWeather"); + assert.equal(loonEnv["AirQuality.Calculate.Algorithm"], "WAQI_InstantCast_CN_25_DRAFT"); + assert.equal(loonEnv["AirQuality.Calculate.AllowOverRange"], false); +}); From a1af1e6d515b554427a653eb36c770ed91d671c4 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:16:56 +0800 Subject: [PATCH 14/42] =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=9A=E8=AF=B4=E6=98=8E=20WK3=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=B9=B6=E5=B0=86=E5=86=85=E7=BD=AE=E7=AE=97=E6=B3=95=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E8=AE=BE=E4=B8=BA=E7=BE=8E=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 46 +++++++++++++++++++++++ modules/iRingo.WeatherKit.WK3.yaml | 4 +- src/egern/entry/parameters.mjs | 2 +- src/egern/wk3/function/database.mjs | 2 +- tests/egern-wk3/egern-parameters.test.mjs | 2 +- tests/wk3-modules.test.mjs | 8 ++-- 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0a7b8332..d59aff4e 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ #  iRingo: 🌤 WeatherKit + +此 Fork 的 `wk3/ios27-wk3-review` 分支以 NSRingo/WeatherKit **v3.3.2** 为基点,保留原版 WK2 入口,并在 `src/egern/` 隔离增加本机 WK3 处理。审查完成前不要将该分支当作正式发行版;`main` 仍是未经这些改动的上游版本。 + +## 本分支更新 + +- WK3 FlatBuffer 有界读写:在已验证结构内替换空气质量,保留其他数据槽与未知字段;国标 AQI 可转换为同源美标 AQI。香港在 Apple WK3 缺少 AQ 数据时,可按现有条件补入彩云美标 AQ。 +- WK3 已实现分钟降水、同源同标准的昨日空气等级比较,以及严格匹配原事件 ID 的天气预警补全;失败或未验证结构保持 Apple 原始数据。 +- 保留 WK2 的天气来源选择与天气处理;内置空气质量算法提供上游的 12 种选择,包含 InstantCast、EAQI、UBA 和 AQHI 系列。**默认 `WAQI_InstantCast_US`**,但仅在今日空气指数来源选择 `Calculate` 时用于本地计算。 +- Egern 模块保留五类脚本、七条分流规则与 23 个可调参数;另外提供 Surge、Loon、Stash 和 Quantumult X 的独立模板及构建脚本。新增内容按功能分为独立提交,不压缩历史。 + +## 模块适配状态 + +| 软件 | 模块格式 | 当前验证范围 | +| --- | --- | --- | +| Egern | `modules/iRingo.WeatherKit.WK3.yaml` | 本机 `ctx` 入口、参数与 WK3 处理通过合成回放;既有 Gist 版的美标 AQI、分钟降水与昨日对比曾经设备确认,本分支新 Rollup 产物尚未设备验证。仓库模块仍指向既有 Gist JS。 | +| Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | +| Loon | `.lpx` | 独立 JS、位置参数与二进制响应模拟通过;未设备验证。 | +| Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | +| Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | +| Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | + +四平台配置生成器在 `scripts/build-wk3-modules.mjs`;各平台独立 JS 构建器在 `scripts/build-wk3-platform.mjs`。平台模板和脚本引用各自独立,不能将 Egern 的 `ctx` 脚本地址直接替换进其他模块。 + +## 默认配置 + +| 设置 | 默认值 | 生效范围 | +| --- | --- | --- | +| 今日空气指数来源 | `ColorfulCloudsUS` | 彩云美标;仅匹配原始标准 `HJ6332012` 后才尝试替换。 | +| 昨日空气指数来源 | `ColorfulCloudsUS` | 与今日同来源、同标准才比较;否则保持未知。 | +| 内置 AQ 计算算法 | `WAQI_InstantCast_US` | **只有来源设为 `Calculate` 才执行**;其余 11 种上游算法可选,WK3 目标标准不兼容时透传。 | +| 分钟降水 | `ColorfulClouds` | 已验证的分钟雨结构;未知条件透传。 | +| 天气预警 | `QWeatherWeb` | 严格匹配后补全原生详情;`WeatherKit` 为原样透传。 | +| 天气来源 | `ColorfulClouds` | WK2 可用;WK3 当前/逐日/逐小时天气仍透传 Apple。 | +| 逐日/逐小时替换 | `false` / `false` | WK2 开关,WK3 不生效。 | +| 天气替换范围 / 请求超时 / 日志 | `CN` / `8000` 毫秒 / `INFO` | 香港缺 AQ 补入另有专用条件;第三方令牌按需在设备上配置。 | + +上述为生成模块的默认参数,不覆盖已安装模块或设备上的个性化参数。改变内置算法默认值也**不会**自动将今日指数来源改为 `Calculate`。 + +## 已知限制与发布 + +- WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 +- Quantumult X 二进制天气不可转换;其他平台代理仅通过脚本模拟,没有客户端实机结论。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 +- Fork **尚无 Release**。`releases/latest/download/...` 或生成模块内的 `releases/download/v<version>/...` 只有在审查、合并、实际构建并上传对应附件后才能用于订阅;目前不要导入这些尚不存在的链接。生成模块不会自动发布附件。 +- 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 + +验证命令:`node --test tests/egern-wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs`。四平台模块需指定**实际已发布**的版本号运行 `node scripts/build-wk3-modules.mjs --version <version> --outdir <output>`;脚本用 `node scripts/build-wk3-platform.mjs <surge|loon|stash|quantumultx> <output>` 构建。发布前还要把模块与 JS 放入实际发行附件并核对模块中每条 URL。 diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml index ab705435..e399cb61 100644 --- a/modules/iRingo.WeatherKit.WK3.yaml +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -86,9 +86,9 @@ rules: - domain: match: tether.edge.apple policy: REJECT-DROP -compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_CN","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} +compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_US","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} compat_arguments_desc: "DataSets: [数据集] 解析与处理范围\n 逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。\nWeather.Provider: [天气] 数据源\n WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。\nNextHour.Provider: [分钟降水] 数据源\n WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换范围(原始标准)\n 逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制国标主要污染物\n 沿用原项目:国标指数≤50时仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。\nAirQuality.Current.Pollutants.Units.Replace: [污染物单位] 替换原标准\n 默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。\nAirQuality.Current.Pollutants.Units.Mode: [污染物单位] 转换模式\n Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [昨日对比] 今日变化时重算\n WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。\nAirQuality.Calculate.Algorithm: [内置算法] 计算公式\n 上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。\nAirQuality.Calculate.AllowOverRange: [内置算法] 允许指数超标\n 沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。\nAPI.ColorfulClouds.Token: [API] 彩云令牌\n 留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。\nAPI.QWeather.Host: [API] 和风主机\n 使用和风 API 数据源时填写控制台主机名。\nAPI.QWeather.Token: [API] 和风令牌\n 使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO,与当前脚本一致;只控制本机日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" -env_schema: {"DataSets":{"name":"[数据集] 解析与处理范围","description":"逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。","default_value":"QWeatherWeb","options":["QWeatherWeb","WeatherKit"]},"NextHour.Provider":{"name":"[分钟降水] 数据源","description":"WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换范围(原始标准)","description":"逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制国标主要污染物","description":"沿用原项目:国标指数≤50时仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[污染物单位] 替换原标准","description":"默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[污染物单位] 转换模式","description":"Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[昨日对比] 今日变化时重算","description":"WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[内置算法] 计算公式","description":"上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。","default_value":"WAQI_InstantCast_CN","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[内置算法] 允许指数超标","description":"沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云令牌","description":"留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风主机","description":"使用和风 API 数据源时填写控制台主机名。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风令牌","description":"使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO,与当前脚本一致;只控制本机日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} +env_schema: {"DataSets":{"name":"[数据集] 解析与处理范围","description":"逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。","default_value":"QWeatherWeb","options":["QWeatherWeb","WeatherKit"]},"NextHour.Provider":{"name":"[分钟降水] 数据源","description":"WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换范围(原始标准)","description":"逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制国标主要污染物","description":"沿用原项目:国标指数≤50时仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[污染物单位] 替换原标准","description":"默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[污染物单位] 转换模式","description":"Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[昨日对比] 今日变化时重算","description":"WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[内置算法] 计算公式","description":"上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。","default_value":"WAQI_InstantCast_US","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[内置算法] 允许指数超标","description":"沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云令牌","description":"留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风主机","description":"使用和风 API 数据源时填写控制台主机名。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风令牌","description":"使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO,与当前脚本一致;只控制本机日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} scriptings: - http_response: name: WeatherKit.api.availability.response diff --git a/src/egern/entry/parameters.mjs b/src/egern/entry/parameters.mjs index 6548bd6e..32e2ae31 100644 --- a/src/egern/entry/parameters.mjs +++ b/src/egern/entry/parameters.mjs @@ -17,7 +17,7 @@ export const localParameters = [ { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", name: "[昨日对比] 今日变化时重算", value: "true", boolean: true, description: "WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。" }, { key: "AirQuality.Comparison.Yesterday.IndexProvider", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate", "WeatherKit"], description: "默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。" }, { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", name: "[昨日污染物] 数据源", value: "ColorfulCloudsCN", options: ["ColorfulCloudsCN", "QWeather"], description: "原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。" }, - { key: "AirQuality.Calculate.Algorithm", name: "[内置算法] 计算公式", value: "WAQI_InstantCast_CN", options: ["None", "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"], description: "上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。" }, + { key: "AirQuality.Calculate.Algorithm", name: "[内置算法] 计算公式", value: "WAQI_InstantCast_US", options: ["None", "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"], description: "上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。" }, { key: "AirQuality.Calculate.AllowOverRange", name: "[内置算法] 允许指数超标", value: "true", boolean: true, description: "沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。" }, { key: "API.ColorfulClouds.Token", name: "[API] 彩云令牌", value: "", description: "留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。" }, { key: "API.QWeather.Host", name: "[API] 和风主机", value: "devapi.qweather.com", description: "使用和风 API 数据源时填写控制台主机名。" }, diff --git a/src/egern/wk3/function/database.mjs b/src/egern/wk3/function/database.mjs index d8d0dba8..3d5c950a 100644 --- a/src/egern/wk3/function/database.mjs +++ b/src/egern/wk3/function/database.mjs @@ -7,7 +7,7 @@ export default { AirQuality: { Current: { Pollutants: { Provider: "ColorfulClouds", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: ["HJ6332012"], Provider: "ColorfulCloudsCN", ForceCNPrimaryPollutants: true } }, Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { PollutantsProvider: "ColorfulCloudsCN", IndexProvider: "ColorfulCloudsCN" } }, - Calculate: { Algorithm: "WAQI_InstantCast_CN", AllowOverRange: true }, + Calculate: { Algorithm: "WAQI_InstantCast_US", AllowOverRange: true }, }, API: { QWeather: { Token: null, Host: "devapi.qweather.com" }, ColorfulClouds: { Token: null } }, EdgeCache: false, diff --git a/tests/egern-wk3/egern-parameters.test.mjs b/tests/egern-wk3/egern-parameters.test.mjs index 34fc7672..6e1c8085 100644 --- a/tests/egern-wk3/egern-parameters.test.mjs +++ b/tests/egern-wk3/egern-parameters.test.mjs @@ -3,7 +3,7 @@ import {configuration}from '../../src/egern/entry/index.mjs';import {localParame const defaults=()=>Object.fromEntries(localParameters.map(p=>[p.key,p.value])); const value=(o,path)=>path.split('.').reduce((a,k)=>a?.[k],o); test('all original restored keys reach Settings with current US/minute/yesterday defaults',()=>{ - const {Settings:s}=configuration({env:defaults()});assert.equal(s.AirQuality.Current.Index.Provider,'ColorfulCloudsUS');assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider,'ColorfulCloudsUS');assert.equal(s.NextHour.Provider,'ColorfulClouds');assert.equal(s.WeatherAlerts.Provider,'QWeatherWeb');assert.deepEqual(s.Weather.Replace,['CN']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,[]);assert.equal(s.Weather.ReplaceDaily,false);assert.equal(s.Weather.ReplaceHourly,false);assert.equal(s.AirQuality.Calculate.Algorithm,'WAQI_InstantCast_CN');assert.equal(s.API.QWeather.Host,'devapi.qweather.com');assert.equal(s.API.QWeather.Token,null); + const {Settings:s}=configuration({env:defaults()});assert.equal(s.AirQuality.Current.Index.Provider,'ColorfulCloudsUS');assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider,'ColorfulCloudsUS');assert.equal(s.NextHour.Provider,'ColorfulClouds');assert.equal(s.WeatherAlerts.Provider,'QWeatherWeb');assert.deepEqual(s.Weather.Replace,['CN']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,[]);assert.equal(s.Weather.ReplaceDaily,false);assert.equal(s.Weather.ReplaceHourly,false);assert.equal(s.AirQuality.Calculate.Algorithm,'WAQI_InstantCast_US');assert.equal(s.API.QWeather.Host,'devapi.qweather.com');assert.equal(s.API.QWeather.Token,null); for(const p of localParameters){if(p.key==='HTTP_TIMEOUT_MS')continue;assert.notEqual(value(s,p.key),undefined,p.key);} }); test('dotted edits override JSON defaults and list values do not turn into nested arrays',()=>{ diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs index ac845a38..875a8484 100644 --- a/tests/wk3-modules.test.mjs +++ b/tests/wk3-modules.test.mjs @@ -63,7 +63,7 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.equal(text.split(`wk3-${platform}-response.bundle.js`).length - 1, 2); assert.equal((text.match(/v3\.3\.2\//g) ?? []).length, 5); if (platform === 'surge') { - assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"WAQI_InstantCast_CN"/m); + assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"WAQI_InstantCast_US"/m); assert.match(rules[4], /requires-body=1, binary-body-mode=1/); assert.match(rules[0], /requires-body=1/); assert.ok(rules.slice(1, 4).every(line => !line.includes('binary-body-mode=1'))); @@ -78,9 +78,9 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.match(args.find(line => line.startsWith('Weather.Provider =')), /select,"ColorfulClouds","WeatherKit","QWeather"/); const algorithms = args.find(line => line.startsWith('AirQuality.Calculate.Algorithm =')); const officialOrder = ['None', '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']; - assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"WAQI_InstantCast_CN",/); + assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"WAQI_InstantCast_US",/); assert.deepEqual(algorithms.split(',tag=')[0].match(/"[^"]+"/g).map(value => JSON.parse(value)), - ['WAQI_InstantCast_CN', ...officialOrder.filter(value => value !== 'WAQI_InstantCast_CN')]); + ['WAQI_InstantCast_US', ...officialOrder.filter(value => value !== 'WAQI_InstantCast_US')]); assert.match(rules[4], /requires-body=1, binary-body-mode=1/); assert.match(rules[0], /requires-body=1/); assert.match(rules[0], /argument=\[\{DataSets\},/); @@ -97,7 +97,7 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.equal(argumentsJSON.length, 5); for (const values of argumentsJSON) { assert.equal(Object.keys(values).length, 23); - assert.equal(values['AirQuality.Calculate.Algorithm'], 'WAQI_InstantCast_CN'); + assert.equal(values['AirQuality.Calculate.Algorithm'], 'WAQI_InstantCast_US'); assert.equal(values['AirQuality.Current.Index.Provider'], 'ColorfulCloudsUS'); } for (const group of groups) assert.match(providers, new RegExp(`^ WeatherKit\\.api\\.${group.name.replaceAll('.', '\\.')}:[\\s\\S]*? interval: 86400`, 'm')); From 9d7e6a18200a163363740cc55a68f4526caa9924 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:07:57 +0800 Subject: [PATCH 15/42] =?UTF-8?q?=E6=9E=84=E5=BB=BA=EF=BC=88WK3=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E7=BB=9F=E4=B8=80=20Egern=20=E4=B8=8E=E5=90=84?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=9A=84=E5=8F=91=E8=A1=8C=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 20 ++++++++++-- README.md | 4 +-- package.json | 1 + scripts/build-egern-module.mjs | 9 +++-- scripts/build-egern.mjs | 4 +-- scripts/build-wk3-release.mjs | 30 +++++++++++++++++ tests/egern-wk3/egern-parameters.test.mjs | 7 ++++ tests/wk3-release-build.test.mjs | 40 +++++++++++++++++++++++ 8 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 scripts/build-wk3-release.mjs create mode 100644 tests/wk3-release-build.test.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bdd591f7..655589c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,8 +48,24 @@ jobs: run: npm run build:args env: PUPPETEER_CACHE_DIR: ${{ runner.temp }}/puppeteer - - name: Verify Egern module - run: test -s dist/iRingo.WeatherKit.yaml + - name: Build WK3 scripts and tagged modules + run: | + if [ "${{ github.ref_type }}" = tag ]; then + npm run build:wk3 -- --version "${GITHUB_REF_NAME#v}" + else + npm run build:wk3 + fi + - name: Verify Egern and WK3 artifacts + run: | + test -s dist/iRingo.WeatherKit.yaml + for platform in egern surge loon stash quantumultx; do + for stage in request response; do test -s "dist/wk3-$platform-$stage.bundle.js"; done + done + if [ "${{ github.ref_type }}" = tag ]; then + for extension in yaml sgmodule lpx stoverride snippet; do + test -s "dist/iRingo.WeatherKit.WK3.$extension" + done + fi - name: Upload artifact uses: actions/upload-artifact@master with: diff --git a/README.md b/README.md index d59aff4e..fc4dd86f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | | Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | -四平台配置生成器在 `scripts/build-wk3-modules.mjs`;各平台独立 JS 构建器在 `scripts/build-wk3-platform.mjs`。平台模板和脚本引用各自独立,不能将 Egern 的 `ctx` 脚本地址直接替换进其他模块。 +五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。仓库中的 `modules/iRingo.WeatherKit.WK3.yaml` 仍指向既有 Gist,不影响现用订阅;五平台脚本不能互换。 ## 默认配置 @@ -44,4 +44,4 @@ - Fork **尚无 Release**。`releases/latest/download/...` 或生成模块内的 `releases/download/v<version>/...` 只有在审查、合并、实际构建并上传对应附件后才能用于订阅;目前不要导入这些尚不存在的链接。生成模块不会自动发布附件。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 -验证命令:`node --test tests/egern-wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs`。四平台模块需指定**实际已发布**的版本号运行 `node scripts/build-wk3-modules.mjs --version <version> --outdir <output>`;脚本用 `node scripts/build-wk3-platform.mjs <surge|loon|stash|quantumultx> <output>` 构建。发布前还要把模块与 JS 放入实际发行附件并核对模块中每条 URL。 +验证命令:`node --test tests/egern-wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs tests/wk3-release-build.test.mjs`。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 diff --git a/package.json b/package.json index ab06a09e..d51c60b5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "deploy:workers": "npx wrangler deploy --minify -c wrangler.workers.jsonc", "deploy:vercel": "npx vercel deploy --prod", "build": "rollup -c", + "build:wk3": "node scripts/build-wk3-release.mjs --outdir dist", "build:dev": "rollup -c rollup.dev.config.mjs", "build:dev-args": "arguments-builder build -c arguments-builder.dev.config.ts", "sgmoudle:dev": "sgmoudle dev", diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs index f3a3a7d2..c6c51c6d 100644 --- a/scripts/build-egern-module.mjs +++ b/scripts/build-egern-module.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {localParameters} from '../src/egern/entry/parameters.mjs'; +import {release} from './build-wk3-modules.mjs'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); export const localScriptGroups = [ { type:'http_response', name:'WeatherKit.api.availability.response', match:'^https://weatherkit\\.apple\\.com/api/v[12]/availability/', body:true, binary:false, file:'response' }, @@ -10,7 +11,7 @@ export const localScriptGroups = [ { type:'http_request', name:'WeatherKit.api.weatherAlerts.api.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/weatherAlerts\\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\\.[0-9]+)?,-?[0-9]+(?:\\.[0-9]+)?)(?:&|$)', body:false, binary:false, file:'request' }, { type:'http_response', name:'WeatherKit.api.weather.response', match:'^https://weatherkit\\.apple\\.com/api/v[23]/weather/', body:true, binary:true, file:'response' }, ]; -export function renderLocalModule(){ +export function renderLocalModule({version} = {}){ const template=fs.readFileSync(path.join(root,'template/egern.wk3.handlebars'),'utf8'); const q=JSON.stringify; const compat=Object.fromEntries(localParameters.map(p=>[p.key,p.value])); @@ -19,8 +20,10 @@ export function renderLocalModule(){ // JSON objects are valid YAML. Quote individual substitution values using // YAML single quotes: literal double quotes in array syntax remain safe. let env=' env:\n';for(const p of localParameters)env+=` ${p.key}: '{{{${p.key}}}}'\n`; - const base='https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw'; - const scripts=localScriptGroups.map(g=>`- ${g.type}:\n name: ${g.name}\n${env} match: '${g.match}'\n script_url: ${base}/weatherkit-local-${g.file}.js?v=upstream-groups-1\n timeout: 30\n body_required: ${g.body}\n binary_body: ${g.binary}\n${g.body?' max_size: 2097152\n':''} update_interval: 86400\n`).join(''); + if(version && !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw Error('Specify an explicit release version'); + const base=version ? release(version) : 'https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw'; + const scriptName=g=>version ? `wk3-egern-${g.file}.bundle.js` : `weatherkit-local-${g.file}.js?v=upstream-groups-1`; + const scripts=localScriptGroups.map(g=>`- ${g.type}:\n name: ${g.name}\n${env} match: '${g.match}'\n script_url: ${base}/${scriptName(g)}\n timeout: 30\n body_required: ${g.body}\n binary_body: ${g.binary}\n${g.body?' max_size: 2097152\n':''} update_interval: 86400\n`).join(''); return template.replace('__WK3_COMPAT_ARGUMENTS__',()=>q(compat)).replace('__WK3_ARGUMENT_DESCRIPTION__',()=>q(description)).replace('__WK3_ENV_SCHEMA__',()=>q(schema)).replace('__WK3_SCRIPTINGS__',()=>scripts.trimEnd()); } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ diff --git a/scripts/build-egern.mjs b/scripts/build-egern.mjs index 59fbc4fa..baa77356 100644 --- a/scripts/build-egern.mjs +++ b/scripts/build-egern.mjs @@ -48,7 +48,7 @@ async function bundle(input, name, minify) { } } -export async function buildEgern({ outdir, minify = true } = {}) { +export async function buildEgern({ outdir, minify = true, filePrefix = "" } = {}) { const runtime = await bundle(runtimePath, "__egernSupport", minify); const results = {}; for (const [name, handler] of [["request", "handleRequest"], ["response", "handleResponse"]]) { @@ -59,7 +59,7 @@ export async function buildEgern({ outdir, minify = true } = {}) { results[name] = { code, bytes: Buffer.byteLength(code), inputs }; if (outdir) { await mkdir(outdir, { recursive: true }); - await writeFile(path.join(outdir, `${name}.bundle.js`), code); + await writeFile(path.join(outdir, `${filePrefix}${name}.bundle.js`), code); } } return results; diff --git a/scripts/build-wk3-release.mjs b/scripts/build-wk3-release.mjs new file mode 100644 index 00000000..33f8924b --- /dev/null +++ b/scripts/build-wk3-release.mjs @@ -0,0 +1,30 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildEgern } from "./build-egern.mjs"; +import { renderLocalModule } from "./build-egern-module.mjs"; +import { buildPlatform } from "./build-wk3-platform.mjs"; +import { buildWK3Modules, platforms, renderWK3Module } from "./build-wk3-modules.mjs"; +import { writeFile } from "node:fs/promises"; + +export async function buildWK3Release({ outdir, version } = {}) { + if (!outdir) throw new Error("Specify --outdir"); + if (version) { + renderWK3Module("surge", version); + renderLocalModule({ version }); + } + const egern = await buildEgern({ outdir, filePrefix: "wk3-egern-" }); + for (const platform of Object.keys(platforms)) await buildPlatform(platform, { outdir }); + if (version) { + buildWK3Modules({ outdir, version }); + await writeFile(path.join(outdir, "iRingo.WeatherKit.WK3.yaml"), renderLocalModule({ version })); + } + return { scripts: Object.keys(platforms).length * 2 + Object.keys(egern).length, modules: version ? Object.keys(platforms).length + 1 : 0 }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const flag = name => { const index = process.argv.indexOf(name); return index < 0 ? undefined : process.argv[index + 1]; }; + const outdir = flag("--outdir"); + if (!outdir) throw new Error("Usage: node scripts/build-wk3-release.mjs --outdir dist [--version 3.3.2]"); + const result = await buildWK3Release({ outdir: path.resolve(outdir), version: flag("--version") }); + console.log(`WK3: ${result.scripts} scripts, ${result.modules} modules`); +} diff --git a/tests/egern-wk3/egern-parameters.test.mjs b/tests/egern-wk3/egern-parameters.test.mjs index 6e1c8085..307f128b 100644 --- a/tests/egern-wk3/egern-parameters.test.mjs +++ b/tests/egern-wk3/egern-parameters.test.mjs @@ -24,6 +24,13 @@ test('template declares every placeholder and binds identical parameters to both const m=renderLocalModule();const line=prefix=>JSON.parse(m.split('\n').find(s=>s.startsWith(prefix)).slice(prefix.length));const args=line('compat_arguments: '),schema=line('env_schema: ');assert.equal(Object.keys(args).length,localParameters.length);assert.deepEqual(Object.keys(args),Object.keys(schema));const refs=[...m.matchAll(/\{\{\{([^}]+)\}\}\}/g)].map(x=>x[1]);for(const p of localParameters){assert.equal(refs.filter(x=>x===p.key).length,localScriptGroups.length);assert.equal(args[p.key],p.value);assert.equal(schema[p.key].default_value,p.value);} assert.ok(!m.includes('url_rewrites:')&&!m.includes('vercel.app'));assert.match(m,/binary_body: true/);assert.match(m,/hostnames:\n includes:/); }); +test('tagged Egern module uses the same versioned release attachments as other platforms',()=>{ + const text=renderLocalModule({version:'3.3.2'}); + assert.equal((text.match(/script_url: https:\/\/github.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/wk3-egern-(?:request|response)\.bundle\.js/g)||[]).length,localScriptGroups.length); + assert.doesNotMatch(text,/gist\.githubusercontent\.com/); + assert.match(renderLocalModule(),/gist\.githubusercontent\.com/); + assert.throws(()=>renderLocalModule({version:'latest'}),/explicit release version/); +}); test('actual built script obeys DataSets=[] and LogLevel OFF, preserving binary bytes with no network',async()=>{ const {response}=await buildEgern();let logs=0,network=0;const host=vm.createContext({URL,TextEncoder,TextDecoder,atob,btoa,console:Object.fromEntries(['log','info','warn','error','debug'].map(k=>[k,()=>logs++]))});const handler=vm.runInContext('('+response.code.replace('export default ','')+')',host);const bytes=new Uint8Array([255,255,255,255]);const out=await handler({env:{...defaults(),DataSets:'[]',LogLevel:'OFF'},request:{url:'https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality',headers:{}},response:new Response(bytes,{headers:{'content-type':'application/vnd.apple.flatbuffer'}}),http:{get(){network++;throw Error('unexpected network')}}});assert.deepEqual(Array.from(out.body),Array.from(bytes));assert.equal(network,0);assert.equal(logs,0); }); diff --git a/tests/wk3-release-build.test.mjs b/tests/wk3-release-build.test.mjs new file mode 100644 index 00000000..407f27df --- /dev/null +++ b/tests/wk3-release-build.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { buildWK3Release } from "../scripts/build-wk3-release.mjs"; +import { renderLocalModule } from "../scripts/build-egern-module.mjs"; + +const platforms = ["egern", "surge", "loon", "stash", "quantumultx"]; +const extensions = ["yaml", "sgmodule", "lpx", "stoverride", "snippet"]; + +test("tagged WK3 build publishes all platform scripts and matching modules", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "wk3-release-")); + try { + const result = await buildWK3Release({ outdir: dir, version: "3.3.2" }); + assert.deepEqual(result, { scripts: 10, modules: 5 }); + const files = await readdir(dir); + for (const platform of platforms) for (const stage of ["request", "response"]) { + const file = `wk3-${platform}-${stage}.bundle.js`; + assert.ok(files.includes(file), file); + assert.ok((await stat(path.join(dir, file))).size > 1000, file); + } + for (const extension of extensions) { + const file = `iRingo.WeatherKit.WK3.${extension}`; + const text = await readFile(path.join(dir, file), "utf8"); + assert.ok(text.length > 500, file); + for (const [, asset] of text.matchAll(/(?:script_url:|script-path=|url:\s*(?:script-\w+|https:\/\/github\.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/))[^\n]*?\b(wk3-[a-z]+-(?:request|response)\.bundle\.js)/g)) { + assert.ok(files.includes(asset), `${file} references missing ${asset}`); + } + } + const egern = await readFile(path.join(dir, "iRingo.WeatherKit.WK3.yaml"), "utf8"); + assert.equal(egern, renderLocalModule({ version: "3.3.2" })); + assert.equal((egern.match(/script_url: https:\/\/github\.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/wk3-egern-(?:request|response)\.bundle\.js/g) ?? []).length, 5); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test("release build refuses an implicit output path or unversioned tag", async () => { + await assert.rejects(buildWK3Release({ version: "3.3.2" }), /--outdir/); + await assert.rejects(buildWK3Release({ outdir: path.join(os.tmpdir(), "unused"), version: "latest" }), /explicit release version/); +}); From 4311fc8df28eed41adb91db036abb1bc7b0d5362 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:39:49 +0800 Subject: [PATCH 16/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88CI=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E4=BD=BF=E7=94=A8=E9=BB=98=E8=AE=A4=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E6=A3=80=E5=87=BA=E5=AE=A1=E6=9F=A5=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 1 - .github/workflows/dev.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 655589c7..359e594c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,6 @@ jobs: uses: actions/checkout@main with: submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} - name: Set up Node.js uses: actions/setup-node@main with: diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 14e87a73..a2e62548 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -15,7 +15,6 @@ jobs: uses: actions/checkout@main with: submodules: recursive - token: ${{ secrets.SUBMODULE_TOKEN }} ref: dev - name: Set up Node.js uses: actions/setup-node@main From 8d4fb1e5e5ea7bfbb1fcfaada7e5dba92357afcb Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:51:45 +0800 Subject: [PATCH 17/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88CI=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E4=B8=BA=E7=A7=81=E6=9C=89=E5=8C=85=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E4=BB=A4=E7=89=8C=E5=9B=9E=E9=80=80=E5=8F=8A?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 3 ++- .github/workflows/deploy.yml | 1 + .github/workflows/dev.yml | 3 ++- .github/workflows/draft.yml | 1 + .github/workflows/pre-release.yml | 1 + .github/workflows/release.yml | 1 + 6 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 359e594c..63986558 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ on: permissions: actions: read contents: read + packages: read jobs: build: @@ -27,7 +28,7 @@ jobs: - name: Install dependencies run: npm install env: - NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN || github.token }} - name: Install Puppeteer Chrome run: | node node_modules/puppeteer/install.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3054d5c4..2d38322d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,7 @@ on: permissions: actions: read contents: read + packages: read jobs: debug: diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index a2e62548..ff01f2e5 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -6,6 +6,7 @@ on: permissions: actions: read contents: read + packages: read jobs: build: @@ -24,7 +25,7 @@ jobs: - name: Install dependencies run: npm install env: - NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_TOKEN || github.token }} - name: Build run: npm run dev - name: Upload artifact diff --git a/.github/workflows/draft.yml b/.github/workflows/draft.yml index b3f51aea..0e27ed41 100644 --- a/.github/workflows/draft.yml +++ b/.github/workflows/draft.yml @@ -8,6 +8,7 @@ on: permissions: actions: read contents: write + packages: read jobs: build: diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index a867161b..1cdaa89c 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -10,6 +10,7 @@ on: permissions: actions: read contents: write + packages: read jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8a7d2a4..29ca44d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: permissions: actions: read contents: write + packages: read jobs: build: From 72dcc032a40accb0c5372606e1212e5d221344dc Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:02:36 +0800 Subject: [PATCH 18/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88CI=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=9B=BA=E5=AE=9A=20Node=2022=20=E9=81=BF=E5=85=8D=20?= =?UTF-8?q?npm=2012=20=E7=A6=81=E6=AD=A2=E8=BF=9C=E7=A8=8B=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 2 +- .github/workflows/dev.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 63986558..db8a8e5c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@main with: - node-version: 'latest' + node-version: '22' cache: 'npm' # https://github.com/puppeteer/puppeteer/issues/13595#issuecomment-2640309385 # https://github.com/puppeteer/puppeteer/blob/cdfbde6247fbe286c31a91ac62ffed6f2f801ee4/.github/workflows/ci.yml#L140 diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index ff01f2e5..8f37c715 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@main with: - node-version: 'latest' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm install From 72229ed83bf5f209800a0bc3cf36ae5555d2d914 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:39:27 +0800 Subject: [PATCH 19/42] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=88WK3=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E7=BB=9F=E4=B8=80=E4=BA=94=E5=B9=B3=E5=8F=B0=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=85=B1=E4=BA=AB=E6=A0=B8=E5=BF=83=E4=B8=8E=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +++++++++-- modules/iRingo.WeatherKit.WK3.yaml | 2 +- scripts/build-egern-module.mjs | 15 +++++++-------- scripts/build-egern.mjs | 16 ++++++++-------- scripts/build-wk3-modules.mjs | 11 +++-------- scripts/build-wk3-platform.mjs | 10 +++++----- src/platform/wk3-adapter.mjs | 2 +- src/{egern => }/wk3/class/AirQuality.mjs | 0 src/{egern => }/wk3/class/AirQualityScale.mjs | 0 src/{egern => }/wk3/class/ColorfulClouds.mjs | 0 .../wk3/class/FlatBufferRootProcessor.mjs | 0 src/{egern => }/wk3/class/ForecastNextHour.mjs | 0 src/{egern => }/wk3/class/NativeAlertDetails.mjs | 0 src/{egern => }/wk3/class/QWeather.mjs | 0 .../wk3/class/SimplePrecisionMath.mjs | 0 src/{egern => }/wk3/class/WK3Alerts.mjs | 0 src/{egern => }/wk3/class/WK3Comparison.mjs | 0 src/{egern => }/wk3/class/WK3NextHour.mjs | 0 src/{egern => }/wk3/class/Weather.mjs | 0 src/{egern => }/wk3/class/WeatherAlerts.mjs | 0 src/{egern => }/wk3/class/WeatherKit2.mjs | 0 src/{egern => }/wk3/class/WeatherKit3.mjs | 0 src/{egern => wk3}/entry/index.mjs | 6 +++--- src/{egern => wk3}/entry/request.mjs | 10 +++++----- src/{egern => wk3}/entry/response.mjs | 12 ++++++------ src/{egern => }/wk3/function/buildSettings.mjs | 0 src/{egern => }/wk3/function/database.mjs | 0 .../wk3/function/parseWeatherKitURL.mjs | 0 .../wk3/function/shouldReplaceWeatherKit.mjs | 0 src/{egern/entry => wk3}/parameters.mjs | 0 src/{egern => }/wk3/process/Response.mjs | 0 src/{egern => }/wk3/proto/apple/wk2.js | 0 src/wk3/routes.mjs | 8 ++++++++ src/{egern/entry => wk3}/runtime.mjs | 5 +++-- src/{egern => }/wk3/utils/Console.mjs | 0 src/{egern => }/wk3/utils/context.mjs | 0 src/{egern => }/wk3/utils/fetch.mjs | 0 src/{egern => }/wk3/utils/index.mjs | 0 src/{egern => }/wk3/utils/object.mjs | 0 tests/wk3-modules.test.mjs | 15 ++++++++++++++- tests/wk3-platform-adapter.test.mjs | 2 +- tests/{egern-wk3 => wk3}/aq-algorithms.test.mjs | 2 +- tests/{egern-wk3 => wk3}/egern-local.test.mjs | 10 +++++----- .../{egern-wk3 => wk3}/egern-parameters.test.mjs | 2 +- .../native-alert-details.test.mjs | 4 ++-- tests/{egern-wk3 => wk3}/wk2-selective.test.mjs | 4 ++-- tests/{egern-wk3 => wk3}/wk3-alerts.test.mjs | 12 ++++++------ tests/{egern-wk3 => wk3}/wk3-codec.test.mjs | 2 +- tests/{egern-wk3 => wk3}/wk3-comparison.test.mjs | 4 ++-- .../wk3-features-integration.test.mjs | 10 +++++----- tests/{egern-wk3 => wk3}/wk3-hk-aq.test.mjs | 10 +++++----- tests/{egern-wk3 => wk3}/wk3-next-hour.test.mjs | 2 +- 52 files changed, 105 insertions(+), 82 deletions(-) rename src/{egern => }/wk3/class/AirQuality.mjs (100%) rename src/{egern => }/wk3/class/AirQualityScale.mjs (100%) rename src/{egern => }/wk3/class/ColorfulClouds.mjs (100%) rename src/{egern => }/wk3/class/FlatBufferRootProcessor.mjs (100%) rename src/{egern => }/wk3/class/ForecastNextHour.mjs (100%) rename src/{egern => }/wk3/class/NativeAlertDetails.mjs (100%) rename src/{egern => }/wk3/class/QWeather.mjs (100%) rename src/{egern => }/wk3/class/SimplePrecisionMath.mjs (100%) rename src/{egern => }/wk3/class/WK3Alerts.mjs (100%) rename src/{egern => }/wk3/class/WK3Comparison.mjs (100%) rename src/{egern => }/wk3/class/WK3NextHour.mjs (100%) rename src/{egern => }/wk3/class/Weather.mjs (100%) rename src/{egern => }/wk3/class/WeatherAlerts.mjs (100%) rename src/{egern => }/wk3/class/WeatherKit2.mjs (100%) rename src/{egern => }/wk3/class/WeatherKit3.mjs (100%) rename src/{egern => wk3}/entry/index.mjs (92%) rename src/{egern => wk3}/entry/request.mjs (89%) rename src/{egern => wk3}/entry/response.mjs (91%) rename src/{egern => }/wk3/function/buildSettings.mjs (100%) rename src/{egern => }/wk3/function/database.mjs (100%) rename src/{egern => }/wk3/function/parseWeatherKitURL.mjs (100%) rename src/{egern => }/wk3/function/shouldReplaceWeatherKit.mjs (100%) rename src/{egern/entry => wk3}/parameters.mjs (100%) rename src/{egern => }/wk3/process/Response.mjs (100%) rename src/{egern => }/wk3/proto/apple/wk2.js (100%) create mode 100644 src/wk3/routes.mjs rename src/{egern/entry => wk3}/runtime.mjs (94%) rename src/{egern => }/wk3/utils/Console.mjs (100%) rename src/{egern => }/wk3/utils/context.mjs (100%) rename src/{egern => }/wk3/utils/fetch.mjs (100%) rename src/{egern => }/wk3/utils/index.mjs (100%) rename src/{egern => }/wk3/utils/object.mjs (100%) rename tests/{egern-wk3 => wk3}/aq-algorithms.test.mjs (96%) rename tests/{egern-wk3 => wk3}/egern-local.test.mjs (97%) rename tests/{egern-wk3 => wk3}/egern-parameters.test.mjs (95%) rename tests/{egern-wk3 => wk3}/native-alert-details.test.mjs (98%) rename tests/{egern-wk3 => wk3}/wk2-selective.test.mjs (98%) rename tests/{egern-wk3 => wk3}/wk3-alerts.test.mjs (92%) rename tests/{egern-wk3 => wk3}/wk3-codec.test.mjs (99%) rename tests/{egern-wk3 => wk3}/wk3-comparison.test.mjs (98%) rename tests/{egern-wk3 => wk3}/wk3-features-integration.test.mjs (93%) rename tests/{egern-wk3 => wk3}/wk3-hk-aq.test.mjs (89%) rename tests/{egern-wk3 => wk3}/wk3-next-hour.test.mjs (99%) diff --git a/README.md b/README.md index fc4dd86f..9dbe11e5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ #  iRingo: 🌤 WeatherKit -此 Fork 的 `wk3/ios27-wk3-review` 分支以 NSRingo/WeatherKit **v3.3.2** 为基点,保留原版 WK2 入口,并在 `src/egern/` 隔离增加本机 WK3 处理。审查完成前不要将该分支当作正式发行版;`main` 仍是未经这些改动的上游版本。 +此 Fork 的 `wk3/ios27-wk3-review` 分支以 NSRingo/WeatherKit **v3.3.2** 为基点,保留原版 WK2 入口;本机 WK3 业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。审查完成前不要将该分支当作正式发行版;`main` 仍是未经这些改动的上游版本。 ## 本分支更新 @@ -22,6 +22,13 @@ 五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。仓库中的 `modules/iRingo.WeatherKit.WK3.yaml` 仍指向既有 Gist,不影响现用订阅;五平台脚本不能互换。 +## 代码组织 + +- `src/wk3/class`、`function`、`process`、`proto`、`utils`:五平台共用的编解码、算法、来源处理与业务逻辑,按上游分类保留;不再嵌套在 Egern 目录。 +- `src/wk3/entry`:共用的配置归一化与请求/响应处理;`runtime.mjs`、`parameters.mjs`、`routes.mjs` 分别维护共享 HTTP 封装、参数和五类路由。 +- `src/platform/wk3-adapter.mjs`:Surge/Loon/Stash/Quantumult X 的客户端对象适配;Egern 原生上下文直接调用同一组共享入口。客户端网络 API 和输出格式由各自 Rollup 构建器绑定,不混入业务算法。 +- `tests/wk3/`:共享协议及业务测试;根目录 `tests/wk3-*.test.mjs`:跨平台代理和发行构建测试。 + ## 默认配置 | 设置 | 默认值 | 生效范围 | @@ -44,4 +51,4 @@ - Fork **尚无 Release**。`releases/latest/download/...` 或生成模块内的 `releases/download/v<version>/...` 只有在审查、合并、实际构建并上传对应附件后才能用于订阅;目前不要导入这些尚不存在的链接。生成模块不会自动发布附件。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 -验证命令:`node --test tests/egern-wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs tests/wk3-release-build.test.mjs`。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 +验证命令:`node --test tests/wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs tests/wk3-release-build.test.mjs`。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml index e399cb61..1c59b054 100644 --- a/modules/iRingo.WeatherKit.WK3.yaml +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -181,7 +181,7 @@ scriptings: API.QWeather.Token: '{{{API.QWeather.Token}}}' LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' - match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' + match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 timeout: 30 body_required: false diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs index c6c51c6d..37b68bde 100644 --- a/scripts/build-egern-module.mjs +++ b/scripts/build-egern-module.mjs @@ -1,16 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {localParameters} from '../src/egern/entry/parameters.mjs'; +import {localParameters} from '../src/wk3/parameters.mjs'; +import {groups} from '../src/wk3/routes.mjs'; import {release} from './build-wk3-modules.mjs'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); -export const localScriptGroups = [ - { type:'http_response', name:'WeatherKit.api.availability.response', match:'^https://weatherkit\\.apple\\.com/api/v[12]/availability/', body:true, binary:false, file:'response' }, - { type:'http_request', name:'WeatherKit.api.airQualityScale.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/airQualityScale/', body:false, binary:false, file:'request' }, - { type:'http_request', name:'WeatherKit.api.weatherAlerts.html.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/weatherAlerts\\?(?:[^#]*&)?ids=[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)', body:false, binary:false, file:'request' }, - { type:'http_request', name:'WeatherKit.api.weatherAlerts.api.request', match:'^https://weatherkit\\.apple\\.com/api/v[12]/weatherAlerts\\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\\.[0-9]+)?,-?[0-9]+(?:\\.[0-9]+)?)(?:&|$)', body:false, binary:false, file:'request' }, - { type:'http_response', name:'WeatherKit.api.weather.response', match:'^https://weatherkit\\.apple\\.com/api/v[23]/weather/', body:true, binary:true, file:'response' }, -]; +export const localScriptGroups = groups.map(g=>({ + type: `http_${g.stage}`, name: `WeatherKit.api.${g.name}`, + match: g.pattern.replace('^https?:','^https:').replaceAll('\\/', '/'), + body: Boolean(g.body), binary: Boolean(g.binary), file: g.stage, +})); export function renderLocalModule({version} = {}){ const template=fs.readFileSync(path.join(root,'template/egern.wk3.handlebars'),'utf8'); const q=JSON.stringify; diff --git a/scripts/build-egern.mjs b/scripts/build-egern.mjs index baa77356..cba6400d 100644 --- a/scripts/build-egern.mjs +++ b/scripts/build-egern.mjs @@ -6,28 +6,28 @@ import nodeResolve from "@rollup/plugin-node-resolve"; import terser from "@rollup/plugin-terser"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const runtimePath = path.join(root, "src/egern/entry/runtime.mjs"); +const runtimePath = path.join(root, "src/wk3/runtime.mjs"); const replacements = { "console": 'const levels={OFF:0,ERROR:1,WARN:2,INFO:3,DEBUG:4,ALL:5};let settings={};try{settings=JSON.parse(ctx.env?.SETTINGS||"{}")}catch{}const level=levels[ctx.env?.LogLevel??settings.LogLevel??"INFO"]??3;const fmt=a=>a.map(x=>typeof x==="object"&&x!==null?JSON.stringify(x,null,2):x);export const Console=Object.fromEntries([["debug",4],["info",3],["warn",2],["error",1],["log",1],["exception",1]].map(([k,n])=>[k,(...a)=>{if(level>=n)console[k==="exception"?"error":k](...fmt(a))}]));', }; function egernPlugin() { const virtual = new Map([ - ["egern:runtime", "export const webFetch = __egernRuntime.webFetch;"], - [path.join(root, "src/egern/wk3/utils/context.mjs"), "export const requestContext = undefined;"], - [path.join(root, "src/egern/wk3/utils/fetch.mjs"), "export const fetch = __egernRuntime.fetch;"], - [path.join(root, "src/egern/wk3/utils/Console.mjs"), replacements.console], + ["wk3:runtime", "export const webFetch = __egernRuntime.webFetch;"], + [path.join(root, "src/wk3/utils/context.mjs"), "export const requestContext = undefined;"], + [path.join(root, "src/wk3/utils/fetch.mjs"), "export const fetch = __egernRuntime.fetch;"], + [path.join(root, "src/wk3/utils/Console.mjs"), replacements.console], ]); return { name: "egern-native-runtime", resolveId(source, importer) { - if (source === "egern:runtime") return source; + if (source === "wk3:runtime") return source; if (!importer || !/(?:^|\/)\b(?:Console|context|fetch)\.mjs$/.test(source)) return null; const resolved = path.resolve(path.dirname(importer), source); return virtual.has(resolved) ? resolved : null; }, load(id) { - if (id === "egern:runtime") return virtual.get(id); + if (id === "wk3:runtime") return virtual.get(id); return virtual.get(id) ?? null; }, transform(code) { @@ -52,7 +52,7 @@ export async function buildEgern({ outdir, minify = true, filePrefix = "" } = {} const runtime = await bundle(runtimePath, "__egernSupport", minify); const results = {}; for (const [name, handler] of [["request", "handleRequest"], ["response", "handleResponse"]]) { - const input = path.join(root, `src/egern/entry/${name}.mjs`); + const input = path.join(root, `src/wk3/entry/${name}.mjs`); const business = await bundle(input, "__egernBusiness", minify); const code = `// Generated for Egern native ctx API. No runtime imports.\nexport default async function(ctx) {\n${runtime}\nconst __egernRuntime = __egernSupport.createRuntime(ctx);\n${business}\nreturn __egernBusiness.${handler}(ctx);\n}\n`; const inputs = [input]; diff --git a/scripts/build-wk3-modules.mjs b/scripts/build-wk3-modules.mjs index 19b0cccc..921b6119 100644 --- a/scripts/build-wk3-modules.mjs +++ b/scripts/build-wk3-modules.mjs @@ -1,19 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { localParameters } from '../src/egern/entry/parameters.mjs'; +import { localParameters } from '../src/wk3/parameters.mjs'; +import { groups } from '../src/wk3/routes.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const parameters = localParameters; export const platforms = { surge: 'sgmodule', loon: 'lpx', stash: 'stoverride', quantumultx: 'snippet' }; export const release = version => `https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v${version}`; -export const groups = [ - { name: 'availability.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/`, stage: 'response', body: true }, - { name: 'airQualityScale.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/`, stage: 'request' }, - { name: 'weatherAlerts.html.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)`, stage: 'request', echo: true }, - { name: 'weatherAlerts.api.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)`, stage: 'request', echo: true }, - { name: 'weather.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/`, stage: 'response', body: true, binary: true }, -]; +export { groups } from '../src/wk3/routes.mjs'; const label = group => `WeatherKit.api.${group.name}`; const script = (group, platform) => `wk3-${platform}-${group.stage}.bundle.js`; const url = (group, version, platform) => `${release(version)}/${script(group, platform)}`; diff --git a/scripts/build-wk3-platform.mjs b/scripts/build-wk3-platform.mjs index 4bc120d5..37785f1e 100644 --- a/scripts/build-wk3-platform.mjs +++ b/scripts/build-wk3-platform.mjs @@ -11,14 +11,14 @@ const logModule = 'const levels={OFF:0,ERROR:1,WARN:2,INFO:3,DEBUG:4,ALL:5};let function runtimePlugin() { const virtual = new Map([ ["wk3:runtime", 'let runtime; export const setRuntime=value=>{runtime=value}; export const webFetch=(...args)=>runtime.webFetch(...args); export const runtimeFetch=(...args)=>runtime.fetch(...args);'], - [path.join(root, "src/egern/wk3/utils/context.mjs"), "export const requestContext=undefined;"], - [path.join(root, "src/egern/wk3/utils/fetch.mjs"), 'export { runtimeFetch as fetch } from "wk3:runtime";'], - [path.join(root, "src/egern/wk3/utils/Console.mjs"), logModule], + [path.join(root, "src/wk3/utils/context.mjs"), "export const requestContext=undefined;"], + [path.join(root, "src/wk3/utils/fetch.mjs"), 'export { runtimeFetch as fetch } from "wk3:runtime";'], + [path.join(root, "src/wk3/utils/Console.mjs"), logModule], ]); return { name: "wk3-platform-runtime", resolveId(source, importer) { - if (source === "egern:runtime" || source === "wk3:runtime") return "wk3:runtime"; + if (source === "wk3:runtime") return "wk3:runtime"; if (!importer || !/(?:^|\/)(?:Console|context|fetch)\.mjs$/.test(source)) return null; const resolved = path.resolve(path.dirname(importer), source); return virtual.has(resolved) ? resolved : null; @@ -41,7 +41,7 @@ export async function buildPlatform(platform, { outdir, minify = true } = {}) { resolveId(id) { return id === entry ? id : null; }, load(id) { if (id !== entry) return null; - return `import { setRuntime } from "wk3:runtime";\nimport { runPlatform } from ${JSON.stringify(path.join(root, "src/platform/wk3-adapter.mjs"))};\nimport { ${handlerName} } from ${JSON.stringify(path.join(root, `src/egern/entry/${name}.mjs`))};\nimport { createRuntime } from ${JSON.stringify(path.join(root, "src/egern/entry/runtime.mjs"))};\nconst __bytes=v=>typeof v===\"string\"?Uint8Array.from(v,c=>c.charCodeAt(0)&255).buffer:v instanceof ArrayBuffer?v:ArrayBuffer.isView(v)?v.buffer.slice(v.byteOffset,v.byteOffset+v.byteLength):new Uint8Array(v).buffer;const __wk3Runtime=createRuntime({env:globalThis.$environment??globalThis.$env??{},http:globalThis.$httpClient?{get:(u,o)=>new Promise((r,j)=>$httpClient.get({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))),post:(u,o)=>new Promise((r,j)=>$httpClient.post({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)})))}:{get:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>__bytes(x.body)}},post:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>new TextEncoder().encode(x.body).buffer}}}});\nsetRuntime(__wk3Runtime);runPlatform(async ctx=>${handlerName}(ctx),"${name}",globalThis,"${platform === "quantumultx" ? "Quantumult X" : platform}");`; + return `import { setRuntime } from "wk3:runtime";\nimport { runPlatform } from ${JSON.stringify(path.join(root, "src/platform/wk3-adapter.mjs"))};\nimport { ${handlerName} } from ${JSON.stringify(path.join(root, `src/wk3/entry/${name}.mjs`))};\nimport { createRuntime } from ${JSON.stringify(path.join(root, "src/wk3/runtime.mjs"))};\nconst __bytes=v=>typeof v===\"string\"?Uint8Array.from(v,c=>c.charCodeAt(0)&255).buffer:v instanceof ArrayBuffer?v:ArrayBuffer.isView(v)?v.buffer.slice(v.byteOffset,v.byteOffset+v.byteLength):new Uint8Array(v).buffer;const __wk3Runtime=createRuntime({env:globalThis.$environment??globalThis.$env??{},http:globalThis.$httpClient?{get:(u,o)=>new Promise((r,j)=>$httpClient.get({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))),post:(u,o)=>new Promise((r,j)=>$httpClient.post({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)})))}:{get:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>__bytes(x.body)}},post:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>new TextEncoder().encode(x.body).buffer}}}});\nsetRuntime(__wk3Runtime);runPlatform(async ctx=>${handlerName}(ctx),"${name}",globalThis,"${platform === "quantumultx" ? "Quantumult X" : platform}");`; }, }; const plugins = [plugin, runtimePlugin(), nodeResolve({ browser: true, preferBuiltins: false })]; diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs index 3a1bd0c0..1605f5e7 100644 --- a/src/platform/wk3-adapter.mjs +++ b/src/platform/wk3-adapter.mjs @@ -1,4 +1,4 @@ -import { localParameters } from "../egern/entry/parameters.mjs"; +import { localParameters } from "../wk3/parameters.mjs"; export async function runPlatform(handler, stage, globals = globalThis, platformOverride) { const platform = platformOverride ?? (typeof globals.$platform === "string" ? globals.$platform : "generic"); diff --git a/src/egern/wk3/class/AirQuality.mjs b/src/wk3/class/AirQuality.mjs similarity index 100% rename from src/egern/wk3/class/AirQuality.mjs rename to src/wk3/class/AirQuality.mjs diff --git a/src/egern/wk3/class/AirQualityScale.mjs b/src/wk3/class/AirQualityScale.mjs similarity index 100% rename from src/egern/wk3/class/AirQualityScale.mjs rename to src/wk3/class/AirQualityScale.mjs diff --git a/src/egern/wk3/class/ColorfulClouds.mjs b/src/wk3/class/ColorfulClouds.mjs similarity index 100% rename from src/egern/wk3/class/ColorfulClouds.mjs rename to src/wk3/class/ColorfulClouds.mjs diff --git a/src/egern/wk3/class/FlatBufferRootProcessor.mjs b/src/wk3/class/FlatBufferRootProcessor.mjs similarity index 100% rename from src/egern/wk3/class/FlatBufferRootProcessor.mjs rename to src/wk3/class/FlatBufferRootProcessor.mjs diff --git a/src/egern/wk3/class/ForecastNextHour.mjs b/src/wk3/class/ForecastNextHour.mjs similarity index 100% rename from src/egern/wk3/class/ForecastNextHour.mjs rename to src/wk3/class/ForecastNextHour.mjs diff --git a/src/egern/wk3/class/NativeAlertDetails.mjs b/src/wk3/class/NativeAlertDetails.mjs similarity index 100% rename from src/egern/wk3/class/NativeAlertDetails.mjs rename to src/wk3/class/NativeAlertDetails.mjs diff --git a/src/egern/wk3/class/QWeather.mjs b/src/wk3/class/QWeather.mjs similarity index 100% rename from src/egern/wk3/class/QWeather.mjs rename to src/wk3/class/QWeather.mjs diff --git a/src/egern/wk3/class/SimplePrecisionMath.mjs b/src/wk3/class/SimplePrecisionMath.mjs similarity index 100% rename from src/egern/wk3/class/SimplePrecisionMath.mjs rename to src/wk3/class/SimplePrecisionMath.mjs diff --git a/src/egern/wk3/class/WK3Alerts.mjs b/src/wk3/class/WK3Alerts.mjs similarity index 100% rename from src/egern/wk3/class/WK3Alerts.mjs rename to src/wk3/class/WK3Alerts.mjs diff --git a/src/egern/wk3/class/WK3Comparison.mjs b/src/wk3/class/WK3Comparison.mjs similarity index 100% rename from src/egern/wk3/class/WK3Comparison.mjs rename to src/wk3/class/WK3Comparison.mjs diff --git a/src/egern/wk3/class/WK3NextHour.mjs b/src/wk3/class/WK3NextHour.mjs similarity index 100% rename from src/egern/wk3/class/WK3NextHour.mjs rename to src/wk3/class/WK3NextHour.mjs diff --git a/src/egern/wk3/class/Weather.mjs b/src/wk3/class/Weather.mjs similarity index 100% rename from src/egern/wk3/class/Weather.mjs rename to src/wk3/class/Weather.mjs diff --git a/src/egern/wk3/class/WeatherAlerts.mjs b/src/wk3/class/WeatherAlerts.mjs similarity index 100% rename from src/egern/wk3/class/WeatherAlerts.mjs rename to src/wk3/class/WeatherAlerts.mjs diff --git a/src/egern/wk3/class/WeatherKit2.mjs b/src/wk3/class/WeatherKit2.mjs similarity index 100% rename from src/egern/wk3/class/WeatherKit2.mjs rename to src/wk3/class/WeatherKit2.mjs diff --git a/src/egern/wk3/class/WeatherKit3.mjs b/src/wk3/class/WeatherKit3.mjs similarity index 100% rename from src/egern/wk3/class/WeatherKit3.mjs rename to src/wk3/class/WeatherKit3.mjs diff --git a/src/egern/entry/index.mjs b/src/wk3/entry/index.mjs similarity index 92% rename from src/egern/entry/index.mjs rename to src/wk3/entry/index.mjs index 1297cedb..21eaba4a 100644 --- a/src/egern/entry/index.mjs +++ b/src/wk3/entry/index.mjs @@ -1,6 +1,6 @@ -import database from "../wk3/function/database.mjs"; -import buildSettings from "../wk3/function/buildSettings.mjs"; -import { get, set, merge } from "../wk3/utils/object.mjs"; +import database from "../function/database.mjs"; +import buildSettings from "../function/buildSettings.mjs"; +import { get, set, merge } from "../utils/object.mjs"; export function configuration(ctx) { // SETTINGS is the same nested Settings object accepted by buildSettings. diff --git a/src/egern/entry/request.mjs b/src/wk3/entry/request.mjs similarity index 89% rename from src/egern/entry/request.mjs rename to src/wk3/entry/request.mjs index 1b9e069e..a49365e9 100644 --- a/src/egern/entry/request.mjs +++ b/src/wk3/entry/request.mjs @@ -1,9 +1,9 @@ import { configuration } from "./index.mjs"; -import { webFetch } from "egern:runtime"; -import WeatherAlerts from "../wk3/class/WeatherAlerts.mjs"; -import AirQualityScale from "../wk3/class/AirQualityScale.mjs"; -import { headerObject, responseHeaders } from "./runtime.mjs"; -import { isNativeAlertDetailsID, resolveNativeAlertDetails } from "../wk3/class/NativeAlertDetails.mjs"; +import { webFetch } from "wk3:runtime"; +import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import AirQualityScale from "../class/AirQualityScale.mjs"; +import { headerObject, responseHeaders } from "../runtime.mjs"; +import { isNativeAlertDetailsID, resolveNativeAlertDetails } from "../class/NativeAlertDetails.mjs"; export async function handleRequest(ctx) { const url = new URL(ctx.request.url); diff --git a/src/egern/entry/response.mjs b/src/wk3/entry/response.mjs similarity index 91% rename from src/egern/entry/response.mjs rename to src/wk3/entry/response.mjs index a1d86635..42fd76f7 100644 --- a/src/egern/entry/response.mjs +++ b/src/wk3/entry/response.mjs @@ -1,10 +1,10 @@ import { configuration } from "./index.mjs"; -import { headerObject, responseHeaders } from "./runtime.mjs"; -import { Response as processResponse } from "../wk3/process/Response.mjs"; -import parseWeatherKitURL from "../wk3/function/parseWeatherKitURL.mjs"; -import shouldReplaceWeatherKit from "../wk3/function/shouldReplaceWeatherKit.mjs"; -import ColorfulClouds from "../wk3/class/ColorfulClouds.mjs"; -import QWeather from "../wk3/class/QWeather.mjs"; +import { headerObject, responseHeaders } from "../runtime.mjs"; +import { Response as processResponse } from "../process/Response.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import shouldReplaceWeatherKit from "../function/shouldReplaceWeatherKit.mjs"; +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; async function processingContext(ctx, url, Settings, Configs) { const parsed = parseWeatherKitURL(url); diff --git a/src/egern/wk3/function/buildSettings.mjs b/src/wk3/function/buildSettings.mjs similarity index 100% rename from src/egern/wk3/function/buildSettings.mjs rename to src/wk3/function/buildSettings.mjs diff --git a/src/egern/wk3/function/database.mjs b/src/wk3/function/database.mjs similarity index 100% rename from src/egern/wk3/function/database.mjs rename to src/wk3/function/database.mjs diff --git a/src/egern/wk3/function/parseWeatherKitURL.mjs b/src/wk3/function/parseWeatherKitURL.mjs similarity index 100% rename from src/egern/wk3/function/parseWeatherKitURL.mjs rename to src/wk3/function/parseWeatherKitURL.mjs diff --git a/src/egern/wk3/function/shouldReplaceWeatherKit.mjs b/src/wk3/function/shouldReplaceWeatherKit.mjs similarity index 100% rename from src/egern/wk3/function/shouldReplaceWeatherKit.mjs rename to src/wk3/function/shouldReplaceWeatherKit.mjs diff --git a/src/egern/entry/parameters.mjs b/src/wk3/parameters.mjs similarity index 100% rename from src/egern/entry/parameters.mjs rename to src/wk3/parameters.mjs diff --git a/src/egern/wk3/process/Response.mjs b/src/wk3/process/Response.mjs similarity index 100% rename from src/egern/wk3/process/Response.mjs rename to src/wk3/process/Response.mjs diff --git a/src/egern/wk3/proto/apple/wk2.js b/src/wk3/proto/apple/wk2.js similarity index 100% rename from src/egern/wk3/proto/apple/wk2.js rename to src/wk3/proto/apple/wk2.js diff --git a/src/wk3/routes.mjs b/src/wk3/routes.mjs new file mode 100644 index 00000000..e22ea34d --- /dev/null +++ b/src/wk3/routes.mjs @@ -0,0 +1,8 @@ +// Shared route definitions; each module generator renders its native syntax. +export const groups = [ + { name: 'availability.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/`, stage: 'response', body: true }, + { name: 'airQualityScale.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/`, stage: 'request' }, + { name: 'weatherAlerts.html.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)`, stage: 'request', echo: true }, + { name: 'weatherAlerts.api.request', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)`, stage: 'request', echo: true }, + { name: 'weather.response', pattern: String.raw`^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/`, stage: 'response', body: true, binary: true }, +]; diff --git a/src/egern/entry/runtime.mjs b/src/wk3/runtime.mjs similarity index 94% rename from src/egern/entry/runtime.mjs rename to src/wk3/runtime.mjs index 6b8542aa..27e7028a 100644 --- a/src/egern/entry/runtime.mjs +++ b/src/wk3/runtime.mjs @@ -1,4 +1,5 @@ -// Egern native API only. A fresh runtime is bound to each bundled invocation. +// Shared HTTP/header adapter for normalized platform contexts. +// Each bundled invocation owns its runtime; client APIs are supplied by the platform entry. export function headerObject(headers = {}) { const result = {}; const add = (name, value) => { @@ -47,7 +48,7 @@ export function createRuntime(ctx) { const body = request.bodyBytes ?? request.body; if (body !== undefined && body !== null) nativeOptions.body = body instanceof ArrayBuffer ? new Uint8Array(body) : body; } - if (typeof ctx.http?.[method] !== "function") throw new TypeError(`Unsupported Egern HTTP method: ${method}`); + if (typeof ctx.http?.[method] !== "function") throw new TypeError(`Unsupported HTTP method: ${method}`); return ctx.http[method](request.url, nativeOptions); } return { diff --git a/src/egern/wk3/utils/Console.mjs b/src/wk3/utils/Console.mjs similarity index 100% rename from src/egern/wk3/utils/Console.mjs rename to src/wk3/utils/Console.mjs diff --git a/src/egern/wk3/utils/context.mjs b/src/wk3/utils/context.mjs similarity index 100% rename from src/egern/wk3/utils/context.mjs rename to src/wk3/utils/context.mjs diff --git a/src/egern/wk3/utils/fetch.mjs b/src/wk3/utils/fetch.mjs similarity index 100% rename from src/egern/wk3/utils/fetch.mjs rename to src/wk3/utils/fetch.mjs diff --git a/src/egern/wk3/utils/index.mjs b/src/wk3/utils/index.mjs similarity index 100% rename from src/egern/wk3/utils/index.mjs rename to src/wk3/utils/index.mjs diff --git a/src/egern/wk3/utils/object.mjs b/src/wk3/utils/object.mjs similarity index 100% rename from src/egern/wk3/utils/object.mjs rename to src/wk3/utils/object.mjs diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs index 875a8484..fae21817 100644 --- a/tests/wk3-modules.test.mjs +++ b/tests/wk3-modules.test.mjs @@ -5,8 +5,9 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { localParameters } from '../src/egern/entry/parameters.mjs'; +import { localParameters } from '../src/wk3/parameters.mjs'; import { buildWK3Modules, groups, platforms, release, renderWK3Module } from '../scripts/build-wk3-modules.mjs'; +import { localScriptGroups } from '../scripts/build-egern-module.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const base = release('3.3.2'); @@ -41,6 +42,18 @@ test('five disjoint WK3 routing groups cover the supported API versions', () => } }); +test('Egern shares all five route groups without overlapping native alert IDs', () => { + assert.equal(localScriptGroups.length, groups.length); + for (const [url, expected] of examples) { + assert.deepEqual(localScriptGroups.flatMap(({ match }, i) => new RegExp(match).test(url) ? [i] : []), [expected], url); + } + for (const [i, group] of groups.entries()) { + assert.equal(localScriptGroups[i].file, group.stage); + assert.equal(localScriptGroups[i].body, Boolean(group.body)); + assert.equal(localScriptGroups[i].binary, Boolean(group.binary)); + } +}); + for (const platform of Object.keys(platforms)) test(`${platform}: structure, five groups, versioned scripts and MITM`, () => { const text = renderWK3Module(platform, '3.3.2'); const rules = rows(platform, text); diff --git a/tests/wk3-platform-adapter.test.mjs b/tests/wk3-platform-adapter.test.mjs index c493500e..e18fdc74 100644 --- a/tests/wk3-platform-adapter.test.mjs +++ b/tests/wk3-platform-adapter.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import vm from "node:vm"; import { Builder } from "flatbuffers"; -import { encodeAirQuality, decodeWK3DataSet } from "../src/egern/wk3/class/WeatherKit3.mjs"; +import { encodeAirQuality, decodeWK3DataSet } from "../src/wk3/class/WeatherKit3.mjs"; import { runPlatform } from "../src/platform/wk3-adapter.mjs"; import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; import { renderWK3Module } from "../scripts/build-wk3-modules.mjs"; diff --git a/tests/egern-wk3/aq-algorithms.test.mjs b/tests/wk3/aq-algorithms.test.mjs similarity index 96% rename from tests/egern-wk3/aq-algorithms.test.mjs rename to tests/wk3/aq-algorithms.test.mjs index 7295e307..4ae5c3e2 100644 --- a/tests/egern-wk3/aq-algorithms.test.mjs +++ b/tests/wk3/aq-algorithms.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -const { default: AirQuality } = await import("../../src/egern/wk3/class/AirQuality.mjs"); +const { default: AirQuality } = await import("../../src/wk3/class/AirQuality.mjs"); const pollutants = [ { pollutantType: "PM2_5", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }, diff --git a/tests/egern-wk3/egern-local.test.mjs b/tests/wk3/egern-local.test.mjs similarity index 97% rename from tests/egern-wk3/egern-local.test.mjs rename to tests/wk3/egern-local.test.mjs index bf58d635..cd4930de 100644 --- a/tests/egern-wk3/egern-local.test.mjs +++ b/tests/wk3/egern-local.test.mjs @@ -2,12 +2,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { Builder, ByteBuffer } from "flatbuffers"; import { buildEgern } from "../../scripts/build-egern.mjs"; -import { createRuntime } from "../../src/egern/entry/runtime.mjs"; -import { encodeAirQuality, decodeWK3DataSet } from "../../src/egern/wk3/class/WeatherKit3.mjs"; -import { decodeWK3NextHour } from "../../src/egern/wk3/class/WK3NextHour.mjs"; -import { decodeWK3Alerts } from "../../src/egern/wk3/class/WK3Alerts.mjs"; +import { createRuntime } from "../../src/wk3/runtime.mjs"; +import { encodeAirQuality, decodeWK3DataSet } from "../../src/wk3/class/WeatherKit3.mjs"; +import { decodeWK3NextHour } from "../../src/wk3/class/WK3NextHour.mjs"; +import { decodeWK3Alerts } from "../../src/wk3/class/WK3Alerts.mjs"; import vm from "node:vm"; -import WeatherKit2 from "../../src/egern/wk3/class/WeatherKit2.mjs"; +import WeatherKit2 from "../../src/wk3/class/WeatherKit2.mjs"; const bundles = await buildEgern(); const load = async code => (await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`)).default; diff --git a/tests/egern-wk3/egern-parameters.test.mjs b/tests/wk3/egern-parameters.test.mjs similarity index 95% rename from tests/egern-wk3/egern-parameters.test.mjs rename to tests/wk3/egern-parameters.test.mjs index 307f128b..f2563682 100644 --- a/tests/egern-wk3/egern-parameters.test.mjs +++ b/tests/wk3/egern-parameters.test.mjs @@ -1,5 +1,5 @@ import test from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm'; -import {configuration}from '../../src/egern/entry/index.mjs';import {localParameters}from '../../src/egern/entry/parameters.mjs';import {renderLocalModule,localScriptGroups}from '../../scripts/build-egern-module.mjs';import {buildEgern}from '../../scripts/build-egern.mjs'; +import {configuration}from '../../src/wk3/entry/index.mjs';import {localParameters}from '../../src/wk3/parameters.mjs';import {renderLocalModule,localScriptGroups}from '../../scripts/build-egern-module.mjs';import {buildEgern}from '../../scripts/build-egern.mjs'; const defaults=()=>Object.fromEntries(localParameters.map(p=>[p.key,p.value])); const value=(o,path)=>path.split('.').reduce((a,k)=>a?.[k],o); test('all original restored keys reach Settings with current US/minute/yesterday defaults',()=>{ diff --git a/tests/egern-wk3/native-alert-details.test.mjs b/tests/wk3/native-alert-details.test.mjs similarity index 98% rename from tests/egern-wk3/native-alert-details.test.mjs rename to tests/wk3/native-alert-details.test.mjs index f9672ec8..57452d72 100644 --- a/tests/egern-wk3/native-alert-details.test.mjs +++ b/tests/wk3/native-alert-details.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import WeatherAlerts from "../../src/egern/wk3/class/WeatherAlerts.mjs"; -import { buildNativeAlertDetailsURL, resolveNativeAlertDetails, isNativeAlertDetailsID } from "../../src/egern/wk3/class/NativeAlertDetails.mjs"; +import WeatherAlerts from "../../src/wk3/class/WeatherAlerts.mjs"; +import { buildNativeAlertDetailsURL, resolveNativeAlertDetails, isNativeAlertDetailsID } from "../../src/wk3/class/NativeAlertDetails.mjs"; // Synthetic public HTML and UUID bytes only; no Apple sessions or coordinates. const identifier = "synthetic-101010100"; diff --git a/tests/egern-wk3/wk2-selective.test.mjs b/tests/wk3/wk2-selective.test.mjs similarity index 98% rename from tests/egern-wk3/wk2-selective.test.mjs rename to tests/wk3/wk2-selective.test.mjs index d74eb0e2..5503ef12 100644 --- a/tests/egern-wk3/wk2-selective.test.mjs +++ b/tests/wk3/wk2-selective.test.mjs @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { Builder, ByteBuffer } from "flatbuffers"; -import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../../src/egern/wk3/class/WeatherKit2.mjs"; -import * as WK2 from "../../src/egern/wk3/proto/apple/wk2.js"; +import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../../src/wk3/class/WeatherKit2.mjs"; +import * as WK2 from "../../src/wk3/proto/apple/wk2.js"; // All fixtures are synthetic FlatBuffers, with no HAR or user response data. const airQuality = { diff --git a/tests/egern-wk3/wk3-alerts.test.mjs b/tests/wk3/wk3-alerts.test.mjs similarity index 92% rename from tests/egern-wk3/wk3-alerts.test.mjs rename to tests/wk3/wk3-alerts.test.mjs index 0bc34f7a..7959cb0e 100644 --- a/tests/egern-wk3/wk3-alerts.test.mjs +++ b/tests/wk3/wk3-alerts.test.mjs @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { Builder, ByteBuffer } from "flatbuffers"; -import { decodeWK3Alerts, patchWK3Alerts, enrichWK3Alerts, patchWK3QWeatherAlerts, WK3_ALERT_ENUMS, WK3AlertsTable, wk3AlertsCodec } from "../../src/egern/wk3/class/WK3Alerts.mjs"; -import WeatherAlerts from "../../src/egern/wk3/class/WeatherAlerts.mjs"; +import { decodeWK3Alerts, patchWK3Alerts, enrichWK3Alerts, patchWK3QWeatherAlerts, WK3_ALERT_ENUMS, WK3AlertsTable, wk3AlertsCodec } from "../../src/wk3/class/WK3Alerts.mjs"; +import WeatherAlerts from "../../src/wk3/class/WeatherAlerts.mjs"; // All fixtures synthetic. No HAR, Apple response bytes, credentials or real location. const html = `<h1 class="c-submenu__location">测试区</h1><div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00+08:00</p><p class="warning-events__txt">注意防范大风。</p><div class="warning-defense__txt"><p>1. 停止高空作业。</p></div></div>`; @@ -106,9 +106,9 @@ test("HTML fetch sends no Apple authorization and fails on status/content type", }); test('production Response routes to Apple native details while preserving summary UUID', async()=>{ - const {Response:processResponse}=await import('../../src/egern/wk3/process/Response.mjs'); - const {default:database}=await import('../../src/egern/wk3/function/database.mjs'); - const {default:buildSettings}=await import('../../src/egern/wk3/function/buildSettings.mjs'); + const {Response:processResponse}=await import('../../src/wk3/process/Response.mjs'); + const {default:database}=await import('../../src/wk3/function/database.mjs'); + const {default:buildSettings}=await import('../../src/wk3/function/buildSettings.mjs'); const saved=globalThis.fetch;globalThis.fetch=fetchPage; - try{const bytes=fixture(),before=decodeWK3Alerts(bytes);const out=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database).Settings,Configs:{}});const after=decodeWK3Alerts(out.body);assert.equal(after.alerts[0].description,'大风蓝色预警');assert.equal(new URL(after.detailsUrl).origin,'https://weatherkit.apple.com');assert.equal(new URL(after.detailsUrl).pathname,'/alertDetails/index.html');assert.equal(after.alerts[0].detailsUrl,after.detailsUrl);assert.deepEqual(after.alerts[0].id,before.alerts[0].id);const u=new URL(after.detailsUrl);const {resolveNativeAlertDetails}=await import('../../src/egern/wk3/class/NativeAlertDetails.mjs');const details=await resolveNativeAlertDetails(u.searchParams.get('ids'),u.searchParams.get('lang'),fetchPage);assert.equal(details.length,1);assert.equal(details[0].id,'00010203-0405-0607-0809-0a0b0c0d0e0f');assert.equal(details[0].detailsUrl,'#'+details[0].id);assert.match(details[0].messages[0].text,/注意防范/);}finally{globalThis.fetch=saved;} + try{const bytes=fixture(),before=decodeWK3Alerts(bytes);const out=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database).Settings,Configs:{}});const after=decodeWK3Alerts(out.body);assert.equal(after.alerts[0].description,'大风蓝色预警');assert.equal(new URL(after.detailsUrl).origin,'https://weatherkit.apple.com');assert.equal(new URL(after.detailsUrl).pathname,'/alertDetails/index.html');assert.equal(after.alerts[0].detailsUrl,after.detailsUrl);assert.deepEqual(after.alerts[0].id,before.alerts[0].id);const u=new URL(after.detailsUrl);const {resolveNativeAlertDetails}=await import('../../src/wk3/class/NativeAlertDetails.mjs');const details=await resolveNativeAlertDetails(u.searchParams.get('ids'),u.searchParams.get('lang'),fetchPage);assert.equal(details.length,1);assert.equal(details[0].id,'00010203-0405-0607-0809-0a0b0c0d0e0f');assert.equal(details[0].detailsUrl,'#'+details[0].id);assert.match(details[0].messages[0].text,/注意防范/);}finally{globalThis.fetch=saved;} }); diff --git a/tests/egern-wk3/wk3-codec.test.mjs b/tests/wk3/wk3-codec.test.mjs similarity index 99% rename from tests/egern-wk3/wk3-codec.test.mjs rename to tests/wk3/wk3-codec.test.mjs index 3da3ab7f..49e02291 100644 --- a/tests/egern-wk3/wk3-codec.test.mjs +++ b/tests/wk3/wk3-codec.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { Builder, ByteBuffer } from "flatbuffers"; -import { WK3_CODECS, WK3_ROOT_SLOTS, decodeWK3DataSet, encodeAirQuality, decodeAirQuality, WK3AirQualityTable } from "../../src/egern/wk3/class/WeatherKit3.mjs"; +import { WK3_CODECS, WK3_ROOT_SLOTS, decodeWK3DataSet, encodeAirQuality, decodeAirQuality, WK3AirQualityTable } from "../../src/wk3/class/WeatherKit3.mjs"; // Synthetic only. No HAR bytes, provider URLs, coordinates or authorization. function finish(builder, offset) { builder.finish(offset); return builder.asUint8Array(); } diff --git a/tests/egern-wk3/wk3-comparison.test.mjs b/tests/wk3/wk3-comparison.test.mjs similarity index 98% rename from tests/egern-wk3/wk3-comparison.test.mjs rename to tests/wk3/wk3-comparison.test.mjs index 56f90590..4702cd98 100644 --- a/tests/egern-wk3/wk3-comparison.test.mjs +++ b/tests/wk3/wk3-comparison.test.mjs @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { Builder, ByteBuffer } from "flatbuffers"; -import { WK3AirQualityTable, encodeAirQuality, decodeAirQuality } from "../../src/egern/wk3/class/WeatherKit3.mjs"; -import { WK3_COMPARISON, computeWK3Comparison, injectWK3Comparison } from "../../src/egern/wk3/class/WK3Comparison.mjs"; +import { WK3AirQualityTable, encodeAirQuality, decodeAirQuality } from "../../src/wk3/class/WeatherKit3.mjs"; +import { WK3_COMPARISON, computeWK3Comparison, injectWK3Comparison } from "../../src/wk3/class/WK3Comparison.mjs"; const aq = (categoryIndex = 2, scale = "EPA_NowCast.2604") => ({ metadata: { providerName: "Synthetic", temporarilyUnavailable: false }, diff --git a/tests/egern-wk3/wk3-features-integration.test.mjs b/tests/wk3/wk3-features-integration.test.mjs similarity index 93% rename from tests/egern-wk3/wk3-features-integration.test.mjs rename to tests/wk3/wk3-features-integration.test.mjs index b34f2064..eff69b1c 100644 --- a/tests/egern-wk3/wk3-features-integration.test.mjs +++ b/tests/wk3/wk3-features-integration.test.mjs @@ -1,11 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { Builder,ByteBuffer } from 'flatbuffers'; -import { Response as processResponse } from '../../src/egern/wk3/process/Response.mjs'; -import { encodeAirQuality,decodeWK3DataSet } from '../../src/egern/wk3/class/WeatherKit3.mjs'; -import { encodeNextHour,providerToWK3NextHour,decodeWK3NextHour } from '../../src/egern/wk3/class/WK3NextHour.mjs'; -import database from '../../src/egern/wk3/function/database.mjs'; -import buildSettings from '../../src/egern/wk3/function/buildSettings.mjs'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import { encodeAirQuality,decodeWK3DataSet } from '../../src/wk3/class/WeatherKit3.mjs'; +import { encodeNextHour,providerToWK3NextHour,decodeWK3NextHour } from '../../src/wk3/class/WK3NextHour.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; const start=2000000040; function rain(){return {metadata:{sourceType:'MODELED',providerName:'Synthetic',latitude:0,longitude:0},forecastStart:start,forecastEnd:start+180,minutes:[0,1,2].map(i=>({startTime:start+i*60,precipitationChance:70,precipitationIntensity:1.5,perceivedPrecipitationIntensity:1.2})),summary:[{startTime:start,endTime:0,condition:'RAIN',precipitationChance:70,precipitationIntensity:1.5}],condition:[{startTime:start,endTime:0,forecastToken:'CONSTANT',beginCondition:'RAIN',endCondition:'RAIN',parameters:[]}]};} const aq={metadata:{sourceType:3,providerName:'Synthetic Apple'},scale:'HJ6332012.2604',index:60,categoryIndex:2,isSignificant:true,primaryPollutant:11,pollutants:[],previousDayComparison:2}; diff --git a/tests/egern-wk3/wk3-hk-aq.test.mjs b/tests/wk3/wk3-hk-aq.test.mjs similarity index 89% rename from tests/egern-wk3/wk3-hk-aq.test.mjs rename to tests/wk3/wk3-hk-aq.test.mjs index 44121da1..49872568 100644 --- a/tests/egern-wk3/wk3-hk-aq.test.mjs +++ b/tests/wk3/wk3-hk-aq.test.mjs @@ -1,11 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { Builder, ByteBuffer } from 'flatbuffers'; -import { Response as processResponse } from '../../src/egern/wk3/process/Response.mjs'; -import { decodeWK3DataSet, encodeAirQuality } from '../../src/egern/wk3/class/WeatherKit3.mjs'; -import buildSettings from '../../src/egern/wk3/function/buildSettings.mjs'; -import database from '../../src/egern/wk3/function/database.mjs'; -import { isHongKong } from '../../src/egern/wk3/function/shouldReplaceWeatherKit.mjs'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import { decodeWK3DataSet, encodeAirQuality } from '../../src/wk3/class/WeatherKit3.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import { isHongKong } from '../../src/wk3/function/shouldReplaceWeatherKit.mjs'; const provider={metadata:{providerName:'Synthetic HK',sourceType:'MODELED'},scale:'EPA_NowCast.2414',index:72,categoryIndex:2,isSignificant:false,primaryPollutant:'PM2_5',pollutants:[{pollutantType:'PM2_5',amount:15,units:'MICROGRAMS_PER_CUBIC_METER'}]}; function root(aq=false){const b=new Builder(),a=aq?encodeAirQuality(b,{scale:'HK.2604',index:3,categoryIndex:2,metadata:{providerName:'Apple',sourceType:3},primaryPollutant:1,pollutants:[]}):0;const text=b.createString('keep weather/alerts/news/unknown');b.startObject(1);b.addFieldOffset(0,text,0);const o=b.endObject();b.startObject(18);if(a)b.addFieldOffset(0,a,0);for(const slot of[1,2,3,9,17])b.addFieldOffset(slot,o,0);b.finish(b.endObject());return b.asUint8Array();} async function run(bytes,{country='HK',lat=22.278,lon=114.171,dataSets='airQuality,currentWeather,weatherAlerts',result=provider,settings={}}={}){return (await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/zh-Hans-SG/${lat}/${lon}?country=${country}&dataSets=${dataSets}`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database,{AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS',Replace:['HJ6332012']}},Comparison:{Yesterday:{IndexProvider:'ColorfulCloudsUS'}}},...settings}).Settings,Configs:{},enviroments:{},preFetched:{index:Promise.resolve(result),yesterdayAirQuality:Promise.resolve({...provider,index:125,categoryIndex:3})}})).body;} diff --git a/tests/egern-wk3/wk3-next-hour.test.mjs b/tests/wk3/wk3-next-hour.test.mjs similarity index 99% rename from tests/egern-wk3/wk3-next-hour.test.mjs rename to tests/wk3/wk3-next-hour.test.mjs index e3a4cf7e..430bd525 100644 --- a/tests/egern-wk3/wk3-next-hour.test.mjs +++ b/tests/wk3/wk3-next-hour.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { Builder, ByteBuffer } from 'flatbuffers'; -import { WK3NextHourTable, WK3_NEXT_HOUR_ENUMS, decodeNextHour, decodeWK3NextHour, encodeNextHour, providerToWK3NextHour, nextHourCodec } from '../../src/egern/wk3/class/WK3NextHour.mjs'; +import { WK3NextHourTable, WK3_NEXT_HOUR_ENUMS, decodeNextHour, decodeWK3NextHour, encodeNextHour, providerToWK3NextHour, nextHourCodec } from '../../src/wk3/class/WK3NextHour.mjs'; const units = { intensityUnit: 'mm/h', chanceUnit: 'percent' }; // Fixed public London coordinate; synthetic values, not a captured user request. From a1defe6465ca5098bfce32d1574c1e2ddf2af4e2 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:55:29 +0800 Subject: [PATCH 20/42] =?UTF-8?q?=E5=8F=91=E8=A1=8C=EF=BC=88v3.3.3?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E8=A1=A5=E5=85=85=E6=AD=A3=E5=BC=8F=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B8=8E=E4=B8=AD=E6=96=87=E7=89=88=E6=9C=AC=E4=BB=8B?= =?UTF-8?q?=E7=BB=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++------------ README.md | 4 ++-- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067da420..82c1b45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,47 @@ -### 🆕 New Features - * none +# v3.3.3 — WK3 本机处理与多平台代理统一架构 -### 🛠️ Bug Fixes - * 修复 WeatherKit Response 处理范围未同时受用户设置和 URL `dataSets` 约束的问题:现在只处理两者的交集,避免地图天气小组件只请求当前天气与空气质量时扩展处理其他数据集。 - * 修复和风天气历史空气质量接口路径多余斜杠的问题,确保请求使用正确的 `/v7/historical/air` 路径。 +基于 NSRingo/WeatherKit v3.3.2,保留上游 WK2 入口与中文独立提交历史,增加 iOS 27 WK3 本机处理。Egern、Surge、Loon、Stash、Quantumult X 共用 `src/wk3/` 业务核心、参数与路由,客户端只适配运行时。 -### 🔣 Dependencies - * 将 FlatBuffer Root 处理器迁移至已发布的 `@nsnanocat/flatbuffer-root@1.0.0`,移除仓库内重复实现。 +## 更新内容 -### ‼️ Breaking Changes - * none +- WK3 空气质量有界替换:支持已验证的国标转彩云美标及同标准更新,香港 Apple 缺 AQ 时按条件补入彩云美标;保留其他数据槽与未知字段。 +- 增加已验证结构的分钟降水、同源同标准昨日空气等级比较;来源或结构不兼容时保持原始数据。 +- 严格匹配 Apple 原有预警事件后补全原生详情,保留原事件 ID;不能匹配时不伪造预警。 +- 内置空气质量提供上游 12 个选项(包含 `None`),默认 **`WAQI_InstantCast_US`**;仅当今日指数来源选为 `Calculate` 时执行本地算法。今日、昨日来源默认仍为 **`ColorfulCloudsUS`**。 +- 统一五平台构建:版本化发行生成 10 份独立 JS 和 5 份 WK3 模块;Egern 的 23 项参数、五类脚本、七条分流规则保留。五类路由同源,排除 `wk3_` 原生预警标识的重复匹配。 +- 修复 Fork CI 的缺失 checkout token、包读取令牌回退和 Node latest 漂移,固定 Node 22;无新增依赖。 -### 🔄 Other Changes - * 从 BoxJs 设置中隐藏仅供插件参数使用的 `Storage` 选项,保留运行时配置行为。 - * 补充数据集交集、用户设置过滤、FlatBuffer 根槽映射和 QWeather 接口路径的回归测试。 +## 下载与安装 + +推荐使用本次发行中**文件名包含 `.WK3.`** 的模块,不要用旧版模块来测试 WK3。 + +| 客户端 | 模块下载 | 验证范围 | +| --- | --- | --- | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.yaml) | 本机接口与合成回放通过;本次 Rollup 产物尚无设备验收。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.sgmodule) | 参数、字节回放及实际脚本合成 AQI 60→87 通过;尚无设备验收。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.lpx) | 参数与二进制脚本模拟通过;尚无设备验收。 | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.stoverride) | 配置、参数及二进制脚本模拟通过;尚无设备验收。 | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.3/iRingo.WeatherKit.WK3.snippet) | 仅请求/文本路径;**WK3 二进制天气透传,不转换**,无交互参数界面。 | + +先停用冲突的旧天气模块,再安装对应客户端模块;模块会下载同版本、对应平台的 JS。不要跨平台交换脚本。现用 Gist 没有被替换,旧 Gist 用户不会被自动升级。发行包保留的上游非 WK3 产物属于旧入口,不是本次 WK3 推荐安装项。 + +## 默认配置 + +- 今日/昨日空气指数:`ColorfulCloudsUS`;原始标准替换范围 `HJ6332012`。 +- 本地计算算法:`WAQI_InstantCast_US`,仅 `Calculate` 来源时使用。 +- 分钟降水:`ColorfulClouds`;预警:`QWeatherWeb`,可选 `WeatherKit` 原样透传。 +- WK2 天气来源:`ColorfulClouds`;逐日/逐小时替换默认均关闭。 +- 替换范围 `CN`,香港缺 AQ 有专用条件;日志 `INFO`、单次请求超时默认 8000 毫秒。设备已保存参数不会被默认值强制覆盖。 + +## 已知限制 + +- **WK3 当前、逐日、逐小时天气尚无经真实样本验证的 codec,仍保持 Apple 原始数据**;选择 WK2 天气来源不能解锁这些 WK3 替换能力。 +- Quantumult X 的 WK3 AQ、分钟雨等二进制修改不支持;Shadowrocket 没有 WK3 本机模块。 +- 新发行产物没有各客户端实机验收;原 Gist 的历史设备确认不能替代本版验证。预警补全依赖严格唯一匹配,网络及第三方服务也可能不可用。 +- 12 个算法选项不代表所有目标标准都可写入 WK3。无法确认的跨标准转换会透传;AQHI 配置及单位换算限制沿用上游实现。 + +## 验证记录 + +- 共享核心与平台测试 **106/106** 通过,发行构建测试 **2/2** 通过,分组执行避免本机并发打包超时。 +- `72229ed` 的 GitHub Build [42004403531](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42004403531) 通过安装、旧入口构建、WK3 构建、产物检查及归档;CI 构建成功不等于客户端实机通过。 +- 此次架构迁移保留 26 个协议、算法、来源文件的内容不变,更新导入和构建路径;CLI 与资源命名保持兼容。 diff --git a/README.md b/README.md index 9dbe11e5..394c270d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ #  iRingo: 🌤 WeatherKit -此 Fork 的 `wk3/ios27-wk3-review` 分支以 NSRingo/WeatherKit **v3.3.2** 为基点,保留原版 WK2 入口;本机 WK3 业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。审查完成前不要将该分支当作正式发行版;`main` 仍是未经这些改动的上游版本。 +此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 ## 本分支更新 @@ -48,7 +48,7 @@ - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 - Quantumult X 二进制天气不可转换;其他平台代理仅通过脚本模拟,没有客户端实机结论。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- Fork **尚无 Release**。`releases/latest/download/...` 或生成模块内的 `releases/download/v<version>/...` 只有在审查、合并、实际构建并上传对应附件后才能用于订阅;目前不要导入这些尚不存在的链接。生成模块不会自动发布附件。 +- 本 Fork 的 v3.3.3 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;仓库内 `modules/` 的旧 Gist 模块仅保留兼容,不自动更新用户已安装配置。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 验证命令:`node --test tests/wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs tests/wk3-release-build.test.mjs`。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 From 90278471f42a409201583c7fb3e4feb85ea769ea Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:35:24 +0800 Subject: [PATCH 21/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E7=A9=BA?= =?UTF-8?q?=E6=B0=94=E8=B4=A8=E9=87=8F=EF=BC=89=EF=BC=9A=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E7=BE=8E=E6=A0=87=E8=BE=B9=E7=95=8C=E3=80=81AQHI=20=E4=B8=8E?= =?UTF-8?q?=E6=B1=A1=E6=9F=93=E7=89=A9=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/class/AirQuality.mjs | 148 ++++++++++++++------------- src/wk3/class/AirQuality.mjs | 158 ++++++++++++++-------------- tests/wk3/aq-algorithms.test.mjs | 11 +- tests/wk3/aq-correctness.test.mjs | 165 ++++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 149 deletions(-) create mode 100644 tests/wk3/aq-correctness.test.mjs diff --git a/src/class/AirQuality.mjs b/src/class/AirQuality.mjs index b5c4b770..56336df7 100644 --- a/src/class/AirQuality.mjs +++ b/src/class/AirQuality.mjs @@ -15,7 +15,8 @@ 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; } /** @@ -70,8 +71,9 @@ export default class AirQuality { const today = Number(todayCategoryIndex); const yesterday = Number(yesterdayCategoryIndex); - // -1、0 和 null 都表示“无可用等级”,不能参与好转/恶化比较。 - if (![today, yesterday].every(categoryIndex => Number.isFinite(categoryIndex) && categoryIndex > 0)) { + // -1、0、空值与非整数均不能参与好转/恶化比较。 + if ([todayCategoryIndex, yesterdayCategoryIndex].some(value => value === null || typeof value === "boolean" || String(value).trim() === "") || + ![today, yesterday].every(value => Number.isInteger(value) && value > 0)) { Console.error("CompareCategoryIndexes", "categoryIndex无效"); return UNKNOWN; } else { @@ -104,7 +106,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}的单位`, @@ -132,32 +134,18 @@ export default class AirQuality { * @param {any} airQuality - 空气质量数据对象 * @returns {any} 单位修复后的空气质量数据对象 */ + // CO unit repair adapted from Horatio's Apache-2.0 PR #107 (831852f). + // A value already labeled µg/m³ cannot safely be distinguished from a mislabeled raw mg/m³ value. static FixPollutantsUnits(airQuality) { - 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; - }); - break; - } - default: { - Console.info(`Provider ${airQuality?.metadata?.providerName} is no need to fix.`); - break; - } - } - Console.info("✅ FixPollutantsUnits"); - return airQuality; + Console.debug("☑️ FixPollutantsUnits"); + if (!["和风天气", "QWeather"].includes(airQuality?.metadata?.providerName) || !Array.isArray(airQuality?.pollutants)) return airQuality; + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = airQuality.pollutants.map(pollutant => { + if (pollutant?.pollutantType !== "CO" || pollutant.units !== mgm3) return pollutant; + const amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3); + return amount < 0 ? pollutant : { ...pollutant, amount, units: ugm3 }; + }); + return { ...airQuality, pollutants }; } static #GetStpConversionFactors(airQuality) { @@ -175,12 +163,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"; 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 = Object.hasOwn(AirQuality.Config.Scales, algorithm ?? "") ? AirQuality.Config.Scales[algorithm] : undefined; + if (algorithm !== "None" && !scale) { + Console.error("Pollutants2AQI", `Unsupported algorithm: ${algorithm}`); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: airQuality?.pollutants }; + } switch (algorithm) { case "None": { @@ -196,7 +188,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; } @@ -225,6 +217,7 @@ export default class AirQuality { // PollutantsToAQHI Console.info("☑️ PollutantsToAQHI"); airQuality = AirQuality.#PollutantsToAQHI(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; airQuality = { ...airQuality, index: airQuality.categoryIndex, @@ -236,6 +229,7 @@ export default class AirQuality { case "AQHI_Multi_CN_HK": { Console.info("☑️ PollutantsToAQHIMulti"); airQuality = AirQuality.#PollutantsToAQHIMulti(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; airQuality = { ...airQuality, index: airQuality.categoryIndex, @@ -268,8 +262,7 @@ export default class AirQuality { const getScale = scaleName => { const scales = AirQuality.Config.Scales; switch (scaleName) { - case scales.CN_AQHI.weatherKitScale.name: - return scales.CN_AQHI; + case scales.HK_AQHI.weatherKitScale.name: return scales.HK_AQHI; case scales.CA_AQHI.weatherKitScale.name: @@ -486,40 +479,47 @@ export default class AirQuality { * @returns {number} * 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。 */ - static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits) { + static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits, step) { if (!pollutantScale) { Console.warn("PollutantsToIndexes", `标准未定义污染物${pollutantType},index将标记为-1`); return -1; } + if (!Number.isFinite(pollutant.amount) || pollutant.amount < 0 || pollutant.units !== pollutantScale.units) return -1; + const scaled = step ? pollutant.amount / step : pollutant.amount; + const amount = step ? Math.floor(scaled + Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) * step : pollutant.amount; const { add, subtract, multiply, divide } = SimplePrecisionMath; 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; }); + if (!range) return -1; + const { indexes, amounts } = range; 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 : -1; } /** @@ -549,11 +549,11 @@ export default class AirQuality { * 每种标准污染物对应的 index 列表。 * - 若标准所需污染物缺失,返回该污染物 index=-1。 */ - 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; - return pollutant; + const converted = { ...pollutant }; + converted.index = AirQuality.#ComputePollutantIndex(converted, converted.pollutantType, pollutantScales?.[converted.pollutantType], AirQuality.Config.Units.Friendly, concentrationSteps?.[converted.pollutantType]); + return converted; }); } @@ -650,10 +650,13 @@ export default class AirQuality { newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants); } - newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants); + newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants, options.concentrationSteps); Console.debug("PollutantsToAirQuality", `convertedPollutants: ${JSON.stringify(newPollutants)}`); const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories); + if (!Number.isFinite(primaryPollutant.index) || primaryPollutant.index < 0 || primaryPollutant.categoryIndex < 1) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: newPollutants }; + } const maxIndex = scale?.weatherKitScale?.maxIndex; const index = allowOverRange || !Number.isFinite(maxIndex) ? Math.round(primaryPollutant.index) : Math.min(Math.round(primaryPollutant.index), maxIndex); Console.info("✅ PollutantsToAirQuality"); @@ -684,6 +687,13 @@ export default class AirQuality { * @param {{ stpConversionFactors?: object }} [options] * @returns {object} 标准化 airQuality 对象 */ + // Required-input rule adapted from Horatio's Apache-2.0 PR #107 (831852f). + static #HasRiskInputs(pollutants, scale) { + const present = new Set(pollutants.filter(p => p && Number.isFinite(p.amount) && p.amount >= 0 && p.units === scale.pollutants[p.pollutantType]?.units).map(p => p.pollutantType)); + const pmTypes = ["PM10", "PM2_5"].filter(type => scale.pollutants[type]); + return pmTypes.some(type => present.has(type)) && Object.keys(scale.pollutants).every(type => pmTypes.includes(type) || present.has(type)); + } + static #PollutantsToAQHI(pollutants, scale, options = {}) { Console.info("☑️ PollutantsToAQHI"); @@ -694,16 +704,19 @@ export default class AirQuality { } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; // 先做单位转换(目标单位均为 µg/m³) - const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } // 按 pollutantType 索引浓度 const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); if (!p) return null; - if (p.units !== ugm3) { + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { Console.warn("PollutantsToAQHI", `${type} 单位不是 µg/m³(${p.units}),跳过`); return null; } @@ -750,7 +763,9 @@ export default class AirQuality { }); // 将 %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 }); @@ -777,13 +792,16 @@ export default class AirQuality { } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; - const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); if (!p) return null; - if (p.units !== ugm3) { + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { Console.warn("PollutantsToAQHIMulti", `${type} 单位不是 µg/m³(${p.units}),跳过`); return null; } @@ -1858,7 +1876,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 +2063,17 @@ export default class AirQuality { { categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] }, ], }, - // β 系数(回归系数),浓度单位 µg/m³ + // β 系数:NO2、O3 使用 ppb,PM2.5 使用 µg/m³。 + // %AR × 10 / 10.4, then round to 1–10 (11 means 10+). + indexScaleFactor: 10 / 10.4, 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,8 +2299,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) { - Console.error("ConvertUnit", `Amount ${amount} < 0`); + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + Console.error("ConvertUnit", `Amount ${amount} is invalid`); return -1; } @@ -2297,16 +2317,6 @@ 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); - } - if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) { Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`); return -1; diff --git a/src/wk3/class/AirQuality.mjs b/src/wk3/class/AirQuality.mjs index c357ebfa..48bdb2ab 100644 --- a/src/wk3/class/AirQuality.mjs +++ b/src/wk3/class/AirQuality.mjs @@ -15,7 +15,8 @@ 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; } /** @@ -67,12 +68,14 @@ export default class AirQuality { Console.debug("☑️ CompareCategoryIndexes", `todayCategoryIndex: ${todayCategoryIndex}`, `yesterdayCategoryIndex: ${yesterdayCategoryIndex}`); const { UNKNOWN, SAME, WORSE, BETTER } = AirQuality.Config.CompareCategoryIndexes; - const diff = Number(todayCategoryIndex) - Number(yesterdayCategoryIndex); - - if (Number.isNaN(diff)) { + const today = Number(todayCategoryIndex); + const yesterday = Number(yesterdayCategoryIndex); + if ([todayCategoryIndex, yesterdayCategoryIndex].some(value => value === null || typeof value === "boolean" || String(value).trim() === "") || + ![today, yesterday].every(value => Number.isInteger(value) && value > 0)) { Console.error("CompareCategoryIndexes", "categoryIndex无效"); return UNKNOWN; } else { + const diff = today - yesterday; if (diff === 0) { Console.debug("✅ CompareCategoryIndexes"); return SAME; @@ -101,7 +104,7 @@ export default class AirQuality { Console.debug("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}的单位`, @@ -129,32 +132,18 @@ export default class AirQuality { * @param {any} airQuality - 空气质量数据对象 * @returns {any} 单位修复后的空气质量数据对象 */ + // CO unit repair adapted from Horatio's Apache-2.0 PR #107 (831852f). + // A value already labeled µg/m³ cannot safely be distinguished from a mislabeled raw mg/m³ value. static FixPollutantsUnits(airQuality) { Console.debug("☑️ 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; - }); - break; - } - default: { - Console.info(`Provider ${airQuality?.metadata?.providerName} is no need to fix.`); - break; - } - } - Console.debug("✅ FixPollutantsUnits"); - return airQuality; + if (!["和风天气", "QWeather"].includes(airQuality?.metadata?.providerName) || !Array.isArray(airQuality?.pollutants)) return airQuality; + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + const pollutants = airQuality.pollutants.map(pollutant => { + if (pollutant?.pollutantType !== "CO" || pollutant.units !== mgm3) return pollutant; + const amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3); + return amount < 0 ? pollutant : { ...pollutant, amount, units: ugm3 }; + }); + return { ...airQuality, pollutants }; } static #GetStpConversionFactors(airQuality) { @@ -172,12 +161,16 @@ export default class AirQuality { static Pollutants2AQI(airQuality, Settings, options = {}) { Console.debug("☑️ Pollutants2AQI"); - const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm; + const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm ?? "UBA"; 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 = Object.hasOwn(AirQuality.Config.Scales, algorithm ?? "") ? AirQuality.Config.Scales[algorithm] : undefined; + if (algorithm !== "None" && !scale) { + Console.error("Pollutants2AQI", `Unsupported algorithm: ${algorithm}`); + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: airQuality?.pollutants }; + } switch (algorithm) { case "None": { @@ -193,7 +186,7 @@ export default class AirQuality { case "WAQI_InstantCast_US": { // PollutantsToInstantCastUS Console.debug("☑️ 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.debug("✅ PollutantsToInstantCastUS"); break; } @@ -220,12 +213,14 @@ export default class AirQuality { case "HK_AQHI": case "CN_DEATH_HK_AQHI": { airQuality = AirQuality.#PollutantsToAQHI(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; airQuality = { ...airQuality, index: airQuality.categoryIndex }; break; } case "AQHI_Multi_CN": case "AQHI_Multi_CN_HK": { airQuality = AirQuality.#PollutantsToAQHIMulti(airQuality.pollutants, scale, { stpConversionFactors }); + if (airQuality.metadata.temporarilyUnavailable) return airQuality; airQuality = { ...airQuality, index: airQuality.categoryIndex }; break; } @@ -260,6 +255,10 @@ export default class AirQuality { return scales.WAQI_InstantCast_US; case scales.EU_EAQI.weatherKitScale.name: return scales.EU_EAQI; + case scales.CA_AQHI.weatherKitScale.name: + return scales.CA_AQHI; + case scales.HK_AQHI.weatherKitScale.name: + return scales.HK_AQHI; default: return scales[scaleName]; } @@ -322,13 +321,6 @@ export default class AirQuality { const pollutants = injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable ? injectedPollutants.pollutants : airQuality?.pollutants; Console.debug("✅ ConvertPollutants"); - const hasIndexedPollutants = Array.isArray(injectedIndex?.pollutants) && injectedIndex.pollutants.some(pollutant => Number.isFinite(Number(pollutant?.index))); - const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider; - if (isIndexInjected && hasIndexedPollutants && indexProvider && indexProvider !== "WeatherKit" && !replaceUnits.includes(scaleName)) { - Console.info("ConvertPollutants", `Use pollutants converted by ${indexProvider}`); - return injectedIndex.pollutants; - } - if (replaceUnits.includes(scaleName)) { if (isIndexInjected && Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && unitsMode === "Scale") { Console.info("ConvertPollutants", `Use pollutants from iRingo`); @@ -352,6 +344,7 @@ export default class AirQuality { if (!scale) return ""; Console.debug("☑️ GetNameFromScale", `scale: ${scale}`); + if (Object.values(AirQuality.Config.Scales).some(entry => entry.weatherKitScale?.name === scale)) return scale; const lastDotIndex = scale.lastIndexOf("."); if (lastDotIndex === -1) { Console.warn("GetNameFromScale", `无法找到${scale}的版本号`); @@ -486,40 +479,47 @@ export default class AirQuality { * @returns {number} * 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。 */ - static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits) { + static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits, step) { if (!pollutantScale) { Console.info("PollutantsToIndexes", `当前标准不使用污染物${pollutantType}计算指数,保留浓度并将index标记为-1`); return -1; } + if (!Number.isFinite(pollutant.amount) || pollutant.amount < 0 || pollutant.units !== pollutantScale.units) return -1; + const scaled = step ? pollutant.amount / step : pollutant.amount; + const amount = step ? Math.floor(scaled + Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) * step : pollutant.amount; const { add, subtract, multiply, divide } = SimplePrecisionMath; 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; }); + if (!range) return -1; + const { indexes, amounts } = range; Console.debug("indexes:", indexes, "amounts:", 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 : -1; } /** @@ -549,11 +549,11 @@ export default class AirQuality { * 每种标准污染物对应的 index 列表。 * - 若标准所需污染物缺失,返回该污染物 index=-1。 */ - 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; - return pollutant; + const converted = { ...pollutant }; + converted.index = AirQuality.#ComputePollutantIndex(converted, converted.pollutantType, pollutantScales?.[converted.pollutantType], AirQuality.Config.Units.Friendly, concentrationSteps?.[converted.pollutantType]); + return converted; }); } @@ -665,10 +665,13 @@ export default class AirQuality { newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants); } - newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants); + newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants, options.concentrationSteps); Console.debug("PollutantsToAirQuality", "convertedPollutants:", newPollutants); const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories); + if (!Number.isFinite(primaryPollutant.index) || primaryPollutant.index < 0 || primaryPollutant.categoryIndex < 1) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: newPollutants }; + } const maxIndex = scale?.weatherKitScale?.maxIndex; const index = allowOverRange || !Number.isFinite(maxIndex) ? Math.round(primaryPollutant.index) : Math.min(Math.round(primaryPollutant.index), maxIndex); Console.debug("✅ PollutantsToAirQuality"); @@ -683,6 +686,13 @@ export default class AirQuality { }; } + // Required-input rule adapted from Horatio's Apache-2.0 PR #107 (831852f). + static #HasRiskInputs(pollutants, scale) { + const present = new Set(pollutants.filter(p => p && Number.isFinite(p.amount) && p.amount >= 0 && p.units === scale.pollutants[p.pollutantType]?.units).map(p => p.pollutantType)); + const pmTypes = ["PM10", "PM2_5"].filter(type => scale.pollutants[type]); + return pmTypes.some(type => present.has(type)) && Object.keys(scale.pollutants).every(type => pmTypes.includes(type) || present.has(type)); + } + static #PollutantsToAQHI(pollutants, scale, options = {}) { Console.info("☑️ PollutantsToAQHI"); @@ -693,16 +703,19 @@ export default class AirQuality { } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; // 先做单位转换(目标单位均为 µg/m³) - const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } // 按 pollutantType 索引浓度 const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); if (!p) return null; - if (p.units !== ugm3) { + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { Console.warn("PollutantsToAQHI", `${type} 单位不是 µg/m³(${p.units}),跳过`); return null; } @@ -749,7 +762,9 @@ export default class AirQuality { }); // 将 %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 }); @@ -776,13 +791,16 @@ export default class AirQuality { } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; - const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + const sourcePollutants = pollutants.map(pollutant => ({ ...pollutant })); + const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(sourcePollutants, stpConversionFactors, scale.pollutants) : sourcePollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants: convertedPollutants }; + } const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); if (!p) return null; - if (p.units !== ugm3) { + if (p.units !== scale.pollutants[type]?.units || !Number.isFinite(p.amount) || p.amount < 0) { Console.warn("PollutantsToAQHIMulti", `${type} 单位不是 µg/m³(${p.units}),跳过`); return null; } @@ -1865,7 +1883,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] }, // 中度污染 @@ -2047,15 +2065,17 @@ HK_AQHI: { { categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] }, ], }, - // β 系数(回归系数),浓度单位 µg/m³ + // β 系数:NO2、O3 使用 ppb,PM2.5 使用 µg/m³。 + // %AR × 10 / 10.4, then round to 1–10 (11 means 10+). + indexScaleFactor: 10 / 10.4, 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 }, }, }, @@ -2281,8 +2301,8 @@ HK_AQHI: { Console.debug("☑️ ConvertUnit"); const friendlyUnits = AirQuality.Config.Units.Friendly; Console.debug("Convert", amount, "from", friendlyUnits[from], "with", fromStpConversionFactor, "to", friendlyUnits[to], "with", toStpConversionFactor); - if (amount < 0) { - Console.error("ConvertUnit", `Amount ${amount} < 0`); + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + Console.error("ConvertUnit", `Amount ${amount} is invalid`); return -1; } @@ -2299,16 +2319,6 @@ HK_AQHI: { 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); - } - if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) { Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`); return -1; diff --git a/tests/wk3/aq-algorithms.test.mjs b/tests/wk3/aq-algorithms.test.mjs index 4ae5c3e2..d31ccc9f 100644 --- a/tests/wk3/aq-algorithms.test.mjs +++ b/tests/wk3/aq-algorithms.test.mjs @@ -35,7 +35,7 @@ test("WK3 exposes all twelve upstream AQ algorithms without silent UBA fallback" test("AQHI algorithms use their distinct upstream risk/index calculation", () => { const expected = { HK_AQHI: ["HK.AQHI", 5], - CA_AQHI: ["CA.AQHI", 9], + CA_AQHI: ["CA.AQHI", 5], CN_DEATH_AQHI: ["CN.AQHI", 4], CN_DEATH_HK_AQHI: ["CN.AQHI", 5], AQHI_Multi_CN: ["CA.AQHI", 4], @@ -49,10 +49,9 @@ test("AQHI algorithms use their distinct upstream risk/index calculation", () => } }); -test("unknown algorithm behavior remains the existing UBA fallback", () => { - const expected = AirQuality.Pollutants2AQI(input, {}, { algorithm: "UBA" }); +test("unknown algorithm fails closed rather than pretending to be UBA", () => { const actual = AirQuality.Pollutants2AQI(input, {}, { algorithm: "not-a-supported-algorithm" }); - assert.equal(actual.index, expected.index); - assert.equal(actual.categoryIndex, expected.categoryIndex); - assert.equal(actual.scale, expected.scale); + assert.equal(actual.metadata?.temporarilyUnavailable, true); + assert.deepEqual(actual.pollutants, pollutants); + assert.notEqual(actual.scale, AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.UBA.weatherKitScale)); }); diff --git a/tests/wk3/aq-correctness.test.mjs b/tests/wk3/aq-correctness.test.mjs new file mode 100644 index 00000000..c8ce9fba --- /dev/null +++ b/tests/wk3/aq-correctness.test.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import AirQualityWK3 from "../../src/wk3/class/AirQuality.mjs"; + +// Exercise the legacy implementation without installing its runtime logger dependency. +// Only imports are redirected; all algorithm code and configuration remain unchanged. +const legacyURL = new URL("../../src/class/AirQuality.mjs", import.meta.url); +const legacySource = (await readFile(legacyURL, "utf8")) + .replace('"@nsnanocat/util"', JSON.stringify(new URL("../../src/wk3/utils/index.mjs", import.meta.url).href)) + .replace('"./SimplePrecisionMath.mjs"', JSON.stringify(new URL("../../src/class/SimplePrecisionMath.mjs", import.meta.url).href)); +const { default: AirQualityLegacy } = await import(`data:text/javascript;base64,${Buffer.from(legacySource).toString("base64")}`); +const ug = "MICROGRAMS_PER_CUBIC_METER", mg = "MILLIGRAMS_PER_CUBIC_METER"; +const ppb = "PARTS_PER_BILLION", ppm = "PARTS_PER_MILLION"; +const p = (pollutantType, amount, units = ug) => ({ pollutantType, amount, units }); +const sample = () => [p("PM2_5", 25), p("PM10", 50), p("OZONE", 100), p("NO2", 25), p("SO2", 40), p("CO", 700)]; +const input = pollutants => ({ metadata: { providerName: "test" }, pollutants }); +const freezeInput = pollutants => { + const value = input(pollutants.map(Object.freeze)); + Object.freeze(value.pollutants); + Object.freeze(value.metadata); + return Object.freeze(value); +}; +for (const [entry, AQ] of [["wk3", AirQualityWK3], ["legacy", AirQualityLegacy]]) { + const calc = (pollutants, algorithm = "WAQI_InstantCast_US", options = {}) => AQ.Pollutants2AQI(input(pollutants), {}, { algorithm, ...options }); + test(`${entry}: US concentration truncation affects index only, including decimal boundaries`, () => { + // Fixed boundary expectations from the configured InstantCast breakpoints. + for (const [type, amount, units, expected] of [ + ["PM2_5", 9.09, ug, 50], ["PM2_5", 9.1, ug, 51], + ["PM2_5", 35.49, ug, 100], ["PM2_5", 35.5, ug, 101], + ["PM10", 54.99, ug, 50], ["PM10", 55, ug, 51], + ["OZONE", 62.59, ppb, 50], ["OZONE", 62.6, ppb, 51], + ["SO2", 35.99, ppb, 50], ["SO2", 36, ppb, 51], + ["NO2", 53.99, ppb, 50], ["NO2", 54, ppb, 51], + ["CO", 4499, ppb, 50], + ]) { + const result = calc([p(type, amount, units)]); + assert.equal(result.index, expected, `${type} ${amount}`); + assert.equal(result.pollutants[0].amount, amount, "physical concentration must not be truncated"); + } + }); + test(`${entry}: identical mixing-ratio units do not depend on STP factors`, () => { + assert.equal(AQ.ConvertUnit(53, ppb, ppb, 1.88, -1), 53); + assert.equal(AQ.ConvertUnit(53, ppb, ppb, 1.88, 1.91), 53); + assert.equal(AQ.ConvertUnit(0.053, ppm, ppb, 1.88, 1.91), 53); + assert.equal(AQ.ConvertUnit(53, ppb, ppm, -1, 1.91), 0.053); + for (const amount of [NaN, Infinity, -1, null, undefined, ""]) assert.equal(AQ.ConvertUnit(amount, ug, ug), -1); + }); + test(`${entry}: PM10 over-range extrapolation is continuous at 604/605`, () => { + assert.equal(calc([p("PM10", 604)], undefined, { allowOverRange: true }).index, 500); + assert.equal(calc([p("PM10", 605)], undefined, { allowOverRange: true }).index, 501); + assert.equal(calc([p("PM10", 605)], undefined, { allowOverRange: false }).index, 500); + }); + test(`${entry}: CN25 zero concentration is valid category 1`, () => { + const result = calc(sample().map(x => ({ ...x, amount: 0 })), "WAQI_InstantCast_CN_25_DRAFT"); + assert.equal(result.index, 0); + assert.equal(result.categoryIndex, 1); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(result.primaryPollutant, "NOT_AVAILABLE"); + }); + test(`${entry}: Canadian AQHI ppb/mass equivalents use all required model inputs`, () => { + const gases = [p("NO2", 20, ppb), p("OZONE", 40, ppb), p("PM2_5", 10)]; + const mass = [p("NO2", 37.60945396612444), p("OZONE", 78.4758596679524), p("PM2_5", 10)]; + // (1000/10.4) * (expm1(.000871*20)+expm1(.000537*40)+expm1(.000487*10)) = 4.2468121822. + for (const pollutants of [gases, mass]) { + const result = calc(pollutants, "CA_AQHI"); + assert.equal(result.index, 4); + assert.equal(result.categoryIndex, 4); + assert.equal(result.metadata.temporarilyUnavailable, false); + } + }); + test(`${entry}: Canadian AQHI rounds rather than ceiling, minimum 1 and maximum 10+`, () => { + // Only NO2 is nonzero; these fixed concentrations yield 2.49, 2.51, and 11.2 before rounding. + for (const [amount, expected] of [[0, 1], [29.352901364443454, 2], [29.58565572211123, 3], [126.49928704508866, 11]]) { + const result = calc([p("NO2", amount, ppb), p("OZONE", 0, ppb), p("PM2_5", 0)], "CA_AQHI"); + assert.equal(result.index, expected, String(amount)); + } + }); + test(`${entry}: AQHI missing required gas or particle inputs is unavailable`, () => { + for (const algorithm of ["CA_AQHI", "HK_AQHI", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK"]) { + for (const absent of [["NO2"], ["OZONE"], ["PM10", "PM2_5"]]) { + const result = calc(sample().filter(x => !absent.includes(x.pollutantType)), algorithm); + assert.equal(result.metadata.temporarilyUnavailable, true, `${algorithm} absent ${absent}`); + } + // A PM10 alternative is valid only when the model defines its coefficient. + const pm10Only = calc(sample().filter(x => x.pollutantType !== "PM2_5"), algorithm); + assert.equal(pm10Only.metadata.temporarilyUnavailable, !["HK_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN_HK"].includes(algorithm), algorithm); + const invalid = sample().map(x => x.pollutantType === "NO2" ? { ...x, amount: NaN } : x); + assert.equal(calc(invalid, algorithm).metadata.temporarilyUnavailable, true, `${algorithm} invalid gas`); + } + }); + test(`${entry}: QWeather CO conversion is unit-driven, immutable and idempotent`, () => { + for (const providerName of ["QWeather", "和风天气"]) { + const original = Object.freeze({ metadata: Object.freeze({ providerName }), pollutants: Object.freeze([Object.freeze(p("CO", 0.7, mg)), Object.freeze(p("PM10", 20))]) }); + const fixed = AQ.FixPollutantsUnits(original); + assert.deepEqual(fixed.pollutants[0], p("CO", 700)); + assert.deepEqual(AQ.FixPollutantsUnits(fixed), fixed); + assert.deepEqual(original.pollutants[0], p("CO", 0.7, mg)); + const normalized = { metadata: { providerName }, pollutants: [p("CO", 700)] }; + assert.deepEqual(AQ.FixPollutantsUnits(normalized), normalized); + } + }); + test(`${entry}: invalid category comparisons are UNKNOWN`, () => { + for (const invalid of [-1, 0, null, undefined, NaN, Infinity, "", "bad", true, 1.5]) { + assert.equal(AQ.CompareCategoryIndexes(invalid, 2), "UNKNOWN", String(invalid)); + assert.equal(AQ.CompareCategoryIndexes(2, invalid), "UNKNOWN", String(invalid)); + } + assert.equal(AQ.CompareCategoryIndexes("2", 2), "SAME"); + assert.equal(AQ.CompareCategoryIndexes(1, 2), "BETTER"); + assert.equal(AQ.CompareCategoryIndexes(3, 2), "WORSE"); + }); + test(`${entry}: empty Units.Replace retains physical source units even with calculated index`, () => { + const original = { ...input(sample()), scale: "EPA_NowCast" }; + const injectedIndex = { ...input([p("CO", 600, ppb)]), index: 10, scale: "EPA_NowCast", pollutants: [{ ...p("CO", 600, ppb), index: 10 }] }; + const settings = { AirQuality: { Current: { Index: { Provider: "Calculate" }, Pollutants: { Units: { Mode: "Scale", Replace: [] } } } } }; + assert.deepEqual(AQ.ConvertPollutants(original, undefined, true, injectedIndex, settings), original.pollutants); + settings.AirQuality.Current.Pollutants.Units.Replace = ["EPA_NowCast"]; + assert.deepEqual(AQ.ConvertPollutants(original, undefined, true, injectedIndex, settings), injectedIndex.pollutants); + settings.AirQuality.Current.Index.Provider = "WeatherKit"; + assert.doesNotThrow(() => AQ.ConvertPollutants(original, undefined, false, undefined, settings)); + }); + test(`${entry}: explicit CA.AQHI replacement handles versionless scale; unknown CN scale is safe`, () => { + const settings = { AirQuality: { Current: { Pollutants: { Units: { Mode: "Scale", Replace: ["CA.AQHI"] } } } } }; + const original = { ...input([p("NO2", 18.80472698306222), p("OZONE", 19.6189649169881), p("PM2_5", 10)]), scale: "CA.AQHI" }; + const result = AQ.ConvertPollutants(original, undefined, false, undefined, settings); + assert.deepEqual(result.map(x => x.units), [ppb, ppb, ug]); + assert.ok(Math.abs(result[0].amount - 10) < 1e-10); + assert.equal(original.pollutants[0].units, ug); + settings.AirQuality.Current.Pollutants.Units.Replace = ["CN.AQHI"]; + assert.doesNotThrow(() => AQ.ConvertPollutants({ ...original, scale: "CN.AQHI" }, undefined, false, undefined, settings)); + }); + test(`${entry}: unknown algorithms fail closed and None remains identity`, () => { + const original = freezeInput(sample()); + for (const algorithm of ["unsupported", "toString", "constructor", "__proto__"]) { + const result = AQ.Pollutants2AQI(original, {}, { algorithm }); + assert.equal(result.metadata.temporarilyUnavailable, true, algorithm); + assert.notEqual(result.scale, "UBA"); + assert.deepEqual(result.pollutants, original.pollutants); + } + assert.equal(AQ.Pollutants2AQI(original, {}, { algorithm: "None" }), original); + assert.equal(AQ.Pollutants2AQI(original, {}).scale, AQ.ToWeatherKitScale(AQ.Config.Scales.UBA.weatherKitScale)); + assert.equal(AQ.Pollutants2AQI(input([]), {}, { algorithm: "UBA" }).metadata.temporarilyUnavailable, true); + }); + test(`${entry}: no supported pollutant index is unavailable, not a negative usable AQI`, () => { + const raw = freezeInput([p("NOX", 12)]); + const result = AQ.Pollutants2AQI(raw, {}, { algorithm: "WAQI_InstantCast_US" }); + assert.equal(result.metadata.temporarilyUnavailable, true); + assert.deepEqual(result.pollutants.map(x => [x.pollutantType, x.amount, x.units]), [["NOX", 12, ug]]); + }); + test(`${entry}: calculations preserve six physical pollutants and never mutate inputs`, () => { + for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK"]) { + const original = freezeInput(sample()); + const result = AQ.Pollutants2AQI(original, {}, { algorithm }); + assert.equal(result.pollutants.length, 6, algorithm); + assert.deepEqual(result.pollutants.map(x => x.pollutantType), original.pollutants.map(x => x.pollutantType)); + for (const pollutant of result.pollutants) { + const source = original.pollutants.find(x => x.pollutantType === pollutant.pollutantType); + const targetFactor = AQ.Config.Scales[algorithm].pollutants[pollutant.pollutantType]?.stpConversionFactor; + const expected = pollutant.units === source.units ? source.amount : AQ.ConvertUnit(pollutant.amount, pollutant.units, source.units, targetFactor); + assert.ok(Math.abs(expected - source.amount) < 1e-8, `${algorithm}: ${pollutant.pollutantType} physical concentration`); + assert.equal(Object.hasOwn(source, "index"), false); + } + } + }); +} From ddf4a9b613f48ec844b6f45598030750eb248012 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:35:24 +0800 Subject: [PATCH 22/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=89=EF=BC=9A=E9=81=B5=E5=BE=AA=E6=8C=87=E6=95=B0?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E7=9B=AE=E6=A0=87=E4=B8=8E=E6=98=A8=E6=97=A5?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/process/Response.dev.mjs | 35 ++-- src/process/Response.mjs | 35 ++-- src/wk3/entry/response.mjs | 17 +- src/wk3/function/buildSettings.mjs | 21 +-- src/wk3/process/Response.mjs | 101 ++++++---- tests/legacy-aq-settings.test.mjs | 175 +++++++++++++++++ tests/wk3/aq-settings-semantics.test.mjs | 227 +++++++++++++++++++++++ 7 files changed, 516 insertions(+), 95 deletions(-) create mode 100644 tests/legacy-aq-settings.test.mjs create mode 100644 tests/wk3/aq-settings-semantics.test.mjs diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 2eb95155..b0b6697a 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -423,15 +423,23 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); - // 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; - - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Step3. 目标列表决定是否替换指数;污染物补齐不应覆盖有效的 Apple 指数。 + const hasUsableIndex = value => value?.metadata && !value.metadata.temporarilyUnavailable && Number.isFinite(value.index) && value.index >= 0 && Number.isInteger(value.categoryIndex) && value.categoryIndex > 0 && typeof value.scale === "string" && value.scale.length > 0; + 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 targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!hasUsableIndex(airQuality) && needPollutants)); + const candidateIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : undefined; + const indexAvailable = needInjectIndex && !!hasUsableIndex(candidateIndex); + const injectedIndex = indexAvailable ? candidateIndex : airQuality; + + // Step4. 仅在有效指数实际变化且允许替换时重算;未知对比可独立补齐。 + 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 currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 @@ -442,7 +450,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,13 +460,14 @@ 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 } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings) ?? [], previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); @@ -728,11 +737,13 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Console.info("✅ InjectComparison"); return comparisonAirQuality; } - case "ColorfulCloudsUS": - default: { + case "ColorfulCloudsUS": { const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider) ? airQuality?.categoryIndex : undefined); Console.info("✅ InjectComparison"); return comparisonAirQuality; } + case "WeatherKit": + default: + return { previousDayComparison: UNKNOWN }; } } diff --git a/src/process/Response.mjs b/src/process/Response.mjs index 941e2a17..e5287372 100644 --- a/src/process/Response.mjs +++ b/src/process/Response.mjs @@ -367,15 +367,23 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); - // 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; - - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Step3. 目标列表决定是否替换指数;污染物补齐不应覆盖有效的 Apple 指数。 + const hasUsableIndex = value => value?.metadata && !value.metadata.temporarilyUnavailable && Number.isFinite(value.index) && value.index >= 0 && Number.isInteger(value.categoryIndex) && value.categoryIndex > 0 && typeof value.scale === "string" && value.scale.length > 0; + 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 targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!hasUsableIndex(airQuality) && needPollutants)); + const candidateIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : undefined; + const indexAvailable = needInjectIndex && !!hasUsableIndex(candidateIndex); + const injectedIndex = indexAvailable ? candidateIndex : airQuality; + + // Step4. 仅在有效指数实际变化且允许替换时重算;未知对比可独立补齐。 + 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 currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 @@ -386,7 +394,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,13 +404,14 @@ 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 } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings) ?? [], + pollutants: AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings) ?? [], previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); @@ -672,11 +681,13 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Console.info("✅ InjectComparison"); return comparisonAirQuality; } - case "ColorfulCloudsUS": - default: { + case "ColorfulCloudsUS": { const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider) ? airQuality?.categoryIndex : undefined); Console.info("✅ InjectComparison"); return comparisonAirQuality; } + case "WeatherKit": + default: + return { previousDayComparison: UNKNOWN }; } } diff --git a/src/wk3/entry/response.mjs b/src/wk3/entry/response.mjs index 42fd76f7..55cae3d4 100644 --- a/src/wk3/entry/response.mjs +++ b/src/wk3/entry/response.mjs @@ -20,22 +20,7 @@ async function processingContext(ctx, url, Settings, Configs) { if (parameters.dataSets.includes("forecastNextHour") && Settings.NextHour.Provider !== "WeatherKit") { preFetched.forecastNextHour = attempt(Settings.NextHour.Provider === "QWeather" ? enviroments.qWeather.Minutely() : enviroments.colorfulClouds.Minutely()); } - if (parameters.dataSets.includes("airQuality")) { - const indexProvider = Settings.AirQuality.Current.Index.Provider; - if (indexProvider === "QWeather") preFetched.index = attempt(enviroments.qWeather.CurrentAirQuality(Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants)); - else if (indexProvider === "ColorfulCloudsUS" || indexProvider === "ColorfulCloudsCN") preFetched.index = attempt(enviroments.colorfulClouds.CurrentAirQuality(indexProvider === "ColorfulCloudsUS", Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants)); - const yesterdayProvider = Settings.AirQuality.Comparison.Yesterday.IndexProvider; - if (yesterdayProvider === "QWeather") { - const cacheKey = "weatherkit-local.qweather-grid"; - const cache = ctx.storage?.getJSON(cacheKey); - const grid = await QWeather.GetLocationsGrid(cache, value => ctx.storage?.setJSON(cacheKey, value)); - preFetched.locationsGrid = grid; - const locationInfo = QWeather.GetLocationInfo(grid, parameters.latitude, parameters.longitude); - preFetched.yesterdayAirQuality = attempt(enviroments.qWeather.prefetchYesterdayAirQuality(locationInfo).then(() => enviroments.qWeather.YesterdayAirQuality(locationInfo))); - } else if (yesterdayProvider === "ColorfulCloudsUS" || yesterdayProvider === "ColorfulCloudsCN") { - preFetched.yesterdayAirQuality = attempt(enviroments.colorfulClouds.prefetchYesterdayHourly().then(() => enviroments.colorfulClouds.YesterdayAirQuality(yesterdayProvider === "ColorfulCloudsUS"))); - } - } + // AQ acquisition is deferred until the decoded response proves it is needed. } return { Settings, Configs, parameters, enviroments, preFetched }; } diff --git a/src/wk3/function/buildSettings.mjs b/src/wk3/function/buildSettings.mjs index 1babd891..94d107fb 100644 --- a/src/wk3/function/buildSettings.mjs +++ b/src/wk3/function/buildSettings.mjs @@ -48,7 +48,7 @@ function traverseObject(o, c) { */ function string2array(Settings, path) { const setting = get(Settings, path); - if (!Array.isArray(setting)) set(Settings, path, setting ? [setting] : []); + if (!Array.isArray(setting)) set(Settings, path, setting === "[]" ? [] : setting ? [setting] : []); } /** @@ -72,6 +72,10 @@ export default function buildSettings(database, queryArguments = {}) { // 2. 合并请求级参数(请求参数优先级最高) if (queryArguments && typeof queryArguments === "object") { merge(Settings, queryArguments); + if (Array.isArray(queryArguments.AirQuality?.Current?.Index?.Replace) + && queryArguments.AirQuality.Current.Index.Replace.length === 0) { + Settings.AirQuality.Current.Index.Replace = []; + } } // 3. 规范化需要为数组的字段 @@ -97,21 +101,6 @@ export default function buildSettings(database, queryArguments = {}) { } }); - const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider; - const calculateAlgorithm = Settings?.AirQuality?.Calculate?.Algorithm; - const indexReplace = Settings?.AirQuality?.Current?.Index?.Replace; - const yesterday = Settings?.AirQuality?.Comparison?.Yesterday; - const shouldReplaceChinaScaleForNonCNIndex = indexProvider === "ColorfulCloudsUS" - || (indexProvider === "Calculate" && ["WAQI_InstantCast_US", "EU_EAQI", "UBA"].includes(calculateAlgorithm)); - if (shouldReplaceChinaScaleForNonCNIndex && Array.isArray(indexReplace) && !indexReplace.includes("HJ6332012")) { - indexReplace.unshift("HJ6332012"); - } - if (yesterday && indexProvider === "ColorfulCloudsUS" && yesterday.IndexProvider === "ColorfulCloudsCN") { - yesterday.IndexProvider = "ColorfulCloudsUS"; - } else if (yesterday && indexProvider === "Calculate" && shouldReplaceChinaScaleForNonCNIndex && yesterday.IndexProvider === "ColorfulCloudsCN") { - yesterday.IndexProvider = "Calculate"; - } - // 5. 构建 Configs(深拷贝合并,并将 Locale/i18n 转为 Map) const Configs = merge( {}, diff --git a/src/wk3/process/Response.mjs b/src/wk3/process/Response.mjs index 992cf40d..ffe85fdb 100644 --- a/src/wk3/process/Response.mjs +++ b/src/wk3/process/Response.mjs @@ -239,6 +239,23 @@ export async function Response($request, $response, context = {}) { return $response; } +// Only explicitly supported providers may supply an index; None is not an algorithm. +function canInjectIndex(Settings) { + const aq = Settings?.AirQuality; + const provider = aq?.Current?.Index?.Provider; + if (["QWeather", "ColorfulCloudsUS", "ColorfulCloudsCN"].includes(provider)) return true; + return provider === "Calculate" && aq?.Calculate?.Algorithm !== "None" + && Object.hasOwn(AirQuality.Config.Scales, aq?.Calculate?.Algorithm ?? "") + && ["WeatherKit", "QWeather", "ColorfulClouds"].includes(aq?.Current?.Pollutants?.Provider); +} + +function validIndex(candidate) { + return !!candidate?.metadata && !candidate.metadata.temporarilyUnavailable + && Number.isInteger(candidate.index) && candidate.index >= 0 && candidate.index <= 32767 + && Number.isInteger(candidate.categoryIndex) && candidate.categoryIndex >= 1 && candidate.categoryIndex <= 127 + && typeof candidate.scale === "string" && candidate.scale.length > 0; +} + // WK3 enums differ from WK2. Same-scale updates retain the original narrow patch; // CN -> US uses independently observed mappings and a complete provider AQ child. async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = {}) { @@ -247,19 +264,19 @@ async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = { if (!original) return rawBody; const scaleName = AirQuality.GetNameFromScale(original.scale); const replace = Settings?.AirQuality?.Current?.Index?.Replace || []; - if (!replace.includes(scaleName)) return rawBody; + if (!replace.includes(scaleName) || !canInjectIndex(Settings)) return rawBody; let injected; if (preFetched.index) { injected = await preFetched.index; } else if (Settings?.AirQuality?.Current?.Index?.Provider === "Calculate") { - const pollutants = preFetched.pollutants ? await preFetched.pollutants : await InjectPollutants(Settings, enviroments); + const pollutants = preFetched.pollutants ? await preFetched.pollutants : Settings?.AirQuality?.Current?.Pollutants?.Provider === "WeatherKit" ? original : await InjectPollutants(Settings, enviroments); if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return rawBody; injected = await InjectIndex(pollutants, Settings, enviroments); if (injected?.metadata) injected = { ...injected, metadata: { ...pollutants.metadata, ...injected.metadata } }; } else { injected = await InjectIndex(undefined, Settings, enviroments); } - if (!injected?.metadata || injected.metadata.temporarilyUnavailable) return rawBody; + if (!validIndex(injected)) return rawBody; const targetScale = AirQuality.GetNameFromScale(injected.scale); if (!scaleName || targetScale !== scaleName) { // CN -> US only. Replace the entire AQ child with one provider's @@ -271,12 +288,17 @@ async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = { const vtable = table - readInt32LE(rawBody, table); // Do not discard future AQ fields when replacing the known child. if (readUint16LE(rawBody, vtable) > 20) return rawBody; - const replacement = await injectWK3Comparison(providerToWK3USAirQuality(injected), { - currentProvider: Settings?.AirQuality?.Current?.Index?.Provider, - yesterdayProvider: Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider, - sources: enviroments, - yesterday: preFetched.yesterdayAirQuality, - }); + const replacement = providerToWK3USAirQuality(injected); + if (Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange === true || ![2, 3, 4].includes(original.previousDayComparison)) { + Object.assign(replacement, await injectWK3Comparison(replacement, { + currentProvider: Settings?.AirQuality?.Current?.Index?.Provider, + yesterdayProvider: Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider, + sources: enviroments, + yesterday: preFetched.yesterdayAirQuality, + })); + } else { + replacement.previousDayComparison = original.previousDayComparison; + } const builder = new flatbuffers.Builder(); builder.finish(encodeWK3AirQuality(builder, replacement)); const patched = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); @@ -866,39 +888,42 @@ async function InjectAirQuality(airQuality, Settings, enviroments, preFetched = // Step1. 修复污染物单位 airQuality = AirQuality.FixPollutantsUnits(airQuality); - // Step2. 判断原始污染物是否为空,并在需要时注入污染物数据 - const isPollutantEmpty = !Array.isArray(airQuality?.pollutants) || airQuality.pollutants.length === 0; - const needReplacePollutants = Settings?.AirQuality?.Current?.Pollutants?.Provider && Settings.AirQuality.Current.Pollutants.Provider !== "WeatherKit"; - const shouldInjectPollutants = isPollutantEmpty || needReplacePollutants; + // Step2. 仅按显式污染物来源配置注入污染物数据 + const needReplacePollutants = ["QWeather", "ColorfulClouds"].includes(Settings?.AirQuality?.Current?.Pollutants?.Provider); + const shouldInjectPollutants = needReplacePollutants; const injectedPollutants = shouldInjectPollutants ? (preFetched.pollutants ? await preFetched.pollutants : await InjectPollutants(Settings, enviroments)) : airQuality; if (shouldInjectPollutants && preFetched.pollutants) Console.info("InjectAirQuality", "污染物使用预取数据"); const needPollutants = shouldInjectPollutants && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.includes(AirQuality.GetNameFromScale(airQuality?.scale)); - let injectedIndex = injectedPollutants; - if (needInjectIndex) { - if (preFetched.index) { - injectedIndex = await preFetched.index; - Console.info("InjectAirQuality", "指数使用预取数据"); - } else { - injectedIndex = await InjectIndex(injectedPollutants, Settings, enviroments); + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider; + const mayInjectIndex = canInjectIndex(Settings) + && Settings?.AirQuality?.Current?.Index?.Replace?.includes(AirQuality.GetNameFromScale(airQuality?.scale)); + let candidate; + if (mayInjectIndex) { + try { + candidate = preFetched.index ? await preFetched.index : await InjectIndex(injectedPollutants, Settings, enviroments); + } catch { + // A failed candidate must not invalidate Apple's current AQ or comparison. } } + const needInjectIndex = mayInjectIndex && validIndex(candidate) + && ["index", "scale", "categoryIndex"].some(key => candidate[key] !== airQuality?.[key]); + const injectedIndex = needInjectIndex ? candidate : airQuality; - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // Known comparisons are recalculated only after a successful, actual change. const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const forceInjectComparison = needInjectIndex || Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange; - const previousDayComparison = forceInjectComparison ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; - const needInjectComparison = forceInjectComparison || previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; - let injectedComparison = { ...injectedIndex, previousDayComparison: weatherKitComparison }; + const forceInjectComparison = Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange === true && needInjectIndex; + const needInjectComparison = forceInjectComparison || weatherKitComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const currentIndexProvider = needInjectIndex ? indexProvider : "WeatherKit"; + let injectedComparison = { previousDayComparison: weatherKitComparison }; if (needInjectComparison) { try { - injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, enviroments, preFetched); + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, enviroments, preFetched) + ?? { previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN }; } catch (error) { Console.warn("InjectAirQuality", "昨日对比获取失败,使用 UNKNOWN 继续返回当前空气质量。", error?.message ?? error); - injectedComparison = { ...injectedIndex, previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN }; + injectedComparison = { previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN }; } } @@ -931,7 +956,7 @@ async function InjectAirQuality(airQuality, Settings, enviroments, preFetched = // Step7. 合并输出:优先使用可用注入结果,并统一 metadata / pollutants / previousDayComparison airQuality = { ...airQuality, - ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? injectedIndex : {}), + ...(needInjectIndex ? injectedIndex : {}), metadata: { ...(airQuality?.metadata ? airQuality.metadata : injectedPollutants?.metadata), providerName, @@ -963,12 +988,12 @@ async function InjectPollutants(Settings, enviroments) { Console.debug("✅ InjectPollutants"); return currentAirQuality; } - case "ColorfulClouds": - default: { + case "ColorfulClouds": { const currentAirQuality = await enviroments.colorfulClouds.CurrentAirQuality(); Console.debug("✅ InjectPollutants"); return currentAirQuality; } + default: return undefined; } } @@ -994,12 +1019,12 @@ async function InjectIndex(airQuality, Settings, enviroments) { Console.debug("✅ InjectIndex"); return currentAirQuality; } - case "Calculate": - default: { + case "Calculate": { const currentAirQuality = AirQuality.Pollutants2AQI(airQuality, Settings); Console.debug("✅ InjectIndex"); return currentAirQuality; } + default: return undefined; } } @@ -1180,7 +1205,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, envi const PollutantsProvider = Settings?.AirQuality?.Comparison?.Yesterday?.PollutantsProvider; Console.debug(`Settings?.AirQuality?.Comparison?.Yesterday?.PollutantsProvider: ${PollutantsProvider}`); - if (algorithm !== "") { + if (algorithm && algorithm !== "None") { switch (PollutantsProvider) { case "ColorfulCloudsCN": { if (algorithm === "WAQI_InstantCast_US") { @@ -1192,8 +1217,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, envi Console.debug("✅ InjectComparison"); return comparisonAirQuality; } - case "QWeather": - default: { + case "QWeather": { const pollutantsToAirQuality = airQuality => AirQuality.Pollutants2AQI(airQuality, Settings, { algorithm }); const comparisonAirQuality = await qweatherComparison(airQuality?.categoryIndex, pollutantsToAirQuality); Console.debug("✅ InjectComparison"); @@ -1216,8 +1240,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, envi Console.debug("✅ InjectComparison"); return comparisonAirQuality; } - case "ColorfulCloudsUS": - default: { + case "ColorfulCloudsUS": { const comparisonAirQuality = colorfulCloudsComparison(true, isEPA454_B18007(currentIndexProvider, airQuality?.scale) ? airQuality?.categoryIndex : undefined); Console.debug("✅ InjectComparison"); return comparisonAirQuality; diff --git a/tests/legacy-aq-settings.test.mjs b/tests/legacy-aq-settings.test.mjs new file mode 100644 index 00000000..521f7fea --- /dev/null +++ b/tests/legacy-aq-settings.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import SimplePrecisionMath from "../src/class/SimplePrecisionMath.mjs"; + +// Execute the actual legacy functions and AQ algorithms without the unavailable +// private @nsnanocat/util package. Only logging and provider I/O are substituted. +const Console = Object.fromEntries(["info", "debug", "error"].map(name => [name, () => {}])); +const aqSource = await readFile(new URL("../src/class/AirQuality.mjs", import.meta.url), "utf8"); +const AirQuality = new Function("Console", "SimplePrecisionMath", aqSource.replace(/^import .*;\n/gm, "").replace("export default class AirQuality", "return class AirQuality"))(Console, SimplePrecisionMath); +const logoSource = await readFile(new URL("../src/function/providerNameToLogo.mjs", import.meta.url), "utf8"); +const providerNameToLogo = new Function("Console", logoSource.replace(/^import .*;\n/gm, "").replace("export default function", "return function"))(Console); + +async function loadInjection(path) { + const source = await readFile(new URL(path, import.meta.url), "utf8"); + return new Function("AirQuality", "Console", "providerNameToLogo", `${source.slice(source.indexOf("async function InjectAirQuality("))}\nreturn InjectAirQuality;`)(AirQuality, Console, providerNameToLogo); +} +const [Response, ResponseDev] = await Promise.all([loadInjection("../src/process/Response.mjs"), loadInjection("../src/process/Response.dev.mjs")]); + +const pollutants = [{ pollutantType: "PM2_5", amount: 25, units: "MICROGRAMS_PER_CUBIC_METER" }]; +const apple = { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + index: 42, + categoryIndex: 1, + scale: "HJ6332012.2414", + isSignificant: true, + primaryPollutant: "PM2_5", + pollutants, + previousDayComparison: "BETTER", +}; +const supplement = { + ...apple, + metadata: { providerName: "QWeather", temporarilyUnavailable: false }, + index: 180, + categoryIndex: 4, + scale: "HJ6332012", +}; + +async function run(t, handler, { source = apple, replace = ["HJ6332012"], provider = "Calculate", algorithm = "EU_EAQI", replaceComparison = false, yesterday = "ColorfulCloudsUS", candidate = supplement } = {}) { + const calls = { current: 0, yesterday: 0 }; + const enviroments = { + qWeather: { CurrentAirQuality: async () => { calls.current++; return structuredClone(candidate); } }, + colorfulClouds: { + YesterdayAirQuality: async () => { calls.yesterday++; return structuredClone({ ...supplement, categoryIndex: 2 }); }, + CurrentAirQuality: async () => { calls.current++; return structuredClone({ ...supplement, categoryIndex: 1 }); }, + }, + }; + const settings = { + DataSets: ["airQuality"], LogLevel: "OFF", Storage: "Argument", + AirQuality: { + Current: { Pollutants: { Provider: "QWeather", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: replace, Provider: provider } }, + Calculate: { Algorithm: algorithm }, + Comparison: { ReplaceWhenCurrentChange: replaceComparison, Yesterday: { IndexProvider: yesterday, PollutantsProvider: "QWeather" } }, + }, + }; + return { output: await handler(structuredClone(source), settings, {}, enviroments), calls }; +} + +function assertAppleIndex(output) { + for (const key of ["index", "categoryIndex", "scale", "isSignificant", "primaryPollutant"]) assert.equal(output[key], apple[key], key); + assert.doesNotMatch(output.metadata.providerName, /指数:/); +} + +for (const [name, handler] of [["release", Response], ["dev", ResponseDev]]) { + test(`${name}: empty targets preserve Apple index while supplementing pollutants`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, pollutants: [] }, replace: [] }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, pollutants); + assert.match(output.metadata.providerName, /污染物:QWeather/); + assert.equal(output.previousDayComparison, "BETTER"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: nonmatching target cannot replace a valid Apple index after supplementation`, async t => { + const { output } = await run(t, handler, { source: { ...apple, pollutants: [] }, replace: ["UBA"] }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, pollutants); + }); + + test(`${name}: Calculate None preserves Apple fields and independent supplementation`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, pollutants: [] }, algorithm: "None", replaceComparison: true }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, pollutants); + assert.equal(output.previousDayComparison, "BETTER"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: nonempty targets allow recovery of a missing Apple index`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, metadata: { ...apple.metadata, temporarilyUnavailable: true }, index: 0, categoryIndex: 0, scale: "", pollutants: [] }, replace: ["UBA"] }); + assert.equal(output.scale, "EU.EAQI"); + assert.ok(Number.isFinite(output.index)); + assert.equal(output.metadata.temporarilyUnavailable, false); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: empty targets also disable recovery of a missing Apple index`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, index: 0, categoryIndex: 0, scale: "", pollutants: [] }, replace: [] }); + assert.equal(output.index, 0); + assert.equal(output.scale, ""); + assert.deepEqual(output.pollutants, pollutants); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: category-only change with switch true recalculates comparison`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, index: apple.index, scale: apple.scale }, replaceComparison: true }); + assert.equal(output.index, apple.index); + assert.equal(output.categoryIndex, supplement.categoryIndex); + assert.equal(calls.yesterday, 1); + }); + + test(`${name}: None disables calculation without suppressing external index providers`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", algorithm: "None" }); + assert.equal(output.index, supplement.index); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: replacement keeps Apple comparison when switch is false`, async t => { + const { output, calls } = await run(t, handler); + assert.equal(output.scale, "EU.EAQI"); + assert.equal(output.previousDayComparison, "BETTER"); + assert.equal(calls.yesterday, 0); + }); + + test(`${name}: identical injected index does not force comparison`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, index: apple.index, categoryIndex: apple.categoryIndex, scale: apple.scale }, replaceComparison: true }); + assert.equal(output.previousDayComparison, "BETTER"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); + + test(`${name}: failed injected index does not force comparison or claim its source`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, metadata: { providerName: "QWeather", temporarilyUnavailable: true } }, replaceComparison: true }); + assertAppleIndex(output); + assert.equal(output.previousDayComparison, "BETTER"); + assert.equal(calls.yesterday, 0); + }); + + test(`${name}: invalid numeric injected index retains Apple data`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", candidate: { ...supplement, index: NaN }, replaceComparison: true }); + assertAppleIndex(output); + assert.equal(output.previousDayComparison, "BETTER"); + assert.equal(calls.yesterday, 0); + }); + + test(`${name}: a changed index with switch true recalculates comparison`, async t => { + const { output, calls } = await run(t, handler, { provider: "QWeather", replaceComparison: true }); + assert.equal(output.index, supplement.index); + assert.equal(calls.yesterday, 1); + assert.match(output.metadata.providerName, /对比昨日:/); + }); + + test(`${name}: unknown comparison is supplemented even without index replacement`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, previousDayComparison: "UNKNOWN" }, replace: [] }); + assert.equal(calls.yesterday, 1); + assert.equal(output.previousDayComparison, "BETTER"); + assert.match(output.metadata.providerName, /对比昨日:/); + }); + + for (const yesterday of ["WeatherKit", "UnknownProvider"]) { + test(`${name}: unknown comparison with ${yesterday} does not use ColorfulCloudsUS`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, previousDayComparison: "UNKNOWN" }, replace: [], yesterday }); + assertAppleIndex(output); + assert.equal(output.previousDayComparison, "UNKNOWN"); + assert.equal(output.metadata.providerName, "Apple"); + assert.deepEqual(calls, { current: 0, yesterday: 0 }); + }); + } + + test(`${name}: unavailable supplement cannot erase existing index or add source attribution`, async t => { + const { output, calls } = await run(t, handler, { source: { ...apple, pollutants: [] }, replace: [], candidate: { metadata: { providerName: "QWeather", temporarilyUnavailable: true }, pollutants: [] } }); + assertAppleIndex(output); + assert.deepEqual(output.pollutants, []); + assert.equal(output.metadata.providerName, "Apple"); + assert.deepEqual(calls, { current: 1, yesterday: 0 }); + }); +} diff --git a/tests/wk3/aq-settings-semantics.test.mjs b/tests/wk3/aq-settings-semantics.test.mjs new file mode 100644 index 00000000..f6b3db94 --- /dev/null +++ b/tests/wk3/aq-settings-semantics.test.mjs @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import WeatherKit2 from '../../src/wk3/class/WeatherKit2.mjs'; +import QWeather from '../../src/wk3/class/QWeather.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import { handleResponse } from '../../src/wk3/entry/response.mjs'; +import { decodeWK3DataSet, encodeAirQuality } from '../../src/wk3/class/WeatherKit3.mjs'; +import ColorfulClouds from '../../src/wk3/class/ColorfulClouds.mjs'; + +const apple = { metadata: { providerName: 'Apple', sourceType: 3 }, scale: 'HJ6332012.2604', index: 60, categoryIndex: 2, previousDayComparison: 3 }; +const provider = { metadata: { providerName: 'Synthetic', sourceType: 'MODELED' }, scale: 'EPA_NowCast.2414', index: 75, categoryIndex: 2, isSignificant: false, primaryPollutant: 'PM2_5', pollutants: [{ pollutantType: 'PM2_5', amount: 12, units: 'MICROGRAMS_PER_CUBIC_METER' }] }; +const url = 'https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality'; +function bytes(source = apple) { + const b = new Builder(); + const a = encodeAirQuality(b, source); + b.startObject(1); + b.addFieldOffset(0, a, 0); + b.finish(b.endObject()); + return b.asUint8Array(); +} +function settings(overrides = {}) { + return buildSettings(database, { AirQuality: overrides }).Settings; +} +async function patch(Settings, preFetched = {}, source = apple, sources = {}) { + const input = bytes(source); + const result = await processResponse({ url, headers: {} }, { status: 200, headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' }, body: input }, { Settings, Configs: {}, enviroments: { parameters: { country: 'CN', latitude: 31, longitude: 121 }, country: 'CN', ...sources }, preFetched }); + return { input, output: result.body, aq: decodeWK3DataSet(result.body) }; +} + +test('explicit index targets and yesterday choice survive configuration normalization', () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: '[]' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }); + assert.deepEqual(s.AirQuality.Current.Index.Replace, []); + assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider, 'ColorfulCloudsCN'); + const c = settings({ Current: { Index: { Provider: 'Calculate' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }); + assert.equal(c.AirQuality.Comparison.Yesterday.IndexProvider, 'ColorfulCloudsCN'); +}); + +test('WK3 explicit empty targets leave Apple AQ and comparison byte-exact', async () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: '[]' } } }); + const result = await patch(s, { index: Promise.resolve(provider) }); + assert.deepEqual(result.output, result.input); + assert.equal(result.aq.previousDayComparison, 3); +}); + +test('WK3 Calculate plus no pollutant source does not overwrite Apple AQ', async () => { + const s = settings({ Current: { Index: { Provider: 'Calculate', Replace: ['HJ6332012'] }, Pollutants: { Provider: 'None' } } }); + const result = await patch(s); + assert.deepEqual(result.output, result.input); +}); + +test('WK3 replacement keeps Apple comparison when current-change comparison is disabled', async () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { ReplaceWhenCurrentChange: false } }); + const result = await patch(s, { index: Promise.resolve(provider) }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 3); +}); + +test('WK3 replacement respects selected yesterday provider and same-source guard', async () => { + const s = settings({ Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }); + const result = await patch(s, { index: Promise.resolve(provider), yesterdayAirQuality: Promise.resolve({ ...provider, index: 125, categoryIndex: 3 }) }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 0); +}); + +test('entry does not prefetch for empty targets, None, or no replacement location', async () => { + const original = ColorfulClouds.prototype.CurrentAirQuality; + const oldYesterday = ColorfulClouds.prototype.prefetchYesterdayHourly; + let calls = 0; + ColorfulClouds.prototype.CurrentAirQuality = async () => { calls++; return provider; }; + ColorfulClouds.prototype.prefetchYesterdayHourly = async () => { calls++; }; + try { + for (const [country, overrides] of [ + ['CN', { Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: [] } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], + ['CN', { Current: { Index: { Provider: 'None', Replace: ['HJ6332012'] } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], + ['US', { Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], + ]) { + const requestUrl = url.replace('country=CN', `country=${country}`); + const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: overrides }) }, request: { url: requestUrl, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' } }) }); + assert.equal(output.status, 200); + assert.equal(calls, 0, `unexpected provider call for ${country}/${overrides.Current.Index.Provider}`); + } + } finally { + ColorfulClouds.prototype.CurrentAirQuality = original; + ColorfulClouds.prototype.prefetchYesterdayHourly = oldYesterday; + } +}); + +const wk2Apple = { ...apple, metadata: { providerName: 'Apple', sourceType: 'MODELED' }, previousDayComparison: 'SAME', primaryPollutant: 'PM2_5', pollutants: provider.pollutants }; +async function patchWK2(overrides, { source = wk2Apple, candidate = provider, ios27 = false } = {}) { + const calls = { current: 0, yesterday: 0 }; + const sources = { colorfulClouds: { + CurrentAirQuality: async () => { calls.current++; return candidate; }, + YesterdayAirQuality: async () => { calls.yesterday++; return { ...provider, index: 125, categoryIndex: 3 }; }, + } }; + const input = WeatherKit2.encode(undefined, { airQuality: source }); + const result = await processResponse({ url: url.replace('/v3/', '/v2/'), headers: ios27 ? { 'user-agent': 'WeatherKit_Weather_iOS_Version 27' } : {} }, { status: 200, headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK2.Weather' }, body: input }, { Settings: settings({ Current: { Pollutants: { Provider: 'WeatherKit' } }, ...overrides }), Configs: {}, enviroments: { parameters: { country: 'CN', latitude: 31, longitude: 121 }, country: 'CN', ...sources } }); + return { calls, aq: WeatherKit2.decode(new ByteBuffer(result.body), ['airQuality']).airQuality }; +} + +test('explicit nonempty index targets are not expanded with HJ', () => { + assert.deepEqual(settings({ Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: ['UBA'] } } }).AirQuality.Current.Index.Replace, ['UBA']); +}); + +for (const indexProvider of ['WeatherKit', 'UnknownProvider', 'Calculate']) { + test(`real WK3 ${indexProvider}/algorithm None never uses the index candidate or queries AQ`, async () => { + let calls = 0; + const result = await patch(settings({ Current: { Index: { Provider: indexProvider }, Pollutants: { Provider: 'ColorfulClouds' } }, Calculate: { Algorithm: 'None' } }), {}, apple, { colorfulClouds: { CurrentAirQuality: async () => { calls++; return provider; }, YesterdayAirQuality: async () => { calls++; return provider; } } }); + assert.deepEqual(result.output, result.input); + assert.equal(calls, 0); + }); + test(`real WK2 ${indexProvider}/algorithm None keeps Apple index and known comparison`, async () => { + const result = await patchWK2({ Current: { Index: { Provider: indexProvider }, Pollutants: { Provider: 'WeatherKit' } }, Calculate: { Algorithm: 'None' } }); + assert.equal(result.aq.index, wk2Apple.index); + assert.equal(result.aq.scale, wk2Apple.scale); + assert.equal(result.aq.previousDayComparison, 'SAME'); + assert.deepEqual(result.calls, { current: 0, yesterday: 0 }); + assert.equal(result.aq.metadata.providerName, 'Apple'); + }); +} + +test('WK2 unchanged index/scale/category does not query yesterday or claim an index replacement', async () => { + const result = await patchWK2({ Current: { Index: { Provider: 'ColorfulCloudsCN' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }, { candidate: { ...provider, index: 60, categoryIndex: 2, scale: wk2Apple.scale } }); + assert.equal(result.aq.previousDayComparison, 'SAME'); + assert.equal(result.calls.yesterday, 0); + assert.equal(result.aq.metadata.providerName, 'Apple'); +}); + +for (const candidate of [null, { ...provider, metadata: { providerName: 'Failed', temporarilyUnavailable: true } }, { ...provider, index: NaN }, { ...provider, categoryIndex: 0 }, { ...provider, scale: '' }]) { + test(`WK2 invalid candidate ${candidate?.index}/${candidate?.categoryIndex}/${candidate?.scale} preserves Apple and skips yesterday`, async () => { + const result = await patchWK2({ Current: { Index: { Provider: 'ColorfulCloudsUS' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: true, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }, { candidate }); + assert.equal(result.aq.index, 60); + assert.equal(result.aq.previousDayComparison, 'SAME'); + assert.equal(result.aq.metadata.providerName, 'Apple'); + assert.equal(result.calls.yesterday, 0); + }); +} + +test('WK2 real changed index recomputes known comparison only when flag is true', async () => { + for (const flag of [false, true]) { + const result = await patchWK2({ Current: { Index: { Provider: 'ColorfulCloudsUS' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: flag, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, flag ? 'BETTER' : 'SAME'); + assert.equal(result.calls.yesterday, flag ? 1 : 0); + } +}); + +test('WK2 UNKNOWN comparison can be filled from explicit source without replacing index', async () => { + const result = await patchWK2({ Current: { Index: { Provider: 'WeatherKit' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { ReplaceWhenCurrentChange: false, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }, { source: { ...wk2Apple, previousDayComparison: 'UNKNOWN' } }); + assert.equal(result.aq.index, 60); + assert.equal(result.aq.previousDayComparison, 'BETTER'); + assert.deepEqual(result.calls, { current: 1, yesterday: 1 }); +}); + +test('WK2 WeatherKit and unknown yesterday sources never fall back to ColorfulClouds', async () => { + for (const yesterday of ['WeatherKit', 'MissingProvider']) { + const result = await patchWK2({ Current: { Index: { Provider: 'WeatherKit' }, Pollutants: { Provider: 'WeatherKit' } }, Comparison: { Yesterday: { IndexProvider: yesterday } } }, { source: { ...wk2Apple, previousDayComparison: 'UNKNOWN' } }); + assert.equal(result.aq.previousDayComparison, 'UNKNOWN'); + assert.deepEqual(result.calls, { current: 0, yesterday: 0 }); + } +}); + +test('WK3 flag false can fill UNKNOWN on a real replacement but preserves valid comparison', async () => { + let yesterdayCalls = 0; + const sources = { colorfulClouds: { YesterdayAirQuality: async () => { yesterdayCalls++; return { ...provider, index: 125, categoryIndex: 3 }; } } }; + const result = await patch(settings({ Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { ReplaceWhenCurrentChange: false, Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }), { index: Promise.resolve(provider) }, { ...apple, previousDayComparison: 0 }, sources); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 4); + assert.equal(yesterdayCalls, 1); +}); + +test('handleResponse never prefetches yesterday for unchanged, failed or disabled index', async () => { + const restore = [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality, ColorfulClouds.prototype.prefetchYesterdayHourly, QWeather.GetLocationsGrid]; + let current = 0, yesterday = 0, grid = 0; + let candidate = provider; + ColorfulClouds.prototype.CurrentAirQuality = async () => { current++; return candidate; }; + ColorfulClouds.prototype.YesterdayAirQuality = async () => { yesterday++; return provider; }; + ColorfulClouds.prototype.prefetchYesterdayHourly = async () => { yesterday++; }; + QWeather.GetLocationsGrid = async () => { grid++; return {}; }; + try { + for (const indexProvider of ['WeatherKit', 'UnknownProvider', 'Calculate', 'ColorfulCloudsUS']) { + current = 0; yesterday = 0; grid = 0; + candidate = { ...provider, metadata: { providerName: 'Unavailable', temporarilyUnavailable: true } }; + const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: { Current: { Index: { Provider: indexProvider }, Pollutants: { Provider: 'ColorfulClouds' } }, Calculate: { Algorithm: 'None' }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } } }) }, request: { url, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer' } }) }); + assert.equal(decodeWK3DataSet(output.body).index, 60); + assert.deepEqual(output.body, bytes(), `${indexProvider} must preserve original bytes`); + assert.equal(current, indexProvider === 'ColorfulCloudsUS' ? 1 : 0, indexProvider); + assert.equal(yesterday, 0, indexProvider); + assert.equal(grid, 0, indexProvider); + } + } finally { + [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality, ColorfulClouds.prototype.prefetchYesterdayHourly, QWeather.GetLocationsGrid] = restore; + } +}); + +test('handleResponse positive control replaces AQ and obtains yesterday only after success', async () => { + const restore = [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality]; + let current = 0, yesterday = 0; + ColorfulClouds.prototype.CurrentAirQuality = async () => { current++; return provider; }; + ColorfulClouds.prototype.YesterdayAirQuality = async () => { yesterday++; return { ...provider, index: 125, categoryIndex: 3 }; }; + try { + const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: { Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } } }) }, request: { url, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer' } }) }); + assert.equal(decodeWK3DataSet(output.body).index, 75); + assert.equal(decodeWK3DataSet(output.body).previousDayComparison, 4); + assert.equal(current, 1); + assert.equal(yesterday, 1); + } finally { + [ColorfulClouds.prototype.CurrentAirQuality, ColorfulClouds.prototype.YesterdayAirQuality] = restore; + } +}); + +test('WK3 same-scale unchanged AQ has byte-exact output and no yesterday query', async () => { + let yesterday = 0; + const result = await patch(settings({ Current: { Index: { Provider: 'ColorfulCloudsCN' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsCN' } } }), { index: Promise.resolve({ ...provider, scale: apple.scale, index: apple.index, categoryIndex: apple.categoryIndex }) }, apple, { colorfulClouds: { YesterdayAirQuality: async () => { yesterday++; return provider; } } }); + assert.deepEqual(result.output, result.input); + assert.equal(yesterday, 0); +}); + +test('WK3 bare Calculate does not identify a comparable source/algorithm pair', async () => { + let calls = 0; + const result = await patch(settings({ Current: { Index: { Provider: 'Calculate' }, Pollutants: { Provider: 'ColorfulClouds' } }, Comparison: { Yesterday: { IndexProvider: 'Calculate' } } }), { index: Promise.resolve(provider), yesterdayAirQuality: Promise.resolve({ ...provider, index: 125, categoryIndex: 3 }) }, apple, { colorfulClouds: { YesterdayAirQuality: async () => { calls++; return provider; } } }); + assert.equal(result.aq.index, 75); + assert.equal(result.aq.previousDayComparison, 0); + assert.equal(calls, 0); +}); From 3bf6302a22b7252166c672e8467f7e97efb02521 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:36:21 +0800 Subject: [PATCH 23/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=EF=BC=89=EF=BC=9A=E6=AD=A3=E7=A1=AE=E5=9B=9E=E4=BC=A0?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=8E=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-egern.mjs | 19 ++-- scripts/build-wk3-platform.mjs | 21 ++++- src/platform/wk3-adapter.mjs | 5 +- src/wk3/utils/Console.mjs | 69 ++++++++++++--- tests/wk3-logging.test.mjs | 124 +++++++++++++++++++++++++++ tests/wk3-platform-adapter.test.mjs | 8 +- tests/wk3-platform-settings.test.mjs | 109 +++++++++++++++++++++++ 7 files changed, 325 insertions(+), 30 deletions(-) create mode 100644 tests/wk3-logging.test.mjs create mode 100644 tests/wk3-platform-settings.test.mjs diff --git a/scripts/build-egern.mjs b/scripts/build-egern.mjs index cba6400d..d6fb8fd7 100644 --- a/scripts/build-egern.mjs +++ b/scripts/build-egern.mjs @@ -7,21 +7,24 @@ import terser from "@rollup/plugin-terser"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const runtimePath = path.join(root, "src/wk3/runtime.mjs"); -const replacements = { - "console": 'const levels={OFF:0,ERROR:1,WARN:2,INFO:3,DEBUG:4,ALL:5};let settings={};try{settings=JSON.parse(ctx.env?.SETTINGS||"{}")}catch{}const level=levels[ctx.env?.LogLevel??settings.LogLevel??"INFO"]??3;const fmt=a=>a.map(x=>typeof x==="object"&&x!==null?JSON.stringify(x,null,2):x);export const Console=Object.fromEntries([["debug",4],["info",3],["warn",2],["error",1],["log",1],["exception",1]].map(([k,n])=>[k,(...a)=>{if(level>=n)console[k==="exception"?"error":k](...fmt(a))}]));', -}; function egernPlugin() { const virtual = new Map([ ["wk3:runtime", "export const webFetch = __egernRuntime.webFetch;"], [path.join(root, "src/wk3/utils/context.mjs"), "export const requestContext = undefined;"], [path.join(root, "src/wk3/utils/fetch.mjs"), "export const fetch = __egernRuntime.fetch;"], - [path.join(root, "src/wk3/utils/Console.mjs"), replacements.console], + ...["request", "response"].map(stage => [`wk3:entry:${stage}`, ` +import { handle${stage === "request" ? "Request" : "Response"} as handler } from ${JSON.stringify(path.join(root, `src/wk3/entry/${stage}.mjs`))}; +import { setLogEnv, logExecution } from ${JSON.stringify(path.join(root, "src/wk3/utils/Console.mjs"))}; +export function execute(ctx) { + setLogEnv(ctx.env); + return logExecution(handler, ctx, "${stage}"); +}`]), ]); return { name: "egern-native-runtime", resolveId(source, importer) { - if (source === "wk3:runtime") return source; + if (source === "wk3:runtime" || source.startsWith("wk3:entry:")) return source; if (!importer || !/(?:^|\/)\b(?:Console|context|fetch)\.mjs$/.test(source)) return null; const resolved = path.resolve(path.dirname(importer), source); return virtual.has(resolved) ? resolved : null; @@ -51,10 +54,10 @@ async function bundle(input, name, minify) { export async function buildEgern({ outdir, minify = true, filePrefix = "" } = {}) { const runtime = await bundle(runtimePath, "__egernSupport", minify); const results = {}; - for (const [name, handler] of [["request", "handleRequest"], ["response", "handleResponse"]]) { + for (const name of ["request", "response"]) { const input = path.join(root, `src/wk3/entry/${name}.mjs`); - const business = await bundle(input, "__egernBusiness", minify); - const code = `// Generated for Egern native ctx API. No runtime imports.\nexport default async function(ctx) {\n${runtime}\nconst __egernRuntime = __egernSupport.createRuntime(ctx);\n${business}\nreturn __egernBusiness.${handler}(ctx);\n}\n`; + const business = await bundle(`wk3:entry:${name}`, "__egernBusiness", minify); + const code = `// Generated for Egern native ctx API. No runtime imports.\nexport default async function(ctx) {\n${runtime}\nconst __egernRuntime = __egernSupport.createRuntime(ctx);\n${business}\nreturn __egernBusiness.execute(ctx);\n}\n`; const inputs = [input]; results[name] = { code, bytes: Buffer.byteLength(code), inputs }; if (outdir) { diff --git a/scripts/build-wk3-platform.mjs b/scripts/build-wk3-platform.mjs index 37785f1e..bb6a3230 100644 --- a/scripts/build-wk3-platform.mjs +++ b/scripts/build-wk3-platform.mjs @@ -7,13 +7,11 @@ import terser from "@rollup/plugin-terser"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const platforms = new Set(["surge", "loon", "stash", "quantumultx"]); -const logModule = 'const levels={OFF:0,ERROR:1,WARN:2,INFO:3,DEBUG:4,ALL:5};let settings={};try{settings=JSON.parse(globalThis.$environment?.SETTINGS||globalThis.$env?.SETTINGS||"{}")}catch{}const level=levels[globalThis.$environment?.LogLevel??globalThis.$env?.LogLevel??settings.LogLevel??"INFO"]??3;const fmt=a=>a.map(x=>typeof x==="object"&&x!==null?JSON.stringify(x,null,2):x);export const Console=Object.fromEntries([["debug",4],["info",3],["warn",2],["error",1],["log",1],["exception",1]].map(([k,n])=>[k,(...a)=>{if(level>=n)console[k==="exception"?"error":k](...fmt(a))}]));'; function runtimePlugin() { const virtual = new Map([ ["wk3:runtime", 'let runtime; export const setRuntime=value=>{runtime=value}; export const webFetch=(...args)=>runtime.webFetch(...args); export const runtimeFetch=(...args)=>runtime.fetch(...args);'], [path.join(root, "src/wk3/utils/context.mjs"), "export const requestContext=undefined;"], [path.join(root, "src/wk3/utils/fetch.mjs"), 'export { runtimeFetch as fetch } from "wk3:runtime";'], - [path.join(root, "src/wk3/utils/Console.mjs"), logModule], ]); return { name: "wk3-platform-runtime", @@ -41,7 +39,24 @@ export async function buildPlatform(platform, { outdir, minify = true } = {}) { resolveId(id) { return id === entry ? id : null; }, load(id) { if (id !== entry) return null; - return `import { setRuntime } from "wk3:runtime";\nimport { runPlatform } from ${JSON.stringify(path.join(root, "src/platform/wk3-adapter.mjs"))};\nimport { ${handlerName} } from ${JSON.stringify(path.join(root, `src/wk3/entry/${name}.mjs`))};\nimport { createRuntime } from ${JSON.stringify(path.join(root, "src/wk3/runtime.mjs"))};\nconst __bytes=v=>typeof v===\"string\"?Uint8Array.from(v,c=>c.charCodeAt(0)&255).buffer:v instanceof ArrayBuffer?v:ArrayBuffer.isView(v)?v.buffer.slice(v.byteOffset,v.byteOffset+v.byteLength):new Uint8Array(v).buffer;const __wk3Runtime=createRuntime({env:globalThis.$environment??globalThis.$env??{},http:globalThis.$httpClient?{get:(u,o)=>new Promise((r,j)=>$httpClient.get({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))),post:(u,o)=>new Promise((r,j)=>$httpClient.post({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)})))}:{get:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>__bytes(x.body)}},post:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>new TextEncoder().encode(x.body).buffer}}}});\nsetRuntime(__wk3Runtime);runPlatform(async ctx=>${handlerName}(ctx),"${name}",globalThis,"${platform === "quantumultx" ? "Quantumult X" : platform}");`; + return `import { setRuntime } from "wk3:runtime"; +import { runPlatform } from ${JSON.stringify(path.join(root, "src/platform/wk3-adapter.mjs"))}; +import { ${handlerName} } from ${JSON.stringify(path.join(root, `src/wk3/entry/${name}.mjs`))}; +import { createRuntime } from ${JSON.stringify(path.join(root, "src/wk3/runtime.mjs"))}; +import { setLogEnv, logExecution } from ${JSON.stringify(path.join(root, "src/wk3/utils/Console.mjs"))}; +const __bytes=v=>typeof v==="string"?Uint8Array.from(v,c=>c.charCodeAt(0)&255).buffer:v instanceof ArrayBuffer?v:ArrayBuffer.isView(v)?v.buffer.slice(v.byteOffset,v.byteOffset+v.byteLength):new Uint8Array(v).buffer; +runPlatform(async ctx=>{ + setLogEnv(ctx.env); + const runtime=createRuntime({env:ctx.env,http:globalThis.$httpClient?{ + get:(u,o)=>new Promise((r,j)=>$httpClient.get({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))), + post:(u,o)=>new Promise((r,j)=>$httpClient.post({url:u,...o},(e,x,b)=>e?j(e):r({status:x.status,headers:x.headers,text:async()=>b,arrayBuffer:async()=>__bytes(b)}))) + }:{ + get:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>__bytes(x.body)}}, + post:async(u,o)=>{const x=await $task.fetch({url:u,...o});return {status:x.statusCode,headers:x.headers,text:async()=>x.body,arrayBuffer:async()=>new TextEncoder().encode(x.body).buffer}} + }}); + setRuntime(runtime); + return logExecution(${handlerName}, ctx, "${name}").catch(() => undefined); +},"${name}",globalThis,"${platform === "quantumultx" ? "Quantumult X" : platform}");`; }, }; const plugins = [plugin, runtimePlugin(), nodeResolve({ browser: true, preferBuiltins: false })]; diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs index 1605f5e7..86ca1d58 100644 --- a/src/platform/wk3-adapter.mjs +++ b/src/platform/wk3-adapter.mjs @@ -42,7 +42,7 @@ export async function runPlatform(handler, stage, globals = globalThis, platform if (match) { const key = decodeURIComponent(match[1]).trim(); const raw = match[2] ?? match[3] ?? match[4] ?? ""; - argumentValues[key] = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1).split(",").map(v => v.trim().replace(/^['\"]|['\"]$/g, "")) : raw === "true" ? true : raw === "false" ? false : decodeURIComponent(raw); + argumentValues[key] = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1).split(",").map(v => v.trim().replace(/^['\"]|['\"]$/g, "")).filter(Boolean) : raw === "true" ? true : raw === "false" ? false : decodeURIComponent(raw); } } } else { @@ -111,5 +111,6 @@ export async function runPlatform(handler, stage, globals = globalThis, platform delete response.statusText; return done(response); } - return done({ response }); + // Request scripts synthesize a response; response scripts return modifications directly. + return done(stage === "request" ? { response } : response); } diff --git a/src/wk3/utils/Console.mjs b/src/wk3/utils/Console.mjs index 7caefd77..b9d3c8b0 100644 --- a/src/wk3/utils/Console.mjs +++ b/src/wk3/utils/Console.mjs @@ -1,17 +1,60 @@ const LEVELS = { OFF: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4, ALL: 5 }; +let level = LEVELS.INFO; -// 日志级别在部署时固定,不支持运行时修改(避免跨请求污染) -// 如需调整,修改此处或在 database.mjs 中同步调整 LogLevel 默认值 -const LEVEL = LEVELS.INFO; +export function setLogEnv(env = {}) { + let settings = {}; + try { settings = typeof env.SETTINGS === "string" ? JSON.parse(env.SETTINGS) : env.SETTINGS; } catch {} + const name = String(env.LogLevel ?? settings?.LogLevel ?? "INFO").toUpperCase(); + level = Object.hasOwn(LEVELS, name) ? LEVELS[name] : LEVELS.INFO; +} -const fmt = msg => - msg.map(m => (typeof m === "object" && m !== null ? JSON.stringify(m, null, 2) : m)); +const sensitive = key => /token|api[_-]?key|authorization|password/i.test(key) || /^key$/i.test(key); +const redactText = text => text + .replace(/("[^"]*(?:token|api[_-]?key|authorization|password)[^"]*"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\]\s]+)/gi, '$1"[REDACTED]"') + .replace(/([?&](?:token|key|api[_-]?key|authorization|password)=)[^&#\s"']*/gi, "$1[REDACTED]"); -export const Console = { - debug: (...msg) => { if (LEVEL >= LEVELS.DEBUG) console.debug(...fmt(msg)); }, - info: (...msg) => { if (LEVEL >= LEVELS.INFO) console.info(...fmt(msg)); }, - warn: (...msg) => { if (LEVEL >= LEVELS.WARN) console.warn(...fmt(msg)); }, - error: (...msg) => { if (LEVEL >= LEVELS.ERROR) console.error(...fmt(msg)); }, - log: (...msg) => { if (LEVEL > LEVELS.OFF) console.log(...fmt(msg)); }, - exception: (...msg) => { if (LEVEL >= LEVELS.ERROR) console.error(...fmt(msg)); }, -}; +function redact(value, seen = new WeakSet()) { + if (typeof value === "string") { + if (/^\s*[\[{]/.test(value)) { + try { return JSON.stringify(redact(JSON.parse(value), seen), null, 2); } catch {} + } + return redactText(value); + } + if (!value || typeof value !== "object") return value; + if (value instanceof Error || Object.prototype.toString.call(value) === "[object Error]") return redactText(value.stack || value.message); + if (seen.has(value)) return "[Circular]"; + seen.add(value); + const result = Array.isArray(value) ? value.map(item => redact(item, seen)) + : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive(key) ? "[REDACTED]" : redact(item, seen)])); + seen.delete(value); + return result; +} + +function format(messages) { + // Settings includes private provider configuration; keep it out of process logs. + if (messages[0] === "[buildSettings] Settings:") return [messages[0], "[REDACTED]"]; + return messages.map(message => { + const value = redact(message); + return value && typeof value === "object" ? JSON.stringify(value, null, 2) : value; + }); +} + +export const Console = Object.fromEntries( + [["debug", 4], ["info", 3], ["warn", 2], ["error", 1], ["log", 1], ["exception", 1]].map(([method, threshold]) => [method, (...messages) => { + if (level < threshold) return; + const output = console[method === "exception" ? "error" : method] ?? console.log; + if (typeof output === "function") output.apply(console, format(messages)); + }]), +); + +export async function logExecution(handler, ctx, stage) { + Console.info(`☑️ ${stage}`); + try { + return await handler(ctx); + } catch (error) { + Console.error(`${stage}:`, error); + throw error; + } finally { + Console.info(`✅ ${stage}`); + } +} diff --git a/tests/wk3-logging.test.mjs b/tests/wk3-logging.test.mjs new file mode 100644 index 00000000..340ed713 --- /dev/null +++ b/tests/wk3-logging.test.mjs @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import { buildEgern } from "../scripts/build-egern.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; + +const source = await readFile(new URL("../src/wk3/utils/Console.mjs", import.meta.url), "utf8"); +function capture(logOnly = false) { + const logs = []; + const console = Object.fromEntries((logOnly ? ["log"] : ["debug", "info", "warn", "error", "log"]).map(method => [method, (...args) => logs.push([method, ...args])])); + return { logs, console }; +} +function logger(logOnly = false) { + const output = capture(logOnly); + const context = vm.createContext({ console: output.console }); + vm.runInContext(`${source.replace(/export /g, "")}\nglobalThis.logger={Console,setLogEnv};`, context); + return { ...output, ...context.logger }; +} +const secret = "private-token-unique"; +const payload = { Token: secret, nested: [{ apiKey: secret, Authorization: `Bearer ${secret}`, password: secret }], url: `https://example.invalid/?token=${secret}&key=${secret}&keep=yes`, summary: "provider summary intact" }; + +test("shared levels, defaults, SETTINGS precedence and console.log fallback", () => { + const { Console, setLogEnv, logs } = logger(true); + const methods = ["debug", "info", "warn", "error", "log", "exception"]; + for (const [LogLevel, count] of [["OFF", 0], ["ERROR", 3], ["WARN", 4], ["INFO", 5], ["DEBUG", 6], ["ALL", 6], ["invalid", 5]]) { + logs.length = 0; + setLogEnv({ LogLevel }); + for (const method of methods) Console[method](method); + assert.equal(logs.length, count, LogLevel); + assert.ok(logs.every(([method]) => method === "log")); + } + logs.length = 0; + setLogEnv({ SETTINGS: '{"LogLevel":"OFF"}' }); Console.info("hidden"); + setLogEnv({ LogLevel: "INFO", SETTINGS: '{"LogLevel":"OFF"}' }); Console.info("visible"); + setLogEnv({ SETTINGS: "invalid json" }); Console.info("default"); + setLogEnv(); Console.info("default again"); + assert.equal(logs.length, 3); +}); + +test("formatter redacts objects, JSON strings, prefixed JSON, nested JSON and query keys without mutating inputs", () => { + const { Console, logs } = logger(); + const before = JSON.stringify(payload); + Console.info(payload, before, `body: ${before}`, { body: before }); + Console.info("[buildSettings] Settings:", JSON.stringify({ privateHost: "private-host-unique", ...payload })); + const output = JSON.stringify(logs); + assert.ok(!output.includes(secret), output); + assert.ok(!output.includes("private-host-unique"), output); + assert.match(output, /provider summary intact/); + assert.match(output, /keep=yes/); + assert.equal(JSON.stringify(payload), before); +}); + +const bundles = new Map(); +async function files(platform) { + if (!bundles.has(platform)) bundles.set(platform, platform === "egern" ? buildEgern({ minify: false }) : buildPlatform(platform, { minify: false })); + return bundles.get(platform); +} +async function execute(platform, code, stage, { env = {}, body = "{}", logOnly = false, invalid = false } = {}) { + const output = capture(logOnly); + const url = invalid ? "invalid URL" : "https://weatherkit.apple.com/api/v2/airQualityScale/en/HJ6332012?token=private-token-unique&key=private-token-unique"; + const globals = { URL, Headers, Response, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, console: output.console }; + if (platform === "egern") { + const context = vm.createContext(globals); + const handler = vm.runInContext(code.replace("export default async function(ctx)", "(async function(ctx)") + ")", context); + const ctx = { env, request: { url, headers: {} }, response: new Response(body, { headers: { "content-type": "application/json" } }), http: { async get() { return new Response(body, { headers: { "content-type": "application/json" } }); } }, respond: value => value }; + let result, error; + try { result = await handler(ctx); } catch (caught) { error = caught; } + return { ...output, result, error, handler, ctx }; + } + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + Object.assign(globals, { + $environment: { LogLevel: "ALL" }, // Parsed script arguments must override host env. + $argument: { LogLevel: "INFO", ...env }, + $request: { url, headers: {} }, + $response: { status: 200, headers: { "content-type": "application/json" }, body }, + $httpClient: platform === "quantumultx" ? undefined : { get(options, callback) { callback(null, { status: 200, headers: { "content-type": "application/json" } }, body); } }, + $task: { async fetch() { return { statusCode: 200, headers: { "content-type": "application/json" }, body }; } }, + $done(value) { finish(value); }, + }); + vm.runInNewContext(code, globals, { timeout: 30000 }); + const result = await Promise.race([completed, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("bundle did not finish")), 5000); timer.unref(); })]); + return { ...output, result }; +} + +for (const platform of ["egern", "surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: generated request/response INFO progress, OFF silence, redaction and error fallback`, async () => { + const bundle = await files(platform); + for (const stage of ["request", "response"]) { + const code = bundle[stage].code; + const visible = await execute(platform, code, stage, { body: JSON.stringify(payload), env: { SETTINGS: JSON.stringify({ API: { QWeather: { Token: secret, Host: "private-host-unique" } } }) } }); + const text = JSON.stringify(visible.logs); + assert.equal(visible.logs[0][1], `☑️ ${stage}`); + assert.equal(visible.logs.at(-1)[1], `✅ ${stage}`); + assert.ok(!text.includes(secret), text); + assert.ok(!text.includes("private-host-unique"), text); + assert.ok(!visible.logs.some(([method]) => method === "debug")); + if (stage === "response") { + assert.match(text, /provider summary intact/); + assert.match(text, /parseWeatherKitURL/); + assert.match(text, /airQualityScale Apple/); + } + const off = await execute(platform, code, stage, { env: { LogLevel: "OFF" }, invalid: true, logOnly: true }); + assert.deepEqual(off.logs, []); + const failed = await execute(platform, code, stage, { env: { LogLevel: "ERROR" }, invalid: true, logOnly: true }); + assert.equal(failed.logs.length, 1); + assert.equal(failed.logs[0][0], "log"); + assert.match(JSON.stringify(failed.logs), /Invalid URL/); + } + }); +} + +test("Egern overlapping invocations keep their own ctx log level", async () => { + const bundle = await files("egern"); + const first = await execute("egern", bundle.request.code, "request"); + first.logs.length = 0; + let release; + const waiting = first.handler({ ...first.ctx, http: { get: () => new Promise(resolve => { release = resolve; }) } }); + await first.handler({ ...first.ctx, env: { LogLevel: "OFF" } }); + release(new Response("{}")); + await waiting; + assert.deepEqual(first.logs.map(row => row[1]), ["☑️ request", "✅ request"]); +}); diff --git a/tests/wk3-platform-adapter.test.mjs b/tests/wk3-platform-adapter.test.mjs index e18fdc74..d6e75249 100644 --- a/tests/wk3-platform-adapter.test.mjs +++ b/tests/wk3-platform-adapter.test.mjs @@ -36,8 +36,8 @@ for (const platform of platforms) { assert.equal(returned, undefined, "Quantumult X raw binary response is passed through"); } else { assert.ok(observed); - const result = returned.response; - assert.equal(result.status, platform === "quantumultx" ? "HTTP/1.1 201 OK" : 201); + const result = returned; + assert.equal(result.status, 201); assert.equal(result.headers["X-Patched"], "yes"); assert.ok(!Object.keys(result.headers).some(k => ["content-length", "content-encoding", "transfer-encoding"].includes(k.toLowerCase()))); if (platform === "quantumultx") assert.equal(result.body, "{\"ok\":true}"); @@ -62,7 +62,7 @@ test("binary bodyBytes takes precedence over an empty text body", async () => { assert.deepEqual(Array.from(new Uint8Array(await ctx.response.arrayBuffer())), bytes); return { status: 200, headers: {}, body: new Uint8Array(bytes) }; }, "response", globals, "surge"); - assert.deepEqual(Array.from(returned.response.body, c => c.charCodeAt(0)), bytes); + assert.deepEqual(Array.from(returned.body, c => c.charCodeAt(0)), bytes); }); test("all verified platforms produce executable request and response bundles", async () => { @@ -108,7 +108,7 @@ test("Surge bundle patches WK3 AQ using platform HTTP callbacks", async () => { await new Promise(resolve => setTimeout(resolve, 300)); assert.equal(calls.length, 1); assert.ok(requests.some(url => url.includes("/realtime")), `No provider request: ${JSON.stringify(requests)}`); - const result = calls[0].response; + const result = calls[0]; assert.equal(decodeWK3DataSet(Uint8Array.from(result.body, char => char.charCodeAt(0))).index, 87); }); diff --git a/tests/wk3-platform-settings.test.mjs b/tests/wk3-platform-settings.test.mjs new file mode 100644 index 00000000..fa5ce066 --- /dev/null +++ b/tests/wk3-platform-settings.test.mjs @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { runPlatform } from "../src/platform/wk3-adapter.mjs"; +import { localParameters } from "../src/wk3/parameters.mjs"; + +const bundles = new Map(); +async function bundle(platform) { + if (!bundles.has(platform)) bundles.set(platform, buildPlatform(platform, { minify: false })); + return bundles.get(platform); +} +async function execute(code, values) { + const logs = [], requests = []; + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, + console: Object.fromEntries(["debug", "info", "warn", "error", "log"].map(key => [key, (...args) => logs.push([key, ...args])])), + $environment: { HTTP_TIMEOUT_MS: "9999", LogLevel: "ALL" }, + $request: { url: "https://weatherkit.apple.com/api/v2/airQualityScale/en/HJ6332012", headers: {} }, + $response: { status: 200, headers: { "content-type": "application/json" }, body: "{}" }, + $httpClient: { get(options, callback) { + requests.push(options); + callback(null, { status: 200, headers: { "content-type": "application/json" } }, "{}"); + } }, + $task: { async fetch(options) { + requests.push(options); + return { statusCode: 200, headers: { "content-type": "application/json" }, body: "{}" }; + } }, + $done(value) { finish(value); }, + ...values, + }; + vm.runInNewContext(code, globals, { timeout: 30000 }); + const result = await completed; + return { result, logs, requests, globals }; +} + +for (const platform of ["surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: generated bundle consumes argument timeout and OFF`, async () => { + const files = await bundle(platform); + const values = localParameters.map(parameter => parameter.key === "HTTP_TIMEOUT_MS" ? "1234" : parameter.key === "LogLevel" ? "OFF" : parameter.value); + const argument = platform === "loon" ? JSON.stringify(values) + : platform === "stash" ? { HTTP_TIMEOUT_MS: "1234", LogLevel: "OFF" } + : platform === "quantumultx" ? '{"HTTP_TIMEOUT_MS":"1234","LogLevel":"OFF"}' + : 'HTTP_TIMEOUT_MS="1234"&LogLevel="OFF"'; + const options = { $argument: argument, ...(platform === "quantumultx" ? { $httpClient: undefined } : {}) }; + const request = await execute(files.request.code, options); + assert.equal(request.requests.length, 1); + const response = await execute(files.response.code, { + ...options, + $request: { url: "https://weatherkit.apple.com/api/v2/availability/en", headers: {} }, + }); + assert.equal(response.logs.length, 0, JSON.stringify(response.logs)); + const visible = await execute(files.response.code, { + ...options, $argument: { LogLevel: "INFO" }, + $request: { url: "https://weatherkit.apple.com/api/v2/availability/en", headers: {} }, + }); + assert.ok(visible.logs.length > 0, "INFO control must exercise the logger"); + assert.equal(request.requests[0].timeout, 1234); + assert.equal(request.globals.fetch, undefined, "native fetch must not be patched"); + assert.equal(request.globals.$environment.HTTP_TIMEOUT_MS, "9999"); + }); +} + +for (const platform of ["surge", "loon", "stash"]) { + test(`${platform}: generated binary response keeps bodyBytes over empty body`, async () => { + const files = await bundle(platform); + const bytes = new Uint8Array([0, 1, 127, 128, 255]); + const result = await execute(files.response.code, { + $argument: { DataSets: [], LogLevel: "OFF" }, + $request: { url: "https://weatherkit.apple.com/api/v3/weather/en/31/121?country=CN&dataSets=airQuality", headers: {} }, + $response: { status: 200, headers: { "content-type": "application/vnd.apple.flatbuffer" }, body: "", bodyBytes: bytes }, + }); + assert.deepEqual(Array.from(result.result.body, c => typeof c === "number" ? c : c.charCodeAt(0)), Array.from(bytes)); + assert.equal(result.logs.length, 0); + assert.equal(result.requests.length, 0); + }); +} + +for (const platform of ["surge", "loon", "stash"]) { + for (const stage of ["request", "response"]) { + test(`${platform}: ${stage} $done has documented shape and preserves bytes`, async () => { + let returned; + const bytes = new Uint8Array([0, 1, 127, 128, 255]); + await runPlatform(async ctx => { + if (stage === "response") assert.deepEqual(new Uint8Array(await ctx.response.arrayBuffer()), bytes); + return { status: 201, headers: { "Content-Type": "application/octet-stream" }, body: bytes }; + }, stage, { + $request: { url: "https://example.invalid", headers: {} }, + $response: { status: 200, headers: { "Content-Type": "application/octet-stream" }, body: "", bodyBytes: bytes }, + $done(value) { returned = value; }, + }, platform); + const result = stage === "request" ? returned.response : returned; + assert.equal(result.status, 201); + assert.deepEqual(Array.from(result.body, c => typeof c === "number" ? c : c.charCodeAt(0)), Array.from(bytes)); + }); + } +} + +test("Quantumult X binary response remains untouched", async () => { + let called = false, returned = "pending"; + await runPlatform(async () => { called = true; }, "response", { + $response: { headers: { "content-type": "application/octet-stream" }, bodyBytes: new Uint8Array([128, 255]) }, + $done(value) { returned = value; }, + }, "Quantumult X"); + assert.equal(called, false); + assert.equal(returned, undefined); +}); From c56227d5f4211ebe3310c27937535f0a25da5703 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:36:21 +0800 Subject: [PATCH 24/42] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=88=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=89=EF=BC=9A=E5=AF=B9=E9=BD=90=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E8=AF=B4=E6=98=8E=E4=B8=8E=E6=9C=89=E6=95=88?= =?UTF-8?q?=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/iRingo.WeatherKit.WK3.yaml | 4 +- scripts/build-egern-module.mjs | 6 +- scripts/build-wk3-modules.mjs | 16 ++-- src/wk3/parameters.mjs | 54 +++++++----- template/wk3.stash.handlebars | 2 + tests/wk3-module-parameters.test.mjs | 122 +++++++++++++++++++++++++++ tests/wk3-modules.test.mjs | 6 +- 7 files changed, 173 insertions(+), 37 deletions(-) create mode 100644 tests/wk3-module-parameters.test.mjs diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml index 1c59b054..cd646e7d 100644 --- a/modules/iRingo.WeatherKit.WK3.yaml +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -87,8 +87,8 @@ rules: match: tether.edge.apple policy: REJECT-DROP compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_US","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} -compat_arguments_desc: "DataSets: [数据集] 解析与处理范围\n 逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。\nWeather.Provider: [天气] 数据源\n WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。\nNextHour.Provider: [分钟降水] 数据源\n WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换范围(原始标准)\n 逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制国标主要污染物\n 沿用原项目:国标指数≤50时仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。\nAirQuality.Current.Pollutants.Units.Replace: [污染物单位] 替换原标准\n 默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。\nAirQuality.Current.Pollutants.Units.Mode: [污染物单位] 转换模式\n Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [昨日对比] 今日变化时重算\n WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。\nAirQuality.Calculate.Algorithm: [内置算法] 计算公式\n 上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。\nAirQuality.Calculate.AllowOverRange: [内置算法] 允许指数超标\n 沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。\nAPI.ColorfulClouds.Token: [API] 彩云令牌\n 留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。\nAPI.QWeather.Host: [API] 和风主机\n 使用和风 API 数据源时填写控制台主机名。\nAPI.QWeather.Token: [API] 和风令牌\n 使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO,与当前脚本一致;只控制本机日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" -env_schema: {"DataSets":{"name":"[数据集] 解析与处理范围","description":"逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。","default_value":"QWeatherWeb","options":["QWeatherWeb","WeatherKit"]},"NextHour.Provider":{"name":"[分钟降水] 数据源","description":"WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换范围(原始标准)","description":"逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制国标主要污染物","description":"沿用原项目:国标指数≤50时仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[污染物单位] 替换原标准","description":"默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[污染物单位] 转换模式","description":"Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[昨日对比] 今日变化时重算","description":"WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。","default_value":"ColorfulCloudsUS","options":["ColorfulCloudsUS","ColorfulCloudsCN","QWeather","Calculate","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[内置算法] 计算公式","description":"上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。","default_value":"WAQI_InstantCast_US","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[内置算法] 允许指数超标","description":"沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云令牌","description":"留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风主机","description":"使用和风 API 数据源时填写控制台主机名。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风令牌","description":"使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO,与当前脚本一致;只控制本机日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} +compat_arguments_desc: "DataSets: [数据集]\n 选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。\nWeather.Provider: [天气] 数据源\n 使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。\nNextHour.Provider: [未来一小时降水强度] 数据源\n 使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标\n 替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物\n 忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标\n 转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式\n 污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换\n 即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法\n 共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标\n 允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌\n 彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。\nAPI.QWeather.Host: [API] 和风天气主机\n 和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。\nAPI.QWeather.Token: [API] 和风天气令牌\n 和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" +env_schema: {"DataSets":{"name":"[数据集]","description":"选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。","default_value":"QWeatherWeb","options":["WeatherKit","QWeatherWeb"]},"NextHour.Provider":{"name":"[未来一小时降水强度] 数据源","description":"使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换目标","description":"替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制主要污染物","description":"忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[今日污染物 - 单位转换] 替换目标","description":"转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[今日污染物 - 单位转换] 模式","description":"污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[空气质量 - 对比昨日] 变化时替换","description":"即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[iRingo内置算法] 算法","description":"共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。","default_value":"WAQI_InstantCast_US","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[iRingo内置算法] 允许指数超标","description":"允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云天气令牌","description":"彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风天气主机","description":"和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风天气令牌","description":"和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} scriptings: - http_response: name: WeatherKit.api.availability.response diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs index 37b68bde..b4f33766 100644 --- a/scripts/build-egern-module.mjs +++ b/scripts/build-egern-module.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {localParameters} from '../src/wk3/parameters.mjs'; +import {localParameters,parameterDescription} from '../src/wk3/parameters.mjs'; import {groups} from '../src/wk3/routes.mjs'; import {release} from './build-wk3-modules.mjs'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); @@ -14,8 +14,8 @@ export function renderLocalModule({version} = {}){ const template=fs.readFileSync(path.join(root,'template/egern.wk3.handlebars'),'utf8'); const q=JSON.stringify; const compat=Object.fromEntries(localParameters.map(p=>[p.key,p.value])); - const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:p.description,default_value:p.value,...(p.boolean?{options:['true','false']}:p.options?{options:p.options}:{})}])); - const description=localParameters.map(p=>`${p.key}: ${p.name}\n ${p.description}`).join('\n'); + const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:parameterDescription(p),default_value:p.value,...(p.boolean?{options:['true','false']}:p.options?{options:p.options}:{})}])); + const description=localParameters.map(p=>`${p.key}: ${p.name}\n ${parameterDescription(p)}`).join('\n'); // JSON objects are valid YAML. Quote individual substitution values using // YAML single quotes: literal double quotes in array syntax remain safe. let env=' env:\n';for(const p of localParameters)env+=` ${p.key}: '{{{${p.key}}}}'\n`; diff --git a/scripts/build-wk3-modules.mjs b/scripts/build-wk3-modules.mjs index 921b6119..b966c848 100644 --- a/scripts/build-wk3-modules.mjs +++ b/scripts/build-wk3-modules.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { localParameters } from '../src/wk3/parameters.mjs'; +import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; import { groups } from '../src/wk3/routes.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -20,10 +20,12 @@ const expand = (text, replacements) => { return text; }; const defaults = parameters.map(({ key, value }) => `${key}:${JSON.stringify(value)}`).join(','); -const description = parameters.map(({ key, name, description: desc }) => `${key}: ${name};${desc}`.replaceAll('\n', ' ')).join('\\n'); -const loonArguments = parameters.map(({ key, value, boolean, options, name, description: desc }) => { +const descriptions = parameters.map(parameter => `${parameter.key}: ${parameter.name};${parameterDescription(parameter)}`.replaceAll('\n', ' ')); +const description = descriptions.join('\\n'); +const loonArguments = parameters.map(parameter => { + const { key, value, boolean, options, name } = parameter; const kind = boolean ? `switch,${value}` : options ? `select,${[value, ...options.filter(v => v !== value)].map(v => JSON.stringify(v)).join(',')}` : `input,${JSON.stringify(value)}`; - return `${key} = ${kind},tag=${name},desc=${desc.replaceAll(/[\r\n,]/g, ',')}`; + return `${key} = ${kind},tag=${name},desc=${parameterDescription(parameter).replaceAll(/[\r\n,]/g, ',')}`; }).join('\n'); const quoteYaml = text => `'${text.replaceAll("'", "''")}'`; const stashArgument = quoteYaml(JSON.stringify(Object.fromEntries(parameters.map(({ key, value }) => [key, value])))); @@ -32,16 +34,16 @@ export function renderWK3Module(platform, version) { if (!Object.hasOwn(platforms, platform)) throw new Error(`Unsupported platform: ${platform}`); if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error('Specify an explicit release version, e.g. 3.3.2'); const template = fs.readFileSync(path.join(root, `template/wk3.${platform}.handlebars`), 'utf8'); - const rows = groups.map(group => { + const rows = groups.map((group, index) => { const tag = label(group), target = url(group, version, platform); if (platform === 'surge') return `${tag} = type=http-${group.stage}, pattern=${group.pattern.includes('(?:') ? `"${group.pattern}"` : group.pattern}${group.body ? ', requires-body=1' : ''}${group.binary ? ', binary-body-mode=1' : ''}, engine=webview, script-path=${target}, argument=${surgeArgument}`; if (platform === 'loon') return `http-${group.stage} ${group.pattern}${group.body ? ' requires-body=1,' : ''}${group.binary ? ' binary-body-mode=1,' : ''} script-path=${target}, tag=${tag}, argument=${loonArgument}`; - if (platform === 'stash') return ` - match: ${quoteYaml(group.pattern)}\n name: ${tag}\n type: ${group.stage}${group.body ? '\n require-body: true' : ''}${group.binary ? '\n binary-mode: true' : ''}\n argument: ${stashArgument}`; + if (platform === 'stash') return ` - match: ${quoteYaml(group.pattern)}\n name: ${tag}\n type: ${group.stage}${group.body ? '\n require-body: true' : ''}${group.binary ? '\n binary-mode: true' : ''}\n argument: ${index === 0 ? `&wk3-arguments ${stashArgument}` : '*wk3-arguments'}`; const action = group.echo ? 'script-echo-response' : group.stage === 'request' ? 'script-request-header' : 'script-response-body'; return `# ${tag}${group.binary ? ' (binary response requires device validation)' : ''}\n${group.pattern} url ${action} ${target}`; }).join('\n'); const providers = groups.map(group => ` ${label(group)}:\n url: ${url(group, version, platform)}\n interval: 86400`).join('\n'); - return expand(template, { VERSION: version, SCRIPTS: rows, PROVIDERS: providers, ARGUMENTS: platform === 'loon' ? loonArguments : defaults, ARGUMENT_DESC: description }); + return expand(template, { VERSION: version, SCRIPTS: rows, PROVIDERS: providers, ARGUMENTS: platform === 'loon' ? loonArguments : defaults, ARGUMENT_DESC: description, ARGUMENT_COMMENTS: descriptions.map(line => `# ${line}`).join('\n') }); } export function buildWK3Modules({ outdir, version } = {}) { diff --git a/src/wk3/parameters.mjs b/src/wk3/parameters.mjs index 32e2ae31..ddf22f6e 100644 --- a/src/wk3/parameters.mjs +++ b/src/wk3/parameters.mjs @@ -1,27 +1,35 @@ // Parameter keys follow the fork's settings and upstream argument templates. // Defaults reproduce the existing local module; controls do not expand WK3 support. +// `choices` documents list inputs; only scalar `options` render as single selects. export const localParameters = [ - { key: "DataSets", name: "[数据集] 解析与处理范围", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", description: "逗号分隔;填 [] 全部透传。WK3 仅处理空气质量、分钟降水与预警;当前/逐日/逐小时天气仍透传。" }, - { key: "Weather.Replace", name: "[天气] 替换范围", value: "CN", description: "逗号分隔国家码;保留现有大陆范围与香港缺失 AQ 专用补入逻辑,不支持正则。" }, - { key: "Weather.Provider", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WK2 可选择天气数据源;WK3 当前/逐日/逐小时天气仍透传。" }, - { key: "Weather.ReplaceDaily", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐日预报保持苹果数据。" }, - { key: "Weather.ReplaceHourly", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐小时预报保持苹果数据。" }, - { key: "WeatherAlerts.Provider", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["QWeatherWeb", "WeatherKit"], description: "QWeatherWeb:和风公开页面补全,苹果原生展示;WeatherKit:预警原样透传。原项目 API 预警源尚未移植,不提供无效选择。" }, - { key: "NextHour.Provider", name: "[分钟降水] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], description: "WeatherKit 不替换;彩云/和风按已验证 WK3 雨况处理。" }, - { key: "AirQuality.Current.Index.Provider", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate"], description: "默认彩云美标。WK3 已验证国标→美标和同标准指数替换;其他跨标准保留苹果数据。" }, - { key: "AirQuality.Current.Index.Replace", name: "[今日空气指数] 替换范围(原始标准)", value: "HJ6332012", description: "逗号分隔原始标准;不是输出标准。国内转美标仍填 HJ6332012。现有非国标算法会确保包含国内原标准。" }, - { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", name: "[今日空气指数] 强制国标主要污染物", value: "true", boolean: true, description: "沿用原项目:国标指数≤50时仍显示主要污染物。" }, - { key: "AirQuality.Current.Pollutants.Provider", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], description: "用于 WK2/内置计算;WK3 美标直接替换时污染物跟随指数来源。" }, - { key: "AirQuality.Current.Pollutants.Units.Replace", name: "[污染物单位] 替换原标准", value: "[]", description: "默认 [] 不额外转换;可填 HJ6332012、EPA_NowCast、EU.EAQI、UBA,逗号分隔。WK3 完整美标 AQ 使用同源单位,此独立设置主要用于 WK2。" }, - { key: "AirQuality.Current.Pollutants.Units.Mode", name: "[污染物单位] 转换模式", value: "Scale", options: ["Scale", "ugm3", "EU_ppb", "US_ppb", "Force_ugm3", "Force_EU_ppb", "Force_US_ppb"], description: "Scale 按标准;其余为原项目单位模式,适用范围同单位替换项。" }, - { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", name: "[昨日对比] 今日变化时重算", value: "true", boolean: true, description: "WK2 沿用原设置;WK3 当前美标数据替换后始终仅做同源同标准比较,失败为未知。" }, - { key: "AirQuality.Comparison.Yesterday.IndexProvider", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "Calculate", "WeatherKit"], description: "默认彩云美标。WK3 不跨来源/算法比较;不兼容组合返回未知;WeatherKit 不注入。" }, - { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", name: "[昨日污染物] 数据源", value: "ColorfulCloudsCN", options: ["ColorfulCloudsCN", "QWeather"], description: "原项目内置昨日算法所用来源;WK3 裸 Calculate 昨日比较尚未接通。" }, - { key: "AirQuality.Calculate.Algorithm", name: "[内置算法] 计算公式", value: "WAQI_InstantCast_US", options: ["None", "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"], description: "上游十二种算法;仅指数来源选 Calculate 时计算。WK3 未验证的目标标准保持苹果原值。" }, - { key: "AirQuality.Calculate.AllowOverRange", name: "[内置算法] 允许指数超标", value: "true", boolean: true, description: "沿用上游配置。WK3 当前美标编码校验指数0–500;本项不会绕过 WK3 物理范围校验。" }, - { key: "API.ColorfulClouds.Token", name: "[API] 彩云令牌", value: "", description: "留空沿用当前公共令牌;自有令牌只在设备参数中填写,不写入公开 Gist。" }, - { key: "API.QWeather.Host", name: "[API] 和风主机", value: "devapi.qweather.com", description: "使用和风 API 数据源时填写控制台主机名。" }, - { key: "API.QWeather.Token", name: "[API] 和风令牌", value: "", description: "使用和风 API 数据源时需要;网页预警补全不需要。" }, - { key: "LogLevel", name: "[调试] 日志等级", value: "INFO", options: ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "ALL"], description: "默认 INFO,与当前脚本一致;只控制本机日志。" }, - { key: "HTTP_TIMEOUT_MS", name: "[网络] 单次请求超时(毫秒)", value: "8000", description: "默认8000毫秒;脚本总体超时仍为30秒。" }, + { key: "DataSets", type: "array", name: "[数据集]", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", choices: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], description: "选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。" }, + { key: "Weather.Replace", type: "string", name: "[天气] 替换范围", value: "CN", description: "逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。" }, + { key: "Weather.Provider", type: "string", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不替换)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。" }, + { key: "Weather.ReplaceDaily", type: "boolean", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐日预报保持苹果数据。" }, + { key: "Weather.ReplaceHourly", type: "boolean", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐小时预报保持苹果数据。" }, + { key: "WeatherAlerts.Provider", type: "string", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["WeatherKit", "QWeatherWeb"], optionLabels: {"WeatherKit": "WeatherKit(不修改)", "QWeatherWeb": "和风天气网页"}, description: "QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。" }, + { key: "NextHour.Provider", type: "string", name: "[未来一小时降水强度] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不添加)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。" }, + { key: "AirQuality.Current.Index.Provider", type: "string", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;其余跨标准保留苹果数据。" }, + { key: "AirQuality.Current.Index.Replace", type: "array", name: "[今日空气指数] 替换目标", value: "HJ6332012", choices: ["HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", "ES.MITECO", "SG.NEA", "NAQI", "DAQI"], description: "替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012。WK3 仅支持同标准或国标→美标。" }, + { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", type: "boolean", name: "[今日空气指数] 强制主要污染物", value: "true", boolean: true, description: "忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。" }, + { key: "AirQuality.Current.Pollutants.Provider", type: "string", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], optionLabels: {"ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。" }, + { key: "AirQuality.Current.Pollutants.Units.Replace", type: "array", name: "[今日污染物 - 单位转换] 替换目标", value: "[]", choices: ["EPA_NowCast", "EU.EAQI", "HJ6332012", "UBA"], description: "转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。" }, + { key: "AirQuality.Current.Pollutants.Units.Mode", type: "string", name: "[今日污染物 - 单位转换] 模式", value: "Scale", options: ["Scale", "ugm3", "EU_ppb", "US_ppb", "Force_ugm3", "Force_EU_ppb", "Force_US_ppb"], optionLabels: {"Scale": "与空气质量标准的要求相同", "ugm3": "除非标准要求,都转为µg/m³", "EU_ppb": "除非标准要求,都转为欧盟ppb", "US_ppb": "除非标准要求,都转为美标ppb", "Force_ugm3": "µg/m³", "Force_EU_ppb": "欧盟ppb", "Force_US_ppb": "美标ppb"}, description: "污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。" }, + { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", type: "boolean", name: "[空气质量 - 对比昨日] 变化时替换", value: "true", boolean: true, description: "即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。" }, + { key: "AirQuality.Comparison.Yesterday.IndexProvider", type: "string", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "WeatherKit"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)", "WeatherKit": "保留 Apple/不额外查询(本机兼容)"}, description: "用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。" }, + { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", type: "string", name: "[昨日污染物] 数据源", value: "ColorfulCloudsCN", options: ["ColorfulCloudsCN", "QWeather"], optionLabels: {"QWeather": "和风天气", "ColorfulCloudsCN": "彩云天气(本机兼容)"}, description: "为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。" }, + { key: "AirQuality.Calculate.Algorithm", type: "string", name: "[iRingo内置算法] 算法", value: "WAQI_InstantCast_US", options: ["None", "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"], optionLabels: {"None": "不转换", "UBA": "德国LQI(FB001846)", "EU_EAQI": "欧盟EAQI(ETC HE Report 2024/17)", "WAQI_InstantCast_US": "美标InstantCast(EPA-454/B-24-002)", "WAQI_InstantCast_CN": "国标InstantCast(HJ 633—2012)", "WAQI_InstantCast_CN_25_DRAFT": "国标InstantCast(HJ 633 2025年草案)", "CA_AQHI": "加拿大AQHI(10.17269/s41997-019-00237-w)", "HK_AQHI": "香港AQHI", "AQHI_Multi_CN": "AQHI-Multi(中国)", "AQHI_Multi_CN_HK": "AQHI-Multi(中国+香港)", "CN_DEATH_AQHI": "中国(致死风险)AQHI", "CN_DEATH_HK_AQHI": "中国(致死风险)+香港AQHI"}, description: "共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。" }, + { key: "AirQuality.Calculate.AllowOverRange", type: "boolean", name: "[iRingo内置算法] 允许指数超标", value: "true", boolean: true, description: "允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。" }, + { key: "API.ColorfulClouds.Token", type: "string", name: "[API] 彩云天气令牌", value: "", description: "彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。" }, + { key: "API.QWeather.Host", type: "string", name: "[API] 和风天气主机", value: "devapi.qweather.com", description: "和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。" }, + { key: "API.QWeather.Token", type: "string", name: "[API] 和风天气令牌", value: "", description: "和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。" }, + { key: "LogLevel", type: "string", name: "[调试] 日志等级", value: "INFO", options: ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "ALL"], optionLabels: {"OFF": "关闭", "ERROR": "❌ 错误", "WARN": "⚠️ 警告", "INFO": "ℹ️ 信息", "DEBUG": "🅱️ 调试", "ALL": "全部"}, description: "选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。" }, + { key: "HTTP_TIMEOUT_MS", type: "number", name: "[网络] 单次请求超时(毫秒)", value: "8000", description: "默认8000毫秒;脚本总体超时仍为30秒。" }, ]; + +// Keep option values unchanged on the wire; display upstream labels in help text. +export function parameterDescription(parameter) { + const values = parameter.options ?? parameter.choices; + const options = values?.map(value => parameter.optionLabels?.[value] ? `${value}(${parameter.optionLabels[value]})` : value); + return `${parameter.description}${options?.length ? ` 可选值:${options.join("、")}。` : ""}`; +} diff --git a/template/wk3.stash.handlebars b/template/wk3.stash.handlebars index 9b1a2ab7..01ac581b 100644 --- a/template/wk3.stash.handlebars +++ b/template/wk3.stash.handlebars @@ -8,6 +8,8 @@ rules: - 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 +# Stash 无插件参数选择面板;编辑首个 argument 的 JSON,YAML 锚点会将设置共享到全部五组脚本。 +__WK3_ARGUMENT_COMMENTS__ http: mitm: - weatherkit.apple.com diff --git a/tests/wk3-module-parameters.test.mjs b/tests/wk3-module-parameters.test.mjs new file mode 100644 index 00000000..24bd4ce9 --- /dev/null +++ b/tests/wk3-module-parameters.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; +import { renderLocalModule } from '../scripts/build-egern-module.mjs'; +import { renderWK3Module } from '../scripts/build-wk3-modules.mjs'; +import { configuration } from '../src/wk3/entry/index.mjs'; +import { runPlatform } from '../src/platform/wk3-adapter.mjs'; + +const parameters = new Map(localParameters.map(p => [p.key, p])); +const jsonLine = (text, prefix) => JSON.parse(text.split('\n').find(line => line.startsWith(prefix)).slice(prefix.length)); +const get = (object, path) => path.split('.').reduce((value, key) => value?.[key], object); +const version = '3.3.4'; + +test('upstream names and types, preserved local defaults and supported options', () => { + const upstream = fs.readFileSync(new URL('../arguments-builder.full.config.ts', import.meta.url), 'utf8'); + assert.equal(parameters.size, 23); + for (const parameter of localParameters) { + const block = upstream.slice(upstream.indexOf(`key: "${parameter.key}"`)); + if (!upstream.includes(`key: "${parameter.key}"`)) continue; + assert.equal(parameter.name, block.match(/name: "([^"]+)"/)[1], parameter.key); + assert.equal(parameter.type, block.match(/type: "([^"]+)"/)[1], parameter.key); + } + for (const key of ['AirQuality.Current.Index.Provider', 'AirQuality.Comparison.Yesterday.IndexProvider']) assert.equal(parameters.get(key).value, 'ColorfulCloudsUS'); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').value, 'WAQI_InstantCast_US'); + assert.equal(parameters.get('LogLevel').value, 'INFO'); + assert.deepEqual(parameters.get('WeatherAlerts.Provider').options, ['WeatherKit', 'QWeatherWeb']); + assert.ok(parameters.get('AirQuality.Comparison.Yesterday.IndexProvider').options.includes('WeatherKit')); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').options.length, 12); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').options[0], 'None'); + assert.ok(!parameters.has('Storage') && !parameters.has('API.WAQI.Token')); +}); + +test('Egern actual env schema and compatibility help show all supported choices', () => { + const text = renderLocalModule({ version }); + const schema = jsonLine(text, 'env_schema: '); + const help = jsonLine(text, 'compat_arguments_desc: '); + assert.equal(Object.keys(schema).length, 23); + for (const parameter of localParameters) { + assert.equal(schema[parameter.key].name, parameter.name); + assert.equal(schema[parameter.key].default_value, parameter.value); + assert.equal(schema[parameter.key].description, parameterDescription(parameter)); + assert.ok(help.includes(parameter.name)); + if (parameter.options) assert.deepEqual(schema[parameter.key].options, parameter.options); + if (parameter.type === 'array') assert.equal(schema[parameter.key].options, undefined, 'lists must remain editable, not single selects'); + } +}); + +for (const platform of ['surge', 'loon', 'stash']) test(`${platform}: actual module displays names, choices and limitations`, () => { + const text = renderWK3Module(platform, version); + for (const parameter of localParameters) { + assert.ok(text.includes(parameter.name), parameter.key); + for (const option of parameter.options ?? parameter.choices ?? []) assert.ok(text.includes(option), `${parameter.key}: ${option}`); + } + for (const expected of ['不会自动添加国内目标', 'false 保留', '12 个选项', 'API 预警尚未移植', 'WK3 当前/逐日/逐小时天气']) assert.ok(text.includes(expected), expected); + assert.doesNotMatch(text, /确保包含国内原标准|十二种算法|始终仅做同源/); +}); + +function moduleArguments(platform, changes = {}) { + const text = platform === 'egern' ? renderLocalModule({ version }) : renderWK3Module(platform, version); + if (platform === 'egern') return { ...jsonLine(text, 'compat_arguments: '), ...changes }; + if (platform === 'stash') { + const original = JSON.parse(text.match(/argument: &wk3-arguments '([^']+)'/)[1]); + return JSON.stringify({ ...original, ...changes }); + } + if (platform === 'surge') { + const header = text.match(/^#!arguments = (.*)$/m)[1]; + const original = Object.fromEntries([...header.matchAll(/(?:^|,)([^:,]+):("(?:[^"\\]|\\.)*")/g)].map(([, key, value]) => [key, JSON.parse(value)])); + const values = { ...original, ...changes }; + return text.match(/, argument=(.*)$/m)[1].replace(/\{\{\{([^}]+)\}\}\}/g, (_, key) => values[key]); + } + const values = text.split('[Argument]\n')[1].split('\n[Rule]')[0].trim().split('\n').map(line => { + const [, key, kind, first] = line.match(/^([^ ]+) = (input|select|switch),("(?:[^"\\]|\\.)*"|[^,]+)/); + return changes[key] ?? (kind === 'switch' ? first === 'true' : JSON.parse(first)); + }); + return JSON.stringify(values); +} + +async function consume(platform, changes) { + const argument = moduleArguments(platform, changes); + if (platform === 'egern') return { settings: configuration({ env: argument }).Settings, env: argument }; + let result, configurationError; + await runPlatform(ctx => { + try { result = { settings: configuration(ctx).Settings, env: ctx.env }; } + catch (error) { configurationError = error; } + }, 'request', { $argument: argument }, platform); + if (configurationError) throw configurationError; + assert.ok(result, 'generated module arguments must parse into Settings'); + return result; +} + +for (const platform of ['egern', 'surge', 'loon', 'stash']) { + test(`${platform}: generated module edits retain types and all scalar choices reach Settings`, async () => { + for (const parameter of localParameters) { + const edits = parameter.boolean ? ['true', 'false'] : parameter.options; + if (!edits) continue; + for (const edit of edits) { + const { settings } = await consume(platform, { [parameter.key]: edit }); + assert.equal(get(settings, parameter.key), parameter.boolean ? edit === 'true' : edit, `${parameter.key}=${edit}`); + } + } + const { settings, env } = await consume(platform, { + DataSets: 'airQuality,forecastNextHour', 'AirQuality.Current.Index.Replace': 'EPA_NowCast', + 'AirQuality.Current.Pollutants.Units.Replace': 'EPA_NowCast,UBA', + 'API.ColorfulClouds.Token': '001234', 'API.QWeather.Token': 'synthetic,token', + 'API.QWeather.Host': 'example.invalid', HTTP_TIMEOUT_MS: '1234', + }); + assert.deepEqual(settings.DataSets, ['airQuality', 'forecastNextHour']); + assert.deepEqual(settings.AirQuality.Current.Index.Replace, ['EPA_NowCast']); + assert.deepEqual(settings.AirQuality.Current.Pollutants.Units.Replace, ['EPA_NowCast', 'UBA']); + assert.equal(settings.API.ColorfulClouds.Token, '001234'); + assert.equal(settings.API.QWeather.Token, 'synthetic,token'); + assert.equal(settings.API.QWeather.Host, 'example.invalid'); + assert.equal(env.HTTP_TIMEOUT_MS, '1234'); + }); + test(`${platform}: generated module [] disables datasets and index replacement`, async () => { + const { settings } = await consume(platform, { DataSets: '[]', 'AirQuality.Current.Index.Replace': '[]', 'Weather.Replace': '[]' }); + assert.deepEqual(settings.DataSets, []); + assert.deepEqual(settings.AirQuality.Current.Index.Replace, []); + assert.deepEqual(settings.Weather.Replace, []); + }); +} diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs index fae21817..12a11637 100644 --- a/tests/wk3-modules.test.mjs +++ b/tests/wk3-modules.test.mjs @@ -106,8 +106,10 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.equal((scripts.match(/^ type: response$/gm) ?? []).length, 2); assert.equal((scripts.match(/^ require-body: true$/gm) ?? []).length, 2); assert.equal((scripts.match(/^ binary-mode: true$/gm) ?? []).length, 1); - const argumentsJSON = [...scripts.matchAll(/^ argument: '([^']+)'$/gm)].map(([, value]) => JSON.parse(value)); - assert.equal(argumentsJSON.length, 5); + const sharedArgument = scripts.match(/^ argument: &wk3-arguments '([^']+)'$/m); + assert.ok(sharedArgument, 'one editable parameter object shared by all routes'); + assert.equal((scripts.match(/^ argument: \*wk3-arguments$/gm) ?? []).length, 4); + const argumentsJSON = [JSON.parse(sharedArgument[1])]; for (const values of argumentsJSON) { assert.equal(Object.keys(values).length, 23); assert.equal(values['AirQuality.Calculate.Algorithm'], 'WAQI_InstantCast_US'); From d4d81c7f79be7cb60188eb95430da7a0e1d15e94 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:36:21 +0800 Subject: [PATCH 25/42] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88=E5=8F=91?= =?UTF-8?q?=E8=A1=8C=EF=BC=89=EF=BC=9A=E6=89=A7=E8=A1=8C=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=B9=B6=E6=A0=B8=E5=AF=B9=E6=89=80=E6=9C=89?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 2 ++ package.json | 1 + tests/wk3-release-build.test.mjs | 11 +++++++---- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db8a8e5c..099d0a49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,6 +42,8 @@ jobs: version: ${{ github.ref_name }} keep-v: "false" # If set to "true", will not remove any 'v' prefix from the version number. ignore-semver-check: "false" # If set to "true", will not check if the version number is a valid semver version. + - name: Run WK3 regressions + run: npm run test:wk3 - name: Build scripts run: npm run build - name: Generate modules diff --git a/package.json b/package.json index d51c60b5..d9838760 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "deploy:vercel": "npx vercel deploy --prod", "build": "rollup -c", "build:wk3": "node scripts/build-wk3-release.mjs --outdir dist", + "test:wk3": "node --test --test-concurrency=1 tests/*.test.mjs tests/wk3/*.test.mjs", "build:dev": "rollup -c rollup.dev.config.mjs", "build:dev-args": "arguments-builder build -c arguments-builder.dev.config.ts", "sgmoudle:dev": "sgmoudle dev", diff --git a/tests/wk3-release-build.test.mjs b/tests/wk3-release-build.test.mjs index 407f27df..c00f0233 100644 --- a/tests/wk3-release-build.test.mjs +++ b/tests/wk3-release-build.test.mjs @@ -20,13 +20,16 @@ test("tagged WK3 build publishes all platform scripts and matching modules", asy assert.ok(files.includes(file), file); assert.ok((await stat(path.join(dir, file))).size > 1000, file); } - for (const extension of extensions) { + for (const [index, extension] of extensions.entries()) { const file = `iRingo.WeatherKit.WK3.${extension}`; const text = await readFile(path.join(dir, file), "utf8"); assert.ok(text.length > 500, file); - for (const [, asset] of text.matchAll(/(?:script_url:|script-path=|url:\s*(?:script-\w+|https:\/\/github\.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/))[^\n]*?\b(wk3-[a-z]+-(?:request|response)\.bundle\.js)/g)) { - assert.ok(files.includes(asset), `${file} references missing ${asset}`); - } + const references = [...text.matchAll(/https:\/\/github\.com\/patrickyanxxxxx\/WeatherKit-WK3\/releases\/download\/v3\.3\.2\/(wk3-[a-z]+-(?:request|response)\.bundle\.js)/g)].map(match => match[1]); + assert.deepEqual(references.sort(), [ + ...Array(3).fill(`wk3-${platforms[index]}-request.bundle.js`), + ...Array(2).fill(`wk3-${platforms[index]}-response.bundle.js`), + ].sort(), `${file} must reference exactly its own five versioned scripts`); + for (const asset of references) assert.ok(files.includes(asset), `${file} references missing ${asset}`); } const egern = await readFile(path.join(dir, "iRingo.WeatherKit.WK3.yaml"), "utf8"); assert.equal(egern, renderLocalModule({ version: "3.3.2" })); From 389f0afe85a27304be99cdcb00e6758af4f4a736 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:46:40 +0800 Subject: [PATCH 26/42] =?UTF-8?q?=E5=8F=91=E8=A1=8C=EF=BC=88v3.3.4?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E8=AF=B4=E6=98=8E=E6=AD=A3=E7=A1=AE=E6=80=A7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81=E8=AE=BE=E7=BD=AE=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ README.md | 19 ++++++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c1b45e..b2ffa9ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +# v3.3.4 — 空气算法、配置语义与平台代理修复 + +本次针对 v3.3.3 与上游/PR #107 对比中复现的问题作定点修复,不覆盖旧标签。参考移植的算法代码保留 Horatio、PR #107 `831852f` 和 Apache-2.0 来源说明;未整套引入其 JSON 协商、缓存或额外依赖。 + +## 修复内容 + +- **算法与单位:**美标计算按污染物精度截断但保留物理浓度;PM10 超量程 604→605 的 AQI 从 500 连续到 501。修正 ppb/ppm 同单位转换、CN25 零值、加拿大 AQHI 单位一致性与四舍五入;缺少必要输入、未知算法及负分项不再伪装为有效结果。CO 仅在明确 mg/m³ 时转为 µg/m³,重复调用不放大、不修改原对象。 +- **配置语义:**空 Replace 禁用指数替换,不被自动补入 HJ6332012;昨日来源不随今日来源暗改。算法 None、WeatherKit 与未知来源不默认计算;候选失败或指数未改变不重查昨日、不覆盖 Apple。已知比较依开关保留,未知比较只按显式来源补齐,WK3 继续限制同源同标准。 +- **平台代理:**修正 Surge/Loon/Stash 响应阶段 `$done` 直接返回响应修改对象,请求阶段保留合成响应包装。修复 Surge 空数组参数;实际 `$argument` 控制请求超时与日志,而非只读取环境默认。 +- **模块设置:**名称、类型、选项标签与帮助对照上游整理,保持 23 参数和 US 默认;保留本机兼容选项并注明范围。Stash 五脚本共用一个可编辑 argument;未支持的 WK3 天气/API 预警不伪装可用。 +- **执行日志:**五平台共用 INFO 开始/结束及原有处理摘要,DEBUG/ALL 查看更多过程;OFF 静默。兼容只提供 console.log 的客户端,格式化时遮蔽令牌及完整私人设置。 +- **验证:**将实际串行回归接入 CI,再构建旧入口与五平台发行产物;每份模块必须含五条正确平台/版本脚本引用,避免零匹配假通过。修复分支 [Build 42037645244](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42037645244) **338/338 通过、0 跳过**,构建和归档成功。 + +## 默认与升级 + +默认仍为今日/昨日 `ColorfulCloudsUS`、内置算法 `WAQI_InstantCast_US`(只在 Calculate 时使用)、分钟彩云、预警 QWeatherWeb、INFO、单次 8000ms。已保存参数不会被强制重置,更新模块后请核对自己的选项。 + +使用本 Fork Release 附件中名称含 `.WK3.` 的模块;Egern/Surge/Loon/Stash/QX 均引用对应版本平台脚本。最新入口:[Egern](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml)、[Surge](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule)、[Loon](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx)、[Stash](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride)、[Quantumult X](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet)。保留 v3.3.3 便于回退;现用 Gist 未修改。 + +## 保留的边界 + +- v3.3.3 Egern 用户反馈正常;**本补丁尚无新设备验收**,其他平台代理仍仅模拟/脚本回放。 +- WK3 当前/逐日/逐小时天气仍透传,未知结构不猜测写入;QX 二进制仍透传,Shadowrocket 无本机 WK3 模块。 +- 12 个选项包含 None;WK3 不保证所有算法目标可编码。InstantCast 单组浓度计算不等同于 EPA NowCast 时间加权,也不自动提供加拿大 AQHI 的三小时均值。 +- 已被来源错误标为 µg/m³ 的 CO 无法仅凭单位判断原值应否放大,不凭猜测修正。跨来源/算法昨日比较不伪造结果。 + +--- + # v3.3.3 — WK3 本机处理与多平台代理统一架构 基于 NSRingo/WeatherKit v3.3.2,保留上游 WK2 入口与中文独立提交历史,增加 iOS 27 WK3 本机处理。Egern、Surge、Loon、Stash、Quantumult X 共用 `src/wk3/` 业务核心、参数与路由,客户端只适配运行时。 diff --git a/README.md b/README.md index 394c270d..f4771068 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,16 @@ 此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 -## 本分支更新 +## v3.3.4 修复 + +- 校正美标浓度截断与超量程连续性、加拿大 AQHI 单位与四舍五入、国标草案零浓度;CO 单位修复幂等,不改变输入;无有效分项或必要污染物时不输出伪有效指数。 +- 空替换目标、算法 `None`、Apple 来源和昨日来源按用户选择执行;成功且实际改变指数后才按开关重算已知比较,失败保留 Apple 数据,移除无必要 AQ 预取。 +- 修正 Surge/Loon/Stash 响应阶段 `$done` 结构及 Surge `[]` 参数解析;超时与日志使用实际解析后的模块参数。 +- 模块名称、选项标签与帮助对齐上游;保留 23 项参数及现有默认。Stash 在第一条脚本 `argument` 编辑参数,其余四条共用该值。未实现的上游功能明确标注,不提供空壳选项。 +- 五平台共用日志实现;默认 INFO 显示 `☑️ request/response`、处理摘要和 `✅ request/response`,DEBUG/ALL 展示更多处理步骤,WARN/ERROR 显示回退或异常,OFF 静默。令牌和完整配置不在日志中明文输出。 +- `npm run test:wk3` 串行执行旧入口与 WK3 全部回归,正式 CI 先测试再构建;修复分支 CI **338/338 通过、0 跳过**。无新增依赖、hash 或缓存层。 + +## WK3 基础能力 - WK3 FlatBuffer 有界读写:在已验证结构内替换空气质量,保留其他数据槽与未知字段;国标 AQI 可转换为同源美标 AQI。香港在 Apple WK3 缺少 AQ 数据时,可按现有条件补入彩云美标 AQ。 - WK3 已实现分钟降水、同源同标准的昨日空气等级比较,以及严格匹配原事件 ID 的天气预警补全;失败或未验证结构保持 Apple 原始数据。 @@ -13,7 +22,7 @@ | 软件 | 模块格式 | 当前验证范围 | | --- | --- | --- | -| Egern | `modules/iRingo.WeatherKit.WK3.yaml` | 本机 `ctx` 入口、参数与 WK3 处理通过合成回放;既有 Gist 版的美标 AQI、分钟降水与昨日对比曾经设备确认,本分支新 Rollup 产物尚未设备验证。仓库模块仍指向既有 Gist JS。 | +| Egern | `.yaml` | 用户已反馈 v3.3.3 正式版实机正常;v3.3.4 为自动测试及脚本回放验证,仍需设备复验。发行模块引用同版本附件,仓库 `modules/` 保留旧 Gist 兼容模块。 | | Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | | Loon | `.lpx` | 独立 JS、位置参数与二进制响应模拟通过;未设备验证。 | | Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | @@ -35,7 +44,7 @@ | --- | --- | --- | | 今日空气指数来源 | `ColorfulCloudsUS` | 彩云美标;仅匹配原始标准 `HJ6332012` 后才尝试替换。 | | 昨日空气指数来源 | `ColorfulCloudsUS` | 与今日同来源、同标准才比较;否则保持未知。 | -| 内置 AQ 计算算法 | `WAQI_InstantCast_US` | **只有来源设为 `Calculate` 才执行**;其余 11 种上游算法可选,WK3 目标标准不兼容时透传。 | +| 内置 AQ 计算算法 | `WAQI_InstantCast_US` | **只有来源设为 `Calculate` 才执行**;共 12 个选项含 `None`(11 种计算),WK3 目标标准不兼容时透传。 | | 分钟降水 | `ColorfulClouds` | 已验证的分钟雨结构;未知条件透传。 | | 天气预警 | `QWeatherWeb` | 严格匹配后补全原生详情;`WeatherKit` 为原样透传。 | | 天气来源 | `ColorfulClouds` | WK2 可用;WK3 当前/逐日/逐小时天气仍透传 Apple。 | @@ -48,7 +57,7 @@ - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 - Quantumult X 二进制天气不可转换;其他平台代理仅通过脚本模拟,没有客户端实机结论。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- 本 Fork 的 v3.3.3 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;仓库内 `modules/` 的旧 Gist 模块仅保留兼容,不自动更新用户已安装配置。 +- 本 Fork 的 v3.3.4 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;仓库内 `modules/` 的旧 Gist 模块仅保留兼容,不自动更新用户已安装配置。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 -验证命令:`node --test tests/wk3/*.test.mjs tests/wk3-modules.test.mjs tests/wk3-platform-adapter.test.mjs tests/wk3-release-build.test.mjs`。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 +验证命令:`npm run test:wk3`(包含旧入口、算法边界、配置、模块、五平台日志与发行构建回归,串行避免本机并发打包争用)。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 From f1211389423143c38e07eeff8d10f00f349d7e18 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:06:23 +0800 Subject: [PATCH 27/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=89=EF=BC=9A=E4=BA=94=E5=B9=B3=E5=8F=B0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=BE=93=E5=87=BA=E5=88=B0=E5=8F=AF=E8=A7=81=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wk3/utils/Console.mjs | 4 ++-- tests/wk3-logging.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/wk3/utils/Console.mjs b/src/wk3/utils/Console.mjs index b9d3c8b0..8895251a 100644 --- a/src/wk3/utils/Console.mjs +++ b/src/wk3/utils/Console.mjs @@ -42,8 +42,8 @@ function format(messages) { export const Console = Object.fromEntries( [["debug", 4], ["info", 3], ["warn", 2], ["error", 1], ["log", 1], ["exception", 1]].map(([method, threshold]) => [method, (...messages) => { if (level < threshold) return; - const output = console[method === "exception" ? "error" : method] ?? console.log; - if (typeof output === "function") output.apply(console, format(messages)); + const output = console.log ?? console[method === "exception" ? "error" : method]; + if (typeof output === "function") output.call(console, format(messages).join(" ")); }]), ); diff --git a/tests/wk3-logging.test.mjs b/tests/wk3-logging.test.mjs index 340ed713..b59024da 100644 --- a/tests/wk3-logging.test.mjs +++ b/tests/wk3-logging.test.mjs @@ -111,6 +111,27 @@ for (const platform of ["egern", "surge", "loon", "stash", "quantumultx"]) { }); } +test("Egern INFO reaches console.log even when console.info exists", async () => { + const bundle = await files("egern"); + for (const stage of ["request", "response"]) { + const visible = await execute("egern", bundle[stage].code, stage); + const logMessages = visible.logs.filter(([method]) => method === "log").map(([, message]) => message); + assert.equal(logMessages[0], `☑️ ${stage}`); + assert.equal(logMessages.at(-1), `✅ ${stage}`); + } +}); + +test("Loon INFO reaches console.log even when console.info exists", async () => { + const bundle = await files("loon"); + for (const stage of ["request", "response"]) { + const visible = await execute("loon", bundle[stage].code, stage); + const logMessages = visible.logs.filter(([method]) => method === "log").map(([, message]) => message); + assert.equal(logMessages[0], `☑️ ${stage}`); + assert.equal(logMessages.at(-1), `✅ ${stage}`); + assert.ok(logMessages.every(message => typeof message === "string")); + } +}); + test("Egern overlapping invocations keep their own ctx log level", async () => { const bundle = await files("egern"); const first = await execute("egern", bundle.request.code, "request"); From a7fd93f9ec3e900b0c4ba6febcacf17281d0210f Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:30:55 +0800 Subject: [PATCH 28/42] =?UTF-8?q?=E5=8F=91=E8=A1=8C=EF=BC=88v3.3.5?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E8=AF=B4=E6=98=8E=E6=97=A5=E5=BF=97=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E4=BF=AE=E5=A4=8D=E4=B8=8E=E4=BA=94=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ README.md | 6 +++++- tests/wk3-logging.test.mjs | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ffa9ce..5abcc763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# v3.3.5 — 修复代理软件内脚本日志为空 + +针对用户在 Egern、Loon 实机反馈的“脚本有执行记录,日志正文仍为空”,将五平台共用日志出口统一为 `console.log`。v3.3.4 优先调用 `console.info/debug/warn/error`;这些方法存在不代表客户端会把它们收进脚本日志,因此原来的方法存在性回退不足以保证日志可见。 + +- 保留 INFO/DEBUG/WARN/ERROR/OFF 等应用层过滤与敏感数据遮蔽,不改变算法、参数默认、天气处理或安全回退。 +- 每条消息先格式化、脱敏并合成一个字符串,再调用一次 `console.log`,兼容 Loon 以单个主参数记录日志的接口。 +- Egern、Loon 的正式压缩 bundle 在同样的诊断环境下,默认 INFO 从 0 条 `console.log` 变为 4 条;五平台日志及平台回归 33/33 通过,修复分支完整 CI 测试和构建成功。 +- 请更新模块,让脚本地址切到 v3.3.5,确认设备中 `LogLevel` 为 INFO/DEBUG/ALL;旧脚本的历史执行记录不会补写日志。现用 Gist 不变,旧发行版保留。 +- 这是日志兼容修复,尚待用户实机确认恢复;不能把模拟输出当作 Egern/Loon 实机日志已修复的结论。QX 二进制透传、WK3 当前/日/小时天气透传等限制不变。 + +--- + # v3.3.4 — 空气算法、配置语义与平台代理修复 本次针对 v3.3.3 与上游/PR #107 对比中复现的问题作定点修复,不覆盖旧标签。参考移植的算法代码保留 Horatio、PR #107 `831852f` 和 Apache-2.0 来源说明;未整套引入其 JSON 协商、缓存或额外依赖。 diff --git a/README.md b/README.md index f4771068..a713c021 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ 此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 +## v3.3.5 日志兼容修复 + +针对用户在 Egern、Loon 反馈的“有执行记录但日志为空”,五平台统一通过 `console.log` 输出脱敏后的单字符串,不再因 `console.info` 方法存在就假定客户端会收集。日志等级与业务处理不变。更新模块后确认 `LogLevel` 为 INFO/DEBUG/ALL,刷新天气查看**新执行记录**;旧记录不会补写。正式压缩脚本已复现修复前后输出差异,仍待设备复验,旧 Gist 未更新。 + ## v3.3.4 修复 - 校正美标浓度截断与超量程连续性、加拿大 AQHI 单位与四舍五入、国标草案零浓度;CO 单位修复幂等,不改变输入;无有效分项或必要污染物时不输出伪有效指数。 @@ -57,7 +61,7 @@ - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 - Quantumult X 二进制天气不可转换;其他平台代理仅通过脚本模拟,没有客户端实机结论。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- 本 Fork 的 v3.3.4 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;仓库内 `modules/` 的旧 Gist 模块仅保留兼容,不自动更新用户已安装配置。 +- 本 Fork 的 v3.3.5 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;仓库内 `modules/` 的旧 Gist 模块仅保留兼容,不自动更新用户已安装配置。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 验证命令:`npm run test:wk3`(包含旧入口、算法边界、配置、模块、五平台日志与发行构建回归,串行避免本机并发打包争用)。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 diff --git a/tests/wk3-logging.test.mjs b/tests/wk3-logging.test.mjs index b59024da..8b53fdf4 100644 --- a/tests/wk3-logging.test.mjs +++ b/tests/wk3-logging.test.mjs @@ -95,7 +95,7 @@ for (const platform of ["egern", "surge", "loon", "stash", "quantumultx"]) { assert.equal(visible.logs.at(-1)[1], `✅ ${stage}`); assert.ok(!text.includes(secret), text); assert.ok(!text.includes("private-host-unique"), text); - assert.ok(!visible.logs.some(([method]) => method === "debug")); + assert.ok(visible.logs.every(([method, message, ...rest]) => method === "log" && typeof message === "string" && rest.length === 0), `${platform} must write one string per console.log call`); if (stage === "response") { assert.match(text, /provider summary intact/); assert.match(text, /parseWeatherKitURL/); From 7fd5388c506b4931af3c54b9dfce5e5673a9ed8a Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:38:24 +0800 Subject: [PATCH 29/42] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E7=B2=BE?= =?UTF-8?q?=E7=AE=80=E5=BD=93=E5=89=8D=E5=8F=91=E8=A1=8C=E7=89=88=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- README.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abcc763..5823ca06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - 保留 INFO/DEBUG/WARN/ERROR/OFF 等应用层过滤与敏感数据遮蔽,不改变算法、参数默认、天气处理或安全回退。 - 每条消息先格式化、脱敏并合成一个字符串,再调用一次 `console.log`,兼容 Loon 以单个主参数记录日志的接口。 - Egern、Loon 的正式压缩 bundle 在同样的诊断环境下,默认 INFO 从 0 条 `console.log` 变为 4 条;五平台日志及平台回归 33/33 通过,修复分支完整 CI 测试和构建成功。 -- 请更新模块,让脚本地址切到 v3.3.5,确认设备中 `LogLevel` 为 INFO/DEBUG/ALL;旧脚本的历史执行记录不会补写日志。现用 Gist 不变,旧发行版保留。 +- 请更新模块,让脚本地址切到 v3.3.5,确认设备中 `LogLevel` 为 INFO/DEBUG/ALL;旧脚本的历史执行记录不会补写日志;旧发行版保留。 - 这是日志兼容修复,尚待用户实机确认恢复;不能把模拟输出当作 Egern/Loon 实机日志已修复的结论。QX 二进制透传、WK3 当前/日/小时天气透传等限制不变。 --- diff --git a/README.md b/README.md index a713c021..250054ce 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## v3.3.5 日志兼容修复 -针对用户在 Egern、Loon 反馈的“有执行记录但日志为空”,五平台统一通过 `console.log` 输出脱敏后的单字符串,不再因 `console.info` 方法存在就假定客户端会收集。日志等级与业务处理不变。更新模块后确认 `LogLevel` 为 INFO/DEBUG/ALL,刷新天气查看**新执行记录**;旧记录不会补写。正式压缩脚本已复现修复前后输出差异,仍待设备复验,旧 Gist 未更新。 +针对用户在 Egern、Loon 反馈的“有执行记录但日志为空”,五平台统一通过 `console.log` 输出脱敏后的单字符串,不再因 `console.info` 方法存在就假定客户端会收集。日志等级与业务处理不变。更新模块后确认 `LogLevel` 为 INFO/DEBUG/ALL,刷新天气查看**新执行记录**;旧记录不会补写。正式压缩脚本已复现修复前后输出差异,仍待设备复验。 ## v3.3.4 修复 @@ -26,14 +26,14 @@ | 软件 | 模块格式 | 当前验证范围 | | --- | --- | --- | -| Egern | `.yaml` | 用户已反馈 v3.3.3 正式版实机正常;v3.3.4 为自动测试及脚本回放验证,仍需设备复验。发行模块引用同版本附件,仓库 `modules/` 保留旧 Gist 兼容模块。 | +| Egern | `.yaml` | 用户已反馈 v3.3.3 正式版实机正常;v3.3.5 日志修复已完成自动测试及脚本回放,仍需设备复验。发行模块引用同版本附件。 | | Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | | Loon | `.lpx` | 独立 JS、位置参数与二进制响应模拟通过;未设备验证。 | | Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | | Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | | Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | -五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。仓库中的 `modules/iRingo.WeatherKit.WK3.yaml` 仍指向既有 Gist,不影响现用订阅;五平台脚本不能互换。 +五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。请使用发行附件中的模块;五平台脚本不能互换。 ## 代码组织 @@ -61,7 +61,7 @@ - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 - Quantumult X 二进制天气不可转换;其他平台代理仅通过脚本模拟,没有客户端实机结论。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- 本 Fork 的 v3.3.5 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;仓库内 `modules/` 的旧 Gist 模块仅保留兼容,不自动更新用户已安装配置。 +- 本 Fork 的 v3.3.5 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 验证命令:`npm run test:wk3`(包含旧入口、算法边界、配置、模块、五平台日志与发行构建回归,串行避免本机并发打包争用)。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 From 8db4fadcbdd38cac11b4cd775e06ce40c4c36982 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:12:37 +0800 Subject: [PATCH 30/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88Loon=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=8E=9F=E7=94=9F=E5=AD=97=E8=8A=82=E5=9B=9E=E4=BC=A0?= =?UTF-8?q?=E5=A4=A9=E6=B0=94=E4=BA=8C=E8=BF=9B=E5=88=B6=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/platform/wk3-adapter.mjs | 8 ++- tests/wk3-loon-binary.test.mjs | 85 +++++++++++++++++++++++++++++ tests/wk3-platform-adapter.test.mjs | 6 +- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/wk3-loon-binary.test.mjs diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs index 86ca1d58..1d10898a 100644 --- a/src/platform/wk3-adapter.mjs +++ b/src/platform/wk3-adapter.mjs @@ -98,9 +98,11 @@ export async function runPlatform(handler, stage, globals = globalThis, platform if (typeof done !== "function") return result; if (result === undefined || result === null) return done(); const bodyToPlatform = body => { - if (body instanceof ArrayBuffer) return binaryStringFromBytes(new Uint8Array(body)); - if (ArrayBuffer.isView(body)) return binaryStringFromBytes(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); - return body; + const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) + : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : null; + if (!bytes) return body; + // Loon binary-body-mode consumes Uint8Array; a string is treated as text. + return platform === "loon" ? bytes : binaryStringFromBytes(bytes); }; const response = { status: platform === "Quantumult X" ? `HTTP/1.1 ${result.status ?? 200} ${result.statusText ?? "OK"}` : result.status ?? 200, diff --git a/tests/wk3-loon-binary.test.mjs b/tests/wk3-loon-binary.test.mjs new file mode 100644 index 00000000..e40fed13 --- /dev/null +++ b/tests/wk3-loon-binary.test.mjs @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { Builder } from "flatbuffers"; +import { runPlatform } from "../src/platform/wk3-adapter.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { encodeAirQuality, decodeWK3DataSet } from "../src/wk3/class/WeatherKit3.mjs"; +import { localParameters } from "../src/wk3/parameters.mjs"; + +const raw = new Uint8Array([0, 1, 127, 128, 195, 255]); +for (const stage of ["request", "response"]) { + test(`Loon ${stage}: native bytes survive $done without text encoding`, async () => { + for (const body of [raw, raw.buffer, new Uint8Array([99, ...raw, 99]).subarray(1, -1)]) { + let returned; + await runPlatform(async () => ({ status: 200, headers: {}, body }), stage, { + $request: { url: "https://example.invalid", headers: {} }, + $response: { status: 200, headers: {}, body: raw }, + $done(value) { returned = value; }, + }, "loon"); + const result = stage === "request" ? returned.response : returned; + assert.ok(result.body instanceof Uint8Array, "binary Loon output must be native Uint8Array, never a string"); + assert.deepEqual(Array.from(result.body), Array.from(raw)); + } + }); +} + +test("Loon text responses remain strings", async () => { + let returned; + await runPlatform(async () => ({ status: 200, headers: {}, body: "天气正常" }), "response", { + $request: { url: "https://example.invalid", headers: {} }, + $done(value) { returned = value; }, + }, "loon"); + assert.equal(returned.body, "天气正常"); +}); + +let built; +const bundle = () => built ??= buildPlatform("loon"); +async function execute(code, body, overrides = {}) { + const logs = [], requests = []; + const values = localParameters.map(p => overrides[p.key] ?? p.value); + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + vm.runInNewContext(code, { + URL, Headers, Response, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, + console: { log: line => logs.push(line) }, + $loon: "3.5.4", $argument: JSON.stringify(values), + $request: { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality", method: "GET", headers: {} }, + $response: { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body }, + $httpClient: { get(options, callback) { + requests.push(options.url); + assert.ok(options.url.includes("/realtime"), options.url); + callback(null, { status: 200, headers: { "content-type": "application/json" } }, JSON.stringify({ status: "ok", location: [31, 121], result: { realtime: { status: "ok", air_quality: { description: { usa: "moderate" }, aqi: { usa: 87, chn: 60 }, obs_time: 1893456000, pm25: 12, pm10: 20, co: 0.4, no2: 10, so2: 5, o3: 40 } } } })); + } }, + $done: value => finish(value), + }, { timeout: 30000 }); + const result = await Promise.race([completed, new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("Loon script did not finish")), 5000); + timer.unref(); + })]); + return { result, logs, requests }; +} + +test("actual minified Loon bundle preserves binary pass-through with high bytes", async () => { + const { response } = await bundle(); + const { result, logs, requests } = await execute(response.code, raw, { DataSets: "[]" }); + assert.ok(result.body instanceof Uint8Array, "native Loon host receives bytes, not UTF-8 text"); + assert.deepEqual(Array.from(result.body), Array.from(raw)); + assert.equal(requests.length, 0); + assert.equal(logs[0], "☑️ response"); + assert.equal(logs.at(-1), "✅ response"); +}); + +test("actual minified Loon bundle returns decodable WK3 US AQ after source request", async () => { + const b = new Builder(); + const aq = encodeAirQuality(b, { metadata: { sourceType: 3, providerName: "Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); + b.startObject(15); b.addFieldOffset(0, aq, 0); b.finish(b.endObject()); + const { response } = await bundle(); + const { result, logs, requests } = await execute(response.code, b.asUint8Array(), { "AirQuality.Comparison.Yesterday.IndexProvider": "WeatherKit" }); + assert.ok(result.body instanceof Uint8Array, "returned WK3 must stay binary for Loon"); + const decoded = decodeWK3DataSet(result.body); + assert.equal(decoded.index, 87); + assert.equal(decoded.scale, "EPA_NowCast.2604"); + assert.equal(requests.length, 1); + assert.ok(logs.some(line => /WK3 AQ patch/.test(line))); +}); diff --git a/tests/wk3-platform-adapter.test.mjs b/tests/wk3-platform-adapter.test.mjs index d6e75249..e8f9f469 100644 --- a/tests/wk3-platform-adapter.test.mjs +++ b/tests/wk3-platform-adapter.test.mjs @@ -40,8 +40,10 @@ for (const platform of platforms) { assert.equal(result.status, 201); assert.equal(result.headers["X-Patched"], "yes"); assert.ok(!Object.keys(result.headers).some(k => ["content-length", "content-encoding", "transfer-encoding"].includes(k.toLowerCase()))); - if (platform === "quantumultx") assert.equal(result.body, "{\"ok\":true}"); - else assert.deepEqual(Array.from(result.body, c => c.charCodeAt(0)), bytes); + if (platform === "loon") { + assert.ok(result.body instanceof Uint8Array); + assert.deepEqual(Array.from(result.body), bytes); + } else assert.deepEqual(Array.from(result.body, c => c.charCodeAt(0)), bytes); } doneCalls = 0; From 50d07dd2e0af2fc3e17a7f5eea230ae291511344 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:32:01 +0800 Subject: [PATCH 31/42] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88Loon=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E8=AF=B4=E6=98=8E=E5=8E=9F=E7=94=9F=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=8A=E8=AE=BE=E5=A4=87=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ README.md | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5823ca06..7db2144c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# v3.3.6 — 修复 Loon 天气二进制响应回写 + +用户在 Loon 3.5.4(1015)确认脚本已有执行记录和日志,但天气 App 提示不可用。排查发现平台代理把 Loon 的二进制正文转换成字符串,再传给 `$done`;即使原样透传也会改变原生接口的正文类型。 + +- Loon 的 ArrayBuffer 和 TypedArray 现在按准确的字节范围转换为 `Uint8Array` 回传,不再作为文本发送;文本响应保持字符串。其他平台返回方式不变。 +- 新增原生字节、带偏移视图、原样透传、真实压缩 bundle 与完整 WK3 AQ 回放测试。修复前 4/5 失败,修复后定向及相邻平台代理回归 28/28 通过。 +- 修复分支 [Build 42064167010](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42064167010) 实际执行完整回归 **345/345 通过、0 跳过**,旧入口和五平台构建、产物检查及归档成功。 +- 原生 Loon 回放确认 AQI 60→87,返回的字节仍可解码;这不是手机端验收,天气 App 不可用是否完全消失仍需用户复验。 +- 保留日志修复、23 项设置与 US 默认;不改天气算法或扩大 WK3 支持范围。 + +--- + # v3.3.5 — 修复代理软件内脚本日志为空 针对用户在 Egern、Loon 实机反馈的“脚本有执行记录,日志正文仍为空”,将五平台共用日志出口统一为 `console.log`。v3.3.4 优先调用 `console.info/debug/warn/error`;这些方法存在不代表客户端会把它们收进脚本日志,因此原来的方法存在性回退不足以保证日志可见。 diff --git a/README.md b/README.md index 250054ce..231c3f28 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ 此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 +## v3.3.6 Loon 二进制回写修复 + +修正 Loon 将天气二进制转为字符串回传的问题,按官方接口保持 `Uint8Array`,文本响应不变。实际压缩脚本的 WK3 AQ 回放和字节保持测试通过;修复分支 CI 345/345 通过、0 跳过。用户反馈 Loon 3.5.4(1015)已有日志但天气不可用,本次补丁仍需设备复验,不将模拟通过视为手机已恢复。 + ## v3.3.5 日志兼容修复 针对用户在 Egern、Loon 反馈的“有执行记录但日志为空”,五平台统一通过 `console.log` 输出脱敏后的单字符串,不再因 `console.info` 方法存在就假定客户端会收集。日志等级与业务处理不变。更新模块后确认 `LogLevel` 为 INFO/DEBUG/ALL,刷新天气查看**新执行记录**;旧记录不会补写。正式压缩脚本已复现修复前后输出差异,仍待设备复验。 @@ -28,7 +32,7 @@ | --- | --- | --- | | Egern | `.yaml` | 用户已反馈 v3.3.3 正式版实机正常;v3.3.5 日志修复已完成自动测试及脚本回放,仍需设备复验。发行模块引用同版本附件。 | | Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | -| Loon | `.lpx` | 独立 JS、位置参数与二进制响应模拟通过;未设备验证。 | +| Loon | `.lpx` | 用户确认 3.5.4(1015)脚本有日志但天气不可用;v3.3.6 修复原生字节回写并通过压缩脚本回放,待设备复验。 | | Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | | Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | | Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | From 983b2ed6f69323b855ce98a8c8ef55939e86a6ee Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:34:31 +0800 Subject: [PATCH 32/42] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88Loon=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E4=BF=9D=E7=95=99=20Apple=20=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=20availability=20=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wk3/entry/response.mjs | 9 +++------ src/wk3/process/Response.mjs | 20 +++++++++++-------- tests/wk3-availability.test.mjs | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 tests/wk3-availability.test.mjs diff --git a/src/wk3/entry/response.mjs b/src/wk3/entry/response.mjs index 55cae3d4..bc5e7185 100644 --- a/src/wk3/entry/response.mjs +++ b/src/wk3/entry/response.mjs @@ -28,18 +28,15 @@ async function processingContext(ctx, url, Settings, Configs) { export async function handleResponse(ctx) { const url = new URL(ctx.request.url); if (url.hostname !== "weatherkit.apple.com") return; - if (/^\/api\/v[12]\/availability\//.test(url.pathname)) { - const { Configs } = configuration(ctx); - return { status: 200, headers: responseHeaders(ctx.response.headers, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }), body: JSON.stringify(Configs?.Availability?.v2 ?? []) }; - } - if (!/^\/api\/(?:v[23]\/weather\/|v[12]\/airQualityScale\/)/.test(url.pathname)) return; + const availability = /^\/api\/v[12]\/availability\//.test(url.pathname); + if (!availability && !/^\/api\/(?:v[23]\/weather\/|v[12]\/airQualityScale\/)/.test(url.pathname)) return; const headers = headerObject(ctx.response.headers); const binary = /flatbuffer|protobuf|octet-stream/i.test(headers["content-type"] ?? ""); const originalBody = binary ? new Uint8Array(await ctx.response.arrayBuffer()) : await ctx.response.text(); const originalStatus = ctx.response.status; try { const { Settings, Configs } = configuration(ctx); - const context = await processingContext(ctx, url, Settings, Configs); + const context = availability ? { Settings, Configs } : await processingContext(ctx, url, Settings, Configs); const request = { url: url.toString(), method: ctx.request.method, headers: headerObject(ctx.request.headers) }; // Binary is kept in body, not bodyBytes: Response writes its patch to body. const response = { status: originalStatus, headers, body: originalBody }; diff --git a/src/wk3/process/Response.mjs b/src/wk3/process/Response.mjs index ffe85fdb..a3ed594a 100644 --- a/src/wk3/process/Response.mjs +++ b/src/wk3/process/Response.mjs @@ -11,6 +11,7 @@ import { patchWK3QWeatherAlerts } from "../class/WK3Alerts.mjs"; import { decodeWK3NextHour, encodeNextHour as encodeWK3NextHour, providerToWK3NextHour } from "../class/WK3NextHour.mjs"; import * as WK2 from "../proto/apple/wk2.js"; import database from "../function/database.mjs"; +import mergeWeatherKitAvailability from "../../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import buildSettings from "../function/buildSettings.mjs"; import shouldReplaceWeatherKit, { isHongKong } from "../function/shouldReplaceWeatherKit.mjs"; @@ -69,6 +70,17 @@ export async function Response($request, $response, context = {}) { return $response; } } + // Preserve Apple's capability list and errors; only extend a recognized array. + if (url.hostname === "weatherkit.apple.com" && /^\/api\/v[12]\/availability\//.test(url.pathname)) { + if ($response.status !== 200 || !["application/json", "text/json"].includes(FORMAT)) return $response; + try { + const original = JSON.parse($response.body); + if (!Array.isArray(original) || !original.every(item => typeof item === "string")) return $response; + const merged = mergeWeatherKitAvailability(original, Configs?.Availability?.v2); + if (JSON.stringify(merged) !== JSON.stringify(original)) $response.body = JSON.stringify(merged); + } catch { /* Unknown payloads remain unchanged. */ } + return $response; + } // 格式判断 switch (FORMAT) { case undefined: // 视为无body @@ -96,14 +108,6 @@ export async function Response($request, $response, context = {}) { case "application/json": body = JSON.parse($response.body); Console.debug(`[Apple Response] ${url.pathname}`, body); - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - if (url.pathname.startsWith("/api/v1/availability/") || url.pathname.startsWith("/api/v2/availability/")) { - body = Configs?.Availability?.v2; - } - break; - } $response.body = JSON.stringify(body); break; case "application/vnd.apple.flatbuffer": diff --git a/tests/wk3-availability.test.mjs b/tests/wk3-availability.test.mjs new file mode 100644 index 00000000..69d9c690 --- /dev/null +++ b/tests/wk3-availability.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {handleResponse} from '../src/wk3/entry/response.mjs'; +import {Response as processResponse} from '../src/wk3/process/Response.mjs'; +import buildSettings from '../src/wk3/function/buildSettings.mjs'; +import database from '../src/wk3/function/database.mjs'; + +for (const version of ['v1','v2']) { + test(`${version} availability preserves Apple capabilities and request language`,async()=>{ + const url=`https://weatherkit.apple.com/api/${version}/availability/zh-Hans-SG/41.797/123.429?country=CN`; + const capabilities=['currentWeather','uvIndex','appleFutureLocalizedProduct']; + const request={url,method:'GET',headers:{'Accept-Language':'zh-Hans-SG'}}; + const reply=await handleResponse({env:{},request,response:new Response(JSON.stringify(capabilities),{headers:{'content-type':'application/json','content-language':'zh-Hans-SG'}})}); + const out=JSON.parse(reply.body); + for(const capability of capabilities)assert.ok(out.includes(capability),capability); + assert.equal(out.filter(x=>x==='currentWeather').length,1); + assert.ok(out.includes('airQuality')); + assert.equal(reply.headers.get('content-language'),'zh-Hans-SG'); + assert.equal(request.url,url); + assert.equal(request.headers['Accept-Language'],'zh-Hans-SG'); + const {Settings,Configs}=buildSettings(database); + const processed=await processResponse(request,{status:200,headers:{'content-type':'application/json'},body:JSON.stringify(capabilities)},{Settings,Configs}); + for(const capability of capabilities)assert.ok(JSON.parse(processed.body).includes(capability),`direct core ${capability}`); + }); +} +for(const value of [{status:503,body:'{"error":"暂时不可用"}',type:'application/json'},{status:200,body:'{"language":"zh-Hans","capabilities":["uvIndex"]}',type:'application/json'},{status:200,body:'not JSON',type:'application/json'},{status:200,body:'<html>原始响应</html>',type:'text/html'}]){ + test(`availability preserves unrecognized response ${value.status}/${value.type}/${value.body}`,async()=>{ + const reply=await handleResponse({env:{},request:{url:'https://weatherkit.apple.com/api/v2/availability/zh-Hans/31/121',headers:{}},response:new Response(value.body,{status:value.status,headers:{'content-type':value.type,'content-language':'zh-Hans'}})}); + assert.equal(reply.status,value.status); + assert.equal(reply.body,value.body); + assert.equal(reply.headers.get('content-type'),value.type); + assert.equal(reply.headers.get('content-language'),'zh-Hans'); + }); +} From 825913a7ae311075b7b1eeade5c258b5a437d4e5 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:38:02 +0800 Subject: [PATCH 33/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88WK3=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E8=B7=A8=E5=B9=B3=E5=8F=B0=E4=BB=A3=E7=90=86=E8=A1=A5?= =?UTF-8?q?=E5=85=A5=E5=A4=A7=E9=99=86=E7=BC=BA=E5=A4=B1=E7=BE=8E=E6=A0=87?= =?UTF-8?q?=E7=A9=BA=E6=B0=94=E8=B4=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wk3/process/Response.mjs | 15 +++++--- tests/wk3/wk3-mainland-aq.test.mjs | 59 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 tests/wk3/wk3-mainland-aq.test.mjs diff --git a/src/wk3/process/Response.mjs b/src/wk3/process/Response.mjs index a3ed594a..f9da1169 100644 --- a/src/wk3/process/Response.mjs +++ b/src/wk3/process/Response.mjs @@ -130,7 +130,7 @@ export async function Response($request, $response, context = {}) { colorfulClouds: new ColorfulClouds({ ...parameters, country: "HK" }, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), parameters, country: "HK", }; - rawBody = await PatchWK3HongKongMissingAirQuality(rawBody, Settings, enviroments, preFetched); + rawBody = await PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched); break; } if (!shouldReplace) { @@ -265,7 +265,10 @@ function validIndex(candidate) { async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = {}) { try { const original = decodeWK3DataSet(rawBody); - if (!original) return rawBody; + if (!original) { + if (!Settings?.AirQuality?.Current?.Index?.Replace?.length) return rawBody; + return await PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched, true); + } const scaleName = AirQuality.GetNameFromScale(original.scale); const replace = Settings?.AirQuality?.Current?.Index?.Replace || []; if (!replace.includes(scaleName) || !canInjectIndex(Settings)) return rawBody; @@ -328,8 +331,10 @@ async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = { } } -async function PatchWK3HongKongMissingAirQuality(rawBody, Settings, enviroments, preFetched = {}) { +async function PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched = {}, mainland = false) { if (Settings?.AirQuality?.Current?.Index?.Provider !== "ColorfulCloudsUS") return rawBody; + const label = mainland ? "WK3 missing AQ patch" : "WK3 HK AQ patch"; + const region = mainland ? "大陆缺失" : "香港"; try { const injected = preFetched.index ? await preFetched.index : await enviroments.colorfulClouds.CurrentAirQuality(true, Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants); const replacement = await injectWK3Comparison(providerToWK3USAirQuality(injected), { @@ -340,10 +345,10 @@ async function PatchWK3HongKongMissingAirQuality(rawBody, Settings, enviroments, const builder = new flatbuffers.Builder(); builder.finish(encodeWK3AirQuality(builder, replacement)); const patched = rebuildWeatherRootWithAddedTable(rawBody, 0, builder.asUint8Array()); - Console.info("WK3 HK AQ patch", `补入香港美标 AQI ${replacement.index}; 昨日对比=${replacement.previousDayComparison},其他天气原样保留。`); + Console.info(label, `补入${region}美标 AQI ${replacement.index}; 昨日对比=${replacement.previousDayComparison},其他天气原样保留。`); return patched; } catch (error) { - Console.warn("WK3 HK AQ patch", "数据源不可用,保持原响应。", error?.message ?? error); + Console.warn(label, "数据源不可用,保持原响应。", error?.message ?? error); return rawBody; } } diff --git a/tests/wk3/wk3-mainland-aq.test.mjs b/tests/wk3/wk3-mainland-aq.test.mjs new file mode 100644 index 00000000..ad33ece7 --- /dev/null +++ b/tests/wk3/wk3-mainland-aq.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { Response as processResponse } from "../../src/wk3/process/Response.mjs"; +import { decodeWK3DataSet } from "../../src/wk3/class/WeatherKit3.mjs"; +import buildSettings from "../../src/wk3/function/buildSettings.mjs"; +import database from "../../src/wk3/function/database.mjs"; + +const provider = { metadata: { providerName: "Synthetic mainland", sourceType: "MODELED" }, scale: "EPA_NowCast.2414", index: 87, categoryIndex: 2, isSignificant: false, primaryPollutant: "PM2_5", pollutants: [{ pollutantType: "PM2_5", amount: 12, units: "MICROGRAMS_PER_CUBIC_METER" }] }; +function root() { + const b = new Builder(), text = b.createString("preserve weather, alerts and future slots"); + b.startObject(1); b.addFieldOffset(0, text, 0); const child = b.endObject(); + b.startObject(18); for (const slot of [1, 2, 3, 9, 17]) b.addFieldOffset(slot, child, 0); + b.finish(b.endObject()); return b.asUint8Array(); +} +async function run(bytes, { country = "CN", lat = 52.972, lon = 122.539, dataSets = "airQuality", env = {}, failure = false, result = provider } = {}) { + const parameters = {}; + for (const [key, value] of Object.entries({ "AirQuality.Current.Index.Provider": "ColorfulCloudsUS", "AirQuality.Comparison.Yesterday.IndexProvider": "WeatherKit", ...env })) { + const keys = key.split("."), leaf = keys.pop(); let object = parameters; + for (const part of keys) object = object[part] ??= {}; + object[leaf] = value; + } + const { Settings } = buildSettings(database, parameters); + let calls = 0; + const response = await processResponse({ url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans-SG/${lat}/${lon}?country=${country}&dataSets=${dataSets}`, headers: {} }, { status: 200, headers: { "content-type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: bytes }, { Settings, Configs: {}, enviroments: { colorfulClouds: { async CurrentAirQuality(us) { calls++; assert.equal(us, true); if (failure) throw new Error("unavailable"); return result; } } } }); + return { body: response.body, calls }; +} +test("mainland missing AQ receives verified US child and keeps original arena aligned byte-for-byte", async () => { + for (const [lat, lon] of [[52.972, 122.539], [27.705, 108.844]]) { + const bytes = root(), { body, calls } = await run(bytes, { lat, lon }), aq = decodeWK3DataSet(body); + assert.equal(calls, 1); assert.equal(aq.index, 87); assert.equal(aq.scale, "EPA_NowCast.2604"); + const a = new ByteBuffer(bytes), b = new ByteBuffer(body), ar = a.readUint32(0), br = b.readUint32(0); + let shift; + for (const slot of [1, 2, 3, 9, 17]) { + const oldTarget = a.__indirect(ar + a.__offset(ar, 4 + 2 * slot)); + const newTarget = b.__indirect(br + b.__offset(br, 4 + 2 * slot)); + shift ??= newTarget - oldTarget; assert.equal(newTarget - oldTarget, shift); + } + assert.equal(shift % 8, 0); assert.deepEqual(body.slice(shift, shift + bytes.length), bytes); + const again = await run(body, { lat, lon }); assert.equal(again.calls, 0); assert.deepEqual(again.body, body); + } +}); +test("missing AQ respects request, dataset, region, replacement and source settings without querying", async () => { + for (const options of [ + { dataSets: "currentWeather" }, { env: { DataSets: "[]" } }, + { env: { "Weather.Replace": "[]" } }, { env: { "AirQuality.Current.Index.Replace": "[]" } }, + { env: { "AirQuality.Current.Index.Provider": "WeatherKit" } }, + { env: { "AirQuality.Current.Index.Provider": "Calculate", "AirQuality.Calculate.Algorithm": "None" } }, + { country: "US", lat: 40.713, lon: -74.007 }, { country: "MO", lat: 22.19, lon: 113.54 }, + { country: "CN", lat: 25.03, lon: 121.56 }, + ]) { + const bytes = root(), output = await run(bytes, options); assert.equal(output.calls, 0); assert.deepEqual(output.body, bytes); + } +}); +test("missing AQ preserves response when provider fails or returns unavailable/incomplete data", async () => { + for (const options of [{ failure: true }, { result: null }, { result: { ...provider, metadata: { temporarilyUnavailable: true } } }, { result: { ...provider, pollutants: [] } }, { result: { ...provider, index: NaN } }]) { + const bytes = root(), output = await run(bytes, options); assert.equal(output.calls, 1); assert.deepEqual(output.body, bytes); + } +}); From dee07b4efc8b6af630c00afd70a2d1051716735f Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:41:57 +0800 Subject: [PATCH 34/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E9=A2=84?= =?UTF-8?q?=E8=AD=A6=EF=BC=89=EF=BC=9A=E5=90=8C=E6=AD=A5=E5=9B=9B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=BB=A3=E7=90=86=E5=8E=9F=E7=94=9F=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=B9=B6=E4=BF=9D=E6=8C=81=20Egern=20?= =?UTF-8?q?=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-wk3-platform.mjs | 14 ++- src/platform/wk3-alert-compat.mjs | 34 ++++++ tests/wk3-platform-alert-compat.test.mjs | 128 +++++++++++++++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/platform/wk3-alert-compat.mjs create mode 100644 tests/wk3-platform-alert-compat.test.mjs diff --git a/scripts/build-wk3-platform.mjs b/scripts/build-wk3-platform.mjs index bb6a3230..1d3edef4 100644 --- a/scripts/build-wk3-platform.mjs +++ b/scripts/build-wk3-platform.mjs @@ -23,8 +23,18 @@ function runtimePlugin() { }, load(id) { return virtual.get(id) ?? null; }, transform(code, id) { - if (!code.includes("globalThis.fetch") || id === "wk3:runtime") return null; - return { code: `import { webFetch as __wk3WebFetch } from "wk3:runtime";\n${code.replace(/\bglobalThis\.fetch\b/g, "__wk3WebFetch")}`, map: null }; + const original = code; + const alertCompat = JSON.stringify(path.join(root, "src/platform/wk3-alert-compat.mjs")); + if (id === path.join(root, "src/wk3/class/WeatherAlerts.mjs")) { + code = `import { readAlertHeader } from ${alertCompat};\n${code.replace('typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"]', 'readAlertHeader(headers, "content-type")')}`; + } + if (id === path.join(root, "src/wk3/class/NativeAlertDetails.mjs")) { + code = `import { encodeAlertBase64, decodeAlertBase64 } from ${alertCompat};\n${code.replace('btoa(String.fromCharCode(...bytes))', 'encodeAlertBase64(bytes)').replace('atob(encoded.replace', 'decodeAlertBase64(encoded.replace')}`; + } + if (code.includes("globalThis.fetch") && id !== "wk3:runtime") { + code = `import { webFetch as __wk3WebFetch } from "wk3:runtime";\n${code.replace(/\bglobalThis\.fetch\b/g, "__wk3WebFetch")}`; + } + return code === original ? null : { code, map: null }; }, }; } diff --git a/src/platform/wk3-alert-compat.mjs b/src/platform/wk3-alert-compat.mjs new file mode 100644 index 00000000..ece4ac38 --- /dev/null +++ b/src/platform/wk3-alert-compat.mjs @@ -0,0 +1,34 @@ +// Imported only by the four-platform builder; shared/Egern alerts stay native. +export function readAlertHeader(headers, name) { + if (typeof headers?.get === "function") return headers.get(name); + const key = Object.keys(headers ?? {}).find(key => key.toLowerCase() === name.toLowerCase()); + return key === undefined ? undefined : headers[key]; +} + +const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +export function encodeAlertBase64(bytes) { + let output = ""; + for (let i = 0; i < bytes.length; i += 3) { + const value = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); + output += alphabet[value >>> 18] + alphabet[(value >>> 12) & 63] + + (i + 1 < bytes.length ? alphabet[(value >>> 6) & 63] : "=") + + (i + 2 < bytes.length ? alphabet[value & 63] : "="); + } + return output; +} + +// NativeAlertDetails already validates base64url and adds standard padding. +// Its existing re-encode check still rejects noncanonical unused bits. +export function decodeAlertBase64(value) { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new TypeError("Invalid alert base64"); + let output = "", buffer = 0, bits = 0; + for (const character of value.replace(/=+$/, "")) { + buffer = (buffer << 6) | alphabet.indexOf(character); + bits += 6; + if (bits >= 8) { + bits -= 8; + output += String.fromCharCode((buffer >>> bits) & 255); + } + } + return output; +} diff --git a/tests/wk3-platform-alert-compat.test.mjs b/tests/wk3-platform-alert-compat.test.mjs new file mode 100644 index 00000000..fb4b4c35 --- /dev/null +++ b/tests/wk3-platform-alert-compat.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { Builder } from "flatbuffers"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { readAlertHeader, encodeAlertBase64, decodeAlertBase64 } from "../src/platform/wk3-alert-compat.mjs"; +import { buildNativeAlertDetailsURL } from "../src/wk3/class/NativeAlertDetails.mjs"; +import { decodeWK3Alerts } from "../src/wk3/class/WK3Alerts.mjs"; + +const identifier = "synthetic-101010100"; +const issuedTime = Date.parse("2030-01-01T00:00:00Z") / 1000; +const uuid = "00010203-0405-0607-0809-0a0b0c0d0e0f"; +const original = { id: { bytes: Array.from({ length: 16 }, (_, i) => i) }, description: "大风", issuedTime, severity: 4 }; +const html = '<div class="c-city-warning-events warning--blue"><h3>测试区气象台发布大风蓝色预警</h3><p>发布日期:2030-01-01T08:00:00+08:00</p><p class="warning-events__txt">注意防范大风。</p></div>'; +const nativeURL = () => new URL(buildNativeAlertDetailsURL({ identifier, alerts: [original] })); +const detailsURL = ids => `https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=${ids}`; + +function fixture() { + const b = new Builder(); + const details = b.createString(`https://www.qweather.com/severe-weather/${identifier}.html?from=AppleWeatherService`); + const description = b.createString(original.description); + b.startVector(1, 16, 1); + for (let i = 15; i >= 0; i--) b.addInt8(i); + const bytes = b.endVector(); + b.startObject(1); b.addFieldOffset(0, bytes, 0); const id = b.endObject(); + b.startObject(22); + b.addFieldOffset(0, id, 0); b.addFieldOffset(5, description, 0); b.addFieldOffset(12, details, 0); + b.addFieldInt32(9, issuedTime, 0); b.addFieldInt8(14, 4, 0); + const alert = b.endObject(); + b.startVector(4, 1, 4); b.addOffset(alert); const alerts = b.endVector(); + b.startObject(3); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); const collection = b.endObject(); + b.startObject(10); b.addFieldOffset(9, collection, 0); b.finish(b.endObject()); + return b.asUint8Array(); +} + +async function execute(code, platform, url, { stage = "request", header = "Content-Type", contentType = "text/html" } = {}) { + const requests = []; + let finish; + const completed = new Promise(resolve => { finish = resolve; }); + const globals = { + URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, + console: { log() {}, info() {}, debug() {}, warn() {}, error() {} }, + $argument: { DataSets: ["weatherAlerts"] }, + $request: { url, method: "GET", headers: {} }, + $response: stage === "response" ? { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: fixture() } : undefined, + $done: finish, + }; + const fetch = options => { + requests.push(options); + assert.equal(options.url, `https://www.qweather.com/severe-weather/${identifier}.html?from=AppleWeatherService`); + assert.deepEqual(Object.keys(options.headers), ["accept"]); + assert.equal(options.headers.accept, "text/html"); + return { status: 200, headers: { [header]: contentType }, body: html }; + }; + if (platform === "quantumultx") globals.$task = { fetch: async options => ({ ...fetch(options), statusCode: 200 }) }; + else globals.$httpClient = { get(options, callback) { const response = fetch(options); callback(null, response, response.body); } }; + assert.equal("btoa" in globals, false); + assert.equal("atob" in globals, false); + vm.runInNewContext(code, globals, { timeout: 30000 }); + const timer = setTimeout(() => finish({ timeout: true }), 5000); + try { + const result = await completed; + assert.ok(!result?.timeout, "platform completed"); + return { result: platform !== "quantumultx" && stage === "request" ? result?.response : result, requests }; + } finally { clearTimeout(timer); } +} + +test("platform alert helpers cover case-insensitive headers and all base64 byte/padding boundaries", () => { + for (const key of ["content-type", "Content-Type", "CONTENT-TYPE", "cOnTeNt-TyPe"]) assert.equal(readAlertHeader({ [key]: "text/html" }, "content-type"), "text/html"); + assert.equal(readAlertHeader(new Headers({ "Content-Type": "text/html" }), "content-type"), "text/html"); + assert.equal(readAlertHeader(undefined, "content-type"), undefined); + for (const length of [0, 1, 2, 3, 4, 255, 256, 257, 11998, 11999, 12000]) { + const bytes = Uint8Array.from({ length }, (_, i) => i & 255); + const encoded = encodeAlertBase64(bytes); + assert.equal(encoded, Buffer.from(bytes).toString("base64")); + assert.equal(decodeAlertBase64(encoded), Buffer.from(bytes).toString("latin1")); + } + for (const value of ["!", "a", "aa=", "====", "a==="]) assert.throws(() => decodeAlertBase64(value)); +}); + +for (const platform of ["surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: minified alerts accept plain Content-Type without host base64 and retain native UUID roundtrip`, async () => { + const bundles = await buildPlatform(platform, { outdir: process.env.WK3_TEST_OUTDIR ? `${process.env.WK3_TEST_OUTDIR}/${platform}` : undefined }); + for (const stage of ["request", "response"]) assert.doesNotMatch(bundles[stage].code, /\b(?:btoa|atob)\b/); + let url = nativeURL(); + const response = await execute(bundles.response.code, platform, "https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts", { stage: "response" }); + if (platform === "quantumultx") { + assert.equal(response.result, undefined, "existing Quantumult X binary pass-through is retained"); + assert.equal(response.requests.length, 0); + } else { + assert.equal(response.requests.length, 1); + const bytes = typeof response.result.body === "string" ? Uint8Array.from(response.result.body, c => c.charCodeAt(0)) : response.result.body; + const patched = decodeWK3Alerts(bytes); + assert.equal(patched.alerts[0].description, "大风蓝色预警"); + assert.deepEqual(patched.alerts[0].id, original.id); + url = new URL(patched.detailsUrl); + assert.equal(url.origin, "https://weatherkit.apple.com"); + assert.equal(patched.alerts[0].detailsUrl, url.toString()); + assert.equal(url.searchParams.get("ids"), new URL(buildNativeAlertDetailsURL({ identifier, alerts: [{ ...original, description: "大风蓝色预警" }] })).searchParams.get("ids")); + } + const ids = url.searchParams.get("ids"); + for (const header of ["Content-Type", "content-type", "cOnTeNt-TyPe"]) { + const { result, requests } = await execute(bundles.request.code, platform, detailsURL(ids), { header }); + assert.equal(requests.length, 1); + const details = JSON.parse(result.body); + assert.equal(details.length, 1); + assert.equal(details[0].id, uuid); + assert.equal(details[0].detailsUrl, `#${uuid}`); + assert.equal(details[0].description, "大风蓝色预警"); + assert.equal(details[0].severity, "minor"); + assert.equal(details[0].issuedTime, "2030-01-01T00:00:00.000Z"); + assert.match(details[0].messages[0].text, /注意防范/); + } + const region = await execute(bundles.request.code, platform, detailsURL(identifier)); + assert.equal(JSON.parse(region.result.body).length, 1); + const wrongType = await execute(bundles.request.code, platform, detailsURL(ids), { contentType: "application/json" }); + assert.deepEqual(JSON.parse(wrongType.result.body), []); + const payload = JSON.parse(Buffer.from(ids.slice(4), "base64url")); + const invalidShape = "wk3_" + Buffer.from(JSON.stringify({ ...payload, extra: true })).toString("base64url"); + // Valid JSON with noncanonical unused bits must still fail before HTTP. + const noncanonical = "wk3_" + Buffer.from("{}").toString("base64url").slice(0, -1) + "1"; + for (const malformed of ["wk3_!", "wk3_a", "wk3__w", ids + "=", invalidShape, noncanonical]) { + const rejected = await execute(bundles.request.code, platform, detailsURL(malformed)); + assert.deepEqual(JSON.parse(rejected.result.body), []); + assert.equal(rejected.requests.length, 0); + } + }); +} From 726ba5901dc2d10d16ff2fbf87f368a06f627226 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:41:57 +0800 Subject: [PATCH 35/42] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88WK3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=89=EF=BC=9A=E8=AE=B0=E5=BD=95=20Loon=20?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E4=B8=8E=E8=B7=A8=E5=B9=B3=E5=8F=B0=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++-- README.md | 14 ++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db2144c..f81ae653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,20 @@ -# v3.3.6 — 修复 Loon 天气二进制响应回写 +# 未发行 — WK3 测试分支同步 + +用户已确认 Loon 测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用,修复同步至 `wk3/ios27-wk3-review`。 + +- 大陆缺 AQ 自动补入接入共享核心,Egern、Surge、Loon、Stash 均使用既有编码器补入完整美标子表;遵守数据集、地区、来源和替换选项,数据源失败保持原响应,保留原预警和其他天气数据。 +- Surge、Loon、Stash、Quantumult X 构建兼容普通响应头大小写,并使用局部 Base64 编解码处理原生预警定位符;保留原 Apple 事件 UUID、严格匹配及载荷校验。Egern 预警源码和构建入口不变。 +- Quantumult X 仍保留 WK3 二进制响应直通,预警详情请求兼容修复生效;没有扩大二进制改写能力。 +- Loon 原生字节回写和 availability 合并修复同时纳入。正式版仍为 v3.3.5。 + +## Loon 原生字节回写修复 用户在 Loon 3.5.4(1015)确认脚本已有执行记录和日志,但天气 App 提示不可用。排查发现平台代理把 Loon 的二进制正文转换成字符串,再传给 `$done`;即使原样透传也会改变原生接口的正文类型。 - Loon 的 ArrayBuffer 和 TypedArray 现在按准确的字节范围转换为 `Uint8Array` 回传,不再作为文本发送;文本响应保持字符串。其他平台返回方式不变。 - 新增原生字节、带偏移视图、原样透传、真实压缩 bundle 与完整 WK3 AQ 回放测试。修复前 4/5 失败,修复后定向及相邻平台代理回归 28/28 通过。 - 修复分支 [Build 42064167010](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42064167010) 实际执行完整回归 **345/345 通过、0 跳过**,旧入口和五平台构建、产物检查及归档成功。 -- 原生 Loon 回放确认 AQI 60→87,返回的字节仍可解码;这不是手机端验收,天气 App 不可用是否完全消失仍需用户复验。 +- 原生 Loon 回放确认 AQI 60→87,返回的字节仍可解码;后续测试版 2 增加预警和大陆缺 AQ 修复,用户已实机确认该版本可用。 - 保留日志修复、23 项设置与 US 默认;不改天气算法或扩大 WK3 支持范围。 --- diff --git a/README.md b/README.md index 231c3f28..f94be26f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ 此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 -## v3.3.6 Loon 二进制回写修复 +## WK3 测试分支更新(未发行) -修正 Loon 将天气二进制转为字符串回传的问题,按官方接口保持 `Uint8Array`,文本响应不变。实际压缩脚本的 WK3 AQ 回放和字节保持测试通过;修复分支 CI 345/345 通过、0 跳过。用户反馈 Loon 3.5.4(1015)已有日志但天气不可用,本次补丁仍需设备复验,不将模拟通过视为手机已恢复。 +`wk3/ios27-wk3-review` 已合入用户实机确认可用的 Loon 测试版 2 修复:原生 `Uint8Array` 回传、保留 Apple availability、补入大陆缺失 AQ,以及预警原生详情兼容。 + +大陆缺 AQ 补入由 Egern、Surge、Loon、Stash 共用:请求和设置启用 AQ、替换范围命中大陆、今日来源为 `ColorfulCloudsUS` 且替换目标非空时,补入完整美标 AQ 子表;失败保留原响应。预警响应头大小写和局部 Base64 兼容仅接入其他四平台代理构建,Egern 预警实现保持原样。Quantumult X 保留二进制直通限制,详情请求路径包含兼容修复。正式版仍为 v3.3.5。 ## v3.3.5 日志兼容修复 @@ -21,7 +23,7 @@ ## WK3 基础能力 -- WK3 FlatBuffer 有界读写:在已验证结构内替换空气质量,保留其他数据槽与未知字段;国标 AQI 可转换为同源美标 AQI。香港在 Apple WK3 缺少 AQ 数据时,可按现有条件补入彩云美标 AQ。 +- WK3 FlatBuffer 有界读写:在已验证结构内替换空气质量,保留其他数据槽与未知字段;国标 AQI 可转换为同源美标 AQI。大陆和香港在 WK3 缺少 AQ 数据时,可按各自条件补入彩云美标 AQ。 - WK3 已实现分钟降水、同源同标准的昨日空气等级比较,以及严格匹配原事件 ID 的天气预警补全;失败或未验证结构保持 Apple 原始数据。 - 保留 WK2 的天气来源选择与天气处理;内置空气质量算法提供上游的 12 种选择,包含 InstantCast、EAQI、UBA 和 AQHI 系列。**默认 `WAQI_InstantCast_US`**,但仅在今日空气指数来源选择 `Calculate` 时用于本地计算。 - Egern 模块保留五类脚本、七条分流规则与 23 个可调参数;另外提供 Surge、Loon、Stash 和 Quantumult X 的独立模板及构建脚本。新增内容按功能分为独立提交,不压缩历史。 @@ -32,7 +34,7 @@ | --- | --- | --- | | Egern | `.yaml` | 用户已反馈 v3.3.3 正式版实机正常;v3.3.5 日志修复已完成自动测试及脚本回放,仍需设备复验。发行模块引用同版本附件。 | | Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | -| Loon | `.lpx` | 用户确认 3.5.4(1015)脚本有日志但天气不可用;v3.3.6 修复原生字节回写并通过压缩脚本回放,待设备复验。 | +| Loon | `.lpx` | 用户已确认测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用;修复已合入 WK3 测试分支,尚未正式发行。 | | Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | | Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | | Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | @@ -50,7 +52,7 @@ | 设置 | 默认值 | 生效范围 | | --- | --- | --- | -| 今日空气指数来源 | `ColorfulCloudsUS` | 彩云美标;仅匹配原始标准 `HJ6332012` 后才尝试替换。 | +| 今日空气指数来源 | `ColorfulCloudsUS` | 彩云美标;已有 AQ 仅匹配 `HJ6332012` 后替换,大陆缺 AQ 时按上述条件补入。 | | 昨日空气指数来源 | `ColorfulCloudsUS` | 与今日同来源、同标准才比较;否则保持未知。 | | 内置 AQ 计算算法 | `WAQI_InstantCast_US` | **只有来源设为 `Calculate` 才执行**;共 12 个选项含 `None`(11 种计算),WK3 目标标准不兼容时透传。 | | 分钟降水 | `ColorfulClouds` | 已验证的分钟雨结构;未知条件透传。 | @@ -64,7 +66,7 @@ ## 已知限制与发布 - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 -- Quantumult X 二进制天气不可转换;其他平台代理仅通过脚本模拟,没有客户端实机结论。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 +- Quantumult X 二进制天气不可转换;Loon 测试版 2 已有用户实机确认,Surge、Stash 的本轮修改通过压缩脚本和抓包回放。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 - 本 Fork 的 v3.3.5 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 From 9fc8d85afaf595f338db032090a77283a5c63db3 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:18:48 +0800 Subject: [PATCH 36/42] =?UTF-8?q?=E5=8F=91=E8=A1=8C=EF=BC=88v3.3.6?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E5=AE=8C=E5=96=84=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81=E6=A8=A1=E5=9D=97=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=8F=8A=20UV=20=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 48 +++++++++++++++++++++-------- README.md | 18 +++++++---- modules/iRingo.WeatherKit.WK3.yaml | 16 +++++----- src/wk3/parameters.mjs | 4 +-- template/egern.wk3.handlebars | 2 +- template/wk3.loon.handlebars | 2 +- template/wk3.quantumultx.handlebars | 2 +- template/wk3.stash.handlebars | 2 +- template/wk3.surge.handlebars | 2 +- 9 files changed, 62 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f81ae653..aad5d98b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,43 @@ -# 未发行 — WK3 测试分支同步 +# v3.3.6 — 大陆缺失空气质量补入与 Loon、预警兼容修复 -用户已确认 Loon 测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用,修复同步至 `wk3/ios27-wk3-review`。 +合入 `wk3/ios27-wk3-review` 的已验收修复,保留中文独立提交。用户已确认 Loon 测试版 2 可用,并确认 Egern 测试版的大陆缺 AQ 补入生效;Surge、Stash 本轮通过实际压缩脚本及抓包回放,尚无对应设备反馈。 -- 大陆缺 AQ 自动补入接入共享核心,Egern、Surge、Loon、Stash 均使用既有编码器补入完整美标子表;遵守数据集、地区、来源和替换选项,数据源失败保持原响应,保留原预警和其他天气数据。 -- Surge、Loon、Stash、Quantumult X 构建兼容普通响应头大小写,并使用局部 Base64 编解码处理原生预警定位符;保留原 Apple 事件 UUID、严格匹配及载荷校验。Egern 预警源码和构建入口不变。 -- Quantumult X 仍保留 WK3 二进制响应直通,预警详情请求兼容修复生效;没有扩大二进制改写能力。 -- Loon 原生字节回写和 availability 合并修复同时纳入。正式版仍为 v3.3.5。 +## 更新内容 + +- **大陆缺 AQ 自动补入:**Egern、Surge、Loon、Stash 共用已验证的美标编码器。当 WK3 原响应缺少 AQ 子表、请求和设置启用 `airQuality`、天气替换范围命中大陆、今日来源为 `ColorfulCloudsUS` 且指数替换目标非空时,补入完整美标指数、污染物、单位、来源和同源昨日等级比较。已有 AQ 继续按原始标准与替换目标处理;数据源不可用或数据不完整时保留原响应。原预警及其他天气数据保留。 +- **Loon 原生字节回写:**ArrayBuffer、TypedArray 按准确字节范围作为 `Uint8Array` 回传,避免二进制转字符串导致天气不可用;文本响应保持字符串。 +- **保留 Apple availability:**合并 Apple 原有能力并补齐模块能力,保留未知字段、错误状态及语言响应头,不再用固定列表覆盖或把错误强制改成 200。 +- **非 Egern 预警兼容:**Surge、Loon、Stash、Quantumult X 构建兼容普通响应头的大小写,预警定位符采用局部 Base64 编解码,减少对宿主 API 的依赖。保留原 Apple 事件 UUID、严格唯一匹配和载荷校验;Egern 预警实现保持原样。Quantumult X 的修复限于预警详情请求,二进制响应仍直通。 +- 保留五平台共享核心、23 项模块参数、US 默认、日志等级与脱敏;没有新增依赖或缓存层。 + +## 模块下载与升级 + +安装文件名含 `.WK3.` 的对应平台模块;同次发行的十份 JS 为模块自动加载的依赖,无需单独导入。附件中的旧上游模块不是 WK3 推荐入口。 + +| 客户端 | 本次模块 | 设置与支持范围 | +| --- | --- | --- | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.yaml) | 原生模块参数面板;大陆补 AQ 已有测试版实机确认,预警实现不变。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.sgmodule) | 模块参数;支持 WK3 AQ、分钟雨、昨日比较及匹配预警补全。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.lpx) | 插件参数面板;已验收测试版 2 的字节回写、缺 AQ 和预警修复纳入正式版。 | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本 argument 的 JSON,其他四条通过 YAML 锚点共享。 | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/iRingo.WeatherKit.WK3.snippet) | 无模块参数面板;请求/文本路径可用,WK3 二进制响应直通,不执行 AQ、分钟雨或预警摘要改写。 | + +停用测试模块或其他冲突天气模块,再安装或更新对应正式模块;不要同时启用测试版和正式版。五类脚本分别负责 availability 响应、空气标尺请求、预警网页请求、原生预警详情请求、天气响应。MITM 需启用 `weatherkit.apple.com` 并信任证书。下载后脚本在代理 App 本机执行,Apple、彩云、和风数据仍需联网。 + +默认今日/昨日指数来源为 `ColorfulCloudsUS`,指数替换目标 `HJ6332012`,内置算法 `WAQI_InstantCast_US`(仅来源选 `Calculate` 时运行);分钟雨 `ColorfulClouds`、预警 `QWeatherWeb`、天气范围 `CN`、日志 `INFO`、单次请求超时 8000ms。已有个人参数不会被强制重置。空指数替换目标禁用大陆缺 AQ 补入;选择 `WeatherKit`、算法 `None` 或非美标来源不会触发这条补入路径。 + +查看 `WeatherKit.api.weather.response` 日志可确认 AQ 执行过程;成功补入包含 `WK3 missing AQ patch`,availability 日志不代表天气补入已经运行。 + +## 已知问题与限制 -## Loon 原生字节回写修复 +- **保留 UV 英文建议问题:**部分城市紫外线卡片的防晒时段建议显示英文,例如 `Use sun protection 11:00–16:00.`,标题和等级仍为中文。本次没有修改该文案或语言设置。现有代码核查及历史成都抓包回放未发现 UV 产品被改写;客户端本地化回退是候选解释,根因尚未证实。 +- WK3 当前、逐日、逐小时天气仍保留 Apple 数据;参数中的天气来源选择不能启用这些 WK3 替换功能。分钟雨只处理已验证结构。 +- 预警仅补全可严格匹配的 Apple 原有事件,不凭空新增;第三方网页变化或失配时保留原信息。未实现上游预警 API 来源。 +- Quantumult X 二进制限制如上;Shadowrocket 暂无本机 WK3 模块。 -用户在 Loon 3.5.4(1015)确认脚本已有执行记录和日志,但天气 App 提示不可用。排查发现平台代理把 Loon 的二进制正文转换成字符串,再传给 `$done`;即使原样透传也会改变原生接口的正文类型。 +## 验证 -- Loon 的 ArrayBuffer 和 TypedArray 现在按准确的字节范围转换为 `Uint8Array` 回传,不再作为文本发送;文本响应保持字符串。其他平台返回方式不变。 -- 新增原生字节、带偏移视图、原样透传、真实压缩 bundle 与完整 WK3 AQ 回放测试。修复前 4/5 失败,修复后定向及相邻平台代理回归 28/28 通过。 -- 修复分支 [Build 42064167010](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42064167010) 实际执行完整回归 **345/345 通过、0 跳过**,旧入口和五平台构建、产物检查及归档成功。 -- 原生 Loon 回放确认 AQI 60→87,返回的字节仍可解码;后续测试版 2 增加预警和大陆缺 AQ 修复,用户已实机确认该版本可用。 -- 保留日志修复、23 项设置与 US 默认;不改天气算法或扩大 WK3 支持范围。 +测试分支 [Build 42123745628](https://github.com/patrickyanxxxxx/WeatherKit-WK3/actions/runs/42123745628) 完整回归 **359/359 通过、0 失败、0 跳过**,旧入口及五平台构建、模块生成和归档成功。四款支持二进制改写的平台均完成真实缺 AQ 抓包与模拟数据源回放,原天气 arena 和预警保留;回放中的 AQI 87 为模拟值,不代表城市实时读数。标签发布另行运行完整回归与构建。 --- diff --git a/README.md b/README.md index f94be26f..986990ee 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ 此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 -## WK3 测试分支更新(未发行) +## v3.3.6 更新 -`wk3/ios27-wk3-review` 已合入用户实机确认可用的 Loon 测试版 2 修复:原生 `Uint8Array` 回传、保留 Apple availability、补入大陆缺失 AQ,以及预警原生详情兼容。 +合入 `wk3/ios27-wk3-review` 的 Loon 原生 `Uint8Array` 回传、Apple availability 保留、大陆缺 AQ 补入及非 Egern 预警兼容修复。Loon 测试版 2 已获用户实机确认可用;Egern 测试版的大陆缺 AQ 补入也已确认生效。 -大陆缺 AQ 补入由 Egern、Surge、Loon、Stash 共用:请求和设置启用 AQ、替换范围命中大陆、今日来源为 `ColorfulCloudsUS` 且替换目标非空时,补入完整美标 AQ 子表;失败保留原响应。预警响应头大小写和局部 Base64 兼容仅接入其他四平台代理构建,Egern 预警实现保持原样。Quantumult X 保留二进制直通限制,详情请求路径包含兼容修复。正式版仍为 v3.3.5。 +大陆缺 AQ 补入由 Egern、Surge、Loon、Stash 共用:请求和设置启用 AQ、替换范围命中大陆、今日来源为 `ColorfulCloudsUS` 且替换目标非空时,补入完整美标 AQ 子表;失败保留原响应。普通响应头大小写和局部 Base64 预警兼容仅接入其他四平台代理构建,Egern 预警实现保持原样。Quantumult X 保留二进制直通限制,详情请求路径包含兼容修复。 + +**保留已知问题:**部分城市 UV 面板的防晒时段建议仍可能显示英文,标题和等级正常;根因尚未证实,本次不修改语言设置或建议文案。完整更新、安装方法与默认参数见 [CHANGELOG](CHANGELOG.md)。 ## v3.3.5 日志兼容修复 @@ -32,13 +34,17 @@ | 软件 | 模块格式 | 当前验证范围 | | --- | --- | --- | -| Egern | `.yaml` | 用户已反馈 v3.3.3 正式版实机正常;v3.3.5 日志修复已完成自动测试及脚本回放,仍需设备复验。发行模块引用同版本附件。 | +| Egern | `.yaml` | 用户已确认 Egern 测试版大陆缺 AQ 补入生效;正式模块引用同版本附件,预警实现保持原样。 | | Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | -| Loon | `.lpx` | 用户已确认测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用;修复已合入 WK3 测试分支,尚未正式发行。 | +| Loon | `.lpx` | 用户已确认测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用;对应修复已纳入 v3.3.6。 | | Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | | Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | | Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | +正式模块:[Egern](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) · [Surge](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) · [Loon](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) · [Stash](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) · [Quantumult X](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet)。 + +升级时停用测试版及其他冲突天气模块,更新整个正式模块,使五条脚本都引用 v3.3.6;保留并核对个人参数。MITM 需启用 `weatherkit.apple.com` 并信任证书。成功补入 AQ 的日志位于 `WeatherKit.api.weather.response`,包含 `WK3 missing AQ patch`。 + 五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。请使用发行附件中的模块;五平台脚本不能互换。 ## 代码组织 @@ -67,7 +73,7 @@ - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 - Quantumult X 二进制天气不可转换;Loon 测试版 2 已有用户实机确认,Surge、Stash 的本轮修改通过压缩脚本和抓包回放。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- 本 Fork 的 v3.3.5 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 +- 本 Fork 的 v3.3.6 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 验证命令:`npm run test:wk3`(包含旧入口、算法边界、配置、模块、五平台日志与发行构建回归,串行避免本机并发打包争用)。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml index cd646e7d..7cf36bb6 100644 --- a/modules/iRingo.WeatherKit.WK3.yaml +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -1,5 +1,5 @@ name: 'WeatherKit-WK3' -description: '本机 WK3:美标 AQ、分钟雨、昨日对比、原生预警' +description: '本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比、原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文' author: WeatherKit-WK3 contributors homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png @@ -87,8 +87,8 @@ rules: match: tether.edge.apple policy: REJECT-DROP compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_US","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} -compat_arguments_desc: "DataSets: [数据集]\n 选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。\nWeather.Provider: [天气] 数据源\n 使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。\nNextHour.Provider: [未来一小时降水强度] 数据源\n 使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标\n 替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物\n 忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标\n 转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式\n 污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换\n 即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法\n 共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标\n 允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌\n 彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。\nAPI.QWeather.Host: [API] 和风天气主机\n 和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。\nAPI.QWeather.Token: [API] 和风天气令牌\n 和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" -env_schema: {"DataSets":{"name":"[数据集]","description":"选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。","default_value":"QWeatherWeb","options":["WeatherKit","QWeatherWeb"]},"NextHour.Provider":{"name":"[未来一小时降水强度] 数据源","description":"使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换目标","description":"替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制主要污染物","description":"忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[今日污染物 - 单位转换] 替换目标","description":"转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[今日污染物 - 单位转换] 模式","description":"污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[空气质量 - 对比昨日] 变化时替换","description":"即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[iRingo内置算法] 算法","description":"共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。","default_value":"WAQI_InstantCast_US","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[iRingo内置算法] 允许指数超标","description":"允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云天气令牌","description":"彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风天气主机","description":"和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风天气令牌","description":"和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} +compat_arguments_desc: "DataSets: [数据集]\n 选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。\nWeather.Provider: [天气] 数据源\n 使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。\nNextHour.Provider: [未来一小时降水强度] 数据源\n 使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;大陆缺 AQ 时仅 ColorfulCloudsUS 且替换目标非空可补入完整美标,失败保留原响应;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标\n 替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012;大陆缺 AQ 补入也要求此项非空。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物\n 忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标\n 转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式\n 污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换\n 即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法\n 共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标\n 允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌\n 彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。\nAPI.QWeather.Host: [API] 和风天气主机\n 和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。\nAPI.QWeather.Token: [API] 和风天气令牌\n 和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" +env_schema: {"DataSets":{"name":"[数据集]","description":"选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。","default_value":"QWeatherWeb","options":["WeatherKit","QWeatherWeb"]},"NextHour.Provider":{"name":"[未来一小时降水强度] 数据源","description":"使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;大陆缺 AQ 时仅 ColorfulCloudsUS 且替换目标非空可补入完整美标,失败保留原响应;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换目标","description":"替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012;大陆缺 AQ 补入也要求此项非空。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制主要污染物","description":"忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[今日污染物 - 单位转换] 替换目标","description":"转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[今日污染物 - 单位转换] 模式","description":"污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[空气质量 - 对比昨日] 变化时替换","description":"即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[iRingo内置算法] 算法","description":"共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。","default_value":"WAQI_InstantCast_US","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[iRingo内置算法] 允许指数超标","description":"允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云天气令牌","description":"彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风天气主机","description":"和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风天气令牌","description":"和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} scriptings: - http_response: name: WeatherKit.api.availability.response @@ -117,7 +117,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/availability/' - script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-response.js?v=upstream-groups-1 + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-response.bundle.js timeout: 30 body_required: true binary_body: false @@ -150,7 +150,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/airQualityScale/' - script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-request.bundle.js timeout: 30 body_required: false binary_body: false @@ -182,7 +182,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' - script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-request.bundle.js timeout: 30 body_required: false binary_body: false @@ -214,7 +214,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)' - script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-request.js?v=upstream-groups-1 + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-request.bundle.js timeout: 30 body_required: false binary_body: false @@ -246,7 +246,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[23]/weather/' - script_url: https://gist.githubusercontent.com/patrickyanxxxxx/9125b747b26480a0c9c37ae1c446c39f/raw/weatherkit-local-response.js?v=upstream-groups-1 + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-response.bundle.js timeout: 30 body_required: true binary_body: true diff --git a/src/wk3/parameters.mjs b/src/wk3/parameters.mjs index ddf22f6e..5f34908e 100644 --- a/src/wk3/parameters.mjs +++ b/src/wk3/parameters.mjs @@ -9,8 +9,8 @@ export const localParameters = [ { key: "Weather.ReplaceHourly", type: "boolean", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐小时预报保持苹果数据。" }, { key: "WeatherAlerts.Provider", type: "string", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["WeatherKit", "QWeatherWeb"], optionLabels: {"WeatherKit": "WeatherKit(不修改)", "QWeatherWeb": "和风天气网页"}, description: "QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。" }, { key: "NextHour.Provider", type: "string", name: "[未来一小时降水强度] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不添加)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。" }, - { key: "AirQuality.Current.Index.Provider", type: "string", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;其余跨标准保留苹果数据。" }, - { key: "AirQuality.Current.Index.Replace", type: "array", name: "[今日空气指数] 替换目标", value: "HJ6332012", choices: ["HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", "ES.MITECO", "SG.NEA", "NAQI", "DAQI"], description: "替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012。WK3 仅支持同标准或国标→美标。" }, + { key: "AirQuality.Current.Index.Provider", type: "string", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;大陆缺 AQ 时仅 ColorfulCloudsUS 且替换目标非空可补入完整美标,失败保留原响应;其余跨标准保留苹果数据。" }, + { key: "AirQuality.Current.Index.Replace", type: "array", name: "[今日空气指数] 替换目标", value: "HJ6332012", choices: ["HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", "ES.MITECO", "SG.NEA", "NAQI", "DAQI"], description: "替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012;大陆缺 AQ 补入也要求此项非空。WK3 仅支持同标准或国标→美标。" }, { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", type: "boolean", name: "[今日空气指数] 强制主要污染物", value: "true", boolean: true, description: "忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。" }, { key: "AirQuality.Current.Pollutants.Provider", type: "string", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], optionLabels: {"ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。" }, { key: "AirQuality.Current.Pollutants.Units.Replace", type: "array", name: "[今日污染物 - 单位转换] 替换目标", value: "[]", choices: ["EPA_NowCast", "EU.EAQI", "HJ6332012", "UBA"], description: "转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。" }, diff --git a/template/egern.wk3.handlebars b/template/egern.wk3.handlebars index 63b7f8ef..9da615a6 100644 --- a/template/egern.wk3.handlebars +++ b/template/egern.wk3.handlebars @@ -1,5 +1,5 @@ name: 'WeatherKit-WK3' -description: '本机 WK3:美标 AQ、分钟雨、昨日对比、原生预警' +description: '本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比、原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文' author: WeatherKit-WK3 contributors homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png diff --git a/template/wk3.loon.handlebars b/template/wk3.loon.handlebars index 40a9c289..664357d6 100644 --- a/template/wk3.loon.handlebars +++ b/template/wk3.loon.handlebars @@ -1,5 +1,5 @@ #!name = WeatherKit-Local-WK3 -#!desc = 本机 WK3:空气质量算法可选、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +#!desc = 本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比与苹果原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文 #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ diff --git a/template/wk3.quantumultx.handlebars b/template/wk3.quantumultx.handlebars index ab1a26aa..c156fe59 100644 --- a/template/wk3.quantumultx.handlebars +++ b/template/wk3.quantumultx.handlebars @@ -1,5 +1,5 @@ #!name = WeatherKit-Local-WK3 -#!desc = 本机 WK3:空气质量算法依运行时默认值、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +#!desc = WK3 请求与文本处理、原生预警详情兼容;二进制天气直通,不改写 AQ、分钟雨或预警摘要;当前/逐日/逐小时天气保持苹果原始数据;无参数面板,部分城市 UV 防晒建议英文问题保留 #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ diff --git a/template/wk3.stash.handlebars b/template/wk3.stash.handlebars index 01ac581b..fa2db98b 100644 --- a/template/wk3.stash.handlebars +++ b/template/wk3.stash.handlebars @@ -1,5 +1,5 @@ name: WeatherKit-Local-WK3 -desc: 本机 WK3:空气质量算法可选、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +desc: 本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比与苹果原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文 homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png version: "__WK3_VERSION__" diff --git a/template/wk3.surge.handlebars b/template/wk3.surge.handlebars index dca3388d..43c2500c 100644 --- a/template/wk3.surge.handlebars +++ b/template/wk3.surge.handlebars @@ -1,5 +1,5 @@ #!name = WeatherKit-Local-WK3 -#!desc = 本机 WK3:空气质量算法可选、分钟降水与苹果原生预警;WK3 当前/逐日/逐小时天气保持苹果原始数据 +#!desc = 本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比与苹果原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文 #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ From 5049e3c70d0c62c19f98d3c1fc1caa6df6708514 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:17:09 +0800 Subject: [PATCH 37/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88Surge=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E5=8E=9F=E7=94=9F=E5=AD=97=E8=8A=82=E5=9B=9E=E4=BC=A0?= =?UTF-8?q?=20WK3=20=E5=93=8D=E5=BA=94=E5=B9=B6=E6=A0=A1=E9=AA=8C=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E4=BA=8C=E8=BF=9B=E5=88=B6=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/platform/wk3-adapter.mjs | 4 ++-- tests/wk3-platform-adapter.test.mjs | 10 ++++++---- tests/wk3-surge-binary.test.mjs | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 tests/wk3-surge-binary.test.mjs diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs index 1d10898a..09a3b1ed 100644 --- a/src/platform/wk3-adapter.mjs +++ b/src/platform/wk3-adapter.mjs @@ -101,8 +101,8 @@ export async function runPlatform(handler, stage, globals = globalThis, platform const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : null; if (!bytes) return body; - // Loon binary-body-mode consumes Uint8Array; a string is treated as text. - return platform === "loon" ? bytes : binaryStringFromBytes(bytes); + // Surge and Loon binary-body-mode consume Uint8Array; strings are text. + return platform === "surge" || platform === "loon" ? bytes : binaryStringFromBytes(bytes); }; const response = { status: platform === "Quantumult X" ? `HTTP/1.1 ${result.status ?? 200} ${result.statusText ?? "OK"}` : result.status ?? 200, diff --git a/tests/wk3-platform-adapter.test.mjs b/tests/wk3-platform-adapter.test.mjs index e8f9f469..0495a7a2 100644 --- a/tests/wk3-platform-adapter.test.mjs +++ b/tests/wk3-platform-adapter.test.mjs @@ -40,7 +40,7 @@ for (const platform of platforms) { assert.equal(result.status, 201); assert.equal(result.headers["X-Patched"], "yes"); assert.ok(!Object.keys(result.headers).some(k => ["content-length", "content-encoding", "transfer-encoding"].includes(k.toLowerCase()))); - if (platform === "loon") { + if (platform === "surge" || platform === "loon") { assert.ok(result.body instanceof Uint8Array); assert.deepEqual(Array.from(result.body), bytes); } else assert.deepEqual(Array.from(result.body, c => c.charCodeAt(0)), bytes); @@ -64,7 +64,8 @@ test("binary bodyBytes takes precedence over an empty text body", async () => { assert.deepEqual(Array.from(new Uint8Array(await ctx.response.arrayBuffer())), bytes); return { status: 200, headers: {}, body: new Uint8Array(bytes) }; }, "response", globals, "surge"); - assert.deepEqual(Array.from(returned.body, c => c.charCodeAt(0)), bytes); + assert.ok(returned.body instanceof Uint8Array); + assert.deepEqual(Array.from(returned.body), bytes); }); test("all verified platforms produce executable request and response bundles", async () => { @@ -93,7 +94,7 @@ test("Surge bundle patches WK3 AQ using platform HTTP callbacks", async () => { builder.startObject(15); builder.addFieldOffset(0, aq, 0); builder.finish(builder.endObject()); const original = builder.asUint8Array(); const bundles = await buildPlatform("surge", { minify: false }); - const calls = [], requests = [], payload = String.fromCharCode(...original); + const calls = [], requests = [], payload = original; const globals = { URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Response, Headers, console, $argument: 'AirQuality.Current.Index.Provider="ColorfulCloudsUS"&AirQuality.Current.Index.Replace="HJ6332012"&AirQuality.Comparison.Yesterday.IndexProvider="WeatherKit"&DataSets="airQuality"', @@ -111,7 +112,8 @@ test("Surge bundle patches WK3 AQ using platform HTTP callbacks", async () => { assert.equal(calls.length, 1); assert.ok(requests.some(url => url.includes("/realtime")), `No provider request: ${JSON.stringify(requests)}`); const result = calls[0]; - assert.equal(decodeWK3DataSet(Uint8Array.from(result.body, char => char.charCodeAt(0))).index, 87); + assert.ok(result.body instanceof Uint8Array); + assert.equal(decodeWK3DataSet(result.body).index, 87); }); test("unknown platform is refused instead of advertised as supported", async () => { diff --git a/tests/wk3-surge-binary.test.mjs b/tests/wk3-surge-binary.test.mjs new file mode 100644 index 00000000..7780a524 --- /dev/null +++ b/tests/wk3-surge-binary.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runPlatform } from '../src/platform/wk3-adapter.mjs'; + +const bytes = new Uint8Array([0,1,127,128,195,255]); +for (const stage of ['request','response']) test(`Surge ${stage}: binary body is a native Uint8Array with exact view bounds`, async () => { + for (const body of [bytes, bytes.buffer, new Uint8Array([99,...bytes,99]).subarray(1,-1)]) { + let output; + await runPlatform(async () => ({ status:200, headers:{'Content-Type':'application/vnd.apple.flatbuffer'}, body }), stage, { + $request:{url:'https://weatherkit.apple.com/api/v3/weather/zh/31/121'}, + $response:{status:200,headers:{},body:bytes}, + $done:value=>{ output=stage==='request'?value.response:value; }, + }, 'surge'); + assert.ok(output.body instanceof Uint8Array, 'Surge binary-body-mode must receive native bytes, not UTF-8 text'); + assert.deepEqual([...output.body],[...bytes]); + } +}); +test('Surge text responses remain strings', async () => { + let result; + await runPlatform(async()=>({status:200,headers:{},body:'天气测试'}),'response',{$response:{},$done:value=>{result=value;}},'surge'); + assert.equal(result.body,'天气测试'); +}); From e7939000a4c9e2bb5fa449365119ae4b71859f09 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:17:09 +0800 Subject: [PATCH 38/42] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88v3.3.7?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E8=AE=B0=E5=BD=95=20Surge=20=E5=AE=9E?= =?UTF-8?q?=E6=9C=BA=E4=BF=AE=E5=A4=8D=E4=B8=8E=E6=AD=A3=E5=BC=8F=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8D=87=E7=BA=A7=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ README.md | 16 ++++++++++++---- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad5d98b..631b95c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +# v3.3.7 — 修复 Surge WK3 二进制回传与天气增强无效果 + +修复 Surge 普通模块在已有脚本执行记录时仍无法正常显示天气增强数据的问题。用户已确认 `3.3.6-test-surge-binary.1` 测试版通过,本版纳入同一项原生字节回传修复。 + +## 修复内容 + +- **Surge 使用原生二进制回传:**启用 `binary-body-mode` 时,将 ArrayBuffer、Uint8Array 及其他 TypedArray 的准确字节范围作为 `Uint8Array` 返回给 Surge。避免原先转换成字符串后,被宿主按 UTF-8 再编码而损坏 WK3 FlatBuffer。 +- **保留原天气数据:**修复覆盖需要改写的 AQ、分钟雨与预警响应,也覆盖海外等应保留原数据的响应。文本结果继续使用字符串;Loon、Stash、Egern、Quantumult X 的现有输出方式保持不变。 +- **补充宿主接口回归:**检查高位字节、请求/响应输出、子数组边界和文本透传;纠正旧测试先将字符串按字符码恢复再解码、因而未覆盖宿主 UTF-8 编码行为的问题。 +- 23 项模块参数、默认来源、空气质量算法及原有回退处理保持不变。 + +## 实机验证 + +用户确认 Surge 测试版通过。真实抓包中五份完整 WK3 响应均可直接正常解码,不再出现二进制转字符串后的 UTF-8 二次编码;四份国内/香港响应的彩云美标 AQI 与同批数据源一致,三份大陆响应已写入彩云分钟雨;另有匹配预警的标题、发布机构及 Apple 原生详情入口。海外响应保留原来源。 + +本轮分钟雨样本均为零降水,预警详情正文未在抓包中点击验证;这些具体边界不扩展为逐项界面验收。抓包及个人信息不纳入发行附件。 + +## 下载与升级 + +| 客户端 | 正式模块 | +| --- | --- | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.sgmodule) | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.yaml) | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.lpx) | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.stoverride) | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.7/iRingo.WeatherKit.WK3.snippet) | + +Surge 用户先停用 `3.3.6-test-surge-binary.1` 或旧普通模块,安装/更新整个正式模块,确认五类脚本均引用 v3.3.7,再核对已保存的个人参数。MITM 需启用 `weatherkit.apple.com` 并信任证书。独立雷达模块可继续启用;请勿同时启用两个普通天气模块。 + +本次沿用五平台发行构建,模块自动加载各自同版本的 JS,无需手动导入脚本,也不能跨平台交换脚本。 + +## 保留的限制 + +- WK3 当前、逐日及逐小时天气继续保留 Apple 数据。 +- Quantumult X 的 WK3 二进制天气响应继续直通,Shadowrocket 暂无本机 WK3 模块。 +- 预警仅补全可严格匹配的 Apple 原事件;第三方数据源失败或结构不兼容时保留原数据。 +- 部分城市 UV 防晒时段建议可能显示英文,既有问题本次未修改。 +- 本次发行只包含普通天气模块的 Surge 修复,实验雷达继续在独立分支维护。 + +--- + # v3.3.6 — 大陆缺失空气质量补入与 Loon、预警兼容修复 合入 `wk3/ios27-wk3-review` 的已验收修复,保留中文独立提交。用户已确认 Loon 测试版 2 可用,并确认 Egern 测试版的大陆缺 AQ 补入生效;Surge、Stash 本轮通过实际压缩脚本及抓包回放,尚无对应设备反馈。 diff --git a/README.md b/README.md index 986990ee..f2de84ca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ 此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 +## v3.3.7 — Surge 二进制响应修复 + +修复 Surge 普通 WK3 模块“有脚本执行记录但天气增强无效果”的二进制回传缺陷:启用 `binary-body-mode` 后,天气响应使用原生 `Uint8Array` 回传,避免字符串被再次按 UTF-8 编码而损坏 FlatBuffer。此问题也会影响原本应保留的海外天气数据。 + +用户已确认 `3.3.6-test-surge-binary.1` 测试版通过。真实 Surge 抓包中,五份完整天气响应均可正常解码,国内/香港彩云美标 AQ、大陆分钟雨与预警改写有实际响应证据。文本响应及 Loon/Stash/Egern/Quantumult X 的现有输出方式保持不变,23 项参数与默认设置不变。 + +升级时停用 Surge 二进制修复测试模块,再安装对应正式模块并核对个人参数;独立雷达模块可保留。完整说明见 [CHANGELOG](CHANGELOG.md)。 + ## v3.3.6 更新 合入 `wk3/ios27-wk3-review` 的 Loon 原生 `Uint8Array` 回传、Apple availability 保留、大陆缺 AQ 补入及非 Egern 预警兼容修复。Loon 测试版 2 已获用户实机确认可用;Egern 测试版的大陆缺 AQ 补入也已确认生效。 @@ -35,7 +43,7 @@ | 软件 | 模块格式 | 当前验证范围 | | --- | --- | --- | | Egern | `.yaml` | 用户已确认 Egern 测试版大陆缺 AQ 补入生效;正式模块引用同版本附件,预警实现保持原样。 | -| Surge | `.sgmodule` | 独立 JS 构建、参数传递与合成 WK3 AQ 响应端到端回放通过(AQI 60→87);未设备验证。 | +| Surge | `.sgmodule` | 用户已确认二进制修复测试版通过;真实抓包验证 AQ、分钟雨及预警改写,v3.3.7 纳入原生字节回传修复。 | | Loon | `.lpx` | 用户已确认测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用;对应修复已纳入 v3.3.6。 | | Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | | Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | @@ -43,7 +51,7 @@ 正式模块:[Egern](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) · [Surge](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) · [Loon](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) · [Stash](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) · [Quantumult X](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet)。 -升级时停用测试版及其他冲突天气模块,更新整个正式模块,使五条脚本都引用 v3.3.6;保留并核对个人参数。MITM 需启用 `weatherkit.apple.com` 并信任证书。成功补入 AQ 的日志位于 `WeatherKit.api.weather.response`,包含 `WK3 missing AQ patch`。 +升级时停用测试版及其他冲突天气模块,更新整个正式模块,使五条脚本都引用 v3.3.7;保留并核对个人参数。MITM 需启用 `weatherkit.apple.com` 并信任证书。成功补入 AQ 的日志位于 `WeatherKit.api.weather.response`,包含 `WK3 missing AQ patch`。 五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。请使用发行附件中的模块;五平台脚本不能互换。 @@ -72,8 +80,8 @@ ## 已知限制与发布 - WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 -- Quantumult X 二进制天气不可转换;Loon 测试版 2 已有用户实机确认,Surge、Stash 的本轮修改通过压缩脚本和抓包回放。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- 本 Fork 的 v3.3.6 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 +- Quantumult X 二进制天气不可转换;Surge 二进制修复测试版、Loon 测试版 2 已有用户实机确认,Stash 目前通过压缩脚本和抓包回放。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 +- 本 Fork 的 v3.3.7 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 - 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 验证命令:`npm run test:wk3`(包含旧入口、算法边界、配置、模块、五平台日志与发行构建回归,串行避免本机并发打包争用)。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 From f938114370bc692bf78f4ca2fc4486cd32ebb924 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:33:53 +0800 Subject: [PATCH 39/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E5=8F=91?= =?UTF-8?q?=E8=A1=8C=E8=AF=B4=E6=98=8E=EF=BC=89=EF=BC=9A=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E4=BB=85=E6=8F=90=E5=8F=96=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/draft.yml | 11 ++++++++++- .github/workflows/pre-release.yml | 11 ++++++++++- .github/workflows/release.yml | 11 ++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/draft.yml b/.github/workflows/draft.yml index 0e27ed41..0d65a877 100644 --- a/.github/workflows/draft.yml +++ b/.github/workflows/draft.yml @@ -22,10 +22,19 @@ jobs: uses: actions/download-artifact@master with: name: artifact + - name: Extract current release notes + shell: python + run: | + import pathlib, re + text = pathlib.Path("CHANGELOG.md").read_text() + section = re.split(r"(?m)^# v(?=\d)", text, maxsplit=2) + notes = "# v" + section[1] if len(section) > 1 else text + notes = re.sub(r"\n---\s*\Z", "", notes).strip() + "\n" + pathlib.Path("RELEASE-NOTES.md").write_text(notes) - name: Publish Draft uses: softprops/action-gh-release@v2 with: - body_path: CHANGELOG.md + body_path: RELEASE-NOTES.md token: ${{ secrets.GITHUB_TOKEN }} files: | rulesets/* diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 1cdaa89c..4a4beb20 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -24,10 +24,19 @@ jobs: uses: actions/download-artifact@master with: name: artifact + - name: Extract current release notes + shell: python + run: | + import pathlib, re + text = pathlib.Path("CHANGELOG.md").read_text() + section = re.split(r"(?m)^# v(?=\d)", text, maxsplit=2) + notes = "# v" + section[1] if len(section) > 1 else text + notes = re.sub(r"\n---\s*\Z", "", notes).strip() + "\n" + pathlib.Path("RELEASE-NOTES.md").write_text(notes) - name: Publish Pre-Release uses: softprops/action-gh-release@v2 with: - body_path: CHANGELOG.md + body_path: RELEASE-NOTES.md token: ${{ secrets.GITHUB_TOKEN }} files: | rulesets/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29ca44d3..f6b44f3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,19 @@ jobs: uses: actions/download-artifact@master with: name: artifact + - name: Extract current release notes + shell: python + run: | + import pathlib, re + text = pathlib.Path("CHANGELOG.md").read_text() + section = re.split(r"(?m)^# v(?=\d)", text, maxsplit=2) + notes = "# v" + section[1] if len(section) > 1 else text + notes = re.sub(r"\n---\s*\Z", "", notes).strip() + "\n" + pathlib.Path("RELEASE-NOTES.md").write_text(notes) - name: Publish Release uses: softprops/action-gh-release@v2 with: - body_path: CHANGELOG.md + body_path: RELEASE-NOTES.md token: ${{ secrets.GITHUB_TOKEN }} files: | rulesets/* From f5320b320018baabc1b0563d9b93e0535fe92443 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:08:55 +0800 Subject: [PATCH 40/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88WK3=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E9=80=9A=E5=85=A8=E7=AE=97=E6=B3=95AQ?= =?UTF-8?q?=E3=80=81=E9=9B=A8=E9=9B=AA=E5=88=86=E9=92=9F=E4=B8=8E=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build-egern-module.mjs | 5 +- src/platform/wk3-adapter.mjs | 2 + src/wk3/class/AirQuality.mjs | 31 ++- src/wk3/class/AirQualityScale.mjs | 218 +++++++++++++---- src/wk3/class/ColorfulClouds.mjs | 2 +- src/wk3/class/ForecastNextHour.mjs | 5 +- src/wk3/class/NativeAlertDetails.mjs | 20 +- src/wk3/class/QWeather.mjs | 16 +- src/wk3/class/WK3Alerts.mjs | 76 ++++-- src/wk3/class/WK3Comparison.mjs | 75 ++++-- src/wk3/class/WK3NextHour.mjs | 38 ++- src/wk3/class/WeatherKit3.mjs | 139 ++++++++--- src/wk3/entry/index.mjs | 5 + src/wk3/entry/request.mjs | 18 +- src/wk3/option-values.mjs | 27 +++ src/wk3/parameters.mjs | 54 ++--- src/wk3/process/Response.mjs | 226 +++++++++++------- template/egern.wk3.handlebars | 2 +- template/wk3.loon.handlebars | 2 +- template/wk3.quantumultx.handlebars | 2 +- template/wk3.stash.handlebars | 2 +- template/wk3.surge.handlebars | 2 +- tests/wk3-alert-normalization.test.mjs | 186 ++++++++++++++ tests/wk3-loon-binary.test.mjs | 2 +- tests/wk3-module-parameters.test.mjs | 16 +- tests/wk3-modules.test.mjs | 12 +- tests/wk3/aq-algorithm-scales.test.mjs | 150 ++++++++++++ tests/wk3/aq-all-algorithms.test.mjs | 219 +++++++++++++++++ tests/wk3/aq-settings-semantics.test.mjs | 3 +- .../wk3/eaqi-presentation-comparison.test.mjs | 24 ++ tests/wk3/eaqi-upstream-resource.test.mjs | 36 +++ tests/wk3/egern-parameters.test.mjs | 7 +- tests/wk3/native-alert-details.test.mjs | 19 +- tests/wk3/wk3-alerts.test.mjs | 3 +- tests/wk3/wk3-aq-all-conversions.test.mjs | 96 ++++++++ tests/wk3/wk3-aq-conversion.test.mjs | 108 +++++++++ tests/wk3/wk3-comparison.test.mjs | 169 +++++++++++++ tests/wk3/wk3-hk-aq.test.mjs | 2 +- tests/wk3/wk3-mainland-aq.test.mjs | 5 +- tests/wk3/wk3-minutely-provider.test.mjs | 173 ++++++++++++++ tests/wk3/wk3-minutely-source.test.mjs | 38 +++ tests/wk3/wk3-next-hour.test.mjs | 63 ++++- tests/wk3/wk3-upstream-aq-routing.test.mjs | 34 +++ 43 files changed, 2034 insertions(+), 298 deletions(-) create mode 100644 src/wk3/option-values.mjs create mode 100644 tests/wk3-alert-normalization.test.mjs create mode 100644 tests/wk3/aq-algorithm-scales.test.mjs create mode 100644 tests/wk3/aq-all-algorithms.test.mjs create mode 100644 tests/wk3/eaqi-presentation-comparison.test.mjs create mode 100644 tests/wk3/eaqi-upstream-resource.test.mjs create mode 100644 tests/wk3/wk3-aq-all-conversions.test.mjs create mode 100644 tests/wk3/wk3-aq-conversion.test.mjs create mode 100644 tests/wk3/wk3-minutely-provider.test.mjs create mode 100644 tests/wk3/wk3-minutely-source.test.mjs create mode 100644 tests/wk3/wk3-upstream-aq-routing.test.mjs diff --git a/scripts/build-egern-module.mjs b/scripts/build-egern-module.mjs index b4f33766..be78b5a0 100644 --- a/scripts/build-egern-module.mjs +++ b/scripts/build-egern-module.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {localParameters,parameterDescription} from '../src/wk3/parameters.mjs'; import {groups} from '../src/wk3/routes.mjs'; +import {displayModuleOption} from '../src/wk3/option-values.mjs'; import {release} from './build-wk3-modules.mjs'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); export const localScriptGroups = groups.map(g=>({ @@ -13,8 +14,8 @@ export const localScriptGroups = groups.map(g=>({ export function renderLocalModule({version} = {}){ const template=fs.readFileSync(path.join(root,'template/egern.wk3.handlebars'),'utf8'); const q=JSON.stringify; - const compat=Object.fromEntries(localParameters.map(p=>[p.key,p.value])); - const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:parameterDescription(p),default_value:p.value,...(p.boolean?{options:['true','false']}:p.options?{options:p.options}:{})}])); + const compat=Object.fromEntries(localParameters.map(p=>[p.key,displayModuleOption(p.key,p.value)])); + const schema=Object.fromEntries(localParameters.map(p=>[p.key,{name:p.name,description:parameterDescription(p),default_value:displayModuleOption(p.key,p.value),...(p.boolean?{options:['true','false']}:p.options?{options:p.options.map(value=>displayModuleOption(p.key,value))}:{})}])); const description=localParameters.map(p=>`${p.key}: ${p.name}\n ${parameterDescription(p)}`).join('\n'); // JSON objects are valid YAML. Quote individual substitution values using // YAML single quotes: literal double quotes in array syntax remain safe. diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs index 09a3b1ed..4212802d 100644 --- a/src/platform/wk3-adapter.mjs +++ b/src/platform/wk3-adapter.mjs @@ -97,6 +97,8 @@ export async function runPlatform(handler, stage, globals = globalThis, platform catch (error) { (globals.console ?? console).error(error); result = undefined; } if (typeof done !== "function") return result; if (result === undefined || result === null) return done(); + // Request URL rewrites are not synthetic HTTP responses. + if (stage === "request" && typeof result.url === "string") return done(result); const bodyToPlatform = body => { const bytes = body instanceof ArrayBuffer ? new Uint8Array(body) : ArrayBuffer.isView(body) ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : null; diff --git a/src/wk3/class/AirQuality.mjs b/src/wk3/class/AirQuality.mjs index 48bdb2ab..ddf9a0e1 100644 --- a/src/wk3/class/AirQuality.mjs +++ b/src/wk3/class/AirQuality.mjs @@ -259,6 +259,8 @@ export default class AirQuality { return scales.CA_AQHI; case scales.HK_AQHI.weatherKitScale.name: return scales.HK_AQHI; + case scales.CN_DEATH_AQHI.weatherKitScale.name: + return scales.CN_DEATH_AQHI; default: return scales[scaleName]; } @@ -321,6 +323,8 @@ export default class AirQuality { const pollutants = injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable ? injectedPollutants.pollutants : airQuality?.pollutants; Console.debug("✅ ConvertPollutants"); + if (unitsMode === "None") return pollutants; + if (replaceUnits.includes(scaleName)) { if (isIndexInjected && Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && unitsMode === "Scale") { Console.info("ConvertPollutants", `Use pollutants from iRingo`); @@ -389,10 +393,10 @@ export default class AirQuality { Console.debug("✅ chooseAlogrithm", `algorithm: WAQI_InstantCast_US`); return "WAQI_InstantCast_US"; } else { - const supportedScales = [scales.EU_EAQI.weatherKitScale.name, scales.UBA.weatherKitScale.name]; - if (supportedScales.includes(currentScale)) { - Console.debug("✅ chooseAlogrithm", `algorithm: ${currentScale}`); - return currentScale; + const supportedScales = { [scales.EU_EAQI.weatherKitScale.name]: "EU_EAQI", [scales.UBA.weatherKitScale.name]: "UBA" }; + if (Object.hasOwn(supportedScales, currentScale)) { + Console.debug("✅ chooseAlogrithm", `algorithm: ${supportedScales[currentScale]}`); + return supportedScales[currentScale]; } Console.error("chooseAlogrithm", "没有找到合适的内置算法"); @@ -444,6 +448,24 @@ export default class AirQuality { case "WAQI_InstantCast_CN_25_DRAFT": scaleDesc = " (InstantCast with HJ 633 2025 DRAFT)"; break; + case "CN_DEATH_AQHI": + scaleDesc = " (China CDC Death Risk AQHI)"; + break; + case "CA_AQHI": + scaleDesc = " (InstantCast with CA AQHI)"; + break; + case "HK_AQHI": + scaleDesc = " (InstantCast with HK AQHI)"; + break; + case "CN_DEATH_HK_AQHI": + scaleDesc = " (China CDC Death Risk + HK AQHI)"; + break; + case "AQHI_Multi_CN": + scaleDesc = " (AQHI-Multi for CN)"; + break; + case "AQHI_Multi_CN_HK": + scaleDesc = " (AQHI-Multi for CN+HK)"; + break; case "UBA": default: scaleDesc = " (FB001846)"; @@ -972,7 +994,6 @@ export default class AirQuality { EU_EAQI: { weatherKitScale: { name: "EU.EAQI", - version: "2414", maxIndex: 60, }, // Indexes below for calculation only, not for display diff --git a/src/wk3/class/AirQualityScale.mjs b/src/wk3/class/AirQualityScale.mjs index 2ccd8f41..1e9affef 100644 --- a/src/wk3/class/AirQualityScale.mjs +++ b/src/wk3/class/AirQualityScale.mjs @@ -6,8 +6,9 @@ * 支持的标尺: * - HJ6332012.* — 中国国标 AQI * - EPA_NowCast.* — 美国 EPA AQI - * - HK.AQHI.* — 香港 AQHI - * - CN.AQHI.* — 中国 AQHI + * - HK.AQHI — 香港 AQHI(可带数字版本) + * - CA.AQHI — 加拿大 AQHI(可带数字版本) + * - CN.AQHI — 中国 AQHI(可带数字版本) * - EU.EAQI.* — 欧洲 EAQI * - UBA.* — 德国 LQI * @@ -284,12 +285,12 @@ const EPA_NOWCAST = { }, }; -// ─── EU EAQI 欧洲 ────────────────────────────────────────────────────────────── +// ─── EU EAQI 欧盟 ────────────────────────────────────────────────────────────── const EU_EAQI = { displayName: { - "zh-Hans-CN": "欧洲空气质量指数", - "zh-Hant-HK": "歐洲空氣質量指數", + "zh-Hans-CN": "欧盟空气质量指数", + "zh-Hant-HK": "歐盟空氣質量指數", "en-US": "European Air Quality Index", }, shortDisplayName: { @@ -298,19 +299,21 @@ const EU_EAQI = { "en-US": "EAQI", }, longDisplayName: { - "zh-Hans-CN": "欧洲 (EAQI)", - "zh-Hant-HK": "歐洲 (EAQI)", - "en-US": "Europe (EAQI)", + "zh-Hans-CN": "欧盟 (EAQI)", + "zh-Hant-HK": "歐盟 (EAQI)", + "en-US": "European Union (EAQI)", }, + // Display indexes match AirQuality.Config.Scales.EU_EAQI (2024/17), + // including the sixth category. They are not a generic 0..100 scale. categories: [ { - range: [0, 20], + range: [0, 9], glyph: "aqi.low", colors: ["#1E88E5"], categoryName: { - "zh-Hans-CN": "非常好", - "zh-Hant-HK": "非常好", - "en-US": "Very Good", + "zh-Hans-CN": "好", + "zh-Hant-HK": "好", + "en-US": "Good", }, recommendation: { "zh-Hans-CN": "享受户外活动。", @@ -319,13 +322,13 @@ const EU_EAQI = { }, }, { - range: [21, 40], + range: [10, 19], glyph: "aqi.low", colors: ["#00C853"], categoryName: { - "zh-Hans-CN": "好", - "zh-Hant-HK": "好", - "en-US": "Good", + "zh-Hans-CN": "尚可", + "zh-Hant-HK": "尚可", + "en-US": "Fair", }, recommendation: { "zh-Hans-CN": "享受户外活动。", @@ -334,7 +337,7 @@ const EU_EAQI = { }, }, { - range: [41, 60], + range: [20, 29], glyph: "aqi.medium", colors: ["#FFEB3B"], categoryName: { @@ -349,7 +352,7 @@ const EU_EAQI = { }, }, { - range: [61, 80], + range: [30, 39], glyph: "aqi.high", colors: ["#FF9800"], categoryName: { @@ -364,7 +367,7 @@ const EU_EAQI = { }, }, { - range: [81, 100], + range: [40, 49], glyph: "aqi.high", colors: ["#F44336"], categoryName: { @@ -378,19 +381,35 @@ const EU_EAQI = { "en-US": "Everyone should reduce outdoor exertion.", }, }, + { + range: [50, 60], + glyph: "aqi.high", + colors: ["#7E0023"], + categoryName: { + "zh-Hans-CN": "极差", + "zh-Hant-HK": "極差", + "en-US": "Extremely Poor", + }, + recommendation: { + "zh-Hans-CN": "所有人应减少户外体力消耗,敏感人群应避免户外体力消耗。", + "zh-Hant-HK": "所有人應減少戶外體力消耗,敏感人群應避免戶外體力消耗。", + "en-US": "Everyone should reduce outdoor physical exertion. Sensitive groups should avoid outdoor physical exertion.", + }, + }, ], gradient: { - stops: [ - { location: 0, color: "#1E88E5" }, - { location: 20, color: "#1E88E5" }, - { location: 21, color: "#00C853" }, - { location: 40, color: "#00C853" }, - { location: 41, color: "#FFEB3B" }, - { location: 60, color: "#FFEB3B" }, - { location: 61, color: "#FF9800" }, - { location: 80, color: "#FF9800" }, - { location: 81, color: "#F44336" }, - ], + // Dense interpolated stops avoid category-wide plateaus even when a + // renderer draws the supplied colors as short discrete segments. + stops: Array.from({ length: 121 }, (_, i) => { + const colors = ["1E88E5", "00C853", "FFEB3B", "FF9800", "F44336", "7E0023"]; + const position = i / 24, band = Math.min(4, Math.floor(position)), fraction = position - band; + const channels = [0, 2, 4].map(offset => { + const a = Number.parseInt(colors[band].slice(offset, offset + 2), 16); + const b = Number.parseInt(colors[band + 1].slice(offset, offset + 2), 16); + return Math.round(a + (b - a) * fraction).toString(16).padStart(2, "0"); + }); + return { location: i / 2, color: `#${channels.join("")}`.toUpperCase() }; + }), }, }; @@ -491,11 +510,11 @@ const UBA = { ], gradient: { stops: [ - { location: 0, color: "#00C853" }, - { location: 1, color: "#64DD17" }, - { location: 2, color: "#FFEB3B" }, - { location: 3, color: "#FF9800" }, - { location: 4, color: "#F44336" }, + { location: 1, color: "#00C853" }, + { location: 2, color: "#64DD17" }, + { location: 3, color: "#FFEB3B" }, + { location: 4, color: "#FF9800" }, + { location: 5, color: "#F44336" }, ], }, }; @@ -613,6 +632,100 @@ const HK_AQHI = { }, }; +// ─── CA AQHI ─────────────────────────────────────────────────────────────────── + +/** Canada AQHI health-risk bands: 1–3 low, 4–6 moderate, 7–10 high, 10+ very high. + * Source: Health Canada, Air Quality Health Index categories and health messages: + * https://www.canada.ca/en/environment-climate-change/services/air-quality-health-index/understanding-messages.html + * AirQuality.Config.Scales.CA_AQHI encodes 10+ as category/index 11. The two + * AQHI_Multi_CN algorithms also emit CA.AQHI upstream, sharing this display + * scale; the index provider's metadata continues to identify their algorithm. + * These are display-index ranges, not the internal excess-risk calculation. + */ +const CA_AQHI = { + displayName: { + "zh-Hans-CN": "空气质量健康指数", + "zh-Hant-HK": "空氣質素健康指數", + "en-US": "Air Quality Health Index", + }, + shortDisplayName: { + "zh-Hans-CN": "AQHI", + "zh-Hant-HK": "AQHI", + "en-US": "AQHI", + }, + longDisplayName: { + "zh-Hans-CN": "加拿大 (AQHI)", + "zh-Hant-HK": "加拿大 (AQHI)", + "en-US": "Canada (AQHI)", + }, + categories: [ + { + range: [1, 3], + glyph: "aqi.low", + colors: ["#00CCFF", "#0099CC", "#006699"], + categoryName: { + "zh-Hans-CN": "低健康风险", + "zh-Hant-HK": "低健康風險", + "en-US": "Low Health Risk", + }, + recommendation: { + "zh-Hans-CN": "适宜进行户外活动。", + "zh-Hant-HK": "適宜進行戶外活動。", + "en-US": "Enjoy your usual outdoor activities.", + }, + }, + { + range: [4, 6], + glyph: "aqi.medium", + colors: ["#FFFF00", "#FFCC00", "#FF9900"], + categoryName: { + "zh-Hans-CN": "中等健康风险", + "zh-Hant-HK": "中等健康風險", + "en-US": "Moderate Health Risk", + }, + recommendation: { + "zh-Hans-CN": "敏感人群如出现症状,应考虑减少或调整剧烈户外活动。一般人群如无症状,可如常活动。", + "zh-Hant-HK": "敏感人群如出現症狀,應考慮減少或調整劇烈戶外活動。一般人群如無症狀,可如常活動。", + "en-US": "At-risk people should consider reducing or rescheduling strenuous outdoor activities if experiencing symptoms. Others may continue usual activities unless experiencing symptoms.", + }, + }, + { + range: [7, 10], + glyph: "aqi.high", + colors: ["#FF6666", "#FF0000", "#CC0000", "#990000"], + categoryName: { + "zh-Hans-CN": "高健康风险", + "zh-Hant-HK": "高健康風險", + "en-US": "High Health Risk", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应减少或调整剧烈户外活动,儿童及长者也应适当减少活动。一般人群如出现症状,应考虑减少剧烈户外活动。", + "zh-Hant-HK": "敏感人群應減少或調整劇烈戶外活動,兒童及長者也應適當減少活動。一般人群如出現症狀,應考慮減少劇烈戶外活動。", + "en-US": "At-risk people should reduce or reschedule strenuous outdoor activities; children and seniors should also take it easy. Others should consider reducing strenuous outdoor activities if experiencing symptoms.", + }, + }, + { + range: [11, 11], + glyph: "aqi.high", + colors: ["#660000"], + categoryName: { + "zh-Hans-CN": "极高健康风险", + "zh-Hant-HK": "極高健康風險", + "en-US": "Very High Health Risk", + }, + recommendation: { + "zh-Hans-CN": "敏感人群应避免剧烈户外活动,儿童及长者也应避免户外体力消耗。一般人群应减少或调整剧烈户外活动。", + "zh-Hant-HK": "敏感人群應避免劇烈戶外活動,兒童及長者也應避免戶外體力消耗。一般人群應減少或調整劇烈戶外活動。", + "en-US": "At-risk people should avoid strenuous outdoor activities; children and seniors should also avoid outdoor physical exertion. Others should reduce or reschedule strenuous outdoor activities.", + }, + }, + ], + gradient: { + stops: ["#00CCFF", "#0099CC", "#006699", "#FFFF00", "#FFCC00", "#FF9900", "#FF6666", "#FF0000", "#CC0000", "#990000", "#660000"] + .map((color, index) => ({ location: index + 1, color })), + }, +}; + // ─── CN AQHI ─────────────────────────────────────────────────────────────────── const CN_AQHI = { @@ -684,7 +797,7 @@ const CN_AQHI = { * @returns {string} */ function i18n(map, lang) { - return map[lang] ?? map["en-US"] ?? ""; + return map[lang] ?? (lang === "zh-Hant-TW" ? map["zh-Hant-HK"] : undefined) ?? map["en-US"] ?? ""; } /** @@ -700,12 +813,11 @@ function normalizeScaleLanguage(language) { } function parseScaleIdentity(scaleName) { - const [name, version] = `${scaleName || ""}`.split("."); - const parsedVersion = Number.parseInt(version, 10); - return { - name: name || scaleName, - version: Number.isFinite(parsedVersion) ? parsedVersion : 1, - }; + // Only a trailing numeric component is a version. AQHI and EAQI are part + // of the standard's name, including when the algorithm emits no version. + const value = `${scaleName || ""}`; + const match = /^(.*)\.(\d+)$/.exec(value); + return { name: match ? match[1] : value, version: match ? Number(match[2]) : 1 }; } // ─── AirQualityScale 类 ──────────────────────────────────────────────────────── @@ -732,7 +844,7 @@ export default class AirQualityScale { static buildScale(language, scaleName) { Console.debug("☑️ AirQualityScale.buildScale", `language: ${language}`, `scaleName: ${scaleName}`); - const scaleBaseName = scaleName?.split(".")?.[0]; + const scaleBaseName = parseScaleIdentity(scaleName).name; Console.debug(`scaleBaseName: ${scaleBaseName}`); let result = null; @@ -744,17 +856,20 @@ export default class AirQualityScale { case "EPA_NowCast": result = AirQualityScale.#buildGenericScale(language, scaleName, EPA_NOWCAST); break; - case "EU": + case "EU.EAQI": case "EAQI": result = AirQualityScale.#buildGenericScale(language, scaleName, EU_EAQI); break; case "UBA": result = AirQualityScale.#buildGenericScale(language, scaleName, UBA); break; - case "HK": + case "HK.AQHI": result = AirQualityScale.#buildAQHIScale(language, scaleName, HK_AQHI); break; - case "CN": + case "CA.AQHI": + result = AirQualityScale.#buildAQHIScale(language, scaleName, CA_AQHI); + break; + case "CN.AQHI": result = AirQualityScale.#buildAQHIScale(language, scaleName, CN_AQHI); break; default: @@ -785,7 +900,10 @@ export default class AirQualityScale { // 对于小数范围(UBA 0-0.99, 1-1.99 等),也每个 category 一个条目 categories.push({ categoryNumber: categories.length + 1, - range: [min, max === Number.POSITIVE_INFINITY ? 9999 : max], + // UBA's public index is the category (1..5), while its + // pollutant interpolation uses the internal 0..4 ranges. + range: scaleDef === UBA ? [categories.length + 1, categories.length + 1] + : [min, max === Number.POSITIVE_INFINITY ? 9999 : max], color: band.colors[0], categoryName: i18n(band.categoryName, normalizeLang), recommendation: i18n(band.recommendation, normalizeLang), @@ -802,9 +920,9 @@ export default class AirQualityScale { language: normalizeScaleLanguage(language), version: scaleIdentity.version, aqi: { - numerical: true, + numerical: scaleDef !== EU_EAQI, ascending: true, - range: scaleDef === UBA ? [0, 4] : scaleDef === EU_EAQI ? [0, 100] : [0, 500], + range: scaleDef === UBA ? [1, 5] : scaleDef === EU_EAQI ? [0, 60] : [0, 500], categories, gradient: scaleDef.gradient, }, @@ -821,7 +939,7 @@ export default class AirQualityScale { } /** - * 构建 AQHI 标尺(HK、CN)。 + * 构建 AQHI 标尺(HK、CA、CN)。 * @param {string} language * @param {string} scaleName * @param {object} scaleDef - AQHI 标尺定义常量 diff --git a/src/wk3/class/ColorfulClouds.mjs b/src/wk3/class/ColorfulClouds.mjs index e439a2d0..b100db7d 100644 --- a/src/wk3/class/ColorfulClouds.mjs +++ b/src/wk3/class/ColorfulClouds.mjs @@ -159,7 +159,7 @@ export default class ColorfulClouds { const timeStamp = (Date.now() / 1000) | 0; const metadata = { attributionUrl: "https://www.caiyunapp.com/h5", - expireTime: timeStamp + 60 * 60, + expireTime: timeStamp + ForecastNextHour.ExpirationInterval, language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, latitude: body?.location?.[0], longitude: body?.location?.[1], diff --git a/src/wk3/class/ForecastNextHour.mjs b/src/wk3/class/ForecastNextHour.mjs index 59f746ae..4a4ad76f 100644 --- a/src/wk3/class/ForecastNextHour.mjs +++ b/src/wk3/class/ForecastNextHour.mjs @@ -5,6 +5,9 @@ export default class ForecastNextHour { Version = "v1.6.3"; Author = "iRingo"; + // Match upstream dev b4a75db: next-hour products expire after ten minutes. + static ExpirationInterval = 10 * 60; + static #Configs = { Pollutants: { co: "CO", @@ -291,7 +294,7 @@ export default class ForecastNextHour { if (summaries.length > 4) { summaries = summaries.slice(0, 4); if (summaries[3]) { - summaries[3].endTime = 0; + summaries[3] = { ...summaries[3], endTime: 0 }; } } // 先通过 summaries 定基调 diff --git a/src/wk3/class/NativeAlertDetails.mjs b/src/wk3/class/NativeAlertDetails.mjs index 5c9a4a46..18234d44 100644 --- a/src/wk3/class/NativeAlertDetails.mjs +++ b/src/wk3/class/NativeAlertDetails.mjs @@ -79,11 +79,17 @@ function titleParts(value) { if (typeof value !== "string") return null; const text = value.normalize("NFKC").trim().toLowerCase().replace(/\s+/g, "").replace(/[。.]+$/, ""); const match = text.match(/^(.+?)(?:(蓝|黄|橙|红)色)?预警(?:信号)?$/); - return match ? { text: match[1], color: match[2] } : { text, color: undefined }; + // Some official event names already end in “预警”, before the colored + // warning suffix (e.g. 高速公路灾害性天气预警红色预警信号). + return match ? { text: match[1].replace(/(?:预警(?:信号)?)+$/, ""), color: match[2] } : { text, color: undefined }; } -function sameTitle(original, source) { +/** Only the generic Apple title observed in the device diagnostic. */ +export function isGenericAlertTitle(value) { + return typeof value === "string" && value.normalize("NFKC").replace(/\s+/g, "").replace(/[。.]+$/, "") === "恶劣天气"; +} +export function sameAlertTitle(original, source) { const a = titleParts(original), b = titleParts(source); - return a && b && a.text === b.text && (!a.color || !b.color || a.color === b.color); + return Boolean(a?.text && b?.text && a.text === b.text && (!a.color || !b.color || a.color === b.color)); } /** Returns only the requested original events, in their original order. Source @@ -94,15 +100,15 @@ function sameTitle(original, source) { export async function resolveNativeAlertDetails(ids, language = "zh-CN", fetchImpl = globalThis.fetch) { const payload = parseIDs(ids); languageTag(language); - // Paired Apple JSON / WK3: Shanghai MINOR=4; Benxi/Shenyang MODERATE=3. - // Other integers still have no verified mapping and are not guessed. - const severities = { 3: "moderate", 4: "minor" }; + // MINOR/MODERATE are paired locally; EXTREME/SEVERE are explicitly + // defined by upstream @nsringo/weatherkit 2.0.5 (dev b4a75db). + const severities = { 1: "extreme", 2: "severe", 3: "moderate", 4: "minor" }; if (payload.alerts.some(alert => !Object.hasOwn(severities, alert.severity))) throw new TypeError("Unverified WK3 severity"); const sources = await WeatherAlerts.DetailsForQWeather(payload.region, language, fetchImpl); if (!Array.isArray(sources)) throw new TypeError("Invalid QWeather details"); const matches = payload.alerts.map(alert => { const candidates = sources.filter(source => source && source.severity === severities[alert.severity] - && sameTitle(alert.description, source.description) + && sameAlertTitle(alert.description, source.description) && typeof source.issuedTime === "string" && Date.parse(source.issuedTime) === alert.issuedTime * 1000); if (candidates.length !== 1) throw new Error("Missing or ambiguous native alert match"); return candidates[0]; diff --git a/src/wk3/class/QWeather.mjs b/src/wk3/class/QWeather.mjs index bf174ad8..60a31439 100644 --- a/src/wk3/class/QWeather.mjs +++ b/src/wk3/class/QWeather.mjs @@ -282,15 +282,15 @@ export default class QWeather { switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; - let minuteStemp = new Date(body?.updateTime).setSeconds(0, 0); - minuteStemp = minuteStemp.valueOf() / 1000; + // updateTime is publication time; fxTime defines the forecast grid. + const minuteStemp = new Date(body?.minutely?.[0]?.fxTime).valueOf() / 1000; forecastNextHour = { metadata: { attributionUrl: body?.fxLink, - expireTime: timeStamp + 60 * 60, + expireTime: timeStamp + ForecastNextHour.ExpirationInterval, language: "zh-CN", // `${this.language}-${this.country}`, // body?.lang, - latitude: this.latitude, - longitude: this.longitude, + latitude: Number(this.latitude), + longitude: Number(this.longitude), providerName: "和风天气", readTime: timeStamp, @@ -603,13 +603,13 @@ export default class QWeather { } #Metadata(attributionUrl = `https://www.qweather.com/`, sourceType = "MODELED", temporarilyUnavailable = false) { - const timeStamp = Date.now() / 1000; + const timeStamp = Math.trunc(Date.now() / 1000); return { - longitude: this.longitude, + longitude: Number(this.longitude), providerName: "和风天气", reportedTime: timeStamp, - latitude: this.latitude, + latitude: Number(this.latitude), expireTime: timeStamp + 60 * 60, attributionUrl, temporarilyUnavailable, diff --git a/src/wk3/class/WK3Alerts.mjs b/src/wk3/class/WK3Alerts.mjs index 4ceda1cc..2da3bbce 100644 --- a/src/wk3/class/WK3Alerts.mjs +++ b/src/wk3/class/WK3Alerts.mjs @@ -1,16 +1,18 @@ import { Builder, ByteBuffer } from "flatbuffers"; import WeatherAlerts from "./WeatherAlerts.mjs"; -import { buildNativeAlertDetailsURL } from "./NativeAlertDetails.mjs"; +import { buildNativeAlertDetailsURL, sameAlertTitle, isGenericAlertTitle } from "./NativeAlertDetails.mjs"; /** Verified against paired Apple v3 JSON / WK3 b66 responses (2026-11-04): * Shanghai wind alert and Miami Coastal Flood Statement. NOT a WK2 codec. * Root slot 9; collection: metadata=0, detailsUrl=1, alerts=2. * Area-name slot 2 and slots >=22 of summaries remain UNVERIFIED. - * Only observed enum tokens are translated. Unknown raw integers round-trip. + * Severity EXTREME/SEVERE follows upstream @nsringo/weatherkit 2.0.5, + * dev b4a75db / published dev bundle 2026-11-07 19:05:32. + * Other enums remain observed-only. Unknown raw integers round-trip. */ export const WK3_ALERTS_ROOT_SLOT = 9; export const WK3_ALERT_ENUMS = { - severity: { UNKNOWN: 0, MODERATE: 3, MINOR: 4 }, + severity: { UNKNOWN: 0, EXTREME: 1, SEVERE: 2, MODERATE: 3, MINOR: 4 }, significance: { UNKNOWN: 0, STATEMENT: 10 }, urgency: { UNKNOWN: 0, EXPECTED: 2 }, certainty: { UNKNOWN: 0, LIKELY: 2 }, @@ -197,43 +199,75 @@ export const wk3AlertsCodec = { * Details link is opt-in: enable ONLY after routing /api/v1/weatherAlerts region * identifiers to WeatherAlerts.DetailsForQWeather(). Otherwise keep QWeather URL. */ -export async function patchWK3QWeatherAlerts(bytes, { language = "zh-CN", timezone, fetchImpl = globalThis.fetch, rewriteDetails = false } = {}) { +export async function patchWK3QWeatherAlerts(bytes, { language = "zh-CN", timezone, fetchImpl = globalThis.fetch, rewriteDetails = false, report = {} } = {}) { const raw = input(bytes); + Object.assign(report, { total: 0, matched: 0, summariesUpdated: 0, detailsConverted: false, reason: "no-alerts", unmatched: {}, titleMismatches: [] }); + let stage = "decode-failed"; try { const original = decodeWK3Alerts(raw); if (!original?.alerts.length) return raw; + report.total = original.alerts.length; + report.reason = "unsupported-page-url"; const identifier = WeatherAlerts.ParseWeatherAlertPageURL(original.detailsUrl); if (!identifier) return raw; + stage = "page-fetch-failed"; const extracted = await WeatherAlerts.FetchWeatherAlertPage(identifier, language, fetchImpl); + report.reason = "empty-page"; if (!extracted.alerts.length) return raw; + stage = "page-build-failed"; const context = { identifier, language, countryCode: "CN", eventSource: "CN", attributionUrl: original.detailsUrl }; const sources = WeatherAlerts.Build(extracted, context), used = new Set(); + const unmatched = reason => { report.unmatched[reason] = (report.unmatched[reason] || 0) + 1; return null; }; const matched = original.alerts.map(alert => { - // Never map solely by index: require title/phenomenon, same observed - // severity and a compatible issue time; ambiguity leaves it intact. - const candidates = sources.map((source, index) => ({ source, index })).filter(({ source, index }) => { - if (used.has(index) || !alert.description || !source.description.includes(alert.description)) return false; - const severity = WK3_ALERT_ENUMS.severity[String(source.severity).toUpperCase()]; - if (severity === undefined || severity !== alert.severity) return false; - const issued = Date.parse(source.issuedTime) / 1000; - return !alert.issuedTime || issued === alert.issuedTime; - }); - if (candidates.length !== 1) return null; + // An observed generic Apple title carries no event name. It may + // resolve only to a named, unique source with exact severity/time; + // never use array order or discard used candidates to force a match. + const generic = isGenericAlertTitle(alert.description); + if (generic && !alert.issuedTime) return unmatched("missing-issued-time"); + let candidates = sources.map((source, index) => ({ source, index })) + .filter(({ source }) => generic + ? typeof source.description === "string" && source.description.trim() && !isGenericAlertTitle(source.description) + : sameAlertTitle(alert.description, source.description)); + if (!candidates.length) { + // Public warning titles only: no URL, UUID, coordinates or credentials. + const title = value => typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 320) : null; + if (report.titleMismatches.length < 4) report.titleMismatches.push({ + appleTitle: title(alert.description), + pageTitles: extracted.alerts.slice(0, 8).map(source => title(source.description)), + parsedTitles: sources.slice(0, 8).map(source => title(source.description)), + }); + return unmatched("title-mismatch"); + } + if (![WK3_ALERT_ENUMS.severity.EXTREME, WK3_ALERT_ENUMS.severity.SEVERE, WK3_ALERT_ENUMS.severity.MODERATE, WK3_ALERT_ENUMS.severity.MINOR].includes(alert.severity)) return unmatched("unverified-severity"); + candidates = candidates.filter(({ source }) => WK3_ALERT_ENUMS.severity[String(source.severity).toUpperCase()] === alert.severity); + if (!candidates.length) return unmatched("severity-mismatch"); + candidates = candidates.filter(({ source }) => !alert.issuedTime || Date.parse(source.issuedTime) / 1000 === alert.issuedTime); + if (!candidates.length) return unmatched("issued-time-mismatch"); + if (candidates.length !== 1 || used.has(candidates[0].index)) return unmatched("ambiguous-match"); used.add(candidates[0].index); return candidates[0].source; }); - if (!matched.some(Boolean)) return raw; + report.matched = matched.filter(Boolean).length; + report.reason = "unmatched-alerts"; + if (!report.matched) return raw; let alerts = enrichWK3Alerts(original, matched); // Source publication platform is not the issuer. With an exact match, // prefer the observed page's actual issuing meteorological office. alerts = alerts.map((alert, i) => matched[i] && /^(国家预警信息发布中心|QWeather|和风天气)$/.test(alert.source ?? "") ? { ...alert, source: matched[i].source } : alert); + const summariesUpdated = alerts.filter((alert, i) => JSON.stringify(alert) !== JSON.stringify(original.alerts[i])).length; const options = {}; - if (rewriteDetails) { - // Do not send unrelated/generated IDs to the native renderer. All - // events must have exact matches before replacing the shared URL. - if (matched.some(source => !source)) return patchWK3Alerts(raw, alerts); + let detailsConverted = false; + if (rewriteDetails && report.matched === report.total) { + // All events must match before replacing the shared URL. + stage = "details-build-failed"; const url = buildNativeAlertDetailsURL({ identifier, language, timezone, alerts }); alerts = alerts.map(alert => ({ ...alert, detailsUrl: url })); options.detailsUrl = url; + detailsConverted = true; } - return patchWK3Alerts(raw, alerts, options); - } catch { return raw; } + stage = "summary-patch-failed"; + const patched = patchWK3Alerts(raw, alerts, options); + // Publish successful counts only after the atomic patch completes. + Object.assign(report, { summariesUpdated, detailsConverted, + reason: detailsConverted ? "converted" : rewriteDetails ? "unmatched-alerts" : "details-disabled" }); + return patched; + } catch { report.reason = stage; return raw; } } diff --git a/src/wk3/class/WK3Comparison.mjs b/src/wk3/class/WK3Comparison.mjs index 2ce5705a..6f567ab8 100644 --- a/src/wk3/class/WK3Comparison.mjs +++ b/src/wk3/class/WK3Comparison.mjs @@ -6,28 +6,67 @@ */ export const WK3_COMPARISON = { UNKNOWN: 0, WORSE: 2, SAME: 3, BETTER: 4 }; +// Menu standards with ascending category numbers. Versions are deliberately +// allowlisted: a future version may change category meanings. Only the observed +// CN/US pair is compatible across versions; other standards require exact scale. +const MENU_STANDARDS = new Set([ + "HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", + "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", + "ES.MITECO", "SG.NEA", "NAQI", "DAQI", "HK.AQHI", "CN.AQHI", +]); +const ALGORITHM_STANDARDS = new Map([ + ["UBA", "UBA"], ["EU_EAQI", "EU.EAQI"], + ["WAQI_InstantCast_US", "EPA_NowCast"], ["WAQI_InstantCast_CN", "HJ6332012"], + ["WAQI_InstantCast_CN_25_DRAFT", "HJ6332012"], + ["CA_AQHI", "CA.AQHI"], ["HK_AQHI", "HK.AQHI"], + ["CN_DEATH_AQHI", "CN.AQHI"], ["CN_DEATH_HK_AQHI", "CN.AQHI"], + ["AQHI_Multi_CN", "CA.AQHI"], ["AQHI_Multi_CN_HK", "CA.AQHI"], +]); function standard(scale) { - // Both observed versions have the same six category boundaries. This is - // category compatibility, NOT equivalence of concentration/AQI algorithms. - if (/^EPA_NowCast\.(2414|2604)$/.test(scale)) return "US"; - if (/^HJ6332012\.(2414|2604)$/.test(scale)) return "CN"; - return null; + if (typeof scale !== "string") return null; + // These algorithms intentionally emit versionless names upstream. + if (["EU.EAQI", "HK.AQHI", "CA.AQHI", "CN.AQHI"].includes(scale)) return scale; + const match = /^(.*)\.(2414|2604)$/.exec(scale); + return match && MENU_STANDARDS.has(match[1]) ? match[1] : null; } -function category(index) { - return index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; +function compatibleScales(current, yesterday) { + const name = standard(current); + return name && name === standard(yesterday) + && (current === yesterday || ["HJ6332012", "EPA_NowCast"].includes(name)); } -function validAQ(data) { - if (!data?.metadata || data.metadata.temporarilyUnavailable || !standard(data.scale)) return false; - if (!Number.isInteger(data.categoryIndex) || data.categoryIndex < 1 || data.categoryIndex > 6) return false; - // A category alone is sufficient; if AQI is supplied it must agree. - return data.index === undefined || (Number.isInteger(data.index) && data.index >= 0 && data.index <= 500 && category(data.index) === data.categoryIndex); +function validAQ(data, calculated = false) { + const name = standard(data?.scale); + if (!data?.metadata || data.metadata.temporarilyUnavailable || !name) return false; + const six = ["HJ6332012", "EPA_NowCast"].includes(name); + // For menu standards without local category tables, trust the source's + // category number (as upstream does), bounded by WK3's signed int8 field. + const maxCategory = six || name === "EU.EAQI" ? 6 : name === "UBA" ? 5 : name.endsWith(".AQHI") ? 11 : 127; + if (!Number.isInteger(data.categoryIndex) || data.categoryIndex < 1 || data.categoryIndex > maxCategory) return false; + if (data.index === undefined) return true; + if (!Number.isInteger(data.index) || data.index < 0 || data.index > 32767) return false; + if (six) { + // Calculations may explicitly allow an AQI above 500; its category is 6. + if (!calculated && data.index > 500) return false; + const category = data.index <= 50 ? 1 : data.index <= 100 ? 2 : data.index <= 150 ? 3 : data.index <= 200 ? 4 : data.index <= 300 ? 5 : 6; + return category === data.categoryIndex; + } + // UBA and AQHI conversions expose categoryIndex as index. EAQI exposes its + // 0..60 display index, not the pollutant concentration or an AQHI risk sum. + if (calculated && (name === "UBA" || name.endsWith(".AQHI"))) return data.index === data.categoryIndex; + if (calculated && name === "EU.EAQI") return Math.min(6, Math.floor(data.index / 10) + 1) === data.categoryIndex; + return true; } function matchingSource(currentProvider, yesterdayProvider, scale) { if (typeof currentProvider !== "string" || !currentProvider.trim() || currentProvider !== yesterdayProvider) return false; // A bare Calculate does not identify either its source or its algorithm. - // For precomputed data use e.g. Calculate:QWeather:WAQI_InstantCast_US. - if (currentProvider === "Calculate") return false; - const expected = { ColorfulCloudsUS: "US", ColorfulCloudsCN: "CN", QWeather: "CN" }[currentProvider]; + // Response must supply already computed pairs and explicit identities, e.g. + // Calculate:QWeather:UBA. Never turn a category-only history into pollutants. + if (currentProvider.startsWith("Calculate")) { + const parts = currentProvider.split(":"); + return parts.length === 3 && parts[0] === "Calculate" && parts[1].trim().length > 0 + && ALGORITHM_STANDARDS.get(parts[2]) === standard(scale); + } + const expected = { ColorfulCloudsUS: "EPA_NowCast", ColorfulCloudsCN: "HJ6332012", QWeather: "HJ6332012" }[currentProvider]; return !expected || expected === standard(scale); } @@ -38,7 +77,8 @@ function matchingSource(currentProvider, yesterdayProvider, scale) { * Calculated pairs must already use the same source AND algorithm on both days. */ export function computeWK3Comparison(current, yesterday, { currentProvider, yesterdayProvider } = {}) { - if (!validAQ(current) || !validAQ(yesterday) || standard(current.scale) !== standard(yesterday.scale) + const calculated = typeof currentProvider === "string" && currentProvider.startsWith("Calculate:"); + if (!validAQ(current, calculated) || !validAQ(yesterday, calculated) || !compatibleScales(current.scale, yesterday.scale) || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return WK3_COMPARISON.UNKNOWN; if (current.categoryIndex < yesterday.categoryIndex) return WK3_COMPARISON.BETTER; if (current.categoryIndex > yesterday.categoryIndex) return WK3_COMPARISON.WORSE; @@ -63,7 +103,8 @@ export async function injectWK3Comparison(current, { // Attach a rejection handler even when mismatched inputs skip fetching. const prefetched = yesterday === undefined ? undefined : Promise.resolve(yesterday); prefetched?.catch(() => {}); - if (!validAQ(current) || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return result; + const calculated = typeof currentProvider === "string" && currentProvider.startsWith("Calculate:"); + if (!validAQ(current, calculated) || !matchingSource(currentProvider, yesterdayProvider, current.scale)) return result; let previous = prefetched; if (previous === undefined) { switch (yesterdayProvider) { diff --git a/src/wk3/class/WK3NextHour.mjs b/src/wk3/class/WK3NextHour.mjs index df7679ba..c29bfd37 100644 --- a/src/wk3/class/WK3NextHour.mjs +++ b/src/wk3/class/WK3NextHour.mjs @@ -13,9 +13,14 @@ import { Metadata } from "../proto/apple/wk2.js"; */ export const WK3_NEXT_HOUR_ROOT_SLOT = 5; export const WK3_NEXT_HOUR_ENUMS = { - condition: { CLEAR: 1, RAIN: 7, DRIZZLE: 8 }, - precipitation: { CLEAR: 1, RAIN: 2 }, - forecastToken: { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, CONSTANT: 6 }, + // Explicit WK3 exports from NSRingo dev b4a75db / @nsringo/weatherkit 2.0.5, + // VirgilClyne response.dev.bundle.js (2026-11-07 19:05:32). Not WK2 arithmetic. + // Snow members are upstream definitions, not newly device-verified captures. + // FLURRIES / POSSIBLE_FLURRIES have no numeric aliases; conversion may omit + // the complete descriptive condition vector after validating its source. + condition: { CLEAR: 1, SLEET: 4, POSSIBLE_SLEET: 5, HEAVY_RAIN: 6, RAIN: 7, DRIZZLE: 8, POSSIBLE_DRIZZLE: 9, HEAVY_SNOW: 10, SNOW: 11 }, + precipitation: { CLEAR: 1, RAIN: 2, SNOW: 3, SLEET: 4, HAIL: 5, MIXED: 7 }, + forecastToken: { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, STOP_START: 5, CONSTANT: 6 }, parameterType: { FIRST_AT: 1, SECOND_AT: 2 }, sourceType: { MODELED: 2, STATION: 3 }, }; @@ -167,10 +172,14 @@ function requiredNumber(n, validate) { * Caller MUST explicitly attest units; no conversion of raw five-minute totals, * probability fractions, radar dBZ, or guessing conditions from text here. * Existing provider perceived intensity/condition/summary logic is not rederived. - * Unsupported symbolic enums, missing data, or inconsistent timelines throw; + * FLURRIES/POSSIBLE_FLURRIES have no WK3 numeric definition. After validating + * every source condition, omit the WHOLE descriptive vector if either occurs; + * retain verified SNOW summaries and actual minute values without enum guesses. + * Other unsupported enums, missing data, or inconsistent timelines still throw; * caller catches and keeps the ORIGINAL child arena (including unknown fields). + * Optional report.omittedSnowConditions counts affected source segments. */ -export function providerToWK3NextHour(data, { intensityUnit, chanceUnit } = {}) { +export function providerToWK3NextHour(data, { intensityUnit, chanceUnit, report = {} } = {}) { if (intensityUnit !== "mm/h" || chanceUnit !== "percent") throw new TypeError("Explicit mm/h and percent units required"); if (!data?.metadata?.providerName || data.metadata.temporarilyUnavailable) throw new TypeError("Missing available nextHour provider metadata"); if (!Array.isArray(data.minutes) || !data.minutes.length || !Array.isArray(data.condition) || !data.condition.length || !Array.isArray(data.summary) || !data.summary.length) throw new TypeError("Missing nextHour minutes/condition/summary"); @@ -184,9 +193,21 @@ export function providerToWK3NextHour(data, { intensityUnit, chanceUnit } = {}) const segment = s => ({ startTime: requiredNumber(s.startTime, uint), endTime: s.endTime == null ? 0 : requiredNumber(s.endTime, uint) }); const summary = data.summary.map(s => ({ ...segment(s), condition: (Array.isArray(s.condition) ? s.condition : [s.condition]).map(c => mapEnum("precipitation", c)), precipitationChance: requiredNumber(s.precipitationChance, chance), precipitationIntensity: requiredNumber(s.precipitationIntensity, float) })); if (summary.some(s => s.condition.length !== 1)) throw new TypeError("Unverified WK3 multi-condition summary vector"); + let omittedSnowConditions = 0; const condition = data.condition.map(c => { if (!Array.isArray(c.parameters)) throw new TypeError("Missing condition parameters"); - return { ...segment(c), forecastToken: mapEnum("forecastToken", c.forecastToken), beginCondition: mapEnum("condition", c.beginCondition), endCondition: mapEnum("condition", c.endCondition), parameters: c.parameters.map(p => ({ type: mapEnum("parameterType", p.type), date: requiredNumber(p.date, uint) })) }; + // Validate tokens/parameters and both endpoints even if another endpoint + // is an unnamed snow description. Omission must never hide bad input. + const times = segment(c), forecastToken = mapEnum("forecastToken", c.forecastToken); + const parameters = c.parameters.map(p => ({ type: mapEnum("parameterType", p.type), date: requiredNumber(p.date, uint) })); + let omitted = false; + const conditionValue = name => { + if (name === "FLURRIES" || name === "POSSIBLE_FLURRIES") { omitted = true; return null; } + return mapEnum("condition", name); + }; + const beginCondition = conditionValue(c.beginCondition), endCondition = conditionValue(c.endCondition); + if (omitted) omittedSnowConditions++; + return { ...times, forecastToken, beginCondition, endCondition, parameters }; }); for (const segments of [summary, condition]) { for (let i = 0; i < segments.length; i++) { @@ -194,5 +215,8 @@ export function providerToWK3NextHour(data, { intensityUnit, chanceUnit } = {}) if (s.startTime !== (i === 0 ? forecastStart : segments[i - 1].endTime) || (next ? s.endTime !== next.startTime || s.endTime <= s.startTime || s.endTime >= forecastEnd : s.endTime !== 0)) throw new TypeError("Inconsistent nextHour segment timeline"); } } - return { metadata: { ...data.metadata, sourceType: mapEnum("sourceType", data.metadata.sourceType) }, forecastStart, forecastEnd, minutes, summary, condition }; + if (omittedSnowConditions && !summary.some(s => s.condition[0] === WK3_NEXT_HOUR_ENUMS.precipitation.SNOW)) throw new TypeError("Unnamed snow conditions require a SNOW summary"); + const metadata = { ...data.metadata, sourceType: mapEnum("sourceType", data.metadata.sourceType) }; + report.omittedSnowConditions = omittedSnowConditions; + return { metadata, forecastStart, forecastEnd, minutes, summary, condition: omittedSnowConditions ? [] : condition }; } diff --git a/src/wk3/class/WeatherKit3.mjs b/src/wk3/class/WeatherKit3.mjs index ef9eb70a..a83fba12 100644 --- a/src/wk3/class/WeatherKit3.mjs +++ b/src/wk3/class/WeatherKit3.mjs @@ -76,46 +76,125 @@ export function encodeAirQuality(builder, data) { } // Verified by paired Apple v3 JSON / WK3 b66 responses, not WK2 enum arithmetic. // Only observed names are mapped; unsupported provider names fail closed. -export const WK3_AQ_POLLUTANTS = { NOT_AVAILABLE: 1, NO: 5, NO2: 6, NOX: 7, OZONE: 8, PM2_5: 9, SO2: 10, PM10: 11, CO: 12 }; +export const WK3_AQ_POLLUTANTS = { + NOT_AVAILABLE: 1, C6H6: 2, NH3: 3, NMHC: 4, NO: 5, NO2: 6, NOX: 7, + OZONE: 8, PM2_5: 9, SO2: 10, PM10: 11, CO: 12, +}; export const WK3_AQ_UNITS = { PARTS_PER_BILLION: 1, MICROGRAMS_PER_CUBIC_METER: 2 }; -export const WK3_AQ_SOURCES = { MODELED: 2, STATION: 3 }; +export const WK3_AQ_SOURCES = { APPLE_INTERNAL: 1, MODELED: 2, STATION: 3 }; +export const WK3_AQ_COMPARISONS = { UNKNOWN: 0, WORSE: 2, SAME: 3, BETTER: 4 }; -/** Build a self-consistent US AQ child from one provider. Do not carry China's - * primary pollutant / yesterday comparison into a new scale. UNKNOWN=0 was - * verified against Apple JSON; yesterday injection remains unsupported. +const WK3_AQ_SCALES = new Set([ + "UBA", "EU.EAQI", "HJ6332012", "HJ6332025_DRAFT", "EPA_NowCast", + "HK.AQHI", "CA.AQHI", "CN.AQHI", +]); +function mapAQComparison(value = "UNKNOWN") { + if (!Object.hasOwn(WK3_AQ_COMPARISONS, value)) throw new TypeError(`Unknown WK3 AQ comparison: ${value}`); + return WK3_AQ_COMPARISONS[value]; +} +function aqCategory(scale, index) { + if (["EPA_NowCast", "HJ6332012", "HJ6332025_DRAFT"].includes(scale)) return index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; + return null; +} + + +/** Convert supported algorithm/provider AQ payloads into WK3 wire enums. + * Required metadata: providerName and sourceType. Missing optional metadata + * fields (coordinates and timestamps included) retain codec defaults of zero. */ -export function providerToWK3USAirQuality(data) { - const map = (dictionary, name) => { - if (!Object.hasOwn(dictionary, name)) throw new TypeError(`Unsupported WK3 AQ enum: ${name}`); - return dictionary[name]; - }; - if (!["EPA_NowCast.2414", "EPA_NowCast.2604"].includes(data?.scale)) throw new TypeError("Unsupported EPA_NowCast scale version"); - if (!Number.isInteger(data.index)) throw new TypeError("Missing integer AQI"); - const index = unsigned(data.index, 500); - const category = index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; - if (data.categoryIndex !== category || data.isSignificant !== (category >= 3)) throw new TypeError("Inconsistent EPA AQI category/significance"); - if (!data.metadata || data.metadata.temporarilyUnavailable || !data.metadata.providerName) throw new TypeError("Missing AQ provider metadata"); - if (!Array.isArray(data.pollutants) || !data.pollutants.length) throw new TypeError("Missing provider pollutants"); +export function providerToWK3AirQuality(data) { + if (!data || typeof data !== "object") throw new TypeError("Missing provider AQ data"); + if (!Number.isInteger(data.index) || data.index < 0 || data.index > 32767) throw new RangeError("WK3 AQ index must be 0..32767"); + if (!Number.isInteger(data.categoryIndex) || data.categoryIndex < 1 || data.categoryIndex > 127) throw new RangeError("WK3 AQ category must be 1..127"); + const scaleBase = typeof data.scale === "string" ? data.scale.replace(/\.\d+(?:\.\d+)*$/, "") : ""; + if (!WK3_AQ_SCALES.has(scaleBase)) throw new TypeError(`Unsupported WK3 AQ scale: ${data.scale}`); + const expectedCategory = aqCategory(scaleBase, data.index); + if (expectedCategory !== null && data.categoryIndex !== expectedCategory) throw new TypeError(`Inconsistent ${scaleBase} categoryIndex`); + if (expectedCategory !== null && data.isSignificant !== (data.categoryIndex >= 3)) throw new TypeError(`Inconsistent ${scaleBase} significance`); + if (!data.metadata || data.metadata.temporarilyUnavailable || typeof data.metadata.providerName !== "string" || !data.metadata.providerName || !Object.hasOwn(WK3_AQ_SOURCES, data.metadata.sourceType)) throw new TypeError("Missing or invalid AQ provider metadata"); + if (!Array.isArray(data.pollutants) || data.pollutants.length === 0) throw new TypeError("Missing provider pollutants"); const seen = new Set(); const pollutants = data.pollutants.map(p => { - const pollutantType = map(WK3_AQ_POLLUTANTS, p.pollutantType); - if (pollutantType === 1 || seen.has(pollutantType)) throw new TypeError("Invalid or duplicate pollutant"); + if (!p || typeof p !== "object" || !Object.hasOwn(WK3_AQ_POLLUTANTS, p.pollutantType) || !Object.hasOwn(WK3_AQ_UNITS, p.units)) throw new TypeError("Unknown WK3 AQ pollutant/unit"); + const pollutantType = WK3_AQ_POLLUTANTS[p.pollutantType]; + if (pollutantType === WK3_AQ_POLLUTANTS.NOT_AVAILABLE || seen.has(pollutantType)) throw new TypeError("Invalid or duplicate pollutant"); seen.add(pollutantType); - if (typeof p.amount !== "number") throw new TypeError("Missing pollutant concentration"); const amount = finite(p.amount); if (amount < 0) throw new RangeError("Negative pollutant concentration"); - return { pollutantType, amount, units: map(WK3_AQ_UNITS, p.units) }; + return { ...p, pollutantType, amount, units: WK3_AQ_UNITS[p.units] }; }); - const primaryPollutant = map(WK3_AQ_POLLUTANTS, data.primaryPollutant); - if (primaryPollutant !== 1 && !seen.has(primaryPollutant)) throw new TypeError("Primary pollutant missing from provider data"); - return { - metadata: { ...data.metadata, sourceType: map(WK3_AQ_SOURCES, data.metadata.sourceType) }, - index, categoryIndex: category, isSignificant: category >= 3, pollutants, - // Upstream providers still emit .2414 (Apple now returns 404). Both use - // the same six AQI bands; .2604 is the observed live display scale, not - // a claim that the provider implements Apple's concentration algorithm. - primaryPollutant, previousDayComparison: 0, scale: "EPA_NowCast.2604", + if (!Object.hasOwn(WK3_AQ_POLLUTANTS, data.primaryPollutant)) throw new TypeError("Unknown WK3 AQ primary pollutant"); + const primaryPollutant = WK3_AQ_POLLUTANTS[data.primaryPollutant]; + if (primaryPollutant !== WK3_AQ_POLLUTANTS.NOT_AVAILABLE && !seen.has(primaryPollutant)) throw new TypeError("Primary pollutant missing from provider data"); + const metadata = { + ...data.metadata, attributionUrl: data.metadata.attributionUrl ?? null, expireTime: data.metadata.expireTime ?? 0, + language: data.metadata.language ?? null, latitude: finite(data.metadata.latitude ?? 0), longitude: finite(data.metadata.longitude ?? 0), + providerLogo: data.metadata.providerLogo ?? null, readTime: data.metadata.readTime ?? 0, reportedTime: data.metadata.reportedTime ?? 0, + temporarilyUnavailable: false, sourceType: WK3_AQ_SOURCES[data.metadata.sourceType], }; + const normalizedScale = data.scale === "EPA_NowCast.2414" ? "EPA_NowCast.2604" : data.scale; + return { ...data, metadata, index: data.index, categoryIndex: data.categoryIndex, isSignificant: data.isSignificant === true, + pollutants, primaryPollutant, previousDayComparison: mapAQComparison(data.previousDayComparison), scale: normalizedScale }; +} + +function enumName(dictionary, value, kind) { + const name = Object.keys(dictionary).find(key => dictionary[key] === value); + if (name === undefined) throw new TypeError(`Unknown WK3 AQ ${kind}: ${value}`); + return name; +} +function assertKnownVtableFields(table, count, label) { + if (!table) throw new TypeError(`Missing WK3 AQ ${label}`); + const bb = table.bb, position = table.bb_pos; + const vtable = position - bb.readInt32(position); + if (bb.readInt16(vtable) > 4 + count * 2) throw new TypeError(`Unknown WK3 AQ ${label} fields`); +} +export function wk3AirQualityToProvider(data) { + if (data && typeof data.index === "function") { + assertKnownVtableFields(data, 8, "table"); + const metadataTable = data.metadata(); + assertKnownVtableFields(metadataTable, 11, "metadata"); + const pollutants = Array.from({ length: data.pollutantsLength() }, (_, i) => { + const p = data.pollutants(i); assertKnownVtableFields(p, 3, "pollutant"); + return { pollutantType: p.pollutantType(), amount: p.amount(), units: p.units() }; + }); + return wk3AirQualityToProvider({ metadata: decodeMetadata(metadataTable), categoryIndex: data.categoryIndex(), index: data.index(), + isSignificant: data.isSignificant(), pollutants, previousDayComparison: data.previousDayComparison(), + primaryPollutant: data.primaryPollutant(), scale: data.scale() }); + } + if (!data || !data.metadata || !Array.isArray(data.pollutants)) throw new TypeError("Missing raw WK3 AQ object"); + const metadata = { ...data.metadata, sourceType: enumName(WK3_AQ_SOURCES, data.metadata.sourceType, "source") }; + const pollutants = data.pollutants.map(p => ({ + ...p, pollutantType: enumName(WK3_AQ_POLLUTANTS, p.pollutantType, "pollutant"), + units: enumName(WK3_AQ_UNITS, p.units, "unit"), + })); + return { ...data, metadata, pollutants, + previousDayComparison: enumName(WK3_AQ_COMPARISONS, data.previousDayComparison, "comparison"), + primaryPollutant: enumName(WK3_AQ_POLLUTANTS, data.primaryPollutant, "primary pollutant") }; +} + +/** US-specific compatibility wrapper retains its original 0..500 bands. */ +export function providerToWK3USAirQuality(data) { + if (!data || !["EPA_NowCast.2414", "EPA_NowCast.2604"].includes(data.scale)) throw new TypeError("Unsupported EPA_NowCast scale version"); + if (!Number.isInteger(data.index)) throw new TypeError("Missing integer AQI"); + const index = unsigned(data.index, 500); + const category = index <= 50 ? 1 : index <= 100 ? 2 : index <= 150 ? 3 : index <= 200 ? 4 : index <= 300 ? 5 : 6; + if (data.categoryIndex !== category || data.isSignificant !== (category >= 3)) throw new TypeError("Inconsistent EPA AQI category/significance"); + const result = providerToWK3AirQuality(data); + return { ...result, index, categoryIndex: category, isSignificant: category >= 3, previousDayComparison: 0, scale: "EPA_NowCast.2604" }; +} + +/** Unknown AQ/metadata/pollutant fields make complete-child replacement unsafe. */ +export function assertWK3AirQualityReplaceable(bytes) { + const bb = bytes instanceof ByteBuffer ? bytes : new ByteBuffer(bytes); + const start = bb.position(), root = start + bb.readUint32(start); + const offset = bb.__offset(root, 4 + 2 * WK3_ROOT_SLOTS.airQuality); + if (!offset) return true; + const aq = new WK3AirQualityTable().__init(bb.__indirect(root + offset), bb); + assertKnownVtableFields(aq, 8, "table"); + const metadata = aq.metadata(); + if (metadata) assertKnownVtableFields(metadata, 11, "metadata"); + for (let i = 0; i < aq.pollutantsLength(); i++) assertKnownVtableFields(aq.pollutants(i), 3, "pollutant"); + return true; } export const airQualityCodec = { tableClass: WK3AirQualityTable, decode: decodeAirQuality, encode: encodeAirQuality }; diff --git a/src/wk3/entry/index.mjs b/src/wk3/entry/index.mjs index 21eaba4a..c10a3ad3 100644 --- a/src/wk3/entry/index.mjs +++ b/src/wk3/entry/index.mjs @@ -1,6 +1,7 @@ import database from "../function/database.mjs"; import buildSettings from "../function/buildSettings.mjs"; import { get, set, merge } from "../utils/object.mjs"; +import { moduleOptionLabels, normalizeModuleOption } from "../option-values.mjs"; export function configuration(ctx) { // SETTINGS is the same nested Settings object accepted by buildSettings. @@ -10,6 +11,10 @@ export function configuration(ctx) { if (key.includes(".") || key === "DataSets" || key === "LogLevel") set(dotted, key, value); } merge(overrides, dotted); + for (const key of Object.keys(moduleOptionLabels)) { + const value = get(overrides, key); + if (value !== undefined) set(overrides, key, normalizeModuleOption(key, value)); + } // The existing merge deliberately preserves defaults for an empty array. // Its string "[]" convention clears them during buildSettings normalization. function emptyArrays(value) { diff --git a/src/wk3/entry/request.mjs b/src/wk3/entry/request.mjs index a49365e9..a7663e87 100644 --- a/src/wk3/entry/request.mjs +++ b/src/wk3/entry/request.mjs @@ -9,12 +9,24 @@ export async function handleRequest(ctx) { const url = new URL(ctx.request.url); if (url.hostname !== "weatherkit.apple.com") return; if (/^\/api\/v[12]\/airQualityScale\//.test(url.pathname)) { - // Keep the existing Apple-first behavior while moving this responsibility - // to the upstream request category. Native ctx.http does not re-enter scripts. + const parts = url.pathname.split("/").filter(Boolean); + const scaleName = decodeURIComponent(parts[4] ?? ""); + // Upstream dev requests EAQI's native, versionless Apple scale. + if (/^EU\.EAQI(?:\.\d+)?$/.test(scaleName)) { + url.pathname = `/api/${parts[1]}/airQualityScale/${parts[3]}/EU.EAQI`; + const headers = ctx.request.headers; + if (typeof headers?.delete === "function") headers.delete("if-none-match"); + else for (const key of Object.keys(headers ?? {})) if (key.toLowerCase() === "if-none-match") delete headers[key]; + return { url: url.toString(), headers }; + } + // Other standards retain their existing Apple-first behavior. try { const upstream = await webFetch(url.toString(), { headers: headerObject(ctx.request.headers) }); - if (upstream.status === 200) return ctx.respond({ status: 200, headers: responseHeaders(upstream.headers), body: await upstream.text() }); const parts = url.pathname.split("/").filter(Boolean); + if (upstream.status === 200) { + let body = await upstream.text(); + return ctx.respond({ status: 200, headers: responseHeaders(upstream.headers), body }); + } const fallback = AirQualityScale.buildScale(parts[3] ?? "en", parts[4] ?? ""); if (fallback) return ctx.respond(fallback); return ctx.respond({ status: upstream.status, headers: responseHeaders(upstream.headers), body: await upstream.text() }); diff --git a/src/wk3/option-values.mjs b/src/wk3/option-values.mjs new file mode 100644 index 00000000..745c530c --- /dev/null +++ b/src/wk3/option-values.mjs @@ -0,0 +1,27 @@ +// Egern string pickers display their stored values. Keep readable labels at +// that UI boundary; existing canonical values remain accepted by scripts. +const indexSources = { + Calculate: "iRingo内置算法", + ColorfulCloudsUS: "彩云天气(美标)", + ColorfulCloudsCN: "彩云天气(国标)", + QWeather: "和风天气", +}; +export const moduleOptionLabels = { + "AirQuality.Current.Index.Provider": indexSources, + "AirQuality.Comparison.Yesterday.IndexProvider": indexSources, + "AirQuality.Calculate.Algorithm": { + None: "不转换", UBA: "德国LQI", EU_EAQI: "欧盟EAQI", + WAQI_InstantCast_US: "美标InstantCast", WAQI_InstantCast_CN: "国标InstantCast(2012)", + WAQI_InstantCast_CN_25_DRAFT: "国标InstantCast(2025草案)", + CA_AQHI: "加拿大AQHI", HK_AQHI: "香港AQHI", AQHI_Multi_CN: "AQHI-Multi(中国)", + AQHI_Multi_CN_HK: "AQHI-Multi(中国+香港)", CN_DEATH_AQHI: "中国健康风险AQHI", + CN_DEATH_HK_AQHI: "中国+香港健康风险AQHI", + }, +}; +export function displayModuleOption(key, value) { + return moduleOptionLabels[key]?.[value] ?? value; +} +export function normalizeModuleOption(key, value) { + const labels = moduleOptionLabels[key]; + return labels ? Object.keys(labels).find(option => labels[option] === value) ?? value : value; +} diff --git a/src/wk3/parameters.mjs b/src/wk3/parameters.mjs index 5f34908e..df6a3068 100644 --- a/src/wk3/parameters.mjs +++ b/src/wk3/parameters.mjs @@ -1,35 +1,33 @@ // Parameter keys follow the fork's settings and upstream argument templates. -// Defaults reproduce the existing local module; controls do not expand WK3 support. +// Defaults reproduce the existing local module; WK3 AQ controls use shared conversion. // `choices` documents list inputs; only scalar `options` render as single selects. export const localParameters = [ - { key: "DataSets", type: "array", name: "[数据集]", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", choices: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], description: "选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。" }, - { key: "Weather.Replace", type: "string", name: "[天气] 替换范围", value: "CN", description: "逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。" }, - { key: "Weather.Provider", type: "string", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不替换)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。" }, - { key: "Weather.ReplaceDaily", type: "boolean", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐日预报保持苹果数据。" }, - { key: "Weather.ReplaceHourly", type: "boolean", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "WK2 可选;WK3 逐小时预报保持苹果数据。" }, - { key: "WeatherAlerts.Provider", type: "string", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["WeatherKit", "QWeatherWeb"], optionLabels: {"WeatherKit": "WeatherKit(不修改)", "QWeatherWeb": "和风天气网页"}, description: "QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。" }, - { key: "NextHour.Provider", type: "string", name: "[未来一小时降水强度] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不添加)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。" }, - { key: "AirQuality.Current.Index.Provider", type: "string", name: "[今日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;大陆缺 AQ 时仅 ColorfulCloudsUS 且替换目标非空可补入完整美标,失败保留原响应;其余跨标准保留苹果数据。" }, - { key: "AirQuality.Current.Index.Replace", type: "array", name: "[今日空气指数] 替换目标", value: "HJ6332012", choices: ["HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", "ES.MITECO", "SG.NEA", "NAQI", "DAQI"], description: "替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012;大陆缺 AQ 补入也要求此项非空。WK3 仅支持同标准或国标→美标。" }, - { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", type: "boolean", name: "[今日空气指数] 强制主要污染物", value: "true", boolean: true, description: "忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。" }, - { key: "AirQuality.Current.Pollutants.Provider", type: "string", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], optionLabels: {"ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。" }, - { key: "AirQuality.Current.Pollutants.Units.Replace", type: "array", name: "[今日污染物 - 单位转换] 替换目标", value: "[]", choices: ["EPA_NowCast", "EU.EAQI", "HJ6332012", "UBA"], description: "转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。" }, - { key: "AirQuality.Current.Pollutants.Units.Mode", type: "string", name: "[今日污染物 - 单位转换] 模式", value: "Scale", options: ["Scale", "ugm3", "EU_ppb", "US_ppb", "Force_ugm3", "Force_EU_ppb", "Force_US_ppb"], optionLabels: {"Scale": "与空气质量标准的要求相同", "ugm3": "除非标准要求,都转为µg/m³", "EU_ppb": "除非标准要求,都转为欧盟ppb", "US_ppb": "除非标准要求,都转为美标ppb", "Force_ugm3": "µg/m³", "Force_EU_ppb": "欧盟ppb", "Force_US_ppb": "美标ppb"}, description: "污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。" }, - { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", type: "boolean", name: "[空气质量 - 对比昨日] 变化时替换", value: "true", boolean: true, description: "即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。" }, - { key: "AirQuality.Comparison.Yesterday.IndexProvider", type: "string", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather", "WeatherKit"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)", "WeatherKit": "保留 Apple/不额外查询(本机兼容)"}, description: "用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。" }, - { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", type: "string", name: "[昨日污染物] 数据源", value: "ColorfulCloudsCN", options: ["ColorfulCloudsCN", "QWeather"], optionLabels: {"QWeather": "和风天气", "ColorfulCloudsCN": "彩云天气(本机兼容)"}, description: "为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。" }, - { key: "AirQuality.Calculate.Algorithm", type: "string", name: "[iRingo内置算法] 算法", value: "WAQI_InstantCast_US", options: ["None", "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"], optionLabels: {"None": "不转换", "UBA": "德国LQI(FB001846)", "EU_EAQI": "欧盟EAQI(ETC HE Report 2024/17)", "WAQI_InstantCast_US": "美标InstantCast(EPA-454/B-24-002)", "WAQI_InstantCast_CN": "国标InstantCast(HJ 633—2012)", "WAQI_InstantCast_CN_25_DRAFT": "国标InstantCast(HJ 633 2025年草案)", "CA_AQHI": "加拿大AQHI(10.17269/s41997-019-00237-w)", "HK_AQHI": "香港AQHI", "AQHI_Multi_CN": "AQHI-Multi(中国)", "AQHI_Multi_CN_HK": "AQHI-Multi(中国+香港)", "CN_DEATH_AQHI": "中国(致死风险)AQHI", "CN_DEATH_HK_AQHI": "中国(致死风险)+香港AQHI"}, description: "共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。" }, - { key: "AirQuality.Calculate.AllowOverRange", type: "boolean", name: "[iRingo内置算法] 允许指数超标", value: "true", boolean: true, description: "允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。" }, - { key: "API.ColorfulClouds.Token", type: "string", name: "[API] 彩云天气令牌", value: "", description: "彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。" }, - { key: "API.QWeather.Host", type: "string", name: "[API] 和风天气主机", value: "devapi.qweather.com", description: "和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。" }, - { key: "API.QWeather.Token", type: "string", name: "[API] 和风天气令牌", value: "", description: "和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。" }, - { key: "LogLevel", type: "string", name: "[调试] 日志等级", value: "INFO", options: ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "ALL"], optionLabels: {"OFF": "关闭", "ERROR": "❌ 错误", "WARN": "⚠️ 警告", "INFO": "ℹ️ 信息", "DEBUG": "🅱️ 调试", "ALL": "全部"}, description: "选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。" }, - { key: "HTTP_TIMEOUT_MS", type: "number", name: "[网络] 单次请求超时(毫秒)", value: "8000", description: "默认8000毫秒;脚本总体超时仍为30秒。" }, + { key: "DataSets", type: "array", name: "[数据集]", value: "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges", choices: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], description: "选择要处理的数据;留空或 [] 全部保留原数据。" }, + { key: "Weather.Replace", type: "string", name: "[天气] 替换范围", value: "CN", description: "普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。" }, + { key: "Weather.Provider", type: "string", name: "[天气] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不替换)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。" }, + { key: "Weather.ReplaceDaily", type: "boolean", name: "[天气] 替换逐日预报", value: "false", boolean: true, description: "是否替换逐日预报;仅 WK2 生效。" }, + { key: "Weather.ReplaceHourly", type: "boolean", name: "[天气] 替换逐小时预报", value: "false", boolean: true, description: "是否替换逐小时预报;仅 WK2 生效。" }, + { key: "WeatherAlerts.Provider", type: "string", name: "[天气预警] 数据源", value: "QWeatherWeb", options: ["WeatherKit", "QWeatherWeb"], optionLabels: {"WeatherKit": "WeatherKit(不修改)", "QWeatherWeb": "和风天气网页"}, description: "和风网页补全原生预警;选择 WeatherKit 保留原数据。" }, + { key: "NextHour.Provider", type: "string", name: "[未来一小时降水强度] 数据源", value: "ColorfulClouds", options: ["WeatherKit", "ColorfulClouds", "QWeather"], optionLabels: {"WeatherKit": "WeatherKit(不添加)", "ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "选择未来一小时雨雪分钟数据来源。" }, + { key: "AirQuality.Current.Index.Provider", type: "string", name: "[今日空气指数] 数据源", value: "Calculate", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "选择指数来源:iRingo内置算法、彩云或和风。" }, + { key: "AirQuality.Current.Index.Replace", type: "array", name: "[今日空气指数] 替换目标", value: "HJ6332012", choices: ["HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", "ES.MITECO", "SG.NEA", "NAQI", "DAQI", "HK.AQHI", "CN.AQHI"], description: "填写要替换的原始标准,逗号分隔;留空或 [] 不替换。" }, + { key: "AirQuality.Current.Index.ForceCNPrimaryPollutants", type: "boolean", name: "[今日空气指数] 强制主要污染物", value: "true", boolean: true, description: "国标指数不超过 50 时,仍显示主要污染物。" }, + { key: "AirQuality.Current.Pollutants.Provider", type: "string", name: "[今日污染物] 数据源", value: "ColorfulClouds", options: ["ColorfulClouds", "QWeather"], optionLabels: {"ColorfulClouds": "彩云天气", "QWeather": "和风天气"}, description: "已有 Apple 污染物优先保留;缺失时使用此来源。" }, + { key: "AirQuality.Current.Pollutants.Units.Replace", type: "array", name: "[今日污染物 - 单位转换] 替换目标", value: "[]", choices: ["EPA_NowCast", "EU.EAQI", "HJ6332012", "UBA", "CA.AQHI", "HK.AQHI", "CN.AQHI"], description: "需要转换单位的空气质量标准;留空或 [] 不转换。" }, + { key: "AirQuality.Current.Pollutants.Units.Mode", type: "string", name: "[今日污染物 - 单位转换] 模式", value: "Scale", options: ["None", "Scale", "ugm3", "EU_ppb", "US_ppb", "Force_ugm3", "Force_EU_ppb", "Force_US_ppb"], optionLabels: {"None": "不转换污染物单位", "Scale": "与空气质量标准的要求相同", "ugm3": "除非标准要求,都转为µg/m³", "EU_ppb": "除非标准要求,都转为欧盟ppb", "US_ppb": "除非标准要求,都转为美标ppb", "Force_ugm3": "µg/m³", "Force_EU_ppb": "欧盟ppb", "Force_US_ppb": "美标ppb"}, description: "Scale 跟随标准,Force 强制转换,None 保留原单位。" }, + { key: "AirQuality.Comparison.ReplaceWhenCurrentChange", type: "boolean", name: "[空气质量 - 对比昨日] 变化时替换", value: "false", boolean: true, description: "今日指数变化时,是否重新计算昨日对比。" }, + { key: "AirQuality.Comparison.Yesterday.IndexProvider", type: "string", name: "[昨日空气指数] 数据源", value: "ColorfulCloudsUS", options: ["Calculate", "ColorfulCloudsUS", "ColorfulCloudsCN", "QWeather"], optionLabels: {"Calculate": "iRingo内置算法", "ColorfulCloudsUS": "彩云天气(美标,18年9月版)", "ColorfulCloudsCN": "彩云天气(国标,12年2月版)", "QWeather": "和风天气(国标,12年2月版)"}, description: "选择昨日指数来源;仅同源、同标准的数据可比较。" }, + { key: "AirQuality.Comparison.Yesterday.PollutantsProvider", type: "string", name: "[昨日污染物] 数据源", value: "QWeather", options: ["QWeather"], optionLabels: {"QWeather": "和风天气"}, description: "内置算法计算昨日指数所需的污染物来源。" }, + { key: "AirQuality.Calculate.Algorithm", type: "string", name: "[iRingo内置算法] 算法", value: "EU_EAQI", options: ["None", "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"], optionLabels: {"None": "不转换", "UBA": "德国LQI(FB001846)", "EU_EAQI": "欧盟EAQI(ETC HE Report 2024/17)", "WAQI_InstantCast_US": "美标InstantCast(EPA-454/B-24-002)", "WAQI_InstantCast_CN": "国标InstantCast(HJ 633—2012)", "WAQI_InstantCast_CN_25_DRAFT": "国标InstantCast(HJ 633 2025年草案)", "CA_AQHI": "加拿大AQHI(10.17269/s41997-019-00237-w)", "HK_AQHI": "香港AQHI", "AQHI_Multi_CN": "AQHI-Multi(中国)", "AQHI_Multi_CN_HK": "AQHI-Multi(中国+香港)", "CN_DEATH_AQHI": "中国(致死风险)AQHI", "CN_DEATH_HK_AQHI": "中国(致死风险)+香港AQHI"}, description: "使用污染物本地计算指数;None 不转换。" }, + { key: "AirQuality.Calculate.AllowOverRange", type: "boolean", name: "[iRingo内置算法] 允许指数超标", value: "true", boolean: true, description: "允许内置美标、国标指数超过 500。" }, + { key: "API.ColorfulClouds.Token", type: "string", name: "[API] 彩云天气令牌", value: "", description: "自有彩云令牌;留空使用内置公开令牌。" }, + { key: "API.QWeather.Host", type: "string", name: "[API] 和风天气主机", value: "devapi.qweather.com", description: "和风 API 主机名;使用和风来源时填写。" }, + { key: "API.QWeather.Token", type: "string", name: "[API] 和风天气令牌", value: "", description: "和风 API 令牌;网页预警不需要。" }, + { key: "LogLevel", type: "string", name: "[调试] 日志等级", value: "INFO", options: ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "ALL"], optionLabels: {"OFF": "关闭", "ERROR": "❌ 错误", "WARN": "⚠️ 警告", "INFO": "ℹ️ 信息", "DEBUG": "🅱️ 调试", "ALL": "全部"}, description: "默认 INFO;DEBUG 显示细节,OFF 关闭日志。" }, + { key: "HTTP_TIMEOUT_MS", type: "number", name: "[网络] 单次请求超时(毫秒)", value: "8000", description: "单次网络请求超时,默认 8000 毫秒。" }, ]; -// Keep option values unchanged on the wire; display upstream labels in help text. +// Select controls already show their choices; help text stays concise. export function parameterDescription(parameter) { - const values = parameter.options ?? parameter.choices; - const options = values?.map(value => parameter.optionLabels?.[value] ? `${value}(${parameter.optionLabels[value]})` : value); - return `${parameter.description}${options?.length ? ` 可选值:${options.join("、")}。` : ""}`; + return parameter.description; } diff --git a/src/wk3/process/Response.mjs b/src/wk3/process/Response.mjs index f9da1169..3df73450 100644 --- a/src/wk3/process/Response.mjs +++ b/src/wk3/process/Response.mjs @@ -5,7 +5,7 @@ import ColorfulClouds from "../class/ColorfulClouds.mjs"; import QWeather from "../class/QWeather.mjs"; import Weather from "../class/Weather.mjs"; import WeatherKit2, { decodeAirQuality, encodeAirQuality, decodeForecastNextHour, encodeForecastNextHour } from "../class/WeatherKit2.mjs"; -import { decodeWK3DataSet, encodeAirQuality as encodeWK3AirQuality, providerToWK3USAirQuality } from "../class/WeatherKit3.mjs"; +import { decodeWK3DataSet, encodeAirQuality as encodeWK3AirQuality, providerToWK3AirQuality, wk3AirQualityToProvider, assertWK3AirQualityReplaceable } from "../class/WeatherKit3.mjs"; import { injectWK3Comparison } from "../class/WK3Comparison.mjs"; import { patchWK3QWeatherAlerts } from "../class/WK3Alerts.mjs"; import { decodeWK3NextHour, encodeNextHour as encodeWK3NextHour, providerToWK3NextHour } from "../class/WK3NextHour.mjs"; @@ -14,7 +14,7 @@ import database from "../function/database.mjs"; import mergeWeatherKitAvailability from "../../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import buildSettings from "../function/buildSettings.mjs"; -import shouldReplaceWeatherKit, { isHongKong } from "../function/shouldReplaceWeatherKit.mjs"; +import shouldReplaceWeatherKit from "../function/shouldReplaceWeatherKit.mjs"; import { Console } from "../utils/index.mjs"; /***************** Processing *****************/ export async function Response($request, $response, context = {}) { @@ -124,16 +124,7 @@ export async function Response($request, $response, context = {}) { const parsedParameters = preParameters || parseWeatherKitURL(url); const parameters = { ...parsedParameters, dataSets: parsedParameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)) }; const shouldReplace = shouldReplaceWeatherKit(parameters, Settings); - if (!shouldReplace && url.pathname.startsWith("/api/v3/weather/") && isHongKong(parameters) - && parameters.dataSets.includes("airQuality") && !decodeWK3DataSet(rawBody)) { - const enviroments = preEnviroments || { - colorfulClouds: new ColorfulClouds({ ...parameters, country: "HK" }, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), - parameters, country: "HK", - }; - rawBody = await PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched); - break; - } - if (!shouldReplace) { + if (!shouldReplace && !url.pathname.startsWith("/api/v3/weather/")) { Console.log(`[proxy] ${parameters.country}/${parameters.latitude},${parameters.longitude} 无需替换,直接跳过 FlatBuffer 编解码。`); break; } @@ -145,10 +136,13 @@ export async function Response($request, $response, context = {}) { country: parameters.country, }; if (parameters.dataSets.includes("airQuality")) rawBody = await PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched); - if (parameters.dataSets.includes("forecastNextHour")) rawBody = await PatchWK3NextHour(rawBody, Settings, enviroments, preFetched); - if (parameters.dataSets.includes("weatherAlerts")) { - const patched = await patchWK3QWeatherAlerts(rawBody, { language: parameters.language, timezone: url.searchParams.get("timezone") || undefined, rewriteDetails: true }); - Console.info("WK3 alerts patch", patched === rawBody ? "无可补全的匹配预警,保留原始信息。" : "已补全国内预警;苹果原生详情页保留原事件 ID。"); + if (shouldReplace && parameters.dataSets.includes("forecastNextHour")) rawBody = await PatchWK3NextHour(rawBody, Settings, enviroments, preFetched); + if (shouldReplace && parameters.dataSets.includes("weatherAlerts")) { + const report = {}; + const patched = await patchWK3QWeatherAlerts(rawBody, { language: parameters.language, timezone: url.searchParams.get("timezone") || undefined, rewriteDetails: true, report }); + Console.info("WK3 alerts summary", `摘要补全:${report.summariesUpdated}/${report.total} 条;事件匹配:${report.matched}/${report.total} 条。`); + Console.info("WK3 alerts details", report.detailsConverted ? "详情转换:已切换苹果原生详情,保留原事件 ID。" : `详情转换:未转换,保留原链接;reason=${report.reason}`, report.detailsConverted ? "" : report.unmatched); + if (report.titleMismatches.length) Console.info("WK3 alerts titles", report.titleMismatches); rawBody = patched; } break; @@ -260,95 +254,156 @@ function validIndex(candidate) { && typeof candidate.scale === "string" && candidate.scale.length > 0; } -// WK3 enums differ from WK2. Same-scale updates retain the original narrow patch; -// CN -> US uses independently observed mappings and a complete provider AQ child. +// Complete WK3 AQ products use the explicit upstream enum mappings. Existing +// unknown AQ fields remain protected; all other weather arenas stay untouched. +async function wk3AQCandidate(original, Settings, enviroments, preFetched) { + const provider = Settings?.AirQuality?.Current?.Index?.Provider; + const pollutantProvider = Settings?.AirQuality?.Current?.Pollutants?.Provider; + let pollutants; + if (provider === "Calculate" && !preFetched.index) { + // Upstream keeps Apple's available pollutants; fetch the selected + // source only when the original pollutant product is missing. + pollutants = original?.pollutants?.length ? wk3AirQualityToProvider(original) + : preFetched.pollutants ? await preFetched.pollutants + : pollutantProvider === "WeatherKit" ? undefined + : await InjectPollutants(Settings, enviroments); + if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return; + } + let candidate = preFetched.index ? await preFetched.index : await InjectIndex(pollutants, Settings, enviroments); + if (!validIndex(candidate)) return; + candidate = { ...candidate, metadata: { ...pollutants?.metadata, ...candidate.metadata } }; + if (provider !== "Calculate") { + const sameSource = pollutantProvider === "ColorfulClouds" && /^ColorfulClouds(?:US|CN)$/.test(provider) + || pollutantProvider === "QWeather" && provider === "QWeather"; + pollutants = original?.pollutants?.length ? wk3AirQualityToProvider(original) + : preFetched.pollutants ? await preFetched.pollutants + : sameSource ? candidate + : pollutantProvider === "WeatherKit" ? candidate + : await InjectPollutants(Settings, enviroments); + if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return; + } + // Calculators already normalize the concentrations used by their index. + // Unit selection operates on that result, rather than reverting to raw units. + const unitInput = provider === "Calculate" && Settings.AirQuality.Current.Pollutants.Units?.Mode !== "None" ? candidate : (pollutants ?? candidate); + if (provider === "Calculate") candidate.metadata.providerName = AirQuality.appendScaleToProviderName(candidate, Settings); + candidate.pollutants = AirQuality.ConvertPollutants(candidate, unitInput, true, candidate, Settings); + return candidate; +} + +async function wk3AQComparison(current, original, Settings, enviroments, preFetched) { + if (original && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange !== true + && [2, 3, 4].includes(original.previousDayComparison)) { + return { ...current, previousDayComparison: original.previousDayComparison }; + } + const aq = Settings.AirQuality; + const algorithm = aq.Calculate?.Algorithm; + const calculated = aq.Current?.Index?.Provider === "Calculate"; + const currentSource = original?.pollutants?.length ? "WeatherKit" : aq.Current?.Pollutants?.Provider; + const yesterdaySource = aq.Comparison?.Yesterday?.PollutantsProvider === "ColorfulCloudsCN" + ? "ColorfulClouds" : aq.Comparison?.Yesterday?.PollutantsProvider; + const currentProvider = calculated ? `Calculate:${currentSource}:${algorithm}` : aq.Current?.Index?.Provider; + const yesterdayProvider = aq.Comparison?.Yesterday?.IndexProvider === "Calculate" + ? `Calculate:${yesterdaySource}:${algorithm}` : aq.Comparison?.Yesterday?.IndexProvider; + try { + if (calculated && preFetched.index && !preFetched.pollutants) return { ...current, previousDayComparison: 0 }; + let yesterday = preFetched.yesterdayAirQuality, locationInfo; + if (currentProvider !== yesterdayProvider) { + // Upstream compares the selected source's own current/yesterday + // pair when its standard differs from the displayed index. + if (["ColorfulCloudsUS", "ColorfulCloudsCN"].includes(yesterdayProvider)) { + const useUsa = yesterdayProvider === "ColorfulCloudsUS"; + const today = await enviroments.colorfulClouds.CurrentAirQuality(useUsa, aq.Current.Index.ForceCNPrimaryPollutants); + const comparison = await injectWK3Comparison(today, { + currentProvider: yesterdayProvider, yesterdayProvider, sources: enviroments, + yesterday: preFetched.yesterdayAirQuality, + }); + const previousDayComparison = comparison.previousDayComparison; + const source = `彩云天气(${useUsa ? "美标" : "国标"})`; + Console.info("WK3 AQ comparison", `对比来源:${source}今日与昨日;结果=${previousDayComparison}。`); + return { ...current, previousDayComparison, metadata: { ...current.metadata, + providerName: previousDayComparison ? `${current.metadata.providerName}\n对比昨日:${source}` : current.metadata.providerName } }; + } + return { ...current, previousDayComparison: 0 }; + } + if (yesterday === undefined && (yesterdayProvider === "QWeather" || calculated && yesterdaySource === "QWeather")) { + const grid = preFetched.locationsGrid ?? await QWeather.GetLocationsGrid(undefined, () => {}); + locationInfo = QWeather.GetLocationInfo(grid, enviroments.qWeather.latitude, enviroments.qWeather.longitude); + } + if (yesterday === undefined && calculated) { + // The ColorfulClouds historical endpoint contains categories, not + // historical concentrations; it cannot stand in for calculated AQ. + if (yesterdaySource !== "QWeather") return { ...current, previousDayComparison: 0 }; + const source = await enviroments.qWeather.YesterdayAirQuality(locationInfo); + if (!source?.metadata || source.metadata.temporarilyUnavailable || !source.pollutants?.length) return { ...current, previousDayComparison: 0 }; + const result = AirQuality.Pollutants2AQI(source, Settings, { algorithm }); + yesterday = { ...result, metadata: { ...source.metadata, ...result?.metadata } }; + } + return await injectWK3Comparison(current, { currentProvider, yesterdayProvider, sources: enviroments, yesterday, locationInfo }); + } catch { + return { ...current, previousDayComparison: 0 }; + } +} + async function PatchWK3AirQuality(rawBody, Settings, enviroments, preFetched = {}) { try { const original = decodeWK3DataSet(rawBody); - if (!original) { - if (!Settings?.AirQuality?.Current?.Index?.Replace?.length) return rawBody; - return await PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched, true); + if (!original) return await PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched, true); + // Upstream EAQI is versionless. Update only the display resource string + // for an existing EAQI product; retain its values and all unknown fields. + if (/^EU\.EAQI\.\d+$/.test(original.scale)) { + const root = readUint32LE(rawBody, 0); + const field = getTableFieldOffset(rawBody, root, 0); + const table = root + field + readUint32LE(rawBody, root + field); + rawBody = appendFlatBufferObjectToTableField(rawBody, table, 7, createStringBuffer("EU.EAQI")); + original.scale = "EU.EAQI"; } const scaleName = AirQuality.GetNameFromScale(original.scale); const replace = Settings?.AirQuality?.Current?.Index?.Replace || []; - if (!replace.includes(scaleName) || !canInjectIndex(Settings)) return rawBody; - let injected; - if (preFetched.index) { - injected = await preFetched.index; - } else if (Settings?.AirQuality?.Current?.Index?.Provider === "Calculate") { - const pollutants = preFetched.pollutants ? await preFetched.pollutants : Settings?.AirQuality?.Current?.Pollutants?.Provider === "WeatherKit" ? original : await InjectPollutants(Settings, enviroments); - if (!pollutants?.metadata || pollutants.metadata.temporarilyUnavailable) return rawBody; - injected = await InjectIndex(pollutants, Settings, enviroments); - if (injected?.metadata) injected = { ...injected, metadata: { ...pollutants.metadata, ...injected.metadata } }; + const replaceIndex = replace.includes(scaleName) && canInjectIndex(Settings); + const replaceUnits = Settings?.AirQuality?.Current?.Pollutants?.Units?.Replace?.includes(scaleName); + if (!replaceIndex && !replaceUnits) return rawBody; + assertWK3AirQualityReplaceable(rawBody); + let replacement; + if (replaceIndex) { + const candidate = await wk3AQCandidate(original, Settings, enviroments, preFetched); + if (!candidate) return rawBody; + replacement = providerToWK3AirQuality(candidate); + const changedIndex = ["index", "categoryIndex", "scale"].some(key => replacement[key] !== original[key]); + if (!changedIndex && !replaceUnits && Settings.AirQuality.Current.Index.Provider !== "Calculate") return rawBody; + replacement = changedIndex ? await wk3AQComparison(replacement, original, Settings, enviroments, preFetched) + : { ...replacement, previousDayComparison: original.previousDayComparison }; } else { - injected = await InjectIndex(undefined, Settings, enviroments); - } - if (!validIndex(injected)) return rawBody; - const targetScale = AirQuality.GetNameFromScale(injected.scale); - if (!scaleName || targetScale !== scaleName) { - // CN -> US only. Replace the entire AQ child with one provider's - // consistent units/primary/source, leaving all other root arenas intact. - if (scaleName !== "HJ6332012" || targetScale !== "EPA_NowCast") return rawBody; - const root = readUint32LE(rawBody, 0); - const field = getTableFieldOffset(rawBody, root, 0); - const table = root + field + readUint32LE(rawBody, root + field); - const vtable = table - readInt32LE(rawBody, table); - // Do not discard future AQ fields when replacing the known child. - if (readUint16LE(rawBody, vtable) > 20) return rawBody; - const replacement = providerToWK3USAirQuality(injected); - if (Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange === true || ![2, 3, 4].includes(original.previousDayComparison)) { - Object.assign(replacement, await injectWK3Comparison(replacement, { - currentProvider: Settings?.AirQuality?.Current?.Index?.Provider, - yesterdayProvider: Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider, - sources: enviroments, - yesterday: preFetched.yesterdayAirQuality, - })); - } else { - replacement.previousDayComparison = original.previousDayComparison; - } - const builder = new flatbuffers.Builder(); - builder.finish(encodeWK3AirQuality(builder, replacement)); - const patched = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); - Console.info("WK3 AQ patch", `国标转美标: ${original.index} -> ${replacement.index} (${replacement.scale}); 同步污染物、单位、首要污染物及来源,昨日对比=${replacement.previousDayComparison}。`); - return patched; + const provider = wk3AirQualityToProvider(original); + const pollutants = AirQuality.ConvertPollutants(provider, provider, false, provider, Settings); + replacement = providerToWK3AirQuality({ ...provider, pollutants }); + replacement.previousDayComparison = original.previousDayComparison; } - if (!Number.isInteger(injected.index) || injected.index < 0 || injected.index > 32767 - || !Number.isInteger(injected.categoryIndex) || injected.categoryIndex < 0 || injected.categoryIndex > 127) return rawBody; - const root = readUint32LE(rawBody, 0); - const field = getTableFieldOffset(rawBody, root, 0); - const table = root + field + readUint32LE(rawBody, root + field); - // Omitted scalar slots cannot be written in place. Preserve the original - // rather than rebuilding a schema with unverified fields or enum defaults. - if (!getTableFieldOffset(rawBody, table, 1) || !getTableFieldOffset(rawBody, table, 2)) return rawBody; - const patched = new Uint8Array(rawBody); - writeTableInt8IfPresent(patched, table, 1, injected.categoryIndex); - writeTableInt16IfPresent(patched, table, 2, injected.index); - Console.info("WK3 AQ patch", `同标准 AQI: ${original.index} -> ${injected.index} (${scaleName}); 保留原始污染物、昨日对比与全部预报槽。`); + const builder = new flatbuffers.Builder(); + builder.finish(encodeWK3AirQuality(builder, replacement)); + const patched = appendFlatBufferTableToWeatherRoot(rawBody, 0, builder.asUint8Array()); + Console.info("WK3 AQ patch", `${original.scale} -> ${replacement.scale}; AQI: ${original.index} -> ${replacement.index}; 算法=${Settings.AirQuality.Current.Index.Provider === "Calculate" ? Settings.AirQuality.Calculate.Algorithm : "数据源直取"}; 污染物=${replacement.pollutants.length}; 昨日对比=${replacement.previousDayComparison}。`); return patched; } catch (error) { - Console.warn("WK3 AQ patch", "指数替换失败,保留 Apple 原始二进制响应。", error?.message ?? error); + Console.warn("WK3 AQ patch", "指数或单位转换失败,保留 Apple 原始二进制响应。", error?.message ?? error); return rawBody; } } async function PatchWK3MissingAirQuality(rawBody, Settings, enviroments, preFetched = {}, mainland = false) { - if (Settings?.AirQuality?.Current?.Index?.Provider !== "ColorfulCloudsUS") return rawBody; + if (!Settings?.AirQuality?.Current?.Index?.Replace?.length || !canInjectIndex(Settings)) return rawBody; const label = mainland ? "WK3 missing AQ patch" : "WK3 HK AQ patch"; - const region = mainland ? "大陆缺失" : "香港"; + const region = { TW: "台湾地区", MO: "澳门" }[enviroments.country] || (mainland ? "大陆" : "香港"); try { - const injected = preFetched.index ? await preFetched.index : await enviroments.colorfulClouds.CurrentAirQuality(true, Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants); - const replacement = await injectWK3Comparison(providerToWK3USAirQuality(injected), { - currentProvider: "ColorfulCloudsUS", - yesterdayProvider: Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider, - sources: enviroments, yesterday: preFetched.yesterdayAirQuality, - }); + const candidate = await wk3AQCandidate(null, Settings, enviroments, preFetched); + if (!candidate) return rawBody; + const replacement = await wk3AQComparison(providerToWK3AirQuality(candidate), null, Settings, enviroments, preFetched); const builder = new flatbuffers.Builder(); builder.finish(encodeWK3AirQuality(builder, replacement)); const patched = rebuildWeatherRootWithAddedTable(rawBody, 0, builder.asUint8Array()); - Console.info(label, `补入${region}美标 AQI ${replacement.index}; 昨日对比=${replacement.previousDayComparison},其他天气原样保留。`); + Console.info(label, `补入${region} AQI ${replacement.index} (${replacement.scale}); 算法=${Settings.AirQuality.Current.Index.Provider === "Calculate" ? Settings.AirQuality.Calculate.Algorithm : "数据源直取"}; 昨日对比=${replacement.previousDayComparison},其他天气原样保留。`); return patched; } catch (error) { - Console.warn(label, "数据源不可用,保持原响应。", error?.message ?? error); + Console.warn(label, "数据源或转换不可用,保持原响应。", error?.message ?? error); return rawBody; } } @@ -359,14 +414,15 @@ async function PatchWK3NextHour(rawBody, Settings, enviroments, preFetched = {}) // Inspect before replacing: unknown future fields retain their arena. decodeWK3NextHour(rawBody); const data = await InjectForecastNextHour(undefined, Settings, enviroments, preFetched.forecastNextHour); - const nextHour = providerToWK3NextHour(data, { intensityUnit: "mm/h", chanceUnit: "percent" }); + const report = {}; + const nextHour = providerToWK3NextHour(data, { intensityUnit: "mm/h", chanceUnit: "percent", report }); const builder = new flatbuffers.Builder(); builder.finish(encodeWK3NextHour(builder, nextHour)); const root = readUint32LE(rawBody, 0); const patched = getTableFieldOffset(rawBody, root, 5) ? appendFlatBufferTableToWeatherRoot(rawBody, 5, builder.asUint8Array()) : rebuildWeatherRootWithAddedTable(rawBody, 5, builder.asUint8Array()); - Console.info("WK3 nextHour patch", ...formatNextHourSummary(data)); + Console.info("WK3 nextHour patch", ...formatNextHourSummary({ ...data, condition: nextHour.condition }), `precipitationTypes: ${[...new Set(data.summary.flatMap(item => Array.isArray(item.condition) ? item.condition : [item.condition]))].join(",")}`, `omittedSnowDescriptions: ${report.omittedSnowConditions}`); return patched; } catch (error) { Console.warn("WK3 nextHour patch", "分钟降水替换失败,保留原响应。", error?.message ?? error); diff --git a/template/egern.wk3.handlebars b/template/egern.wk3.handlebars index 9da615a6..25703db8 100644 --- a/template/egern.wk3.handlebars +++ b/template/egern.wk3.handlebars @@ -1,5 +1,5 @@ name: 'WeatherKit-WK3' -description: '本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比、原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文' +description: '空气质量、原生预警与雨雪分钟增强。' author: WeatherKit-WK3 contributors homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png diff --git a/template/wk3.loon.handlebars b/template/wk3.loon.handlebars index 664357d6..a815a421 100644 --- a/template/wk3.loon.handlebars +++ b/template/wk3.loon.handlebars @@ -1,5 +1,5 @@ #!name = WeatherKit-Local-WK3 -#!desc = 本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比与苹果原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ diff --git a/template/wk3.quantumultx.handlebars b/template/wk3.quantumultx.handlebars index c156fe59..5d5e49cd 100644 --- a/template/wk3.quantumultx.handlebars +++ b/template/wk3.quantumultx.handlebars @@ -1,5 +1,5 @@ #!name = WeatherKit-Local-WK3 -#!desc = WK3 请求与文本处理、原生预警详情兼容;二进制天气直通,不改写 AQ、分钟雨或预警摘要;当前/逐日/逐小时天气保持苹果原始数据;无参数面板,部分城市 UV 防晒建议英文问题保留 +#!desc = WK3 请求与文本处理;二进制天气透传。安装与参数说明见仓库 modules/README.md。 #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ diff --git a/template/wk3.stash.handlebars b/template/wk3.stash.handlebars index fa2db98b..66010ee0 100644 --- a/template/wk3.stash.handlebars +++ b/template/wk3.stash.handlebars @@ -1,5 +1,5 @@ name: WeatherKit-Local-WK3 -desc: 本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比与苹果原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文 +desc: 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png version: "__WK3_VERSION__" diff --git a/template/wk3.surge.handlebars b/template/wk3.surge.handlebars index 43c2500c..c81433e4 100644 --- a/template/wk3.surge.handlebars +++ b/template/wk3.surge.handlebars @@ -1,5 +1,5 @@ #!name = WeatherKit-Local-WK3 -#!desc = 本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比与苹果原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ diff --git a/tests/wk3-alert-normalization.test.mjs b/tests/wk3-alert-normalization.test.mjs new file mode 100644 index 00000000..6e4ab3e9 --- /dev/null +++ b/tests/wk3-alert-normalization.test.mjs @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import { Builder } from "flatbuffers"; +import { buildEgern } from "../scripts/build-egern.mjs"; +import { buildPlatform } from "../scripts/build-wk3-platform.mjs"; +import { decodeWK3Alerts, patchWK3QWeatherAlerts } from "../src/wk3/class/WK3Alerts.mjs"; +import { sameAlertTitle, resolveNativeAlertDetails, buildNativeAlertDetailsURL } from "../src/wk3/class/NativeAlertDetails.mjs"; + +const issued = Date.parse("2030-01-01T00:00:00Z") / 1000; +const pageURL = "https://www.qweather.com/severe-weather/synthetic-101010100.html?from=AppleWeatherService"; +const blue = { title: "大风", severity: 4, color: "蓝" }; +const yellow = { title: "寒潮", severity: 3, color: "黄" }; +const orange = { title: "暴雪", severity: 2, color: "橙" }; // Upstream @nsringo/weatherkit 2.0.5 Severity. +function fixture(events) { + const b = new Builder(), details = b.createString(pageURL); + const offsets = events.map((event, index) => { + const description = b.createString(event.title), source = b.createString("国家预警信息发布中心"); + b.startVector(1, 16, 1); + for (let i = 15; i >= 0; i--) b.addInt8(index * 16 + i); + const bytes = b.endVector(); + b.startObject(1); b.addFieldOffset(0, bytes, 0); const id = b.endObject(); + b.startObject(event.unknown ? 23 : 22); + b.addFieldOffset(0, id, 0); b.addFieldOffset(5, description, 0); b.addFieldOffset(12, details, 0); + b.addFieldOffset(16, source, 0); b.addFieldInt32(9, event.time ?? issued, 0); b.addFieldInt8(14, event.severity, 0); + if (event.unknown) b.addFieldInt32(22, 42, 0); + return b.endObject(); + }); + b.startVector(4, offsets.length, 4); + for (const offset of [...offsets].reverse()) b.addOffset(offset); + const alerts = b.endVector(); + b.startObject(3); b.addFieldOffset(1, details, 0); b.addFieldOffset(2, alerts, 0); const collection = b.endObject(); + b.startObject(10); b.addFieldOffset(9, collection, 0); b.finish(b.endObject()); return b.asUint8Array(); +} +function html(events) { + return events.map(event => `<div class="c-city-warning-events warning--${{ 蓝: "blue", 黄: "yellow", 橙: "orange", 红: "red" }[event.color]}"><h3>测试区气象台发布${event.title}${event.color}色预警</h3><p>发布日期:${new Date((event.time ?? issued) * 1000).toISOString()}</p><p class="warning-events__txt">测试正文。</p></div>`).join(""); +} +const fetchPage = events => async () => new Response(html(events), { headers: { "Content-Type": "text/html" } }); +const cases = [ + { name: "signal suffix", events: [{ ...blue, title: " 大风预警信号。 " }], page: [blue], updated: 1, native: true }, + { name: "reordered yellow and blue", events: [blue, yellow], page: [yellow, blue], updated: 2, native: true }, + { name: "time differs by one second", events: [blue, yellow], page: [blue, { ...yellow, time: issued + 1 }], updated: 1, native: false, reason: "issued-time-mismatch" }, + { name: "missing second event", events: [blue, yellow], page: [blue], updated: 1, native: false, reason: "title-mismatch" }, + { name: "orange and blue", events: [blue, orange], page: [orange, blue], updated: 2, native: true }, + { name: "red highway and yellow road ice", events: [{ title: "高速公路灾害性天气预警", severity: 1, color: "红" }, { title: "道路结冰", severity: 3, color: "黄" }], page: [{ title: "道路结冰", severity: 3, color: "黄" }, { title: "高速公路灾害性天气预警", severity: 1, color: "红" }], updated: 2, native: true }, + { name: "generic red highway and named road ice", events: [{ title: "恶劣天气", severity: 1 }, { title: "道路结冰", severity: 3 }], page: [{ title: "道路结冰", severity: 3, color: "黄" }, { title: "高速公路灾害性天气预警", severity: 1, color: "红" }], updated: 2, native: true }, + { name: "generic severity mismatch", events: [{ title: "恶劣天气", severity: 1 }], page: [blue], updated: 0, native: false, reason: "severity-mismatch" }, + { name: "generic requires exact time", events: [{ title: "恶劣天气", severity: 1 }], page: [{ title: "暴雨", severity: 1, color: "红", time: issued + 1 }], updated: 0, native: false, reason: "issued-time-mismatch" }, + { name: "generic requires nonzero Apple time", events: [{ title: "恶劣天气", severity: 1, time: 0 }], page: [{ title: "暴雨", severity: 1, color: "红" }], updated: 0, native: false, reason: "missing-issued-time" }, + { name: "generic does not choose by order", events: [{ title: "恶劣天气", severity: 1 }], page: [{ title: "暴雨", severity: 1, color: "红" }, { title: "高速公路灾害性天气预警", severity: 1, color: "红" }], updated: 0, native: false, reason: "ambiguous-match" }, + { name: "two generic alerts cannot share candidate", events: [{ title: "恶劣天气", severity: 1 }, { title: "恶劣天气", severity: 1 }], page: [{ title: "暴雨", severity: 1, color: "红" }], updated: 1, native: false, reason: "ambiguous-match" }, + { name: "unknown severity", events: [{ ...blue, severity: 231 }], page: [blue], updated: 0, native: false, reason: "unverified-severity" }, + { name: "unrelated substring", events: [blue], page: [{ ...blue, title: "雷暴大风" }], updated: 0, native: false, reason: "title-mismatch" }, + { name: "duplicate candidate", events: [blue], page: [blue, blue], updated: 0, native: false, reason: "ambiguous-match" }, + { name: "shared candidate", events: [blue, { ...blue, title: "大风蓝色预警信号" }], page: [blue], updated: 1, native: false, reason: "ambiguous-match" }, + { name: "unverified summary field", events: [{ ...blue, unknown: true }], page: [blue], updated: 0, native: false, stage: "summary-patch-failed" }, +]; + +test("one title normalizer accepts suffix, whitespace and punctuation but rejects unrelated events and colors", () => { + for (const title of ["大风", "大风预警", "大风预警信号", " 大 风 蓝色预警信号。 "]) assert.equal(sameAlertTitle(title, "大风蓝色预警"), true); + for (const title of ["雷暴大风", "风", "大风黄色预警", "", null]) assert.equal(sameAlertTitle(title, "大风蓝色预警"), false); + assert.equal(sameAlertTitle("Wind", "wind"), true); + assert.equal(sameAlertTitle("高速公路灾害性天气预警", "高速公路灾害性天气预警红色预警信号"), true); + assert.equal(sameAlertTitle("高速公路灾害性天气", "高速公路灾害性天气预警红色预警"), true); + assert.equal(sameAlertTitle("高速公路灾害性天气预警黄色预警", "高速公路灾害性天气预警红色预警"), false); +}); + +test("summary report describes only committed changes and retains every matching safeguard", async () => { + for (const scenario of cases) { + const raw = fixture(scenario.events), report = {}; + const out = await patchWK3QWeatherAlerts(raw, { fetchImpl: fetchPage(scenario.page), rewriteDetails: true, report }); + assert.equal(report.summariesUpdated, scenario.updated, scenario.name); + assert.equal(report.detailsConverted, scenario.native, scenario.name); + assert.equal(new URL(decodeWK3Alerts(out).detailsUrl).hostname, scenario.native ? "weatherkit.apple.com" : "www.qweather.com", scenario.name); + if (scenario.reason) assert.equal(report.unmatched[scenario.reason], 1, scenario.name); + if (scenario.reason === "title-mismatch") { + assert.deepEqual(Object.keys(report.titleMismatches[0]), ["appleTitle", "pageTitles", "parsedTitles"]); + assert.ok(report.titleMismatches[0].appleTitle); + assert.ok(report.titleMismatches[0].pageTitles[0].startsWith("测试区气象台发布")); + assert.equal(report.titleMismatches[0].parsedTitles.length, scenario.page.length); + } else assert.deepEqual(report.titleMismatches, []); + if (scenario.stage) { assert.equal(report.reason, scenario.stage); assert.strictEqual(out, raw); } + } + const report = {}, raw = fixture([blue]); + assert.strictEqual(await patchWK3QWeatherAlerts(raw, { report, fetchImpl: async () => { throw new Error("offline"); }, rewriteDetails: true }), raw); + assert.equal(report.reason, "page-fetch-failed"); + assert.equal(report.summariesUpdated, 0); assert.equal(report.detailsConverted, false); +}); + +async function executePlatform(code, platform, raw, page, stage = "response", url = "https://weatherkit.apple.com/api/v3/weather/zh-CN/31/121?country=CN&dataSets=weatherAlerts") { + const logs = [], requests = []; + const logger = Object.fromEntries(["log", "info", "debug", "warn", "error"].map(key => [key, (...args) => logs.push(args.join(" "))])); + const fetch = async (requestURL, options) => { + assert.equal(String(requestURL), pageURL); + assert.deepEqual(Object.keys(options.headers).map(key => key.toLowerCase()), ["accept"]); + requests.push(requestURL); + return new Response(html(page), { headers: { "Content-Type": "text/html" } }); + }; + const headers = { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }; + let result; + if (platform === "egern") { + const transformed = code.replace("export default async function(ctx)", "globalThis.run = async function(ctx)"); + const context = { URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Headers, btoa, atob, console: logger }; + vm.createContext(context); vm.runInContext(transformed, context, { timeout: 30000 }); + result = await context.run({ env: { DataSets: "weatherAlerts", LogLevel: "INFO" }, request: { url, method: "GET", headers: new Headers() }, response: { status: 200, headers: new Headers(headers), arrayBuffer: async () => raw.slice().buffer }, http: { get: fetch }, respond: value => value }); + } else { + let finish; + const complete = new Promise(resolve => { finish = resolve; }); + const globals = { URL, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, console: logger, + $argument: { DataSets: ["weatherAlerts"], LogLevel: "INFO" }, + $request: { url, method: "GET", headers: {} }, + $response: stage === "response" ? { status: 200, headers, body: raw } : undefined, + $done: finish }; + if (platform === "quantumultx") globals.$task = { fetch: async options => { const r = await fetch(options.url, options); return { statusCode: r.status, headers: Object.fromEntries(r.headers), body: await r.text() }; } }; + else globals.$httpClient = { get(options, callback) { fetch(options.url, options).then(async r => callback(null, { status: r.status, headers: Object.fromEntries(r.headers) }, await r.text())); } }; + vm.runInNewContext(code, globals, { timeout: 30000 }); + let timer; + try { result = await Promise.race([complete, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("platform completion timeout")), 5000); })]); } + finally { clearTimeout(timer); } + if (stage === "request" && platform !== "quantumultx") result = result?.response; + } + return { result, logs, requests }; +} + +for (const platform of ["egern", "surge", "loon", "stash", "quantumultx"]) { + test(`${platform}: built scripts normalize titles and log summary and native conversion independently`, async () => { + const outdir = process.env.WK3_TEST_OUTDIR ? `${process.env.WK3_TEST_OUTDIR}/${platform}` : undefined; + const bundles = process.env.WK3_TEST_BUNDLE_DIR + ? Object.fromEntries(await Promise.all(["request", "response"].map(async stage => [stage, { code: await readFile(`${process.env.WK3_TEST_BUNDLE_DIR}/${platform}/wk3-${platform}-${stage}.bundle.js`, "utf8") }]))) + : platform === "egern" ? await buildEgern({ outdir, filePrefix: "wk3-egern-" }) : await buildPlatform(platform, { outdir }); + if (platform !== "egern") { + const url = new URL(buildNativeAlertDetailsURL({ identifier: "synthetic-101010100", alerts: [{ id: { bytes: Array.from({ length: 16 }, (_, i) => i) }, description: " 大风预警信号。 ", issuedTime: issued, severity: 4 }] })); + const requestURL = `https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=${url.searchParams.get("ids")}`; + const detail = await executePlatform(bundles.request.code, platform, fixture([blue]), [blue], "request", requestURL); + const parsed = JSON.parse(detail.result.body); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].id, "00010203-0405-0607-0809-0a0b0c0d0e0f"); + assert.equal(parsed[0].description, "大风蓝色预警"); + } + for (const scenario of cases) { + const raw = fixture(scenario.events); + const { result, logs, requests } = await executePlatform(bundles.response.code, platform, raw, scenario.page); + if (platform === "quantumultx") { + assert.equal(result, undefined); assert.equal(requests.length, 0); + assert.equal(logs.some(line => line.includes("WK3 alerts summary") || line.includes("WK3 alerts details")), false); + continue; + } + const bytes = typeof result.body === "string" ? Uint8Array.from(result.body, c => c.charCodeAt(0)) : result.body; + const after = decodeWK3Alerts(bytes); + assert.equal(new URL(after.detailsUrl).hostname, scenario.native ? "weatherkit.apple.com" : "www.qweather.com", scenario.name); + const summaryLogs = logs.filter(line => line.includes("WK3 alerts summary")); + const detailLogs = logs.filter(line => line.includes("WK3 alerts details")); + assert.equal(summaryLogs.length, 1); assert.equal(detailLogs.length, 1); + assert.ok(summaryLogs[0].includes(`摘要补全:${scenario.updated}/${scenario.events.length}`), scenario.name); + assert.ok(detailLogs[0].includes(scenario.native ? "已切换苹果原生详情" : "未转换,保留原链接"), scenario.name); + if (scenario.reason) assert.ok(detailLogs[0].includes(scenario.reason), scenario.name); + const titleLogs = logs.filter(line => line.includes("WK3 alerts titles")); + assert.equal(titleLogs.length, scenario.reason === "title-mismatch" ? 1 : 0, scenario.name); + if (titleLogs.length) { + assert.ok(titleLogs[0].includes('"appleTitle"')); + assert.ok(titleLogs[0].includes('"pageTitles"')); + assert.ok(titleLogs[0].includes('"parsedTitles"')); + assert.ok(!titleLogs[0].includes(pageURL)); + assert.ok(!titleLogs[0].includes("issuedTime")); + } + if (scenario.native) { + const native = new URL(after.detailsUrl); + const details = await resolveNativeAlertDetails(native.searchParams.get("ids"), "zh-CN", fetchPage(scenario.page)); + assert.equal(details.length, scenario.events.length); + if (scenario.name === "generic red highway and named road ice") { + assert.equal(after.alerts[0].description, "高速公路灾害性天气预警红色预警"); + const payload = JSON.parse(Buffer.from(native.searchParams.get("ids").slice(4), "base64url").toString("utf8")); + assert.equal(payload.alerts[0].description, after.alerts[0].description); + const requestURL = `https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=${native.searchParams.get("ids")}`; + const resolved = await executePlatform(bundles.request.code, platform, raw, scenario.page, "request", requestURL); + assert.deepEqual(JSON.parse(resolved.result.body), details); + } + assert.deepEqual(details.map(item => item.id), scenario.events.map((_, i) => { + const h = Array.from({ length: 16 }, (_, j) => (i * 16 + j).toString(16).padStart(2, "0")).join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; + })); + } + } + }); +} diff --git a/tests/wk3-loon-binary.test.mjs b/tests/wk3-loon-binary.test.mjs index e40fed13..582a31e9 100644 --- a/tests/wk3-loon-binary.test.mjs +++ b/tests/wk3-loon-binary.test.mjs @@ -75,7 +75,7 @@ test("actual minified Loon bundle returns decodable WK3 US AQ after source reque const aq = encodeAirQuality(b, { metadata: { sourceType: 3, providerName: "Apple" }, scale: "HJ6332012.2604", index: 60, categoryIndex: 2, pollutants: [], primaryPollutant: 11 }); b.startObject(15); b.addFieldOffset(0, aq, 0); b.finish(b.endObject()); const { response } = await bundle(); - const { result, logs, requests } = await execute(response.code, b.asUint8Array(), { "AirQuality.Comparison.Yesterday.IndexProvider": "WeatherKit" }); + const { result, logs, requests } = await execute(response.code, b.asUint8Array(), { "AirQuality.Current.Index.Provider": "ColorfulCloudsUS", "AirQuality.Comparison.Yesterday.IndexProvider": "WeatherKit" }); assert.ok(result.body instanceof Uint8Array, "returned WK3 must stay binary for Loon"); const decoded = decodeWK3DataSet(result.body); assert.equal(decoded.index, 87); diff --git a/tests/wk3-module-parameters.test.mjs b/tests/wk3-module-parameters.test.mjs index 24bd4ce9..d2afe32a 100644 --- a/tests/wk3-module-parameters.test.mjs +++ b/tests/wk3-module-parameters.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { displayModuleOption } from '../src/wk3/option-values.mjs'; import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; import { renderLocalModule } from '../scripts/build-egern-module.mjs'; import { renderWK3Module } from '../scripts/build-wk3-modules.mjs'; @@ -21,11 +22,12 @@ test('upstream names and types, preserved local defaults and supported options', assert.equal(parameter.name, block.match(/name: "([^"]+)"/)[1], parameter.key); assert.equal(parameter.type, block.match(/type: "([^"]+)"/)[1], parameter.key); } - for (const key of ['AirQuality.Current.Index.Provider', 'AirQuality.Comparison.Yesterday.IndexProvider']) assert.equal(parameters.get(key).value, 'ColorfulCloudsUS'); - assert.equal(parameters.get('AirQuality.Calculate.Algorithm').value, 'WAQI_InstantCast_US'); + assert.equal(parameters.get('AirQuality.Current.Index.Provider').value, 'Calculate'); + assert.equal(parameters.get('AirQuality.Comparison.Yesterday.IndexProvider').value, 'ColorfulCloudsUS'); + assert.equal(parameters.get('AirQuality.Calculate.Algorithm').value, 'EU_EAQI'); assert.equal(parameters.get('LogLevel').value, 'INFO'); assert.deepEqual(parameters.get('WeatherAlerts.Provider').options, ['WeatherKit', 'QWeatherWeb']); - assert.ok(parameters.get('AirQuality.Comparison.Yesterday.IndexProvider').options.includes('WeatherKit')); + assert.deepEqual(parameters.get('AirQuality.Comparison.Yesterday.PollutantsProvider').options, ['QWeather']); assert.equal(parameters.get('AirQuality.Calculate.Algorithm').options.length, 12); assert.equal(parameters.get('AirQuality.Calculate.Algorithm').options[0], 'None'); assert.ok(!parameters.has('Storage') && !parameters.has('API.WAQI.Token')); @@ -38,10 +40,10 @@ test('Egern actual env schema and compatibility help show all supported choices' assert.equal(Object.keys(schema).length, 23); for (const parameter of localParameters) { assert.equal(schema[parameter.key].name, parameter.name); - assert.equal(schema[parameter.key].default_value, parameter.value); + assert.equal(schema[parameter.key].default_value, displayModuleOption(parameter.key, parameter.value)); assert.equal(schema[parameter.key].description, parameterDescription(parameter)); assert.ok(help.includes(parameter.name)); - if (parameter.options) assert.deepEqual(schema[parameter.key].options, parameter.options); + if (parameter.options) assert.deepEqual(schema[parameter.key].options, parameter.options.map(value => displayModuleOption(parameter.key, value))); if (parameter.type === 'array') assert.equal(schema[parameter.key].options, undefined, 'lists must remain editable, not single selects'); } }); @@ -50,9 +52,9 @@ for (const platform of ['surge', 'loon', 'stash']) test(`${platform}: actual mod const text = renderWK3Module(platform, version); for (const parameter of localParameters) { assert.ok(text.includes(parameter.name), parameter.key); - for (const option of parameter.options ?? parameter.choices ?? []) assert.ok(text.includes(option), `${parameter.key}: ${option}`); + if (platform === 'loon') for (const option of parameter.options ?? []) assert.ok(text.includes(option), `${parameter.key}: ${option}`); } - for (const expected of ['不会自动添加国内目标', 'false 保留', '12 个选项', 'API 预警尚未移植', 'WK3 当前/逐日/逐小时天气']) assert.ok(text.includes(expected), expected); + for (const parameter of localParameters) assert.ok(parameter.description.length <= 80, parameter.key); assert.doesNotMatch(text, /确保包含国内原标准|十二种算法|始终仅做同源/); }); diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs index 12a11637..fd0570c2 100644 --- a/tests/wk3-modules.test.mjs +++ b/tests/wk3-modules.test.mjs @@ -62,7 +62,7 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.match(text, /weather-analytics-events\.apple\.com/); assert.match(text, /tthr\.apple\.com/); assert.match(text, /tether\.edge\.apple/); - assert.match(text, /当前\/逐日\/逐小时天气保持苹果原始数据/); + assert.ok(text.includes('WK3') || text.includes('WeatherKit')); assert.doesNotMatch(text, /__WK3_|releases\/latest|shadowrocket|NSRingo\/WeatherKit\/releases|gist\.githubusercontent/); for (const [index, group] of groups.entries()) { assert.ok(rules[index].includes(group.pattern), `${platform} ${group.name} regex`); @@ -76,7 +76,7 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.equal(text.split(`wk3-${platform}-response.bundle.js`).length - 1, 2); assert.equal((text.match(/v3\.3\.2\//g) ?? []).length, 5); if (platform === 'surge') { - assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"WAQI_InstantCast_US"/m); + assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"EU_EAQI"/m); assert.match(rules[4], /requires-body=1, binary-body-mode=1/); assert.match(rules[0], /requires-body=1/); assert.ok(rules.slice(1, 4).every(line => !line.includes('binary-body-mode=1'))); @@ -91,9 +91,9 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.match(args.find(line => line.startsWith('Weather.Provider =')), /select,"ColorfulClouds","WeatherKit","QWeather"/); const algorithms = args.find(line => line.startsWith('AirQuality.Calculate.Algorithm =')); const officialOrder = ['None', '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']; - assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"WAQI_InstantCast_US",/); + assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"EU_EAQI",/); assert.deepEqual(algorithms.split(',tag=')[0].match(/"[^"]+"/g).map(value => JSON.parse(value)), - ['WAQI_InstantCast_US', ...officialOrder.filter(value => value !== 'WAQI_InstantCast_US')]); + ['EU_EAQI', ...officialOrder.filter(value => value !== 'EU_EAQI')]); assert.match(rules[4], /requires-body=1, binary-body-mode=1/); assert.match(rules[0], /requires-body=1/); assert.match(rules[0], /argument=\[\{DataSets\},/); @@ -112,8 +112,8 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv const argumentsJSON = [JSON.parse(sharedArgument[1])]; for (const values of argumentsJSON) { assert.equal(Object.keys(values).length, 23); - assert.equal(values['AirQuality.Calculate.Algorithm'], 'WAQI_InstantCast_US'); - assert.equal(values['AirQuality.Current.Index.Provider'], 'ColorfulCloudsUS'); + assert.equal(values['AirQuality.Calculate.Algorithm'], 'EU_EAQI'); + assert.equal(values['AirQuality.Current.Index.Provider'], 'Calculate'); } for (const group of groups) assert.match(providers, new RegExp(`^ WeatherKit\\.api\\.${group.name.replaceAll('.', '\\.')}:[\\s\\S]*? interval: 86400`, 'm')); } diff --git a/tests/wk3/aq-algorithm-scales.test.mjs b/tests/wk3/aq-algorithm-scales.test.mjs new file mode 100644 index 00000000..e9d5bb85 --- /dev/null +++ b/tests/wk3/aq-algorithm-scales.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import AirQuality from "../../src/wk3/class/AirQuality.mjs"; +import AirQualityScale from "../../src/wk3/class/AirQualityScale.mjs"; +import { Response as processResponse } from "../../src/wk3/process/Response.mjs"; + +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", +]; +function calculate(algorithm, amount = 25) { + return AirQuality.Pollutants2AQI({ + metadata: { providerName: "Synthetic", temporarilyUnavailable: false }, + pollutants: ["PM2_5", "PM10", "OZONE", "NO2", "SO2"].map(pollutantType => ({ + pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER", + })), + }, {}, { algorithm }); +} +function local(scaleName, language = "en-US") { + const result = AirQualityScale.buildScale(language, scaleName); + assert.ok(result, `missing local fallback for ${scaleName}`); + assert.equal(result.status, 200); + return JSON.parse(result.body); +} +for (const algorithm of algorithms) { + test(`${algorithm}: emitted scale has matching identity, category count and display ranges`, () => { + const definition = AirQuality.Config.Scales[algorithm]; + const current = calculate(algorithm); + const scale = local(current.scale); + assert.equal(scale.name, definition.weatherKitScale.name); + assert.equal(scale.version, Number(definition.weatherKitScale.version ?? 1)); + assert.equal(scale.aqi.categories.length, definition.categories.ranges.length); + assert.equal(scale.aqi.numerical, algorithm !== "EU_EAQI"); + assert.equal(scale.aqi.ascending, true); + for (const amount of [0, 1, 25, 100, 300]) { + const aq = calculate(algorithm, amount); + assert.equal(aq.metadata.temporarilyUnavailable, false); + const category = scale.aqi.categories.find(item => item.categoryNumber === aq.categoryIndex); + assert.ok(category, `${algorithm}: missing category ${aq.categoryIndex}`); + assert.ok(aq.index >= category.range[0] && aq.index <= category.range[1], + `${algorithm}: index ${aq.index}, category ${aq.categoryIndex}, range ${category.range}`); + assert.ok(aq.index >= scale.aqi.range[0] && aq.index <= scale.aqi.range[1], + `${algorithm}: index ${aq.index} outside scale range ${scale.aqi.range}`); + } + }); +} + +test("AQHI names retain their dotted identity with or without a numeric version suffix", () => { + for (const name of ["HK.AQHI", "CN.AQHI", "CA.AQHI"]) { + assert.equal(local(name).name, name); + assert.equal(local(name).version, 1); + for (const version of [2414, 2604]) { + assert.equal(local(`${name}.${version}`).name, name); + assert.equal(local(`${name}.${version}`).version, version); + } + } +}); + +test("Canada AQHI uses Canadian health risk bands, 1..10 plus 10+, without HK labels", () => { + for (const language of ["en-US", "zh-Hans-CN", "zh-Hant-HK", "zh-Hant-TW"]) { + const scale = local("CA.AQHI", language); + assert.deepEqual(scale.aqi.range, [1, 11]); + assert.equal(scale.name, "CA.AQHI"); + assert.match(scale.longDisplayName, /Canada|加拿大/); + assert.doesNotMatch(scale.longDisplayName, /Hong Kong|香港/); + assert.equal(scale.aqi.categories.length, 11); + for (const [i, category] of scale.aqi.categories.entries()) { + assert.equal(category.categoryNumber, i + 1); + assert.deepEqual(category.range, [i + 1, i + 1]); + assert.ok(category.categoryName); + assert.ok(category.recommendation); + assert.match(category.color, /^#[0-9A-F]{6}$/i); + } + } + const categories = local("CA.AQHI").aqi.categories; + assert.deepEqual(categories.map(item => item.categoryName), [ + "Low Health Risk", "Low Health Risk", "Low Health Risk", + "Moderate Health Risk", "Moderate Health Risk", "Moderate Health Risk", + "High Health Risk", "High Health Risk", "High Health Risk", "High Health Risk", "Very High Health Risk", + ]); + assert.notDeepEqual(categories, local("HK.AQHI").aqi.categories); +}); + +for (const algorithm of algorithms) { + test(`${algorithm}: Apple 404 falls back through the real response route`, async () => { + const current = calculate(algorithm); + const original = { status: 404, headers: { "content-type": "application/json" }, body: '{"error":"not found"}' }; + const result = await processResponse({ + url: `https://weatherkit.apple.com/api/v2/airQualityScale/en-US/${current.scale}`, + headers: {}, + }, original, { Settings: {} }); + assert.equal(result.status, 200); + const scale = JSON.parse(result.body); + assert.equal(scale.name, AirQuality.Config.Scales[algorithm].weatherKitScale.name); + assert.ok(scale.aqi.categories.some(item => item.categoryNumber === current.categoryIndex)); + }); +} + +test("Apple 200 remains unchanged and unknown local standards stay unsupported", async () => { + const response = { status: 200, headers: { "content-type": "application/json" }, body: '{"name":"Apple original"}' }; + const result = await processResponse({ + url: "https://weatherkit.apple.com/api/v2/airQualityScale/en-US/CA.AQHI", headers: {}, + }, response, { Settings: {} }); + assert.equal(result, response); + assert.equal(result.body, '{"name":"Apple original"}'); + for (const name of ["Other.AQHI", "CABogus.AQHI", "HK.Other", "EU.Other", "CA.AQHI.extra"]) { + assert.equal(AirQualityScale.buildScale("en-US", name), null, name); + } +}); + +test("all algorithm display indices map to exactly their numbered category", () => { + const expected = { + "UBA.2414": [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]], + "EU.EAQI.2414": [[0, 9], [10, 19], [20, 29], [30, 39], [40, 49], [50, 60]], + "HJ6332012.2604": [[0, 50], [51, 100], [101, 150], [151, 200], [201, 300], [301, 500]], + "EPA_NowCast.2414": [[0, 50], [51, 100], [101, 150], [151, 200], [201, 300], [301, 500]], + "HK.AQHI": Array.from({ length: 11 }, (_, i) => [i + 1, i + 1]), + "CA.AQHI": Array.from({ length: 11 }, (_, i) => [i + 1, i + 1]), + "CN.AQHI": Array.from({ length: 11 }, (_, i) => [i + 1, i + 1]), + }; + for (const [name, ranges] of Object.entries(expected)) { + const scale = local(name); + assert.deepEqual(scale.aqi.categories.map(item => item.range), ranges, name); + for (let value = scale.aqi.range[0]; value <= scale.aqi.range[1]; value++) { + assert.equal(scale.aqi.categories.filter(item => value >= item.range[0] && value <= item.range[1]).length, 1, + `${name}: index ${value} must map to one category`); + } + } +}); + +test("existing HK/CN AQHI bands, colors and ranges remain intact", () => { + const expected = { + "HK.AQHI": { + colors: ["#04DE71", "#04DE71", "#58E156", "#FFE620", "#FFBE10", "#FF9500", "#FA114F", "#D11343", "#A91537", "#80172B", "#80172B"], + names: ["低", "低", "低", "中", "中", "中", "高", "甚高", "甚高", "甚高", "严重"], + }, + "CN.AQHI": { + colors: ["#2094FA", "#04DE71", "#CCFF66", "#FFE620", "#FF9500", "#FA114F", "#DC1346", "#BD143D", "#9F1634", "#80172B", "#80172B"], + names: ["极低", "低", "低", "中", "中", "高", "高", "高", "高", "高", "极高"], + }, + }; + for (const [name, { colors, names }] of Object.entries(expected)) { + const scale = local(name, "zh-Hans-CN"); + assert.deepEqual(scale.aqi.range, [1, 11]); + assert.deepEqual(scale.aqi.categories.map(item => item.color), colors, name); + assert.deepEqual(scale.aqi.categories.map(item => item.categoryName), names, name); + } + assert.deepEqual(local("UBA.2414").aqi.categories.map(item => item.color), ["#00C853", "#64DD17", "#FFEB3B", "#FF9800", "#F44336"]); + assert.deepEqual(local("EPA_NowCast.2414").aqi.categories.map(item => item.color), ["#00E400", "#FFFF00", "#FF7E00", "#FF0000", "#99004C", "#7E0023"]); +}); diff --git a/tests/wk3/aq-all-algorithms.test.mjs b/tests/wk3/aq-all-algorithms.test.mjs new file mode 100644 index 00000000..b7e8fcea --- /dev/null +++ b/tests/wk3/aq-all-algorithms.test.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import { setLogEnv } from '../../src/wk3/utils/Console.mjs'; + +setLogEnv({ LogLevel: 'OFF' }); +const ug = 'MICROGRAMS_PER_CUBIC_METER', ppb = 'PARTS_PER_BILLION'; +const sample = () => ({ metadata: { providerName: 'Synthetic' }, pollutants: [ + ['PM2_5', 25], ['PM10', 50], ['OZONE', 100], ['NO2', 25], ['SO2', 40], ['CO', 600], +].map(([pollutantType, amount]) => ({ pollutantType, amount, units: ug })) }); +// Measured against upstream b4a75db and the actual response.dev.bundle.js. +// CA retains the existing corrected ppb conversion and rounded AQHI formula. +const cases = [ + ['UBA', 'UBA.2414', 3, 3, 'PM10', [2.636428571429, 2.837692307692, 2.376478873239, 1.729473684211, 2.30724137931, -1]], + ['EU_EAQI', 'EU.EAQI', 22, 3, 'PM2_5', [22.382352941176, 20.486486486486, 19, 19, 19, -1]], + ['WAQI_InstantCast_US', 'EPA_NowCast.2414', 81, 2, 'PM2_5', [80.623574144487, 46.296296296296, 40.72, 12.264150943396, 21.428571428571, 5.681818181818]], + ['WAQI_InstantCast_CN', 'HJ6332012.2604', 50, 1, 'NOT_AVAILABLE', [35.714285714286, 50, 31.25, 12.5, 13.333333333333, 6]], + ['WAQI_InstantCast_CN_25_DRAFT', 'HJ6332012.2604', 50, 1, 'NOT_AVAILABLE', [41.666666666667, 50, 31.25, 12.5, 13.333333333333, 6]], + ['CA_AQHI', 'CA.AQHI', 5, 5, 'OZONE', [1.224941701489, 0, 2.774951470588, 1.164683764963, 0, -1]], + ['HK_AQHI', 'HK.AQHI', 5, 5, 'OZONE', [0.546630351405, 1.420875321381, 5.009468889136, 1.121886218007, 0.558849771744, -1]], + ['AQHI_Multi_CN', 'CA.AQHI', 4, 4, 'OZONE', [3.0162, 0, 27.1273, 9.769525, 8.68896, -1]], + ['AQHI_Multi_CN_HK', 'CA.AQHI', 4, 4, 'OZONE', [3.0162, 7.80615, 27.1273, 9.769525, 8.68896, -1]], + ['CN_DEATH_AQHI', 'CN.AQHI', 4, 4, 'OZONE', [0.551515276734, 0, 2.429031789062, 2.275503416445, 2.388068369577, -1]], + ['CN_DEATH_HK_AQHI', 'CN.AQHI', 5, 5, 'OZONE', [0.546630351405, 1.420875321381, 2.429031789062, 2.275503416445, 2.388068369577, -1]], +]; +const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-8, `${message}: ${actual} != ${expected}`); +function settings(algorithm, mode = 'Scale', replace = []) { + return buildSettings(database, { AirQuality: { Calculate: { Algorithm: algorithm, AllowOverRange: true }, Current: { Index: { Provider: 'Calculate', ForceCNPrimaryPollutants: false }, Pollutants: { Units: { Mode: mode, Replace: replace } } } } }).Settings; +} +for (const [algorithm, scale, index, categoryIndex, primaryPollutant, indexes] of cases) { + const name = AirQuality.GetNameFromScale(scale); + test(`${algorithm}: configured calculation, six pollutant values and schema`, () => { + const input = sample(); + const saved = structuredClone(input); + Object.freeze(input.metadata); + input.pollutants.forEach(Object.freeze); + Object.freeze(input.pollutants); + Object.freeze(input); + const result = AirQuality.Pollutants2AQI(input, settings(algorithm)); + assert.equal(result.scale, scale); + assert.equal(result.index, index); + assert.equal(result.categoryIndex, categoryIndex); + assert.equal(result.primaryPollutant, primaryPollutant); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(typeof result.isSignificant, 'boolean'); + assert.equal(result.pollutants.length, 6); + assert.deepEqual(Object.keys(result).sort(), ['index', 'categoryIndex', 'primaryPollutant', 'scale', 'isSignificant', 'metadata', 'pollutants'].sort()); + for (let i = 0; i < 6; i++) { + const p = result.pollutants[i], original = saved.pollutants[i]; + assert.equal(p.pollutantType, original.pollutantType); + const factor = AirQuality.Config.Scales[algorithm].pollutants[p.pollutantType]?.stpConversionFactor; + const amount = p.units === ug ? p.amount : AirQuality.ConvertUnit(p.amount, p.units, ug, factor); + close(amount, original.amount, `${algorithm}/${p.pollutantType} concentration`); + close(p.index, indexes[i], `${algorithm}/${p.pollutantType} index`); + } + assert.deepEqual(input, saved); + }); + test(`${algorithm}: six actual zeros are usable and preserved`, () => { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 0; }); + const result = AirQuality.Pollutants2AQI(input, settings(algorithm)); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(result.categoryIndex, 1); + assert.equal(result.index, algorithm === 'UBA' || algorithm.includes('AQHI') ? 1 : 0); + assert.equal(result.pollutants.length, 6); + assert.ok(result.pollutants.every(p => p.amount === 0)); + }); + test(`${algorithm}: AllowOverRange follows upstream per-algorithm policy`, () => { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 100000; }); + const capped = AirQuality.Pollutants2AQI(input, settings(algorithm), { allowOverRange: false }); + const extended = AirQuality.Pollutants2AQI(input, settings(algorithm), { allowOverRange: true }); + assert.equal(capped.metadata.temporarilyUnavailable, false); + assert.equal(extended.metadata.temporarilyUnavailable, false); + const max = algorithm === 'UBA' ? 5 : algorithm === 'EU_EAQI' ? 60 : algorithm.includes('AQHI') ? 11 : 500; + assert.equal(capped.index, max); + assert.equal(extended.index, algorithm === 'WAQI_InstantCast_US' ? 199051 : algorithm.startsWith('WAQI_InstantCast_CN') ? 99900 : max); + assert.equal(capped.categoryIndex, extended.categoryIndex); + }); + for (const mode of ['None', 'Scale', 'ugm3', 'EU_ppb', 'US_ppb', 'Force_ugm3', 'Force_EU_ppb', 'Force_US_ppb']) { + test(`${algorithm}: unit conversion ${mode} preserves six physical pollutants`, () => { + const input = sample(); + const calculated = AirQuality.Pollutants2AQI(input, settings(algorithm)); + const configured = settings(algorithm, mode, [name]); + const result = AirQuality.ConvertPollutants(input, undefined, true, calculated, configured); + assert.equal(result.length, 6); + for (let i = 0; i < 6; i++) { + const p = result[i], original = input.pollutants[i]; + assert.equal(p.pollutantType, original.pollutantType); + // Display conversion follows the public scale, which is shared by Multi variants. + const displayAlgorithm = name === 'CA.AQHI' ? 'CA_AQHI' : name === 'CN.AQHI' ? 'CN_DEATH_AQHI' : algorithm; + const scaleAlgorithm = mode === 'Scale' ? algorithm : displayAlgorithm; + const standard = AirQuality.Config.Scales[scaleAlgorithm].pollutants[p.pollutantType]; + let expectedUnit = mode.includes('ppb') && !['PM2_5', 'PM10'].includes(p.pollutantType) ? ppb : ug; + if (!mode.startsWith('Force_') && standard) expectedUnit = standard.units; + if (mode === 'None') expectedUnit = ug; + assert.equal(p.units, expectedUnit, p.pollutantType); + if (mode === 'None') assert.deepEqual(p, original); + if (p.units === ug) close(p.amount, original.amount, p.pollutantType); + else { + const fallbackFactors = mode.includes('EU_ppb') ? AirQuality.Config.STP_ConversionFactors.EU : AirQuality.Config.STP_ConversionFactors.US; + const factor = !mode.startsWith('Force_') && standard?.units === ppb ? standard.stpConversionFactor : fallbackFactors[p.pollutantType]; + close(p.amount * factor, original.amount, p.pollutantType); + } + } + }); + } +} + +test('None algorithm is identity; explicit algorithm options override configuration', () => { + const input = sample(); + assert.equal(AirQuality.Pollutants2AQI(input, settings('None')), input); + assert.equal(AirQuality.Pollutants2AQI(input, settings('UBA'), { algorithm: 'None' }), input); + assert.equal(AirQuality.Pollutants2AQI(input, settings('UBA'), { algorithm: 'WAQI_InstantCast_US' }).index, 81); +}); + +test('None units retain the selected pollutant provider even when calculated index is injected', () => { + const input = sample(); + const injected = { metadata: { providerName: 'Selected' }, pollutants: input.pollutants.map(p => ({ ...p, amount: p.amount * 2 })) }; + const calculated = AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_US')); + const configured = settings('WAQI_InstantCast_US', 'None', ['EPA_NowCast']); + assert.equal(AirQuality.ConvertPollutants(input, injected, true, calculated, configured), injected.pollutants); + injected.metadata.temporarilyUnavailable = true; + assert.equal(AirQuality.ConvertPollutants(input, injected, true, calculated, configured), input.pollutants); +}); + +test('explicit conversion targets gate every unit mode', () => { + const input = sample(); + const calculated = AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_US')); + for (const mode of ['None', 'Scale', 'ugm3', 'EU_ppb', 'US_ppb', 'Force_ugm3', 'Force_EU_ppb', 'Force_US_ppb']) { + for (const replace of [[], ['UBA']]) assert.equal(AirQuality.ConvertPollutants(input, undefined, true, calculated, settings('WAQI_InstantCast_US', mode, replace)), input.pollutants); + } +}); + +test('CN.AQHI public scale supports force units even without an injected calculated index', () => { + const input = { ...sample(), scale: 'CN.AQHI' }; + const result = AirQuality.ConvertPollutants(input, undefined, false, undefined, settings('CN_DEATH_AQHI', 'Force_US_ppb', ['CN.AQHI'])); + assert.equal(result.length, 6); + assert.equal(result.find(p => p.pollutantType === 'OZONE').units, ppb); + assert.equal(input.pollutants.find(p => p.pollutantType === 'OZONE').units, ug); +}); + +test('WeatherKit EU standard maps to runnable EU_EAQI algorithm, not EU.EAQI scale text', () => { + for (const [scale, expected] of [['EU.EAQI', 'EU_EAQI'], ['EU.EAQI.2414', 'EU_EAQI'], ['UBA.2414', 'UBA'], ['EPA_NowCast.2414', 'WAQI_InstantCast_US'], ['HJ6332012.2604', 'WAQI_InstantCast_CN']]) { + const configured = { AirQuality: { Comparison: { Yesterday: { IndexProvider: 'WeatherKit' } } } }; + const algorithm = AirQuality.chooseAlogrithm({ scale }, configured); + assert.equal(algorithm, expected); + assert.equal(AirQuality.Pollutants2AQI(sample(), configured, { algorithm }).metadata.temporarilyUnavailable, false); + } +}); + +test('AQHI provider descriptions match upstream and preserve multiline attribution', () => { + const suffixes = { + CA_AQHI: ' (InstantCast with CA AQHI)', HK_AQHI: ' (InstantCast with HK AQHI)', + CN_DEATH_AQHI: ' (China CDC Death Risk AQHI)', CN_DEATH_HK_AQHI: ' (China CDC Death Risk + HK AQHI)', + AQHI_Multi_CN: ' (AQHI-Multi for CN)', AQHI_Multi_CN_HK: ' (AQHI-Multi for CN+HK)', + }; + for (const [algorithm, suffix] of Object.entries(suffixes)) { + assert.equal(AirQuality.appendScaleToProviderName({ metadata: { providerName: 'iRingo\nProvider attribution' } }, settings(algorithm)), `iRingo${suffix}\nProvider attribution`); + } + assert.equal(AirQuality.Author, 'Virgil Clyne & Wordless Echo'); +}); + +test('CN 2012 and 2025 draft execute different PM2.5 thresholds', () => { + const input = { metadata: { providerName: 'Synthetic' }, pollutants: [{ pollutantType: 'PM2_5', amount: 40, units: ug }] }; + assert.equal(AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_CN')).index, 56); + assert.equal(AirQuality.Pollutants2AQI(input, settings('WAQI_InstantCast_CN_25_DRAFT')).index, 66); +}); + +for (const [algorithm] of cases) { + test(`${algorithm}: empty, invalid and unsupported pollutant inputs fail closed`, () => { + for (const pollutants of [[], sample().pollutants.map(p => ({ ...p, amount: -1 })), sample().pollutants.map(p => ({ ...p, amount: NaN })), sample().pollutants.map(p => ({ ...p, units: 'UNKNOWN' })), [{ pollutantType: 'NOX', amount: 10, units: ug }]]) { + const result = AirQuality.Pollutants2AQI({ metadata: { providerName: 'Synthetic' }, pollutants }, settings(algorithm)); + assert.equal(result.metadata.temporarilyUnavailable, true); + } + }); +} + +test('menu contains exactly the eleven supported algorithms plus None, all runnable through settings', async () => { + const { localParameters } = await import('../../src/wk3/parameters.mjs'); + const parameter = localParameters.find(p => p.key === 'AirQuality.Calculate.Algorithm'); + assert.deepEqual([...parameter.options].sort(), ['None', ...cases.map(([algorithm]) => algorithm)].sort()); + for (const algorithm of parameter.options) { + const input = sample(); + const result = AirQuality.Pollutants2AQI(input, settings(algorithm)); + if (algorithm === 'None') assert.equal(result, input); + else { + assert.ok(Object.hasOwn(AirQuality.Config.Scales, algorithm)); + assert.equal(result.metadata.temporarilyUnavailable, false); + } + } +}); + +test('AllowOverRange false survives buildSettings string normalization and actually caps InstantCast', () => { + for (const algorithm of ['WAQI_InstantCast_US', 'WAQI_InstantCast_CN', 'WAQI_InstantCast_CN_25_DRAFT']) { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 100000; }); + const configured = buildSettings(database, { AirQuality: { Calculate: { Algorithm: algorithm, AllowOverRange: 'false' } } }).Settings; + assert.equal(configured.AirQuality.Calculate.AllowOverRange, false); + assert.equal(AirQuality.Pollutants2AQI(input, configured).index, 500); + configured.AirQuality.Calculate.AllowOverRange = true; + assert.ok(AirQuality.Pollutants2AQI(input, configured).index > 500); + } +}); + +for (const [algorithm, scale] of cases) { + test(`${algorithm}: zero remains zero in all eight unit modes`, () => { + const input = sample(); + input.pollutants.forEach(p => { p.amount = 0; }); + const calculated = AirQuality.Pollutants2AQI(input, settings(algorithm)); + for (const mode of ['None', 'Scale', 'ugm3', 'EU_ppb', 'US_ppb', 'Force_ugm3', 'Force_EU_ppb', 'Force_US_ppb']) { + const result = AirQuality.ConvertPollutants(input, undefined, true, calculated, settings(algorithm, mode, [AirQuality.GetNameFromScale(scale)])); + assert.equal(result.length, 6); + assert.ok(result.every(p => p.amount === 0), mode); + } + }); +} diff --git a/tests/wk3/aq-settings-semantics.test.mjs b/tests/wk3/aq-settings-semantics.test.mjs index f6b3db94..0b49bafc 100644 --- a/tests/wk3/aq-settings-semantics.test.mjs +++ b/tests/wk3/aq-settings-semantics.test.mjs @@ -65,7 +65,7 @@ test('WK3 replacement respects selected yesterday provider and same-source guard assert.equal(result.aq.previousDayComparison, 0); }); -test('entry does not prefetch for empty targets, None, or no replacement location', async () => { +test('entry does not prefetch for empty targets or a disabled index provider', async () => { const original = ColorfulClouds.prototype.CurrentAirQuality; const oldYesterday = ColorfulClouds.prototype.prefetchYesterdayHourly; let calls = 0; @@ -75,7 +75,6 @@ test('entry does not prefetch for empty targets, None, or no replacement locatio for (const [country, overrides] of [ ['CN', { Current: { Index: { Provider: 'ColorfulCloudsUS', Replace: [] } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], ['CN', { Current: { Index: { Provider: 'None', Replace: ['HJ6332012'] } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], - ['US', { Current: { Index: { Provider: 'ColorfulCloudsUS' } }, Comparison: { Yesterday: { IndexProvider: 'ColorfulCloudsUS' } } }], ]) { const requestUrl = url.replace('country=CN', `country=${country}`); const output = await handleResponse({ env: { SETTINGS: JSON.stringify({ AirQuality: overrides }) }, request: { url: requestUrl, method: 'GET', headers: {} }, response: new globalThis.Response(bytes(), { headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' } }) }); diff --git a/tests/wk3/eaqi-presentation-comparison.test.mjs b/tests/wk3/eaqi-presentation-comparison.test.mjs new file mode 100644 index 00000000..98012d2c --- /dev/null +++ b/tests/wk3/eaqi-presentation-comparison.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Builder} from 'flatbuffers'; +import Scale from '../../src/wk3/class/AirQualityScale.mjs'; +import {encodeAirQuality,decodeWK3DataSet} from '../../src/wk3/class/WeatherKit3.mjs'; +import {Response as processResponse} from '../../src/wk3/process/Response.mjs'; +import {configuration} from '../../src/wk3/entry/index.mjs'; +import {localParameters} from '../../src/wk3/parameters.mjs'; +const pollutants=[[9,25],[11,50],[8,100],[6,25],[10,40],[12,800]].map(([pollutantType,amount])=>({pollutantType,amount,units:2})); +function root(comparison=0){const b=new Builder();const aq=encodeAirQuality(b,{metadata:{providerName:'Apple',sourceType:2},scale:'HJ6332012.2604',index:70,categoryIndex:2,isSignificant:false,primaryPollutant:9,previousDayComparison:comparison,pollutants});b.startObject(1);b.addFieldOffset(0,aq,0);b.finish(b.endObject());return b.asUint8Array();} +const source=(index,categoryIndex)=>({metadata:{providerName:'Synthetic Caiyun',sourceType:'MODELED'},scale:'EPA_NowCast.2414',index,categoryIndex}); +async function run({comparison=0,force=false,today=source(81,2),yesterday=source(125,3),failure=false}={}){const env=Object.fromEntries(localParameters.map(p=>[p.key,p.value]));const {Settings}=configuration({env:{...env,'AirQuality.Current.Index.Provider':'Calculate','AirQuality.Calculate.Algorithm':'EU_EAQI','AirQuality.Comparison.Yesterday.IndexProvider':'ColorfulCloudsUS','AirQuality.Comparison.ReplaceWhenCurrentChange':String(force)}});const calls=[]; + const input=root(comparison);const result=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer'},body:input},{Settings,Configs:{},enviroments:{colorfulClouds:{CurrentAirQuality:async usa=>{calls.push('today');assert.equal(usa,true);if(failure)throw new Error('offline');return today;},YesterdayAirQuality:async usa=>{calls.push('yesterday');assert.equal(usa,true);return yesterday;}}}});return {aq:decodeWK3DataSet(result.body),calls};} +test('EAQI local scale uses EU naming, categorical display and continuous interpolation',()=>{ + const a=JSON.parse(Scale.buildScale('zh-Hans','EU.EAQI.2414').body);assert.equal(a.longDisplayName,'欧盟 (EAQI)');assert.equal(a.displayName,'欧盟空气质量指数');assert.equal(a.aqi.numerical,false);assert.deepEqual(a.aqi.range,[0,60]);assert.equal(a.aqi.categories.length,6);assert.equal(a.aqi.gradient.stops.length,121);assert.equal(a.aqi.gradient.stops[0].location,0);assert.equal(a.aqi.gradient.stops.at(-1).location,60); + for(let i=1;i<a.aqi.gradient.stops.length;i++){const previous=a.aqi.gradient.stops[i-1],next=a.aqi.gradient.stops[i];assert.equal(next.location-previous.location,0.5);for(const offset of [1,3,5])assert.ok(Math.abs(parseInt(next.color.slice(offset,offset+2),16)-parseInt(previous.color.slice(offset,offset+2),16))<=11);} + assert.equal(JSON.parse(Scale.buildScale('zh-Hant','EU.EAQI.2414').body).longDisplayName,'歐盟 (EAQI)'); + assert.equal(JSON.parse(Scale.buildScale('zh-Hans','EPA_NowCast.2604').body).aqi.numerical,true); +}); +test('EAQI default yesterday choice compares Caiyun own current/past pair, not EAQI vs US',async()=>{ + for(const [index,categoryIndex,expected]of [[125,3,4],[81,2,3],[20,1,2]]){const r=await run({yesterday:source(index,categoryIndex)});assert.equal(r.aq.scale,'EU.EAQI');assert.equal(r.aq.index,22);assert.equal(r.aq.previousDayComparison,expected);assert.deepEqual(r.calls,['today','yesterday']);assert.ok(r.aq.metadata.providerName.includes('对比昨日:彩云天气(美标)'));} +}); +test('valid original comparison and disabled refresh retain their value without requests',async()=>{const r=await run({comparison:3});assert.equal(r.aq.previousDayComparison,3);assert.deepEqual(r.calls,[]);}); +test('forced refresh uses selected source; source failure leaves EAQI valid and comparison unknown',async()=>{const refreshed=await run({comparison:3,force:true});assert.equal(refreshed.aq.previousDayComparison,4);assert.deepEqual(refreshed.calls,['today','yesterday']);const fail=await run({failure:true});assert.equal(fail.aq.index,22);assert.equal(fail.aq.scale,'EU.EAQI');assert.equal(fail.aq.previousDayComparison,0);}); diff --git a/tests/wk3/eaqi-upstream-resource.test.mjs b/tests/wk3/eaqi-upstream-resource.test.mjs new file mode 100644 index 00000000..fcf9f661 --- /dev/null +++ b/tests/wk3/eaqi-upstream-resource.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {Builder,ByteBuffer} from 'flatbuffers'; +import {decodeWK3DataSet,encodeAirQuality} from '../../src/wk3/class/WeatherKit3.mjs'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import {Response as processResponse} from '../../src/wk3/process/Response.mjs'; +import {buildEgern} from '../../scripts/build-egern.mjs'; +import {configuration} from '../../src/wk3/entry/index.mjs'; +import {localParameters} from '../../src/wk3/parameters.mjs'; +const dir=process.env.WK3_TEST_BUNDLE_DIR; +const built=dir ? null : await buildEgern({minify:false}); +const load=async stage=>{const code=dir ? await readFile(`${dir}/egern/wk3-egern-${stage}.bundle.js`,'utf8') : built[stage].code;return (await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'))).default;}; +const request=await load('request'),response=await load('response'); +const env={...Object.fromEntries(localParameters.map(p=>[p.key,p.value])),LogLevel:'OFF'}; +const pollutants=[[9,25],[11,50],[8,100],[6,25],[10,40],[12,800]].map(([pollutantType,amount])=>({pollutantType,amount,units:2})); +function fixture(scale){const b=new Builder();const a=encodeAirQuality(b,{metadata:{providerName:'Apple',sourceType:2},scale,index:18,categoryIndex:2,isSignificant:false,primaryPollutant:9,previousDayComparison:3,pollutants});const mark=b.createString('keep all weather and alerts');b.startObject(1);b.addFieldOffset(0,mark,0);const other=b.endObject();b.startObject(18);b.addFieldOffset(0,a,0);for(const slot of [1,5,9,17])b.addFieldOffset(slot,other,0);b.finish(b.endObject());return b.asUint8Array();} +const url='https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality'; +const context=raw=>({env,request:{url,headers:{}},response:new Response(raw,{headers:{'content-type':'application/vnd.apple.flatbuffer'}}),http:{get(){assert.fail('No script-side fetch for existing AQ');}}}); +test('actual request bundle follows upstream version stripping, without fetching or synthesizing EAQI',async()=>{ + for(const version of ['v1','v2'])for(const language of ['zh-Hans','zh-Hant','en'])for(const name of ['EU.EAQI.2414','EU.EAQI.2604','EU.EAQI']){ + const original=`https://weatherkit.apple.com/api/${version}/airQualityScale/${language}/${name}?keep=yes`;const headers=new Headers({'authorization':'synthetic-test-only','if-none-match':'synthetic-etag'}); + const result=await request({env,request:{url:original,headers},http:{get(){assert.fail('must use native Apple request');}},respond(){assert.fail('must not replace native Apple scale with local color stops');}}); + assert.equal(result.url,`https://weatherkit.apple.com/api/${version}/airQualityScale/${language}/EU.EAQI?keep=yes`);assert.equal(result.headers,headers); + assert.equal(headers.get('authorization'),'synthetic-test-only');assert.equal(headers.get('if-none-match'),null); + } +}); +test('upstream algorithm outputs EU.EAQI without altering calculated index',()=>{const symbolic=pollutants.map((p,i)=>({...p,pollutantType:['PM2_5','PM10','OZONE','NO2','SO2','CO'][i],units:'MICROGRAMS_PER_CUBIC_METER'}));const result=AirQuality.Pollutants2AQI({metadata:{providerName:'Apple'},pollutants:symbolic},{},{algorithm:'EU_EAQI'});assert.equal(result.scale,'EU.EAQI');assert.equal(result.index,22);assert.equal(result.categoryIndex,3);}); +test('actual weather bundle changes existing EAQI resource identity, not AQ values or other original bytes',async()=>{ + for(const scale of ['EU.EAQI.2414','EU.EAQI.2604']){const raw=fixture(scale),snapshot=raw.slice();const before=decodeWK3DataSet(raw);const out=(await response(context(raw))).body;const after=decodeWK3DataSet(out);assert.deepEqual({...after,scale:before.scale},before);assert.equal(after.scale,'EU.EAQI');assert.deepEqual(raw,snapshot); + const bb=new ByteBuffer(raw),root=bb.readUint32(0),aq=bb.__indirect(root+bb.__offset(root,4)),field=aq+bb.__offset(aq,18); + for(let i=0;i<raw.length;i++)if(i<field||i>=field+4)assert.equal(out[i],raw[i]); + } + const raw=fixture('EU.EAQI');assert.deepEqual((await response(context(raw))).body,raw); +}); +test('non-EAQI native data and existing settings are left alone',async()=>{const raw=fixture('EPA_NowCast.2604');const out=(await response(context(raw))).body;assert.deepEqual(out,raw);assert.equal(configuration({env}).Settings.AirQuality.Calculate.Algorithm,'EU_EAQI');}); diff --git a/tests/wk3/egern-parameters.test.mjs b/tests/wk3/egern-parameters.test.mjs index f2563682..cd9d6824 100644 --- a/tests/wk3/egern-parameters.test.mjs +++ b/tests/wk3/egern-parameters.test.mjs @@ -1,9 +1,10 @@ import test from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm'; +import {displayModuleOption} from '../../src/wk3/option-values.mjs'; import {configuration}from '../../src/wk3/entry/index.mjs';import {localParameters}from '../../src/wk3/parameters.mjs';import {renderLocalModule,localScriptGroups}from '../../scripts/build-egern-module.mjs';import {buildEgern}from '../../scripts/build-egern.mjs'; const defaults=()=>Object.fromEntries(localParameters.map(p=>[p.key,p.value])); const value=(o,path)=>path.split('.').reduce((a,k)=>a?.[k],o); -test('all original restored keys reach Settings with current US/minute/yesterday defaults',()=>{ - const {Settings:s}=configuration({env:defaults()});assert.equal(s.AirQuality.Current.Index.Provider,'ColorfulCloudsUS');assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider,'ColorfulCloudsUS');assert.equal(s.NextHour.Provider,'ColorfulClouds');assert.equal(s.WeatherAlerts.Provider,'QWeatherWeb');assert.deepEqual(s.Weather.Replace,['CN']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,[]);assert.equal(s.Weather.ReplaceDaily,false);assert.equal(s.Weather.ReplaceHourly,false);assert.equal(s.AirQuality.Calculate.Algorithm,'WAQI_InstantCast_US');assert.equal(s.API.QWeather.Host,'devapi.qweather.com');assert.equal(s.API.QWeather.Token,null); +test('all original restored keys reach Settings with current upstream EU/minute/yesterday defaults',()=>{ + const {Settings:s}=configuration({env:defaults()});assert.equal(s.AirQuality.Current.Index.Provider,'Calculate');assert.equal(s.AirQuality.Comparison.Yesterday.IndexProvider,'ColorfulCloudsUS');assert.equal(s.NextHour.Provider,'ColorfulClouds');assert.equal(s.WeatherAlerts.Provider,'QWeatherWeb');assert.deepEqual(s.Weather.Replace,['CN']);assert.deepEqual(s.AirQuality.Current.Index.Replace,['HJ6332012']);assert.deepEqual(s.AirQuality.Current.Pollutants.Units.Replace,[]);assert.equal(s.Weather.ReplaceDaily,false);assert.equal(s.Weather.ReplaceHourly,false);assert.equal(s.AirQuality.Calculate.Algorithm,'EU_EAQI');assert.equal(s.API.QWeather.Host,'devapi.qweather.com');assert.equal(s.API.QWeather.Token,null); for(const p of localParameters){if(p.key==='HTTP_TIMEOUT_MS')continue;assert.notEqual(value(s,p.key),undefined,p.key);} }); test('dotted edits override JSON defaults and list values do not turn into nested arrays',()=>{ @@ -21,7 +22,7 @@ test('API numeric strings and comma values survive without coercion or accidenta const s=configuration({env:{...defaults(),'API.ColorfulClouds.Token':'001234','API.QWeather.Token':'example,synthetic','API.QWeather.Host':''}}).Settings;assert.equal(s.API.ColorfulClouds.Token,'001234');assert.equal(s.API.QWeather.Token,'example,synthetic');assert.equal(s.API.QWeather.Host,'devapi.qweather.com'); }); test('template declares every placeholder and binds identical parameters to both scripts',()=>{ - const m=renderLocalModule();const line=prefix=>JSON.parse(m.split('\n').find(s=>s.startsWith(prefix)).slice(prefix.length));const args=line('compat_arguments: '),schema=line('env_schema: ');assert.equal(Object.keys(args).length,localParameters.length);assert.deepEqual(Object.keys(args),Object.keys(schema));const refs=[...m.matchAll(/\{\{\{([^}]+)\}\}\}/g)].map(x=>x[1]);for(const p of localParameters){assert.equal(refs.filter(x=>x===p.key).length,localScriptGroups.length);assert.equal(args[p.key],p.value);assert.equal(schema[p.key].default_value,p.value);} + const m=renderLocalModule();const line=prefix=>JSON.parse(m.split('\n').find(s=>s.startsWith(prefix)).slice(prefix.length));const args=line('compat_arguments: '),schema=line('env_schema: ');assert.equal(Object.keys(args).length,localParameters.length);assert.deepEqual(Object.keys(args),Object.keys(schema));const refs=[...m.matchAll(/\{\{\{([^}]+)\}\}\}/g)].map(x=>x[1]);for(const p of localParameters){assert.equal(refs.filter(x=>x===p.key).length,localScriptGroups.length);assert.equal(args[p.key],displayModuleOption(p.key,p.value));assert.equal(schema[p.key].default_value,displayModuleOption(p.key,p.value));} assert.ok(!m.includes('url_rewrites:')&&!m.includes('vercel.app'));assert.match(m,/binary_body: true/);assert.match(m,/hostnames:\n includes:/); }); test('tagged Egern module uses the same versioned release attachments as other platforms',()=>{ diff --git a/tests/wk3/native-alert-details.test.mjs b/tests/wk3/native-alert-details.test.mjs index 57452d72..729306de 100644 --- a/tests/wk3/native-alert-details.test.mjs +++ b/tests/wk3/native-alert-details.test.mjs @@ -151,7 +151,7 @@ test("builder validates UUID bytes, region, count, field size, locale and timezo }); test("unverified WK3 severity integers are never guessed or added to the wire mapping", async () => { - for (const severity of [0, 1, 2, 5, 231]) { + for (const severity of [0, 5, 231]) { await assert.rejects(resolveNativeAlertDetails(idsFor([alert("大风", 0, { severity })]), "zh-CN", async () => { assert.fail("must not fetch"); }), /Unverified WK3 severity/); } }); @@ -165,3 +165,20 @@ test('paired MODERATE=3 supports reordered yellow/yellow and yellow/blue event g assert.ok(result.every(a=>a.messages.length&&a.detailsUrl==='#'+a.id)); } }); + +test('upstream severity definitions support red/orange across event types and mixed groups', async () => { + for (const name of ['高速公路灾害性天气预警', '道路结冰', '暴雪', '暴雨', '森林火险', '大风']) { + for (const [severity, color, css, token] of [[1, '红', 'red', 'extreme'], [2, '橙', 'orange', 'severe']]) { + const original = alert(name, 0, { severity }); + const result = await resolveNativeAlertDetails(idsFor([original]), 'zh-CN', htmlFetch(block(`${name}${color}色预警信号`, undefined, css))); + assert.equal(result[0].id, uuid); + assert.equal(result[0].severity, token); + assert.ok(result[0].messages.length); + await assert.rejects(resolveNativeAlertDetails(idsFor([original]), 'zh-CN', htmlFetch(block(`${name}${color}色预警信号`, '2030-01-01T08:01:00+08:00', css))), /Missing or ambiguous/); + } + } + const originals = [alert('高速公路灾害性天气预警', 0, { severity: 1 }), alert('道路结冰', 16, { severity: 3 })]; + const result = await resolveNativeAlertDetails(idsFor(originals), 'zh-CN', htmlFetch(block('道路结冰黄色预警', undefined, 'yellow') + block('高速公路灾害性天气预警红色预警信号', undefined, 'red'))); + assert.deepEqual(result.map(a => a.severity), ['extreme', 'moderate']); + assert.deepEqual(result.map(a => a.id), readPayload(idsFor(originals)).alerts.map(a => a.id)); +}); diff --git a/tests/wk3/wk3-alerts.test.mjs b/tests/wk3/wk3-alerts.test.mjs index 7959cb0e..a6ed3621 100644 --- a/tests/wk3/wk3-alerts.test.mjs +++ b/tests/wk3/wk3-alerts.test.mjs @@ -30,7 +30,8 @@ function alertVectorPointer(bytes) { const fetchPage = async () => new Response(html, { headers: { "Content-Type": "text/html" } }); test("observed WK3 enums are explicit, not WK2 arithmetic", () => { assert.equal(WK3_ALERT_ENUMS.severity.MINOR, 4); assert.equal(WK3_ALERT_ENUMS.significance.STATEMENT, 10); - assert.equal(WK3_ALERT_ENUMS.responses.MONITOR, 7); assert.equal(WK3_ALERT_ENUMS.severity.SEVERE, undefined); + assert.equal(WK3_ALERT_ENUMS.responses.MONITOR, 7); + assert.equal(WK3_ALERT_ENUMS.severity.SEVERE, 2); assert.equal(WK3_ALERT_ENUMS.severity.EXTREME, 1); const d = decodeWK3Alerts(fixture({ rawSeverity: 231 })); assert.equal(d.alerts[0].severity, 231); assert.deepEqual(d.alerts[0].responses, [7]); assert.deepEqual(d.alerts[0].id.bytes, Array.from({ length: 16 }, (_, i) => i)); }); diff --git a/tests/wk3/wk3-aq-all-conversions.test.mjs b/tests/wk3/wk3-aq-all-conversions.test.mjs new file mode 100644 index 00000000..40951c38 --- /dev/null +++ b/tests/wk3/wk3-aq-all-conversions.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder, ByteBuffer } from 'flatbuffers'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import { encodeAirQuality, decodeWK3DataSet } from '../../src/wk3/class/WeatherKit3.mjs'; +import { Response as processResponse } from '../../src/wk3/process/Response.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +import QWeather from '../../src/wk3/class/QWeather.mjs'; +import { merge } from '../../src/wk3/utils/object.mjs'; +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 pollutants=[['PM2_5',25],['PM10',50],['OZONE',100],['NO2',25],['SO2',40],['CO',800]].map(([pollutantType,amount])=>({pollutantType,amount,units:'MICROGRAMS_PER_CUBIC_METER'})); +const source=()=>({metadata:{providerName:'Synthetic',sourceType:'MODELED'},index:70,categoryIndex:2,isSignificant:false,scale:'HJ6332012.2604',primaryPollutant:'PM2_5',pollutants:structuredClone(pollutants)}); +const apple=()=>({metadata:{providerName:'Apple',sourceType:2},index:70,categoryIndex:2,isSignificant:false,scale:'HJ6332012.2604',primaryPollutant:9,pollutants:pollutants.map((p,i)=>({...p,pollutantType:[9,11,8,6,10,12][i],units:2})),previousDayComparison:3}); +function root(aq) {const b=new Builder(); const offset=aq?encodeAirQuality(b,aq):0;const marker=b.createString('other-weather-preserved');b.startObject(1);b.addFieldOffset(0,marker,0);const child=b.endObject();b.startObject(18);if(offset)b.addFieldOffset(0,offset,0);for(const slot of [1,4,9,17])b.addFieldOffset(slot,child,0);b.finish(b.endObject());return b.asUint8Array();} +function target(bytes,slot){const bb=new ByteBuffer(bytes),r=bb.readUint32(0);return bb.__indirect(r+bb.__offset(r,4+2*slot));} +async function patch({algorithm='UBA',missing=false,provider='Calculate',pollutantProvider='ColorfulClouds',overrides={},input,fail=false,preFetched={},sources={}}={}){ + const settings=buildSettings(database,merge({}, {AirQuality:{Current:{Index:{Provider:provider,Replace:['HJ6332012']},Pollutants:{Provider:pollutantProvider,Units:{Replace:[],Mode:'Scale'}}},Calculate:{Algorithm:algorithm,AllowOverRange:true},Comparison:{Yesterday:{IndexProvider:'WeatherKit'}}}},overrides)).Settings; + const bytes=input??root(missing?undefined:apple()),calls=[]; + const get=async label=>{calls.push(label);if(fail)throw new Error('offline');return source();}; + const response=await processResponse({url:'https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=airQuality',headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer'},body:bytes},{Settings:settings,Configs:{},enviroments:{parameters:{country:'CN',latitude:31,longitude:121},colorfulClouds:{CurrentAirQuality:()=>get('ColorfulClouds')},qWeather:{CurrentAirQuality:()=>get('QWeather')},...sources},preFetched}); + return {bytes,out:response.body,aq:decodeWK3DataSet(response.body),calls,settings}; +} +for(const algorithm of algorithms)for(const missing of [false,true])test(`${algorithm}: ${missing?'missing AQ injection':'cross-standard replacement'} retains full AQ and other products`,async()=>{ + const r=await patch({algorithm,missing});const expected=AirQuality.Pollutants2AQI(source(),r.settings); + assert.ok(r.aq,algorithm);assert.equal(r.aq.index,expected.index);assert.equal(r.aq.categoryIndex,expected.categoryIndex); + assert.equal(AirQuality.GetNameFromScale(r.aq.scale),AirQuality.GetNameFromScale(expected.scale));assert.equal(r.calls.length,missing?1:0); + assert.ok(r.aq.metadata.providerName.includes('iRingo'));assert.equal(r.aq.pollutants.length,expected.pollutants.length); + const shift=target(r.out,1)-target(r.bytes,1);for(const slot of [4,9,17])assert.equal(target(r.out,slot)-target(r.bytes,slot),shift); + if(missing)assert.deepEqual(r.out.slice(shift,shift+r.bytes.length),r.bytes); +}); +test('all algorithms can calculate from original Apple symbolic-mapped concentrations without provider calls',async()=>{ + for(const algorithm of algorithms){const r=await patch({algorithm,pollutantProvider:'WeatherKit'});assert.equal(r.calls.length,0);const e=AirQuality.Pollutants2AQI(source(),r.settings);assert.equal(r.aq.index,e.index,algorithm);assert.equal(AirQuality.GetNameFromScale(r.aq.scale),AirQuality.GetNameFromScale(e.scale),algorithm);} +}); +test('all direct CN/US providers can fill missing AQ and cross standards',async()=>{ + for(const provider of ['ColorfulCloudsCN','QWeather'])for(const missing of [false,true]){const r=await patch({provider,missing,pollutantProvider:provider==='QWeather'?'QWeather':'ColorfulClouds'});assert.equal(r.aq.index,70);assert.equal(r.aq.scale,'HJ6332012.2604');assert.equal(r.calls.length,1);} +}); +test('explicit disabled targets, None and source failures preserve the complete response',async()=>{ + for(const missing of [false,true]){ + for(const overrides of [{AirQuality:{Current:{Index:{Replace:'[]'}}}},{AirQuality:{Calculate:{Algorithm:'None'}}}]){const r=await patch({missing,overrides});assert.deepEqual(r.out,r.bytes);assert.equal(r.calls.length,0);} + const r=await patch({missing,fail:true}); + if(missing)assert.deepEqual(r.out,r.bytes);else {assert.equal(r.calls.length,0);assert.equal(r.aq.scale,'UBA.2414');} + } +}); +test('explicit pollution unit modes transform existing AQ independently of index calculation',async()=>{ + for(const mode of ['Scale','ugm3','EU_ppb','US_ppb','Force_ugm3','Force_EU_ppb','Force_US_ppb']){ + const r=await patch({provider:'WeatherKit',overrides:{AirQuality:{Current:{Pollutants:{Units:{Replace:['HJ6332012'],Mode:mode}}}}}}); + assert.equal(r.aq.index,70);assert.equal(r.aq.previousDayComparison,3);assert.equal(r.calls.length,0); + if(mode.startsWith('Force_')&&mode.endsWith('ppb')){assert.equal(r.aq.pollutants.find(p=>p.pollutantType===8).units,1);assert.ok(r.aq.pollutants.find(p=>p.pollutantType===8).amount<100);} + } +}); + +test('all menu origin standards can be replaced by each supported algorithm',async()=>{ + const standards=['HJ6332012.2604','EPA_NowCast.2604','UBA.2414','EU.EAQI.2414','CA.AQHI','HK.AQHI','CN.AQHI','IE.AQIH.2414','AT.AQI.2414','BE.BelAQI.2414','FR.ATMO.2414','KR.CAI.2414','CZ.AQI.2414','NL.LKI.2414','ICARS.2414','CH.KBI.2414','ES.MITECO.2414','SG.NEA.2414','NAQI.2414','DAQI.2414']; + for(const standard of standards)for(const algorithm of algorithms){ + const original={...apple(),scale:standard};const r=await patch({algorithm,input:root(original),overrides:{AirQuality:{Current:{Index:{Replace:[AirQuality.GetNameFromScale(standard)]}}}}}); + const e=AirQuality.Pollutants2AQI(source(),r.settings);assert.equal(r.aq.index,e.index,`${standard}->${algorithm}`);assert.equal(AirQuality.GetNameFromScale(r.aq.scale),AirQuality.GetNameFromScale(e.scale)); + } +}); +test('calculated yesterday requires real same-source concentrations and uses the selected algorithm',async()=>{ + for(const algorithm of algorithms){ + const today=source(),yesterday=source();yesterday.pollutants.forEach(p=>p.amount*=2); + const r=await patch({algorithm,pollutantProvider:'QWeather',preFetched:{locationsGrid:[]},overrides:{AirQuality:{Comparison:{Yesterday:{IndexProvider:'Calculate',PollutantsProvider:'QWeather'}}}}}); + // No usable historical endpoint in this harness: failure is isolated from current AQ. + assert.equal(r.aq.previousDayComparison,0);assert.equal(r.aq.index,AirQuality.Pollutants2AQI(today,r.settings).index); + } +}); +test('zero AQI and within-wire overrange results are not blocked by the US-only 500 cap',async()=>{ + for(const algorithm of algorithms){ + const zero=source();zero.pollutants.forEach(p=>p.amount=0); + const r=await patch({algorithm,missing:true,preFetched:{pollutants:Promise.resolve(zero)}});assert.ok(r.aq);assert.equal(r.aq.index,AirQuality.Pollutants2AQI(zero,r.settings).index); + } + const high=source();high.pollutants.forEach(p=>p.amount=1000); + const r=await patch({algorithm:'WAQI_InstantCast_US',missing:true,preFetched:{pollutants:Promise.resolve(high)}}); + assert.ok(r.aq.index>500);assert.ok(r.aq.index<=32767); +}); + +test('same-source QWeather yesterday concentrations are recalculated for every algorithm',async()=>{ + const saved=QWeather.GetLocationInfo;QWeather.GetLocationInfo=()=>({locationID:'synthetic'}); + try{for(const algorithm of algorithms){ + const previous=source();previous.pollutants.forEach(p=>p.amount*=3);let yesterdayCalls=0; + const r=await patch({algorithm,missing:true,pollutantProvider:'QWeather',preFetched:{locationsGrid:[]},overrides:{AirQuality:{Comparison:{Yesterday:{IndexProvider:'Calculate',PollutantsProvider:'QWeather'}}}},sources:{qWeather:{latitude:31,longitude:121,CurrentAirQuality:async()=>source(),YesterdayAirQuality:async()=>{yesterdayCalls++;return previous;}}}}); + const current=AirQuality.Pollutants2AQI(source(),r.settings),past=AirQuality.Pollutants2AQI(previous,r.settings); + assert.equal(r.aq.previousDayComparison,current.categoryIndex<past.categoryIndex?4:current.categoryIndex>past.categoryIndex?2:3,algorithm);assert.equal(yesterdayCalls,1); + }}finally{QWeather.GetLocationInfo=saved;} +}); +test('all calculated standards respect every explicit output unit mode with a complete pollutant list',async()=>{ + for(const algorithm of algorithms)for(const mode of ['None','Scale','ugm3','EU_ppb','US_ppb','Force_ugm3','Force_EU_ppb','Force_US_ppb']){ + const result=AirQuality.Pollutants2AQI(source(),{AirQuality:{Calculate:{Algorithm:algorithm}}}); + const name=AirQuality.GetNameFromScale(result.scale); + const r=await patch({algorithm,overrides:{AirQuality:{Current:{Pollutants:{Units:{Replace:[name],Mode:mode}}}}}}); + assert.equal(r.aq.index,result.index,`${algorithm}/${mode}`);assert.equal(r.aq.pollutants.length,6); + if(mode==='Force_ugm3'||mode==='None')assert.ok(r.aq.pollutants.every(p=>p.units===2),`${algorithm}/${mode}`); + if(mode==='Force_US_ppb'||mode==='Force_EU_ppb')assert.equal(r.aq.pollutants.find(p=>p.pollutantType===8).units,1,`${algorithm}/${mode}`); + } +}); diff --git a/tests/wk3/wk3-aq-conversion.test.mjs b/tests/wk3/wk3-aq-conversion.test.mjs new file mode 100644 index 00000000..7e0dbb14 --- /dev/null +++ b/tests/wk3/wk3-aq-conversion.test.mjs @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Builder, ByteBuffer } from "flatbuffers"; +import { AirQuality, Metadata } from "../../src/wk3/proto/apple/wk2.js"; +import { + WK3_AQ_COMPARISONS, WK3_AQ_POLLUTANTS, WK3_AQ_SOURCES, WK3_AQ_UNITS, + WK3AirQualityTable, assertWK3AirQualityReplaceable, decodeAirQuality, encodeAirQuality, + providerToWK3AirQuality, providerToWK3USAirQuality, wk3AirQualityToProvider, +} from "../../src/wk3/class/WeatherKit3.mjs"; + +const source = (scale = "HJ6332012", index = 56, categoryIndex = 2) => ({ + metadata: { providerName: "Synthetic", sourceType: "MODELED" }, scale, index, categoryIndex, + isSignificant: false, primaryPollutant: "PM2_5", previousDayComparison: "SAME", + pollutants: [{ pollutantType: "PM2_5", amount: 0, units: "MICROGRAMS_PER_CUBIC_METER" }], +}); +function roundTrip(value) { + const b = new Builder(), child = encodeAirQuality(b, value); + b.finish(child); + const bytes = b.asUint8Array(), bb = new ByteBuffer(bytes), root = bb.readUint32(0); + return decodeAirQuality(new WK3AirQualityTable().__init(root, bb)); +} + +test("every upstream-configured scale family converts and round-trips enums", () => { + const scales = ["UBA", "EU.EAQI", "HJ6332012", "HJ6332025_DRAFT", "EPA_NowCast", "HK.AQHI", "CA.AQHI", "CN.AQHI"]; + for (const scale of scales) { + const result = providerToWK3AirQuality(source(scale)); + assert.equal(result.scale, scale); + assert.equal(wk3AirQualityToProvider(new WK3AirQualityTable().__init(...(() => { + const b = new Builder(), child = encodeAirQuality(b, result); b.finish(child); + const bb = new ByteBuffer(b.asUint8Array()); return [bb.readUint32(0), bb]; + })())).scale, scale); + } + for (const enumSet of [WK3_AQ_POLLUTANTS, WK3_AQ_UNITS, WK3_AQ_SOURCES, WK3_AQ_COMPARISONS]) { + for (const [name, wire] of Object.entries(enumSet)) assert.equal(enumSet[name], wire); + } +}); + +test("plain decoded numeric AQ reverses to symbolic provider enums", () => { + const raw = roundTrip(providerToWK3AirQuality(source())); + const symbolic = wk3AirQualityToProvider(raw); + assert.equal(symbolic.pollutants[0].pollutantType, "PM2_5"); + assert.equal(symbolic.pollutants[0].units, "MICROGRAMS_PER_CUBIC_METER"); + assert.equal(symbolic.metadata.sourceType, "MODELED"); + assert.equal(wk3AirQualityToProvider({ ...raw, primaryPollutant: 1, pollutants: [] }).primaryPollutant, "NOT_AVAILABLE"); +}); + +test("generic conversion permits NOT_AVAILABLE primary without inventing pollutants", () => { + const result = providerToWK3AirQuality({ ...source(), primaryPollutant: "NOT_AVAILABLE" }); + assert.equal(result.primaryPollutant, 1); +}); + +test("provider metadata fills optional wire defaults and preserves modeled/station", () => { + const result = providerToWK3AirQuality(source()); + assert.equal(result.metadata.latitude, 0); + assert.equal(result.metadata.longitude, 0); + assert.equal(result.metadata.readTime, 0); + assert.equal(result.metadata.reportedTime, 0); + assert.equal(result.metadata.sourceType, 2); + assert.equal(providerToWK3AirQuality({ ...source(), metadata: { providerName: "Station", sourceType: "STATION" } }).metadata.sourceType, 3); +}); + +test("rejects unrecognized scales/enums, missing metadata, negative and invalid scalars", () => { + for (const invalid of [ + { ...source(), scale: "IE.AQIH" }, + { ...source(), metadata: null }, + { ...source(), metadata: { providerName: "x" } }, + { ...source(), pollutants: [{ pollutantType: "UNKNOWN14", amount: 1, units: "PARTS_PER_BILLION" }] }, + { ...source(), pollutants: [{ pollutantType: "PM2_5", amount: -0.1, units: "PARTS_PER_BILLION" }] }, + { ...source(), index: 32768 }, + { ...source(), categoryIndex: 128 }, + ]) assert.throws(() => providerToWK3AirQuality(invalid)); +}); + +test("generic accepts EPA AQI above 500; strict US wrapper keeps its limit", () => { + const generic = providerToWK3AirQuality({ ...source("EPA_NowCast.2414", 501, 6), isSignificant: true }); + assert.equal(generic.scale, "EPA_NowCast.2604"); + assert.equal(generic.index, 501); + assert.throws(() => providerToWK3AirQuality(source("EPA_NowCast", 501, 5))); + assert.throws(() => providerToWK3USAirQuality({ ...source("EPA_NowCast.2414", 501, 6), isSignificant: true })); + assert.equal(providerToWK3USAirQuality({ ...source("EPA_NowCast.2414", 400, 6), isSignificant: true }).scale, "EPA_NowCast.2604"); +}); + +test("unknown AQ enum wire values fail closed on reverse mapping", () => { + const b = new Builder(); + const child = encodeAirQuality(b, { metadata: { providerName: "x", sourceType: 1 }, scale: "UBA", index: 0, categoryIndex: 1, primaryPollutant: 13, pollutants: [] }); + b.finish(child); + const bb = new ByteBuffer(b.asUint8Array()); + const table = new WK3AirQualityTable().__init(bb.readUint32(0), bb); + assert.throws(() => wk3AirQualityToProvider(table)); +}); + +test("replaceability permits missing AQ and checks nested unknown fields", () => { + const b = new Builder(); + b.startObject(1); b.addFieldInt32(0, 1, 0); const marker = b.endObject(); + b.startObject(15); b.addFieldOffset(1, marker, 0); b.finish(b.endObject()); + assert.equal(assertWK3AirQualityReplaceable(b.asUint8Array()), true); + const valid = roundTrip(providerToWK3AirQuality(source())); + assert.equal(valid.scale, "HJ6332012"); + const nested = new Builder(); + const metadata = Metadata.createMetadata(nested, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 1); + nested.startObject(9); nested.addFieldOffset(0, metadata, 0); nested.addFieldInt32(8, 1, 0); const aq = nested.endObject(); + nested.startObject(15); nested.addFieldOffset(0, aq, 0); nested.finish(nested.endObject()); + assert.throws(() => assertWK3AirQualityReplaceable(nested.asUint8Array()), /Unknown WK3 AQ table fields/); + const missingMeta = new Builder(); + const child = AirQuality.createAirQuality(missingMeta, 0, 1, 0, false, 0, 0, 0, 0); + missingMeta.startObject(15); missingMeta.addFieldOffset(0, child, 0); missingMeta.finish(missingMeta.endObject()); + assert.equal(assertWK3AirQualityReplaceable(missingMeta.asUint8Array()), true); +}); diff --git a/tests/wk3/wk3-comparison.test.mjs b/tests/wk3/wk3-comparison.test.mjs index 4702cd98..b0ca5f50 100644 --- a/tests/wk3/wk3-comparison.test.mjs +++ b/tests/wk3/wk3-comparison.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { Builder, ByteBuffer } from "flatbuffers"; import { WK3AirQualityTable, encodeAirQuality, decodeAirQuality } from "../../src/wk3/class/WeatherKit3.mjs"; import { WK3_COMPARISON, computeWK3Comparison, injectWK3Comparison } from "../../src/wk3/class/WK3Comparison.mjs"; +import AirQuality from "../../src/wk3/class/AirQuality.mjs"; const aq = (categoryIndex = 2, scale = "EPA_NowCast.2604") => ({ metadata: { providerName: "Synthetic", temporarilyUnavailable: false }, @@ -139,3 +140,171 @@ test("rejected prefetch is consumed even if mismatch skips comparison", async () assert.equal(result.previousDayComparison, 0); await new Promise(resolve => setImmediate(resolve)); }); + +const menuStandards = [ + "HJ6332012", "IE.AQIH", "AT.AQI", "BE.BelAQI", "UBA", "FR.ATMO", "KR.CAI", + "CA.AQHI", "CZ.AQI", "NL.LKI", "EPA_NowCast", "ICARS", "EU.EAQI", "CH.KBI", + "ES.MITECO", "SG.NEA", "NAQI", "DAQI", "HK.AQHI", "CN.AQHI", +]; +const weatherKit = { currentProvider: "WeatherKit", yesterdayProvider: "WeatherKit" }; + +test("all menu standards compare same-source categories without imposing CN/US AQI ranges", async () => { + for (const name of menuStandards) { + for (const version of ["2414", "2604"]) { + const scale = `${name}.${version}`; + // Category-only history is a supported provider contract. The local + // helper does not invent concentration or foreign index boundaries. + const current = { ...aq(2, scale), index: undefined }; + const previous = { ...aq(3, scale), index: undefined }; + assert.equal(computeWK3Comparison(current, previous, weatherKit), 4, scale); + assert.equal(computeWK3Comparison(previous, current, weatherKit), 2, scale); + assert.equal(computeWK3Comparison(current, current, weatherKit), 3, scale); + const result = await injectWK3Comparison(current, { ...weatherKit, yesterday: previous }); + assert.equal(result.previousDayComparison, 4, scale); + } + assert.equal(computeWK3Comparison({ ...aq(2, `${name}.9999`), index: undefined }, + { ...aq(2, `${name}.9999`), index: undefined }, weatherKit), 0, name); + } + for (const name of ["CA.AQHI", "HK.AQHI", "CN.AQHI"]) { + const current = { ...aq(2, name), index: 2 }, yesterday = { ...aq(3, name), index: 3 }; + assert.equal(computeWK3Comparison(current, yesterday, weatherKit), 4, name); + assert.equal(computeWK3Comparison(current, { ...yesterday, scale: `${name}.2414` }, weatherKit), 0, name); + } + for (const name of menuStandards.filter(name => !["HJ6332012", "EPA_NowCast"].includes(name))) { + assert.equal(computeWK3Comparison({ ...aq(2, `${name}.2414`), index: undefined }, + { ...aq(2, `${name}.2604`), index: undefined }, weatherKit), 0, name); + } +}); + +test("non-US/CN indices use source categories, including AQHI 11 and representable WK3 values", () => { + const local = (categoryIndex, scale, index) => ({ ...aq(), categoryIndex, scale, index }); + assert.equal(computeWK3Comparison(local(11, "CA.AQHI", 11), local(10, "CA.AQHI", 10), weatherKit), 2); + assert.equal(computeWK3Comparison(local(7, "DAQI.2604", 7), local(9, "DAQI.2604", 9), weatherKit), 4); + assert.equal(computeWK3Comparison(local(6, "KR.CAI.2604", 900), local(6, "KR.CAI.2604", 1000), weatherKit), 3); + for (const [scale, categoryIndex] of [["UBA.2414", 6], ["EU.EAQI.2414", 7], ["CA.AQHI", 12], ["DAQI.2414", 128]]) { + const value = local(categoryIndex, scale, undefined); + assert.equal(computeWK3Comparison(value, value, weatherKit), 0, scale); + } + assert.equal(computeWK3Comparison(local(2, "EU.EAQI", 18), local(2, "EU.EAQI", 18), weatherKit), 3); + for (const scale of ["UBA", "SomeFutureAQI.2604", "EPA_NowCast.2604.extra", "CA.AQHI.9999"]) { + const value = local(2, scale, 2); + assert.equal(computeWK3Comparison(value, value, weatherKit), 0, scale); + } + for (const index of [NaN, Infinity, -1, 32768, "7", null, 1.5]) { + const value = local(2, "DAQI.2414", index); + assert.equal(computeWK3Comparison(value, value, weatherKit), 0, String(index)); + } +}); + +const algorithms = [ + "UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", + "CA_AQHI", "HK_AQHI", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", +]; +function calculatedAQ(algorithm, amount) { + return AirQuality.Pollutants2AQI({ + metadata: { providerName: "Synthetic QWeather", temporarilyUnavailable: false }, + pollutants: ["PM2_5", "PM10", "OZONE", "NO2", "SO2"].map(pollutantType => ({ + pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER", + })), + }, {}, { algorithm }); +} + +test("all eleven actual built-in algorithms compare precomputed same-source pairs in every direction", async () => { + for (const algorithm of algorithms) { + const current = calculatedAQ(algorithm, 1), yesterday = calculatedAQ(algorithm, 300); + assert.equal(current.metadata.temporarilyUnavailable, false, algorithm); + assert.equal(yesterday.metadata.temporarilyUnavailable, false, algorithm); + assert.ok(current.categoryIndex < yesterday.categoryIndex, algorithm); + const provider = `Calculate:QWeather:${algorithm}`; + const options = { currentProvider: provider, yesterdayProvider: provider }; + assert.equal(computeWK3Comparison(current, yesterday, options), 4, algorithm); + assert.equal(computeWK3Comparison(yesterday, current, options), 2, algorithm); + assert.equal(computeWK3Comparison(current, current, options), 3, algorithm); + const injected = await injectWK3Comparison(current, { currentProvider: provider, yesterday: Promise.resolve(yesterday) }); + assert.equal(injected.previousDayComparison, 4, algorithm); + assert.equal(injected.pollutants, current.pollutants, algorithm); + assert.equal(injected.metadata, current.metadata, algorithm); + } +}); + +test("calculated pairs require a complete identical source+algorithm identity consistent with scale", () => { + const current = calculatedAQ("AQHI_Multi_CN", 1), yesterday = calculatedAQ("AQHI_Multi_CN", 300); + assert.equal(current.scale, "CA.AQHI"); + for (const provider of ["Calculate", "Calculate:", "Calculate::AQHI_Multi_CN", "Calculate:QWeather", + "Calculate:QWeather:None", "Calculate:QWeather:unknown", "Calculate:QWeather:HK_AQHI", + "Calculate:QWeather:UBA", "Calculate:QWeather:AQHI_Multi_CN:extra", "CalculateBogus"]) { + assert.equal(computeWK3Comparison(current, yesterday, { currentProvider: provider, yesterdayProvider: provider }), 0, provider); + } + const provider = "Calculate:QWeather:AQHI_Multi_CN"; + for (const yesterdayProvider of ["Calculate:ColorfulClouds:AQHI_Multi_CN", "Calculate:QWeather:CA_AQHI", + "Calculate:QWeather:AQHI_Multi_CN_HK", "QWeather", "Calculate"]) { + assert.equal(computeWK3Comparison(current, yesterday, { currentProvider: provider, yesterdayProvider }), 0, yesterdayProvider); + } + const sameScaleAlgorithms = [ + ["WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT"], ["CN_DEATH_AQHI", "CN_DEATH_HK_AQHI"], + ["CA_AQHI", "AQHI_Multi_CN"], ["AQHI_Multi_CN", "AQHI_Multi_CN_HK"], + ]; + for (const [todayAlgorithm, yesterdayAlgorithm] of sameScaleAlgorithms) { + assert.equal(computeWK3Comparison(calculatedAQ(todayAlgorithm, 1), calculatedAQ(yesterdayAlgorithm, 300), { + currentProvider: `Calculate:QWeather:${todayAlgorithm}`, yesterdayProvider: `Calculate:QWeather:${yesterdayAlgorithm}`, + }), 0, `${todayAlgorithm}/${yesterdayAlgorithm}`); + } +}); + +test("Calculate history is never fabricated from ColorfulClouds categories or another source", async () => { + let calls = 0; + const sources = { + colorfulClouds: { YesterdayAirQuality: () => { calls++; throw new Error("category-only source"); } }, + qWeather: { YesterdayAirQuality: () => { calls++; throw new Error("caller must compute history first"); } }, + }; + for (const source of ["QWeather", "ColorfulClouds", "WeatherKit"]) { + const current = calculatedAQ("UBA", 1); + const result = await injectWK3Comparison(current, { currentProvider: `Calculate:${source}:UBA`, sources, locationInfo: { id: "synthetic" } }); + assert.equal(result.previousDayComparison, 0, source); + assert.equal(result.index, current.index, source); + } + assert.equal(calls, 0); + const current = calculatedAQ("UBA", 1), categoryOnly = { ...aq(), pollutants: [], index: undefined }; + assert.equal((await injectWK3Comparison(current, { + currentProvider: "Calculate:ColorfulClouds:UBA", yesterday: categoryOnly, + })).previousDayComparison, 0); +}); + +test("all algorithm scale collisions still reject cross-standard history and inconsistent calculated indices", () => { + for (const algorithm of algorithms) { + const current = calculatedAQ(algorithm, 1), previous = calculatedAQ(algorithm, 300); + const provider = `Calculate:QWeather:${algorithm}`, options = { currentProvider: provider, yesterdayProvider: provider }; + const different = { ...previous, scale: current.scale === "CA.AQHI" ? "HK.AQHI" : "CA.AQHI" }; + assert.equal(computeWK3Comparison(current, different, options), 0, algorithm); + assert.equal(computeWK3Comparison(current, { ...previous, index: 0 }, options), 0, algorithm); + } + const current = { ...aq(6), index: 700 }, previous = { ...aq(6), index: 900 }; + const provider = "Calculate:QWeather:WAQI_InstantCast_US"; + assert.equal(computeWK3Comparison(current, previous, { currentProvider: provider, yesterdayProvider: provider }), 3); + assert.equal(computeWK3Comparison(current, previous, us), 0); +}); + +test("every pair of distinct menu standards refuses same-source category comparison", () => { + for (const todayName of menuStandards) { + for (const yesterdayName of menuStandards) { + if (todayName === yesterdayName) continue; + const today = { ...aq(2, `${todayName}.2604`), index: undefined }; + const yesterday = { ...aq(2, `${yesterdayName}.2604`), index: undefined }; + assert.equal(computeWK3Comparison(today, yesterday, weatherKit), 0, `${todayName}/${yesterdayName}`); + } + } +}); + +test("calculated over-range injection preserves index and consumes failed skipped prefetch", async () => { + const current = { ...aq(6), index: 700 }, provider = "Calculate:QWeather:WAQI_InstantCast_US"; + const result = await injectWK3Comparison(current, { currentProvider: provider, yesterday: { ...current, index: 900 } }); + assert.equal(result.previousDayComparison, 3); + assert.equal(result.index, 700); + assert.equal(current.previousDayComparison, 2); + const mismatch = await injectWK3Comparison(current, { + currentProvider: provider, yesterdayProvider: "Calculate:ColorfulClouds:WAQI_InstantCast_US", + yesterday: Promise.reject(new Error("already dispatched calculated history")), + }); + assert.equal(mismatch.previousDayComparison, 0); + await new Promise(resolve => setImmediate(resolve)); +}); diff --git a/tests/wk3/wk3-hk-aq.test.mjs b/tests/wk3/wk3-hk-aq.test.mjs index 49872568..14c6145b 100644 --- a/tests/wk3/wk3-hk-aq.test.mjs +++ b/tests/wk3/wk3-hk-aq.test.mjs @@ -10,6 +10,6 @@ const provider={metadata:{providerName:'Synthetic HK',sourceType:'MODELED'},scal function root(aq=false){const b=new Builder(),a=aq?encodeAirQuality(b,{scale:'HK.2604',index:3,categoryIndex:2,metadata:{providerName:'Apple',sourceType:3},primaryPollutant:1,pollutants:[]}):0;const text=b.createString('keep weather/alerts/news/unknown');b.startObject(1);b.addFieldOffset(0,text,0);const o=b.endObject();b.startObject(18);if(a)b.addFieldOffset(0,a,0);for(const slot of[1,2,3,9,17])b.addFieldOffset(slot,o,0);b.finish(b.endObject());return b.asUint8Array();} async function run(bytes,{country='HK',lat=22.278,lon=114.171,dataSets='airQuality,currentWeather,weatherAlerts',result=provider,settings={}}={}){return (await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/zh-Hans-SG/${lat}/${lon}?country=${country}&dataSets=${dataSets}`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer;messageType=WK3.Weather'},body:bytes},{Settings:buildSettings(database,{AirQuality:{Current:{Index:{Provider:'ColorfulCloudsUS',Replace:['HJ6332012']}},Comparison:{Yesterday:{IndexProvider:'ColorfulCloudsUS'}}},...settings}).Settings,Configs:{},enviroments:{},preFetched:{index:Promise.resolve(result),yesterdayAirQuality:Promise.resolve({...provider,index:125,categoryIndex:3})}})).body;} test('HK without AQ gets verified US child while original entire arena stays byte exact and 8-byte aligned',async()=>{const bytes=root(),out=await run(bytes),a=decodeWK3DataSet(out);assert.equal(a.index,72);assert.equal(a.scale,'EPA_NowCast.2604');assert.equal(a.previousDayComparison,4);assert.equal(a.metadata.providerName,'Synthetic HK');const b=new ByteBuffer(bytes),n=new ByteBuffer(out),r=b.readUint32(0),nr=n.readUint32(0);let shift;for(const slot of[1,2,3,9,17]){const t=b.__indirect(r+b.__offset(r,4+2*slot)),nt=n.__indirect(nr+n.__offset(nr,4+2*slot));shift??=nt-t;assert.equal(nt-t,shift);}assert.equal(shift%8,0);assert.deepEqual(out.slice(shift,shift+bytes.length),bytes);}); -test('HK labeled CN still recognized geographically; false HK labels elsewhere never inject',async()=>{assert.ok(isHongKong({country:'CN',latitude:22.278,longitude:114.171}));assert.equal(decodeWK3DataSet(await run(root(),{country:'CN'})).index,72);for(const args of[{country:'US'},{country:'SG'},{country:'HK',lat:31,lon:121},{country:'MO',lat:22.19,lon:113.54}]){const bytes=root();assert.deepEqual(await run(bytes,args),bytes);}}); +test('geographic HK recognition stays independent of the upstream AQ processing scope',async()=>{assert.ok(isHongKong({country:'CN',latitude:22.278,longitude:114.171}));assert.equal(decodeWK3DataSet(await run(root(),{country:'CN'})).index,72);for(const args of[{country:'US'},{country:'SG'},{country:'HK',latitude:31,longitude:121},{country:'MO',latitude:22.19,longitude:113.54}])assert.equal(isHongKong(args),false);}); test('existing Apple AQ, request exclusion, disabled dataset and non-US provider remain unchanged',async()=>{const existing=root(true);assert.deepEqual(await run(existing),existing);for(const options of[{dataSets:'currentWeather,weatherAlerts'},{settings:{DataSets:'[]'}},{settings:{AirQuality:{Current:{Index:{Provider:'WeatherKit'}}}}}]){const bytes=root();assert.deepEqual(await run(bytes,options),bytes);}}); test('unavailable, incomplete and invalid HK provider output never creates synthetic usable AQ',async()=>{for(const result of[undefined,{...provider,metadata:{temporarilyUnavailable:true}},{...provider,pollutants:[]},{...provider,index:NaN}]){const bytes=root();const opts={result:result===undefined?null:result};assert.deepEqual(await run(bytes,opts),bytes);}}); diff --git a/tests/wk3/wk3-mainland-aq.test.mjs b/tests/wk3/wk3-mainland-aq.test.mjs index ad33ece7..2d0d04e5 100644 --- a/tests/wk3/wk3-mainland-aq.test.mjs +++ b/tests/wk3/wk3-mainland-aq.test.mjs @@ -43,11 +43,10 @@ test("mainland missing AQ receives verified US child and keeps original arena al test("missing AQ respects request, dataset, region, replacement and source settings without querying", async () => { for (const options of [ { dataSets: "currentWeather" }, { env: { DataSets: "[]" } }, - { env: { "Weather.Replace": "[]" } }, { env: { "AirQuality.Current.Index.Replace": "[]" } }, + { env: { "AirQuality.Current.Index.Provider": "WeatherKit" } }, { env: { "AirQuality.Current.Index.Provider": "Calculate", "AirQuality.Calculate.Algorithm": "None" } }, - { country: "US", lat: 40.713, lon: -74.007 }, { country: "MO", lat: 22.19, lon: 113.54 }, - { country: "CN", lat: 25.03, lon: 121.56 }, + { env: { "AirQuality.Current.Index.Replace": "[]" } }, ]) { const bytes = root(), output = await run(bytes, options); assert.equal(output.calls, 0); assert.deepEqual(output.body, bytes); } diff --git a/tests/wk3/wk3-minutely-provider.test.mjs b/tests/wk3/wk3-minutely-provider.test.mjs new file mode 100644 index 00000000..ffbd33cd --- /dev/null +++ b/tests/wk3/wk3-minutely-provider.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Builder } from 'flatbuffers'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { buildEgern } from '../../scripts/build-egern.mjs'; +import { decodeWK3NextHour } from '../../src/wk3/class/WK3NextHour.mjs'; + +const responseCode = process.env.WK3_TEST_BUNDLE_DIR + ? await readFile(path.join(process.env.WK3_TEST_BUNDLE_DIR, 'egern', 'wk3-egern-response.bundle.js'), 'utf8') + : (await buildEgern({ minify: false })).response.code; +const response = (await import(`data:text/javascript;base64,${Buffer.from(responseCode).toString('base64')}`)).default; +const start = Date.parse('2030-01-01T08:00:00+08:00') / 1000; +function source() { + const b = new Builder(); + b.startObject(15); b.finish(b.endObject()); + return b.asUint8Array(); +} +function qweather(updateTime = '2030-01-01T08:00:00+08:00') { + return { code: '200', updateTime, fxLink: 'https://synthetic.invalid', summary: '小雨', + minutely: Array.from({ length: 12 }, (_, i) => ({ fxTime: new Date((start + i * 300) * 1000).toISOString(), precip: '0.1' })) }; +} +async function patch(body, { provider = 'QWeather', original = source(), handler = response } = {}) { + const calls = []; + const out = await handler({ + env: { SETTINGS: JSON.stringify({ DataSets: ['forecastNextHour'], NextHour: { Provider: provider }, API: { QWeather: { Token: 'synthetic', Host: 'synthetic.invalid' } } }) }, + request: { method: 'GET', url: 'https://weatherkit.apple.com/api/v3/weather/zh-Hans/31/121?country=CN&dataSets=forecastNextHour', headers: new Headers() }, + response: new Response(original, { headers: { 'content-type': 'application/vnd.apple.flatbuffer;messageType=WK3.Weather' } }), + http: { async get(url) { calls.push(url); return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); } }, + }); + return { out: out.body, calls, original }; +} +test('QWeather rain reaches WK3 through Egern URL string coordinates', async () => { + const { out, calls } = await patch(qweather()); + const next = decodeWK3NextHour(out); + assert.ok(next, 'available minutely rain must not disappear during metadata encoding'); + assert.equal(next.metadata.latitude, 31); + assert.equal(next.metadata.longitude, 121); + assert.equal(next.minutes.length, 60); + assert.ok(Math.abs(next.minutes[0].precipitationIntensity - 1.2) < 1e-6); + assert.equal(calls.length, 1, 'prefetched minutes must not be fetched twice'); +}); +test('QWeather minutely uses first fxTime when updateTime precedes the forecast grid', async () => { + const { out } = await patch(qweather('2030-01-01T07:58:00+08:00')); + const next = decodeWK3NextHour(out); + assert.ok(next, 'updateTime is publication time, not the minute grid origin'); + assert.equal(next.forecastStart, start); + assert.equal(next.forecastEnd, start + 3600); + assert.equal(next.minutes[0].startTime, start); +}); +test('missing QWeather minutes and a discontinuous grid preserve original bytes', async () => { + const gap = qweather(); gap.minutely[1].fxTime = new Date((start + 360) * 1000).toISOString(); + for (const body of [{ code: '204' }, { ...qweather(), minutely: [] }, gap]) { + const { out, original } = await patch(body); + assert.deepEqual(out, original); + } +}); +test('QWeather light snow retains minutes and SNOW summary without guessed text enums', async () => { + const snow = qweather(); snow.summary = '小雪'; + const { out } = await patch(snow); + const next = decodeWK3NextHour(out); + assert.equal(next.minutes.length, 60); + assert.ok(next.minutes[0].precipitationIntensity > 0); + assert.deepEqual(next.summary[0].condition, [3]); + assert.deepEqual(next.condition, []); +}); +function colorful(intensity) { + return { status: 'ok', server_time: start, location: [31, 121], result: { minutely: { + status: 'ok', description: intensity ? '小雨' : '未来两小时无降水', probability: [0.7, 0.7, 0.7, 0.7], precipitation_2h: Array(60).fill(intensity), + } } }; +} +test('ColorfulClouds actual rain and clear minutely both reach WK3 without deriving rain from current weather', async () => { + for (const intensity of [0, 1.5]) { + const { out, calls } = await patch(colorful(intensity), { provider: 'ColorfulClouds' }); + const next = decodeWK3NextHour(out); + assert.ok(next); + assert.equal(next.minutes[0].precipitationIntensity, intensity); + assert.equal(next.condition[0].forecastToken, intensity ? 6 : 1); + assert.equal(calls.length, 1); + } +}); +test('upstream heavy and possible-rain enums replace an existing nonzero minute arena', async () => { + const { out: original } = await patch(colorful(1.5), { provider: 'ColorfulClouds' }); + for (const [intensity, condition] of [[0.1, 9], [15, 6]]) { + const { out } = await patch(colorful(intensity), { provider: 'ColorfulClouds', original }); + const next = decodeWK3NextHour(out); + assert.notDeepEqual(out, original); + assert.ok(Math.abs(next.minutes[0].precipitationIntensity - intensity) < 1e-6); + assert.equal(next.condition[0].beginCondition, condition); + } +}); +// Explicit NSRingo dev b4a75db / actual 2026-11-07 bundle enums, not new device evidence. +function precipitationBody(provider, values, description) { + if (provider === 'QWeather') { + const body = qweather(); body.summary = description; + body.minutely.forEach((m, i) => { m.precip = String(values[i * 5] / 12); }); + return body; + } + const body = colorful(0); body.result.minutely.description = description; + body.result.minutely.precipitation_2h = values; + return body; +} +test('Egern light-snow fallback preserves original bytes for invalid normalized conditions', async () => { + // Faults enter AFTER real provider normalization, because HTTP bodies do not + // expose internal forecast tokens/parameters. The generated handler remains + // unchanged except for this test-only injection at its codec call boundary. + const code = process.env.WK3_TEST_BUNDLE_DIR ? (await buildEgern({ minify: false })).response.code : responseCode; + const marker = 'const nextHour = providerToWK3NextHour(data,'; + assert.equal(code.split(marker).length, 2, 'one real conversion boundary'); + const { out: original } = await patch(colorful(1.5), { provider: 'ColorfulClouds' }); + for (const fault of [ + 'data.condition[0].forecastToken = "UNKNOWN_TOKEN";', + 'data.condition[0].startTime++;', + 'data.condition[0].parameters = [{type:"THIRD_AT",date:data.forecastStart}];', + 'data.condition[0].endCondition = "UNKNOWN_WEATHER";' + ]) { + const instrumented = code.replace(marker, `${fault}\n${marker}`); + const handler = (await import(`data:text/javascript;base64,${Buffer.from(instrumented).toString('base64')}`)).default; + for (const provider of ['QWeather', 'ColorfulClouds']) { + const body = precipitationBody(provider, Array(60).fill(1.5), '下雪'); + const { out } = await patch(body, { provider, original, handler }); + assert.deepEqual(out, original, `${provider}: ${fault}`); + } + } +}); +for (const provider of ['QWeather', 'ColorfulClouds']) { + test(`${provider} SNOW and HEAVY_SNOW retain minute intensities through Egern`, async () => { + for (const [intensity, numericCondition] of [[5, 11], [15, 10]]) { + // First minute/block is 1.5; later SNOW/HEAVY_SNOW determines the run condition. + const values = Array.from({ length: 60 }, (_, i) => i < 5 ? 1.5 : intensity); + const { out, calls } = await patch(precipitationBody(provider, values, '下雪'), { provider }); + const next = decodeWK3NextHour(out); + assert.ok(next); + assert.equal(next.minutes.length, 60); + assert.ok(Math.abs(next.minutes[0].precipitationIntensity - 1.5) < 1e-6); + assert.ok(Math.abs(next.minutes[20].precipitationIntensity - intensity) < 1e-6); + assert.deepEqual(next.summary[0].condition, [3]); + assert.equal(next.condition[0].beginCondition, numericCondition); + assert.equal(next.condition[0].forecastToken, 6); + assert.equal(calls.length, 1); + } + }); + test(`${provider} unnamed snow descriptions omit text but retain light and mixed snow minutes`, async () => { + const { out: original } = await patch(colorful(1.5), { provider: 'ColorfulClouds' }); + for (const values of [Array(60).fill(0.1), Array(60).fill(1.5), Array.from({ length: 60 }, (_, i) => i < 20 ? 0.1 : i < 40 ? 1.5 : 0)]) { + const { out } = await patch(precipitationBody(provider, values, '下雪'), { provider, original }); + const next = decodeWK3NextHour(out); + assert.notDeepEqual(out, original); + assert.equal(next.minutes.length, 60); + assert.ok(next.minutes.some(m => m.precipitationIntensity > 0)); + assert.ok(next.summary.some(s => s.condition[0] === 3)); + assert.deepEqual(next.condition, []); + for (const i of [0, 20, 40]) assert.ok(Math.abs(next.minutes[i].precipitationIntensity - values[i]) < 2e-6); + } + }); + test(`${provider} rain and snow start, stop and intermittent runs reach Egern`, async () => { + for (const [description, intensity, kind] of [['下雨', 1.5, 2], ['下雪', 5, 3]]) { + for (const [pattern, requiredToken] of [['start', 2], ['stop', 3], ['intermittent', 5]]) { + const values = Array.from({ length: 60 }, (_, i) => { + const wet = pattern === 'start' ? i >= 10 : pattern === 'stop' ? i < 20 : i < 10 || i >= 20; + return wet ? intensity : 0; + }); + const { out } = await patch(precipitationBody(provider, values, description), { provider }); + const next = decodeWK3NextHour(out); + assert.ok(next, `${description} ${pattern}`); + assert.equal(next.minutes.length, 60); + assert.ok(next.condition.some(c => c.forecastToken === requiredToken), `${description} ${pattern} token`); + assert.ok(next.summary.some(s => s.condition[0] === kind)); + assert.equal(next.condition.at(-1).endTime, 0); + } + } + }); +} diff --git a/tests/wk3/wk3-minutely-source.test.mjs b/tests/wk3/wk3-minutely-source.test.mjs new file mode 100644 index 00000000..ace1c62a --- /dev/null +++ b/tests/wk3/wk3-minutely-source.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import ForecastNextHour from '../../src/wk3/class/ForecastNextHour.mjs'; +import { providerToWK3NextHour } from '../../src/wk3/class/WK3NextHour.mjs'; +const start = 2000000040; +const units = { intensityUnit: 'mm/h', chanceUnit: 'percent' }; +function source(intensities) { + const minutes = ForecastNextHour.Minute(intensities.map((precipitationIntensity, i) => ({ startTime: start + i * 60, precipitationIntensity, precipitationChance: 70 })), '小雨'); + const summary = ForecastNextHour.Summary(minutes); + const condition = ForecastNextHour.Condition(summary); + return { metadata: { providerName: 'Synthetic', sourceType: 'MODELED' }, forecastStart: start, forecastEnd: start + minutes.length * 60, minutes, summary, condition }; +} +test('actual source ordinary rain, start and stop convert using only verified WK3 enums', () => { + for (const values of [Array(85).fill(1.5), Array(10).fill(0).concat(Array(75).fill(1.5)), Array(10).fill(1.5).concat(Array(75).fill(0))]) { + const data = source(values), next = providerToWK3NextHour(data, units); + assert.equal(next.minutes.length, 85); + assert.ok(next.minutes.some(m => m.precipitationIntensity > 0)); + assert.equal(next.summary.at(-1).endTime, 0); + } +}); +test('source heavy rain, possible rain and intermittent STOP_START use explicit upstream WK3 definitions', () => { + const heavy = providerToWK3NextHour(source(Array(85).fill(15)), units); + assert.equal(heavy.condition[0].beginCondition, 6); + const possible = providerToWK3NextHour(source(Array(85).fill(0.1)), units); + assert.equal(possible.condition[0].beginCondition, 9); + const intermittent = providerToWK3NextHour(source(Array.from({ length: 85 }, (_, i) => i < 10 || i >= 20 ? 1.5 : 0)), units); + assert.ok(intermittent.condition.some(c => c.forecastToken === 5)); +}); +test('four-segment condition limit does not mutate the longer source summary timeline', () => { + const minutes = ForecastNextHour.Minute(Array.from({ length: 85 }, (_, i) => ({ startTime: start + i * 60, precipitationIntensity: i < 60 && Math.floor(i / 10) % 2 === 0 ? 1.5 : 0, precipitationChance: 70 })), '小雨'); + const summary = ForecastNextHour.Summary(minutes), before = structuredClone(summary); + const condition = ForecastNextHour.Condition(summary); + assert.equal(summary.length, 6); + assert.equal(condition.length, 4); + assert.equal(condition.at(-1).endTime, 0); + assert.deepEqual(summary, before, 'condition truncation must not introduce endTime=0 inside the summary'); + assert.equal(summary[3].endTime, summary[4].startTime); +}); diff --git a/tests/wk3/wk3-next-hour.test.mjs b/tests/wk3/wk3-next-hour.test.mjs index 430bd525..872daab9 100644 --- a/tests/wk3/wk3-next-hour.test.mjs +++ b/tests/wk3/wk3-next-hour.test.mjs @@ -23,7 +23,7 @@ function numericClose(a, b, path = '') { else assert.equal(a, b, path); } -test('provider conversion is independent from WK2 and maps only original-paired enums', () => { +test('provider conversion maps paired rain enums independently from WK2', () => { const data = provider(), out = providerToWK3NextHour(data, units); assert.deepEqual(out.summary.map(s => s.condition), [[1], [2]]); assert.deepEqual(out.condition.map(c => c.forecastToken), [2, 6]); @@ -32,7 +32,64 @@ test('provider conversion is independent from WK2 and maps only original-paired assert.equal(out.metadata.sourceType, 2); assert.equal(out.minutes[2].precipitationIntensity, 1.5); assert.equal(data.summary[0].condition, 'CLEAR'); - assert.deepEqual(WK3_NEXT_HOUR_ENUMS.forecastToken, { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, CONSTANT: 6 }); + assert.deepEqual(WK3_NEXT_HOUR_ENUMS.forecastToken, { CLEAR: 1, START: 2, STOP: 3, START_STOP: 4, STOP_START: 5, CONSTANT: 6 }); +}); +test('explicit upstream WK3 snow, precipitation and condition definitions round-trip', () => { + // NSRingo dev b4a75db / @nsringo/weatherkit 2.0.5 / actual dev bundle 2026-11-07. + const conditions = { SLEET: 4, POSSIBLE_SLEET: 5, HEAVY_RAIN: 6, RAIN: 7, DRIZZLE: 8, POSSIBLE_DRIZZLE: 9, HEAVY_SNOW: 10, SNOW: 11 }; + const precipitation = { CLEAR: 1, RAIN: 2, SNOW: 3, SLEET: 4, HAIL: 5, MIXED: 7 }; + for (const [name, value] of Object.entries(conditions)) { + const p = provider(); p.condition.forEach(c => { c.beginCondition = c.endCondition = name; }); + assert.equal(decodeWK3NextHour(root(providerToWK3NextHour(p, units))).condition[0].beginCondition, value); + } + for (const [name, value] of Object.entries(precipitation)) { + const p = provider(); p.summary[1].condition = name; + assert.deepEqual(decodeWK3NextHour(root(providerToWK3NextHour(p, units))).summary[1].condition, [value]); + } + assert.equal(WK3_NEXT_HOUR_ENUMS.condition.FLURRIES, undefined); + assert.equal(WK3_NEXT_HOUR_ENUMS.condition.POSSIBLE_FLURRIES, undefined); +}); +function lightSnowProvider() { + const p = provider(); + p.summary[1].condition = 'SNOW'; + p.condition[0].beginCondition = p.condition[0].endCondition = 'FLURRIES'; + p.condition[1].beginCondition = p.condition[1].endCondition = 'POSSIBLE_FLURRIES'; + return p; +} +test('light snow omits the complete descriptive vector only after validating all source fields', () => { + const p = lightSnowProvider(), before = structuredClone(p), report = {}; + const converted = providerToWK3NextHour(p, { ...units, report }); + const out = decodeWK3NextHour(root(converted)); + assert.deepEqual(out.condition, []); + assert.deepEqual(out.summary[1].condition, [3]); + assert.equal(out.minutes[2].precipitationIntensity, 1.5); + assert.equal(report.omittedSnowConditions, 2); + assert.deepEqual(p, before); + const knownReport = {}; + providerToWK3NextHour(provider(), { ...units, report: knownReport }); + assert.equal(knownReport.omittedSnowConditions, 0); +}); +test('light snow never masks broken timelines, tokens, parameters or other unknown conditions', () => { + for (const change of [ + d => d.condition[1].startTime++, + d => d.condition[0].endTime++, + d => d.condition[1].endTime = d.forecastEnd, + d => d.minutes[1].startTime++, + d => d.condition[0].forecastToken = 'UNKNOWN_TOKEN', + d => d.condition[1].forecastToken = 'UNKNOWN_TOKEN', + d => d.condition[0].parameters[0].type = 'THIRD_AT', + d => d.condition[0].parameters[0].date = NaN, + d => d.condition[1].parameters = undefined, + d => d.condition[0].endCondition = 'UNKNOWN_WEATHER', + d => d.condition[1].beginCondition = 'HAIL', + d => d.summary[1].condition = 'UNKNOWN_WEATHER', + d => d.summary[1].condition = 'RAIN', + d => d.metadata.sourceType = 'UNKNOWN' + ]) { + const p = lightSnowProvider(), report = {}; change(p); + assert.throws(() => providerToWK3NextHour(p, { ...units, report })); + assert.equal(report.omittedSnowConditions, undefined); + } }); test('root 5 codec round-trips nonzero chance/intensity/perceived and summary enum vectors', () => { const data = providerToWK3NextHour(provider(), units), bytes = root(data), copy = bytes.slice(); @@ -98,7 +155,7 @@ test('provider conversion fails closed for unknown enums, absent units, missing assert.throws(() => providerToWK3NextHour(provider())); assert.throws(() => providerToWK3NextHour(provider(), { ...units, chanceUnit: 'fraction' })); assert.throws(() => providerToWK3NextHour(provider(), { ...units, intensityUnit: 'mm/5min' })); - for (const change of [d => d.minutes[0].precipitationChance = 0.5, d => d.minutes[0].precipitationIntensity = undefined, d => d.minutes[0].perceivedPrecipitationIntensity = undefined, d => d.minutes[1].startTime++, d => d.forecastEnd++, d => d.condition[0].forecastToken = 'START_STOP_START', d => d.condition[0].beginCondition = 'HEAVY_RAIN', d => d.condition[0].parameters[0].type = 'THIRD_AT', d => d.condition[0].parameters[0].type = 'NO_RAIN', d => d.condition[0].parameters[0].type = 'BOTH', d => d.summary[1].condition = 'SNOW', d => d.summary[0].condition = [], d => d.summary[0].endTime++, d => d.metadata.sourceType = 'UNKNOWN', d => d.metadata.temporarilyUnavailable = true, d => d.minutes = [], d => d.summary = []]) { + for (const change of [d => d.minutes[0].precipitationChance = 0.5, d => d.minutes[0].precipitationIntensity = undefined, d => d.minutes[0].perceivedPrecipitationIntensity = undefined, d => d.minutes[1].startTime++, d => d.forecastEnd++, d => d.condition[0].forecastToken = 'START_STOP_START', d => d.condition[0].beginCondition = 'FLURRIES', d => d.condition[0].endCondition = 'POSSIBLE_FLURRIES', d => d.condition[0].parameters[0].type = 'THIRD_AT', d => d.condition[0].parameters[0].type = 'NO_RAIN', d => d.condition[0].parameters[0].type = 'BOTH', d => d.summary[1].condition = 'FLURRIES', d => d.summary[0].condition = [], d => d.summary[0].endTime++, d => d.metadata.sourceType = 'UNKNOWN', d => d.metadata.temporarilyUnavailable = true, d => d.minutes = [], d => d.summary = []]) { const data = provider(); change(data); assert.throws(() => providerToWK3NextHour(data, units)); } diff --git a/tests/wk3/wk3-upstream-aq-routing.test.mjs b/tests/wk3/wk3-upstream-aq-routing.test.mjs new file mode 100644 index 00000000..743fbc9a --- /dev/null +++ b/tests/wk3/wk3-upstream-aq-routing.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Builder,ByteBuffer} from 'flatbuffers'; +import {Response as processResponse} from '../../src/wk3/process/Response.mjs'; +import {encodeAirQuality,decodeWK3DataSet} from '../../src/wk3/class/WeatherKit3.mjs'; +import AirQuality from '../../src/wk3/class/AirQuality.mjs'; +import buildSettings from '../../src/wk3/function/buildSettings.mjs'; +import database from '../../src/wk3/function/database.mjs'; +const regions=[['TW',25.033,121.565],['CN',25.033,121.565],['MO',22.1987,113.5439],['CN',22.1987,113.5439]]; +const pollutants=[['PM2_5',25,9],['PM10',50,11],['OZONE',100,8],['NO2',25,6],['SO2',40,10],['CO',800,12]]; +const aq={metadata:{providerName:'Apple',sourceType:2},scale:'HJ6332012.2604',index:70,categoryIndex:2,isSignificant:false,primaryPollutant:9,previousDayComparison:3,pollutants:pollutants.map(([,amount,pollutantType])=>({amount,pollutantType,units:2}))}; +const source={metadata:{providerName:'Synthetic source',sourceType:'MODELED'},scale:'HJ6332012.2604',index:20,categoryIndex:1,isSignificant:false,primaryPollutant:'PM2_5',pollutants:pollutants.map(([pollutantType,amount])=>({pollutantType,amount:amount*2,units:'MICROGRAMS_PER_CUBIC_METER'}))}; +function root(existing=true){const b=new Builder(),a=existing?encodeAirQuality(b,aq):0,text=b.createString('weather/minutes/alerts-preserved');b.startObject(1);b.addFieldOffset(0,text,0);const child=b.endObject();b.startObject(18);if(a)b.addFieldOffset(0,a,0);for(const i of [1,5,9,17])b.addFieldOffset(i,child,0);b.finish(b.endObject());return b.asUint8Array();} +function marker(bytes,i){const b=new ByteBuffer(bytes),r=b.readUint32(0),t=b.__indirect(r+b.__offset(r,4+i*2));return b.__string(t+b.__offset(t,4));} +async function run(region,{existing=true,algorithm='UBA',targets=['HJ6332012'],datasets=['airQuality','forecastNextHour','weatherAlerts'],sourceFailure=false}={}){ + const [country,lat,lon]=region,bytes=root(existing),Settings=buildSettings(database,{Weather:{Replace:'[]'},DataSets:datasets,AirQuality:{Current:{Index:{Provider:'Calculate',Replace:targets},Pollutants:{Provider:'QWeather'}},Calculate:{Algorithm:algorithm},Comparison:{Yesterday:{IndexProvider:'WeatherKit'}}}}).Settings; + let calls=0;const output=await processResponse({url:`https://weatherkit.apple.com/api/v3/weather/zh-Hant/${lat}/${lon}?country=${country}&dataSets=airQuality,forecastNextHour,weatherAlerts`,headers:{}},{status:200,headers:{'content-type':'application/vnd.apple.flatbuffer'},body:bytes},{Settings,Configs:{},enviroments:{qWeather:{CurrentAirQuality:async()=>{calls++;if(sourceFailure)throw new Error('offline');return source;}},colorfulClouds:{Minutely:()=>assert.fail('must not fetch minutes')}}}); + for(const i of [1,5,9,17])assert.equal(marker(output.body,i),marker(bytes,i));return {bytes,out:output.body,aq:decodeWK3DataSet(output.body),calls,Settings}; +} +test('upstream AQ path processes Taipei/Macao without weather region overrides and keeps existing pollutants',async()=>{ + for(const region of regions){const r=await run(region);assert.equal(r.calls,0);assert.equal(r.aq.scale,'UBA.2414');const expected=AirQuality.Pollutants2AQI({metadata:{providerName:'Apple'},pollutants:pollutants.map(([pollutantType,amount])=>({pollutantType,amount,units:'MICROGRAMS_PER_CUBIC_METER'}))},r.Settings);assert.equal(r.aq.index,expected.index);} +}); +test('missing regional AQ uses configured provider, no country remapping',async()=>{ + for(const region of regions){const r=await run(region,{existing:false});assert.equal(r.calls,1);assert.equal(r.aq.scale,'UBA.2414');assert.equal(r.aq.index,AirQuality.Pollutants2AQI(source,r.Settings).index);} +}); +test('all eleven algorithms run on original Taipei/Macao Apple pollutants without provider requests',async()=>{ + 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']; + for(const region of [regions[0],regions[2]])for(const algorithm of algorithms){const r=await run(region,{algorithm});assert.equal(r.calls,0);assert.notDeepEqual(r.out,r.bytes);} +}); +test('AQ disabled, algorithm None, unmatched index targets and provider failure preserve original bytes',async()=>{ + for(const region of [regions[0],regions[2]]){ + for(const options of [{algorithm:'None'},{targets:['EPA_NowCast']},{targets:[]},{datasets:['currentWeather']},{existing:false,sourceFailure:true}]){const r=await run(region,options);assert.deepEqual(r.out,r.bytes);} + } +}); From 6cf39473998fdc3667449ff983fe5fbed3f9a622 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:08:55 +0800 Subject: [PATCH 41/42] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88v3.3.8?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E9=87=8D=E5=86=99=E5=8A=9F=E8=83=BD=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E6=A8=A1=E5=9D=97=E6=8C=87=E5=8D=97=E4=B8=8E?= =?UTF-8?q?=E9=9B=B7=E8=BE=BE=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 30 +++++++ README.md | 134 ++++++++++++++++------------- images/radar-preview.png | Bin 0 -> 181215 bytes modules/README.md | 94 ++++++++++++++++++++ modules/iRingo.WeatherKit.WK3.yaml | 18 ++-- 5 files changed, 207 insertions(+), 69 deletions(-) create mode 100644 images/radar-preview.png create mode 100644 modules/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 631b95c4..d3f7fd1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +# v3.3.8 — WK3 全算法空气质量、雨雪分钟与原生预警 + +本次更新相对 **v3.3.7**。Egern 测试 11 的欧盟名称与色带已由用户实机确认;其他具体能力的验证范围见下文。 + +## 更新内容 + +- **空气质量完整转换:**接通 11 种内置算法、跨标准转换、缺失 AQ 补入及污染物单位模式;保留其他天气数据槽与未知结构保护。AQ 按上游 dev 独立处理,不再被普通天气的大陆范围挡住;已有 Apple 污染物优先使用,缺失时再查询所选来源。 +- **欧盟原生标尺:**EAQI 输出无版本 `EU.EAQI`;标尺请求去除数字版本并移除旧 ETag 条件,使用 Apple 原生资源,撤去对 200 响应的本地展示覆盖,解决已验收测试版的“欧洲”名称与色带分段问题。 +- **指数与标尺配套:**补齐加拿大 AQHI 回退标尺,修正无版本 AQHI 身份、UBA 显示范围及相关单位/算法映射;保留既有加拿大计算和超量程修正。昨日趋势注明来源,彩云按上游流程比较其自身今日/昨日数据,不跨标准硬比较。 +- **雨雪分钟:**参考上游 dev 扩展雪、强雨及间歇降水枚举;未命名轻雪文字可省略并保留分钟数值和雪摘要。修复和风分钟坐标、`fxTime` 起点和 AQ 通用元数据类型;分钟有效期同步为 10 分钟,避免分段处理修改原始摘要。 +- **原生预警:**统一摘要/详情标题规范化,支持上游红橙黄蓝等级,修复高速公路等重复“预警”后缀。Apple 泛称“恶劣天气”在同地区、同等级、同发布时间且唯一候选时补全具体事件;保留原 UUID,全组匹配后才切换共享详情链接。 +- **模块参数与诊断:**保留 23 项设置和全部内置算法,今日来源默认内置算法、默认欧盟 EAQI;Egern 选择器显示中文,旧内部值继续兼容。模块和参数简介精简,摘要补全/详情转换分开记录。 +- **文档:**首页改为相对上游最新正式版的功能说明、默认参数和边界;补充模块 README、实验雷达实现路径与合成预览图。二维雷达仍在独立实验分支,不作为本次发行能力。 + +## 安装与升级 + +使用对应平台文件名含 `.WK3.` 的发行附件。停用旧普通模块或测试普通模块,更新整个模块并核对已保存参数;独立实验雷达模块可保留。Egern、Surge、Loon、Stash 的模块自动加载同版本 JS,不可互换脚本。 + +默认今日来源是“iRingo内置算法”(`Calculate`),算法是 `EU_EAQI`,原始标准替换目标仍为 `HJ6332012`。改变算法不会绕过替换目标;原数据为美标等标准时,按需把对应标准加入目标。和风 API 需要可用主机与令牌。 + +## 验证与保留限制 + +- 用户已确认 Egern 测试 11 的欧盟名称与色带修复可用。正式候选五平台 10 份 JS 与 5 份模块均构建成功;候选 Egern 业务代码与该测试版一致,仅移除测试标识。AQ、雨雪及预警的代码回放不等同于所有客户端、地区和天气条件的逐项实机验收。 +- WK3 当前、逐日、逐小时天气仍透传;Quantumult X 二进制天气仍不转换,Shadowrocket 暂无本机 WK3 模块。 +- 台北/澳门是否能补入取决于原污染物及所选来源;彩云 TW 白名单、和风历史地区限制等仍保留。没有来源数据时不伪造结果。 +- 预警失配、歧义、时间不符或未知结构保留原数据;泛称匹配为有约束推断。轻雪数值编码通过不等于每种轻雪文字及地图显示均已手机验收。 +- 原生一小时雷达的无数据斜线、物理雨强、多色与大陆预测技巧仍在开发,未并入本次普通天气发行。 + +--- + # v3.3.7 — 修复 Surge WK3 二进制回传与天气增强无效果 修复 Surge 普通模块在已有脚本执行记录时仍无法正常显示天气增强数据的问题。用户已确认 `3.3.6-test-surge-binary.1` 测试版通过,本版纳入同一项原生字节回传修复。 diff --git a/README.md b/README.md index f2de84ca..10c6aab0 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,101 @@ -#  iRingo: 🌤 WeatherKit +# WeatherKit-WK3 -此 Fork 以 NSRingo/WeatherKit **v3.3.2** 为基点,v3.3.3 增加本机 WK3 处理,保留原版 WK2 入口;业务统一放在平台无关的 `src/wk3/`,Egern 与其他代理共用处理逻辑。中文独立提交历史已保留。 +让代理在设备本机处理 Apple 天气的 WK3 数据:空气质量、雨雪分钟预报、昨日对比和原生预警详情。**当前、逐日和逐小时天气继续使用 Apple 原始数据。** -## v3.3.7 — Surge 二进制响应修复 +[模块安装与参数说明](modules/README.md) · [发行记录](CHANGELOG.md) · [上游项目](https://github.com/NSRingo/WeatherKit) · [实验雷达地图](#一小时降水雷达地图开发中) -修复 Surge 普通 WK3 模块“有脚本执行记录但天气增强无效果”的二进制回传缺陷:启用 `binary-body-mode` 后,天气响应使用原生 `Uint8Array` 回传,避免字符串被再次按 UTF-8 编码而损坏 FlatBuffer。此问题也会影响原本应保留的海外天气数据。 +## 相比上游正式版 -用户已确认 `3.3.6-test-surge-binary.1` 测试版通过。真实 Surge 抓包中,五份完整天气响应均可正常解码,国内/香港彩云美标 AQ、大陆分钟雨与预警改写有实际响应证据。文本响应及 Loon/Stash/Egern/Quantumult X 的现有输出方式保持不变,23 项参数与默认设置不变。 +对比基线为 [NSRingo/WeatherKit v3.3.2](https://github.com/NSRingo/WeatherKit/releases/tag/v3.3.2)(截至 2026-11-08 的最新非预发行版)。本 Fork 以该版本为基础;部分 WK3 模型和处理方式参考上游 **dev**,不把上游测试版已有能力宣称为本项目独创。 -升级时停用 Surge 二进制修复测试模块,再安装对应正式模块并核对个人参数;独立雷达模块可保留。完整说明见 [CHANGELOG](CHANGELOG.md)。 +| 项目 | 本 Fork 的差异与支持范围 | +| --- | --- | +| WK3 本机处理 | 提供 Egern、Surge、Loon、Stash 的 WK3 专用构建;共用业务核心,保留未处理的数据槽。Quantumult X 二进制天气仍透传。 | +| 空气质量 | 按上游 dev 的独立 AQ 入口处理,不套用普通天气的大陆范围;优先使用 Apple 已有污染物,缺失时查询所选来源。支持完整 AQ 转换、缺失补入和单位转换。 | +| 内置算法 | 11 种计算算法及 `None`;包括欧盟 EAQI、UBA、美标/国标 InstantCast、加拿大/香港 AQHI 和风险算法。保留本 Fork 对加拿大单位、指数取整及超量程连续性的修正。 | +| 欧盟标尺 | 与上游 dev 一样使用无版本 `EU.EAQI`,请求 Apple 原生标尺;不再用本地色带覆盖名称与渐变。 | +| 雨雪分钟预报 | WK3 分钟数据编码与缺失补入,参考上游 dev 的雨雪枚举;修复和风坐标、预报起点及有效期处理。轻雪未命名文字条件可省略,分钟强度和雪摘要保留。 | +| 预警详情 | 补全 Apple 已有事件,保留原 UUID,匹配后进入 Apple 原生详情页面;支持上游定义的红橙黄蓝等级。泛称“恶劣天气”在同地区页面、同等级、同发布时间且候选唯一时推断具体事件。 | +| 客户端兼容 | Egern 原生 `ctx`,Surge/Loon 原生字节回传,其他平台独立适配;错误或未知结构保留原数据。 | +| 参数与日志 | Egern 使用中文选项、简短说明,兼容旧内部值;分别记录预警摘要、详情转换和回退原因,敏感配置不明文输出。 | -## v3.3.6 更新 +上游 dev 与本 Fork 的实现并非完全相同。已有 AQ 的完整替换、事件 UUID 关联、宿主二进制返回等应以本仓库实现及平台说明为准。 -合入 `wk3/ios27-wk3-review` 的 Loon 原生 `Uint8Array` 回传、Apple availability 保留、大陆缺 AQ 补入及非 Egern 预警兼容修复。Loon 测试版 2 已获用户实机确认可用;Egern 测试版的大陆缺 AQ 补入也已确认生效。 +## 安装 -大陆缺 AQ 补入由 Egern、Surge、Loon、Stash 共用:请求和设置启用 AQ、替换范围命中大陆、今日来源为 `ColorfulCloudsUS` 且替换目标非空时,补入完整美标 AQ 子表;失败保留原响应。普通响应头大小写和局部 Base64 预警兼容仅接入其他四平台代理构建,Egern 预警实现保持原样。Quantumult X 保留二进制直通限制,详情请求路径包含兼容修复。 +使用文件名包含 **`.WK3.`** 的发行附件,不要与旧 Rewrite 模块或测试模块重复启用。模块会自动加载同一版本、同一客户端的脚本,无需单独导入 JS。 -**保留已知问题:**部分城市 UV 面板的防晒时段建议仍可能显示英文,标题和等级正常;根因尚未证实,本次不修改语言设置或建议文案。完整更新、安装方法与默认参数见 [CHANGELOG](CHANGELOG.md)。 +| 客户端 | 下载 | 说明 | +| --- | --- | --- | +| Egern | [WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) | 参数面板;本轮原生欧盟标尺测试版已获用户实机确认。 | +| Surge | [WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) | 模块参数;原生二进制回传已有实机验证。 | +| Loon | [WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) | 插件参数;二进制及缺 AQ 补入已有测试版实机验证。 | +| Stash | [WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本的 `argument`,其他四条共享;当前以脚本回放验证为主。 | +| Quantumult X | [WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet) | 请求/文本路径可用;**WK3 二进制天气不转换**,无模块参数面板。 | -## v3.3.5 日志兼容修复 +启用 MITM 并信任证书。普通模块处理 `weatherkit.apple.com`;独立雷达模块另行安装。Shadowrocket 暂无经过验证的本机 WK3 模块。 -针对用户在 Egern、Loon 反馈的“有执行记录但日志为空”,五平台统一通过 `console.log` 输出脱敏后的单字符串,不再因 `console.info` 方法存在就假定客户端会收集。日志等级与业务处理不变。更新模块后确认 `LogLevel` 为 INFO/DEBUG/ALL,刷新天气查看**新执行记录**;旧记录不会补写。正式压缩脚本已复现修复前后输出差异,仍待设备复验。 +## 默认参数与使用要点 -## v3.3.4 修复 +以下为可配置 WK3 模块的默认值,**更新不一定覆盖设备上已保存的选项**。Egern 显示中文,括号内为内部值。 -- 校正美标浓度截断与超量程连续性、加拿大 AQHI 单位与四舍五入、国标草案零浓度;CO 单位修复幂等,不改变输入;无有效分项或必要污染物时不输出伪有效指数。 -- 空替换目标、算法 `None`、Apple 来源和昨日来源按用户选择执行;成功且实际改变指数后才按开关重算已知比较,失败保留 Apple 数据,移除无必要 AQ 预取。 -- 修正 Surge/Loon/Stash 响应阶段 `$done` 结构及 Surge `[]` 参数解析;超时与日志使用实际解析后的模块参数。 -- 模块名称、选项标签与帮助对齐上游;保留 23 项参数及现有默认。Stash 在第一条脚本 `argument` 编辑参数,其余四条共用该值。未实现的上游功能明确标注,不提供空壳选项。 -- 五平台共用日志实现;默认 INFO 显示 `☑️ request/response`、处理摘要和 `✅ request/response`,DEBUG/ALL 展示更多处理步骤,WARN/ERROR 显示回退或异常,OFF 静默。令牌和完整配置不在日志中明文输出。 -- `npm run test:wk3` 串行执行旧入口与 WK3 全部回归,正式 CI 先测试再构建;修复分支 CI **338/338 通过、0 跳过**。无新增依赖、hash 或缓存层。 +| 设置 | 默认值 | 作用 | +| --- | --- | --- | +| 今日空气指数来源 | iRingo内置算法(`Calculate`) | 使用污染物本地计算;也可选彩云美标、彩云国标或和风。 | +| 内置算法 | 欧盟 EAQI(`EU_EAQI`) | 11 种算法加 `None`;仅选择内置算法来源时使用。 | +| 指数替换目标 | `HJ6332012` | **原始标准**,不是目标标准;空值或 `[]` 停用指数替换。 | +| 今日污染物来源 | 彩云(`ColorfulClouds`) | 已有 Apple 污染物优先保留,缺失时才取此来源。 | +| 污染物单位 | 目标 `[]`、模式 `Scale` | 默认不额外转换;配置目标后按标准或指定单位处理。 | +| 昨日指数来源 | 彩云美标(`ColorfulCloudsUS`) | 直接同源比较;今日显示标准不同时,按上游流程比较该来源自己的今日/昨日趋势,并标注来源。 | +| 昨日污染物来源 | 和风(`QWeather`) | 内置算法计算昨日值所需的来源;须有真实历史浓度。 | +| 指数改变时重算已有趋势 | `false` | 保留有效原比较;未知趋势在转换时尝试补齐。 | +| 国标强制显示主要污染物 | `true` | 不超过 50 时也显示主要污染物。 | +| 允许内置指数超标 | `true` | 不取消输入与线格式范围检查;超出可编码范围时保留原响应。 | +| 下一小时雨雪来源 | 彩云(`ColorfulClouds`) | 单点分钟预报,不是二维雷达地图。 | +| 预警来源 | 和风网页(`QWeatherWeb`) | 严格匹配 Apple 已有事件,不凭空增加预警。 | +| 普通天气范围/逐日逐小时替换 | `CN`/`false`、`false` | WK3 当前、逐日、逐小时数据仍透传。 | +| 网络超时/日志 | 8000 毫秒/`INFO` | 单次来源请求超时;`OFF` 关闭日志。 | -## WK3 基础能力 +**算法切换:**先选“iRingo内置算法”,再选算法。已有 AQ 的原始标准须命中替换目标;如果 Apple 已返回美标,只有 `HJ6332012` 不会替换它,可按需加入 `EPA_NowCast`。不要把修改算法选项误认为无条件覆盖所有地区和标准。 -- WK3 FlatBuffer 有界读写:在已验证结构内替换空气质量,保留其他数据槽与未知字段;国标 AQI 可转换为同源美标 AQI。大陆和香港在 WK3 缺少 AQ 数据时,可按各自条件补入彩云美标 AQ。 -- WK3 已实现分钟降水、同源同标准的昨日空气等级比较,以及严格匹配原事件 ID 的天气预警补全;失败或未验证结构保持 Apple 原始数据。 -- 保留 WK2 的天气来源选择与天气处理;内置空气质量算法提供上游的 12 种选择,包含 InstantCast、EAQI、UBA 和 AQHI 系列。**默认 `WAQI_InstantCast_US`**,但仅在今日空气指数来源选择 `Calculate` 时用于本地计算。 -- Egern 模块保留五类脚本、七条分流规则与 23 个可调参数;另外提供 Surge、Loon、Stash 和 Quantumult X 的独立模板及构建脚本。新增内容按功能分为独立提交,不压缩历史。 +**地区与来源:**AQ 不要求改国家码。台北/澳门有 Apple 污染物时可直接计算;缺数据时必须选真正支持该地区的来源。上游彩云 AQ 列表为 CN/HK/MO,TW 缺数据应选和风并配置有效 API;不承诺默认彩云对所有台北响应补入。来源失败不伪造有效数据。当前预警和分钟增强的普通地区范围仍有大陆限制。 -## 模块适配状态 +**昨日比较:**有历史浓度才可重算算法。彩云历史接口给出的类别不能反推 UBA/EAQI/AQHI 的昨日浓度;无数据则趋势未知,今日指数仍可用。不同标准绝不直接比较数值。Quantumult X 无参数注入时沿用运行时内置配置,但其 WK3 二进制响应始终不转换。 -| 软件 | 模块格式 | 当前验证范围 | -| --- | --- | --- | -| Egern | `.yaml` | 用户已确认 Egern 测试版大陆缺 AQ 补入生效;正式模块引用同版本附件,预警实现保持原样。 | -| Surge | `.sgmodule` | 用户已确认二进制修复测试版通过;真实抓包验证 AQ、分钟雨及预警改写,v3.3.7 纳入原生字节回传修复。 | -| Loon | `.lpx` | 用户已确认测试版 2(`3.3.5-test-loon-binary.2-aq.1`)实机可用;对应修复已纳入 v3.3.6。 | -| Stash | `.stoverride` | 独立 JS、配置解析、参数与二进制响应模拟通过;未设备验证。 | -| Quantumult X | `.snippet` | 请求/文本路径及脚本模拟通过;**WK3 二进制天气响应只透传,不作转换**;模块无可交互的参数选择界面。 | -| Shadowrocket | 无 WK3 本机模块 | 上游仅提供服务端 Rewrite 模板;本分支没有经过验证的本机二进制适配,故不提供伪装可用的模块。 | +完整 23 项参数见 [模块 README](modules/README.md)。 -正式模块:[Egern](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) · [Surge](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) · [Loon](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) · [Stash](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) · [Quantumult X](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet)。 +## 一小时降水雷达地图(开发中) -升级时停用测试版及其他冲突天气模块,更新整个正式模块,使五条脚本都引用 v3.3.7;保留并核对个人参数。MITM 需启用 `weatherkit.apple.com` 并信任证书。成功补入 AQ 的日志位于 `WeatherKit.api.weather.response`,包含 `WK3 missing AQ patch`。 +![一小时雷达外推实验预览与实现路径](images/radar-preview.png) -五平台统一构建入口为 `npm run build:wk3`:调用 Egern `scripts/build-egern.mjs` 和其他平台 `scripts/build-wk3-platform.mjs`,生成 10 份平台专用 JS。打标签时添加 `-- --version <版本号>`,同时生成 5 份模块至 `dist/` 根目录;Egern 模块改为指向同版本的发行附件,与其他平台一同进入现有构建/Release 工作流。请使用发行附件中的模块;五平台脚本不能互换。 +**这是独立实验功能,不包含在普通 WK3 模块中,也不是本次正式发行的新能力。** 当前阶段目标为中国大陆。上图使用离线沙箱的合成平移回波,展示时间序列和架构,非实时天气、非雨强预测,也不是 Apple 天气实机截图。 -## 代码组织 +实现路径: -- `src/wk3/class`、`function`、`process`、`proto`、`utils`:五平台共用的编解码、算法、来源处理与业务逻辑,按上游分类保留;不再嵌套在 Egern 目录。 -- `src/wk3/entry`:共用的配置归一化与请求/响应处理;`runtime.mjs`、`parameters.mjs`、`routes.mjs` 分别维护共享 HTTP 封装、参数和五类路由。 -- `src/platform/wk3-adapter.mjs`:Surge/Loon/Stash/Quantumult X 的客户端对象适配;Egern 原生上下文直接调用同一组共享入口。客户端网络 API 和输出格式由各自 Rollup 构建器绑定,不混入业务算法。 -- `tests/wk3/`:共享协议及业务测试;根目录 `tests/wk3-*.test.mjs`:跨平台代理和发行构建测试。 +1. 读取 RainViewer 公开历史雷达与覆盖信息;免费历史源不直接提供未来预测。 +2. 在用户自己的 VPS 上解码调色板、拼接邻域,用最近多帧估计运动并平流外推,计算未来约一小时的瓦片。 +3. 编码成 Apple 地图可读取的 KTX/ASTC 纹理,经用户自己的 Cloudflare Worker 提供受校验的接口。 +4. 独立代理模块仅替换匹配的雷达帧;普通 AQ、预警、分钟雨仍在代理本机处理,不绕行 VPS。 -## 默认配置 - -| 设置 | 默认值 | 生效范围 | -| --- | --- | --- | -| 今日空气指数来源 | `ColorfulCloudsUS` | 彩云美标;已有 AQ 仅匹配 `HJ6332012` 后替换,大陆缺 AQ 时按上述条件补入。 | -| 昨日空气指数来源 | `ColorfulCloudsUS` | 与今日同来源、同标准才比较;否则保持未知。 | -| 内置 AQ 计算算法 | `WAQI_InstantCast_US` | **只有来源设为 `Calculate` 才执行**;共 12 个选项含 `None`(11 种计算),WK3 目标标准不兼容时透传。 | -| 分钟降水 | `ColorfulClouds` | 已验证的分钟雨结构;未知条件透传。 | -| 天气预警 | `QWeatherWeb` | 严格匹配后补全原生详情;`WeatherKit` 为原样透传。 | -| 天气来源 | `ColorfulClouds` | WK2 可用;WK3 当前/逐日/逐小时天气仍透传 Apple。 | -| 逐日/逐小时替换 | `false` / `false` | WK2 开关,WK3 不生效。 | -| 天气替换范围 / 请求超时 / 日志 | `CN` / `8000` 毫秒 / `INFO` | 香港缺 AQ 补入另有专用条件;第三方令牌按需在设备上配置。 | +| 阶段 | 当前进度 | +| --- | --- | +| 数据解码、拼图、运动估计、外推、纹理编码 | 已实现;离线沙箱可以复跑真实计算链路。 | +| 历史/未来帧交付、缓存复用、排队状态及错误回退 | 已实现并做网关/Worker/代理回放测试。 | +| Apple 地图原生交付 | 历史和未来实验纹理已有手机接收证据;已显示实验回波,但**不等于覆盖与预测质量验收**。 | +| 无数据斜线与局部未知区域 | 尚未解决最终显示闭环;不伪造覆盖,不把缺测当无雨。 | +| 多色与物理雨强 | 当前交付仍为二值回波,不能解释为 mm/h;标定和显示待完成。 | +| 中国大陆真实预测技巧与多客户端体验 | 尚未完成多独立天气过程验收;已有诊断中仍有退化,不宣称一小时准确预报。 | -上述为生成模块的默认参数,不覆盖已安装模块或设备上的个性化参数。改变内置算法默认值也**不会**自动将今日指数来源改为 `Calculate`。 +实验源码位于 [`feat/cloudflare-radar`](https://github.com/patrickyanxxxxx/WeatherKit-WK3/tree/feat/cloudflare-radar)。目标是**用户自部署 Worker+自有 VPS**,没有公共默认后端;不把任何个人演示服务当产品默认。当前离线沙箱主要验证 Surge 宿主,其他客户端仍需对应设备验收。 -## 已知限制与发布 +## 限制、安全与开发 -- WK3 `currentWeather`、`forecastDaily`、`forecastHourly` 缺少经真实样本验证的编解码映射;不可把上游 WK2 codec 直接用于 WK3。现阶段这三类数据原样透传,即使在设置中选择了天气数据源。 -- Quantumult X 二进制天气不可转换;Surge 二进制修复测试版、Loon 测试版 2 已有用户实机确认,Stash 目前通过压缩脚本和抓包回放。原生预警补全也依赖可严格匹配到的原事件,失配时原样返回。 -- 本 Fork 的 v3.3.7 WK3 模块在 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases) 获取,文件名含 `.WK3.`。发行附件中的 Egern YAML 引用同版本 JS;更新模块后请核对已保存的个人参数。 -- 上游的私有 GitHub Packages 依赖在无权限环境会使干净 `npm install` 返回 401。本分支使用已有依赖及上游已声明的 Rollup 工具完成验证,没有新增依赖。 +- 预警失配、候选歧义、缺失原发布时间或未知结构时保留原链接;泛称匹配是受约束推断,不是单靠标题精确识别。 +- 未命名轻雪文字条件省略时可能连带省略其他文字段;分钟数值和雪摘要保留。编码通过不等于每种天气都已获手机显示验收。 +- 部分城市 UV 防晒时段建议可能仍为英文;不改语言或伪造当前天气。 +- 来源令牌只在设备参数配置;不提交抓包、位置日志、Apple 认证或个人后端凭据。 +- `src/wk3/` 为共享业务,`src/platform/` 为宿主适配;参数见 `src/wk3/parameters.mjs`,平台模块由模板统一生成。 +- `npm run test:wk3` 运行回归;`npm run build:wk3 -- --outdir dist` 生成脚本;带 `--version 3.3.8` 生成同版本五模块。构建不会自动创建标签或发行版。 +- 保留上游及 Horatio/PR #107 等来源与 Apache-2.0 署名。上游私有包需要相应 GitHub Packages 读取权限;依赖安装失败不能当作测试通过。 -验证命令:`npm run test:wk3`(包含旧入口、算法边界、配置、模块、五平台日志与发行构建回归,串行避免本机并发打包争用)。普通分支运行 `npm run build:wk3` 仅生成五平台脚本;打标签时 CI 使用实际标签版本运行 `npm run build:wk3 -- --version <版本号>`,将十份脚本与五份模块一起写入 `dist/` 并进入现有附件上传步骤。指定版本构建本身不创建标签或 Release;发版后仍须核对客户端能访问每个附件 URL。 +发行差异统一放在 [CHANGELOG](CHANGELOG.md) 和每次 Release 正文,首页不再堆叠逐版更新记录。 diff --git a/images/radar-preview.png b/images/radar-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..24ce4ab540f661606f5e702616f963be6b2c37b9 GIT binary patch literal 181215 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%0DB7R=) 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 literal 0 HcmV?d00001 diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 00000000..8570167c --- /dev/null +++ b/modules/README.md @@ -0,0 +1,94 @@ +# WK3 模块:安装与参数 + +[返回项目首页](../README.md) · [完整发行记录](../CHANGELOG.md) + +推荐从 [Releases](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest) 安装文件名包含 **`.WK3.`** 的对应客户端模块。模块引用同一版本的两个平台专用 JS,下载脚本由客户端完成,运行和数据转换在设备本机执行;第三方数据仍需要联网获取。不要跨客户端混用 JS,也不要同时启用正式普通模块与测试普通模块。 + +## 下载与配置入口 + +| 客户端 | 正式模块 | 参数入口 | +| --- | --- | --- | +| Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) | 模块设置面板;空气指数来源及算法显示中文,旧内部值仍兼容。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) | 模块参数。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) | 插件参数面板。 | +| Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本 `argument` 中的 JSON,其余四条共享。 | +| Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet) | 无交互参数面板;请求/文本路径可用,WK3 二进制天气始终透传。 | + +启用 MITM 并信任证书,确认 `weatherkit.apple.com` 被处理。升级整个模块,不只替换某一条脚本;客户端可能保留旧参数,更新后请核对来源、算法和替换目标。独立实验雷达模块可与普通模块并用,但本目录的普通模块不包含二维雷达计算。 + +旧 `iRingo.WeatherKit.Rewrite.*` 是上游兼容入口,不是本 Fork 的 WK3 本机推荐模块。仓库中的 `iRingo.WeatherKit.WK3.yaml` 为生成配置,正式安装优先使用发行附件。 + +## 23 项参数 + +下表列出模块生成默认值,内部键名保留以便 Surge/Stash 配置及问题定位。Egern 的中文显示值会在脚本入口还原为对应内部值。 + +| 参数 | 默认值 | 用途 | +| --- | --- | --- | +| `DataSets` | airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、locationInfo、news、historicalComparisons、weatherAlerts、weatherChanges | 选择数据集;WK3 仅改 AQ、分钟降水、预警,其余透传。空值/`[]` 关闭处理。 | +| `Weather.Replace` | `CN` | 普通天气处理范围;WK3 AQ 独立处理,不受此项限制。 | +| `Weather.Provider` | `ColorfulClouds` | WK2 天气来源;不改变 WK3 当前、逐日、逐小时天气。 | +| `Weather.ReplaceDaily` | `false` | WK2 逐日替换开关。 | +| `Weather.ReplaceHourly` | `false` | WK2 逐小时替换开关。 | +| `WeatherAlerts.Provider` | `QWeatherWeb` | 和风网页补全 Apple 已有预警;`WeatherKit` 保留原数据。 | +| `NextHour.Provider` | `ColorfulClouds` | 单点下一小时雨雪分钟来源,也可选 `QWeather`/`WeatherKit`。 | +| `AirQuality.Current.Index.Provider` | `Calculate` | 今日指数来源:内置算法、`ColorfulCloudsUS`、`ColorfulCloudsCN` 或 `QWeather`。 | +| `AirQuality.Current.Index.Replace` | `HJ6332012` | 要替换的**原始标准**;逗号分隔,空值/`[]` 不替换。 | +| `AirQuality.Current.Index.ForceCNPrimaryPollutants` | `true` | 国标不超过 50 时仍显示主要污染物。 | +| `AirQuality.Current.Pollutants.Provider` | `ColorfulClouds` | 原 Apple 污染物缺失时的补全来源,可选和风。 | +| `AirQuality.Current.Pollutants.Units.Replace` | `[]` | 要转换单位的标准;默认不额外转换。 | +| `AirQuality.Current.Pollutants.Units.Mode` | `Scale` | 标准单位、指定单位或强制单位;`None` 保留来源单位。 | +| `AirQuality.Comparison.ReplaceWhenCurrentChange` | `false` | 指数变化后是否重算已有有效趋势。 | +| `AirQuality.Comparison.Yesterday.IndexProvider` | `ColorfulCloudsUS` | 昨日指数来源;比较逻辑见下文。 | +| `AirQuality.Comparison.Yesterday.PollutantsProvider` | `QWeather` | 昨日内置计算需要的真实污染物来源。 | +| `AirQuality.Calculate.Algorithm` | `EU_EAQI` | 内置算法选择,见下表。 | +| `AirQuality.Calculate.AllowOverRange` | `true` | 允许内置国标/美标超过 500;不绕过有效性与编码范围校验。 | +| `API.ColorfulClouds.Token` | 空 | 自有彩云令牌;空值使用原有公开令牌。 | +| `API.QWeather.Host` | `devapi.qweather.com` | 按和风账户提供的 API 主机配置。 | +| `API.QWeather.Token` | 空 | 使用和风 API 时填写;和风网页预警不需要。 | +| `LogLevel` | `INFO` | `DEBUG`/`ALL` 显示更多细节,`OFF` 静默。 | +| `HTTP_TIMEOUT_MS` | `8000` | 单次脚本网络请求超时,单位毫秒;宿主脚本总超时另计。 | + +### 内置算法 + +| 内部值 | 显示含义 | +| --- | --- | +| `None` | 不转换 | +| `UBA` | 德国 LQI | +| `EU_EAQI` | 欧盟 EAQI | +| `WAQI_InstantCast_US` | 美标 InstantCast | +| `WAQI_InstantCast_CN` | 国标 InstantCast(2012) | +| `WAQI_InstantCast_CN_25_DRAFT` | 国标 InstantCast(2025 草案,非正式标准) | +| `CA_AQHI` | 加拿大 AQHI | +| `HK_AQHI` | 香港 AQHI | +| `AQHI_Multi_CN` | AQHI-Multi(中国) | +| `AQHI_Multi_CN_HK` | AQHI-Multi(中国+香港) | +| `CN_DEATH_AQHI` | 中国健康风险 AQHI | +| `CN_DEATH_HK_AQHI` | 中国+香港健康风险 AQHI | + +只有今日来源选择 `Calculate` 才执行上表算法;Egern 面板显示“iRingo内置算法”。已有 Apple 污染物优先保留,缺失时才取所选来源。 + +### 替换目标和地区 + +替换目标描述输入而非输出。例如原响应为 `EPA_NowCast` 时,只填 `HJ6332012` 不会转成欧盟;按需加入 `EPA_NowCast`、`EU.EAQI`、`UBA`、`CA.AQHI`、`HK.AQHI`、`CN.AQHI`。空目标必须继续保持关闭效果,不会自动补进所有标准。 + +台北/澳门不再被普通天气地区判断拦住,但数据源本身仍有支持范围。Apple 污染物存在时可本地计算;台北原响应缺失且 `country=TW` 时,彩云上游 AQ 白名单不含 TW,应选支持 TW 的和风并配置有效 API。请求失败或缺少必要污染物时保留原数据,不更改国家码。 + +### 昨日比较 + +- 相同来源、标准及算法的两天数据比较类别趋势。 +- 今日显示标准与昨日选择不同时,彩云美标/国标来源可按上游流程比较**彩云自己的今日与昨日**;趋势来源会写入署名和日志。不是把欧盟类别与美标类别硬比较。 +- 内置昨日重算需要实际历史污染物。彩云只给昨日类别时,不能据此重算其他算法;和风历史受地区、账户及接口可用性限制。 +- `false` 保留有效原趋势;来源失败只使比较未知,不应阻断今日指数。 + +## 如何看日志 + +在 `WeatherKit.api.weather.response` 查看 `WK3 AQ patch`、`WK3 missing AQ patch`、`WK3 nextHour patch`、`WK3 alerts summary/details`。`WK3 alerts titles` 提供有限的标题对照,不含 UUID、URL 或坐标载荷。 + +`[buildSettings] Settings: [REDACTED]` 是敏感设置脱敏,不是报错;`✅ response` 只表示执行结束,不代表每项转换都成功。EAQI 标尺使用 Apple 原生无版本资源,脚本不再合成这条色带。旧测试构建标识不会出现在正式脚本。 + +## 功能边界 + +- WK3 AQ、雨雪分钟和原预警补全可用,其他天气产品继续保留原数据。 +- 泛称预警是同地区、同等级、精确时间、唯一候选约束下的推断;缺项或歧义不转详情,事件 UUID 不重生成。 +- 轻雪的未命名文字条件可省略,数值曲线是否正确显示仍取决于具体客户端和天气结构。 +- 一小时二维雷达地图另见 [首页实验进度](../README.md#一小时降水雷达地图开发中),本次普通模块发行不启用实验后端。 diff --git a/modules/iRingo.WeatherKit.WK3.yaml b/modules/iRingo.WeatherKit.WK3.yaml index 7cf36bb6..cd6d168f 100644 --- a/modules/iRingo.WeatherKit.WK3.yaml +++ b/modules/iRingo.WeatherKit.WK3.yaml @@ -1,5 +1,5 @@ name: 'WeatherKit-WK3' -description: '本机 WK3:大陆缺失美标 AQ 补入、分钟雨、昨日对比、原生预警;当前/逐日/逐小时天气保持苹果原始数据,部分城市 UV 防晒建议仍可能英文' +description: '空气质量、原生预警与雨雪分钟增强。' author: WeatherKit-WK3 contributors homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png @@ -86,9 +86,9 @@ rules: - domain: match: tether.edge.apple policy: REJECT-DROP -compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"ColorfulCloudsUS","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"true","AirQuality.Comparison.Yesterday.IndexProvider":"ColorfulCloudsUS","AirQuality.Comparison.Yesterday.PollutantsProvider":"ColorfulCloudsCN","AirQuality.Calculate.Algorithm":"WAQI_InstantCast_US","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} -compat_arguments_desc: "DataSets: [数据集]\n 选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。\nWeather.Replace: [天气] 替换范围\n 逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。\nWeather.Provider: [天气] 数据源\n 使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n WK2 可选;WK3 逐日预报保持苹果数据。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n WK2 可选;WK3 逐小时预报保持苹果数据。\nWeatherAlerts.Provider: [天气预警] 数据源\n QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。\nNextHour.Provider: [未来一小时降水强度] 数据源\n 使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;大陆缺 AQ 时仅 ColorfulCloudsUS 且替换目标非空可补入完整美标,失败保留原响应;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标\n 替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012;大陆缺 AQ 补入也要求此项非空。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物\n 忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标\n 转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式\n 污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换\n 即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法\n 共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标\n 允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌\n 彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。\nAPI.QWeather.Host: [API] 和风天气主机\n 和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。\nAPI.QWeather.Token: [API] 和风天气令牌\n 和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。\nLogLevel: [调试] 日志等级\n 选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 默认8000毫秒;脚本总体超时仍为30秒。" -env_schema: {"DataSets":{"name":"[数据集]","description":"选择需要解析并处理的数据集;未选槽保持透传。逗号分隔或 JSON 数组,空值或 [] 全部透传。WK3 仅处理空气质量、未来一小时降水强度与预警;当前/逐日/逐小时天气及其余数据槽仍透传。 可选值:airQuality、currentWeather、forecastDaily、forecastHourly、forecastNextHour、weatherAlerts。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"逗号分隔国家码或 JSON 数组;空值或 [] 禁用常规地区替换。上游为正则,本机仅按国家码匹配且限中国大陆;香港缺失 AQ 为独立补入逻辑。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"使用选定的数据源替换 WK2 天气;WeatherKit 不替换。WK3 当前/逐日/逐小时天气保持苹果数据,本项不能启用这些 WK3 功能。 可选值:WeatherKit(WeatherKit(不替换))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"WK2 可选;WK3 逐日预报保持苹果数据。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"WK2 可选;WK3 逐小时预报保持苹果数据。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"QWeatherWeb 使用和风天气网页补全预警,苹果原生展示;WeatherKit 不修改。上游和风天气 API / 彩云天气 API 预警尚未移植,不提供这些选项。 可选值:WeatherKit(WeatherKit(不修改))、QWeatherWeb(和风天气网页)。","default_value":"QWeatherWeb","options":["WeatherKit","QWeatherWeb"]},"NextHour.Provider":{"name":"[未来一小时降水强度] 数据源","description":"使用选定的数据源填充未来一小时降水强度;WeatherKit 不添加,ColorfulClouds 为彩云天气,QWeather 为和风天气。 可选值:WeatherKit(WeatherKit(不添加))、ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"使用选定的数据源填补和替换空气质量指数,默认彩云天气美标。WK3 支持国标→美标及同标准指数替换;大陆缺 AQ 时仅 ColorfulCloudsUS 且替换目标非空可补入完整美标,失败保留原响应;其余跨标准保留苹果数据。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换目标","description":"替换指定原始标准的空气质量指数,非输出标准。逗号分隔或 JSON 数组;空值或 [] 禁用指数替换,不会自动添加国内目标。国内转美标填 HJ6332012;大陆缺 AQ 补入也要求此项非空。WK3 仅支持同标准或国标→美标。 可选值:HJ6332012、IE.AQIH、AT.AQI、BE.BelAQI、UBA、FR.ATMO、KR.CAI、CA.AQHI、CZ.AQI、NL.LKI、EPA_NowCast、ICARS、EU.EAQI、CH.KBI、ES.MITECO、SG.NEA、NAQI、DAQI。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制主要污染物","description":"忽略国标(HJ 633—2012)的 AQI > 50 规定,始终将 IAQI 最大的空气污染物作为主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"使用选定的数据源填补污染物数据,供 WK2 或内置算法计算;WK3 美标完整替换时污染物跟随指数来源。 可选值:ColorfulClouds(彩云天气)、QWeather(和风天气)。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[今日污染物 - 单位转换] 替换目标","description":"转换指定标准的污染物单位,便于与标准比对;逗号分隔或 JSON 数组,空值或 [] 不转换。转换可能损失小数精度。本项用于 WK2;WK3 完整美标 AQ 保持指数来源的单位。 可选值:EPA_NowCast、EU.EAQI、HJ6332012、UBA。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[今日污染物 - 单位转换] 模式","description":"污染物单位的转换目标;仅对单位转换替换目标生效。Scale 跟随标准;ugm3 / EU_ppb / US_ppb 在标准无要求时转换,Force_ 前缀强制转换。本项用于 WK2。 可选值:Scale(与空气质量标准的要求相同)、ugm3(除非标准要求,都转为µg/m³)、EU_ppb(除非标准要求,都转为欧盟ppb)、US_ppb(除非标准要求,都转为美标ppb)、Force_ugm3(µg/m³)、Force_EU_ppb(欧盟ppb)、Force_US_ppb(美标ppb)。","default_value":"Scale","options":["Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[空气质量 - 对比昨日] 变化时替换","description":"即使已有对比昨日数据,当今日指数被替换时也替换昨日对比。false 保留已有对比,WK2 及 WK3 国标→美标时对比未知仍尝试补齐;WK3 仅按同源同标准比较,失败为未知;同标准原位更新仍保留原对比。","default_value":"true","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"用于与今日指数比较,默认彩云天气美标。WK3 仅按同源同标准比较,不兼容组合返回未知;Calculate 的昨日计算仅 WK2 支持。WeatherKit 为保留 Apple/不额外查询的兼容选项;强制跨标准替换时可能返回未知。 可选值:Calculate(iRingo内置算法)、ColorfulCloudsUS(彩云天气(美标,18年9月版))、ColorfulCloudsCN(彩云天气(国标,12年2月版))、QWeather(和风天气(国标,12年2月版))、WeatherKit(保留 Apple/不额外查询(本机兼容))。","default_value":"ColorfulCloudsUS","options":["Calculate","ColorfulCloudsUS","ColorfulCloudsCN","QWeather","WeatherKit"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"为 iRingo 内置算法提供昨日数据。上游仅 QWeather;保留本机 ColorfulCloudsCN 兼容选项(彩云昨日国标/美标类别对比,不提供昨日污染物浓度)。仅 WK2 Calculate 路径消费,本项不能启用 WK3 昨日算法。 可选值:ColorfulCloudsCN(彩云天气(本机兼容))、QWeather(和风天气)。","default_value":"ColorfulCloudsCN","options":["ColorfulCloudsCN","QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[iRingo内置算法] 算法","description":"共 12 个选项(含 None 不转换,即 11 种计算算法)。仅指数数据源为 Calculate 时使用污染物本地计算;InstantCast 源自 WAQI,美标使用 WAQI 臭氧标准。WK3 未支持的跨标准输出保持苹果数据。 可选值:None(不转换)、UBA(德国LQI(FB001846))、EU_EAQI(欧盟EAQI(ETC HE Report 2024/17))、WAQI_InstantCast_US(美标InstantCast(EPA-454/B-24-002))、WAQI_InstantCast_CN(国标InstantCast(HJ 633—2012))、WAQI_InstantCast_CN_25_DRAFT(国标InstantCast(HJ 633 2025年草案))、CA_AQHI(加拿大AQHI(10.17269/s41997-019-00237-w))、HK_AQHI(香港AQHI)、AQHI_Multi_CN(AQHI-Multi(中国))、AQHI_Multi_CN_HK(AQHI-Multi(中国+香港))、CN_DEATH_AQHI(中国(致死风险)AQHI)、CN_DEATH_HK_AQHI(中国(致死风险)+香港AQHI)。","default_value":"WAQI_InstantCast_US","options":["None","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"]},"AirQuality.Calculate.AllowOverRange":{"name":"[iRingo内置算法] 允许指数超标","description":"允许内置美标和国标指数超过 500,超过时指示颜色的小圆点可能消失;仅内置算法消费,不改变彩云/和风指数。WK3 完整美标编码仍要求 0–500,本项不会绕过校验。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云天气令牌","description":"彩云天气 API 令牌;留空沿用当前公共令牌。自有令牌只在设备参数填写,不写入公开模块。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风天气主机","description":"和风天气 API 使用的主机名,使用 API 数据源时填写控制台主机名;空值沿用 devapi.qweather.com。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风天气令牌","description":"和风天气 API 令牌,使用和风 API 数据源时需要;网页预警补全不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"选择脚本日志输出等级,默认 INFO;OFF 关闭,ERROR 错误,WARN 警告及错误,INFO 再含信息,DEBUG 再含调试,ALL 全部。 可选值:OFF(关闭)、ERROR(❌ 错误)、WARN(⚠️ 警告)、INFO(ℹ️ 信息)、DEBUG(🅱️ 调试)、ALL(全部)。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"默认8000毫秒;脚本总体超时仍为30秒。","default_value":"8000"}} +compat_arguments: {"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"iRingo内置算法","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"false","AirQuality.Comparison.Yesterday.IndexProvider":"彩云天气(美标)","AirQuality.Comparison.Yesterday.PollutantsProvider":"QWeather","AirQuality.Calculate.Algorithm":"欧盟EAQI","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"} +compat_arguments_desc: "DataSets: [数据集]\n 选择要处理的数据;留空或 [] 全部保留原数据。\nWeather.Replace: [天气] 替换范围\n 普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。\nWeather.Provider: [天气] 数据源\n 选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。\nWeather.ReplaceDaily: [天气] 替换逐日预报\n 是否替换逐日预报;仅 WK2 生效。\nWeather.ReplaceHourly: [天气] 替换逐小时预报\n 是否替换逐小时预报;仅 WK2 生效。\nWeatherAlerts.Provider: [天气预警] 数据源\n 和风网页补全原生预警;选择 WeatherKit 保留原数据。\nNextHour.Provider: [未来一小时降水强度] 数据源\n 选择未来一小时雨雪分钟数据来源。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源\n 选择指数来源:iRingo内置算法、彩云或和风。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标\n 填写要替换的原始标准,逗号分隔;留空或 [] 不替换。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物\n 国标指数不超过 50 时,仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源\n 已有 Apple 污染物优先保留;缺失时使用此来源。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标\n 需要转换单位的空气质量标准;留空或 [] 不转换。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式\n Scale 跟随标准,Force 强制转换,None 保留原单位。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换\n 今日指数变化时,是否重新计算昨日对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源\n 选择昨日指数来源;仅同源、同标准的数据可比较。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源\n 内置算法计算昨日指数所需的污染物来源。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法\n 使用污染物本地计算指数;None 不转换。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标\n 允许内置美标、国标指数超过 500。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌\n 自有彩云令牌;留空使用内置公开令牌。\nAPI.QWeather.Host: [API] 和风天气主机\n 和风 API 主机名;使用和风来源时填写。\nAPI.QWeather.Token: [API] 和风天气令牌\n 和风 API 令牌;网页预警不需要。\nLogLevel: [调试] 日志等级\n 默认 INFO;DEBUG 显示细节,OFF 关闭日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒)\n 单次网络请求超时,默认 8000 毫秒。" +env_schema: {"DataSets":{"name":"[数据集]","description":"选择要处理的数据;留空或 [] 全部保留原数据。","default_value":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges"},"Weather.Replace":{"name":"[天气] 替换范围","description":"普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。","default_value":"CN"},"Weather.Provider":{"name":"[天气] 数据源","description":"选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"Weather.ReplaceDaily":{"name":"[天气] 替换逐日预报","description":"是否替换逐日预报;仅 WK2 生效。","default_value":"false","options":["true","false"]},"Weather.ReplaceHourly":{"name":"[天气] 替换逐小时预报","description":"是否替换逐小时预报;仅 WK2 生效。","default_value":"false","options":["true","false"]},"WeatherAlerts.Provider":{"name":"[天气预警] 数据源","description":"和风网页补全原生预警;选择 WeatherKit 保留原数据。","default_value":"QWeatherWeb","options":["WeatherKit","QWeatherWeb"]},"NextHour.Provider":{"name":"[未来一小时降水强度] 数据源","description":"选择未来一小时雨雪分钟数据来源。","default_value":"ColorfulClouds","options":["WeatherKit","ColorfulClouds","QWeather"]},"AirQuality.Current.Index.Provider":{"name":"[今日空气指数] 数据源","description":"选择指数来源:iRingo内置算法、彩云或和风。","default_value":"iRingo内置算法","options":["iRingo内置算法","彩云天气(美标)","彩云天气(国标)","和风天气"]},"AirQuality.Current.Index.Replace":{"name":"[今日空气指数] 替换目标","description":"填写要替换的原始标准,逗号分隔;留空或 [] 不替换。","default_value":"HJ6332012"},"AirQuality.Current.Index.ForceCNPrimaryPollutants":{"name":"[今日空气指数] 强制主要污染物","description":"国标指数不超过 50 时,仍显示主要污染物。","default_value":"true","options":["true","false"]},"AirQuality.Current.Pollutants.Provider":{"name":"[今日污染物] 数据源","description":"已有 Apple 污染物优先保留;缺失时使用此来源。","default_value":"ColorfulClouds","options":["ColorfulClouds","QWeather"]},"AirQuality.Current.Pollutants.Units.Replace":{"name":"[今日污染物 - 单位转换] 替换目标","description":"需要转换单位的空气质量标准;留空或 [] 不转换。","default_value":"[]"},"AirQuality.Current.Pollutants.Units.Mode":{"name":"[今日污染物 - 单位转换] 模式","description":"Scale 跟随标准,Force 强制转换,None 保留原单位。","default_value":"Scale","options":["None","Scale","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb"]},"AirQuality.Comparison.ReplaceWhenCurrentChange":{"name":"[空气质量 - 对比昨日] 变化时替换","description":"今日指数变化时,是否重新计算昨日对比。","default_value":"false","options":["true","false"]},"AirQuality.Comparison.Yesterday.IndexProvider":{"name":"[昨日空气指数] 数据源","description":"选择昨日指数来源;仅同源、同标准的数据可比较。","default_value":"彩云天气(美标)","options":["iRingo内置算法","彩云天气(美标)","彩云天气(国标)","和风天气"]},"AirQuality.Comparison.Yesterday.PollutantsProvider":{"name":"[昨日污染物] 数据源","description":"内置算法计算昨日指数所需的污染物来源。","default_value":"QWeather","options":["QWeather"]},"AirQuality.Calculate.Algorithm":{"name":"[iRingo内置算法] 算法","description":"使用污染物本地计算指数;None 不转换。","default_value":"欧盟EAQI","options":["不转换","德国LQI","欧盟EAQI","美标InstantCast","国标InstantCast(2012)","国标InstantCast(2025草案)","加拿大AQHI","香港AQHI","AQHI-Multi(中国)","AQHI-Multi(中国+香港)","中国健康风险AQHI","中国+香港健康风险AQHI"]},"AirQuality.Calculate.AllowOverRange":{"name":"[iRingo内置算法] 允许指数超标","description":"允许内置美标、国标指数超过 500。","default_value":"true","options":["true","false"]},"API.ColorfulClouds.Token":{"name":"[API] 彩云天气令牌","description":"自有彩云令牌;留空使用内置公开令牌。","default_value":""},"API.QWeather.Host":{"name":"[API] 和风天气主机","description":"和风 API 主机名;使用和风来源时填写。","default_value":"devapi.qweather.com"},"API.QWeather.Token":{"name":"[API] 和风天气令牌","description":"和风 API 令牌;网页预警不需要。","default_value":""},"LogLevel":{"name":"[调试] 日志等级","description":"默认 INFO;DEBUG 显示细节,OFF 关闭日志。","default_value":"INFO","options":["OFF","ERROR","WARN","INFO","DEBUG","ALL"]},"HTTP_TIMEOUT_MS":{"name":"[网络] 单次请求超时(毫秒)","description":"单次网络请求超时,默认 8000 毫秒。","default_value":"8000"}} scriptings: - http_response: name: WeatherKit.api.availability.response @@ -117,7 +117,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/availability/' - script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-response.bundle.js + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-response.bundle.js timeout: 30 body_required: true binary_body: false @@ -150,7 +150,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/airQualityScale/' - script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-request.bundle.js + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-request.bundle.js timeout: 30 body_required: false binary_body: false @@ -182,7 +182,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' - script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-request.bundle.js + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-request.bundle.js timeout: 30 body_required: false binary_body: false @@ -214,7 +214,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[12]/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)' - script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-request.bundle.js + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-request.bundle.js timeout: 30 body_required: false binary_body: false @@ -246,7 +246,7 @@ scriptings: LogLevel: '{{{LogLevel}}}' HTTP_TIMEOUT_MS: '{{{HTTP_TIMEOUT_MS}}}' match: '^https://weatherkit\.apple\.com/api/v[23]/weather/' - script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.6/wk3-egern-response.bundle.js + script_url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-egern-response.bundle.js timeout: 30 body_required: true binary_body: true From 24c3f9311f051a9df9465238ee85b4a5f2c4f9c2 Mon Sep 17 00:00:00 2001 From: Patrick Yan <patrickyanxxxxx@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:18 +0800 Subject: [PATCH 42/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=88=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=89=EF=BC=9A=E5=90=8C=E6=AD=A5=E5=85=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=B8=AD=E6=96=87=E7=AE=97=E6=B3=95=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- README.md | 6 +- modules/README.md | 8 +-- modules/iRingo.WeatherKit.WK3.lpx | 46 ++++++++++++++ modules/iRingo.WeatherKit.WK3.sgmodule | 23 +++++++ modules/iRingo.WeatherKit.WK3.snippet | 26 ++++++++ modules/iRingo.WeatherKit.WK3.stoverride | 77 ++++++++++++++++++++++++ scripts/build-wk3-modules.mjs | 7 ++- src/platform/wk3-adapter.mjs | 2 +- src/wk3/option-values.mjs | 4 +- template/wk3.quantumultx.handlebars | 2 +- tests/wk3-module-parameters.test.mjs | 35 +++++++++-- tests/wk3-modules.test.mjs | 11 ++-- 13 files changed, 225 insertions(+), 24 deletions(-) create mode 100644 modules/iRingo.WeatherKit.WK3.lpx create mode 100644 modules/iRingo.WeatherKit.WK3.sgmodule create mode 100644 modules/iRingo.WeatherKit.WK3.snippet create mode 100644 modules/iRingo.WeatherKit.WK3.stoverride diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f7fd1d..c42bf5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - **指数与标尺配套:**补齐加拿大 AQHI 回退标尺,修正无版本 AQHI 身份、UBA 显示范围及相关单位/算法映射;保留既有加拿大计算和超量程修正。昨日趋势注明来源,彩云按上游流程比较其自身今日/昨日数据,不跨标准硬比较。 - **雨雪分钟:**参考上游 dev 扩展雪、强雨及间歇降水枚举;未命名轻雪文字可省略并保留分钟数值和雪摘要。修复和风分钟坐标、`fxTime` 起点和 AQ 通用元数据类型;分钟有效期同步为 10 分钟,避免分段处理修改原始摘要。 - **原生预警:**统一摘要/详情标题规范化,支持上游红橙黄蓝等级,修复高速公路等重复“预警”后缀。Apple 泛称“恶劣天气”在同地区、同等级、同发布时间且唯一候选时补全具体事件;保留原 UUID,全组匹配后才切换共享详情链接。 -- **模块参数与诊断:**保留 23 项设置和全部内置算法,今日来源默认内置算法、默认欧盟 EAQI;Egern 选择器显示中文,旧内部值继续兼容。模块和参数简介精简,摘要补全/详情转换分开记录。 +- **模块参数与诊断:**保留 23 项设置和全部内置算法,今日来源默认内置算法、默认欧盟 EAQI;Egern/Surge/Loon/Stash 的指数来源和算法选项统一中文,旧内部值继续兼容;Quantumult X 无参数面板时同步模块默认值,二进制透传限制不变。模块和参数简介精简,摘要补全/详情转换分开记录。 - **文档:**首页改为相对上游最新正式版的功能说明、默认参数和边界;补充模块 README、实验雷达实现路径与合成预览图。二维雷达仍在独立实验分支,不作为本次发行能力。 ## 安装与升级 diff --git a/README.md b/README.md index 10c6aab0..642c6927 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ | 雨雪分钟预报 | WK3 分钟数据编码与缺失补入,参考上游 dev 的雨雪枚举;修复和风坐标、预报起点及有效期处理。轻雪未命名文字条件可省略,分钟强度和雪摘要保留。 | | 预警详情 | 补全 Apple 已有事件,保留原 UUID,匹配后进入 Apple 原生详情页面;支持上游定义的红橙黄蓝等级。泛称“恶劣天气”在同地区页面、同等级、同发布时间且候选唯一时推断具体事件。 | | 客户端兼容 | Egern 原生 `ctx`,Surge/Loon 原生字节回传,其他平台独立适配;错误或未知结构保留原数据。 | -| 参数与日志 | Egern 使用中文选项、简短说明,兼容旧内部值;分别记录预警摘要、详情转换和回退原因,敏感配置不明文输出。 | +| 参数与日志 | Egern/Surge/Loon/Stash 共用中文指数来源与算法选项、简短说明,兼容旧内部值;分别记录预警摘要、详情转换和回退原因,敏感配置不明文输出。 | 上游 dev 与本 Fork 的实现并非完全相同。已有 AQ 的完整替换、事件 UUID 关联、宿主二进制返回等应以本仓库实现及平台说明为准。 @@ -37,7 +37,7 @@ ## 默认参数与使用要点 -以下为可配置 WK3 模块的默认值,**更新不一定覆盖设备上已保存的选项**。Egern 显示中文,括号内为内部值。 +以下为可配置 WK3 模块的默认值,**更新不一定覆盖设备上已保存的选项**。Egern/Surge/Loon/Stash 的指数来源与算法显示中文,括号内为内部值。 | 设置 | 默认值 | 作用 | | --- | --- | --- | @@ -60,7 +60,7 @@ **地区与来源:**AQ 不要求改国家码。台北/澳门有 Apple 污染物时可直接计算;缺数据时必须选真正支持该地区的来源。上游彩云 AQ 列表为 CN/HK/MO,TW 缺数据应选和风并配置有效 API;不承诺默认彩云对所有台北响应补入。来源失败不伪造有效数据。当前预警和分钟增强的普通地区范围仍有大陆限制。 -**昨日比较:**有历史浓度才可重算算法。彩云历史接口给出的类别不能反推 UBA/EAQI/AQHI 的昨日浓度;无数据则趋势未知,今日指数仍可用。不同标准绝不直接比较数值。Quantumult X 无参数注入时沿用运行时内置配置,但其 WK3 二进制响应始终不转换。 +**昨日比较:**有历史浓度才可重算算法。彩云历史接口给出的类别不能反推 UBA/EAQI/AQHI 的昨日浓度;无数据则趋势未知,今日指数仍可用。不同标准绝不直接比较数值。Quantumult X 无参数注入时使用同一套模块默认值,但其 WK3 二进制响应始终不转换。 完整 23 项参数见 [模块 README](modules/README.md)。 diff --git a/modules/README.md b/modules/README.md index 8570167c..56449f85 100644 --- a/modules/README.md +++ b/modules/README.md @@ -9,8 +9,8 @@ | 客户端 | 正式模块 | 参数入口 | | --- | --- | --- | | Egern | [iRingo.WeatherKit.WK3.yaml](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.yaml) | 模块设置面板;空气指数来源及算法显示中文,旧内部值仍兼容。 | -| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) | 模块参数。 | -| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) | 插件参数面板。 | +| Surge | [iRingo.WeatherKit.WK3.sgmodule](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.sgmodule) | 模块参数;指数来源与算法和 Egern 共用中文值。 | +| Loon | [iRingo.WeatherKit.WK3.lpx](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.lpx) | 插件参数面板;指数来源与算法和 Egern 共用中文选项。 | | Stash | [iRingo.WeatherKit.WK3.stoverride](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.stoverride) | 编辑首条脚本 `argument` 中的 JSON,其余四条共享。 | | Quantumult X | [iRingo.WeatherKit.WK3.snippet](https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/latest/download/iRingo.WeatherKit.WK3.snippet) | 无交互参数面板;请求/文本路径可用,WK3 二进制天气始终透传。 | @@ -20,7 +20,7 @@ ## 23 项参数 -下表列出模块生成默认值,内部键名保留以便 Surge/Stash 配置及问题定位。Egern 的中文显示值会在脚本入口还原为对应内部值。 +下表列出模块生成默认值,内部键名保留以便 Surge/Stash 配置及问题定位。Egern/Surge/Loon/Stash 共用中文指数来源与算法选项,脚本入口还原为对应内部值,旧内部值仍可使用。Quantumult X 无参数面板,脚本使用同一套默认值。 | 参数 | 默认值 | 用途 | | --- | --- | --- | @@ -65,7 +65,7 @@ | `CN_DEATH_AQHI` | 中国健康风险 AQHI | | `CN_DEATH_HK_AQHI` | 中国+香港健康风险 AQHI | -只有今日来源选择 `Calculate` 才执行上表算法;Egern 面板显示“iRingo内置算法”。已有 Apple 污染物优先保留,缺失时才取所选来源。 +只有今日来源选择 `Calculate` 才执行上表算法;四平台配置均显示“iRingo内置算法”。已有 Apple 污染物优先保留,缺失时才取所选来源。 ### 替换目标和地区 diff --git a/modules/iRingo.WeatherKit.WK3.lpx b/modules/iRingo.WeatherKit.WK3.lpx new file mode 100644 index 00000000..cd2c05c0 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.lpx @@ -0,0 +1,46 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = 3.3.8 + +[Argument] +DataSets = input,"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges",tag=[数据集],desc=选择要处理的数据;留空或 [] 全部保留原数据。 +Weather.Replace = input,"CN",tag=[天气] 替换范围,desc=普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。 +Weather.Provider = select,"ColorfulClouds","WeatherKit","QWeather",tag=[天气] 数据源,desc=选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。 +Weather.ReplaceDaily = switch,false,tag=[天气] 替换逐日预报,desc=是否替换逐日预报;仅 WK2 生效。 +Weather.ReplaceHourly = switch,false,tag=[天气] 替换逐小时预报,desc=是否替换逐小时预报;仅 WK2 生效。 +WeatherAlerts.Provider = select,"QWeatherWeb","WeatherKit",tag=[天气预警] 数据源,desc=和风网页补全原生预警;选择 WeatherKit 保留原数据。 +NextHour.Provider = select,"ColorfulClouds","WeatherKit","QWeather",tag=[未来一小时降水强度] 数据源,desc=选择未来一小时雨雪分钟数据来源。 +AirQuality.Current.Index.Provider = select,"iRingo内置算法","彩云天气(美标)","彩云天气(国标)","和风天气",tag=[今日空气指数] 数据源,desc=选择指数来源:iRingo内置算法、彩云或和风。 +AirQuality.Current.Index.Replace = input,"HJ6332012",tag=[今日空气指数] 替换目标,desc=填写要替换的原始标准,逗号分隔;留空或 [] 不替换。 +AirQuality.Current.Index.ForceCNPrimaryPollutants = switch,true,tag=[今日空气指数] 强制主要污染物,desc=国标指数不超过 50 时,仍显示主要污染物。 +AirQuality.Current.Pollutants.Provider = select,"ColorfulClouds","QWeather",tag=[今日污染物] 数据源,desc=已有 Apple 污染物优先保留;缺失时使用此来源。 +AirQuality.Current.Pollutants.Units.Replace = input,"[]",tag=[今日污染物 - 单位转换] 替换目标,desc=需要转换单位的空气质量标准;留空或 [] 不转换。 +AirQuality.Current.Pollutants.Units.Mode = select,"Scale","None","ugm3","EU_ppb","US_ppb","Force_ugm3","Force_EU_ppb","Force_US_ppb",tag=[今日污染物 - 单位转换] 模式,desc=Scale 跟随标准,Force 强制转换,None 保留原单位。 +AirQuality.Comparison.ReplaceWhenCurrentChange = switch,false,tag=[空气质量 - 对比昨日] 变化时替换,desc=今日指数变化时,是否重新计算昨日对比。 +AirQuality.Comparison.Yesterday.IndexProvider = select,"彩云天气(美标)","iRingo内置算法","彩云天气(国标)","和风天气",tag=[昨日空气指数] 数据源,desc=选择昨日指数来源;仅同源、同标准的数据可比较。 +AirQuality.Comparison.Yesterday.PollutantsProvider = select,"QWeather",tag=[昨日污染物] 数据源,desc=内置算法计算昨日指数所需的污染物来源。 +AirQuality.Calculate.Algorithm = select,"欧盟EAQI","不转换","德国LQI","美标InstantCast","国标InstantCast(2012)","国标InstantCast(2025草案)","加拿大AQHI","香港AQHI","AQHI-Multi(中国)","AQHI-Multi(中国+香港)","中国健康风险AQHI","中国+香港健康风险AQHI",tag=[iRingo内置算法] 算法,desc=使用污染物本地计算指数;None 不转换。 +AirQuality.Calculate.AllowOverRange = switch,true,tag=[iRingo内置算法] 允许指数超标,desc=允许内置美标、国标指数超过 500。 +API.ColorfulClouds.Token = input,"",tag=[API] 彩云天气令牌,desc=自有彩云令牌;留空使用内置公开令牌。 +API.QWeather.Host = input,"devapi.qweather.com",tag=[API] 和风天气主机,desc=和风 API 主机名;使用和风来源时填写。 +API.QWeather.Token = input,"",tag=[API] 和风天气令牌,desc=和风 API 令牌;网页预警不需要。 +LogLevel = select,"INFO","OFF","ERROR","WARN","DEBUG","ALL",tag=[调试] 日志等级,desc=默认 INFO;DEBUG 显示细节,OFF 关闭日志。 +HTTP_TIMEOUT_MS = input,"8000",tag=[网络] 单次请求超时(毫秒),desc=单次网络请求超时,默认 8000 毫秒。 + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +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 + +[Script] +http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/ requires-body=1, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-response.bundle.js, tag=WeatherKit.api.availability.response, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/ script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-request.bundle.js, tag=WeatherKit.api.airQualityScale.request, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$) script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-request.bundle.js, tag=WeatherKit.api.weatherAlerts.html.request, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$) script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-request.bundle.js, tag=WeatherKit.api.weatherAlerts.api.request, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] +http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-loon-response.bundle.js, tag=WeatherKit.api.weather.response, argument=[{DataSets},{Weather.Replace},{Weather.Provider},{Weather.ReplaceDaily},{Weather.ReplaceHourly},{WeatherAlerts.Provider},{NextHour.Provider},{AirQuality.Current.Index.Provider},{AirQuality.Current.Index.Replace},{AirQuality.Current.Index.ForceCNPrimaryPollutants},{AirQuality.Current.Pollutants.Provider},{AirQuality.Current.Pollutants.Units.Replace},{AirQuality.Current.Pollutants.Units.Mode},{AirQuality.Comparison.ReplaceWhenCurrentChange},{AirQuality.Comparison.Yesterday.IndexProvider},{AirQuality.Comparison.Yesterday.PollutantsProvider},{AirQuality.Calculate.Algorithm},{AirQuality.Calculate.AllowOverRange},{API.ColorfulClouds.Token},{API.QWeather.Host},{API.QWeather.Token},{LogLevel},{HTTP_TIMEOUT_MS}] + +[MITM] +hostname = weatherkit.apple.com diff --git a/modules/iRingo.WeatherKit.WK3.sgmodule b/modules/iRingo.WeatherKit.WK3.sgmodule new file mode 100644 index 00000000..87144990 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.sgmodule @@ -0,0 +1,23 @@ +#!name = WeatherKit-Local-WK3 +#!desc = 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = 3.3.8 +#!arguments = DataSets:"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges",Weather.Replace:"CN",Weather.Provider:"ColorfulClouds",Weather.ReplaceDaily:"false",Weather.ReplaceHourly:"false",WeatherAlerts.Provider:"QWeatherWeb",NextHour.Provider:"ColorfulClouds",AirQuality.Current.Index.Provider:"iRingo内置算法",AirQuality.Current.Index.Replace:"HJ6332012",AirQuality.Current.Index.ForceCNPrimaryPollutants:"true",AirQuality.Current.Pollutants.Provider:"ColorfulClouds",AirQuality.Current.Pollutants.Units.Replace:"[]",AirQuality.Current.Pollutants.Units.Mode:"Scale",AirQuality.Comparison.ReplaceWhenCurrentChange:"false",AirQuality.Comparison.Yesterday.IndexProvider:"彩云天气(美标)",AirQuality.Comparison.Yesterday.PollutantsProvider:"QWeather",AirQuality.Calculate.Algorithm:"欧盟EAQI",AirQuality.Calculate.AllowOverRange:"true",API.ColorfulClouds.Token:"",API.QWeather.Host:"devapi.qweather.com",API.QWeather.Token:"",LogLevel:"INFO",HTTP_TIMEOUT_MS:"8000" +#!arguments-desc = DataSets: [数据集];选择要处理的数据;留空或 [] 全部保留原数据。\nWeather.Replace: [天气] 替换范围;普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。\nWeather.Provider: [天气] 数据源;选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。\nWeather.ReplaceDaily: [天气] 替换逐日预报;是否替换逐日预报;仅 WK2 生效。\nWeather.ReplaceHourly: [天气] 替换逐小时预报;是否替换逐小时预报;仅 WK2 生效。\nWeatherAlerts.Provider: [天气预警] 数据源;和风网页补全原生预警;选择 WeatherKit 保留原数据。\nNextHour.Provider: [未来一小时降水强度] 数据源;选择未来一小时雨雪分钟数据来源。\nAirQuality.Current.Index.Provider: [今日空气指数] 数据源;选择指数来源:iRingo内置算法、彩云或和风。\nAirQuality.Current.Index.Replace: [今日空气指数] 替换目标;填写要替换的原始标准,逗号分隔;留空或 [] 不替换。\nAirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物;国标指数不超过 50 时,仍显示主要污染物。\nAirQuality.Current.Pollutants.Provider: [今日污染物] 数据源;已有 Apple 污染物优先保留;缺失时使用此来源。\nAirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标;需要转换单位的空气质量标准;留空或 [] 不转换。\nAirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式;Scale 跟随标准,Force 强制转换,None 保留原单位。\nAirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换;今日指数变化时,是否重新计算昨日对比。\nAirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源;选择昨日指数来源;仅同源、同标准的数据可比较。\nAirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源;内置算法计算昨日指数所需的污染物来源。\nAirQuality.Calculate.Algorithm: [iRingo内置算法] 算法;使用污染物本地计算指数;None 不转换。\nAirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标;允许内置美标、国标指数超过 500。\nAPI.ColorfulClouds.Token: [API] 彩云天气令牌;自有彩云令牌;留空使用内置公开令牌。\nAPI.QWeather.Host: [API] 和风天气主机;和风 API 主机名;使用和风来源时填写。\nAPI.QWeather.Token: [API] 和风天气令牌;和风 API 令牌;网页预警不需要。\nLogLevel: [调试] 日志等级;默认 INFO;DEBUG 显示细节,OFF 关闭日志。\nHTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒);单次网络请求超时,默认 8000 毫秒。 + +[Rule] +DOMAIN,weather-analytics-events.apple.com,REJECT-DROP +DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP,extended-matching +DOMAIN,tether.edge.apple,REJECT-DROP,extended-matching +AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP + +[Script] +WeatherKit.api.availability.response = type=http-response, pattern=^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/, requires-body=1, engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-response.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.airQualityScale.request = type=http-request, pattern=^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/, engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-request.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.weatherAlerts.html.request = type=http-request, pattern="^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)", engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-request.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.weatherAlerts.api.request = type=http-request, pattern="^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)", engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-request.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" +WeatherKit.api.weather.response = type=http-response, pattern=^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/, requires-body=1, binary-body-mode=1, engine=webview, script-path=https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-surge-response.bundle.js, argument=DataSets="{{{DataSets}}}"&Weather.Replace="{{{Weather.Replace}}}"&Weather.Provider="{{{Weather.Provider}}}"&Weather.ReplaceDaily="{{{Weather.ReplaceDaily}}}"&Weather.ReplaceHourly="{{{Weather.ReplaceHourly}}}"&WeatherAlerts.Provider="{{{WeatherAlerts.Provider}}}"&NextHour.Provider="{{{NextHour.Provider}}}"&AirQuality.Current.Index.Provider="{{{AirQuality.Current.Index.Provider}}}"&AirQuality.Current.Index.Replace="{{{AirQuality.Current.Index.Replace}}}"&AirQuality.Current.Index.ForceCNPrimaryPollutants="{{{AirQuality.Current.Index.ForceCNPrimaryPollutants}}}"&AirQuality.Current.Pollutants.Provider="{{{AirQuality.Current.Pollutants.Provider}}}"&AirQuality.Current.Pollutants.Units.Replace="{{{AirQuality.Current.Pollutants.Units.Replace}}}"&AirQuality.Current.Pollutants.Units.Mode="{{{AirQuality.Current.Pollutants.Units.Mode}}}"&AirQuality.Comparison.ReplaceWhenCurrentChange="{{{AirQuality.Comparison.ReplaceWhenCurrentChange}}}"&AirQuality.Comparison.Yesterday.IndexProvider="{{{AirQuality.Comparison.Yesterday.IndexProvider}}}"&AirQuality.Comparison.Yesterday.PollutantsProvider="{{{AirQuality.Comparison.Yesterday.PollutantsProvider}}}"&AirQuality.Calculate.Algorithm="{{{AirQuality.Calculate.Algorithm}}}"&AirQuality.Calculate.AllowOverRange="{{{AirQuality.Calculate.AllowOverRange}}}"&API.ColorfulClouds.Token="{{{API.ColorfulClouds.Token}}}"&API.QWeather.Host="{{{API.QWeather.Host}}}"&API.QWeather.Token="{{{API.QWeather.Token}}}"&LogLevel="{{{LogLevel}}}"&HTTP_TIMEOUT_MS="{{{HTTP_TIMEOUT_MS}}}" + +[MITM] +hostname = %APPEND% weatherkit.apple.com diff --git a/modules/iRingo.WeatherKit.WK3.snippet b/modules/iRingo.WeatherKit.WK3.snippet new file mode 100644 index 00000000..1de2010a --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.snippet @@ -0,0 +1,26 @@ +#!name = WeatherKit-Local-WK3 +#!desc = WK3 请求与文本处理;二进制天气透传。安装与参数说明见仓库 modules/README.md。 +#!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 +#!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +#!version = 3.3.8 +# QuantumultX 本地脚本不传模块参数;使用与其他 WK3 模块相同的默认值,二进制响应仍透传。 + +#[filter_local] +host, weather-analytics-events.apple.com, reject +host-suffix, tthr.apple.com, reject +host, tether.edge.apple, reject + +#[rewrite_local] +# WeatherKit.api.availability.response +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/ url script-response-body https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-response.bundle.js +# WeatherKit.api.airQualityScale.request +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/ url script-request-header https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-request.bundle.js +# WeatherKit.api.weatherAlerts.html.request +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$) url script-echo-response https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-request.bundle.js +# WeatherKit.api.weatherAlerts.api.request +^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$) url script-echo-response https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-request.bundle.js +# WeatherKit.api.weather.response (binary response requires device validation) +^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/ url script-response-body https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-quantumultx-response.bundle.js + +#[mitm] +hostname = weatherkit.apple.com diff --git a/modules/iRingo.WeatherKit.WK3.stoverride b/modules/iRingo.WeatherKit.WK3.stoverride new file mode 100644 index 00000000..80d7acd6 --- /dev/null +++ b/modules/iRingo.WeatherKit.WK3.stoverride @@ -0,0 +1,77 @@ +name: WeatherKit-Local-WK3 +desc: 空气质量、原生预警与雨雪分钟增强。安装与参数说明见仓库 modules/README.md。 +homepage: https://github.com/patrickyanxxxxx/WeatherKit-WK3 +icon: https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png +version: "3.3.8" +rules: + - DOMAIN,weather-analytics-events.apple.com,REJECT-DROP + - 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 +# Stash 无插件参数选择面板;编辑首个 argument 的 JSON,YAML 锚点会将设置共享到全部五组脚本。 +# DataSets: [数据集];选择要处理的数据;留空或 [] 全部保留原数据。 +# Weather.Replace: [天气] 替换范围;普通天气的替换地区。WK3 空气质量单独处理,不受此项限制。 +# Weather.Provider: [天气] 数据源;选择普通天气来源;WK3 当前、逐日和逐小时天气仍使用 Apple 数据。 +# Weather.ReplaceDaily: [天气] 替换逐日预报;是否替换逐日预报;仅 WK2 生效。 +# Weather.ReplaceHourly: [天气] 替换逐小时预报;是否替换逐小时预报;仅 WK2 生效。 +# WeatherAlerts.Provider: [天气预警] 数据源;和风网页补全原生预警;选择 WeatherKit 保留原数据。 +# NextHour.Provider: [未来一小时降水强度] 数据源;选择未来一小时雨雪分钟数据来源。 +# AirQuality.Current.Index.Provider: [今日空气指数] 数据源;选择指数来源:iRingo内置算法、彩云或和风。 +# AirQuality.Current.Index.Replace: [今日空气指数] 替换目标;填写要替换的原始标准,逗号分隔;留空或 [] 不替换。 +# AirQuality.Current.Index.ForceCNPrimaryPollutants: [今日空气指数] 强制主要污染物;国标指数不超过 50 时,仍显示主要污染物。 +# AirQuality.Current.Pollutants.Provider: [今日污染物] 数据源;已有 Apple 污染物优先保留;缺失时使用此来源。 +# AirQuality.Current.Pollutants.Units.Replace: [今日污染物 - 单位转换] 替换目标;需要转换单位的空气质量标准;留空或 [] 不转换。 +# AirQuality.Current.Pollutants.Units.Mode: [今日污染物 - 单位转换] 模式;Scale 跟随标准,Force 强制转换,None 保留原单位。 +# AirQuality.Comparison.ReplaceWhenCurrentChange: [空气质量 - 对比昨日] 变化时替换;今日指数变化时,是否重新计算昨日对比。 +# AirQuality.Comparison.Yesterday.IndexProvider: [昨日空气指数] 数据源;选择昨日指数来源;仅同源、同标准的数据可比较。 +# AirQuality.Comparison.Yesterday.PollutantsProvider: [昨日污染物] 数据源;内置算法计算昨日指数所需的污染物来源。 +# AirQuality.Calculate.Algorithm: [iRingo内置算法] 算法;使用污染物本地计算指数;None 不转换。 +# AirQuality.Calculate.AllowOverRange: [iRingo内置算法] 允许指数超标;允许内置美标、国标指数超过 500。 +# API.ColorfulClouds.Token: [API] 彩云天气令牌;自有彩云令牌;留空使用内置公开令牌。 +# API.QWeather.Host: [API] 和风天气主机;和风 API 主机名;使用和风来源时填写。 +# API.QWeather.Token: [API] 和风天气令牌;和风 API 令牌;网页预警不需要。 +# LogLevel: [调试] 日志等级;默认 INFO;DEBUG 显示细节,OFF 关闭日志。 +# HTTP_TIMEOUT_MS: [网络] 单次请求超时(毫秒);单次网络请求超时,默认 8000 毫秒。 +http: + mitm: + - weatherkit.apple.com + script: + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/availability\/' + name: WeatherKit.api.availability.response + type: response + require-body: true + argument: &wk3-arguments '{"DataSets":"airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,locationInfo,news,historicalComparisons,weatherAlerts,weatherChanges","Weather.Replace":"CN","Weather.Provider":"ColorfulClouds","Weather.ReplaceDaily":"false","Weather.ReplaceHourly":"false","WeatherAlerts.Provider":"QWeatherWeb","NextHour.Provider":"ColorfulClouds","AirQuality.Current.Index.Provider":"iRingo内置算法","AirQuality.Current.Index.Replace":"HJ6332012","AirQuality.Current.Index.ForceCNPrimaryPollutants":"true","AirQuality.Current.Pollutants.Provider":"ColorfulClouds","AirQuality.Current.Pollutants.Units.Replace":"[]","AirQuality.Current.Pollutants.Units.Mode":"Scale","AirQuality.Comparison.ReplaceWhenCurrentChange":"false","AirQuality.Comparison.Yesterday.IndexProvider":"彩云天气(美标)","AirQuality.Comparison.Yesterday.PollutantsProvider":"QWeather","AirQuality.Calculate.Algorithm":"欧盟EAQI","AirQuality.Calculate.AllowOverRange":"true","API.ColorfulClouds.Token":"","API.QWeather.Host":"devapi.qweather.com","API.QWeather.Token":"","LogLevel":"INFO","HTTP_TIMEOUT_MS":"8000"}' + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/airQualityScale\/' + name: WeatherKit.api.airQualityScale.request + type: request + argument: *wk3-arguments + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?!wk3_)[a-zA-Z0-9][a-zA-Z0-9_-]*-[0-9]{9}(?:&|$)' + name: WeatherKit.api.weatherAlerts.html.request + type: request + argument: *wk3-arguments + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[12]\/weatherAlerts\?(?:[^#]*&)?ids=(?:wk3_[^&#]*|-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?)(?:&|$)' + name: WeatherKit.api.weatherAlerts.api.request + type: request + argument: *wk3-arguments + - match: '^https?:\/\/weatherkit\.apple\.com\/api\/v[23]\/weather\/' + name: WeatherKit.api.weather.response + type: response + require-body: true + binary-mode: true + argument: *wk3-arguments +script-providers: + WeatherKit.api.availability.response: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-response.bundle.js + interval: 86400 + WeatherKit.api.airQualityScale.request: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-request.bundle.js + interval: 86400 + WeatherKit.api.weatherAlerts.html.request: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-request.bundle.js + interval: 86400 + WeatherKit.api.weatherAlerts.api.request: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-request.bundle.js + interval: 86400 + WeatherKit.api.weather.response: + url: https://github.com/patrickyanxxxxx/WeatherKit-WK3/releases/download/v3.3.8/wk3-stash-response.bundle.js + interval: 86400 diff --git a/scripts/build-wk3-modules.mjs b/scripts/build-wk3-modules.mjs index b966c848..2da12031 100644 --- a/scripts/build-wk3-modules.mjs +++ b/scripts/build-wk3-modules.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; import { groups } from '../src/wk3/routes.mjs'; +import { displayModuleOption } from '../src/wk3/option-values.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const parameters = localParameters; @@ -19,16 +20,16 @@ const expand = (text, replacements) => { if (/__WK3_[A-Z_]+__/.test(text)) throw new Error('Unrendered WK3 template placeholder'); return text; }; -const defaults = parameters.map(({ key, value }) => `${key}:${JSON.stringify(value)}`).join(','); +const defaults = parameters.map(({ key, value }) => `${key}:${JSON.stringify(displayModuleOption(key, value))}`).join(','); const descriptions = parameters.map(parameter => `${parameter.key}: ${parameter.name};${parameterDescription(parameter)}`.replaceAll('\n', ' ')); const description = descriptions.join('\\n'); const loonArguments = parameters.map(parameter => { const { key, value, boolean, options, name } = parameter; - const kind = boolean ? `switch,${value}` : options ? `select,${[value, ...options.filter(v => v !== value)].map(v => JSON.stringify(v)).join(',')}` : `input,${JSON.stringify(value)}`; + const kind = boolean ? `switch,${value}` : options ? `select,${[value, ...options.filter(v => v !== value)].map(v => JSON.stringify(displayModuleOption(key, v))).join(',')}` : `input,${JSON.stringify(value)}`; return `${key} = ${kind},tag=${name},desc=${parameterDescription(parameter).replaceAll(/[\r\n,]/g, ',')}`; }).join('\n'); const quoteYaml = text => `'${text.replaceAll("'", "''")}'`; -const stashArgument = quoteYaml(JSON.stringify(Object.fromEntries(parameters.map(({ key, value }) => [key, value])))); +const stashArgument = quoteYaml(JSON.stringify(Object.fromEntries(parameters.map(({ key, value }) => [key, displayModuleOption(key, value)])))); export function renderWK3Module(platform, version) { if (!Object.hasOwn(platforms, platform)) throw new Error(`Unsupported platform: ${platform}`); diff --git a/src/platform/wk3-adapter.mjs b/src/platform/wk3-adapter.mjs index 4212802d..6d94bcab 100644 --- a/src/platform/wk3-adapter.mjs +++ b/src/platform/wk3-adapter.mjs @@ -72,7 +72,7 @@ export async function runPlatform(handler, stage, globals = globalThis, platform } } const ctx = { - env: { ...(globals.$environment ?? globals.$env ?? {}), ...argumentValues }, + env: { ...(platform === "Quantumult X" ? Object.fromEntries(localParameters.map(p => [p.key, p.value])) : {}), ...(globals.$environment ?? globals.$env ?? {}), ...argumentValues }, request: { ...request, headers: headers(request.headers) }, respond(value) { return value; }, }; diff --git a/src/wk3/option-values.mjs b/src/wk3/option-values.mjs index 745c530c..4578e4c6 100644 --- a/src/wk3/option-values.mjs +++ b/src/wk3/option-values.mjs @@ -1,5 +1,5 @@ -// Egern string pickers display their stored values. Keep readable labels at -// that UI boundary; existing canonical values remain accepted by scripts. +// Module string controls display their stored values. Reuse readable labels +// across clients; existing canonical values remain accepted by scripts. const indexSources = { Calculate: "iRingo内置算法", ColorfulCloudsUS: "彩云天气(美标)", diff --git a/template/wk3.quantumultx.handlebars b/template/wk3.quantumultx.handlebars index 5d5e49cd..5bd517c5 100644 --- a/template/wk3.quantumultx.handlebars +++ b/template/wk3.quantumultx.handlebars @@ -3,7 +3,7 @@ #!homepage = https://github.com/patrickyanxxxxx/WeatherKit-WK3 #!icon = https://developer.apple.com/assets/elements/icons/weatherkit/weatherkit-128x128.png #!version = __WK3_VERSION__ -# QuantumultX 本地脚本不传模块参数;使用运行时默认值。 +# QuantumultX 本地脚本不传模块参数;使用与其他 WK3 模块相同的默认值,二进制响应仍透传。 #[filter_local] host, weather-analytics-events.apple.com, reject diff --git a/tests/wk3-module-parameters.test.mjs b/tests/wk3-module-parameters.test.mjs index d2afe32a..52289320 100644 --- a/tests/wk3-module-parameters.test.mjs +++ b/tests/wk3-module-parameters.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { displayModuleOption } from '../src/wk3/option-values.mjs'; +import { displayModuleOption, normalizeModuleOption } from '../src/wk3/option-values.mjs'; import { localParameters, parameterDescription } from '../src/wk3/parameters.mjs'; import { renderLocalModule } from '../scripts/build-egern-module.mjs'; import { renderWK3Module } from '../scripts/build-wk3-modules.mjs'; @@ -52,7 +52,7 @@ for (const platform of ['surge', 'loon', 'stash']) test(`${platform}: actual mod const text = renderWK3Module(platform, version); for (const parameter of localParameters) { assert.ok(text.includes(parameter.name), parameter.key); - if (platform === 'loon') for (const option of parameter.options ?? []) assert.ok(text.includes(option), `${parameter.key}: ${option}`); + if (platform === 'loon') for (const option of parameter.options ?? []) assert.ok(text.includes(displayModuleOption(parameter.key, option)), `${parameter.key}: ${option}`); } for (const parameter of localParameters) assert.ok(parameter.description.length <= 80, parameter.key); assert.doesNotMatch(text, /确保包含国内原标准|十二种算法|始终仅做同源/); @@ -94,11 +94,11 @@ async function consume(platform, changes) { for (const platform of ['egern', 'surge', 'loon', 'stash']) { test(`${platform}: generated module edits retain types and all scalar choices reach Settings`, async () => { for (const parameter of localParameters) { - const edits = parameter.boolean ? ['true', 'false'] : parameter.options; + const edits = parameter.boolean ? ['true', 'false'] : parameter.options?.flatMap(value => [value, displayModuleOption(parameter.key, value)]); if (!edits) continue; for (const edit of edits) { const { settings } = await consume(platform, { [parameter.key]: edit }); - assert.equal(get(settings, parameter.key), parameter.boolean ? edit === 'true' : edit, `${parameter.key}=${edit}`); + assert.equal(get(settings, parameter.key), parameter.boolean ? edit === 'true' : normalizeModuleOption(parameter.key, edit), `${parameter.key}=${edit}`); } } const { settings, env } = await consume(platform, { @@ -122,3 +122,30 @@ for (const platform of ['egern', 'surge', 'loon', 'stash']) { assert.deepEqual(settings.Weather.Replace, []); }); } + +for (const platform of ['egern', 'surge', 'loon', 'stash']) test(`${platform}: generated defaults and native EAQI request match Egern`, async () => { + const { settings } = await consume(platform, {}); + assert.equal(settings.AirQuality.Current.Index.Provider, 'Calculate'); + assert.equal(settings.AirQuality.Calculate.Algorithm, 'EU_EAQI'); + assert.equal(settings.AirQuality.Comparison.Yesterday.IndexProvider, 'ColorfulCloudsUS'); + assert.equal(settings.AirQuality.Comparison.ReplaceWhenCurrentChange, false); + if (platform !== 'egern') { + const argument = moduleArguments(platform); + let returned; + await runPlatform(async ctx => { + assert.equal(configuration(ctx).Settings.AirQuality.Calculate.Algorithm, 'EU_EAQI'); + return { url: 'https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EU.EAQI', headers: { authorization: 'synthetic' } }; + }, 'request', { $argument: argument, $request: { url: 'https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EU.EAQI.2414' }, $done(value) { returned = value; } }, platform); + assert.equal(returned.url, 'https://weatherkit.apple.com/api/v2/airQualityScale/zh-Hans/EU.EAQI'); + assert.equal(returned.response, undefined); + } +}); +test('Quantumult X without module argument uses the same defaults and retains explicit overrides', async () => { + let settings; + await runPlatform(ctx => { settings = configuration(ctx).Settings; }, 'request', { $request: { url: 'https://example.invalid/' }, $done() {} }, 'Quantumult X'); + assert.equal(settings.AirQuality.Current.Index.Provider, 'Calculate'); + assert.equal(settings.AirQuality.Calculate.Algorithm, 'EU_EAQI'); + assert.equal(settings.AirQuality.Comparison.ReplaceWhenCurrentChange, false); + await runPlatform(ctx => { settings = configuration(ctx).Settings; }, 'request', { $argument: { 'AirQuality.Calculate.Algorithm': '德国LQI' }, $request: { url: 'https://example.invalid/' }, $done() {} }, 'Quantumult X'); + assert.equal(settings.AirQuality.Calculate.Algorithm, 'UBA'); +}); diff --git a/tests/wk3-modules.test.mjs b/tests/wk3-modules.test.mjs index fd0570c2..dffff569 100644 --- a/tests/wk3-modules.test.mjs +++ b/tests/wk3-modules.test.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { displayModuleOption } from '../src/wk3/option-values.mjs'; import { localParameters } from '../src/wk3/parameters.mjs'; import { buildWK3Modules, groups, platforms, release, renderWK3Module } from '../scripts/build-wk3-modules.mjs'; import { localScriptGroups } from '../scripts/build-egern-module.mjs'; @@ -76,7 +77,7 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.equal(text.split(`wk3-${platform}-response.bundle.js`).length - 1, 2); assert.equal((text.match(/v3\.3\.2\//g) ?? []).length, 5); if (platform === 'surge') { - assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"EU_EAQI"/m); + assert.match(text, /^#!arguments = .*AirQuality\.Calculate\.Algorithm:"欧盟EAQI"/m); assert.match(rules[4], /requires-body=1, binary-body-mode=1/); assert.match(rules[0], /requires-body=1/); assert.ok(rules.slice(1, 4).every(line => !line.includes('binary-body-mode=1'))); @@ -91,9 +92,9 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv assert.match(args.find(line => line.startsWith('Weather.Provider =')), /select,"ColorfulClouds","WeatherKit","QWeather"/); const algorithms = args.find(line => line.startsWith('AirQuality.Calculate.Algorithm =')); const officialOrder = ['None', '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']; - assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"EU_EAQI",/); + assert.match(algorithms, /^AirQuality\.Calculate\.Algorithm = select,"欧盟EAQI",/); assert.deepEqual(algorithms.split(',tag=')[0].match(/"[^"]+"/g).map(value => JSON.parse(value)), - ['EU_EAQI', ...officialOrder.filter(value => value !== 'EU_EAQI')]); + ['EU_EAQI', ...officialOrder.filter(value => value !== 'EU_EAQI')].map(value => displayModuleOption('AirQuality.Calculate.Algorithm', value))); assert.match(rules[4], /requires-body=1, binary-body-mode=1/); assert.match(rules[0], /requires-body=1/); assert.match(rules[0], /argument=\[\{DataSets\},/); @@ -112,8 +113,8 @@ for (const platform of Object.keys(platforms)) test(`${platform}: structure, fiv const argumentsJSON = [JSON.parse(sharedArgument[1])]; for (const values of argumentsJSON) { assert.equal(Object.keys(values).length, 23); - assert.equal(values['AirQuality.Calculate.Algorithm'], 'EU_EAQI'); - assert.equal(values['AirQuality.Current.Index.Provider'], 'Calculate'); + assert.equal(values['AirQuality.Calculate.Algorithm'], '欧盟EAQI'); + assert.equal(values['AirQuality.Current.Index.Provider'], 'iRingo内置算法'); } for (const group of groups) assert.match(providers, new RegExp(`^ WeatherKit\\.api\\.${group.name.replaceAll('.', '\\.')}:[\\s\\S]*? interval: 86400`, 'm')); }