From c050294264a782b4206221d1c97021dfb449a24f Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:43:36 +0800 Subject: [PATCH 1/8] fix(weatherkit): preserve v3 products and normalize air quality with bounded provider fallback --- docs/provider-regions-source.json | 26 ++ package-lock.json | 13 + package.json | 7 +- src/class/AirQuality.mjs | 234 +++++----- src/class/ColorfulClouds.mjs | 82 ++-- src/class/ProviderRequestCache.mjs | 400 ++++++++++++++++ src/class/QWeather.mjs | 362 +++++++++++---- src/class/WAQI.mjs | 63 ++- src/class/WeatherKit2.mjs | 469 ++++++++++--------- src/data/providerRegions.mjs | 6 + src/function/createProviderEnvironment.mjs | 20 + src/function/database.mjs | 3 + src/function/ensureTextEncoder.mjs | 46 ++ src/function/fetchProvider.mjs | 32 ++ src/function/mergeForecastCopy.mjs | 16 + src/function/parseWeatherKitURL.mjs | 14 +- src/function/planWeatherProducts.mjs | 9 + src/function/providerRecovery.mjs | 44 ++ src/function/recoverConfiguredProduct.mjs | 20 + src/function/resolveWeatherRegion.mjs | 68 +++ src/function/serializeProviderCache.mjs | 41 ++ src/function/setENV.mjs | 5 +- src/function/utf8Length.mjs | 16 + src/function/weatherMemoryLimits.mjs | 9 + src/loon/request.js | 25 + src/loon/response.js | 11 + src/loon/scale.js | 16 + src/process/Request.dev.mjs | 323 +++++++------ src/process/Request.mjs | 305 +++++++------ src/process/Response.dev.mjs | 503 ++++++++++++--------- src/process/Response.mjs | 409 ++++++++++------- src/process/ResponseJSONV3.mjs | 248 ++++++++++ src/types.d.ts | 12 + 33 files changed, 2737 insertions(+), 1120 deletions(-) create mode 100644 docs/provider-regions-source.json create mode 100644 src/class/ProviderRequestCache.mjs create mode 100644 src/data/providerRegions.mjs create mode 100644 src/function/createProviderEnvironment.mjs create mode 100644 src/function/ensureTextEncoder.mjs create mode 100644 src/function/fetchProvider.mjs create mode 100644 src/function/mergeForecastCopy.mjs create mode 100644 src/function/planWeatherProducts.mjs create mode 100644 src/function/providerRecovery.mjs create mode 100644 src/function/recoverConfiguredProduct.mjs create mode 100644 src/function/resolveWeatherRegion.mjs create mode 100644 src/function/serializeProviderCache.mjs create mode 100644 src/function/utf8Length.mjs create mode 100644 src/function/weatherMemoryLimits.mjs create mode 100644 src/loon/request.js create mode 100644 src/loon/response.js create mode 100644 src/loon/scale.js create mode 100644 src/process/ResponseJSONV3.mjs diff --git a/docs/provider-regions-source.json b/docs/provider-regions-source.json new file mode 100644 index 00000000..74742192 --- /dev/null +++ b/docs/provider-regions-source.json @@ -0,0 +1,26 @@ +{ + "url": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson", + "sourceSHA256": "239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255", + "license": "Public domain", + "licenseURL": "https://www.naturalearthdata.com/about/terms-of-use/", + "providerCodes": [ + "CN", + "HK", + "TW", + "MO" + ], + "simplificationToleranceDegrees": { + "CN": 0.01, + "HK": 0.001, + "MO": 0.001, + "TW": 0.001 + }, + "boundaryExclusionDegrees": { + "CN": 0.05, + "HK": 0.01, + "MO": 0.01, + "TW": 0.01 + }, + "quantizationDegrees": 1e-05, + "purpose": "Conservative weather-provider routing hints, not authoritative administrative boundaries" +} diff --git a/package-lock.json b/package-lock.json index 87b4353d..2e617d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "packages/*" ], "dependencies": { + "@noble/hashes": "1.8.0", "@nsnanocat/flatbuffer-root": "^1.0.0", "@nsnanocat/util": "^2.7.4", "@nsringo/weatherkit": "^1.1.2", @@ -304,6 +305,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index ab06a09e..82bf5967 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ "build:args": "npm-run-all --serial build:release-args build:full-args", "dts": "arguments-builder dts -c arguments-builder.full.config.ts", "submodule": "git submodule update --remote", - "update": "npm update --save" + "update": "npm update --save", + "test": "node --test --test-concurrency=1 tests/*.test.mjs", + "build:loon": "rollup -c && arguments-builder loon -c arguments-builder.release.config.ts && node scripts/verify-loon-build.mjs" }, "browserslist": [ "iOS >= 18" @@ -73,7 +75,8 @@ "fetch-cookie": "^3.2.0", "flatbuffers": "^24.12.23", "hono": "^4.12.31", - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "@noble/hashes": "1.8.0" }, "overrides": { "@iringo/surge2egern": { diff --git a/src/class/AirQuality.mjs b/src/class/AirQuality.mjs index b5c4b770..dfb673dc 100644 --- a/src/class/AirQuality.mjs +++ b/src/class/AirQuality.mjs @@ -15,7 +15,45 @@ export default class AirQuality { const multiplier = 10 ** decimals; // 向上取整 - return Math.ceil(a * multiplier) / multiplier; + const scaled = a * multiplier; + return Math.ceil(scaled - Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) / multiplier; + } + + static RequiredPollutants = ["PM2_5", "PM10", "OZONE", "NO2", "SO2", "CO"]; + + static ValidPollutants(pollutants) { + if (!Array.isArray(pollutants)) return []; + const units = AirQuality.Config.Units.Friendly, seen = new Set(), result = []; + for (const pollutant of pollutants) { + if (!pollutant || typeof pollutant.pollutantType !== "string" || !pollutant.pollutantType || seen.has(pollutant.pollutantType) || !Object.hasOwn(units, pollutant.units)) continue; + const raw = pollutant.amount; + const amount = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() ? Number(raw) : NaN; + if (!Number.isFinite(amount) || amount < 0) continue; + const value = { ...pollutant, amount }; + if (!Number.isFinite(value.index) || value.index < 0) delete value.index; + seen.add(value.pollutantType); + result.push(value); + } + return result; + } + + static MissingPollutants(pollutants) { + const present = new Set(AirQuality.ValidPollutants(pollutants).map(({ pollutantType }) => pollutantType)); + return AirQuality.RequiredPollutants.filter(type => !present.has(type)); + } + + static MergePollutants(current, supplement) { + return AirQuality.ValidPollutants([...(Array.isArray(current) ? current : []), ...(Array.isArray(supplement) ? supplement : [])]); + } + + static #Unavailable(pollutants = []) { + return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants }; + } + + static #HasRiskInputs(pollutants, scale) { + const present = new Set(pollutants.filter(p => p.units === scale.pollutants[p.pollutantType]?.units && Number.isFinite(p.amount) && p.amount >= 0).map(p => p.pollutantType)); + const hasPM = ["PM10", "PM2_5"].some(type => scale.pollutants[type] && present.has(type)); + return Object.keys(scale.pollutants).every(type => type === "PM10" || type === "PM2_5" ? hasPM : present.has(type)); } /** @@ -44,8 +82,8 @@ export default class AirQuality { const failedCategoryIndex = -1; - if (!categories?.ranges || !Array.isArray(categories.ranges) || categories.ranges.length === 0) { - Console.debug(`categories: ${JSON.stringify(categories)}`); + if (!Number.isFinite(index) || index < 0 || !categories?.ranges || !Array.isArray(categories.ranges) || categories.ranges.length === 0) { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`categories: ${JSON.stringify(categories)}`); Console.error("GetPrimaryPollutant", "categories无效"); return failedCategoryIndex; } @@ -104,7 +142,7 @@ export default class AirQuality { Console.info("ConvertUnits", `Converting ${pollutant.pollutantType}`); const amount = AirQuality.ConvertUnit(pollutant.amount, pollutant.units, pollutantScale.units, stpConversionFactors?.[pollutant.pollutantType] || -1, pollutantScale?.stpConversionFactor || -1); - if (amount < 0) { + if (!Number.isFinite(amount) || amount < 0) { Console.error( "ConvertUnits", `无法转换${pollutant.pollutantType}的单位`, @@ -125,30 +163,25 @@ export default class AirQuality { * * 说明: * - 这是空气质量数据修复的统一入口(当前聚焦污染物单位修复); - * - 当前仅处理和风天气(QWeather)CO 单位场景:其 CO 原始单位为 mg/m3, - * 需转换为 WeatherKit 使用的 µg/m3; + * - QWeather 供应商结果按明确单位处理; + * - Apple 原始 QWeather 产品的 CO 数值为 mg/m3,但可能标成 µg/m3。 + * 只有在原始 Apple 产品入口显式声明 appleProduct 时修复这个标签错误, + * 不对已归一化的供应商结果重复换算,也不按浓度大小猜测单位。 * - 后续同类修复请统一收敛到本方法内,必要时可将本方法升级为 FixAirQuality。 * * @param {any} airQuality - 空气质量数据对象 + * @param {{appleProduct?: boolean}} options - 仅原始 Apple 产品入口设为 true * @returns {any} 单位修复后的空气质量数据对象 */ - static FixPollutantsUnits(airQuality) { + static FixPollutantsUnits(airQuality, options = {}) { Console.info("☑️ FixPollutantsUnits"); switch (airQuality?.metadata?.providerName) { case "和风天气": case "QWeather": { - airQuality.pollutants = airQuality?.pollutants?.map(pollutant => { - switch (pollutant.pollutantType) { - case "CO": { - const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; - pollutant.amount = AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3); - break; - } - default: - break; - } - return pollutant; - }); + const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; + airQuality = { ...airQuality, pollutants: airQuality?.pollutants?.map(pollutant => pollutant?.pollutantType === "CO" && (pollutant.units === mgm3 || options.appleProduct === true && pollutant.units === ugm3) + ? { ...pollutant, amount: AirQuality.ConvertUnit(pollutant.amount, mgm3, ugm3), units: ugm3 } + : pollutant) }; break; } default: { @@ -175,12 +208,16 @@ export default class AirQuality { static Pollutants2AQI(airQuality, Settings, options = {}) { Console.info("☑️ Pollutants2AQI"); - const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm; + const algorithm = options?.algorithm ?? Settings?.AirQuality?.Calculate?.Algorithm ?? "UBA"; + if (algorithm === "None") return airQuality; const forcePrimaryPollutant = options?.forcePrimaryPollutant ?? Settings?.AirQuality?.Current?.Index?.ForceCNPrimaryPollutants; const allowOverRange = options?.allowOverRange ?? Settings?.AirQuality?.Calculate?.AllowOverRange; const stpConversionFactors = AirQuality.#GetStpConversionFactors(airQuality); - const scale = AirQuality.Config.Scales[algorithm] ?? AirQuality.Config.Scales.UBA; + const scale = AirQuality.Config.Scales[algorithm]; + const pollutants = AirQuality.ValidPollutants(airQuality?.pollutants); + if (!scale || !pollutants.length) return AirQuality.#Unavailable(pollutants); + airQuality = { ...airQuality, pollutants }; switch (algorithm) { case "None": { @@ -196,7 +233,7 @@ export default class AirQuality { case "WAQI_InstantCast_US": { // PollutantsToInstantCastUS Console.info("☑️ PollutantsToInstantCastUS", `allowOverRange: ${allowOverRange}`); - airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange }); + airQuality = AirQuality.#PollutantsToAirQuality(airQuality.pollutants, scale, { stpConversionFactors, allowOverRange, concentrationSteps: { PM2_5: 0.1, PM10: 1, OZONE: 0.1, NO2: 1, SO2: 1, CO: 100 } }); Console.info("✅ PollutantsToInstantCastUS"); break; } @@ -257,6 +294,7 @@ export default class AirQuality { } } + if (!Number.isFinite(airQuality.index) || airQuality.index < 0 || !Number.isInteger(airQuality.categoryIndex) || airQuality.categoryIndex < 1) return AirQuality.#Unavailable(airQuality.pollutants); Console.info("✅ Pollutants2AQI"); return airQuality; } @@ -486,10 +524,12 @@ export default class AirQuality { * @returns {number} * 该污染物的 index;低于最小阈值时返回 min.indexes[0];超上限时使用 max 区间外推。 */ - static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits) { - if (!pollutantScale) { - Console.warn("PollutantsToIndexes", `标准未定义污染物${pollutantType},index将标记为-1`); - return -1; + static #ComputePollutantIndex(pollutant, pollutantType, pollutantScale, friendlyUnits, step) { + if (!pollutantScale || pollutant.units !== pollutantScale.units || !Number.isFinite(pollutant.amount) || pollutant.amount < 0) return undefined; + let amount = pollutant.amount; + if (step) { + const scaled = amount / step; + amount = Math.floor(scaled + Number.EPSILON * Math.max(1, Math.abs(scaled)) * 8) * step; } const { add, subtract, multiply, divide } = SimplePrecisionMath; @@ -497,29 +537,33 @@ export default class AirQuality { Console.debug(`${pollutantType}: ${pollutant.amount} ${friendlyUnits[pollutant.units] ?? pollutant.units}`); const minValidAmount = pollutantScale.ranges.min.amounts[0]; - if (pollutant.amount < minValidAmount) { + if (amount < minValidAmount) { Console.error("PollutantsToIndexes", `${pollutantType}的含量无效:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}需要 >= ${minValidAmount}`); return pollutantScale.ranges.min.indexes[0]; } - const isOverRange = pollutant.amount > pollutantScale.ranges.max.amounts[1]; + const isOverRange = amount > pollutantScale.ranges.max.amounts[1]; if (isOverRange) { Console.warn("PollutantsToIndexes", `检测到爆表指数!${pollutantType}:${pollutant.amount} ${friendlyUnits[pollutantScale.units]}`); Console.warn("PollutantsToIndexes", "注意身体!"); } - const { indexes, amounts } = isOverRange + const range = isOverRange ? pollutantScale.ranges.max : pollutantScale.ranges.value.find(({ amounts }) => { const [minAmount, maxAmount] = amounts; - return AirQuality.#CeilByPrecision(pollutant.amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(pollutant.amount, maxAmount) <= maxAmount; + return AirQuality.#CeilByPrecision(amount, minAmount) >= minAmount && AirQuality.#CeilByPrecision(amount, maxAmount) <= maxAmount; }); - Console.debug(`indexes: ${JSON.stringify(indexes)}`, `amounts: ${JSON.stringify(amounts)}`); + if (!range) return undefined; + const { indexes, amounts } = range; + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`indexes: ${JSON.stringify(indexes)}`, `amounts: ${JSON.stringify(amounts)}`); const [minIndex, maxIndex] = indexes; const [minAmount, maxAmount] = amounts; - const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(pollutant.amount, minAmount)), pollutant.amount > maxAmount ? maxIndex : minIndex); - return index; + if (minIndex === maxIndex) return minIndex; + const concentration = isOverRange ? amount : Math.max(minAmount, Math.min(maxAmount, amount)); + const index = add(multiply(divide(subtract(maxIndex, minIndex), subtract(maxAmount, minAmount)), subtract(concentration, minAmount)), minIndex); + return Number.isFinite(index) && index >= 0 ? index : undefined; } /** @@ -547,12 +591,13 @@ export default class AirQuality { * * @returns {Array<{pollutantType: string, index: number}>} * 每种标准污染物对应的 index 列表。 - * - 若标准所需污染物缺失,返回该污染物 index=-1。 + * - 标准未定义的分项不输出 index,保留有效物理浓度。 */ - static #PollutantsToIndexes(pollutants = [], pollutantScales) { + static #PollutantsToIndexes(pollutants = [], pollutantScales, concentrationSteps) { return pollutants.map(pollutant => { - const index = AirQuality.#ComputePollutantIndex(pollutant, pollutant.pollutantType, pollutantScales?.[pollutant.pollutantType], AirQuality.Config.Units.Friendly); - pollutant.index = index; + const index = AirQuality.#ComputePollutantIndex(pollutant, pollutant.pollutantType, pollutantScales?.[pollutant.pollutantType], AirQuality.Config.Units.Friendly, concentrationSteps?.[pollutant.pollutantType]); + if (index === undefined) delete pollutant.index; + else pollutant.index = index; return pollutant; }); } @@ -562,35 +607,19 @@ export default class AirQuality { const failedPollutant = { pollutantType: "NOT_AVAILABLE", index: -1, categoryIndex: -1 }; if (!Array.isArray(pollutants) || pollutants.length === 0) { - Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); Console.error("PrimaryPollutant", "pollutants无效"); return failedPollutant; } - const indexesWithCategory = pollutants - .filter(({ index }) => Number.isFinite(index)) - .map(pollutant => ({ - ...pollutant, - categoryIndex: AirQuality.CategoryIndex(pollutant.index, categories), - })) - .sort((a, b) => b.index - a.index); - - if (indexesWithCategory.length === 0) { - Console.debug(`pollutants: ${JSON.stringify(pollutants)}`, `indexesWithCategory: ${JSON.stringify(indexesWithCategory)}`); - Console.error("PrimaryPollutant", "找不到有效的index"); - return failedPollutant; + let primaryPollutant = failedPollutant; + for (const pollutant of pollutants) { + if (!Number.isFinite(pollutant.index) || pollutant.index < 0) continue; + const categoryIndex = AirQuality.CategoryIndex(pollutant.index, categories); + if (categoryIndex < 1) continue; + if (categoryIndex > primaryPollutant.categoryIndex || (categoryIndex === primaryPollutant.categoryIndex && pollutant.index > primaryPollutant.index)) primaryPollutant = { ...pollutant, categoryIndex }; } - - const maxCategoryIndex = indexesWithCategory.reduce((max, { categoryIndex }) => (categoryIndex > max ? categoryIndex : max), Number.NEGATIVE_INFINITY); - const primaryPollutants = indexesWithCategory.filter(({ categoryIndex }) => categoryIndex === maxCategoryIndex); - - if (primaryPollutants.length > 1) { - Console.warn("PrimaryPollutant", `检测到多个同级别的污染物,maxCategoryIndex:${maxCategoryIndex}`); - primaryPollutants.map(({ pollutantType, index }) => Console.warn("GetPrimaryPollutants", `${pollutantType}:${index}`)); - } - - const primaryPollutant = primaryPollutants[0]; - Console.info("✅ PrimaryPollutant", `primaryPollutant: ${JSON.stringify(primaryPollutant)}`); + if (Console.logLevel === "INFO" || Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.info("✅ PrimaryPollutant", `primaryPollutant: ${JSON.stringify(primaryPollutant)}`); return primaryPollutant; } @@ -640,18 +669,18 @@ export default class AirQuality { const allowOverRange = options?.allowOverRange ?? true; if (!Array.isArray(pollutants) || pollutants.length === 0) { - Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); Console.error("PollutantsToAirQuality", "pollutants无效"); return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; } - let newPollutants = []; + let newPollutants = pollutants; if (stpConversionFactors) { newPollutants = AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants); } - newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants); - Console.debug("PollutantsToAirQuality", `convertedPollutants: ${JSON.stringify(newPollutants)}`); + newPollutants = AirQuality.#PollutantsToIndexes(newPollutants, scale.pollutants, options.concentrationSteps); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug("PollutantsToAirQuality", `convertedPollutants: ${JSON.stringify(newPollutants)}`); const primaryPollutant = AirQuality.PrimaryPollutant(newPollutants, scale.categories); const maxIndex = scale?.weatherKitScale?.maxIndex; @@ -688,32 +717,27 @@ export default class AirQuality { Console.info("☑️ PollutantsToAQHI"); if (!Array.isArray(pollutants) || pollutants.length === 0) { - Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); Console.error("PollutantsToAQHI", "pollutants无效"); return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; - - // 先做单位转换(目标单位均为 µg/m³) + // 加拿大气体使用 ppb,其余模型按各自 scale 的单位计算。 const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) return AirQuality.#Unavailable(convertedPollutants); // 按 pollutantType 索引浓度 const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); - if (!p) return null; - if (p.units !== ugm3) { - Console.warn("PollutantsToAQHI", `${type} 单位不是 µg/m³(${p.units}),跳过`); - return null; - } + if (!scale.pollutants[type] || !p || p.units !== scale.pollutants[type].units) return null; return p.amount; }; const { betas } = scale; const ar = (beta, concentration) => { if (concentration === null || concentration < 0) return 0; - return (Math.exp(beta * concentration) - 1) * 100; + return Math.expm1(beta * concentration) * 100; }; const arNO2 = ar(betas.NO2 || 0, getAmount("NO2")); @@ -724,10 +748,12 @@ export default class AirQuality { const arPM = Math.max(arPM10, arPM2_5); const totalAR = arNO2 + arSO2 + arO3 + arPM; + if (!Number.isFinite(totalAR)) return AirQuality.#Unavailable(convertedPollutants); Console.info("PollutantsToAQHI", `%AR: NO2=${arNO2.toFixed(4)}, SO2=${arSO2.toFixed(4)}, O3=${arO3.toFixed(4)}, PM=${arPM.toFixed(4)} (PM10=${arPM10.toFixed(4)}, PM2.5=${arPM2_5.toFixed(4)}), total=${totalAR.toFixed(4)}`); // 设置每个污染物的内部 index 为它的 %AR 值 convertedPollutants.forEach(p => { + if (!scale.pollutants[p.pollutantType]) { delete p.index; return; } switch (p.pollutantType) { case "NO2": p.index = arNO2; @@ -745,12 +771,12 @@ export default class AirQuality { p.index = arPM2_5; break; default: - p.index = -1; + delete p.index; } }); // 将 %AR 映射到 1–11 的 categoryIndex (即 AQHI 指数) - const categoryIndex = AirQuality.CategoryIndex(totalAR, scale.categories); + const categoryIndex = scale.indexScaleFactor ? Math.max(1, Math.min(11, Math.round(totalAR * scale.indexScaleFactor))) : AirQuality.CategoryIndex(totalAR, scale.categories); // 以最大 %AR 对应的污染物作为主污染物 const primaryPollutant = convertedPollutants.reduce((max, cur) => (cur.index > max.index ? cur : max), { pollutantType: "NOT_AVAILABLE", index: -1 }); @@ -771,22 +797,18 @@ export default class AirQuality { Console.info("☑️ PollutantsToAQHIMulti"); if (!Array.isArray(pollutants) || pollutants.length === 0) { - Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutants: ${JSON.stringify(pollutants)}`); Console.error("PollutantsToAQHIMulti", "pollutants无效"); return { metadata: { providerName: "iRingo", temporarilyUnavailable: true } }; } const stpConversionFactors = options?.stpConversionFactors; - const { ugm3 } = AirQuality.Config.Units.WeatherKit; const convertedPollutants = stpConversionFactors ? AirQuality.ConvertUnits(pollutants, stpConversionFactors, scale.pollutants) : pollutants; + if (!AirQuality.#HasRiskInputs(convertedPollutants, scale)) return AirQuality.#Unavailable(convertedPollutants); const getAmount = type => { const p = convertedPollutants.find(({ pollutantType }) => pollutantType === type); - if (!p) return null; - if (p.units !== ugm3) { - Console.warn("PollutantsToAQHIMulti", `${type} 单位不是 µg/m³(${p.units}),跳过`); - return null; - } + if (!scale.pollutants[type] || !p || p.units !== scale.pollutants[type].units) return null; return p.amount; }; @@ -803,6 +825,7 @@ export default class AirQuality { const pmContribution = usePM10 ? contributions.PM10 : contributions.PM2_5; const totalWeighted = contributions.OZONE + contributions.NO2 + contributions.SO2 + pmContribution; const totalRisk = (beta || 0) * totalWeighted; + if (!Number.isFinite(totalRisk)) return AirQuality.#Unavailable(convertedPollutants); Console.info( "PollutantsToAQHIMulti", @@ -810,6 +833,7 @@ export default class AirQuality { ); convertedPollutants.forEach(p => { + if (!scale.pollutants[p.pollutantType]) { delete p.index; return; } switch (p.pollutantType) { case "OZONE": p.index = contributions.OZONE; @@ -827,7 +851,7 @@ export default class AirQuality { p.index = contributions.SO2; break; default: - p.index = -1; + delete p.index; } }); @@ -1667,18 +1691,18 @@ export default class AirQuality { }, CO: { units: "PARTS_PER_BILLION", - stpConversionFactor: 1.144511152104645, + stpConversionFactor: 1.14491990608754, ranges: { min: { indexes: [0, 50], amounts: [0, 4400] }, - max: { indexes: [301, 500], amounts: [30401, 50400] }, + max: { indexes: [301, 500], amounts: [30500, 50400] }, value: [ { indexes: [0, 50], amounts: [0, 4400] }, // Good - { indexes: [51, 100], amounts: [4401, 9400] }, // Moderate - { indexes: [101, 150], amounts: [9401, 12400] }, // Unhealthy for Sensitive Groups - { indexes: [151, 200], amounts: [12401, 15400] }, // Unhealthy - { indexes: [201, 300], amounts: [15401, 30400] }, // Very Unhealthy - { indexes: [301, 500], amounts: [30401, 50400] }, // Hazardous - { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50401, Number.POSITIVE_INFINITY] }, // Hazardous + { indexes: [51, 100], amounts: [4500, 9400] }, // Moderate + { indexes: [101, 150], amounts: [9500, 12400] }, // Unhealthy for Sensitive Groups + { indexes: [151, 200], amounts: [12500, 15400] }, // Unhealthy + { indexes: [201, 300], amounts: [15500, 30400] }, // Very Unhealthy + { indexes: [301, 500], amounts: [30500, 50400] }, // Hazardous + { indexes: [501, Number.POSITIVE_INFINITY], amounts: [50500, Number.POSITIVE_INFINITY] }, // Hazardous ], }, }, @@ -1858,7 +1882,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 +2069,17 @@ export default class AirQuality { { categoryIndex: 11, indexes: [10.41, Number.POSITIVE_INFINITY] }, ], }, - // β 系数(回归系数),浓度单位 µg/m³ + // %AR × 10 / 10.4,四舍五入到 1–10,11 表示 10+。 + indexScaleFactor: 10 / 10.4, + // NO2、O3 使用 ppb;PM2.5 使用 µg/m³。 betas: { NO2: 0.000871, O3: 0.000537, PM2_5: 0.000487, }, pollutants: { - NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: -1 }, - OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: -1 }, + NO2: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.880472698306222 }, + OZONE: { units: "PARTS_PER_BILLION", stpConversionFactor: 1.96189649169881 }, PM2_5: { units: "MICROGRAMS_PER_CUBIC_METER", stpConversionFactor: -1 }, }, }, @@ -2279,7 +2305,8 @@ export default class AirQuality { Console.info("☑️ ConvertUnit"); const friendlyUnits = AirQuality.Config.Units.Friendly; Console.info(`Convert ${amount} from ${friendlyUnits[from]} with ${fromStpConversionFactor} to ${friendlyUnits[to]} with ${toStpConversionFactor}`); - if (amount < 0) { + amount = typeof amount === "number" ? amount : typeof amount === "string" && amount.trim() ? Number(amount) : NaN; + if (!Number.isFinite(amount) || amount < 0) { Console.error("ConvertUnit", `Amount ${amount} < 0`); return -1; } @@ -2297,16 +2324,7 @@ export default class AirQuality { const isPpxFrom = ppx.includes(from); const isPpxTo = ppx.includes(to); const isBothPpx = isPpxFrom && isPpxTo; - if (isBothPpx && fromStpConversionFactor !== toStpConversionFactor) { - if (fromStpConversionFactor <= 0 || toStpConversionFactor <= 0) { - Console.error("ConvertUnit", "无效的STP conversion factor", `fromStpConversionFactor: ${fromStpConversionFactor}`, `toStpConversionFactor: ${toStpConversionFactor}`); - return -1; - } - - const intermediate = AirQuality.ConvertUnit(numAmount, from, ugm3, fromStpConversionFactor); - return AirQuality.ConvertUnit(intermediate, ugm3, to, -1, toStpConversionFactor); - } - + // ppm/ppb 都是摩尔分数,只改变十进制倍率,不经过质量浓度绕行。 if (!isBothPpx && isPpxFrom && fromStpConversionFactor <= 0) { Console.error("ConvertUnit", `fromStpConversionFactor ${fromStpConversionFactor} <= 0`); return -1; diff --git a/src/class/ColorfulClouds.mjs b/src/class/ColorfulClouds.mjs index 45cfaf7b..e1eea149 100644 --- a/src/class/ColorfulClouds.mjs +++ b/src/class/ColorfulClouds.mjs @@ -1,14 +1,18 @@ -import { Console, fetch, Lodash as _ } from "@nsnanocat/util"; -import Weather from "./Weather.mjs"; +// Modified by hhhoratioxu, 2026-10-19: bounded request coalescing and short-lived response caching. +import { Console } from "@nsnanocat/util"; +import fetch from "../function/fetchProvider.mjs"; +import providerNameToLogo from "../function/providerNameToLogo.mjs"; import AirQuality from "./AirQuality.mjs"; import ForecastNextHour from "./ForecastNextHour.mjs"; -import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import ProviderRequestCache, { providerErrorCode, readProviderJSON } from "./ProviderRequestCache.mjs"; +import Weather from "./Weather.mjs"; export default class ColorfulClouds { - constructor(parameters, token) { + constructor(parameters, token, requestCache = new ProviderRequestCache()) { + this.#requests = requestCache; this.Name = "ColorfulClouds"; this.Version = "4.3.0"; - Console.log(`🟧 ${this.Name} v${this.Version}`); + Console.info(`🟧 ${this.Name} v${this.Version}`); this.endpoint = `https://api.caiyunapp.com/v2.6/${token}/${parameters.longitude},${parameters.latitude}`; this.headers = { Referer: "https://caiyunapp.com/" }; this.token = token; @@ -27,8 +31,26 @@ export default class ColorfulClouds { #cache = { alert: {}, - realtime: {}, }; + #requests; + + #Fetch(request, kind) { + const product = new URL(request.url).pathname.split("/").at(-1); + return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { + fetcher: fetch, + includeJSON: true, + partition: [this.Name, this.version, this.language, this.country], + product, + group: [this.latitude, this.longitude, this.country], + kind: kind ?? (product === "minutely" ? "minutely" : /hourly|daily/.test(product) ? "forecast" : product === "location" ? "alerts" : "current"), + maximumTTLSeconds: product === "minutely" ? 30 : 300, + validate: body => { + if (product === "location") return !body?.error && Array.isArray(body?.alerts); + const result = body?.result; + return body?.status === "ok" && result?.[product]?.status === "ok" && (product !== "realtime" || kind !== "alerts" || (result.alert?.status === "ok" && Array.isArray(result.alert.content))); + }, + }); + } #Config = { Language: { @@ -206,25 +228,20 @@ export default class ColorfulClouds { }, }; - async #RealTime() { + async #RealTime(kind = "current") { Console.info("☑️ RealTime"); - - if (this.#cache.realtime?.result?.realtime?.status === "ok") { - Console.info("✅ RealTime", "Using cache"); - return this.#cache.realtime; - } + this.#cache.alert = {}; const request = { url: `${this.endpoint}/realtime?lang=${this.language}&alert=true`, headers: this.headers, }; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request, kind).then(response => readProviderJSON(response)); switch (body?.status) { case "ok": { switch (body?.result?.realtime?.status) { case "ok": { - this.#cache.realtime = body; break; } case "error": @@ -248,7 +265,7 @@ export default class ColorfulClouds { throw Error(JSON.stringify(body ?? {})); } } catch (error) { - Console.error(`RealTime: ${error}`); + Console.error(`RealTime: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ RealTime"); @@ -270,7 +287,7 @@ export default class ColorfulClouds { }; let forecastNextHour; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.status) { case "ok": switch (body?.result?.minutely?.status) { @@ -331,7 +348,7 @@ export default class ColorfulClouds { throw Error(JSON.stringify(body ?? {})); } } catch (error) { - Console.error(`Minutely: ${error}`); + Console.error(`Minutely: ${providerErrorCode(error)}`); } finally { //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); Console.info("✅ Minutely"); @@ -349,6 +366,7 @@ export default class ColorfulClouds { Console.info("☑️ WeatherAlert"); const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: "https://www.caiyunapp.com/h5", }, detailsUrl: `https://weatherkit.apple.com/alertDetails/index.html?ids=${this.latitude},${this.longitude}&lang=${encodeURIComponent(this.weatherKitLanguage)}&party=ColorfulClouds`, @@ -358,11 +376,12 @@ export default class ColorfulClouds { }; let weatherAlerts = failedWeatherAlerts; try { - await this.#RealTime(); + await this.#RealTime("alerts"); if (!Array.isArray(this.#cache.alert?.content)) throw Error(JSON.stringify(this.#cache.alert)); weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(this.#cache.alert) }; + if (!weatherAlerts.metadata?.temporarilyUnavailable || weatherAlerts !== failedWeatherAlerts) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { - Console.error(`WeatherAlert: ${error}`); + Console.error(`WeatherAlert: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlert"); } @@ -378,6 +397,7 @@ export default class ColorfulClouds { Console.info("☑️ WeatherAlertV3CAP"); const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: "https://www.caiyunapp.com/h5", }, detailsUrl: `https://weatherkit.apple.com/alertDetails/index.html?ids=${this.latitude},${this.longitude}&lang=${encodeURIComponent(this.weatherKitLanguage)}&party=ColorfulClouds`, @@ -396,16 +416,16 @@ export default class ColorfulClouds { }; let weatherAlerts = failedWeatherAlerts; try { - const response = await fetch(request); - const body = JSON.parse(response?.body ?? "{}"); + const response = await this.#Fetch(request); + const body = readProviderJSON(response); if (response?.ok === false) { Console.warn("WeatherAlertV3CAP", `upstreamStatus: ${response.statusCode ?? response.status}`); return failedWeatherAlerts; } if (!Array.isArray(body?.alerts)) throw Error(JSON.stringify(body?.error ?? body?.reason ?? body?.code ?? body)); - weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlertsV3CAP(body) }; + weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlertsV3CAP(body), metadata: { ...failedWeatherAlerts.metadata, temporarilyUnavailable: false } }; } catch (error) { - Console.error(`WeatherAlertV3CAP: ${error}`); + Console.error(`WeatherAlertV3CAP: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlertV3CAP"); } @@ -420,7 +440,7 @@ export default class ColorfulClouds { }; if (begin) request.url += `&begin=${begin}`; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.status) { case "ok": switch (body?.result?.hourly?.status) { @@ -439,7 +459,7 @@ export default class ColorfulClouds { throw Error(JSON.stringify(body ?? {})); } } catch (error) { - Console.error(`Hourly: ${error}`); + Console.error(`Hourly: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(this.airQuality, null, 2)}`); Console.info("✅ Hourly"); @@ -456,7 +476,7 @@ export default class ColorfulClouds { if (begin) request.url += `&begin=${begin}`; let forecastDaily; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.status) { case "ok": switch (body?.result?.daily?.status) { @@ -519,7 +539,7 @@ export default class ColorfulClouds { daytimeForecast: { forecastStart: timeStamp + 8 * 3600, // 8 hours forecastEnd: timeStamp + 8 * 3600 + 12 * 3600, // 8 + 12 hours - cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given @@ -543,7 +563,7 @@ export default class ColorfulClouds { overnightForecast: { forecastStart: timeStamp + 20 * 3600, // 20 hours forecastEnd: timeStamp + 20 * 3600 + 12 * 3600, // 20 + 12 hours - cloudCover: body?.result?.daily?.cloudrate?.[i]?.avg, + cloudCover: Math.round(body?.result?.daily?.cloudrate?.[i]?.avg * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given @@ -579,7 +599,7 @@ export default class ColorfulClouds { throw Error(JSON.stringify(body ?? {})); } } catch (error) { - Console.error(`Daily: ${error}`); + Console.error(`Daily: ${providerErrorCode(error)}`); } finally { //Console.debug(`Daily: ${JSON.stringify(Daily, null, 2)}`); Console.info("✅ Daily"); @@ -787,7 +807,7 @@ export default class ColorfulClouds { async YesterdayAirQuality(useUsa = true) { Console.info("☑️ YesterdayAirQuality"); - const yesterdayHourly = await this.#Hourly(1, Math.trunc((Date.now() - 864e5) / 1000)); + const yesterdayHourly = await this.#Hourly(1, Math.floor(Date.now() / 3600000) * 3600 - 86400); const scale = useUsa ? AirQuality.Config.Scales.EPA_NowCast : AirQuality.Config.Scales.HJ6332012; const particularAirQuality = { previousDayComparison: AirQuality.Config.CompareCategoryIndexes.UNKNOWN, @@ -839,7 +859,7 @@ export default class ColorfulClouds { return { metadata: this.#Metadata(hourly.result.server_time, hourly.location), hours: Array.from({ length: hourly.result.hourly.skycon.length }, (_, i) => ({ - cloudCover: hourly.result.hourly.cloudrate[i].value, + cloudCover: Math.round(hourly.result.hourly.cloudrate[i].value * 100), // cloudCoverHighAltPct: 0, // Not given // cloudCoverLowAltPct: 0, // Not given // cloudCoverMidAltPct: 0, // Not given diff --git a/src/class/ProviderRequestCache.mjs b/src/class/ProviderRequestCache.mjs new file mode 100644 index 00000000..3cac0b47 --- /dev/null +++ b/src/class/ProviderRequestCache.mjs @@ -0,0 +1,400 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { sha256 } from "@noble/hashes/sha2"; +import serializeProviderCache from "../function/serializeProviderCache.mjs"; + +// Application bounds, not claims about the host VPN process's memory limits. +export const REQUEST_CACHE_LIMITS = Object.freeze({ entries: 8, bodyCharacters: 96 * 1024, responseCharacters: 1024 * 1024, storageCharacters: 256 * 1024, identityCharacters: 8192, concurrent: 3, pending: 16, maximumTTLSeconds: 300 }); +export const REQUEST_CACHE_KEY = "WeatherKit.ProviderResponses.v1"; +export const PROVIDER_FAILURE_KEY = "WeatherKit.ProviderFailures.v1"; +export function requestCacheTTL(value = 60) { + if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && value.trim() === "") || !Number.isFinite(Number(value)) || Number(value) < 0) return 60; + return Math.min(300, Number(value)); +} +export function fingerprint(identity) { + const bytes = new Uint8Array(identity.length * 2); + for (let index = 0; index < identity.length; index++) { + const code = identity.charCodeAt(index); + bytes[index * 2] = code >>> 8; + bytes[index * 2 + 1] = code & 255; + } + return Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, "0")).join(""); +} +function nativeStore(key) { + if (typeof $loon !== "string" || typeof $persistentStore === "undefined") return undefined; + return { read: () => $persistentStore.read(key), write: value => $persistentStore.write(value, key) }; +} +function bounded(value, fallback, minimum, maximum) { + const number = Number(value); + return value === undefined || value === null || value === "" || !Number.isFinite(number) ? fallback : Math.max(minimum, Math.min(maximum, number)); +} +export class ProviderRequestError extends Error { + constructor(code) { + super(code === "queue_full" ? "Provider request queue is full" : code === "invalid" ? "Unsupported or oversized provider response" : `WeatherKit provider request: ${code}`); + this.name = "ProviderRequestError"; + this.code = code; + } +} +export function providerErrorCode(error) { + if (error instanceof ProviderRequestError) return error.code; + return /timeout|timed out|超时/i.test(String(error?.message ?? error ?? "")) ? "timeout" : "network"; +} +const copyResponse = response => ({ ...response, headers: { ...response?.headers } }); +const parsedJSON = Symbol("validated provider JSON"); +// Each consumer owns this parsed value. It is never shared through pending work or persisted. +export function readProviderJSON(response) { + return response && Object.hasOwn(response, parsedJSON) ? response[parsedJSON] : JSON.parse(response?.body ?? "{}"); +} + +export default class ProviderRequestCache { + #memory = new Map(); + #snapshot; + #inFlight = new Map(); + #waiting = []; + #active = 0; + #failures = new Map(); + #ttl; + #ttls; + #now; + #store; + #failureStore; + #startedAt; + #budget; + #cooldown; + #diagnostics; + #reported = false; + #stats = { requests: 0, coalesced: 0, memoryHits: 0, persistentHits: 0, stores: 0, failures: 0, cooldownSkips: 0, budgetSkips: 0, peakActive: 0, networkMs: 0, fallbacks: 0, backupAttempts: 0, backupSuccesses: 0, reasons: {}, failureReasons: {} }; + constructor({ ttlSeconds = 60, ttls = {}, now = Date.now, store = nativeStore(REQUEST_CACHE_KEY), failureStore = nativeStore(PROVIDER_FAILURE_KEY), budgetMs = 0, cooldownSeconds = 0, diagnostics = false } = {}) { + this.#ttl = requestCacheTTL(ttlSeconds); + this.#ttls = ttls; + this.#now = now; + this.#store = store; + this.#failureStore = failureStore; + this.#startedAt = now(); + this.#budget = bounded(budgetMs, 0, 0, 15000); + this.#cooldown = bounded(cooldownSeconds, 0, 0, 120); + this.#diagnostics = diagnostics === true || diagnostics === "true"; + this.#loadFailures(); + } + static fromSettings(settings = {}, options = {}) { + const p = settings.RequestCache ?? {}; + return new ProviderRequestCache({ + ttlSeconds: p.TTL ?? 60, + ttls: { forecast: bounded(p.ForecastTTL, 180, 0, 300), minutely: bounded(p.MinutelyTTL, 15, 0, 30), alerts: bounded(p.AlertsTTL, 15, 0, 30) }, + budgetMs: bounded(p.BudgetMs, 8000, 1000, 15000), + cooldownSeconds: bounded(p.CooldownSeconds, 30, 0, 120), + diagnostics: p.Diagnostics, + ...options, + }); + } + get stats() { + return { ...this.#stats, reasons: { ...this.#stats.reasons }, failureReasons: { ...this.#stats.failureReasons }, elapsedMs: Math.max(0, this.#now() - this.#startedAt) }; + } + noteBackup(success) { + this.#stats.backupAttempts++; + if (success) this.#stats.backupSuccesses++; + } + noteFallback(product = "unknown", reason = "unavailable") { + this.#stats.fallbacks++; + const key = /^(currentWeather|forecastDaily|forecastHourly|forecastNextHour|weatherAlerts|airQuality|request)$/.test(product) ? product : "other"; + const code = /^(unavailable|invalid|budget|cooldown|network|timeout|region|http|rate_limit|auth)$/.test(reason) ? reason : "unavailable"; + const label = `${key}:${code}`; + this.#stats.reasons[label] = (this.#stats.reasons[label] ?? 0) + 1; + } + report(label = "response") { + if (!this.#diagnostics || this.#reported || typeof $loon !== "string") return; + this.#reported = true; + // Fixed labels and numeric counters only: never include request identities, bodies or error messages. + console.log(`[WeatherKit] ${JSON.stringify({ phase: label === "request" ? "request" : "response", ...this.stats })}`); + } + #lifetime(kind, maximum) { + if (this.#ttl === 0) return 0; + const preferred = this.#ttls[kind] === undefined ? this.#ttl : requestCacheTTL(this.#ttls[kind]); + return Math.min(preferred, requestCacheTTL(maximum), kind === "alerts" || kind === "minutely" ? 30 : 300); + } + async fetch(request, { fetcher, partition = [], product = "", kind = "current", group, maximumTTLSeconds = 300, validate, format = "json", maximumResponseCharacters = REQUEST_CACHE_LIMITS.responseCharacters, cacheable = true, allowNotModified = false, includeJSON = false } = {}) { + if (typeof fetcher !== "function") throw new TypeError("Provider cache requires a fetch function"); + const method = String(request.method ?? "GET").toUpperCase(); + const eligible = cacheable && method === "GET" && request.body === undefined && request.bodyBytes === undefined; + const maximum = bounded(maximumResponseCharacters, REQUEST_CACHE_LIMITS.responseCharacters, 1, 2 * 1024 * 1024); + // Transport identity also protects uncached authentication requests. Only hashes persist. + const identity = this.#key(request, partition, product); + const scope = identity ? fingerprint(JSON.stringify(["validated-product.v2", identity, kind, format, maximum])) : undefined; + const key = eligible ? identity : undefined; + const ttl = this.#lifetime(kind, maximumTTLSeconds); + let parsed; + const valid = body => { + if (typeof body !== "string" || body.length > maximum || typeof validate !== "function") return false; + try { + const value = format === "text" ? body : JSON.parse(body); + if (validate(value) !== true) return false; + if (includeJSON && format !== "text") parsed = value; + return true; + } catch { + return false; + } + }; + const result = response => { + if (includeJSON && format !== "text") Object.defineProperty(response, parsedJSON, { value: parsed }); + return response; + }; + if (key && ttl > 0) { + const memory = this.#memory.get(key); + if (this.#fresh(memory, ttl) && valid(memory.body)) { + memory.lastUsedAt = this.#now(); + this.#remember(memory); + this.#stats.memoryHits++; + return result(this.#response(memory)); + } + this.#memory.delete(key); + const persisted = this.#read().find(entry => entry.key === key && this.#fresh(entry, ttl)); + if (persisted && valid(persisted.body)) { + persisted.lastUsedAt = this.#now(); + this.#remember(persisted); + this.#stats.persistentHits++; + return result(this.#response(persisted)); + } + } + if (scope && this.#cooling(scope)) { + this.#stats.cooldownSkips++; + throw new ProviderRequestError("cooldown"); + } + const owner = eligible && identity ? identity : Symbol("uncacheable request"); + let pending = this.#inFlight.get(owner); + if (pending) this.#stats.coalesced++; + else { + if (this.#inFlight.size >= REQUEST_CACHE_LIMITS.pending) throw new ProviderRequestError("queue_full"); + pending = { invalid: new Set(), saved: new Set() }; + pending.response = this.#schedule(() => this.#dispatch(request, fetcher, identity)); + this.#inFlight.set(owner, pending); + } + try { + // Share the native response, not the first consumer's validation outcome. + const response = await pending.response; + const body = response.body; + const code = Number(response.statusCode ?? response.status) === 304 && !allowNotModified ? "http" : !valid(body) ? "invalid" : undefined; + if (code) { + const failure = scope ?? owner; + if (!pending.invalid.has(failure)) { + pending.invalid.add(failure); + this.#failure(scope, code); + } + throw new ProviderRequestError(code); + } + if (scope) this.#success(scope); + if (key && ttl > 0 && Number(response.statusCode ?? response.status) !== 304 && !pending.saved.has(key)) { + pending.saved.add(key); + this.#save(key, response, body, ttl, group); + } + const output = copyResponse(response); + output.body = body; + return result(output); + } finally { + if (this.#inFlight.get(owner) === pending) this.#inFlight.delete(owner); + } + } + async #dispatch(request, fetcher, identity) { + if (identity && this.#cooling(identity)) { + this.#stats.cooldownSkips++; + throw new ProviderRequestError("cooldown"); + } + const remaining = this.#budget ? this.#budget - (this.#now() - this.#startedAt) : Number.POSITIVE_INFINITY; + if (remaining < 250 || (this.#budget > 0 && this.#now() < this.#startedAt)) { + this.#stats.budgetSkips++; + throw new ProviderRequestError("budget"); + } + const outgoing = typeof $loon === "string" ? { ...request, timeout: Math.max(1, Math.floor(Math.min(bounded(request.timeout, 5000, 1, 5000), remaining))) } : request; + const started = this.#now(); + let headers; + this.#stats.requests++; + try { + // Await the native callback. No detached timer, cancellation or early $done. + const response = await fetcher(outgoing); + headers = response?.headers; + const status = Number(response?.statusCode ?? response?.status); + if (!Number.isInteger(status)) throw new ProviderRequestError("invalid"); + if (status !== 304 && (response?.ok === false || !(status >= 200 && status < 300))) throw new ProviderRequestError(status === 429 ? "rate_limit" : status === 401 || status === 403 ? "auth" : "http"); + if (typeof response.body !== "string" || response.body.length > 2 * 1024 * 1024) throw new ProviderRequestError("invalid"); + if (identity) this.#success(identity); + return response; + } catch (error) { + const code = providerErrorCode(error); + this.#failure(identity, code, headers); + throw new ProviderRequestError(code); + } finally { + this.#stats.networkMs += Math.max(0, this.#now() - started); + } + } + #key(request, partition, product) { + try { + const fields = Object.entries(request) + .filter(([key]) => key !== "headers" && key !== "timeout") + .sort(([a], [b]) => a.localeCompare(b)); + const headers = Object.entries(request.headers ?? {}) + .map(([key, value]) => [key.toLowerCase(), value]) + .sort(([a, av], [b, bv]) => a.localeCompare(b) || String(av).localeCompare(String(bv))); + const identity = JSON.stringify([partition, product, fields, headers]); + return identity.length > REQUEST_CACHE_LIMITS.identityCharacters ? undefined : fingerprint(identity); + } catch { + return undefined; + } + } + async #schedule(work) { + if (this.#active >= REQUEST_CACHE_LIMITS.concurrent) await new Promise(resolve => this.#waiting.push(resolve)); + else this.#active++; + this.#stats.peakActive = Math.max(this.#stats.peakActive, this.#active); + try { + return await work(); + } finally { + const next = this.#waiting.shift(); + if (next) next(); + else this.#active--; + } + } + #fresh(entry, ttl = 300) { + const now = this.#now(); + return ( + entry && + /^[a-f0-9]{64}$/.test(entry.key) && + Number.isFinite(entry.storedAt) && + Number.isFinite(entry.expiresAt) && + entry.expiresAt <= entry.storedAt + 300000 && + Number.isInteger(entry.status) && + entry.status >= 200 && + entry.status < 300 && + typeof entry.body === "string" && + entry.body.length <= REQUEST_CACHE_LIMITS.bodyCharacters && + now >= entry.storedAt && + now < entry.expiresAt && + now - entry.storedAt < ttl * 1000 + ); + } + #read(force = false) { + if (!force && this.#snapshot) return this.#snapshot; + this.#snapshot = []; + if (!this.#store) return this.#snapshot; + try { + const raw = this.#store.read(); + if (typeof raw !== "string" || raw.length > REQUEST_CACHE_LIMITS.storageCharacters) return []; + const parsed = JSON.parse(raw); + if (parsed?.version !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 8) return []; + this.#snapshot = parsed.entries + .filter(entry => this.#fresh(entry)) + .map(({ key, storedAt, expiresAt, status, body, group, lastUsedAt }) => { + // Rereading storage must merge other executions' writes. When + // the text is identical, reuse the existing immutable body. + const remembered = this.#memory.get(key); + if (remembered?.body === body) body = remembered.body; + return { key, storedAt, expiresAt, status, body, group: /^[a-f0-9]{64}$/.test(group) ? group : key, lastUsedAt: Number.isFinite(lastUsedAt) && lastUsedAt >= storedAt && lastUsedAt <= this.#now() ? lastUsedAt : storedAt }; + }); + return this.#snapshot; + } catch { + return []; + } + } + #select(entries) { + // Round-robin by hashed city, with the most recently used city and products first. + const groups = new Map(); + for (const entry of [...entries].sort((a, b) => (b.lastUsedAt ?? b.storedAt) - (a.lastUsedAt ?? a.storedAt))) { + const key = entry.group ?? entry.key; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(entry); + } + const selected = []; + while (selected.length < 8 && groups.size) + for (const [key, queue] of groups) { + selected.push(queue.shift()); + if (!queue.length) groups.delete(key); + if (selected.length === 8) break; + } + return selected; + } + #save(key, response, body, ttl, group) { + if (body.length > REQUEST_CACHE_LIMITS.bodyCharacters) return; + const headers = Object.fromEntries(Object.entries(response.headers ?? {}).map(([name, value]) => [name.toLowerCase(), String(value)])); + if (/\b(no-store|no-cache)\b/i.test(headers["cache-control"] ?? "") || headers.vary?.split(",").some(value => value.trim() === "*")) return; + const maxAge = headers["cache-control"]?.match(/(?:^|,)\s*max-age\s*=\s*"?(\d+)/i)?.[1]; + if (maxAge !== undefined) ttl = Math.min(ttl, Math.max(0, Number(maxAge) - Math.max(0, Number(headers.age) || 0))); + if (ttl <= 0) return; + const storedAt = this.#now(), + entry = { key, storedAt, lastUsedAt: storedAt, expiresAt: storedAt + ttl * 1000, status: Number(response.statusCode ?? response.status), body, group: group ? fingerprint(JSON.stringify(group)) : key }; + this.#remember(entry); + if (!this.#store) return; + const merged = new Map(this.#read(true).map(item => [item.key, item])); + for (const item of this.#memory.values()) if (this.#fresh(item)) merged.set(item.key, item); + const entries = this.#select(merged.values()); + const serialized = serializeProviderCache(entries, REQUEST_CACHE_LIMITS.storageCharacters); + try { + if (this.#store.write(serialized) === true) this.#stats.stores++; + } catch { + /* Optional storage must not break a successful response. */ + } + } + #remember(entry) { + this.#memory.delete(entry.key); + this.#memory.set(entry.key, entry); + if (this.#memory.size > 8) this.#memory = new Map(this.#select(this.#memory.values()).map(item => [item.key, item])); + } + #response(entry) { + return { status: entry.status, statusCode: entry.status, ok: true, headers: {}, body: entry.body }; + } + #loadFailures() { + if (!this.#cooldown || !this.#failureStore) return; + try { + const raw = this.#failureStore.read(); + if (typeof raw !== "string" || raw.length > 8192) return; + const data = JSON.parse(raw); + if (data?.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 32) return; + for (const entry of data.entries) + if (/^[a-f0-9]{64}$/.test(entry.key) && Number.isFinite(entry.at) && entry.at <= this.#now() && this.#now() - entry.at < 120000 && Number.isFinite(entry.until) && entry.until <= entry.at + 120000 && Number.isInteger(entry.count) && entry.count > 0 && entry.count <= 2) + this.#failures.set(entry.key, { key: entry.key, at: entry.at, until: entry.until, count: entry.count }); + } catch { + /* Corrupt optional cooldown state is ignored. */ + } + } + #persistFailures() { + if (!this.#failureStore || !this.#cooldown) return; + // Merge other completed executions' state before writing; no transaction is assumed. + const own = new Map(this.#failures); + this.#loadFailures(); + for (const [key, value] of own) this.#failures.set(key, value); + const entries = [...this.#failures.values()] + .filter(e => this.#now() >= e.at && this.#now() - e.at < 120000) + .sort((a, b) => b.at - a.at) + .slice(0, 32); + this.#failures = new Map(entries.map(entry => [entry.key, entry])); + try { + this.#failureStore.write(JSON.stringify({ version: 1, entries })); + } catch { + /* A failed write can only cause a later extra request. */ + } + } + #cooling(key) { + const value = this.#failures.get(key); + return value && this.#now() >= value.at && this.#now() < value.until; + } + #failure(key, reason, headers = {}) { + this.#stats.failures++; + const code = /^(timeout|network|http|rate_limit|auth|invalid)$/.test(reason) ? reason : "network"; + this.#stats.failureReasons[code] = (this.#stats.failureReasons[code] ?? 0) + 1; + if (!key || !this.#cooldown) return; + const at = this.#now(), + previous = this.#failures.get(key), + count = previous && at >= previous.at && at - previous.at < 120000 ? Math.min(2, previous.count + 1) : 1; + const retry = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "retry-after")?.[1]; + const retrySeconds = Number.isFinite(Number(retry)) ? Number(retry) : Number.isFinite(Date.parse(retry)) ? (Date.parse(retry) - at) / 1000 : 0; + const seconds = reason === "rate_limit" ? Math.min(120, Math.max(this.#cooldown, retrySeconds)) : this.#cooldown; + this.#failures.set(key, { key, at, count, until: at + (count >= 2 || reason === "rate_limit" || reason === "auth" ? seconds * 1000 : 0) }); + this.#persistFailures(); + } + #success(key) { + if (this.#failures.has(key)) { + this.#failures.delete(key); + if (this.#failureStore) { + try { + const entries = [...this.#failures.values()]; + this.#failureStore.write(JSON.stringify({ version: 1, entries })); + } catch {} + } + } + } +} diff --git a/src/class/QWeather.mjs b/src/class/QWeather.mjs index b086d42b..f6f74733 100644 --- a/src/class/QWeather.mjs +++ b/src/class/QWeather.mjs @@ -1,16 +1,21 @@ -import { Lodash as _, Console, fetch, time } from "@nsnanocat/util"; +// Modified by hhhoratioxu, 2026-10-19: bounded request coalescing and short-lived response caching. +import { Lodash as _, Console, time } from "@nsnanocat/util"; import AirQuality from "../class/AirQuality.mjs"; +import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import utf8Length from "../function/utf8Length.mjs"; import ForecastNextHour from "./ForecastNextHour.mjs"; +import ProviderRequestCache, { providerErrorCode, readProviderJSON } from "./ProviderRequestCache.mjs"; import Weather from "./Weather.mjs"; export default class QWeather { static #MaximumWeatherAlertPageSize = 2 * 1024 * 1024; - constructor(parameters, token, host = "devapi.qweather.com") { + constructor(parameters, token, host = "devapi.qweather.com", requestCache = new ProviderRequestCache()) { + this.#requests = requestCache; this.Name = "QWeather"; this.Version = "5.3.1"; - Console.log(`🟧 ${this.Name} v${this.Version}`); + Console.info(`🟧 ${this.Name} v${this.Version}`); this.endpoint = `https://${host}`; this.headers = { "X-QW-Api-Key": token }; this.version = parameters.version; @@ -26,9 +31,34 @@ export default class QWeather { this.weatherKitLanguage = String(parameters.weatherKitLanguage ?? parameters.language ?? "").trim() || "zh-CN"; } - #cache = { - airQualityCurrent: {}, - }; + #requests; + + #Fetch(request) { + const path = new URL(request.url).pathname; + return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { + fetcher: fetch, + includeJSON: true, + partition: [this.Name, this.version, this.language, this.country], + product: path, + group: [this.latitude, this.longitude, this.country], + kind: /weatheralert/.test(path) ? "alerts" : /minutely/.test(path) ? "minutely" : /\/weather\/\d+[hd]$/.test(path) ? "forecast" : "current", + maximumTTLSeconds: path.includes("/minutely/") ? 30 : 300, + validate: body => { + if (body?.error) return false; + if (path.startsWith("/weatheralert/")) return Boolean(body?.metadata) && Array.isArray(body?.alerts); + if (path.startsWith("/airquality/")) return Array.isArray(body?.pollutants) && Array.isArray(body?.indexes) && body.indexes.some(index => typeof index?.code === "string" && index.aqi !== null && String(index.aqi).trim() !== "" && Number.isFinite(Number(index.aqi))); + if (body?.code !== "200") return false; + if (path.startsWith("/geo/")) return Array.isArray(body.location) && body.location.length > 0; + if (path.includes("/minutely/")) return Array.isArray(body.minutely) && body.minutely.length > 0; + if (path.endsWith("/historical/air")) return Array.isArray(body.airHourly) && body.airHourly.length > 0; + if (path.endsWith("/weather/now")) return body.now?.temp !== undefined && body.now.temp !== null && String(body.now.temp).trim() !== "" && Number.isFinite(Number(body.now.temp)); + if (path.endsWith("/now")) return Boolean(body.now && typeof body.now === "object"); + if (/\/weather\/\d+h$/.test(path)) return Array.isArray(body.hourly) && body.hourly.length > 0; + if (/\/weather\/\d+d$/.test(path)) return Array.isArray(body.daily) && body.daily.length > 0; + return false; + }, + }); + } #Config = { Language: { @@ -77,20 +107,173 @@ export default class QWeather { // QWeather 没有提供 CAP category,本表按官方事件名称映射;具体类别必须先于 Met 匹配。 // QWeather does not provide CAP category; this table maps official event names, with specific categories matched before Met. EventCategories: [ - { category: "Geo", codes: [[1013, 1013], [1037, 1037], [1241, 1251], [1603, 1603], [2032, 2032], [2159, 2159], [2163, 2163], [2320, 2323], [2348, 2348], [2363, 2363], [2373, 2373], [2378, 2378], [2399, 2400], [3140, 3140], [3144, 3144]] }, - { category: "Safety", codes: [[1044, 1045], [1218, 1218], [2419, 2420], [2713, 2713]] }, + { + category: "Geo", + codes: [ + [1013, 1013], + [1037, 1037], + [1241, 1251], + [1603, 1603], + [2032, 2032], + [2159, 2159], + [2163, 2163], + [2320, 2323], + [2348, 2348], + [2363, 2363], + [2373, 2373], + [2378, 2378], + [2399, 2400], + [3140, 3140], + [3144, 3144], + ], + }, + { + category: "Safety", + codes: [ + [1044, 1045], + [1218, 1218], + [2419, 2420], + [2713, 2713], + ], + }, { category: "Security", codes: [] }, { category: "Rescue", codes: [] }, - { category: "Fire", codes: [[1025, 1026], [1041, 1041], [1077, 1077], [1084, 1084], [1605, 1605], [2005, 2005], [2132, 2132], [2158, 2158], [2192, 2192], [2207, 2207], [2302, 2302], [2349, 2349], [2414, 2414], [2743, 2743], [3139, 3139]] }, - { category: "Health", codes: [[1024, 1024], [1042, 1042], [1066, 1066], [1068, 1069], [1071, 1072], [1082, 1082], [1210, 1210], [2851, 2851]] }, - { category: "Env", codes: [[1029, 1029], [1032, 1032], [1067, 1067], [1074, 1074], [1217, 1217], [1271, 1274], [2202, 2202], [2374, 2374], [2389, 2389], [2413, 2413], [2527, 2527]] }, - { category: "Transport", codes: [[1021, 1021], [1046, 1046], [1057, 1057], [2077, 2078], [2300, 2301], [2328, 2328], [2360, 2360], [2375, 2376], [2385, 2388], [2415, 2415], [2554, 2554], [2722, 2723], [2791, 2797]] }, - { category: "Infra", codes: [[1081, 1081], [1203, 1204], [1216, 1216], [1221, 1221]] }, + { + category: "Fire", + codes: [ + [1025, 1026], + [1041, 1041], + [1077, 1077], + [1084, 1084], + [1605, 1605], + [2005, 2005], + [2132, 2132], + [2158, 2158], + [2192, 2192], + [2207, 2207], + [2302, 2302], + [2349, 2349], + [2414, 2414], + [2743, 2743], + [3139, 3139], + ], + }, + { + category: "Health", + codes: [ + [1024, 1024], + [1042, 1042], + [1066, 1066], + [1068, 1069], + [1071, 1072], + [1082, 1082], + [1210, 1210], + [2851, 2851], + ], + }, + { + category: "Env", + codes: [ + [1029, 1029], + [1032, 1032], + [1067, 1067], + [1074, 1074], + [1217, 1217], + [1271, 1274], + [2202, 2202], + [2374, 2374], + [2389, 2389], + [2413, 2413], + [2527, 2527], + ], + }, + { + category: "Transport", + codes: [ + [1021, 1021], + [1046, 1046], + [1057, 1057], + [2077, 2078], + [2300, 2301], + [2328, 2328], + [2360, 2360], + [2375, 2376], + [2385, 2388], + [2415, 2415], + [2554, 2554], + [2722, 2723], + [2791, 2797], + ], + }, + { + category: "Infra", + codes: [ + [1081, 1081], + [1203, 1204], + [1216, 1216], + [1221, 1221], + ], + }, { category: "CBRNE", codes: [] }, - { category: "Other", codes: [[2166, 2166], [3106, 3106], [3147, 3147], [9999, 9999]] }, + { + category: "Other", + codes: [ + [2166, 2166], + [3106, 3106], + [3147, 3147], + [9999, 9999], + ], + }, { category: "Met", - codes: [[1001, 1089], [1201, 1221], [1241, 1251], [1271, 1274], [1601, 1610], [1701, 1710], [1801, 1805], [2001, 2007], [2029, 2033], [2050, 2054], [2070, 2085], [2100, 2109], [2111, 2111], [2120, 2135], [2150, 2150], [2152, 2168], [2190, 2193], [2200, 2205], [2207, 2221], [2300, 2309], [2311, 2328], [2330, 2333], [2341, 2341], [2343, 2343], [2345, 2346], [2348, 2400], [2409, 2409], [2411, 2426], [2501, 2502], [2521, 2532], [2550, 2554], [2581, 2581], [2601, 2620], [2641, 2641], [2713, 2713], [2722, 2723], [2743, 2743], [2749, 2749], [2751, 2753], [2755, 2756], [2791, 2797], [2801, 2804], [2839, 2853], [2873, 2874], [3101, 3107], [3131, 3148]], + codes: [ + [1001, 1089], + [1201, 1221], + [1241, 1251], + [1271, 1274], + [1601, 1610], + [1701, 1710], + [1801, 1805], + [2001, 2007], + [2029, 2033], + [2050, 2054], + [2070, 2085], + [2100, 2109], + [2111, 2111], + [2120, 2135], + [2150, 2150], + [2152, 2168], + [2190, 2193], + [2200, 2205], + [2207, 2221], + [2300, 2309], + [2311, 2328], + [2330, 2333], + [2341, 2341], + [2343, 2343], + [2345, 2346], + [2348, 2400], + [2409, 2409], + [2411, 2426], + [2501, 2502], + [2521, 2532], + [2550, 2554], + [2581, 2581], + [2601, 2620], + [2641, 2641], + [2713, 2713], + [2722, 2723], + [2743, 2743], + [2749, 2749], + [2751, 2753], + [2755, 2756], + [2791, 2797], + [2801, 2804], + [2839, 2853], + [2873, 2874], + [3101, 3107], + [3131, 3148], + ], }, ], }, @@ -100,7 +283,7 @@ export default class QWeather { }, }; - static async GetLocationsGrid(qweatherCache, setCache) { + static async GetLocationsGrid(qweatherCache, setCache, runtime = new ProviderRequestCache()) { Console.info("☑️ GetLocationsGrid"); const locationsGrid = qweatherCache?.locationsGrid; // cache within 30 days @@ -109,10 +292,13 @@ export default class QWeather { return locationsGrid.data; } else { Console.info("⚠️ GetLocationsGrid", "Cache not found or stale, fetching..."); - const response = await fetch({ - headers: locationsGrid?.etag ? { "If-None-Match": locationsGrid?.etag } : undefined, - url: "https://raw.githubusercontent.com/NSRingo/QWeather-Location-Grid/refs/heads/main/data/qweather-china-city-list-grid.json", - }); + const response = await runtime.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", + }, + { fetcher: fetch, cacheable: false, format: "text", allowNotModified: true, validate: () => true, maximumResponseCharacters: 2 * 1024 * 1024 }, + ); if (response.status === 304) { Console.info("✅ GetLocationsGrid", "Cache not modified"); @@ -120,7 +306,7 @@ export default class QWeather { return locationsGrid.data; } - const newLocationsGrid = JSON.parse(response.body); + const newLocationsGrid = readProviderJSON(response); setCache({ ...qweatherCache, locationsGrid: { etag: response.headers.ETag, lastUpdated: Date.now(), data: newLocationsGrid }, @@ -323,48 +509,43 @@ export default class QWeather { * @param {Record} requestHeaders 原请求头 / Original request headers. * @returns {Promise<{alerts: Array, areaName: string, source: string}>} 来源集合 / Source collection. */ - static async FetchWeatherAlertPage(identifier, language = "zh-CN", requestHeaders = {}) { + static async FetchWeatherAlertPage(identifier, language = "zh-CN", requestHeaders = {}, runtime = new ProviderRequestCache()) { const sourceUrl = QWeather.BuildWeatherAlertPageURL(identifier, language); const normalizedHeaders = Object.fromEntries(Object.entries(requestHeaders).map(([key, value]) => [key.toLowerCase(), value])); - const sourceHeaders = { - Accept: "text/html,application/xhtml+xml", - "Accept-Language": normalizedHeaders["accept-language"] ?? language, - Referer: "https://www.qweather.com/", - }; + const sourceHeaders = { Accept: "text/html,application/xhtml+xml", "Accept-Language": normalizedHeaders["accept-language"] ?? language, Referer: "https://www.qweather.com/" }; if (normalizedHeaders["user-agent"]) sourceHeaders["User-Agent"] = normalizedHeaders["user-agent"]; - - Console.info("☑️ QWeather.FetchWeatherAlertPage", `url: ${sourceUrl}`, `language: ${language}`); - const sourceResponse = await fetch({ - url: sourceUrl.toString(), - headers: sourceHeaders, - "auto-cookie": false, - }); - const contentType = sourceResponse.headers?.["Content-Type"] ?? sourceResponse.headers?.["content-type"] ?? ""; - Console.info("QWeather.FetchWeatherAlertPage", `status: ${sourceResponse.statusCode ?? sourceResponse.status}`, `contentType: ${contentType || "undefined"}`); - if (!sourceResponse.ok) { - Console.warn("QWeather.FetchWeatherAlertPage", `upstreamStatus: ${sourceResponse.statusCode ?? sourceResponse.status}`); - return { alerts: [], areaName: "", source: "QWeather" }; - } - if (!contentType.toLowerCase().includes("text/html")) { - Console.warn("QWeather.FetchWeatherAlertPage", `unexpectedContentType: ${contentType || "undefined"}`); - return { alerts: [], areaName: "", source: "QWeather" }; + const unavailable = { alerts: [], areaName: "", source: "QWeather", pageValid: false }; + let response; + try { + response = await runtime.fetch( + { url: sourceUrl.toString(), headers: sourceHeaders, "auto-cookie": false }, + { + partition: ["QWeatherWeb", language], + includeJSON: true, + product: "weather-alert-page.v1", + kind: "alerts", + maximumTTLSeconds: 30, + group: [identifier], + validate: body => body?.pageValid === true && Array.isArray(body.alerts) && typeof body.areaName === "string", + fetcher: async request => { + const source = await fetch(request); + const type = String(source.headers?.["Content-Type"] ?? source.headers?.["content-type"] ?? "").toLowerCase(); + const html = typeof source.body === "string" ? source.body : ""; + if (html.length > QWeather.#MaximumWeatherAlertPageSize || utf8Length(html, QWeather.#MaximumWeatherAlertPageSize) > QWeather.#MaximumWeatherAlertPageSize) throw new RangeError("QWeather alert page is too large"); + const recognized = /\bc-submenu__location\b/.test(html) && /c-city-warning|暂无预警|无生效预警|No(?: active)? (?:weather )?warnings/i.test(html); + const page = source.ok && type.includes("text/html") && recognized ? { ...QWeather.ExtractWeatherAlertPage(html), pageValid: true } : unavailable; + // Persist only parsed alert records, not an entire HTML document. + return { ...source, body: JSON.stringify(page) }; + }, + }, + ); + } catch { + return { ...unavailable, unavailable: true, metadata: { temporarilyUnavailable: true } }; } - const html = String(sourceResponse.body ?? ""); - const sourceSize = new TextEncoder().encode(html).byteLength; - Console.debug("QWeather.FetchWeatherAlertPage", `bodyBytes: ${sourceSize}`); - if (sourceSize > QWeather.#MaximumWeatherAlertPageSize) throw new RangeError("QWeather alert page is too large"); - - const extracted = QWeather.ExtractWeatherAlertPage(html); - Console.info("✅ QWeather.FetchWeatherAlertPage", `alerts: ${extracted.alerts.length}`, `source: ${extracted.source}`); + const parsed = readProviderJSON(response); + if (!response.ok || parsed.pageValid !== true) return { ...unavailable, unavailable: true, metadata: { temporarilyUnavailable: true } }; const areaId = identifier.match(/-(\d+)$/)?.[1]; - return { - ...extracted, - alerts: extracted.alerts.map(alert => ({ - ...alert, - ...(areaId && !alert.areaId ? { areaId } : {}), - ...(extracted.areaName && !alert.areaName ? { areaName: extracted.areaName } : {}), - })), - }; + return { alerts: parsed.alerts.map(alert => ({ ...alert, ...(areaId && !alert.areaId ? { areaId } : {}), ...(parsed.areaName && !alert.areaName ? { areaName: parsed.areaName } : {}) })), areaName: parsed.areaName, source: parsed.source, metadata: { temporarilyUnavailable: false } }; } async GeoAPI(path = "city/lookup") { @@ -375,7 +556,7 @@ export default class QWeather { }; let metadata; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": metadata = { @@ -390,9 +571,9 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`GeoAPI: ${error}`); + Console.error(`GeoAPI: ${providerErrorCode(error)}`); } finally { - Console.debug(`metadata: ${JSON.stringify(metadata, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`metadata: ${JSON.stringify(metadata, null, 2)}`); Console.info("✅ GeoAPI"); } return metadata; @@ -407,6 +588,7 @@ export default class QWeather { Console.info("☑️ WeatherAlert"); const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: "https://developer.qweather.com/attribution.html", }, detailsUrl: `https://weatherkit.apple.com/alertDetails/index.html?ids=${this.latitude},${this.longitude}&lang=${encodeURIComponent(this.weatherKitLanguage)}&party=QWeather`, @@ -423,16 +605,17 @@ export default class QWeather { }; let weatherAlerts = failedWeatherAlerts; try { - const response = await fetch(request); - const body = JSON.parse(response?.body ?? "{}"); + const response = await this.#Fetch(request); + const body = readProviderJSON(response); if (response?.ok === false) { Console.warn("WeatherAlert", `upstreamStatus: ${response.statusCode ?? response.status}`); return failedWeatherAlerts; } if (!body?.metadata || !Array.isArray(body?.alerts)) throw Error(JSON.stringify(body?.error ?? body?.code ?? body)); weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(body) }; + if (!weatherAlerts.metadata?.temporarilyUnavailable || weatherAlerts !== failedWeatherAlerts) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { - Console.error(`WeatherAlert: ${error}`); + Console.error(`WeatherAlert: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlert"); } @@ -454,6 +637,7 @@ export default class QWeather { } const failedWeatherAlerts = { metadata: { + temporarilyUnavailable: true, attributionUrl: String(url), }, detailsUrl: QWeather.BuildAppleAlertDetailsURL(identifier, this.weatherKitLanguage), @@ -463,9 +647,11 @@ export default class QWeather { }; let weatherAlerts = failedWeatherAlerts; try { - weatherAlerts = { ...failedWeatherAlerts, ...(await QWeather.FetchWeatherAlertPage(identifier, this.weatherKitLanguage)) }; + const source = await QWeather.FetchWeatherAlertPage(identifier, this.weatherKitLanguage, {}, this.#requests); + weatherAlerts = { ...failedWeatherAlerts, ...source, metadata: { ...failedWeatherAlerts.metadata, ...source.metadata } }; + if (weatherAlerts !== failedWeatherAlerts && !weatherAlerts.unavailable) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { - Console.error(`WeatherAlertWeb: ${error}`); + Console.error(`WeatherAlertWeb: ${providerErrorCode(error)}`); } finally { Console.info("✅ WeatherAlertWeb"); } @@ -480,7 +666,7 @@ export default class QWeather { }; let currentWeather; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -524,7 +710,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`WeatherNow: ${error}`); + Console.error(`WeatherNow: ${providerErrorCode(error)}`); } finally { //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); Console.info("✅ WeatherNow"); @@ -540,7 +726,7 @@ export default class QWeather { }; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -581,7 +767,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`AirNow: ${error}`); + Console.error(`AirNow: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AirNow"); @@ -592,27 +778,21 @@ export default class QWeather { async #AirQualityCurrent() { Console.info("☑️ AirQualityCurrent"); - if (this.#cache.airQualityCurrent?.metadata?.tag && !this.#cache.airQualityCurrent?.error) { - Console.info("✅ AirQualityCurrent", "Using cache"); - return this.#cache.airQualityCurrent; - } - const request = { url: `${this.endpoint}/airquality/v1/current/${this.latitude}/${this.longitude}`, headers: this.headers, }; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.error) { case undefined: { - this.#cache.airQualityCurrent = body; return body; } default: throw Error(JSON.stringify(body?.error, null, 2)); } } catch (error) { - Console.error(`AirQualityCurrent: ${error}`); + Console.error(`AirQualityCurrent: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AirQualityCurrent"); @@ -634,7 +814,7 @@ export default class QWeather { }; let forecastNextHour; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -659,7 +839,7 @@ export default class QWeather { forecastEnd: 0, forecastStart: minuteStemp, minutes: body?.minutely - ?.map((minutely, index) => { + ?.map(minutely => { const minute = { perceivedPrecipitationIntensity: 0, precipitationChance: 0, @@ -695,7 +875,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`Minutely: ${error}`); + Console.error(`Minutely: ${providerErrorCode(error)}`); } finally { //Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); Console.info("✅ Minutely"); @@ -711,7 +891,7 @@ export default class QWeather { }; let forecastHourly; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -774,7 +954,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`Hourly: ${error}`); + Console.error(`Hourly: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(forecastHourly, null, 2)}`); Console.info("✅ Hourly"); @@ -790,7 +970,7 @@ export default class QWeather { }; let forecastDaily; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { const timeStamp = (Date.now() / 1000) | 0; @@ -919,7 +1099,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`Daily: ${error}`); + Console.error(`Daily: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(forecastDaily, null, 2)}`); Console.info("✅ Daily"); @@ -934,7 +1114,7 @@ export default class QWeather { headers: this.headers, }; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.code) { case "200": { return body; @@ -951,7 +1131,7 @@ export default class QWeather { throw Error(body?.code); } } catch (error) { - Console.error(`HistoricalAir: ${error}`); + Console.error(`HistoricalAir: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ HistoricalAir"); @@ -1051,7 +1231,7 @@ export default class QWeather { */ #CreatePollutants(pollutantsObj, scaleCode) { Console.info("☑️ CreatePollutants"); - Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); // TODO: what is ppmC? https://dev.qweather.com/docs/resource/air-info/#pollutants const pollutants = pollutantsObj @@ -1086,7 +1266,7 @@ export default class QWeather { */ #CreatePollutantsV7(pollutantsObj) { Console.info("☑️ CreatePollutantsV7"); - Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`pollutantsObj: ${JSON.stringify(pollutantsObj)}`); const { mgm3, ugm3 } = AirQuality.Config.Units.WeatherKit; const pollutants = Object.entries(pollutantsObj) @@ -1247,7 +1427,7 @@ export default class QWeather { } async YesterdayAirQuality(locationInfo) { - Console.info("☑️ YesterdayAirQuality", `locationInfo ${JSON.stringify(locationInfo)}`); + if (Console.logLevel === "INFO" || Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.info("☑️ YesterdayAirQuality", `locationInfo ${JSON.stringify(locationInfo)}`); const failedAirQuality = { metadata: this.#Metadata(undefined, undefined, true), categoryIndex: -1, @@ -1278,7 +1458,7 @@ export default class QWeather { const pollutants = this.#CreatePollutantsV7(historicalAir.airHourly[hour]); Console.debug(`hour: ${hour}`, `index: ${index}`); - Console.info("✅ YesterdayAirQuality", `pollutants: ${JSON.stringify(pollutants)}`, `categoryIndex: ${categoryIndex}`); + if (Console.logLevel === "INFO" || Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.info("✅ YesterdayAirQuality", `pollutants: ${JSON.stringify(pollutants)}`, `categoryIndex: ${categoryIndex}`); return { metadata: this.#Metadata(historicalAir.fxLink), categoryIndex, diff --git a/src/class/WAQI.mjs b/src/class/WAQI.mjs index 9eed0343..f079a558 100644 --- a/src/class/WAQI.mjs +++ b/src/class/WAQI.mjs @@ -1,12 +1,16 @@ -import { Console, fetch } from "@nsnanocat/util"; +// Modified by hhhoratioxu, 2026-10-19: cache weather data only; token endpoints remain uncached. +import { Console } from "@nsnanocat/util"; import AirQuality from "../class/AirQuality.mjs"; +import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import ProviderRequestCache, { providerErrorCode, readProviderJSON } from "./ProviderRequestCache.mjs"; export default class WAQI { - constructor(parameters, token) { + constructor(parameters, token, requestCache = new ProviderRequestCache()) { + this.#requests = requestCache; this.Name = "WAQI"; this.Version = "1.4.3"; - Console.log(`🟧 ${this.Name} v${this.Version}`); + Console.info(`🟧 ${this.Name} v${this.Version}`); this.headers = { Accept: "application/json" }; this.token = token; this.version = parameters.version; @@ -16,6 +20,23 @@ export default class WAQI { this.country = parameters.country; } + #requests; + + #Fetch(request) { + return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { + fetcher: fetch, + includeJSON: true, + partition: [this.Name, this.version, this.language, this.country], + group: [this.latitude, this.longitude, this.country], + product: "air-quality", + validate: body => { + if (request.url.includes("/mapq/")) return body?.status !== "error" && Array.isArray(body?.d) && body.d.length > 0; + if (request.url.includes("/mapq2/")) return body?.status === "ok" && Array.isArray(body?.data?.stations) && body.data.stations.length > 0; + return body?.status === "ok" && Boolean(body?.data?.city); + }, + }); + } + #Configs = { Pollutants: { co: "CO", @@ -56,7 +77,7 @@ export default class WAQI { }; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (mapqVersion) { case "mapq": switch (body?.status) { @@ -121,7 +142,7 @@ export default class WAQI { break; } } catch (error) { - Console.error(`Nearest: ${error}`); + Console.error(`Nearest: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ Nearest"); @@ -137,7 +158,15 @@ export default class WAQI { }; let token; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#requests + .fetch(request, { + fetcher: fetch, + cacheable: false, + partition: [this.Name, this.version, this.language, this.country], + product: "token", + validate: value => value?.rxs?.status === "ok" && value.rxs.obs?.[0]?.status === "ok" && typeof value.rxs.obs[0].msg?.token === "string" && value.rxs.obs[0].msg.token.trim().length > 0, + }) + .then(response => readProviderJSON(response)); switch (body?.status) { case "error": throw JSON.stringify({ status: body?.status, reason: body?.data }); @@ -160,7 +189,7 @@ export default class WAQI { break; } } catch (error) { - Console.error(`Token: ${error}`); + Console.error(`Token: ${providerErrorCode(error)}`); } finally { //Console.debug(`token: ${token}`); Console.info("✅ Token"); @@ -169,15 +198,23 @@ export default class WAQI { } async AQI(stationId = Number(), token = this.token) { - Console.info("☑️ AQI", `stationId: ${stationId}`, `token: ${token}`); + Console.info("☑️ AQI", `stationId: ${stationId}`); const request = { url: `https://api.waqi.info/api/feed/@${stationId}/aqi.json`, headers: this.headers, - body: `token=${token}&id=${stationId}`, + body: `token=${encodeURIComponent(token)}&id=${encodeURIComponent(stationId)}`, }; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#requests + .fetch(request, { + fetcher: fetch, + cacheable: false, + partition: [this.Name, this.version, this.language, this.country], + product: "authenticated-air-quality", + validate: value => value?.rxs?.status === "ok" && value.rxs.obs?.[0]?.status === "ok" && Boolean(value.rxs.obs[0].msg?.city) && value.rxs.obs[0].msg.aqi !== null && String(value.rxs.obs[0].msg.aqi).trim() !== "" && Number.isFinite(Number(value.rxs.obs[0].msg.aqi)), + }) + .then(response => readProviderJSON(response)); switch (body?.status) { case "error": throw JSON.stringify({ status: body?.status, reason: body?.data }); @@ -220,7 +257,7 @@ export default class WAQI { break; } } catch (error) { - Console.error(`AQI: ${error}`); + Console.error(`AQI: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AQI"); @@ -237,7 +274,7 @@ export default class WAQI { if (stationId) request.url = `https://api2.waqi.info/feed/@${stationId}/?token=${token}`; let airQuality; try { - const body = await fetch(request).then(response => JSON.parse(response?.body ?? "{}")); + const body = await this.#Fetch(request).then(response => readProviderJSON(response)); switch (body?.status) { case "ok": { const timeStamp = (Date.now() / 1000) | 0; @@ -266,7 +303,7 @@ export default class WAQI { throw JSON.stringify({ status: body?.status, reason: body?.data }); } } catch (error) { - Console.error(`AQI2: ${error}`); + Console.error(`AQI2: ${providerErrorCode(error)}`); } finally { //Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); Console.info("✅ AQI2"); diff --git a/src/class/WeatherKit2.mjs b/src/class/WeatherKit2.mjs index 09db4f40..2e7c438a 100644 --- a/src/class/WeatherKit2.mjs +++ b/src/class/WeatherKit2.mjs @@ -1,6 +1,8 @@ +import "../function/ensureTextEncoder.mjs"; import { FlatBufferRootProcessor } from "@nsnanocat/flatbuffer-root"; import * as WK2 from "@nsringo/weatherkit"; import database from "../function/database.mjs"; +import { decodedVectorLength, newDecodeBudget } from "../function/weatherMemoryLimits.mjs"; /** * 使用 Weather 根模型和产品 codec 配置通用根表处理器。 @@ -24,6 +26,15 @@ const WeatherKit2 = new FlatBufferRootProcessor({ rootClass: WK2.Weather, }); +// O(1) table presence probe; no minute vector decoding or copying. +export function hasForecastNextHour(byteBuffer) { + try { + return Boolean(WK2.Weather.getRootAsWeather(byteBuffer).forecastNextHour()); + } catch { + return false; + } +} + export default WeatherKit2; function encodeAirQuality(builder, data) { @@ -428,6 +439,7 @@ function encodeLocationInfo(builder, data) { } function decodeAirQuality(data) { + const budget = newDecodeBudget(); const AirQualityData = data; data = { metadata: decodeMetadata(AirQualityData.metadata()), @@ -439,7 +451,7 @@ function decodeAirQuality(data) { primaryPollutant: WK2.PollutantType[AirQualityData.primaryPollutant()], scale: AirQualityData.scale(), }; - for (let i = 0; i < AirQualityData.pollutantsLength(); i++) + for (let i = 0, count = decodedVectorLength(AirQualityData.pollutantsLength(), budget); i < count; i++) data.pollutants.push({ amount: AirQualityData.pollutants(i).amount(), pollutantType: WK2.PollutantType[AirQualityData.pollutants(i).pollutantType()], @@ -449,6 +461,7 @@ function decodeAirQuality(data) { } function decodeCurrentWeather(data) { + const budget = newDecodeBudget(); const CurrentWeatherData = data; data = { metadata: decodeMetadata(CurrentWeatherData.metadata()), @@ -492,7 +505,7 @@ function decodeCurrentWeather(data) { windGust: CurrentWeatherData.windGust(), windSpeed: CurrentWeatherData.windSpeed(), }; - for (let i = 0; i < CurrentWeatherData.precipitationAmountNext1hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountNext1hByTypeLength(), budget); i < count; i++) data.precipitationAmountNext1hByType.push({ expected: CurrentWeatherData.precipitationAmountNext1hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).expectedSnow(), @@ -500,7 +513,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountNext1hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext1hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountNext24hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountNext24hByTypeLength(), budget); i < count; i++) data.precipitationAmountNext24hByType.push({ expected: CurrentWeatherData.precipitationAmountNext24hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).expectedSnow(), @@ -508,7 +521,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountNext24hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext24hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountNext6hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountNext6hByTypeLength(), budget); i < count; i++) data.precipitationAmountNext6hByType.push({ expected: CurrentWeatherData.precipitationAmountNext6hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).expectedSnow(), @@ -516,7 +529,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountNext6hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountNext6hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious1hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountPrevious1hByTypeLength(), budget); i < count; i++) data.precipitationAmountPrevious1hByType.push({ expected: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).expectedSnow(), @@ -524,7 +537,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountPrevious1hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious1hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious24hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountPrevious24hByTypeLength(), budget); i < count; i++) data.precipitationAmountPrevious24hByType.push({ expected: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).expectedSnow(), @@ -532,7 +545,7 @@ function decodeCurrentWeather(data) { minimumSnow: CurrentWeatherData.precipitationAmountPrevious24hByType(i).minimumSnow(), precipitationType: WK2.PrecipitationType[CurrentWeatherData.precipitationAmountPrevious24hByType(i).precipitationType()], }); - for (let i = 0; i < CurrentWeatherData.precipitationAmountPrevious6hByTypeLength(); i++) + for (let i = 0, count = decodedVectorLength(CurrentWeatherData.precipitationAmountPrevious6hByTypeLength(), budget); i < count; i++) data.precipitationAmountPrevious6hByType.push({ expected: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expected(), expectedSnow: CurrentWeatherData.precipitationAmountPrevious6hByType(i).expectedSnow(), @@ -544,179 +557,184 @@ function decodeCurrentWeather(data) { } function decodeForecastDaily(data) { + const budget = newDecodeBudget(); const DailyForecastData = data; data = { metadata: decodeMetadata(DailyForecastData.metadata()), days: [], }; - for (let i = 0; i < DailyForecastData.daysLength(); i++) { + for (let i = 0, count = decodedVectorLength(DailyForecastData.daysLength(), budget); i < count; i++) { + const sourceDay = DailyForecastData.days(i); const day = { - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).conditionCode()], - forecastEnd: DailyForecastData.days(i).forecastEnd(), - forecastStart: DailyForecastData.days(i).forecastStart(), - humidityMax: DailyForecastData.days(i).humidityMax(), - humidityMin: DailyForecastData.days(i).humidityMin(), - maxUvIndex: DailyForecastData.days(i).maxUvIndex(), - moonPhase: WK2.MoonPhase[DailyForecastData.days(i).moonPhase()], - moonrise: DailyForecastData.days(i).moonrise(), - moonset: DailyForecastData.days(i).moonset(), - precipitationAmount: DailyForecastData.days(i).precipitationAmount(), + conditionCode: WK2.WeatherCondition[sourceDay.conditionCode()], + forecastEnd: sourceDay.forecastEnd(), + forecastStart: sourceDay.forecastStart(), + humidityMax: sourceDay.humidityMax(), + humidityMin: sourceDay.humidityMin(), + maxUvIndex: sourceDay.maxUvIndex(), + moonPhase: WK2.MoonPhase[sourceDay.moonPhase()], + moonrise: sourceDay.moonrise(), + moonset: sourceDay.moonset(), + precipitationAmount: sourceDay.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).precipitationType()], - snowfallAmount: DailyForecastData.days(i).snowfallAmount(), - solarMidnight: DailyForecastData.days(i).solarMidnight(), - solarNoon: DailyForecastData.days(i).solarNoon(), - sunrise: DailyForecastData.days(i).sunrise(), - sunriseCivil: DailyForecastData.days(i).sunriseCivil(), - sunriseNautical: DailyForecastData.days(i).sunriseNautical(), - sunriseAstronomical: DailyForecastData.days(i).sunriseAstronomical(), - sunset: DailyForecastData.days(i).sunset(), - sunsetCivil: DailyForecastData.days(i).sunsetCivil(), - sunsetNautical: DailyForecastData.days(i).sunsetNautical(), - sunsetAstronomical: DailyForecastData.days(i).sunsetAstronomical(), - temperatureMax: DailyForecastData.days(i).temperatureMax(), - temperatureMaxTime: DailyForecastData.days(i).temperatureMaxTime(), - temperatureMin: DailyForecastData.days(i).temperatureMin(), - temperatureMinTime: DailyForecastData.days(i).temperatureMinTime(), - visibilityMax: DailyForecastData.days(i).visibilityMax(), - visibilityMin: DailyForecastData.days(i).visibilityMin(), - windGustSpeedMax: DailyForecastData.days(i).windGustSpeedMax(), - windSpeedAvg: DailyForecastData.days(i).windSpeedAvg(), - windSpeedMax: DailyForecastData.days(i).windSpeedMax(), + precipitationChance: sourceDay.precipitationChance(), + precipitationType: WK2.PrecipitationType[sourceDay.precipitationType()], + snowfallAmount: sourceDay.snowfallAmount(), + solarMidnight: sourceDay.solarMidnight(), + solarNoon: sourceDay.solarNoon(), + sunrise: sourceDay.sunrise(), + sunriseCivil: sourceDay.sunriseCivil(), + sunriseNautical: sourceDay.sunriseNautical(), + sunriseAstronomical: sourceDay.sunriseAstronomical(), + sunset: sourceDay.sunset(), + sunsetCivil: sourceDay.sunsetCivil(), + sunsetNautical: sourceDay.sunsetNautical(), + sunsetAstronomical: sourceDay.sunsetAstronomical(), + temperatureMax: sourceDay.temperatureMax(), + temperatureMaxTime: sourceDay.temperatureMaxTime(), + temperatureMin: sourceDay.temperatureMin(), + temperatureMinTime: sourceDay.temperatureMinTime(), + visibilityMax: sourceDay.visibilityMax(), + visibilityMin: sourceDay.visibilityMin(), + windGustSpeedMax: sourceDay.windGustSpeedMax(), + windSpeedAvg: sourceDay.windSpeedAvg(), + windSpeedMax: sourceDay.windSpeedMax(), }; - for (let j = 0; j < DailyForecastData.days(i).precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(sourceDay.precipitationAmountByTypeLength(), budget); j < count; j++) day.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).precipitationAmountByType(j).precipitationType()], + expected: sourceDay.precipitationAmountByType(j).expected(), + expectedSnow: sourceDay.precipitationAmountByType(j).expectedSnow(), + maximumSnow: sourceDay.precipitationAmountByType(j).maximumSnow(), + minimumSnow: sourceDay.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[sourceDay.precipitationAmountByType(j).precipitationType()], }); - if (DailyForecastData.days(i).daytimeForecast()) { + const daytimeForecast = sourceDay.daytimeForecast(); + if (daytimeForecast) { day.daytimeForecast = { - cloudCover: DailyForecastData.days(i).daytimeForecast().cloudCover(), - cloudCoverHighAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverHighAltPct(), - cloudCoverLowAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverLowAltPct(), - cloudCoverMidAltPct: DailyForecastData.days(i).daytimeForecast().cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).daytimeForecast().conditionCode()], - forecastEnd: DailyForecastData.days(i).daytimeForecast().forecastEnd(), - forecastStart: DailyForecastData.days(i).daytimeForecast().forecastStart(), - humidity: DailyForecastData.days(i).daytimeForecast().humidity(), - humidityMax: DailyForecastData.days(i).daytimeForecast().humidityMax(), - humidityMin: DailyForecastData.days(i).daytimeForecast().humidityMin(), - precipitationAmount: DailyForecastData.days(i).daytimeForecast().precipitationAmount(), + cloudCover: daytimeForecast.cloudCover(), + cloudCoverHighAltPct: daytimeForecast.cloudCoverHighAltPct(), + cloudCoverLowAltPct: daytimeForecast.cloudCoverLowAltPct(), + cloudCoverMidAltPct: daytimeForecast.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[daytimeForecast.conditionCode()], + forecastEnd: daytimeForecast.forecastEnd(), + forecastStart: daytimeForecast.forecastStart(), + humidity: daytimeForecast.humidity(), + humidityMax: daytimeForecast.humidityMax(), + humidityMin: daytimeForecast.humidityMin(), + precipitationAmount: daytimeForecast.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).daytimeForecast().precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).daytimeForecast().precipitationType()], - snowfallAmount: DailyForecastData.days(i).daytimeForecast().snowfallAmount(), - temperatureMax: DailyForecastData.days(i).daytimeForecast().temperatureMax(), - temperatureMin: DailyForecastData.days(i).daytimeForecast().temperatureMin(), - visibilityMax: DailyForecastData.days(i).daytimeForecast().visibilityMax(), - visibilityMin: DailyForecastData.days(i).daytimeForecast().visibilityMin(), - windDirection: DailyForecastData.days(i).daytimeForecast().windDirection(), - windGustSpeedMax: DailyForecastData.days(i).daytimeForecast().windGustSpeedMax(), - windSpeed: DailyForecastData.days(i).daytimeForecast().windSpeed(), - windSpeedMax: DailyForecastData.days(i).daytimeForecast().windSpeedMax(), - precipitationIntensityMax: DailyForecastData.days(i).daytimeForecast().precipitationIntensityMax(), - perceivedPrecipitationIntensityMax: DailyForecastData.days(i).daytimeForecast().perceivedPrecipitationIntensityMax(), - uvIndexMin: DailyForecastData.days(i).daytimeForecast().uvIndexMin(), - uvIndexMax: DailyForecastData.days(i).daytimeForecast().uvIndexMax(), - temperatureApparentMin: DailyForecastData.days(i).daytimeForecast().temperatureApparentMin(), - temperatureApparentMax: DailyForecastData.days(i).daytimeForecast().temperatureApparentMax(), - daylight: DailyForecastData.days(i).daytimeForecast().daylight(), + precipitationChance: daytimeForecast.precipitationChance(), + precipitationType: WK2.PrecipitationType[daytimeForecast.precipitationType()], + snowfallAmount: daytimeForecast.snowfallAmount(), + temperatureMax: daytimeForecast.temperatureMax(), + temperatureMin: daytimeForecast.temperatureMin(), + visibilityMax: daytimeForecast.visibilityMax(), + visibilityMin: daytimeForecast.visibilityMin(), + windDirection: daytimeForecast.windDirection(), + windGustSpeedMax: daytimeForecast.windGustSpeedMax(), + windSpeed: daytimeForecast.windSpeed(), + windSpeedMax: daytimeForecast.windSpeedMax(), + precipitationIntensityMax: daytimeForecast.precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: daytimeForecast.perceivedPrecipitationIntensityMax(), + uvIndexMin: daytimeForecast.uvIndexMin(), + uvIndexMax: daytimeForecast.uvIndexMax(), + temperatureApparentMin: daytimeForecast.temperatureApparentMin(), + temperatureApparentMax: daytimeForecast.temperatureApparentMax(), + daylight: daytimeForecast.daylight(), }; - for (let j = 0; j < DailyForecastData.days(i).daytimeForecast().precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(daytimeForecast.precipitationAmountByTypeLength(), budget); j < count; j++) day.daytimeForecast.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).daytimeForecast().precipitationAmountByType(j).precipitationType()], + expected: daytimeForecast.precipitationAmountByType(j).expected(), + expectedSnow: daytimeForecast.precipitationAmountByType(j).expectedSnow(), + maximumSnow: daytimeForecast.precipitationAmountByType(j).maximumSnow(), + minimumSnow: daytimeForecast.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[daytimeForecast.precipitationAmountByType(j).precipitationType()], }); } - if (DailyForecastData.days(i).overnightForecast()) { + const overnightForecast = sourceDay.overnightForecast(); + if (overnightForecast) { day.overnightForecast = { - cloudCover: DailyForecastData.days(i).overnightForecast().cloudCover(), - cloudCoverHighAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverHighAltPct(), - cloudCoverLowAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverLowAltPct(), - cloudCoverMidAltPct: DailyForecastData.days(i).overnightForecast().cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).overnightForecast().conditionCode()], - forecastEnd: DailyForecastData.days(i).overnightForecast().forecastEnd(), - forecastStart: DailyForecastData.days(i).overnightForecast().forecastStart(), - humidity: DailyForecastData.days(i).overnightForecast().humidity(), - humidityMax: DailyForecastData.days(i).overnightForecast().humidityMax(), - humidityMin: DailyForecastData.days(i).overnightForecast().humidityMin(), - precipitationAmount: DailyForecastData.days(i).overnightForecast().precipitationAmount(), + cloudCover: overnightForecast.cloudCover(), + cloudCoverHighAltPct: overnightForecast.cloudCoverHighAltPct(), + cloudCoverLowAltPct: overnightForecast.cloudCoverLowAltPct(), + cloudCoverMidAltPct: overnightForecast.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[overnightForecast.conditionCode()], + forecastEnd: overnightForecast.forecastEnd(), + forecastStart: overnightForecast.forecastStart(), + humidity: overnightForecast.humidity(), + humidityMax: overnightForecast.humidityMax(), + humidityMin: overnightForecast.humidityMin(), + precipitationAmount: overnightForecast.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).overnightForecast().precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).overnightForecast().precipitationType()], - snowfallAmount: DailyForecastData.days(i).overnightForecast().snowfallAmount(), - temperatureMax: DailyForecastData.days(i).overnightForecast().temperatureMax(), - temperatureMin: DailyForecastData.days(i).overnightForecast().temperatureMin(), - visibilityMax: DailyForecastData.days(i).overnightForecast().visibilityMax(), - visibilityMin: DailyForecastData.days(i).overnightForecast().visibilityMin(), - windDirection: DailyForecastData.days(i).overnightForecast().windDirection(), - windGustSpeedMax: DailyForecastData.days(i).overnightForecast().windGustSpeedMax(), - windSpeed: DailyForecastData.days(i).overnightForecast().windSpeed(), - windSpeedMax: DailyForecastData.days(i).overnightForecast().windSpeedMax(), - precipitationIntensityMax: DailyForecastData.days(i).overnightForecast().precipitationIntensityMax(), - perceivedPrecipitationIntensityMax: DailyForecastData.days(i).overnightForecast().perceivedPrecipitationIntensityMax(), - uvIndexMin: DailyForecastData.days(i).overnightForecast().uvIndexMin(), - uvIndexMax: DailyForecastData.days(i).overnightForecast().uvIndexMax(), - temperatureApparentMin: DailyForecastData.days(i).overnightForecast().temperatureApparentMin(), - temperatureApparentMax: DailyForecastData.days(i).overnightForecast().temperatureApparentMax(), - daylight: DailyForecastData.days(i).overnightForecast().daylight(), + precipitationChance: overnightForecast.precipitationChance(), + precipitationType: WK2.PrecipitationType[overnightForecast.precipitationType()], + snowfallAmount: overnightForecast.snowfallAmount(), + temperatureMax: overnightForecast.temperatureMax(), + temperatureMin: overnightForecast.temperatureMin(), + visibilityMax: overnightForecast.visibilityMax(), + visibilityMin: overnightForecast.visibilityMin(), + windDirection: overnightForecast.windDirection(), + windGustSpeedMax: overnightForecast.windGustSpeedMax(), + windSpeed: overnightForecast.windSpeed(), + windSpeedMax: overnightForecast.windSpeedMax(), + precipitationIntensityMax: overnightForecast.precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: overnightForecast.perceivedPrecipitationIntensityMax(), + uvIndexMin: overnightForecast.uvIndexMin(), + uvIndexMax: overnightForecast.uvIndexMax(), + temperatureApparentMin: overnightForecast.temperatureApparentMin(), + temperatureApparentMax: overnightForecast.temperatureApparentMax(), + daylight: overnightForecast.daylight(), }; - for (let j = 0; j < DailyForecastData.days(i).overnightForecast().precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(overnightForecast.precipitationAmountByTypeLength(), budget); j < count; j++) day.overnightForecast.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).overnightForecast().precipitationAmountByType(j).precipitationType()], + expected: overnightForecast.precipitationAmountByType(j).expected(), + expectedSnow: overnightForecast.precipitationAmountByType(j).expectedSnow(), + maximumSnow: overnightForecast.precipitationAmountByType(j).maximumSnow(), + minimumSnow: overnightForecast.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[overnightForecast.precipitationAmountByType(j).precipitationType()], }); } - if (DailyForecastData.days(i).restOfDayForecast()) { + const restOfDayForecast = sourceDay.restOfDayForecast(); + if (restOfDayForecast) { day.restOfDayForecast = { - cloudCover: DailyForecastData.days(i).restOfDayForecast().cloudCover(), - cloudCoverHighAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverHighAltPct(), - cloudCoverLowAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverLowAltPct(), - cloudCoverMidAltPct: DailyForecastData.days(i).restOfDayForecast().cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[DailyForecastData.days(i).restOfDayForecast().conditionCode()], - forecastEnd: DailyForecastData.days(i).restOfDayForecast().forecastEnd(), - forecastStart: DailyForecastData.days(i).restOfDayForecast().forecastStart(), - humidity: DailyForecastData.days(i).restOfDayForecast().humidity(), - humidityMax: DailyForecastData.days(i).restOfDayForecast().humidityMax(), - humidityMin: DailyForecastData.days(i).restOfDayForecast().humidityMin(), - precipitationAmount: DailyForecastData.days(i).restOfDayForecast().precipitationAmount(), + cloudCover: restOfDayForecast.cloudCover(), + cloudCoverHighAltPct: restOfDayForecast.cloudCoverHighAltPct(), + cloudCoverLowAltPct: restOfDayForecast.cloudCoverLowAltPct(), + cloudCoverMidAltPct: restOfDayForecast.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[restOfDayForecast.conditionCode()], + forecastEnd: restOfDayForecast.forecastEnd(), + forecastStart: restOfDayForecast.forecastStart(), + humidity: restOfDayForecast.humidity(), + humidityMax: restOfDayForecast.humidityMax(), + humidityMin: restOfDayForecast.humidityMin(), + precipitationAmount: restOfDayForecast.precipitationAmount(), precipitationAmountByType: [], - precipitationChance: DailyForecastData.days(i).restOfDayForecast().precipitationChance(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).restOfDayForecast().precipitationType()], - snowfallAmount: DailyForecastData.days(i).restOfDayForecast().snowfallAmount(), - temperatureMax: DailyForecastData.days(i).restOfDayForecast().temperatureMax(), - temperatureMin: DailyForecastData.days(i).restOfDayForecast().temperatureMin(), - visibilityMax: DailyForecastData.days(i).restOfDayForecast().visibilityMax(), - visibilityMin: DailyForecastData.days(i).restOfDayForecast().visibilityMin(), - windDirection: DailyForecastData.days(i).restOfDayForecast().windDirection(), - windGustSpeedMax: DailyForecastData.days(i).restOfDayForecast().windGustSpeedMax(), - windSpeed: DailyForecastData.days(i).restOfDayForecast().windSpeed(), - windSpeedMax: DailyForecastData.days(i).restOfDayForecast().windSpeedMax(), - precipitationIntensityMax: DailyForecastData.days(i).restOfDayForecast().precipitationIntensityMax(), - perceivedPrecipitationIntensityMax: DailyForecastData.days(i).restOfDayForecast().perceivedPrecipitationIntensityMax(), - uvIndexMin: DailyForecastData.days(i).restOfDayForecast().uvIndexMin(), - uvIndexMax: DailyForecastData.days(i).restOfDayForecast().uvIndexMax(), - temperatureApparentMin: DailyForecastData.days(i).restOfDayForecast().temperatureApparentMin(), - temperatureApparentMax: DailyForecastData.days(i).restOfDayForecast().temperatureApparentMax(), - daylight: DailyForecastData.days(i).restOfDayForecast().daylight(), + precipitationChance: restOfDayForecast.precipitationChance(), + precipitationType: WK2.PrecipitationType[restOfDayForecast.precipitationType()], + snowfallAmount: restOfDayForecast.snowfallAmount(), + temperatureMax: restOfDayForecast.temperatureMax(), + temperatureMin: restOfDayForecast.temperatureMin(), + visibilityMax: restOfDayForecast.visibilityMax(), + visibilityMin: restOfDayForecast.visibilityMin(), + windDirection: restOfDayForecast.windDirection(), + windGustSpeedMax: restOfDayForecast.windGustSpeedMax(), + windSpeed: restOfDayForecast.windSpeed(), + windSpeedMax: restOfDayForecast.windSpeedMax(), + precipitationIntensityMax: restOfDayForecast.precipitationIntensityMax(), + perceivedPrecipitationIntensityMax: restOfDayForecast.perceivedPrecipitationIntensityMax(), + uvIndexMin: restOfDayForecast.uvIndexMin(), + uvIndexMax: restOfDayForecast.uvIndexMax(), + temperatureApparentMin: restOfDayForecast.temperatureApparentMin(), + temperatureApparentMax: restOfDayForecast.temperatureApparentMax(), + daylight: restOfDayForecast.daylight(), }; - for (let j = 0; j < DailyForecastData.days(i).restOfDayForecast().precipitationAmountByTypeLength(); j++) + for (let j = 0, count = decodedVectorLength(restOfDayForecast.precipitationAmountByTypeLength(), budget); j < count; j++) day.restOfDayForecast.precipitationAmountByType.push({ - expected: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).expected(), - expectedSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).expectedSnow(), - maximumSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).maximumSnow(), - minimumSnow: DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).minimumSnow(), - precipitationType: WK2.PrecipitationType[DailyForecastData.days(i).restOfDayForecast().precipitationAmountByType(j).precipitationType()], + expected: restOfDayForecast.precipitationAmountByType(j).expected(), + expectedSnow: restOfDayForecast.precipitationAmountByType(j).expectedSnow(), + maximumSnow: restOfDayForecast.precipitationAmountByType(j).maximumSnow(), + minimumSnow: restOfDayForecast.precipitationAmountByType(j).minimumSnow(), + precipitationType: WK2.PrecipitationType[restOfDayForecast.precipitationAmountByType(j).precipitationType()], }); } data.days.push(day); @@ -725,44 +743,48 @@ function decodeForecastDaily(data) { } function decodeForecastHourly(data) { + const budget = newDecodeBudget(); const HourlyForecastData = data; data = { metadata: decodeMetadata(HourlyForecastData.metadata()), hours: [], }; - for (let i = 0; i < HourlyForecastData.hoursLength(); i++) + for (let i = 0, count = decodedVectorLength(HourlyForecastData.hoursLength(), budget); i < count; i++) { + const hour = HourlyForecastData.hours(i); data.hours.push({ - cloudCover: HourlyForecastData.hours(i).cloudCover(), - cloudCoverHighAltPct: HourlyForecastData.hours(i).cloudCoverHighAltPct(), - cloudCoverLowAltPct: HourlyForecastData.hours(i).cloudCoverLowAltPct(), - cloudCoverMidAltPct: HourlyForecastData.hours(i).cloudCoverMidAltPct(), - conditionCode: WK2.WeatherCondition[HourlyForecastData.hours(i).conditionCode()], - daylight: HourlyForecastData.hours(i).daylight(), - forecastStart: HourlyForecastData.hours(i).forecastStart(), - humidity: HourlyForecastData.hours(i).humidity(), - perceivedPrecipitationIntensity: HourlyForecastData.hours(i).perceivedPrecipitationIntensity(), - precipitationAmount: HourlyForecastData.hours(i).precipitationAmount(), - precipitationChance: HourlyForecastData.hours(i).precipitationChance(), - precipitationIntensity: HourlyForecastData.hours(i).precipitationIntensity(), - precipitationType: WK2.PrecipitationType[HourlyForecastData.hours(i).precipitationType()], - pressure: HourlyForecastData.hours(i).pressure(), - pressureTrend: WK2.PressureTrend[HourlyForecastData.hours(i).pressureTrend()], - snowfallAmount: HourlyForecastData.hours(i).snowfallAmount(), - snowfallIntensity: HourlyForecastData.hours(i).snowfallIntensity(), - temperature: HourlyForecastData.hours(i).temperature(), - temperatureApparent: HourlyForecastData.hours(i).temperatureApparent(), - unknown20: HourlyForecastData.hours(i).unknown20(), - temperatureDewPoint: HourlyForecastData.hours(i).temperatureDewPoint(), - uvIndex: HourlyForecastData.hours(i).uvIndex(), - visibility: HourlyForecastData.hours(i).visibility(), - windDirection: HourlyForecastData.hours(i).windDirection(), - windGust: HourlyForecastData.hours(i).windGust(), - windSpeed: HourlyForecastData.hours(i).windSpeed(), + cloudCover: hour.cloudCover(), + cloudCoverHighAltPct: hour.cloudCoverHighAltPct(), + cloudCoverLowAltPct: hour.cloudCoverLowAltPct(), + cloudCoverMidAltPct: hour.cloudCoverMidAltPct(), + conditionCode: WK2.WeatherCondition[hour.conditionCode()], + daylight: hour.daylight(), + forecastStart: hour.forecastStart(), + humidity: hour.humidity(), + perceivedPrecipitationIntensity: hour.perceivedPrecipitationIntensity(), + precipitationAmount: hour.precipitationAmount(), + precipitationChance: hour.precipitationChance(), + precipitationIntensity: hour.precipitationIntensity(), + precipitationType: WK2.PrecipitationType[hour.precipitationType()], + pressure: hour.pressure(), + pressureTrend: WK2.PressureTrend[hour.pressureTrend()], + snowfallAmount: hour.snowfallAmount(), + snowfallIntensity: hour.snowfallIntensity(), + temperature: hour.temperature(), + temperatureApparent: hour.temperatureApparent(), + unknown20: hour.unknown20(), + temperatureDewPoint: hour.temperatureDewPoint(), + uvIndex: hour.uvIndex(), + visibility: hour.visibility(), + windDirection: hour.windDirection(), + windGust: hour.windGust(), + windSpeed: hour.windSpeed(), }); + } return data; } function decodeForecastNextHour(data) { + const budget = newDecodeBudget(); const NextHourForecastData = data; data = { metadata: decodeMetadata(NextHourForecastData.metadata()), @@ -772,7 +794,7 @@ function decodeForecastNextHour(data) { minutes: [], summary: [], }; - for (let i = 0; i < NextHourForecastData.conditionLength(); i++) { + for (let i = 0, count = decodedVectorLength(NextHourForecastData.conditionLength(), budget); i < count; i++) { const condition = { beginCondition: WK2.ConditionType[NextHourForecastData.condition(i).beginCondition()], endCondition: WK2.ConditionType[NextHourForecastData.condition(i).endCondition()], @@ -781,21 +803,21 @@ function decodeForecastNextHour(data) { parameters: [], startTime: NextHourForecastData.condition(i).startTime(), }; - for (let j = 0; j < NextHourForecastData.condition(i).parametersLength(); j++) + for (let j = 0, count = decodedVectorLength(NextHourForecastData.condition(i).parametersLength(), budget); j < count; j++) condition.parameters.push({ date: NextHourForecastData.condition(i).parameters(j).date(), type: WK2.ParameterType[NextHourForecastData.condition(i).parameters(j).type()], }); data.condition.push(condition); } - for (let i = 0; i < NextHourForecastData.minutesLength(); i++) + for (let i = 0, count = decodedVectorLength(NextHourForecastData.minutesLength(), budget); i < count; i++) data.minutes.push({ perceivedPrecipitationIntensity: NextHourForecastData.minutes(i).perceivedPrecipitationIntensity(), precipitationChance: NextHourForecastData.minutes(i).precipitationChance(), precipitationIntensity: NextHourForecastData.minutes(i).precipitationIntensity(), startTime: NextHourForecastData.minutes(i).startTime(), }); - for (let i = 0; i < NextHourForecastData.summaryLength(); i++) + for (let i = 0, count = decodedVectorLength(NextHourForecastData.summaryLength(), budget); i < count; i++) data.summary.push({ condition: WK2.PrecipitationType[NextHourForecastData.summary(i).condition()], endTime: NextHourForecastData.summary(i).endTime(), @@ -807,18 +829,19 @@ function decodeForecastNextHour(data) { } function decodeNews(data) { + const budget = newDecodeBudget(); const NewsData = data; data = { metadata: decodeMetadata(NewsData.metadata()), placements: [], }; - for (let i = 0; i < NewsData.placementsLength(); i++) { + for (let i = 0, count = decodedVectorLength(NewsData.placementsLength(), budget); i < count; i++) { const placement = { articles: [], placement: WK2.PlacementType[NewsData.placements(i).placement()], priority: NewsData.placements(i).priority(), }; - for (let j = 0; j < NewsData.placements(i).articlesLength(); j++) { + for (let j = 0, count = decodedVectorLength(NewsData.placements(i).articlesLength(), budget); j < count; j++) { const article = { alertIds: [], headlineOverride: NewsData.placements(i).articles(j).headlineOverride(), @@ -827,9 +850,9 @@ function decodeNews(data) { phenomena: [], supportedStorefronts: [], }; - for (let k = 0; k < NewsData.placements(i).articles(j).alertIdsLength(); k++) article.alertIds.push(decodeUuid(NewsData.placements(i).articles(j).alertIds(k))); - for (let k = 0; k < NewsData.placements(i).articles(j).phenomenaLength(); k++) article.phenomena.push(NewsData.placements(i).articles(j).phenomena(k)); - for (let k = 0; k < NewsData.placements(i).articles(j).supportedStorefrontsLength(); k++) article.supportedStorefronts.push(NewsData.placements(i).articles(j).supportedStorefronts(k)); + for (let k = 0, count = decodedVectorLength(NewsData.placements(i).articles(j).alertIdsLength(), budget); k < count; k++) article.alertIds.push(decodeUuid(NewsData.placements(i).articles(j).alertIds(k))); + for (let k = 0, count = decodedVectorLength(NewsData.placements(i).articles(j).phenomenaLength(), budget); k < count; k++) article.phenomena.push(NewsData.placements(i).articles(j).phenomena(k)); + for (let k = 0, count = decodedVectorLength(NewsData.placements(i).articles(j).supportedStorefrontsLength(), budget); k < count; k++) article.supportedStorefronts.push(NewsData.placements(i).articles(j).supportedStorefronts(k)); placement.articles.push(article); } data.placements.push(placement); @@ -838,50 +861,53 @@ function decodeNews(data) { } function decodeWeatherAlerts(data) { + const budget = newDecodeBudget(); const WeatherAlertCollectionData = data; data = { metadata: decodeMetadata(WeatherAlertCollectionData.metadata()), alerts: [], detailsUrl: WeatherAlertCollectionData.detailsUrl(), }; - for (let i = 0; i < WeatherAlertCollectionData.alertsLength(); i++) { + for (let i = 0, count = decodedVectorLength(WeatherAlertCollectionData.alertsLength(), budget); i < count; i++) { + const alertView = WeatherAlertCollectionData.alerts(i); const alert = { - areaId: WeatherAlertCollectionData.alerts(i).areaId(), - areaName: WeatherAlertCollectionData.alerts(i).areaName(), - attributionUrl: WeatherAlertCollectionData.alerts(i).attributionUrl(), - certainty: WK2.Certainty[WeatherAlertCollectionData.alerts(i).certainty()], - countryCode: WeatherAlertCollectionData.alerts(i).countryCode(), - description: WeatherAlertCollectionData.alerts(i).description(), - detailsUrl: WeatherAlertCollectionData.alerts(i).detailsUrl(), - effectiveTime: WeatherAlertCollectionData.alerts(i).effectiveTime(), - eventEndTime: WeatherAlertCollectionData.alerts(i).eventEndTime(), - eventOnsetTime: WeatherAlertCollectionData.alerts(i).eventOnsetTime(), - eventSource: WeatherAlertCollectionData.alerts(i).eventSource(), - expireTime: WeatherAlertCollectionData.alerts(i).expireTime(), - id: decodeUuid(WeatherAlertCollectionData.alerts(i).id()), - importance: WK2.ImportanceType[WeatherAlertCollectionData.alerts(i).importance()], - issuedTime: WeatherAlertCollectionData.alerts(i).issuedTime(), - phenomenon: WeatherAlertCollectionData.alerts(i).phenomenon(), + areaId: alertView.areaId(), + areaName: alertView.areaName(), + attributionUrl: alertView.attributionUrl(), + certainty: WK2.Certainty[alertView.certainty()], + countryCode: alertView.countryCode(), + description: alertView.description(), + detailsUrl: alertView.detailsUrl(), + effectiveTime: alertView.effectiveTime(), + eventEndTime: alertView.eventEndTime(), + eventOnsetTime: alertView.eventOnsetTime(), + eventSource: alertView.eventSource(), + expireTime: alertView.expireTime(), + id: decodeUuid(alertView.id()), + importance: WK2.ImportanceType[alertView.importance()], + issuedTime: alertView.issuedTime(), + phenomenon: alertView.phenomenon(), responses: [], - severity: WK2.Severity[WeatherAlertCollectionData.alerts(i).severity()], - significance: WK2.SignificanceType[WeatherAlertCollectionData.alerts(i).significance()], - source: WeatherAlertCollectionData.alerts(i).source(), - token: WeatherAlertCollectionData.alerts(i).token(), - urgency: WK2.Urgency[WeatherAlertCollectionData.alerts(i).urgency()], - unknown23: WeatherAlertCollectionData.alerts(i).unknown23(), - unknown24: WeatherAlertCollectionData.alerts(i).unknown24(), - unknown25: WeatherAlertCollectionData.alerts(i).unknown25(), - unknown26: WeatherAlertCollectionData.alerts(i).unknown26(), + severity: WK2.Severity[alertView.severity()], + significance: WK2.SignificanceType[alertView.significance()], + source: alertView.source(), + token: alertView.token(), + urgency: WK2.Urgency[alertView.urgency()], + unknown23: alertView.unknown23(), + unknown24: alertView.unknown24(), + unknown25: alertView.unknown25(), + unknown26: alertView.unknown26(), }; - //for (let j = 0; j < WeatherAlertCollectionData.alerts(i).idLength(); j++) alert.id.push(WeatherAlertCollectionData.alerts(i).id(j)); - //for (let j = 0; j < WeatherAlertCollectionData.alerts(i).idLength(); j++) alert.id.push({ "lowBytes": WeatherAlertCollectionData.alerts(i).id(j).lowBytes(), "highBytes": WeatherAlertCollectionData.alerts(i).id(j).highBytes() }); - for (let j = 0; j < WeatherAlertCollectionData.alerts(i).responsesLength(); j++) alert.responses.push(WK2.ResponseType[WeatherAlertCollectionData.alerts(i).responses(j)]); + //for (let j = 0, count = decodedVectorLength(alertView.idLength(), budget); j < count; j++) alert.id.push(alertView.id(j)); + //for (let j = 0, count = decodedVectorLength(alertView.idLength(), budget); j < count; j++) alert.id.push({ "lowBytes": alertView.id(j).lowBytes(), "highBytes": alertView.id(j).highBytes() }); + for (let j = 0, count = decodedVectorLength(alertView.responsesLength(), budget); j < count; j++) alert.responses.push(WK2.ResponseType[alertView.responses(j)]); data.alerts.push(alert); } return data; } function decodeWeatherChanges(data) { + const budget = newDecodeBudget(); const WeatherChangesData = data; data = { metadata: decodeMetadata(WeatherChangesData.metadata()), @@ -889,7 +915,7 @@ function decodeWeatherChanges(data) { forecastEnd: WeatherChangesData.forecastEnd(), forecastStart: WeatherChangesData.forecastStart(), }; - for (let i = 0; i < WeatherChangesData.changesLength(); i++) { + for (let i = 0, count = decodedVectorLength(WeatherChangesData.changesLength(), budget); i < count; i++) { const change = { dayPrecipitationChange: WK2.Direction[WeatherChangesData.changes(i).dayPrecipitationChange()], forecastEnd: WeatherChangesData.changes(i).forecastEnd(), @@ -904,12 +930,13 @@ function decodeWeatherChanges(data) { } function decodeHistoricalComparisons(data) { + const budget = newDecodeBudget(); const HistoricalComparisonsData = data; data = { metadata: decodeMetadata(HistoricalComparisonsData.metadata()), comparisons: [], }; - for (let i = 0; i < HistoricalComparisonsData.comparisonsLength(); i++) { + for (let i = 0, count = decodedVectorLength(HistoricalComparisonsData.comparisonsLength(), budget); i < count; i++) { const comparison = { baselineStartDate: HistoricalComparisonsData.comparisons(i).baselineStartDate(), baselineType: HistoricalComparisonsData.comparisons(i).baselineType(), diff --git a/src/data/providerRegions.mjs b/src/data/providerRegions.mjs new file mode 100644 index 00000000..d8ede4a8 --- /dev/null +++ b/src/data/providerRegions.mjs @@ -0,0 +1,6 @@ +// Natural Earth public-domain data. Provider hints only; not authoritative boundaries. +// Source: https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson +let regions; +export default function getProviderRegions() { + return regions ??= JSON.parse("{\"CN\":[[[[78.91769,33.38626],[78.82426,33.46106],[78.78137,33.55279],[78.78726,33.80843],[78.72112,33.99439],[78.73011,34.07926],[78.90323,34.15812],[78.97619,34.21716],[78.98849,34.26313],[78.97619,34.30917],[78.92214,34.3723],[78.80173,34.41501],[78.75068,34.47128],[78.66458,34.52642],[78.37943,34.57867],[78.29603,34.62466],[78.21138,34.84826],[78.14813,34.94314],[78.13004,35.05543],[78.00116,35.26891],[78.00984,35.34746],[78.05563,35.45288],[78.04426,35.49163],[77.98587,35.49417],[77.88313,35.43107],[77.83425,35.45215],[77.80035,35.49541],[77.76035,35.49809],[77.66061,35.4581],[77.5122,35.47846],[77.38373,35.472],[77.2811,35.52801],[77.14127,35.52538],[77.03905,35.58501],[76.91503,35.58315],[76.84299,35.64129],[76.76341,35.64708],[76.67669,35.71379],[76.57076,35.75126],[76.55112,35.78402],[76.56528,35.86546],[76.55546,35.89786],[76.53717,35.90251],[76.50017,35.87151],[76.43206,35.8605],[76.40312,35.83099],[76.34886,35.83099],[76.31196,35.80965],[76.16603,35.80624],[76.13812,35.82665],[76.11466,35.94179],[76.08996,35.97812],[75.95302,36.00726],[75.9,36.06349],[75.90144,36.11361],[75.97152,36.155],[75.97007,36.20234],[76.01441,36.2304],[75.96883,36.30197],[75.98743,36.42119],[75.97658,36.46263],[75.84977,36.64474],[75.72874,36.72319],[75.64978,36.75404],[75.57051,36.76675],[75.50798,36.74763],[75.49258,36.72071],[75.43191,36.72293],[75.41052,36.74732],[75.37796,36.89429],[75.31347,36.92876],[75.19513,36.94888],[75.10015,36.99412],[74.92497,36.97087],[74.87236,36.9071],[74.85324,36.95898],[74.79257,37.01516],[74.71919,37.01645],[74.66245,37.05448],[74.54235,37.02167],[74.47662,37.08314],[74.38257,37.12657],[74.36831,37.16706],[74.45667,37.17732],[74.50008,37.23163],[74.59,37.24336],[74.62328,37.2308],[74.72146,37.29778],[74.73738,37.29612],[74.79402,37.21393],[74.89231,37.23111],[74.95153,37.28129],[75.06842,37.31214],[75.09178,37.37844],[75.16412,37.40064],[75.05343,37.49384],[74.94522,37.54856],[74.89189,37.5992],[74.88611,37.65346],[74.96517,37.76746],[74.94925,37.79532],[74.88611,37.83056],[74.9137,37.88084],[74.89076,37.89771],[74.89282,37.99401],[74.88042,38.02166],[74.80993,38.05321],[74.79071,38.08145],[74.77107,38.29154],[74.7843,38.32567],[74.83825,38.37942],[74.83577,38.45517],[74.76032,38.51998],[74.69645,38.52398],[74.64612,38.56111],[74.3342,38.667],[74.22144,38.64971],[74.12336,38.66947],[74.0844,38.61403],[74.03727,38.59847],[74.05877,38.54703],[74.0289,38.52724],[73.93288,38.52967],[73.8164,38.58659],[73.73641,38.72167],[73.73589,38.80042],[73.6808,38.87298],[73.71719,38.91858],[73.80958,38.93266],[73.82612,38.9769],[73.70933,39.06547],[73.67378,39.14263],[73.60784,39.20732],[73.60226,39.23577],[73.6438,39.35129],[73.63264,39.44834],[73.83831,39.47568],[73.87046,39.54679],[73.92162,39.58213],[73.89351,39.71039],[73.82953,39.74698],[73.81857,39.78605],[73.83201,39.82372],[73.88069,39.86501],[73.8932,39.91813],[73.94404,39.97007],[73.9767,40.0436],[74.1676,40.10639],[74.33379,40.09373],[74.47662,40.17243],[74.5654,40.24695],[74.70575,40.33129],[74.86213,40.32617],[74.85386,40.35883],[74.7874,40.42074],[74.83536,40.51164],[75.00238,40.4473],[75.06439,40.44384],[75.13808,40.46389],[75.19389,40.44125],[75.48183,40.61411],[75.55986,40.63287],[75.58767,40.61194],[75.60565,40.56941],[75.61795,40.49396],[75.66694,40.45025],[75.63831,40.38611],[75.65629,40.32307],[75.68182,40.2917],[75.77205,40.31015],[75.90186,40.29888],[75.96242,40.35733],[76.05162,40.3902],[76.15135,40.36813],[76.24416,40.4412],[76.27341,40.43412],[76.31351,40.34333],[76.4765,40.43614],[76.55639,40.56569],[76.60941,40.59711],[76.64786,40.76485],[76.76248,40.84702],[76.76682,40.94468],[76.86097,41.01321],[77.00773,41.04421],[77.11884,41.01166],[77.24317,41.00566],[77.33298,41.02065],[77.50352,40.98107],[77.65028,40.99714],[77.83104,41.06297],[78.07496,41.03951],[78.1905,41.11775],[78.27536,41.22885],[78.34946,41.2704],[78.35969,41.28746],[78.33933,41.34404],[78.3599,41.37753],[78.51038,41.45442],[78.61921,41.47809],[78.65828,41.53245],[78.80731,41.57845],[78.91583,41.63317],[78.97619,41.6418],[79.08874,41.70255],[79.19592,41.72952],[79.28232,41.7835],[79.39084,41.7727],[79.61098,41.86763],[79.74751,41.87974],[79.78327,41.90509],[79.81283,41.97762],[79.84291,42.00183],[79.93086,42.02328],[80.18191,42.02098],[80.23121,42.03369],[80.25653,42.08436],[80.22459,42.12557],[80.21033,42.18952],[80.24909,42.20489],[80.26841,42.23797],[80.19617,42.43459],[80.21053,42.49084],[80.16237,42.55239],[80.14377,42.64476],[80.24826,42.8162],[80.33394,42.81708],[80.5486,42.87847],[80.54292,42.91666],[80.37383,42.97518],[80.35513,43.00058],[80.36815,43.02846],[80.61847,43.13427],[80.66002,43.13282],[80.73154,43.10109],[80.77339,43.11292],[80.79345,43.14946],[80.74849,43.27516],[80.64286,43.32622],[80.69423,43.39472],[80.71273,43.45887],[80.47615,43.91024],[80.42313,43.97215],[80.40381,44.04894],[80.3389,44.12149],[80.37321,44.231],[80.33042,44.45305],[80.38375,44.60281],[80.35585,44.65288],[80.37631,44.67128],[80.49196,44.70172],[80.49228,44.72797],[80.31451,44.78032],[80.24474,44.81597],[79.99174,44.79365],[79.86864,44.84936],[79.84301,44.87349],[79.8582,44.90372],[80.10336,45.03219],[80.21901,45.02604],[80.29053,45.04624],[80.39006,45.04578],[80.44535,45.10422],[80.53682,45.10107],[80.69712,45.14365],[80.85385,45.1265],[81.54203,45.29331],[81.66523,45.34798],[81.73928,45.33806],[81.76517,45.30773],[81.79364,45.19564],[81.87534,45.18107],[81.95012,45.14448],[82.24529,45.23181],[82.34183,45.20489],[82.47582,45.1157],[82.53975,45.12365],[82.58522,45.17683],[82.61457,45.24856],[82.63199,45.41583],[82.61571,45.43542],[82.51303,45.46575],[82.36265,45.4812],[82.32746,45.49335],[82.29149,45.53319],[82.37221,45.73416],[82.48895,45.90169],[82.56827,46.19651],[82.67436,46.33113],[82.83895,46.73245],[82.98551,46.94251],[82.98551,47.01253],[83.00628,47.08062],[82.99822,47.11025],[83.02147,47.20591],[83.0716,47.21851],[83.15036,47.21154],[83.22198,47.1719],[83.35339,47.14498],[83.52449,47.0671],[83.90535,46.97396],[84.06963,46.96442],[84.15815,46.9931],[84.26584,47.00013],[84.4218,46.9931],[84.52102,46.9698],[84.66354,46.98385],[84.73166,46.94776],[84.7516,46.91799],[84.74959,46.88246],[84.71553,46.83518],[84.73961,46.81477],[84.91604,46.85055],[84.97577,46.902],[85.04822,46.9207],[85.22584,47.04157],[85.49864,47.05183],[85.55682,47.17875],[85.65966,47.20652],[85.67899,47.27489],[85.67036,47.38434],[85.58457,47.49294],[85.59543,47.62407],[85.51538,47.92028],[85.56179,48.03066],[85.58638,48.14125],[85.71867,48.35883],[85.78368,48.40759],[86.00599,48.42955],[86.19203,48.41823],[86.2467,48.43735],[86.30737,48.48309],[86.43062,48.48588],[86.56508,48.52732],[86.60555,48.59011],[86.752,48.71543],[86.73918,48.78354],[86.78946,48.83914],[86.71805,48.91252],[86.70942,48.95422],[86.74352,49.00771],[86.85948,49.10532],[86.97544,49.0924],[87.11632,49.12977],[87.40043,49.07101],[87.47557,49.0876],[87.50606,49.12088],[87.81632,49.16584],[87.83555,49.10072],[87.82191,49.02874],[87.87224,49.00409],[87.8726,48.96802],[87.7569,48.92037],[87.73788,48.86937],[87.79607,48.83976],[87.82108,48.78829],[87.90758,48.75883],[87.97549,48.75599],[88.05941,48.70809],[88.05502,48.67341],[87.94283,48.59949],[87.98303,48.55233],[88.06251,48.54288],[88.16597,48.49373],[88.23563,48.49319],[88.30487,48.46901],[88.4167,48.3941],[88.50269,48.39255],[88.56512,48.34299],[88.56124,48.26827],[88.59623,48.19768],[88.63581,48.17585],[88.70919,48.16636],[88.80541,48.10595],[88.91683,48.10595],[89.04555,47.99299],[89.21872,47.97697],[89.35427,48.03877],[89.42543,48.02164],[89.5418,48.03102],[89.57188,48.02045],[89.58325,47.96204],[89.62738,47.94395],[89.65528,47.89982],[89.72339,47.87816],[89.7513,47.82432],[89.88907,47.82432],[89.93444,47.84426],[89.94669,47.88318],[90.04461,47.87977],[90.06435,47.85062],[90.05216,47.8107],[90.08285,47.7561],[90.16342,47.71001],[90.3448,47.65864],[90.32744,47.62304],[90.38159,47.53839],[90.44123,47.49305],[90.44815,47.40403],[90.48722,47.37819],[90.46872,47.30892],[90.71212,47.0147],[90.7424,46.99186],[90.82818,46.9823],[90.88513,46.94473],[90.93649,46.86443],[90.92533,46.81314],[91.00481,46.74043],[91.01556,46.7032],[90.99592,46.59463],[91.0475,46.56641],[90.93577,46.34387],[90.89619,46.30203],[90.94879,46.16829],[90.99592,46.11372],[91.00244,46.02184],[90.98621,45.99311],[90.91924,45.95007],[90.71077,45.7522],[90.65114,45.49314],[90.75325,45.42095],[90.79656,45.29284],[90.86663,45.25357],[90.85764,45.21507],[90.87324,45.18618],[90.97515,45.21486],[91.0568,45.21755],[91.13504,45.19786],[91.18434,45.15383],[91.24914,45.1297],[91.36655,45.11425],[91.43848,45.14613],[91.53398,45.0711],[91.66296,45.05953],[92.04299,45.07482],[92.2189,45.01012],[92.28535,45.01953],[92.47511,44.99746],[92.84759,45.03911],[92.94505,45.00583],[93.16571,45.00764],[93.24416,44.98154],[93.341,44.98485],[93.42058,44.95509],[93.52528,44.95126],[93.714,44.87458],[93.92742,44.67293],[94.05858,44.65128],[94.18188,44.66084],[94.27748,44.58958],[94.33846,44.51212],[94.43013,44.50447],[94.58805,44.4361],[94.69823,44.3435],[95.03681,44.25497],[95.39772,44.2805],[95.32269,44.15824],[95.31897,44.01711],[95.51027,43.97913],[95.53528,43.95768],[95.58924,43.86949],[95.69248,43.63054],[95.83284,43.4089],[95.87439,43.2471],[96.3187,42.91007],[96.35777,42.7245],[97.19327,42.78726],[99.47448,42.5642],[100.01698,42.67652],[101.21871,42.52983],[101.41074,42.54487],[101.52495,42.53746],[101.6376,42.51544],[101.73537,42.46116],[101.88647,42.27802],[101.97422,42.20789],[102.03416,42.18461],[103.07348,42.00452],[103.69143,41.75923],[104.50078,41.8706],[104.50492,41.65655],[104.89234,41.64477],[104.97383,41.58615],[105.01481,41.59614],[105.2009,41.74347],[105.3582,41.78585],[105.86861,41.99348],[106.76783,42.28662],[108.17736,42.45431],[108.31978,42.43255],[108.53646,42.43511],[108.79685,42.39837],[108.96129,42.44754],[109.25341,42.42594],[109.48513,42.4493],[109.66734,42.54898],[109.9127,42.62882],[110.06019,42.63835],[110.13987,42.68261],[110.40673,42.76861],[110.44941,42.83734],[110.60816,42.94467],[110.6801,43.05766],[110.97341,43.3156],[111.33856,43.43818],[111.4014,43.47551],[111.56769,43.50254],[111.75383,43.6632],[111.84581,43.66692],[111.93335,43.69664],[111.94431,43.78637],[111.83879,43.93879],[111.63446,44.06594],[111.54382,44.15674],[111.50371,44.22572],[111.49059,44.28262],[111.4106,44.32608],[111.39705,44.38737],[111.42413,44.44804],[111.53824,44.58343],[111.55136,44.69319],[111.73853,44.9663],[111.84333,45.03953],[111.92674,45.06092],[111.95826,45.08454],[112.10761,45.06738],[112.41281,45.06604],[112.52391,44.99327],[112.58603,44.92382],[112.7485,44.86527],[113.10486,44.79437],[113.47321,44.7707],[113.53243,44.74528],[113.60478,44.7397],[113.67247,44.76817],[113.88931,44.90827],[114.01705,44.92728],[114.08929,44.96175],[114.14934,45.03007],[114.41889,45.20148],[114.50229,45.30323],[114.53371,45.3855],[114.70331,45.42715],[114.93886,45.37377],[115.12634,45.39444],[115.33728,45.3947],[115.66739,45.45428],[116.00215,45.67556],[116.09486,45.67654],[116.16989,45.7091],[116.25164,45.80325],[116.2071,45.88087],[116.21319,45.90816],[116.35717,46.10556],[116.52511,46.22679],[116.56832,46.29087],[116.722,46.3287],[116.81461,46.38673],[116.95785,46.36642],[117.33747,46.35728],[117.3692,46.47569],[117.4125,46.52463],[117.39379,46.57137],[117.51007,46.57848],[117.56815,46.60354],[117.6252,46.53566],[117.67337,46.51602],[117.7976,46.52026],[117.90064,46.6076],[118.00554,46.62697],[118.08388,46.66826],[118.16904,46.6785],[118.26899,46.72294],[118.33555,46.72216],[118.4728,46.68568],[118.55197,46.68576],[118.60938,46.70578],[118.72007,46.67674],[118.75382,46.69116],[118.81624,46.75818],[118.84652,46.76549],[118.86745,46.75834],[118.89055,46.71353],[118.96724,46.74046],[119.03695,46.66873],[119.31693,46.61168],[119.50126,46.62801],[119.62348,46.61633],[119.68012,46.59163],[119.74027,46.61325],[119.78223,46.65563],[119.85442,46.65966],[119.90703,46.71831],[119.8831,46.75301],[119.90289,46.84241],[119.88145,46.87285],[119.89643,46.89859],[119.8523,46.91073],[119.77014,46.993],[119.75396,47.09444],[119.69996,47.15953],[119.58736,47.23836],[119.52509,47.25081],[119.47599,47.30921],[119.32065,47.40703],[119.2819,47.41486],[119.31776,47.46212],[119.31011,47.4761],[119.1339,47.52754],[119.08346,47.66156],[118.76741,47.75616],[118.54225,47.96625],[118.4728,47.98945],[118.28005,47.99813],[118.18248,48.02821],[117.97278,47.9979],[117.81516,48.00482],[117.7669,47.99314],[117.52722,47.78649],[117.43648,47.7305],[117.36083,47.65087],[117.31711,47.65407],[117.06968,47.81016],[116.82205,47.8763],[116.49969,47.83636],[116.24389,47.86289],[116.08431,47.80693],[115.9729,47.70905],[115.91451,47.68391],[115.8527,47.70556],[115.57572,47.90974],[115.51422,48.13164],[115.79999,48.24233],[115.80929,48.27395],[115.78656,48.49301],[115.8004,48.53037],[116.02819,48.76746],[116.04804,48.79976],[116.03781,48.87015],[116.68428,49.82326],[117.05759,49.67289],[117.25293,49.62245],[117.47286,49.61429],[117.75884,49.51274],[117.83687,49.50902],[117.87377,49.51321],[117.89382,49.54664],[117.93071,49.56731],[118.18847,49.68126],[118.2294,49.72921],[118.37296,49.78632],[118.39373,49.82001],[118.47704,49.83598],[118.52127,49.89406],[118.64436,49.95457],[118.74147,49.95308],[118.92378,49.99163],[118.99044,49.97933],[119.09168,49.98656],[119.20852,50.01514],[119.31621,50.09265],[119.34396,50.14242],[119.33378,50.27626],[119.36551,50.31083],[119.35662,50.34075],[119.17534,50.3561],[119.18051,50.3698],[119.14123,50.37662],[119.13756,50.39253],[119.15488,50.41139],[119.20676,50.41512],[119.24366,50.4455],[119.26458,50.48276],[119.25089,50.50451],[119.29358,50.59924],[119.37373,50.64275],[119.39166,50.67205],[119.44282,50.68507],[119.49666,50.73706],[119.51485,50.79194],[119.49749,50.83592],[119.52798,50.86677],[119.51796,50.9048],[119.60425,50.97818],[119.68322,51.01606],[119.71019,51.03854],[119.7104,51.06247],[119.74084,51.07153],[119.76523,51.14243],[119.75815,51.15801],[119.78543,51.17166],[119.75815,51.19902],[119.76218,51.21504],[119.81499,51.21589],[119.80595,51.24401],[119.81964,51.27473],[119.91566,51.33074],[119.8953,51.38397],[119.95286,51.39767],[119.98273,51.44216],[119.9772,51.48701],[120.05291,51.5522],[120.05673,51.59639],[120.10045,51.65995],[120.16562,51.66855],[120.3193,51.77604],[120.46482,51.8402],[120.49314,51.87572],[120.54115,51.88298],[120.56632,51.90533],[120.6476,51.91102],[120.7109,51.98833],[120.69731,52.04871],[120.77917,52.1176],[120.7832,52.16199],[120.75127,52.20296],[120.75809,52.23741],[120.65494,52.29911],[120.62616,52.34681],[120.69039,52.43913],[120.72398,52.54217],[120.45402,52.63904],[120.26111,52.61105],[120.17595,52.58374],[120.07937,52.58997],[120.03379,52.62537],[120.0326,52.65888],[120.05291,52.69301],[120.03296,52.76066],[120.28018,52.86592],[120.4503,53.00834],[120.61479,53.09707],[120.71406,53.18505],[120.82387,53.23523],[120.82599,53.26187],[120.87426,53.28016],[121.05486,53.29],[121.20349,53.27675],[121.341,53.32365],[121.51215,53.32982],[121.64103,53.38491],[121.88897,53.43059],[122.08162,53.4295],[122.1687,53.46914],[122.31675,53.49601],[122.4016,53.45095],[122.47121,53.46475],[122.85966,53.47395],[123.00255,53.50733],[123.1536,53.51312],[123.25256,53.56779],[123.34025,53.56144],[123.48154,53.51808],[123.49477,53.56009],[123.5144,53.56242],[123.54748,53.54051],[123.51957,53.51999],[123.54189,53.5141],[123.61429,53.56335],[123.70178,53.51007],[123.8502,53.49498],[123.97055,53.45519],[124.01737,53.40702],[124.12181,53.35269],[124.18222,53.37855],[124.25307,53.3755],[124.29343,53.35129],[124.34252,53.27249],[124.3837,53.24613],[124.50049,53.21368],[124.6516,53.20877],[124.68828,53.1936],[124.72017,53.14882],[124.80135,53.15516],[124.87014,53.108],[124.89623,53.11766],[124.87918,53.15386],[124.89768,53.18551],[125.07369,53.2122],[125.12743,53.20655],[125.47651,53.08351],[125.52131,53.04242],[125.62135,53.06214],[125.65019,53.02338],[125.73355,52.98165],[125.71985,52.9463],[125.66094,52.91936],[125.64647,52.88489],[125.83736,52.90672],[125.86372,52.88858],[125.86744,52.84936],[125.97694,52.765],[126.01782,52.78239],[126.08804,52.77541],[126.10407,52.75446],[126.03513,52.72712],[126.06861,52.67989],[125.98278,52.65304],[125.97317,52.63157],[125.99084,52.58643],[126.02805,52.56638],[126.08277,52.59335],[126.21233,52.53305],[126.20034,52.46708],[126.27599,52.46328],[126.35072,52.37691],[126.31573,52.31621],[126.43929,52.27529],[126.31222,52.22795],[126.31671,52.19976],[126.53117,52.15266],[126.55598,52.13064],[126.51443,52.04254],[126.46709,52.03166],[126.43929,51.99786],[126.64791,51.74126],[126.72558,51.70873],[126.70734,51.68563],[126.72372,51.63589],[126.6769,51.57659],[126.80743,51.53422],[126.83038,51.50812],[126.7927,51.45906],[126.8003,51.41896],[126.89011,51.40702],[126.90944,51.3911],[126.8975,51.36196],[126.82702,51.33899],[126.80898,51.28907],[126.84588,51.25147],[126.86991,51.25173],[126.89068,51.2715],[126.88531,51.30289],[126.89937,51.31651],[126.97218,51.324],[126.97776,51.29656],[126.90515,51.20812],[126.93177,51.06378],[127.03233,50.97245],[127.14426,50.90351],[127.22074,50.78817],[127.28735,50.75101],[127.28895,50.67515],[127.34399,50.62776],[127.36709,50.57779],[127.36481,50.55474],[127.28895,50.46602],[127.35851,50.41512],[127.35872,50.38132],[127.33066,50.3282],[127.35463,50.29864],[127.59064,50.20872],[127.57018,50.13022],[127.48667,50.02563],[127.49442,49.97318],[127.534,49.92248],[127.50672,49.8443],[127.53865,49.78993],[127.64428,49.76714],[127.67999,49.73919],[127.7036,49.66064],[127.85465,49.58488],[127.9207,49.58111],[127.96793,49.60323],[128.06715,49.54669],[128.1983,49.53899],[128.26786,49.55879],[128.34475,49.54798],[128.38351,49.57935],[128.55125,49.58845],[128.62867,49.57532],[128.67187,49.61144],[128.80168,49.56514],[128.79951,49.53186],[128.75413,49.50246],[128.76013,49.47088],[128.87919,49.47972],[128.99815,49.44127],[129.06688,49.36164],[129.11535,49.34877],[129.21788,49.39079],[129.34097,49.34117],[129.37322,49.36986],[129.3787,49.42505],[129.4806,49.42127],[129.52773,49.38484],[129.53207,49.32154],[129.55181,49.29048],[129.71118,49.27415],[129.74177,49.19571],[129.84151,49.16718],[129.84151,49.1092],[129.92068,49.08186],[129.91344,49.03954],[129.92647,49.02569],[129.97422,49.01515],[130.2018,48.8826],[130.24934,48.86451],[130.43972,48.8965],[130.46535,48.89133],[130.48664,48.86368],[130.5409,48.85345],[130.67433,48.87082],[130.52974,48.61626],[130.62017,48.5605],[130.62637,48.48226],[130.71588,48.49906],[130.75897,48.48572],[130.74327,48.41159],[130.82512,48.31777],[130.81944,48.28189],[130.77386,48.24419],[130.75277,48.18962],[130.67474,48.13593],[130.66337,48.11644],[130.67474,48.06394],[130.90698,47.88426],[130.9417,47.8206],[130.9418,47.71797],[131.02335,47.68228],[131.10386,47.67655],[131.21011,47.70921],[131.41837,47.74262],[131.50777,47.72153],[131.60326,47.66154],[131.703,47.69616],[131.91405,47.66763],[132.02536,47.69787],[132.16168,47.69001],[132.25077,47.72536],[132.33242,47.73652],[132.52465,47.70753],[132.57416,47.73959],[132.57643,47.79424],[132.668,47.88132],[132.66036,47.93134],[132.67462,47.95061],[132.72247,47.95893],[132.77487,47.93377],[132.80588,47.9362],[132.94075,48.02671],[133.03284,48.05676],[133.09196,48.10678],[133.22373,48.12598],[133.30146,48.10128],[133.52439,48.09544],[133.55994,48.11329],[133.59384,48.16419],[133.67022,48.18998],[133.73791,48.25109],[133.96643,48.27866],[134.05717,48.32718],[134.38635,48.38134],[134.47131,48.33989],[134.54262,48.35731],[134.61135,48.28543],[134.71873,48.26341],[134.68742,48.21985],[134.68297,48.13551],[134.58644,48.06115],[134.55936,48.00952],[134.60132,47.94413],[134.58768,47.92591],[134.65775,47.88669],[134.67646,47.82432],[134.76979,47.74101],[134.77258,47.71073],[134.69289,47.6489],[134.68266,47.58785],[134.62427,47.54963],[134.55688,47.47119],[134.48464,47.43018],[134.41498,47.43922],[134.31287,47.42013],[134.24507,47.35509],[134.18357,47.3275],[134.15412,47.25789],[134.19721,47.20534],[134.18977,47.17294],[134.22729,47.11774],[134.19711,47.08516],[134.12425,47.07051],[134.06916,46.98124],[134.06275,46.90685],[134.01666,46.81947],[134.04467,46.772],[134.0148,46.66503],[133.98803,46.62517],[133.90245,46.57364],[133.89191,46.52897],[133.84581,46.47065],[133.85439,46.44466],[133.90028,46.42657],[133.93387,46.39076],[133.92602,46.3713],[133.86338,46.33929],[133.87444,46.31978],[133.89635,46.3272],[133.91599,46.31299],[133.89377,46.30188],[133.90245,46.25899],[133.85532,46.23656],[133.8609,46.22359],[133.83011,46.19687],[133.79352,46.21429],[133.74101,46.15904],[133.68737,46.14184],[133.72003,46.06375],[133.65813,45.93451],[133.61761,45.93627],[133.58588,45.87632],[133.55229,45.88842],[133.51436,45.87927],[133.47726,45.8201],[133.51136,45.77855],[133.48491,45.76847],[133.47085,45.72889],[133.44305,45.70967],[133.4878,45.68667],[133.49152,45.67339],[133.45669,45.64879],[133.47726,45.62771],[133.42765,45.61525],[133.41349,45.57334],[133.37483,45.57308],[133.17981,45.49381],[133.1332,45.41961],[133.10188,45.28721],[133.09754,45.24354],[133.1272,45.15538],[133.09878,45.10779],[132.96618,45.02888],[132.92122,45.0279],[132.83978,45.05916],[131.96882,45.23398],[131.91456,45.26013],[131.85307,45.3376],[131.81803,45.33279],[131.78527,45.30473],[131.72315,45.21533],[131.62776,45.19848],[131.60523,45.16463],[131.62238,45.12365],[131.60905,45.0897],[131.43376,44.96129],[131.32948,44.96764],[131.17652,44.90341],[131.11006,44.91953],[131.05157,44.90904],[131.0248,44.86243],[130.93343,44.84171],[130.93013,44.82496],[130.97157,44.77561],[131.02139,44.74688],[131.06583,44.68203],[131.26447,44.05421],[131.21776,44.01607],[131.2067,43.97478],[131.21734,43.90848],[131.17032,43.7811],[131.19202,43.58863],[131.16071,43.53277],[131.27429,43.4512],[131.28091,43.38022],[131.24794,43.3442],[131.23771,43.26584],[131.16908,43.2017],[131.18396,43.12081],[131.07834,43.03182],[131.0743,42.9934],[131.10304,42.91043],[131.02387,42.89627],[130.99245,42.84873],[130.76952,42.86581],[130.73489,42.82449],[130.58648,42.81726],[130.40716,42.73538],[130.37967,42.69667],[130.40375,42.67755],[130.5039,42.67781],[130.57191,42.62182],[130.58452,42.5689],[130.53077,42.53048],[130.51651,42.59936],[130.50142,42.61078],[130.45512,42.58515],[130.47103,42.56975],[130.45253,42.54934],[130.43124,42.56151],[130.42773,42.59936],[130.32479,42.65029],[130.26267,42.70861],[130.24304,42.74336],[130.25647,42.86689],[130.23901,42.90224],[130.09876,42.92152],[130.13048,42.95947],[130.12149,42.98185],[130.00822,42.96056],[129.93411,43.00841],[129.87975,42.99603],[129.90704,42.97614],[129.85174,42.9619],[129.75728,42.7347],[129.75759,42.70365],[129.78756,42.67685],[129.77671,42.64719],[129.74157,42.629],[129.76307,42.60624],[129.7365,42.58515],[129.72886,42.47586],[129.70333,42.44237],[129.59171,42.44795],[129.58768,42.41137],[129.53517,42.36956],[129.43409,42.44111],[129.38624,42.42811],[129.34015,42.44547],[129.35214,42.42062],[129.3265,42.42677],[129.30377,42.38958],[129.23607,42.37437],[129.23628,42.32388],[129.1939,42.31824],[129.22801,42.29773],[129.18078,42.24311],[129.2093,42.23468],[129.21116,42.21996],[129.1198,42.14937],[129.06471,42.15376],[129.02657,42.10188],[128.96384,42.08852],[128.94007,42.03537],[128.90348,42.02627],[128.72096,42.04759],[128.70928,42.03017],[128.60789,42.03017],[128.56438,42.0008],[128.50237,41.99609],[128.26197,42.03286],[128.03459,41.99374],[128.02043,41.96506],[128.04782,41.86398],[128.09102,41.84174],[128.15913,41.71182],[128.25091,41.66012],[128.303,41.58341],[128.1857,41.40445],[128.14611,41.37634],[128.10394,41.37913],[128.1182,41.39344],[128.10394,41.39959],[128.06415,41.38843],[128.04689,41.39877],[128.05061,41.42073],[128.01516,41.41329],[128.00142,41.47086],[127.97423,41.45034],[127.94421,41.47086],[127.88147,41.46088],[127.84887,41.43132],[127.66268,41.44698],[127.63834,41.42988],[127.55881,41.45825],[127.54289,41.49132],[127.30513,41.49468],[127.26906,41.50496],[127.28978,41.51855],[127.28146,41.53106],[127.24451,41.52801],[127.2071,41.54594],[127.17971,41.51695],[127.15594,41.53054],[127.13465,41.52201],[127.10468,41.53741],[127.17971,41.58516],[127.09723,41.61245],[127.10468,41.62617],[127.05899,41.6433],[127.04897,41.67774],[127.07662,41.68079],[127.04266,41.73265],[126.94613,41.76619],[126.9298,41.80104],[126.88712,41.78492],[126.86479,41.74727],[126.80888,41.72983],[126.78862,41.69591],[126.72728,41.73691],[126.67948,41.73598],[126.72795,41.70125],[126.6693,41.66712],[126.61788,41.66593],[126.56703,41.61415],[126.57727,41.5618],[126.5013,41.43437],[126.52213,41.35195],[126.48766,41.3444],[126.5013,41.36611],[126.47252,41.36652],[126.44089,41.34838],[126.27475,41.15258],[126.12375,41.07553],[126.13817,41.05698],[126.10407,41.00918],[126.07564,40.99708],[126.04551,40.94468],[125.98056,40.91363],[126.01001,40.91389],[126.00784,40.89931],[125.93617,40.8774],[125.88522,40.89751],[125.82196,40.86975],[125.76093,40.88567],[125.68321,40.8479],[125.64482,40.81059],[125.68264,40.76888],[125.60993,40.76129],[125.57175,40.7849],[125.54648,40.72594],[125.46907,40.71292],[125.45299,40.67338],[125.41889,40.66656],[125.41325,40.63179],[125.29331,40.64858],[125.25611,40.61504],[125.02511,40.53437],[125.01529,40.50828],[125.04129,40.4611],[124.9353,40.45557],[124.89551,40.48363],[124.80084,40.39407],[124.7412,40.37035],[124.71247,40.3103],[124.6426,40.29429],[124.57088,40.25243],[124.54866,40.22091],[124.35825,40.08906],[124.36134,40.01118],[124.2942,39.93378],[124.13722,39.84235],[124.11817,39.83316],[124.08814,39.85187],[123.98569,39.81025],[123.94215,39.84056],[123.88754,39.8229],[123.80763,39.83759],[123.77304,39.81835],[123.72006,39.83055],[123.64308,39.87857],[123.65675,39.9195],[123.63095,39.904],[123.5914,39.78433],[123.56324,39.76879],[123.51319,39.7978],[123.42775,39.72663],[123.40846,39.746],[123.41708,39.82453],[123.3982,39.81835],[123.38673,39.78962],[123.31406,39.77611],[123.2732,39.81025],[123.25717,39.80109],[123.25204,39.81708],[123.23585,39.80244],[123.26539,39.7541],[123.20419,39.74506],[123.26914,39.70759],[123.22316,39.67976],[123.12843,39.66486],[123.108,39.69013],[123.05812,39.67475],[123.00912,39.68049],[122.97763,39.66625],[122.99635,39.64964],[122.96412,39.61164],[122.87159,39.60578],[122.84864,39.61787],[122.7798,39.56045],[122.68824,39.51862],[122.64845,39.53242],[122.5879,39.46699],[122.5621,39.48676],[122.55543,39.456],[122.46982,39.41669],[122.39633,39.41104],[122.29005,39.35098],[122.26743,39.31802],[122.25147,39.32368],[122.24025,39.28563],[122.115,39.20075],[122.1045,39.15913],[122.16269,39.14614],[122.04786,39.09919],[122.04233,39.05972],[121.98805,39.06293],[121.94296,39.03628],[121.88844,39.04247],[121.92319,39.00898],[121.82691,39.03628],[121.89527,38.98102],[121.87135,38.95063],[121.84791,38.94745],[121.81153,38.97285],[121.77247,39.02196],[121.7247,39.03018],[121.73878,39.00153],[121.68019,39.00898],[121.63575,38.99531],[121.65602,38.99063],[121.6526,38.96345],[121.58741,38.94009],[121.69361,38.90961],[121.6963,38.86294],[121.55275,38.85814],[121.48495,38.80988],[121.34474,38.81464],[121.28761,38.78807],[121.23666,38.79849],[121.21754,38.77558],[121.24472,38.77558],[121.17954,38.72093],[121.15309,38.72761],[121.11451,38.76813],[121.13925,38.7895],[121.11451,38.85814],[121.15235,38.8863],[121.14853,38.89908],[121.08619,38.90571],[121.11793,38.94343],[121.17954,38.95433],[121.21949,38.9405],[121.33432,38.96857],[121.33424,39.0266],[121.37485,39.04418],[121.42807,39.01472],[121.4673,39.01512],[121.67408,39.08804],[121.68979,39.11815],[121.681,39.13642],[121.64194,39.17621],[121.59262,39.19221],[121.60792,39.20075],[121.60165,39.22126],[121.63307,39.23615],[121.58994,39.26405],[121.68019,39.27521],[121.7361,39.31175],[121.73129,39.33047],[121.7798,39.37149],[121.83749,39.36465],[121.94296,39.39936],[121.93124,39.41718],[121.84889,39.38736],[121.70777,39.39566],[121.71778,39.37149],[121.52589,39.36465],[121.49195,39.38898],[121.43605,39.50861],[121.35402,39.5222],[121.30616,39.50178],[121.2234,39.52863],[121.30616,39.59797],[121.4498,39.6151],[121.47071,39.63215],[121.46388,39.64582],[121.49817,39.65292],[121.53329,39.6223],[121.53956,39.63898],[121.52589,39.65266],[121.55014,39.67866],[121.50685,39.69432],[121.45729,39.74848],[121.48461,39.764],[121.47071,39.81025],[121.50992,39.82403],[121.5359,39.86176],[121.61609,39.8717],[121.68491,39.92251],[121.77442,39.92084],[121.80966,39.96503],[121.87379,39.9914],[121.87477,40.0163],[121.82691,40.02253],[121.92947,40.07729],[121.93312,40.10139],[121.99567,40.12389],[122.00636,40.15632],[121.99147,40.187],[122.09165,40.22721],[122.10612,40.31012],[122.21103,40.3806],[122.20362,40.42723],[122.25009,40.43061],[122.25147,40.45392],[122.2881,40.47285],[122.30185,40.50235],[122.27564,40.54365],[122.22486,40.57852],[122.15043,40.60386],[122.13759,40.62402],[122.16798,40.65351],[122.15943,40.70161],[122.12184,40.68012],[122.04908,40.74608],[121.85108,40.83226],[121.82594,40.94819],[121.8589,40.96748],[121.86793,40.9958],[121.77771,40.93497],[121.78006,40.90791],[121.73955,40.84908],[121.57504,40.8415],[121.51043,40.87936],[121.45021,40.88593],[121.42978,40.9138],[121.36134,40.93964],[121.29933,40.8996],[121.25839,40.92064],[121.24472,40.9064],[121.22495,40.92707],[121.17579,40.92231],[121.07993,40.86298],[121.08027,40.8284],[121.04985,40.7974],[121.02963,40.82648],[120.98194,40.82681],[120.93954,40.74055],[121.02074,40.71933],[120.82532,40.67403],[120.79843,40.59968],[120.67879,40.52192],[120.65911,40.48376],[120.6041,40.46516],[120.59738,40.42242],[120.54566,40.40241],[120.54753,40.37616],[120.5884,40.38432],[120.59115,40.3673],[120.50176,40.32691],[120.50489,40.24909],[120.47202,40.2143],[120.43605,40.19449],[120.30903,40.17967],[120.26132,40.14672],[119.95028,40.0565],[119.91531,40.03615],[119.912,39.99809],[119.77442,39.95051],[119.6609,39.93268],[119.53559,39.89083],[119.51987,39.8583],[119.5307,39.8147],[119.4668,39.81485],[119.42941,39.79531],[119.34986,39.73176],[119.28492,39.59544],[119.24537,39.59447],[119.20859,39.56957],[119.209,39.5502],[119.24301,39.5707],[119.22934,39.53596],[119.2624,39.57751],[119.2814,39.57501],[119.25642,39.49311],[119.30508,39.43298],[119.27943,39.38401],[119.25566,39.36552],[119.1848,39.35481],[119.01564,39.18977],[118.92604,39.1306],[118.8943,39.12442],[118.96998,39.19331],[118.88063,39.1728],[118.87379,39.21381],[118.79599,39.15178],[118.61936,39.16104],[118.52711,39.14924],[118.52915,39.12465],[118.3261,39.03093],[118.23529,39.0531],[118.14053,39.17283],[118.0964,39.20239],[118.02802,39.2198],[117.88559,39.20112],[117.75733,39.11445],[117.71542,39.1114],[117.75148,39.06791],[117.77526,38.98106],[117.70191,38.99531],[117.70875,38.98102],[117.584,38.81159],[117.54355,38.62132],[117.56597,38.61251],[117.71608,38.37851],[117.83717,38.2709],[117.9417,38.23395],[117.97754,38.19484],[118.0072,38.19701],[118.05373,38.17325],[118.06521,38.14308],[118.21583,38.14496],[118.44893,38.10779],[118.54298,38.06835],[118.65487,38.1457],[118.76035,38.15513],[118.8401,38.15257],[118.95004,38.09726],[118.92481,38.05777],[118.95615,38.0336],[118.99659,37.96312],[119.04841,37.94798],[119.09249,37.91277],[119.11694,37.85327],[119.24308,37.76948],[119.2596,37.71585],[119.25566,37.67187],[119.21774,37.67525],[119.12014,37.73751],[119.06947,37.74063],[119.03713,37.72209],[118.96998,37.59345],[118.94459,37.37812],[118.96119,37.29564],[119.14552,37.17886],[119.28712,37.13727],[119.44117,37.12055],[119.52451,37.13907],[119.7706,37.15184],[119.89731,37.24942],[119.84628,37.34369],[119.8589,37.36017],[120.05983,37.44042],[120.18759,37.51179],[120.32252,37.63239],[120.30641,37.66519],[120.21567,37.68407],[120.3685,37.69473],[120.46339,37.74775],[120.55616,37.76138],[120.73805,37.83397],[120.83668,37.81598],[120.8777,37.82966],[120.92579,37.81928],[120.94077,37.80646],[120.9288,37.78872],[120.94093,37.77399],[121.00538,37.74429],[121.03256,37.71039],[121.13795,37.70116],[121.14853,37.6894],[121.13014,37.6518],[121.14853,37.63788],[121.13437,37.61054],[121.17164,37.57709],[121.30812,37.58124],[121.37452,37.61054],[121.35402,37.63788],[121.39356,37.62246],[121.42978,37.59687],[121.39845,37.59488],[121.38168,37.56611],[121.44679,37.5196],[121.4769,37.468],[121.53712,37.45624],[121.57032,37.42866],[121.67612,37.43919],[121.63429,37.46239],[121.58741,37.45356],[121.63502,37.48127],[121.73243,37.46174],[121.93784,37.46426],[121.95729,37.4597],[121.9498,37.4256],[121.98805,37.4256],[121.99741,37.43407],[121.97779,37.47167],[122.02564,37.52802],[122.07179,37.5445],[122.06715,37.56273],[122.09506,37.54914],[122.12184,37.56273],[122.15187,37.53286],[122.12989,37.48347],[122.1832,37.41816],[122.22421,37.45799],[122.24537,37.4597],[122.29648,37.41816],[122.47804,37.4256],[122.48805,37.41193],[122.57057,37.40156],[122.6124,37.41861],[122.68824,37.40982],[122.67897,37.37466],[122.6561,37.38459],[122.57081,37.34479],[122.59474,37.29523],[122.54965,37.31977],[122.56007,37.27473],[122.62892,37.19282],[122.57431,37.18537],[122.56324,37.17316],[122.57431,37.14444],[122.51002,37.15522],[122.48268,37.13996],[122.47071,37.1103],[122.45395,37.15428],[122.43637,37.08979],[122.45704,37.0659],[122.40235,37.02147],[122.42433,37.00365],[122.43637,37.02774],[122.54005,37.02774],[122.53272,36.92593],[122.50742,36.89802],[122.51271,36.93464],[122.49855,36.93891],[122.45664,36.92064],[122.40577,36.92219],[122.43637,36.87067],[122.37973,36.8614],[122.35523,36.82909],[122.31983,36.82294],[122.18531,36.84992],[122.19687,36.89802],[122.26588,36.89802],[122.16269,36.94579],[122.1797,37.00581],[122.16611,37.01411],[122.14999,37.00532],[122.14291,36.94579],[122.09433,36.95091],[122.0809,36.91657],[122.03305,36.89802],[122.04916,36.97993],[121.92628,36.9936],[121.91489,36.98583],[121.9498,36.93891],[121.78598,36.89118],[121.72804,36.83592],[121.64942,36.81607],[121.68417,36.84276],[121.63746,36.83137],[121.62452,36.81477],[121.62525,36.74038],[121.59425,36.76765],[121.52589,36.75397],[121.49643,36.77448],[121.60719,36.84496],[121.54648,36.83592],[121.51157,36.80858],[121.45948,36.82868],[121.47071,36.78962],[121.45338,36.75739],[121.40895,36.75397],[121.37778,36.73151],[121.37941,36.71699],[121.4017,36.71239],[121.31422,36.71182],[121.15772,36.66861],[121.08017,36.61001],[121.03248,36.62425],[121.02353,36.58393],[120.9288,36.59634],[120.93336,36.62027],[120.8506,36.65095],[120.75058,36.61685],[120.75025,36.54076],[120.77866,36.54108],[120.85035,36.60456],[120.92026,36.55329],[120.9275,36.5329],[120.96363,36.54792],[120.93629,36.49702],[120.94996,36.45917],[120.90626,36.45238],[120.89829,36.41161],[120.86744,36.37718],[120.82643,36.43252],[120.83204,36.45527],[120.80689,36.46393],[120.75058,36.45917],[120.71412,36.4287],[120.68653,36.38715],[120.72218,36.33055],[120.66196,36.33633],[120.64365,36.3225],[120.70485,36.15624],[120.62778,36.11652],[120.55161,36.11371],[120.53623,36.09146],[120.47364,36.08926],[120.35353,36.04084],[120.292,36.06196],[120.36158,36.19164],[120.32277,36.22639],[120.30641,36.27424],[120.27353,36.23428],[120.27516,36.20409],[120.29884,36.19164],[120.28452,36.17597],[120.21632,36.19912],[120.21909,36.22191],[120.11728,36.20966],[120.09718,36.22639],[120.08595,36.20913],[120.10922,36.18879],[120.08969,36.11172],[120.16847,36.06196],[120.1753,35.99364],[120.19777,35.9853],[120.22999,35.9999],[120.2295,35.97281],[120.24277,35.97358],[120.28492,36.01093],[120.30307,35.99404],[120.29884,35.97313],[120.22682,35.94184],[120.1753,35.89004],[120.18897,35.94526],[120.13575,35.90412],[120.09718,35.90082],[120.03883,35.84284],[120.00294,35.72777],[119.96998,35.73237],[119.93775,35.76114],[119.90821,35.74486],[119.90016,35.73627],[119.93564,35.70502],[119.90162,35.67154],[119.90455,35.62458],[119.8602,35.60611],[119.83408,35.63532],[119.8401,35.6579],[119.81162,35.64789],[119.74391,35.57591],[119.74838,35.60932],[119.73251,35.62084],[119.64796,35.58393],[119.55152,35.38003],[119.50766,35.33234],[119.42392,35.31346],[119.358,35.09711],[119.2212,35.06403],[119.19288,35.00043],[119.20338,34.93163],[119.1887,34.85627],[119.20191,34.78498],[119.18906,34.76655],[119.19223,34.71821],[119.20981,34.71857],[119.22934,34.75971],[119.31178,34.75507],[119.34669,34.78083],[119.38771,34.75348],[119.44223,34.74604],[119.47421,34.71528],[119.45655,34.68456],[119.64772,34.53384],[119.72885,34.52412],[119.82618,34.4704],[119.93995,34.45791],[120.0787,34.37751],[120.25709,34.31184],[120.32522,34.1113],[120.37135,34.03604],[120.41334,33.89737],[120.39967,33.86933],[120.43141,33.84602],[120.44972,33.78644],[120.50489,33.73558],[120.51173,33.65672],[120.48373,33.64997],[120.63437,33.46695],[120.66725,33.3251],[120.69508,33.29316],[120.75025,33.2696],[120.73023,33.24213],[120.7435,33.20864],[120.73023,33.2049],[120.80698,33.13776],[120.79225,33.10871],[120.81894,33.09219],[120.82594,33.03018],[120.86744,33.03238],[120.89576,33.01358],[120.9048,32.81265],[120.85239,32.75601],[120.86044,32.68977],[120.8401,32.72451],[120.83033,32.69766],[120.90661,32.65971],[120.9112,32.63012],[120.94389,32.60522],[121.08619,32.53604],[121.10084,32.49795],[121.12021,32.50739],[121.15726,32.4956],[121.35414,32.41482],[121.38941,32.38495],[121.41928,32.25031],[121.36085,32.23163],[121.39869,32.16893],[121.42514,32.16405],[121.44337,32.11433],[121.53081,32.10852],[121.65537,32.06293],[121.79864,31.97048],[121.87496,31.86372],[121.87469,31.8259],[121.92206,31.75438],[121.93038,31.72671],[121.91484,31.70643],[121.81731,31.69095],[121.60719,31.74855],[121.28642,31.89059],[121.11509,31.79633],[121.03993,31.81933],[120.97782,31.81436],[120.94056,31.84465],[120.94142,31.89252],[120.91928,31.89936],[120.8108,32.02261],[120.63438,32.07701],[120.49076,32.08245],[120.36158,32.02558],[120.2671,31.94868],[120.22373,31.93683],[120.13136,31.94318],[120.05234,31.97093],[119.99204,32.01586],[119.9122,32.12373],[119.92213,32.21743],[119.90048,32.19819],[119.87428,32.26512],[119.84213,32.29906],[119.75807,32.32697],[119.69239,32.31977],[119.64088,32.35883],[119.61289,32.35456],[119.66074,32.32722],[119.68539,32.29243],[119.64902,32.21955],[119.60605,32.19693],[119.64527,32.19522],[119.69044,32.23534],[119.78858,32.19864],[119.76376,32.1625],[119.77247,32.12678],[119.85556,32.08698],[120.0064,31.95031],[120.13095,31.9123],[120.22999,31.90888],[120.34117,31.95632],[120.45578,31.98252],[120.45541,32.02143],[120.46949,32.02513],[120.60426,31.99144],[120.68995,31.99824],[120.75035,31.97834],[120.8248,31.8287],[120.80775,31.80974],[120.75147,31.82469],[120.71713,31.86107],[120.73146,31.81387],[120.8319,31.77777],[121.01376,31.74303],[121.1444,31.68466],[121.24472,31.60041],[121.32032,31.50483],[121.54623,31.36612],[121.72516,31.28157],[121.87703,31.09914],[121.95082,30.98208],[121.97738,30.91492],[121.96008,30.87426],[121.54294,30.81649],[121.4106,30.76739],[121.33351,30.70307],[121.20289,30.66511],[121.12135,30.593],[121.0474,30.58276],[120.99815,30.55792],[120.93165,30.47305],[120.93954,30.42194],[120.89479,30.40127],[120.91513,30.37458],[120.88819,30.36856],[120.83179,30.30525],[120.78743,30.29362],[120.75399,30.32706],[120.74496,30.35961],[120.69815,30.39395],[120.47915,30.40352],[120.43031,30.38984],[120.37897,30.27352],[120.2579,30.29808],[120.14709,30.19889],[120.17897,30.20067],[120.27439,30.27762],[120.29552,30.27873],[120.35034,30.24073],[120.38472,30.24959],[120.45049,30.36886],[120.62161,30.37256],[120.65167,30.35927],[120.69869,30.2518],[120.7344,30.23221],[120.63179,30.12239],[120.68246,30.08491],[120.76498,30.08661],[120.79225,30.06607],[120.7837,30.0893],[120.688,30.09781],[120.64625,30.12694],[120.75142,30.22563],[120.81349,30.21325],[120.82862,30.18499],[120.89281,30.1698],[120.99439,30.21109],[121.10914,30.28278],[121.1497,30.32575],[121.2541,30.34839],[121.37738,30.32506],[121.42959,30.29298],[121.50685,30.22003],[121.65831,30.01839],[121.73234,29.9786],[121.72193,29.96052],[121.76748,29.97318],[121.91488,29.9187],[121.93556,29.88736],[122.05624,29.88736],[122.12914,29.90378],[122.13641,29.88453],[122.11476,29.87155],[121.98304,29.82318],[121.90217,29.75254],[121.87315,29.75383],[121.86245,29.708],[121.77647,29.65263],[121.68735,29.56265],[121.59254,29.54361],[121.57431,29.55215],[121.53082,29.51001],[121.51104,29.54899],[121.44337,29.52424],[121.45532,29.48935],[121.42784,29.46795],[121.44259,29.41537],[121.46507,29.42089],[121.47975,29.47476],[121.50489,29.48383],[121.49179,29.45596],[121.51368,29.45197],[121.51173,29.43757],[121.47763,29.41275],[121.48486,29.39054],[121.53132,29.41704],[121.56964,29.47457],[121.64267,29.49522],[121.71095,29.53864],[121.73129,29.51801],[121.764,29.53076],[121.75722,29.49905],[121.77838,29.46942],[121.81199,29.49012],[121.84482,29.5362],[121.81397,29.55215],[121.74496,29.54466],[121.79612,29.60059],[121.91578,29.64085],[121.9712,29.59333],[121.97096,29.56892],[121.94117,29.55117],[121.9498,29.53848],[121.91041,29.52456],[121.97096,29.44912],[121.95729,29.40815],[121.93629,29.42772],[121.92256,29.41815],[121.91625,29.34894],[121.93141,29.32388],[121.90895,29.31879],[121.91578,29.30512],[121.90048,29.29865],[121.93995,29.26415],[121.97438,29.28461],[121.99147,29.25731],[121.95322,29.24966],[121.95729,29.21638],[121.94296,29.22321],[121.94296,29.19587],[121.84059,29.16112],[121.78535,29.16809],[121.79965,29.30512],[121.84059,29.31879],[121.83066,29.33389],[121.79965,29.31879],[121.78818,29.32856],[121.79965,29.37397],[121.78663,29.3745],[121.75864,29.35977],[121.77431,29.25389],[121.72433,29.18203],[121.65246,29.18302],[121.63917,29.21727],[121.65455,29.29999],[121.64039,29.30663],[121.62485,29.30146],[121.62892,29.18842],[121.57431,29.30512],[121.56202,29.29584],[121.57431,29.27778],[121.52589,29.27778],[121.53639,29.25829],[121.50489,29.20893],[121.50351,29.17894],[121.49098,29.17597],[121.48023,29.19721],[121.45639,29.16112],[121.43141,29.17219],[121.40854,29.16112],[121.45639,29.13377],[121.4017,29.10651],[121.62143,29.11327],[121.61476,29.05805],[121.53688,29.06118],[121.53207,29.02049],[121.5486,29.02143],[121.53956,29.03758],[121.55738,29.04902],[121.61744,29.03998],[121.64194,29.05126],[121.68979,29.01708],[121.65382,28.98859],[121.68417,28.95563],[121.71778,28.9488],[121.68417,28.9147],[121.60532,28.94257],[121.58741,28.96312],[121.57081,28.93008],[121.51686,28.95637],[121.49179,28.92837],[121.50489,28.93521],[121.51767,28.91279],[121.58058,28.88679],[121.61085,28.89118],[121.62892,28.87714],[121.62355,28.84227],[121.66993,28.82534],[121.63575,28.80484],[121.66261,28.79059],[121.61826,28.76195],[121.62143,28.73591],[121.50278,28.70039],[121.35117,28.71186],[121.29029,28.75161],[121.23789,28.76386],[121.21445,28.80732],[121.14235,28.84585],[121.15968,28.81354],[121.20606,28.79906],[121.23121,28.76016],[121.29933,28.72289],[121.29868,28.70124],[121.25839,28.67512],[121.365,28.69603],[121.38136,28.68195],[121.41383,28.6966],[121.51013,28.65706],[121.53843,28.57145],[121.58162,28.50469],[121.51157,28.48949],[121.60774,28.4772],[121.58378,28.44295],[121.63226,28.37945],[121.63404,28.34724],[121.65634,28.3393],[121.58554,28.25682],[121.58058,28.30085],[121.54149,28.32179],[121.47309,28.3306],[121.43605,28.2971],[121.40895,28.30492],[121.39552,28.28408],[121.41879,28.26972],[121.41603,28.25617],[121.36484,28.22582],[121.35402,28.18789],[121.37452,28.19526],[121.37403,28.17259],[121.34579,28.14517],[121.28785,28.18089],[121.2617,28.22579],[121.22517,28.23886],[121.24887,28.27021],[121.23683,28.3061],[121.20314,28.33186],[121.21754,28.35236],[121.24415,28.34911],[121.26466,28.36603],[121.21412,28.36058],[121.16896,28.38483],[121.16896,28.35977],[121.1443,28.34357],[121.13025,28.30555],[121.07211,28.29169],[121.06666,28.31143],[121.02499,28.30459],[121.05812,28.27993],[121.1218,28.28436],[121.12475,28.23552],[121.08584,28.1665],[121.00938,28.10313],[120.99351,28.02578],[120.96523,27.98715],[120.9214,27.98168],[120.87998,27.99873],[120.81959,27.98924],[120.73105,28.03583],[120.60605,28.04222],[120.59018,28.05439],[120.577,28.11563],[120.55958,28.11278],[120.58017,28.08979],[120.58896,28.02668],[120.6736,28.02172],[120.75058,27.97557],[120.8353,27.95596],[120.86376,27.91811],[120.86836,27.88256],[120.80749,27.83483],[120.72261,27.71591],[120.70426,27.71284],[120.63527,27.76333],[120.67245,27.71149],[120.67563,27.65965],[120.66648,27.62536],[120.59657,27.57028],[120.61846,27.51642],[120.68198,27.48159],[120.6465,27.44449],[120.63527,27.37971],[120.54591,27.40327],[120.53964,27.36522],[120.50489,27.34496],[120.54933,27.32103],[120.54396,27.28596],[120.51783,27.26301],[120.51783,27.20161],[120.46835,27.1822],[120.45973,27.22045],[120.42172,27.22207],[120.42921,27.17365],[120.40675,27.18423],[120.37623,27.23627],[120.34726,27.24942],[120.30307,27.24226],[120.2881,27.26447],[120.31935,27.29035],[120.29884,27.2978],[120.31186,27.31769],[120.28517,27.30402],[120.26401,27.32445],[120.2715,27.30402],[120.25856,27.29999],[120.22373,27.31147],[120.24366,27.29035],[120.22568,27.26862],[120.19581,27.2978],[120.19581,27.26301],[120.1753,27.23884],[120.18238,27.21686],[120.2566,27.2569],[120.30641,27.21528],[120.34726,27.24258],[120.36549,27.20962],[120.34726,27.18049],[120.36988,27.18749],[120.42172,27.14696],[120.37387,27.14696],[120.40797,27.13955],[120.36704,27.12523],[120.38136,27.11904],[120.37387,27.10602],[120.39503,27.10602],[120.37753,27.09858],[120.28272,27.11229],[120.24366,27.13272],[120.27833,27.06379],[120.25782,27.07266],[120.21632,27.0371],[120.24155,27.034],[120.25522,26.99706],[120.2374,26.98188],[120.14869,26.98188],[120.18702,26.96353],[120.23569,26.96638],[120.2374,26.92723],[120.21909,26.91543],[120.12574,26.92731],[120.11362,26.91877],[120.1207,26.89989],[120.09555,26.88727],[120.03199,26.89989],[120.05527,26.88939],[120.02516,26.86018],[120.02451,26.83788],[120.12818,26.79694],[120.09775,26.75849],[120.10019,26.74291],[120.14869,26.72118],[120.10206,26.68915],[120.1207,26.64606],[120.13437,26.64606],[120.08041,26.61742],[120.07968,26.64606],[120.0149,26.61127],[119.95997,26.61127],[119.93564,26.56045],[119.88429,26.52318],[119.84718,26.52363],[119.83261,26.55048],[119.85377,26.56411],[119.85377,26.59829],[119.98414,26.65351],[119.97682,26.67402],[120.01148,26.72118],[120.03232,26.71662],[120.07968,26.79694],[119.92514,26.79218],[119.93564,26.75902],[119.88803,26.68724],[119.90162,26.65974],[119.87965,26.65534],[119.85694,26.65351],[119.85377,26.68769],[119.82919,26.71499],[119.79176,26.68769],[119.80226,26.76679],[119.8401,26.79002],[119.79176,26.79002],[119.79737,26.81639],[119.84628,26.8522],[119.78199,26.82852],[119.7562,26.73774],[119.73023,26.74291],[119.71201,26.80793],[119.6858,26.812],[119.67799,26.76313],[119.72739,26.71792],[119.72495,26.69782],[119.64601,26.75332],[119.62656,26.74909],[119.62184,26.78677],[119.60605,26.79694],[119.57553,26.78803],[119.55209,26.74909],[119.55893,26.7082],[119.60605,26.69452],[119.59986,26.64606],[119.55893,26.66657],[119.54111,26.65632],[119.55421,26.61518],[119.59986,26.57038],[119.67506,26.6188],[119.7352,26.61197],[119.73642,26.59146],[119.78492,26.57783],[119.78492,26.55048],[119.7706,26.56411],[119.76588,26.53437],[119.7986,26.51577],[119.78492,26.50202],[119.81886,26.45319],[119.81227,26.43378],[119.79176,26.43378],[119.79176,26.41332],[119.76197,26.40746],[119.73764,26.44367],[119.68979,26.45946],[119.65366,26.49803],[119.62037,26.46796],[119.5796,26.47089],[119.56642,26.4549],[119.57309,26.43781],[119.63087,26.44872],[119.64845,26.43745],[119.65553,26.35562],[119.68873,26.33759],[119.68189,26.31713],[119.74708,26.32762],[119.75441,26.37206],[119.7706,26.36494],[119.79176,26.40648],[119.82578,26.42695],[119.85133,26.39281],[119.8401,26.37857],[119.89479,26.35871],[119.94996,26.36494],[119.87745,26.31338],[119.85475,26.32754],[119.80112,26.31757],[119.78492,26.29662],[119.80543,26.29043],[119.79273,26.28408],[119.76441,26.29662],[119.65431,26.27241],[119.63412,26.25507],[119.66822,26.24201],[119.66822,26.2215],[119.60011,26.17634],[119.59986,26.14582],[119.56113,26.12499],[119.45851,25.98249],[119.43556,25.98135],[119.41505,26.00926],[119.32691,26.04804],[119.27711,26.05292],[119.21583,26.10008],[119.09352,26.14582],[119.15732,26.09077],[119.19516,26.08759],[119.24684,25.96723],[119.32985,25.94428],[119.37753,25.94139],[119.44093,25.96381],[119.49,25.99421],[119.51905,26.0445],[119.56642,26.05024],[119.64845,26.01545],[119.67506,26.02228],[119.6954,26.00263],[119.7024,25.98505],[119.67506,25.92609],[119.61647,25.89265],[119.59986,25.84418],[119.58408,25.87694],[119.57423,25.88491],[119.56642,25.87206],[119.62086,25.76032],[119.59645,25.73151],[119.58994,25.68716],[119.5726,25.67967],[119.53541,25.70588],[119.47706,25.67967],[119.44907,25.68716],[119.46705,25.61392],[119.48325,25.62568],[119.49244,25.56477],[119.51735,25.55679],[119.53443,25.57587],[119.55844,25.56379],[119.57944,25.58466],[119.56837,25.54743],[119.59311,25.52949],[119.56642,25.51581],[119.59986,25.50837],[119.5726,25.48786],[119.60361,25.49116],[119.59986,25.47484],[119.62802,25.46137],[119.61402,25.43879],[119.63412,25.43325],[119.64601,25.39256],[119.62037,25.36498],[119.65455,25.35814],[119.57944,25.34447],[119.59986,25.37116],[119.57602,25.46735],[119.53858,25.46117],[119.53598,25.41914],[119.5582,25.40461],[119.54444,25.37718],[119.48325,25.37116],[119.50733,25.47443],[119.46616,25.47484],[119.44703,25.43879],[119.43312,25.45954],[119.44907,25.50528],[119.35654,25.52692],[119.38087,25.59089],[119.36036,25.5773],[119.34303,25.6092],[119.30519,25.60456],[119.32138,25.58735],[119.27784,25.57046],[119.2378,25.48176],[119.10255,25.42031],[119.17319,25.32514],[119.20338,25.35814],[119.22316,25.3166],[119.18149,25.28925],[119.20126,25.25971],[119.22584,25.27969],[119.24301,25.33023],[119.26515,25.31757],[119.27784,25.33023],[119.31886,25.27936],[119.3475,25.26777],[119.34425,25.24238],[119.28126,25.24144],[119.27247,25.23176],[119.28883,25.18911],[119.25563,25.1759],[119.22938,25.19321],[119.17563,25.1765],[119.14198,25.22098],[119.1161,25.21378],[119.10418,25.18295],[119.1338,25.13841],[119.15309,25.13972],[119.1338,25.12474],[119.09327,25.13565],[119.08546,25.12474],[119.09913,25.11115],[119.06479,25.12474],[119.07919,25.15209],[119.06471,25.16254],[119.04444,25.12474],[119.02906,25.14053],[119.06886,25.21674],[118.96933,25.22639],[118.97999,25.24486],[119.02495,25.25929],[119.0273,25.27824],[118.98365,25.28303],[118.87558,25.24384],[118.86484,25.22797],[118.91114,25.21723],[118.91529,25.17939],[118.94825,25.19717],[118.97462,25.18256],[118.94296,25.10883],[118.85768,25.09805],[118.84913,25.0738],[118.87794,25.04072],[118.9537,25.003],[118.97999,25.01179],[119.00416,25.05024],[118.98325,24.9951],[118.99733,24.95344],[119.01767,24.96027],[118.97706,24.93403],[118.96371,24.95441],[118.91163,24.95344],[118.90992,24.90717],[118.93417,24.88605],[118.97324,24.89379],[118.97617,24.87775],[118.952,24.87075],[118.79208,24.8911],[118.7776,24.8852],[118.78012,24.86782],[118.71608,24.84357],[118.69809,24.85952],[118.7295,24.90054],[118.69557,24.94042],[118.68881,24.89574],[118.66773,24.95344],[118.64039,24.91559],[118.67115,24.87466],[118.62412,24.86782],[118.57203,24.8852],[118.6128,24.85578],[118.63795,24.77212],[118.70175,24.81004],[118.71998,24.76936],[118.76295,24.76268],[118.74684,24.72069],[118.68539,24.66649],[118.65406,24.69343],[118.6421,24.6787],[118.63356,24.62787],[118.66415,24.61225],[118.66147,24.59032],[118.62045,24.54548],[118.55397,24.51805],[118.55445,24.55321],[118.57814,24.56021],[118.50123,24.60814],[118.42807,24.63133],[118.43124,24.69969],[118.40089,24.67231],[118.42189,24.6245],[118.4048,24.60664],[118.35165,24.58234],[118.28403,24.59781],[118.27296,24.57441],[118.24985,24.57612],[118.24179,24.54377],[118.219,24.54145],[118.18116,24.57612],[118.18165,24.59781],[118.21518,24.61481],[118.17433,24.6245],[118.18759,24.65737],[118.16049,24.69343],[118.15748,24.66267],[118.14145,24.65697],[118.11036,24.58405],[118.04444,24.61766],[118.03736,24.56061],[118.00294,24.56245],[118.00929,24.58072],[117.97486,24.55272],[118.05812,24.51585],[117.99952,24.47085],[118.02174,24.44575],[117.86769,24.48957],[117.82594,24.48045],[117.90113,24.45319],[117.79127,24.46748],[117.90732,24.38491],[118.04298,24.40668],[118.04078,24.36563],[118.01588,24.32685],[118.05299,24.34691],[118.09059,24.34146],[118.12647,24.26203],[118.07179,24.25458],[117.98927,24.1931],[117.97877,24.1621],[117.96787,24.15982],[117.94361,24.21259],[117.90732,24.18626],[117.93775,24.16169],[117.89576,24.08661],[117.89365,24.02802],[117.83912,24.02363],[117.77198,23.95893],[117.74806,23.90754],[117.72926,23.91938],[117.73789,23.95051],[117.79811,24.0218],[117.76173,24.01484],[117.77613,24.0373],[117.76328,24.07026],[117.75001,24.0373],[117.72413,24.03376],[117.66708,24.06273],[117.6797,24.03905],[117.70875,24.03547],[117.68849,23.9975],[117.69508,23.95356],[117.64863,23.90923],[117.62554,23.84015],[117.63927,23.7941],[117.59167,23.73706],[117.59816,23.85244],[117.57106,23.90327],[117.45485,23.92561],[117.4166,23.94684],[117.40968,23.9134],[117.46852,23.91193],[117.4996,23.87407],[117.49007,23.83258],[117.46348,23.78829],[117.43433,23.77592],[117.38242,23.79523],[117.3191,23.76162],[117.29257,23.7895],[117.27735,23.78221],[117.29233,23.76215],[117.26365,23.65543],[117.27912,23.63627],[117.25709,23.61562],[117.23896,23.60547],[117.24409,23.65755],[117.2295,23.69282],[117.20851,23.66494],[117.2192,23.62963],[117.15083,23.60687],[117.12846,23.57235],[117.08423,23.55061],[117.06841,23.65127],[117.02345,23.65868],[116.9507,23.61088],[116.91757,23.63793],[116.94093,23.65868],[116.91652,23.66275],[116.87208,23.60688],[116.88331,23.55964],[116.8497,23.5113],[116.87262,23.47381],[116.86663,23.45023],[116.77322,23.34943],[116.65784,23.35445],[116.56894,23.39102],[116.54322,23.42121],[116.5232,23.41852],[116.5232,23.37751],[116.57301,23.32368],[116.64189,23.31883],[116.69124,23.33898],[116.70941,23.31977],[116.74863,23.32629],[116.77711,23.24038],[116.80482,23.23612],[116.78395,23.21239],[116.70558,23.2766],[116.66725,23.28132],[116.7151,23.2438],[116.67117,23.21464],[116.64675,23.16299],[116.61522,23.17835],[116.61151,23.23873],[116.56998,23.24697],[116.50888,23.23412],[116.52699,23.20062],[116.56397,23.22297],[116.6045,23.20956],[116.54512,23.14477],[116.53365,23.10913],[116.55166,23.08672],[116.56325,23.01877],[116.52194,22.99841],[116.49469,22.93935],[116.45851,22.94742],[116.37961,22.92898],[116.28906,22.97894],[116.21363,22.98281],[116.20102,22.97346],[116.28146,22.95786],[116.14491,22.84782],[116.09569,22.8259],[116.09125,22.86841],[116.12372,22.89473],[116.04211,22.93292],[116.08725,22.88969],[116.07618,22.86787],[115.95801,22.80474],[115.8835,22.79462],[115.81003,22.74609],[115.78804,22.79784],[115.81357,22.77627],[115.80478,22.81509],[115.73687,22.85167],[115.69704,22.85067],[115.70358,22.86932],[115.68427,22.87778],[115.69264,22.90009],[115.72128,22.90453],[115.73032,22.93423],[115.69199,22.92902],[115.65333,22.89374],[115.57789,22.87727],[115.56056,22.84931],[115.53297,22.86205],[115.54762,22.89541],[115.50563,22.89777],[115.45094,22.93187],[115.43019,22.9182],[115.40602,22.92504],[115.42726,22.90062],[115.4642,22.91201],[115.483,22.90184],[115.49415,22.86078],[115.46803,22.84931],[115.55942,22.83609],[115.61207,22.86359],[115.52702,22.76704],[115.52394,22.74177],[115.55559,22.71437],[115.56732,22.74728],[115.5901,22.73721],[115.56251,22.70563],[115.57522,22.66834],[115.53616,22.66812],[115.46482,22.70103],[115.37351,22.69418],[115.33847,22.7331],[115.42413,22.76199],[115.41121,22.78827],[115.36399,22.76365],[115.33057,22.78854],[115.23339,22.78821],[115.23277,22.80534],[115.32016,22.85053],[115.31715,22.90453],[115.3038,22.90302],[115.24562,22.83535],[115.17294,22.82119],[115.18027,22.78481],[115.13288,22.81145],[115.06548,22.79599],[115.01783,22.73704],[115.0289,22.70838],[114.98534,22.68931],[114.95467,22.70354],[114.94166,22.75385],[114.92677,22.76118],[114.91676,22.74404],[114.86252,22.76322],[114.88187,22.74752],[114.88074,22.72147],[114.92673,22.71693],[114.96663,22.67649],[114.90194,22.59806],[114.91912,22.56115],[114.89597,22.54752],[114.88022,22.55911],[114.88682,22.59087],[114.86582,22.60175],[114.75414,22.58971],[114.74378,22.6094],[114.73745,22.66905],[114.75378,22.69338],[114.74195,22.73265],[114.82125,22.79056],[114.77524,22.81582],[114.75489,22.78168],[114.69548,22.79264],[114.63779,22.75507],[114.56332,22.73273],[114.5711,22.70235],[114.52206,22.70596],[114.50661,22.64652],[114.58253,22.65526],[114.59034,22.64387],[114.49537,22.56196],[114.50929,22.54955],[114.54322,22.5694],[114.61769,22.51415],[114.51582,22.45264],[114.48512,22.46833],[114.47958,22.54621],[114.39576,22.61351],[114.31715,22.60236],[114.30714,22.58544],[114.27621,22.58796],[114.22983,22.55581],[114.16195,22.56054],[114.14872,22.53811],[114.09146,22.53439],[114.05527,22.50694],[113.9638,22.53811],[113.94776,22.49041],[113.91033,22.48583],[113.89034,22.45271],[113.86616,22.51149],[113.89389,22.52115],[113.82236,22.58723],[113.79357,22.66161],[113.77293,22.67245],[113.75807,22.7534],[113.71217,22.76333],[113.67237,22.74689],[113.65358,22.75873],[113.66261,22.79808],[113.6172,22.89029],[113.6172,22.83625],[113.60556,22.84724],[113.61036,22.90453],[113.5923,22.88906],[113.63819,23.0349],[113.61671,23.00788],[113.59669,22.9182],[113.6172,23.0349],[113.57618,22.93871],[113.58302,23.02123],[113.55397,22.98762],[113.52882,23.01081],[113.53639,23.06314],[113.58212,23.09846],[113.72022,23.13793],[113.83009,23.1175],[113.70314,23.15778],[113.50912,23.06216],[113.41749,23.09699],[113.39869,23.08332],[113.48121,23.0515],[113.50231,23.02042],[113.48398,22.92426],[113.42994,22.93134],[113.30763,22.89411],[113.21298,22.90453],[113.29225,22.87727],[113.35043,22.88972],[113.44003,22.80414],[113.49423,22.77485],[113.43954,22.73945],[113.37753,22.7674],[113.46632,22.7165],[113.59917,22.57518],[113.59474,22.52339],[113.54636,22.43758],[113.55812,22.38076],[113.61036,22.37018],[113.57358,22.30073],[113.58765,22.23788],[113.52296,22.21304],[113.52988,22.20226],[113.48374,22.15514],[113.42563,22.18623],[113.41188,22.22553],[113.36451,22.26777],[113.35703,22.30191],[113.27638,22.37206],[113.23194,22.47907],[113.16529,22.57559],[113.16017,22.48762],[113.22731,22.42528],[113.40197,22.17957],[113.30275,22.1833],[113.25099,22.11567],[113.24716,22.05427],[113.15747,22.0247],[113.11886,22.06584],[113.11061,22.09919],[113.12745,22.16096],[113.11061,22.21931],[113.08505,22.20759],[113.07729,22.14727],[113.05789,22.12109],[113.02661,22.14321],[113.02064,22.12598],[113.02562,21.94943],[112.98935,21.93732],[112.95708,21.87565],[112.91159,21.85629],[112.87941,21.87519],[112.85157,21.94831],[112.82602,21.96613],[112.74374,21.93195],[112.73048,21.89033],[112.70045,21.89033],[112.64511,21.84935],[112.64755,21.82266],[112.608,21.77509],[112.55087,21.77285],[112.42604,21.84516],[112.41114,21.90119],[112.49366,21.96613],[112.43629,21.94562],[112.39064,21.95551],[112.37631,21.98656],[112.39747,22.06851],[112.36484,22.03449],[112.35654,21.97972],[112.39064,21.90119],[112.40211,21.80142],[112.38795,21.75609],[112.36329,21.73334],[112.28623,21.70881],[112.24488,21.70653],[112.18238,21.753],[112.20314,21.80671],[112.19264,21.84317],[112.1727,21.81444],[112.02817,21.79735],[112.00709,21.81867],[112.04127,21.88418],[111.96925,21.82404],[111.93409,21.89045],[111.89039,21.92512],[111.90406,21.81525],[111.94638,21.78588],[112.00025,21.77432],[111.86793,21.6767],[111.83888,21.67813],[111.85377,21.70742],[111.87672,21.69855],[111.86061,21.7215],[111.83619,21.71699],[111.83505,21.76683],[111.80689,21.74331],[111.78793,21.76],[111.7579,21.75332],[111.67807,21.78115],[111.76059,21.72582],[111.77963,21.67544],[111.77369,21.65013],[111.74692,21.64395],[111.78793,21.61603],[111.7129,21.62344],[111.63046,21.52985],[111.58188,21.58186],[111.59555,21.53408],[111.53785,21.52045],[111.52101,21.53408],[111.47608,21.51362],[111.47267,21.55459],[111.4441,21.56362],[111.43222,21.60297],[111.41114,21.56826],[111.438,21.53408],[111.3628,21.54121],[111.3497,21.52668],[111.40431,21.52668],[111.26735,21.43936],[111.25294,21.44473],[111.30128,21.49933],[111.19239,21.5312],[111.15797,21.51362],[111.17164,21.49933],[111.22633,21.49933],[111.14365,21.46581],[111.08839,21.47264],[111.12998,21.49933],[111.08839,21.49311],[111.0276,21.54092],[110.98593,21.51362],[111.01393,21.48566],[110.97975,21.44473],[111.00815,21.4499],[111.04225,21.48456],[111.06861,21.47264],[111.04371,21.46939],[110.99293,21.42487],[110.96591,21.43285],[110.86988,21.38325],[110.76816,21.39753],[110.70485,21.34854],[110.65089,21.2604],[110.6548,21.3146],[110.67758,21.34854],[110.64332,21.32184],[110.63648,21.37027],[110.75538,21.4134],[110.76002,21.44473],[110.63559,21.38105],[110.61573,21.30121],[110.64332,21.25292],[110.63648,21.23017],[110.57521,21.24673],[110.58871,21.22557],[110.48146,21.21719],[110.43116,21.19147],[110.42579,21.26923],[110.43735,21.27338],[110.42433,21.28767],[110.47885,21.27338],[110.48634,21.30134],[110.44483,21.30756],[110.48634,21.33552],[110.46534,21.32868],[110.46534,21.34854],[110.43393,21.30878],[110.42392,21.34162],[110.43735,21.39753],[110.41953,21.37743],[110.40097,21.38703],[110.36899,21.42487],[110.3895,21.45213],[110.35849,21.43574],[110.39438,21.364],[110.3895,21.32868],[110.32814,21.33552],[110.3567,21.31916],[110.3628,21.28767],[110.40821,21.23802],[110.39942,21.17658],[110.33375,21.11636],[110.2693,21.08918],[110.21884,21.02757],[110.2046,21.08222],[110.19557,21.07127],[110.15675,20.98603],[110.14308,20.99226],[110.1548,20.97728],[110.14926,20.92398],[110.17726,20.86249],[110.15675,20.87678],[110.15675,20.84886],[110.1915,20.84886],[110.1915,20.83584],[110.28712,20.84203],[110.31967,20.85993],[110.3611,20.83588],[110.36899,20.84886],[110.40943,20.75096],[110.38356,20.71345],[110.35646,20.73233],[110.34181,20.82152],[110.3213,20.79426],[110.33212,20.76789],[110.30763,20.75324],[110.30763,20.73896],[110.33749,20.72582],[110.30079,20.65705],[110.32814,20.67756],[110.31446,20.63654],[110.3628,20.64338],[110.3628,20.6223],[110.38063,20.63377],[110.40854,20.58592],[110.47885,20.56769],[110.53419,20.47826],[110.50229,20.41275],[110.47535,20.39765],[110.40683,20.30516],[110.36207,20.30488],[110.34433,20.27924],[110.28028,20.253],[110.23715,20.27497],[110.12753,20.24799],[110.03785,20.3017],[110.00595,20.28034],[109.96583,20.28095],[109.91725,20.23933],[109.92872,20.32636],[109.91725,20.35228],[109.87412,20.37629],[109.8899,20.38272],[109.8628,20.40632],[109.90358,20.4311],[109.98243,20.35155],[110.01547,20.4184],[110.00978,20.43423],[109.96494,20.47826],[109.9581,20.45161],[109.94361,20.46068],[109.93092,20.50625],[109.91725,20.44477],[109.89609,20.49258],[109.82211,20.51655],[109.84824,20.59565],[109.82106,20.55463],[109.80201,20.61628],[109.74586,20.62979],[109.75156,20.67373],[109.80592,20.67951],[109.82773,20.69798],[109.8414,20.68439],[109.86044,20.71548],[109.78688,20.69115],[109.75644,20.7497],[109.7732,20.76691],[109.75815,20.79279],[109.7732,20.81411],[109.75204,20.84203],[109.75953,20.85505],[109.74537,20.86766],[109.73154,20.85505],[109.73902,20.80785],[109.70436,20.84203],[109.71787,20.80785],[109.66562,20.88093],[109.66123,20.92756],[109.69125,20.95873],[109.67897,20.97386],[109.69752,21.04059],[109.71168,21.03384],[109.69996,21.08299],[109.67018,21.12324],[109.70436,21.17845],[109.75204,21.16413],[109.74667,21.17967],[109.7732,21.2154],[109.76572,21.35594],[109.78688,21.32868],[109.80738,21.35594],[109.92408,21.36278],[109.9048,21.39069],[109.95126,21.4584],[109.92457,21.46503],[109.94443,21.49311],[109.85898,21.48281],[109.8414,21.50678],[109.81422,21.4584],[109.80055,21.55459],[109.77011,21.55158],[109.7732,21.58186],[109.74806,21.57494],[109.74781,21.54523],[109.7732,21.48566],[109.75953,21.49311],[109.74147,21.47403],[109.64967,21.52045],[109.67701,21.5751],[109.67701,21.62344],[109.63722,21.60163],[109.62525,21.58539],[109.636,21.56826],[109.608,21.5751],[109.61549,21.65013],[109.5853,21.63085],[109.56088,21.67129],[109.5608,21.70726],[109.59449,21.7464],[109.56772,21.76],[109.49269,21.69855],[109.5062,21.68496],[109.49269,21.67813],[109.51987,21.68496],[109.52101,21.66576],[109.46469,21.66446],[109.52988,21.63569],[109.57277,21.58173],[109.56088,21.52668],[109.53077,21.49164],[109.47218,21.46581],[109.47218,21.49933],[109.42441,21.45238],[109.34376,21.45352],[109.32081,21.48013],[109.29892,21.44326],[109.24496,21.43163],[109.23878,21.48566],[109.21461,21.41743],[109.17197,21.41755],[109.14942,21.43854],[109.15626,21.40375],[109.13868,21.4016],[109.03981,21.45026],[109.06007,21.48013],[109.13282,21.49592],[109.14259,21.5102],[109.13575,21.60297],[109.12436,21.59097],[109.10108,21.6092],[109.07765,21.59699],[109.06674,21.61603],[109.0464,21.60297],[109.06674,21.64395],[108.99244,21.6092],[108.92799,21.61506],[108.89747,21.67129],[108.85523,21.68496],[108.86948,21.70539],[108.86549,21.73973],[108.92408,21.76683],[108.89747,21.76],[108.90309,21.77432],[108.86948,21.75381],[108.87574,21.80097],[108.85523,21.78115],[108.84986,21.79169],[108.86142,21.81525],[108.80136,21.81835],[108.77955,21.81525],[108.82106,21.80097],[108.81422,21.78115],[108.83261,21.78384],[108.84352,21.74502],[108.81666,21.71638],[108.80592,21.75845],[108.79306,21.75381],[108.7999,21.73334],[108.78688,21.74018],[108.77955,21.72582],[108.7999,21.72582],[108.79306,21.71284],[108.83432,21.68574],[108.82106,21.64395],[108.86142,21.64395],[108.84995,21.62596],[108.78736,21.64143],[108.73854,21.61603],[108.74537,21.60297],[108.72755,21.64476],[108.71119,21.65013],[108.73316,21.66242],[108.75156,21.65013],[108.75156,21.69172],[108.73854,21.67129],[108.71266,21.68842],[108.72486,21.7464],[108.6832,21.70539],[108.64918,21.73334],[108.6832,21.74018],[108.64633,21.75137],[108.67701,21.81525],[108.65602,21.79483],[108.64235,21.82209],[108.63551,21.77432],[108.615,21.78791],[108.62867,21.7464],[108.59449,21.74018],[108.60751,21.78791],[108.58058,21.78132],[108.57138,21.81281],[108.6006,21.83063],[108.63551,21.89033],[108.59449,21.91767],[108.58766,21.89785],[108.56031,21.89549],[108.55299,21.91767],[108.57333,21.95181],[108.56715,21.93879],[108.55299,21.95181],[108.52564,21.90461],[108.50514,21.91083],[108.51881,21.89033],[108.48487,21.8771],[108.49147,21.85627],[108.46314,21.86298],[108.47779,21.94501],[108.45802,21.91767],[108.44874,21.86579],[108.48463,21.82209],[108.47096,21.82209],[108.47779,21.80781],[108.49708,21.80903],[108.50514,21.78791],[108.49147,21.74018],[108.46347,21.74018],[108.50514,21.71906],[108.47779,21.69855],[108.50514,21.69172],[108.50514,21.67129],[108.53305,21.71906],[108.51881,21.67129],[108.55299,21.69855],[108.56544,21.67853],[108.54518,21.66608],[108.54689,21.64305],[108.51197,21.64395],[108.53305,21.63032],[108.51197,21.6092],[108.51881,21.59553],[108.48121,21.58186],[108.48911,21.60761],[108.50514,21.60297],[108.49147,21.62344],[108.46347,21.59553],[108.4712,21.5812],[108.45802,21.56826],[108.47096,21.562],[108.42001,21.55687],[108.38966,21.5751],[108.39064,21.59536],[108.40675,21.60025],[108.43189,21.58324],[108.4437,21.62344],[108.42758,21.61669],[108.41635,21.63032],[108.47096,21.67129],[108.40968,21.66446],[108.42384,21.69855],[108.386,21.67292],[108.33766,21.69746],[108.34132,21.72582],[108.31137,21.68171],[108.32765,21.63032],[108.30649,21.63032],[108.31397,21.61603],[108.29347,21.5893],[108.31397,21.58186],[108.28378,21.54474],[108.21103,21.50064],[108.25278,21.58853],[108.22682,21.62556],[108.23829,21.64395],[108.2103,21.63032],[108.20606,21.64256],[108.2103,21.60297],[108.17636,21.57575],[108.13527,21.58186],[108.12827,21.56273],[108.11476,21.5751],[108.10581,21.55907],[108.06625,21.562],[108.07106,21.53657],[108.0171,21.55524],[107.99122,21.54841],[108.03956,21.52045],[108.01124,21.50654],[108.02223,21.48566],[108.06105,21.49616],[108.08058,21.48566],[108.01661,21.47036],[107.97071,21.43163],[107.96534,21.44733],[107.99122,21.48566],[107.81164,21.64783],[107.74482,21.65754],[107.67186,21.61765],[107.53305,21.60866],[107.50132,21.58153],[107.46102,21.59636],[107.45704,21.6315],[107.42603,21.65207],[107.34816,21.59935],[107.27266,21.71831],[107.21214,21.70524],[107.17235,21.71506],[107.06585,21.79583],[107.0029,21.81056],[106.98714,21.84482],[107.02115,21.89226],[107.00936,21.92745],[106.97123,21.93582],[106.92038,21.91696],[106.86477,21.96807],[106.78152,21.97504],[106.72876,22.00832],[106.6746,21.96217],[106.65311,21.96889],[106.67218,22.0925],[106.63967,22.1909],[106.66293,22.2233],[106.63378,22.30944],[106.5418,22.3451],[106.52691,22.43827],[106.57714,22.59531],[106.60763,22.60999],[106.64634,22.5688],[106.68019,22.57945],[106.74664,22.74435],[106.7915,22.77212],[106.78984,22.79719],[106.73036,22.79763],[106.66747,22.86752],[106.61037,22.85287],[106.58169,22.89527],[106.48779,22.92542],[106.45798,22.88897],[106.31566,22.8538],[106.2554,22.85465],[106.22709,22.87556],[106.20239,22.947],[106.1321,22.97501],[105.99925,22.97537],[105.96121,22.9324],[105.876,22.92827],[105.85388,22.90465],[105.75471,23.00568],[105.69032,23.04397],[105.55157,23.05968],[105.52827,23.15551],[105.47385,23.1945],[105.42589,23.27321],[105.38952,23.27543],[105.34404,23.30419],[105.31216,23.36581],[105.24554,23.33018],[105.2009,23.25401],[105.15563,23.26685],[105.06106,23.23246],[104.93507,23.15427],[104.89477,23.17034],[104.87006,23.16355],[104.86128,23.1196],[104.79792,23.08557],[104.82361,22.92413],[104.7742,22.89623],[104.70077,22.81825],[104.60982,22.823],[104.58843,22.80685],[104.55354,22.83605],[104.449,22.74518],[104.34017,22.68637],[104.23682,22.74208],[104.22876,22.78344],[104.24116,22.8244],[104.08686,22.79156],[104.02381,22.71918],[103.98536,22.52715],[103.95952,22.5071],[103.83633,22.60276],[103.64693,22.79905],[103.59004,22.76802],[103.54715,22.70068],[103.55092,22.64079],[103.50364,22.58141],[103.44751,22.61826],[103.40431,22.6888],[103.40173,22.73784],[103.35708,22.77603],[103.30964,22.78794],[103.24489,22.66869],[103.14175,22.60704],[103.13002,22.57795],[103.142,22.5378],[103.04418,22.48648],[103.04506,22.44054],[103.02904,22.43016],[102.90259,22.47723],[102.83184,22.59971],[102.7519,22.62529],[102.69273,22.6705],[102.61248,22.69159],[102.58669,22.71608],[102.53579,22.69582],[102.51579,22.74518],[102.46778,22.76859],[102.42561,22.7453],[102.39497,22.68053],[102.35585,22.66993],[102.38432,22.62885],[102.25255,22.49548],[102.21803,22.41068],[102.16454,22.42551],[102.10734,22.39755],[102.08553,22.42912],[101.99499,22.44742],[101.89164,22.42969],[101.86792,22.37884],[101.8178,22.40633],[101.78488,22.47222],[101.75506,22.49553],[101.68912,22.47889],[101.65455,22.44612],[101.6437,22.36468],[101.6237,22.34665],[101.60122,22.27699],[101.55099,22.27668],[101.51575,22.24536],[101.56153,22.17611],[101.55543,22.09033],[101.6068,21.9676],[101.67367,21.93138],[101.75108,21.81614],[101.72855,21.71754],[101.7548,21.68979],[101.75594,21.64865],[101.80384,21.62592],[101.79356,21.58824],[101.7362,21.57088],[101.74824,21.5145],[101.72969,21.47425],[101.71511,21.32113],[101.73651,21.29229],[101.80115,21.26707],[101.82374,21.23447],[101.80524,21.2045],[101.76007,21.19597],[101.7702,21.17044],[101.75599,21.14326],[101.70426,21.13509],[101.65512,21.1892],[101.58179,21.17566],[101.58396,21.22439],[101.51761,21.24263],[101.38149,21.22248],[101.29271,21.17607],[101.24434,21.19287],[101.209,21.24553],[101.22036,21.29627],[101.20745,21.31482],[101.23313,21.37172],[101.17143,21.4034],[101.18776,21.50587],[101.18657,21.53533],[101.15422,21.56804],[101.17386,21.61191],[101.16884,21.63785],[101.12766,21.69516],[101.11887,21.75981],[101.0828,21.76674],[100.99278,21.71144],[100.83491,21.65754],[100.70396,21.51641],[100.62174,21.46908],[100.45669,21.45502],[100.39912,21.51889],[100.34197,21.53037],[100.19789,21.43322],[100.1625,21.43637],[100.09728,21.5083],[100.08607,21.55093],[100.10395,21.61563],[100.14374,21.64002],[100.14281,21.65491],[100.08235,21.68447],[99.99672,21.68612],[99.95026,21.72116],[99.91796,21.82916],[99.96566,21.963],[99.93434,22.01886],[99.94241,22.04553],[99.84784,22.05504],[99.84174,22.02708],[99.82102,22.01778],[99.72366,22.07059],[99.68387,22.03902],[99.63354,22.08475],[99.53897,22.10671],[99.49039,22.10434],[99.43861,22.12423],[99.36492,22.09886],[99.25289,22.10279],[99.16638,22.13209],[99.14447,22.15353],[99.17506,22.16867],[99.15935,22.18469],[99.24369,22.34122],[99.24307,22.39316],[99.28276,22.41192],[99.35779,22.49548],[99.34415,22.53325],[99.355,22.60854],[99.30797,22.74518],[99.35903,22.77489],[99.41443,22.8569],[99.41339,22.93075],[99.5153,22.90881],[99.53804,22.92643],[99.49039,23.01201],[99.49308,23.05738],[99.40327,23.06834],[99.33381,23.12962],[99.21661,23.05738],[99.19418,23.09577],[99.05507,23.1096],[99.02644,23.16045],[98.85901,23.17939],[98.86015,23.21228],[98.91069,23.2976],[98.89653,23.32328],[98.85343,23.32488],[98.89012,23.3679],[98.8896,23.40679],[98.86397,23.46661],[98.84888,23.4804],[98.79999,23.48074],[98.78087,23.53285],[98.85601,23.61001],[98.8003,23.69271],[98.79317,23.75847],[98.66367,23.79695],[98.67452,23.93689],[98.65882,23.96112],[98.85622,24.0838],[98.87348,24.1145],[98.86573,24.14569],[98.71618,24.12135],[98.58523,24.07571],[98.50327,24.12127],[98.43495,24.13026],[98.33067,24.09949],[98.18153,24.11866],[98.10505,24.10153],[97.88997,24.02262],[97.81783,23.95815],[97.76781,23.94647],[97.62467,23.84671],[97.60989,23.88314],[97.51646,23.94283],[97.72006,24.14744],[97.70156,24.21093],[97.72409,24.24152],[97.71841,24.27767],[97.64451,24.30239],[97.6382,24.3281],[97.68957,24.34647],[97.6473,24.43233],[97.51325,24.43859],[97.53857,24.59726],[97.53279,24.72332],[97.53609,24.74503],[97.66394,24.82978],[97.76523,24.83466],[97.77298,24.85469],[97.70828,24.92729],[97.6996,25.06501],[97.75406,25.11356],[97.80099,25.23763],[97.82362,25.26161],[97.86031,25.24409],[97.88894,25.20565],[97.90992,25.20756],[97.97627,25.27226],[98.0449,25.31194],[98.0757,25.37608],[98.11208,25.39623],[98.09978,25.50005],[98.1342,25.53462],[98.14009,25.61146],[98.23238,25.58655],[98.27662,25.55245],[98.31207,25.55746],[98.3471,25.57782],[98.41873,25.72422],[98.4981,25.83072],[98.52683,25.83879],[98.61003,25.8006],[98.69023,25.86555],[98.68982,25.8916],[98.62533,25.96999],[98.58244,25.98229],[98.54554,26.10564],[98.56301,26.13122],[98.61933,26.14574],[98.6243,26.10849],[98.64445,26.09789],[98.68083,26.12497],[98.70553,26.17494],[98.677,26.23933],[98.64217,26.25452],[98.65778,26.31162],[98.71349,26.38945],[98.72114,26.5389],[98.75555,26.62504],[98.73199,26.71351],[98.75038,26.79201],[98.71452,26.86203],[98.73633,26.89826],[98.71266,26.99515],[98.73778,27.04869],[98.73137,27.06879],[98.69137,27.0924],[98.67256,27.1756],[98.7077,27.33725],[98.67442,27.3866],[98.67928,27.57734],[98.64724,27.61173],[98.57841,27.59173],[98.52218,27.6563],[98.49231,27.64343],[98.41025,27.68425],[98.38328,27.55579],[98.36395,27.53217],[98.3286,27.52287],[98.2947,27.53662],[98.2544,27.64813],[98.20437,27.7273],[98.19662,27.82319],[98.14009,27.87856],[98.17667,27.89812],[98.17729,27.91292],[98.10712,27.97307],[98.12252,28.01545],[98.11869,28.14079],[98.04862,28.19962],[97.99105,28.21401],[97.99291,28.26892],[97.94475,28.29884],[97.89752,28.35533],[97.85907,28.37013],[97.79726,28.34393],[97.76533,28.35243],[97.6996,28.48803],[97.67045,28.51128],[97.61361,28.48198],[97.54622,28.53847],[97.52772,28.52953],[97.44535,28.38297],[97.44783,28.2976],[97.38437,28.28484],[97.36959,28.25347],[97.3235,28.21748],[97.22965,28.27461],[97.21053,28.30809],[97.07845,28.37514],[96.96579,28.33047],[96.71692,28.43654],[96.56152,28.44729],[96.36964,28.37812],[96.30185,28.42071],[96.33646,28.47967],[96.50238,28.64448],[96.50104,28.68763],[96.59801,28.70991],[96.59258,28.75788],[96.57667,28.80853],[96.51083,28.88553],[96.49223,28.948],[96.45213,28.97038],[96.45761,28.99459],[96.36657,29.03654],[96.24859,28.94534],[96.19532,28.94111],[96.17463,29.10884],[96.21008,29.14577],[96.31642,29.17186],[96.36624,29.25723],[96.3371,29.27971],[96.26785,29.24173],[96.23529,29.24121],[96.17628,29.28646],[96.14197,29.36847],[95.95373,29.35922],[95.86313,29.32399],[95.79594,29.35289],[95.74478,29.34043],[95.74737,29.27365],[95.71716,29.21828],[95.56443,29.24561],[95.55017,29.21248],[95.51534,29.20943],[95.52143,29.13786],[95.48758,29.06899],[95.36741,29.0365],[95.22492,29.05936],[95.1997,29.09425],[94.98906,29.15398],[94.77657,29.1667],[94.76148,29.17471],[94.76768,29.21385],[94.70422,29.2847],[94.63043,29.31945],[94.59994,29.31664],[94.51478,29.22106],[94.39644,29.20708],[94.33577,29.14962],[94.2875,29.14794],[94.27079,29.09793],[94.34731,29.02435],[94.25018,28.93311],[94.17366,28.93016],[94.1354,28.89779],[93.99202,28.84465],[93.98159,28.76813],[93.92779,28.68275],[93.86308,28.70487],[93.78174,28.6805],[93.72248,28.69665],[93.62553,28.67236],[93.44621,28.67189],[93.31717,28.60346],[93.28697,28.52052],[93.18629,28.432],[93.21807,28.39465],[93.20782,28.34054],[92.97492,28.28231],[92.90382,28.2497],[92.83478,28.18353],[92.79023,28.1789],[92.77917,28.1959],[92.67861,28.13306],[92.63892,28.05749],[92.68926,28.04831],[92.73639,27.98591],[92.68367,27.91045],[92.63647,27.89537],[92.57498,27.84781],[92.47511,27.8466],[92.42757,27.82117],[92.39284,27.85603],[92.36793,27.80383],[92.35005,27.80254],[92.32918,27.83293],[92.30385,27.78616],[92.23957,27.86528],[92.12629,27.81272],[92.06488,27.76172],[91.9889,27.7694],[91.95225,27.72482],[91.86491,27.72999],[91.82659,27.80763],[91.72987,27.80414],[91.68013,27.84982],[91.62834,27.85269],[91.65283,27.91675],[91.63795,27.93974],[91.41884,27.99473],[91.34143,28.03056],[91.29017,28.09141],[91.26971,28.07286],[91.19467,28.07059],[91.09256,27.97168],[91.05194,27.96279],[90.97515,27.98209],[90.90952,28.03266],[90.7269,28.0617],[90.66747,28.09033],[90.57528,28.06583],[90.47523,28.07234],[90.48567,28.12466],[90.5976,28.20038],[90.58773,28.2333],[90.50542,28.24976],[90.45604,28.28268],[90.3869,28.29914],[90.35398,28.29914],[90.3295,28.2558],[90.29147,28.26135],[90.2618,28.29914],[90.2618,28.33535],[90.22559,28.3584],[90.15316,28.32219],[90.07086,28.34523],[89.95252,28.30825],[89.91613,28.3156],[89.86292,28.29579],[89.78086,28.22877],[89.75584,28.18438],[89.56149,28.13464],[89.44455,28.03118],[89.33603,27.86907],[89.22492,27.80781],[89.1982,27.73058],[89.12466,27.61485],[89.09153,27.62587],[89.06403,27.61595],[88.97837,27.53028],[88.98297,27.49263],[88.95106,27.43314],[88.99702,27.33087],[88.97193,27.31238],[88.91516,27.33087],[88.89233,27.31554],[88.79539,27.38593],[88.75978,27.43507],[88.75756,27.51145],[88.74102,27.54571],[88.80567,27.65511],[88.85388,27.84368],[88.85156,27.87716],[88.81068,27.92786],[88.81771,27.99488],[88.73565,28.05526],[88.65203,28.0694],[88.61049,28.10583],[88.55282,28.07805],[88.50207,28.02888],[88.45577,28.03152],[88.37877,27.98263],[88.11574,27.94726],[88.0975,27.90401],[88.11822,27.86088],[88.02995,27.89334],[87.97843,27.88052],[87.83669,27.90832],[87.77984,27.83944],[87.72682,27.80776],[87.63608,27.82365],[87.57334,27.80502],[87.53159,27.8368],[87.36912,27.80394],[87.38534,27.84941],[87.36384,27.85525],[87.29052,27.81608],[87.1558,27.8258],[87.11652,27.84458],[87.00568,27.9515],[86.91473,27.94512],[86.84047,28.01478],[86.7398,28.0215],[86.73174,28.07692],[86.68006,28.10573],[86.61231,28.08028],[86.55356,28.10415],[86.5162,28.01225],[86.51046,27.95243],[86.4253,27.90993],[86.36298,27.9461],[86.32773,27.93969],[86.27807,27.98369],[86.20304,28.00258],[86.17224,28.07183],[86.18604,28.13353],[86.15596,28.15652],[86.06873,28.07676],[86.0847,28.04387],[86.07917,28.00113],[86.09813,27.98563],[86.11286,27.9268],[86.0617,27.88905],[85.98026,27.88517],[85.95122,27.91623],[85.95659,27.97623],[85.8983,28.03602],[85.86337,28.14335],[85.73449,28.22153],[85.69976,28.2691],[85.69294,28.33522],[85.61181,28.25115],[85.5653,28.27538],[85.47569,28.2791],[85.43156,28.29706],[85.36257,28.25967],[85.3126,28.28277],[85.24341,28.26791],[85.17359,28.30432],[85.13416,28.29197],[85.0874,28.30411],[85.07758,28.43568],[85.10946,28.4593],[85.12315,28.50459],[85.15375,28.5249],[85.16098,28.595],[85.05494,28.63869],[84.95495,28.5743],[84.92849,28.53847],[84.81196,28.54198],[84.75563,28.58541],[84.67776,28.6044],[84.68297,28.65076],[84.63264,28.66879],[84.61621,28.69365],[84.45085,28.73388],[84.42273,28.79917],[84.37659,28.84884],[84.23515,28.90002],[84.20673,28.94066],[84.23143,29.02598],[84.17546,29.0574],[84.16621,29.09846],[84.13226,29.1438],[84.16166,29.19253],[84.15546,29.20664],[84.11236,29.2091],[84.08973,29.25661],[84.03521,29.24762],[84.01252,29.2702],[83.97584,29.26375],[83.95795,29.28904],[83.91439,29.2894],[83.79321,29.23475],[83.69549,29.22618],[83.63927,29.15476],[83.52372,29.18359],[83.503,29.26103],[83.39313,29.35625],[83.38724,29.3935],[83.35701,29.41536],[83.3552,29.46073],[83.32797,29.48502],[83.26394,29.47301],[83.2472,29.49474],[83.26084,29.54205],[83.17356,29.62127],[83.09336,29.61514],[83.07956,29.57964],[83.06494,29.57879],[82.9759,29.66403],[82.93192,29.66418],[82.89973,29.68842],[82.83658,29.65979],[82.69095,29.78074],[82.68899,29.82774],[82.6185,29.83983],[82.54181,29.92324],[82.45707,29.95409],[82.39443,30.00313],[82.3473,30.00344],[82.31888,30.03181],[82.25573,30.03967],[82.21067,30.06442],[82.15383,30.05817],[82.12758,30.12436],[82.15569,30.18136],[82.08499,30.23082],[82.08877,30.33009],[81.96903,30.32828],[81.94123,30.35396],[81.84439,30.37117],[81.77902,30.35805],[81.61401,30.4169],[81.53681,30.37856],[81.50937,30.32838],[81.46281,30.34135],[81.42612,30.37251],[81.38752,30.37391],[81.36804,30.30105],[81.37785,30.23263],[81.35005,30.15723],[81.32241,30.13377],[81.26696,30.12509],[81.27202,30.07992],[81.22572,30.05047],[81.21683,30.0082],[81.09777,30.01693],[80.97602,30.25521],[80.78166,30.32099],[80.72306,30.39205],[80.59635,30.4598],[80.5083,30.46233],[80.32588,30.54651],[80.25281,30.56501],[80.17984,30.55948],[80.2033,30.60635],[80.16661,30.67441],[80.20702,30.68614],[80.23141,30.72495],[80.1571,30.79316],[80.0922,30.77451],[80.06253,30.78479],[80.02863,30.83078],[79.90316,30.89021],[79.8335,30.96152],[79.73904,30.97925],[79.63031,30.9625],[79.57781,30.93837],[79.5127,30.96617],[79.47642,31.01403],[79.40159,31.02364],[79.36705,31.11156],[79.31942,31.14228],[79.28832,31.22547],[79.25286,31.24544],[79.26202,31.27851],[79.23832,31.32966],[79.1314,31.43843],[79.07021,31.46138],[78.92101,31.26968],[78.86199,31.29148],[78.78447,31.28827],[78.7451,31.30812],[78.76256,31.44511],[78.69538,31.48811],[78.69952,31.51002],[78.81889,31.60737],[78.75812,31.6666],[78.72401,31.67331],[78.69321,31.71853],[78.69559,31.74669],[78.67089,31.77052],[78.74458,31.9642],[78.72215,31.99459],[78.64929,32.03644],[78.51307,32.20757],[78.45798,32.22966],[78.46273,32.2818],[78.43452,32.38376],[78.45219,32.41678],[78.38098,32.52804],[78.38481,32.54755],[78.44754,32.56626],[78.63017,32.57801],[78.69445,32.62966],[78.73683,32.62984],[78.75099,32.61279],[78.73693,32.54579],[78.77145,32.46166],[78.91129,32.35475],[78.94343,32.34637],[79.02818,32.37709],[79.07531,32.37079],[79.11293,32.47239],[79.28741,32.53258],[79.32047,32.59227],[79.37948,32.57645],[79.40682,32.52968],[79.4435,32.53477],[79.51574,32.60812],[79.5312,32.64832],[79.57617,32.67065],[79.62067,32.72872],[79.61391,32.76503],[79.56409,32.79393],[79.41409,32.97205],[79.33359,32.99429],[79.33979,33.01326],[79.39927,33.02688],[79.37317,33.11214],[79.42347,33.15783],[79.4561,33.2504],[79.42008,33.27517],[79.26006,33.29178],[79.09803,33.28766],[78.96499,33.34425],[78.91769,33.38626]]],[[[111.01051,19.68378],[111.03468,19.64224],[110.96583,19.63597],[110.87647,19.53978],[110.84278,19.53994],[110.82179,19.56778],[110.82569,19.60326],[110.86264,19.60871],[110.84767,19.62246],[110.79444,19.60126],[110.82179,19.5541],[110.79623,19.4846],[110.76027,19.45791],[110.75758,19.43089],[110.69825,19.41694],[110.66969,19.38174],[110.67791,19.33438],[110.64137,19.31245],[110.62159,19.23066],[110.58033,19.15518],[110.5101,19.15005],[110.47918,19.16926],[110.50733,19.13223],[110.54623,19.13988],[110.55494,19.08735],[110.58229,19.15005],[110.56324,19.05565],[110.51808,18.99348],[110.51336,18.9064],[110.46559,18.8819],[110.45875,18.84833],[110.43149,18.82148],[110.44508,18.80366],[110.49285,18.79312],[110.50343,18.8819],[110.53443,18.78563],[110.469,18.77717],[110.42457,18.70376],[110.42677,18.68089],[110.40349,18.66279],[110.33522,18.67333],[110.4109,18.69013],[110.40349,18.70376],[110.30787,18.66279],[110.27084,18.66816],[110.1211,18.55581],[110.08814,18.50434],[110.03419,18.51195],[110.08253,18.47777],[110.06821,18.40892],[110.04461,18.44013],[110.05258,18.3902],[110.04029,18.38227],[109.9686,18.38984],[109.9686,18.4055],[110.01368,18.40892],[110.02052,18.45051],[110.0062,18.42939],[109.99252,18.44306],[109.96518,18.41633],[109.89829,18.41425],[109.83546,18.38227],[109.79566,18.40518],[109.7461,18.3959],[109.73178,18.27302],[109.7046,18.29222],[109.67042,18.27302],[109.73927,18.25873],[109.70875,18.20722],[109.69068,18.20917],[109.67726,18.24506],[109.60613,18.20889],[109.61573,18.17617],[109.56121,18.16934],[109.56804,18.19318],[109.54762,18.22085],[109.56625,18.24299],[109.56121,18.25873],[109.52955,18.22769],[109.53395,18.21035],[109.51075,18.21361],[109.47869,18.19269],[109.46502,18.20409],[109.50961,18.23078],[109.50652,18.25275],[109.49293,18.24506],[109.43995,18.28897],[109.21811,18.30565],[109.16261,18.28783],[109.12916,18.34748],[109.136,18.36115],[108.9909,18.36872],[108.79558,18.48656],[108.68491,18.5152],[108.67408,18.55484],[108.69093,18.60472],[108.69361,18.71987],[108.66814,18.78864],[108.62387,18.84919],[108.65626,18.944],[108.61525,19.10163],[108.6631,19.15624],[108.68344,19.15624],[108.66391,19.20962],[108.62208,19.21772],[108.63795,19.24897],[108.62696,19.27497],[108.64479,19.31037],[108.67726,19.32758],[108.66928,19.34374],[108.68653,19.37787],[108.76295,19.38898],[108.79029,19.42316],[108.8698,19.45791],[108.92449,19.50625],[108.94483,19.49201],[108.94077,19.51411],[109.00847,19.59707],[109.1565,19.64964],[109.19207,19.67845],[109.21119,19.7248],[109.25896,19.73164],[109.30738,19.71113],[109.27467,19.75723],[109.2942,19.75951],[109.28679,19.77318],[109.30738,19.78685],[109.2732,19.79365],[109.16334,19.7248],[109.16684,19.80549],[109.24871,19.86884],[109.25514,19.89834],[109.31137,19.9195],[109.3213,19.89203],[109.36883,19.88931],[109.41261,19.8614],[109.44361,19.87299],[109.46502,19.83405],[109.52703,19.86197],[109.58172,19.8557],[109.52019,19.88247],[109.51564,19.90522],[109.55746,19.9173],[109.53093,19.93012],[109.52703,19.95136],[109.59571,19.99315],[109.719,20.01065],[109.71811,19.95751],[109.73756,19.97809],[109.80763,19.95136],[109.81593,19.9912],[109.83513,19.97333],[109.83546,19.93085],[109.87037,19.95335],[109.88331,19.98485],[109.94264,19.99091],[109.9756,19.93228],[109.95151,19.93085],[109.99008,19.90595],[110.0062,19.91035],[109.9922,19.92536],[110.01466,19.94086],[110.01368,19.95751],[110.06821,19.97866],[110.10987,19.96499],[110.12273,19.97431],[110.14959,20.00658],[110.11549,19.98485],[110.06821,19.98485],[110.12973,20.01276],[110.15699,20.06737],[110.261,20.03315],[110.34474,20.05805],[110.37672,20.02643],[110.36817,20.0078],[110.39723,19.97866],[110.41773,19.92341],[110.40943,19.98005],[110.38299,20.01276],[110.38299,20.08104],[110.42457,20.04751],[110.5276,20.02021],[110.52076,20.00658],[110.55055,19.98847],[110.57545,19.99917],[110.60581,19.92316],[110.62322,19.94074],[110.62322,19.97183],[110.60092,20.02106],[110.57081,20.03067],[110.56617,20.06952],[110.59311,20.10733],[110.65203,20.11701],[110.67791,20.16356],[110.71095,20.07518],[110.78834,20.0104],[110.86988,19.99702],[110.92066,20.01203],[110.93458,19.99506],[110.97706,19.86587],[110.99439,19.69428],[111.01051,19.68378]]],[[[111.96266,21.65762],[111.99985,21.654],[111.99985,21.62116],[111.90968,21.60053],[111.86329,21.56826],[111.80934,21.5655],[111.84205,21.60684],[111.8353,21.64395],[111.92823,21.66446],[111.94516,21.65013],[111.96266,21.65762]]],[[[112.64536,21.71284],[112.65455,21.69847],[112.63795,21.66446],[112.6504,21.64045],[112.60719,21.61603],[112.55592,21.62344],[112.5254,21.59886],[112.52426,21.6269],[112.56788,21.66063],[112.55592,21.67813],[112.64536,21.71284]]],[[[112.844,21.77432],[112.86256,21.76944],[112.86256,21.74559],[112.79754,21.70588],[112.81056,21.65762],[112.79347,21.60822],[112.81056,21.5893],[112.7649,21.58186],[112.73439,21.62873],[112.77442,21.69172],[112.71559,21.68024],[112.71168,21.70254],[112.7649,21.73176],[112.77752,21.77265],[112.844,21.77432]]],[[[113.39145,22.02753],[113.40577,22.00702],[113.30942,22.02411],[113.3436,22.06851],[113.38266,22.08959],[113.39894,22.06851],[113.41261,22.07599],[113.39145,22.02753]]],[[[113.36476,22.1579],[113.31365,22.0764],[113.27914,22.07469],[113.27174,22.12002],[113.32309,22.1579],[113.32797,22.14582],[113.36476,22.1579]]],[[[113.59352,22.78482],[113.59457,22.75609],[113.55828,22.75788],[113.52858,22.80215],[113.52117,22.78791],[113.50611,22.79804],[113.48081,22.84931],[113.47316,22.82539],[113.4559,22.83076],[113.39617,22.8981],[113.43295,22.91201],[113.4756,22.9007],[113.52703,22.84662],[113.57301,22.83006],[113.59352,22.78482]]],[[[117.07154,23.47309],[117.09033,23.46108],[117.12585,23.48269],[117.12257,23.46552],[117.14179,23.44965],[117.12878,23.39937],[116.94299,23.43006],[116.98219,23.45946],[117.01002,23.43895],[117.03758,23.4469],[117.04908,23.47408],[117.07154,23.47309]]],[[[117.4689,23.75309],[117.52666,23.74011],[117.51619,23.71628],[117.48416,23.71079],[117.47983,23.66268],[117.44483,23.66494],[117.42115,23.64646],[117.41537,23.60822],[117.43156,23.58263],[117.32048,23.57402],[117.36912,23.62236],[117.39373,23.67919],[117.3405,23.68895],[117.37669,23.72886],[117.37273,23.76675],[117.41358,23.77167],[117.44778,23.74225],[117.4689,23.75309]]],[[[119.30543,26.03596],[119.40121,25.99604],[119.40496,25.97793],[119.38111,25.96699],[119.25758,26.00145],[119.20924,26.09858],[119.25758,26.04755],[119.30543,26.03596]]],[[[121.25553,28.14],[121.28535,28.10195],[121.27914,28.08544],[121.23813,28.09292],[121.23146,28.06501],[121.15157,28.04123],[121.15524,28.07137],[121.12037,28.13569],[121.16528,28.15053],[121.23059,28.21973],[121.2564,28.2148],[121.26889,28.19394],[121.25553,28.14]]],[[[122.23878,30.31314],[122.20387,30.29141],[122.23194,30.27774],[122.20232,30.24193],[122.13771,30.25463],[122.07358,30.29141],[122.21144,30.34662],[122.23878,30.31314]]],[[[122.39894,30.45649],[122.42311,30.41494],[122.34799,30.43598],[122.28541,30.42206],[122.2728,30.43598],[122.28712,30.44221],[122.27377,30.45136],[122.28004,30.48261],[122.33741,30.45848],[122.36915,30.477],[122.37468,30.45734],[122.39894,30.45649]]],[[[121.83774,31.37515],[121.86785,31.3662],[121.88185,31.33661],[121.86622,31.30419],[121.83473,31.29255],[121.7942,31.3688],[121.83774,31.37515]]],[[[119.88071,32.12519],[119.90187,32.07339],[119.792,32.12177],[119.79103,32.16547],[119.81935,32.20376],[119.74985,32.22801],[119.71949,32.24958],[119.71616,32.27265],[119.75082,32.29312],[119.8143,32.27558],[119.88071,32.12519]]],[[[122.3213,39.16596],[122.4026,39.21381],[122.38893,39.18708],[122.4026,39.15913],[122.334,39.14631],[122.3213,39.16596]]],[[[122.636,39.23428],[122.74195,39.23607],[122.69068,39.19331],[122.69752,39.22126],[122.636,39.23428]]],[[[122.68442,39.26219],[122.58619,39.258],[122.53346,39.27167],[122.50506,39.29694],[122.68442,39.26219]]],[[[121.42254,39.47451],[121.45045,39.45026],[121.41627,39.44033],[121.43556,39.4162],[121.42172,39.37368],[121.33692,39.37739],[121.37965,39.42487],[121.36427,39.44546],[121.34059,39.44033],[121.29615,39.39142],[121.25945,39.39151],[121.26588,39.43358],[121.31105,39.44794],[121.34059,39.48135],[121.42254,39.47451]]],[[[122.96437,39.51545],[122.96803,39.54108],[122.98976,39.54951],[123.04005,39.52973],[123.00571,39.4973],[122.96437,39.51545]]],[[[110.59588,20.95873],[110.6325,20.93667],[110.5892,20.88349],[110.54754,20.91771],[110.59588,20.95873]]],[[[110.54754,21.03726],[110.51694,20.93765],[110.49301,20.97797],[110.44142,21.00585],[110.36305,21.00585],[110.26661,20.97264],[110.24586,20.98603],[110.26539,21.00434],[110.24586,21.02757],[110.28736,21.03384],[110.27312,21.0611],[110.30991,21.05292],[110.33522,21.06859],[110.30795,21.06086],[110.28736,21.07445],[110.34262,21.07465],[110.38982,21.09589],[110.41773,21.04059],[110.43214,21.05728],[110.52638,21.0821],[110.54754,21.03726]]],[[[110.61695,21.19147],[110.5652,21.11262],[110.54322,21.10497],[110.52068,21.1118],[110.47918,21.16413],[110.47918,21.15046],[110.46144,21.15107],[110.44532,21.16568],[110.45191,21.17845],[110.41773,21.17845],[110.46217,21.20515],[110.49985,21.20629],[110.46339,21.19074],[110.49293,21.1839],[110.54322,21.20417],[110.56861,21.19147],[110.55494,21.15672],[110.594,21.20279],[110.61695,21.19147]]],[[[118.09254,24.55622],[118.18141,24.5351],[118.18873,24.49413],[118.13648,24.42927],[118.09205,24.43846],[118.07,24.46027],[118.10304,24.47988],[118.10621,24.49413],[118.08229,24.48233],[118.07203,24.49787],[118.09254,24.55622]]],[[[119.84653,25.53632],[119.81861,25.5458],[119.79884,25.51581],[119.84653,25.46117],[119.77198,25.45059],[119.75733,25.40493],[119.69304,25.43765],[119.70997,25.48786],[119.66847,25.48786],[119.68621,25.50772],[119.74855,25.50259],[119.77833,25.52204],[119.72633,25.52513],[119.71616,25.53632],[119.72706,25.59089],[119.70265,25.5773],[119.69272,25.59422],[119.73048,25.64615],[119.76352,25.65949],[119.78175,25.65917],[119.77589,25.63573],[119.792,25.60456],[119.81349,25.60883],[119.83286,25.59089],[119.81691,25.57982],[119.87452,25.55679],[119.84653,25.53632]]],[[[121.95753,29.11953],[121.93531,29.06289],[121.88868,29.07233],[121.90016,29.09374],[121.86817,29.14741],[121.92661,29.16673],[121.95191,29.15229],[121.95753,29.11953]]],[[[122.14324,29.73338],[122.18686,29.67341],[122.16293,29.65453],[122.16977,29.67504],[122.12745,29.66901],[122.09278,29.72577],[122.04005,29.71638],[122.03289,29.73094],[122.06821,29.77953],[122.10743,29.78962],[122.14324,29.73338]]],[[[122.39519,29.88361],[122.38282,29.86473],[122.39519,29.83332],[122.35711,29.86172],[122.36736,29.8959],[122.32521,29.92788],[122.32545,29.95393],[122.40675,29.9527],[122.39519,29.92206],[122.40805,29.89985],[122.39519,29.88361]]],[[[121.88185,30.07636],[121.91049,30.02411],[121.88966,29.98395],[121.86012,29.97809],[121.82887,29.9975],[121.83473,30.04499],[121.85304,30.08714],[121.88185,30.07636]]],[[[122.32748,30.02448],[122.29363,29.93378],[122.22918,29.97866],[122.09897,30.01447],[122.01905,29.99722],[122.01222,30.03193],[121.9734,30.05996],[121.98081,30.09007],[121.96314,30.1424],[122.10328,30.1437],[122.25611,30.06818],[122.28956,30.06733],[122.32748,30.02448]]],[[[121.84408,31.60795],[121.98009,31.5403],[121.97785,31.47777],[121.85137,31.43918],[121.68463,31.50681],[121.57154,31.52936],[121.51746,31.56839],[121.31446,31.65558],[121.2859,31.70405],[121.17629,31.78432],[121.2193,31.82947],[121.26407,31.84108],[121.32256,31.82933],[121.4753,31.77077],[121.66322,31.65235],[121.84408,31.60795]]],[[[111.19654,15.77538],[111.1941,15.77611],[111.19288,15.77823],[111.1963,15.78571],[111.19939,15.78921],[111.20753,15.78929],[111.20851,15.78628],[111.20753,15.78217],[111.2046,15.77924],[111.19654,15.77538]]],[[[111.76336,16.04344],[111.7531,16.04466],[111.75343,16.04743],[111.7614,16.04865],[111.76588,16.04531],[111.76336,16.04344]]],[[[109.10092,21.02119],[109.0905,21.04194],[109.12273,21.06542],[109.13624,21.05101],[109.1277,21.03217],[109.10092,21.02119]]],[[[120.74838,37.93871],[120.75457,37.89667],[120.72242,37.91909],[120.72706,37.94766],[120.74838,37.93871]]],[[[120.60678,37.97989],[120.62582,37.97256],[120.62289,37.9488],[120.60678,37.97989]]],[[[120.72006,37.98094],[120.72047,37.96402],[120.68824,37.98526],[120.72006,37.98094]]],[[[120.76043,38.17048],[120.77329,38.15973],[120.74659,38.16185],[120.74269,38.18757],[120.76043,38.17048]]],[[[120.80649,38.28824],[120.80738,38.30488],[120.83375,38.31257],[120.80649,38.28824]]],[[[120.91424,38.3841],[120.90756,38.38516],[120.90691,38.39256],[120.91782,38.39545],[120.92457,38.38687],[120.92319,38.38325],[120.91725,38.38443],[120.91424,38.3841]]],[[[123.18067,39.09195],[123.19557,39.05207],[123.16066,39.02924],[123.14519,39.04328],[123.16472,39.06639],[123.14356,39.08088],[123.18067,39.09195]]],[[[111.60011,16.52729],[111.59986,16.52749],[111.60084,16.53197],[111.60287,16.53335],[111.60572,16.53352],[111.60816,16.53181],[111.60963,16.52827],[111.6089,16.52725],[111.60629,16.52424],[111.60279,16.52464],[111.60011,16.52729]]],[[[112.73504,16.65795],[112.73015,16.66022],[112.72527,16.66621],[112.72625,16.66901],[112.72877,16.66926],[112.73341,16.66608],[112.73683,16.66022],[112.73504,16.65795]]],[[[112.33318,16.82502],[112.32716,16.82974],[112.32594,16.83324],[112.32789,16.8371],[112.33058,16.83763],[112.33546,16.83259],[112.33725,16.82844],[112.33318,16.82502]]],[[[112.33367,16.93325],[112.33668,16.93211],[112.33709,16.93203],[112.33758,16.93134],[112.34108,16.92699],[112.3405,16.92357],[112.33546,16.92381],[112.33367,16.92642],[112.33367,16.93325]]],[[[110.47137,20.64737],[110.47584,20.59593],[110.45729,20.58686],[110.41765,20.65477],[110.43702,20.66791],[110.47137,20.64737]]],[[[113.278,21.92504],[113.25847,21.88044],[113.22584,21.90082],[113.2317,21.93447],[113.24773,21.94318],[113.26783,21.9433],[113.278,21.92504]]],[[[113.77175,22.00034],[113.76448,21.96682],[113.74821,21.97279],[113.73628,21.99716],[113.77175,22.00034]]],[[[114.30681,22.0572],[114.3025,22.04487],[114.20672,22.02578],[114.30421,22.06928],[114.30681,22.0572]]],[[[113.47348,22.09854],[113.47153,22.11408],[113.51889,22.12727],[113.54762,22.0926],[113.48471,22.07136],[113.47022,22.07762],[113.47348,22.09854]]],[[[113.62338,22.43987],[113.65783,22.42375],[113.62119,22.39142],[113.61044,22.41334],[113.62338,22.43987]]],[[[113.54676,22.74188],[113.5732,22.7007],[113.63569,22.65648],[113.64183,22.60162],[113.60261,22.61152],[113.47651,22.71951],[113.47654,22.73127],[113.54676,22.74188]]],[[[113.52921,22.96035],[113.5536,22.91128],[113.54501,22.87996],[113.50996,22.92122],[113.50575,22.95161],[113.51538,22.96922],[113.52921,22.96035]]],[[[116.99562,23.56354],[117.00929,23.555],[116.9878,23.52411],[116.97096,23.51463],[116.93727,23.52627],[116.99562,23.56354]]],[[[119.55104,25.20946],[119.56756,25.2049],[119.52898,25.16535],[119.51271,25.1988],[119.46119,25.20331],[119.44467,25.22907],[119.47039,25.25218],[119.51197,25.21345],[119.55104,25.20946]]],[[[119.34653,25.56672],[119.33497,25.43797],[119.29689,25.42292],[119.2815,25.4829],[119.30746,25.54682],[119.33172,25.57127],[119.34653,25.56672]]],[[[119.62696,26.1079],[119.64405,26.07844],[119.62037,26.0642],[119.55397,26.09565],[119.59938,26.12812],[119.62696,26.1079]]],[[[120.32683,26.93968],[120.32219,26.9621],[120.3449,26.97195],[120.38567,26.93517],[120.35255,26.92377],[120.32683,26.93968]]],[[[121.074,27.47948],[121.10336,27.45188],[121.06649,27.45498],[121.06726,27.43677],[121.04611,27.4652],[121.074,27.47948]]],[[[121.18165,27.86465],[121.1893,27.84829],[121.17302,27.84101],[121.17123,27.81879],[121.09799,27.81782],[121.11402,27.84414],[121.18165,27.86465]]],[[[121.04933,27.95087],[121.04518,27.96748],[121.07016,27.9829],[121.11134,27.98379],[121.13966,27.96577],[121.10328,27.94619],[121.04933,27.95087]]],[[[121.86215,29.12958],[121.86736,29.11579],[121.84205,29.09504],[121.78272,29.12466],[121.78981,29.14985],[121.86215,29.12958]]],[[[122.3047,29.81745],[122.30836,29.77627],[122.25131,29.8061],[122.2317,29.83625],[122.27117,29.84683],[122.31202,29.82905],[122.3047,29.81745]]],[[[121.97218,29.94477],[121.99244,29.90827],[121.94614,29.90396],[121.94093,29.93769],[121.95997,29.95319],[121.97218,29.94477]]],[[[122.34571,30.24193],[122.2776,30.23554],[122.32398,30.27216],[122.40772,30.26594],[122.40854,30.24396],[122.39584,30.23945],[122.38063,30.26374],[122.34571,30.24193]]],[[[122.52931,30.72077],[122.52475,30.70181],[122.42726,30.70393],[122.42994,30.74307],[122.47291,30.71723],[122.52931,30.72077]]],[[[121.76238,31.37376],[121.78224,31.34178],[121.76637,31.32062],[121.58448,31.44139],[121.69125,31.41918],[121.76238,31.37376]]],[[[118.63274,39.07458],[118.64874,39.04465],[118.59432,38.9722],[118.51431,38.91945],[118.48028,38.93482],[118.4909,38.94319],[118.4707,38.97055],[118.47848,39.04286],[118.52638,39.04669],[118.55972,39.00312],[118.62871,39.04855],[118.61464,39.06765],[118.63274,39.07458]]],[[[111.68027,16.57815],[111.67888,16.58112],[111.68165,16.58019],[111.68027,16.57815]]]],\"HK\":[[[[114.22983,22.55581],[114.23471,22.55618],[114.21485,22.53449],[114.20997,22.53449],[114.20932,22.52371],[114.21795,22.52603],[114.21868,22.52985],[114.2304,22.53295],[114.2343,22.53693],[114.24204,22.5502],[114.24594,22.55097],[114.24903,22.54784],[114.25457,22.55097],[114.25766,22.54784],[114.25327,22.53946],[114.25921,22.5408],[114.26564,22.53526],[114.26515,22.52847],[114.27003,22.52558],[114.278,22.52985],[114.2798,22.52603],[114.2798,22.51972],[114.27768,22.51166],[114.28517,22.51118],[114.28924,22.50519],[114.29851,22.501],[114.32659,22.50877],[114.33277,22.51195],[114.33513,22.50642],[114.30543,22.48302],[114.28045,22.47289],[114.26873,22.46503],[114.26409,22.45808],[114.25375,22.45177],[114.24594,22.45177],[114.23121,22.46979],[114.21713,22.46735],[114.21176,22.47057],[114.20932,22.46581],[114.21095,22.4573],[114.21559,22.45498],[114.21713,22.44794],[114.20997,22.44949],[114.20932,22.45409],[114.20224,22.45808],[114.1906,22.45653],[114.1867,22.45177],[114.16749,22.44806],[114.21176,22.4206],[114.20997,22.41275],[114.21485,22.40888],[114.20932,22.39867],[114.21559,22.40034],[114.2234,22.41502],[114.23422,22.42475],[114.23821,22.43537],[114.24383,22.43553],[114.25147,22.43073],[114.26718,22.43073],[114.26938,22.42552],[114.26718,22.41596],[114.27255,22.40965],[114.27646,22.41983],[114.28899,22.42601],[114.27646,22.444],[114.28899,22.45331],[114.30161,22.45653],[114.30079,22.46503],[114.32374,22.48005],[114.32504,22.47521],[114.33074,22.47411],[114.32504,22.46426],[114.3357,22.46174],[114.33806,22.44636],[114.33277,22.44001],[114.33741,22.43159],[114.34278,22.43769],[114.34758,22.42292],[114.35231,22.42369],[114.35157,22.44099],[114.35987,22.46117],[114.36785,22.45498],[114.36736,22.44627],[114.37021,22.44001],[114.38575,22.43924],[114.39975,22.43614],[114.39739,22.42841],[114.4013,22.41275],[114.38933,22.41791],[114.38177,22.41905],[114.37021,22.39798],[114.37501,22.40054],[114.38494,22.38736],[114.38974,22.36595],[114.38657,22.36522],[114.38575,22.37018],[114.37639,22.37063],[114.37949,22.36209],[114.37558,22.35346],[114.37021,22.34882],[114.36085,22.34577],[114.3576,22.33242],[114.35231,22.33946],[114.35231,22.35192],[114.34197,22.34577],[114.33904,22.35269],[114.33277,22.35977],[114.33432,22.37531],[114.3204,22.38076],[114.31861,22.38939],[114.31007,22.38377],[114.30006,22.38158],[114.28598,22.39098],[114.278,22.39098],[114.26873,22.37844],[114.27337,22.36363],[114.2798,22.35501],[114.27646,22.34805],[114.26718,22.35578],[114.26637,22.36595],[114.25839,22.36209],[114.25554,22.3559],[114.2658,22.34634],[114.27255,22.32071],[114.29127,22.31656],[114.29208,22.30516],[114.30388,22.30671],[114.31162,22.29731],[114.30567,22.29076],[114.2977,22.28563],[114.29054,22.28795],[114.28899,22.28718],[114.28956,22.27391],[114.30006,22.27705],[114.30315,22.26919],[114.30315,22.26215],[114.29672,22.25829],[114.27833,22.27045],[114.2798,22.27936],[114.27337,22.29576],[114.26482,22.30195],[114.26637,22.31599],[114.25554,22.32233],[114.25147,22.30516],[114.25302,22.29576],[114.23894,22.28168],[114.21176,22.31057],[114.21485,22.30117],[114.21249,22.30117],[114.19549,22.31826],[114.18653,22.30874],[114.19142,22.30195],[114.19142,22.2989],[114.18369,22.30195],[114.18165,22.29442],[114.17189,22.28872],[114.16725,22.29418],[114.16822,22.30549],[114.16489,22.31521],[114.15952,22.31521],[114.15952,22.32225],[114.15219,22.32852],[114.12436,22.34223],[114.12355,22.33714],[114.11337,22.35269],[114.11101,22.36595],[114.10483,22.36986],[114.0975,22.36302],[114.07911,22.36595],[114.0783,22.36286],[114.0704,22.36286],[114.06723,22.359],[114.05795,22.36131],[114.02052,22.35037],[114.01498,22.35346],[114.01498,22.359],[113.99863,22.359],[113.99187,22.36408],[113.98268,22.36677],[113.97674,22.37922],[113.97291,22.37999],[113.97063,22.38707],[113.96437,22.38068],[113.97462,22.37206],[113.95493,22.36986],[113.94483,22.36209],[113.93067,22.36909],[113.92596,22.36909],[113.9144,22.37612],[113.91831,22.38784],[113.91057,22.39867],[113.89698,22.40876],[113.90943,22.41861],[113.93067,22.41983],[113.94101,22.42678],[113.95411,22.44949],[113.97755,22.45885],[113.99863,22.48855],[114.00489,22.48855],[114.01271,22.48611],[114.01116,22.47289],[114.01498,22.47521],[114.02988,22.47826],[114.03142,22.48224],[114.03061,22.49551],[114.04225,22.501],[114.05795,22.50487],[114.06577,22.51585],[114.0853,22.52294],[114.08237,22.52936],[114.09146,22.53439],[114.10841,22.53439],[114.1171,22.52757],[114.12888,22.53811],[114.13818,22.53935],[114.14872,22.53811],[114.15389,22.55185],[114.16195,22.56054],[114.17322,22.56395],[114.18304,22.55619],[114.22983,22.55581]]],[[[114.13738,22.17878],[114.11085,22.17707],[114.11411,22.18574],[114.11411,22.21613],[114.10426,22.21597],[114.10637,22.2224],[114.1067,22.23578],[114.11329,22.23334],[114.11744,22.22187],[114.12574,22.21345],[114.13282,22.21613],[114.13282,22.20995],[114.12574,22.20523],[114.14967,22.20604],[114.15463,22.2036],[114.14308,22.19611],[114.13909,22.19111],[114.14536,22.1787],[114.13738,22.17878]]],[[[114.21559,22.28563],[114.22177,22.27936],[114.22812,22.28091],[114.23585,22.27782],[114.24366,22.26459],[114.25147,22.25906],[114.25147,22.24889],[114.24529,22.24189],[114.25375,22.22398],[114.24903,22.2224],[114.25611,22.2036],[114.24366,22.20128],[114.23276,22.21149],[114.23268,22.22199],[114.2295,22.23359],[114.22267,22.23884],[114.21681,22.23432],[114.22267,22.23253],[114.22267,22.22476],[114.21868,22.22398],[114.21176,22.21613],[114.21868,22.206],[114.21559,22.19896],[114.21949,22.19188],[114.20769,22.18956],[114.20606,22.20523],[114.20932,22.20755],[114.20655,22.21239],[114.19606,22.20755],[114.19142,22.20995],[114.19606,22.21532],[114.18906,22.21922],[114.1937,22.22553],[114.19142,22.23098],[114.18271,22.23253],[114.18385,22.24103],[114.17115,22.2333],[114.16725,22.22785],[114.16489,22.22785],[114.16098,22.24038],[114.15219,22.24502],[114.136,22.24575],[114.12908,22.24889],[114.11883,22.26919],[114.11256,22.27151],[114.11337,22.27782],[114.11565,22.27936],[114.13909,22.28872],[114.1657,22.27936],[114.17888,22.27936],[114.18816,22.28718],[114.19679,22.29186],[114.21559,22.28563]]],[[[113.84645,22.19709],[113.8392,22.2049],[113.83733,22.22862],[113.8392,22.2342],[113.84921,22.2342],[113.84921,22.24413],[113.85678,22.246],[113.85678,22.25227],[113.8506,22.25674],[113.8676,22.27074],[113.8768,22.27228],[113.88396,22.27936],[113.89251,22.29271],[113.92148,22.29186],[113.9284,22.28486],[113.93865,22.28872],[113.96192,22.30117],[113.97674,22.29808],[113.98373,22.30903],[113.99244,22.31057],[113.99952,22.32225],[114.01735,22.33552],[114.02052,22.32852],[114.04379,22.34577],[114.05486,22.33633],[114.05022,22.31993],[114.04615,22.32315],[114.03142,22.32457],[114.02963,22.30899],[114.01271,22.31135],[114.01271,22.29808],[114.02215,22.30191],[114.01759,22.28905],[114.01889,22.26459],[114.00416,22.26459],[113.99537,22.26777],[114.00725,22.2521],[113.99244,22.2412],[114.01498,22.22708],[114.00017,22.21613],[113.98194,22.20824],[113.98064,22.23725],[113.94565,22.23725],[113.9284,22.22398],[113.92148,22.22398],[113.91831,22.21922],[113.9284,22.21691],[113.9284,22.21076],[113.91912,22.20502],[113.90626,22.20401],[113.89405,22.21532],[113.89251,22.2224],[113.88396,22.22],[113.85914,22.2014],[113.84645,22.19709]]]],\"TW\":[[[[121.90577,24.9501],[121.83473,24.87153],[121.8235,24.85493],[121.81463,24.83173],[121.80885,24.80516],[121.80681,24.77875],[121.80779,24.75312],[121.82032,24.69965],[121.81959,24.68598],[121.81422,24.67231],[121.81495,24.64436],[121.82797,24.62279],[121.85271,24.60887],[121.88185,24.60399],[121.88185,24.59781],[121.86948,24.59894],[121.85776,24.59785],[121.84791,24.59492],[121.84083,24.59032],[121.85084,24.57298],[121.85597,24.55052],[121.85279,24.53099],[121.83009,24.51923],[121.82838,24.51073],[121.83131,24.50039],[121.83774,24.49103],[121.84002,24.48135],[121.83041,24.47313],[121.80681,24.46064],[121.78004,24.43268],[121.77914,24.42178],[121.78077,24.39761],[121.7715,24.38056],[121.7684,24.36937],[121.76645,24.34736],[121.75856,24.33234],[121.7693,24.31806],[121.77272,24.30923],[121.76743,24.30093],[121.74529,24.28124],[121.72145,24.25117],[121.70387,24.23835],[121.69825,24.23151],[121.70094,24.22419],[121.69777,24.21898],[121.67237,24.20148],[121.66285,24.1931],[121.65219,24.17219],[121.64129,24.12987],[121.61305,24.09211],[121.60743,24.07685],[121.60816,24.06049],[121.615,24.0423],[121.62892,24.02619],[121.62916,24.01496],[121.62501,24.00239],[121.6111,23.98249],[121.60816,23.97036],[121.6067,23.94806],[121.59449,23.89151],[121.57032,23.8321],[121.53981,23.69282],[121.5215,23.65868],[121.46217,23.343],[121.45362,23.32599],[121.42677,23.29312],[121.41676,23.27607],[121.40887,23.25458],[121.40333,23.21475],[121.40382,23.17927],[121.40008,23.14549],[121.3816,23.11066],[121.34742,23.08332],[121.34343,23.07539],[121.33709,23.05508],[121.31886,23.02554],[121.30763,22.9798],[121.29957,22.95917],[121.26051,22.90729],[121.25563,22.89777],[121.20281,22.85651],[121.19028,22.84317],[121.18849,22.83393],[121.19093,22.81196],[121.19028,22.80215],[121.18556,22.79096],[121.17742,22.77863],[121.15935,22.75747],[121.14145,22.74177],[121.08025,22.70409],[121.03907,22.66242],[121.02906,22.65754],[121.02101,22.64789],[121.00115,22.60077],[120.99122,22.58308],[120.96062,22.5478],[120.94825,22.5268],[120.94109,22.48363],[120.92286,22.42475],[120.88657,22.36595],[120.88136,22.34593],[120.87501,22.28925],[120.87924,22.05581],[120.8733,22.02998],[120.86362,22.00894],[120.8357,21.98725],[120.83619,21.95771],[120.84156,21.92548],[120.84034,21.90461],[120.82309,21.92862],[120.79737,21.94676],[120.76743,21.9593],[120.73731,21.96613],[120.7361,21.94282],[120.72731,21.93447],[120.71437,21.93749],[120.70037,21.94843],[120.69956,21.95669],[120.70436,21.98139],[120.68336,22.01386],[120.68165,22.02253],[120.68336,22.04121],[120.68336,22.07905],[120.68556,22.09015],[120.69483,22.10261],[120.69695,22.11323],[120.62127,22.29507],[120.615,22.305],[120.59392,22.32917],[120.57732,22.36384],[120.56967,22.37385],[120.53875,22.39643],[120.51808,22.40697],[120.51197,22.42475],[120.49529,22.43476],[120.45721,22.45161],[120.41326,22.48233],[120.40211,22.4868],[120.38087,22.48387],[120.33123,22.51972],[120.28517,22.58006],[120.33367,22.52595],[120.32797,22.5517],[120.31349,22.57705],[120.29331,22.59829],[120.25896,22.62759],[120.24578,22.64252],[120.23959,22.65908],[120.24781,22.67524],[120.25213,22.68964],[120.24635,22.70991],[120.23618,22.72988],[120.21754,22.75674],[120.19996,22.79247],[120.19606,22.80834],[120.19646,22.81859],[120.20216,22.83124],[120.20346,22.83967],[120.20135,22.84414],[120.19142,22.85204],[120.18922,22.86018],[120.17555,22.8841],[120.16383,22.95384],[120.15691,22.96654],[120.1421,22.98029],[120.16017,22.99213],[120.17026,23.01337],[120.16619,23.03172],[120.1421,23.0349],[120.13746,23.02839],[120.13347,23.01655],[120.12672,23.00715],[120.11411,23.00764],[120.10914,23.01545],[120.11101,23.03998],[120.10792,23.04857],[120.09246,23.05476],[120.08375,23.04755],[120.07667,23.03742],[120.06625,23.0349],[120.05519,23.04369],[120.05242,23.05581],[120.05494,23.06778],[120.05942,23.07587],[120.07057,23.0812],[120.08473,23.08295],[120.09645,23.08641],[120.10043,23.09699],[120.09498,23.10248],[120.05942,23.11066],[120.05942,23.1175],[120.0892,23.11774],[120.08627,23.12694],[120.07008,23.1398],[120.05942,23.15103],[120.06202,23.16502],[120.08188,23.17316],[120.08692,23.18203],[120.08383,23.19575],[120.07862,23.20698],[120.08744,23.23029],[120.0961,23.23949],[120.09425,23.2475],[120.09604,23.25536],[120.1089,23.28669],[120.11492,23.29271],[120.12468,23.29499],[120.11049,23.3037],[120.11653,23.3179],[120.13118,23.31881],[120.14356,23.32388],[120.14894,23.32225],[120.14576,23.33332],[120.13734,23.32773],[120.12094,23.32974],[120.12078,23.34125],[120.13015,23.36096],[120.16458,23.36171],[120.15064,23.39814],[120.13478,23.42626],[120.1421,23.43895],[120.1421,23.44579],[120.13054,23.45099],[120.13209,23.45698],[120.13868,23.46426],[120.1421,23.47309],[120.15754,23.49148],[120.12503,23.48961],[120.12426,23.52002],[120.14169,23.53795],[120.14087,23.59526],[120.14361,23.63804],[120.15423,23.67221],[120.17081,23.6989],[120.17555,23.73383],[120.18442,23.76439],[120.18922,23.77481],[120.21266,23.80317],[120.21949,23.81794],[120.24073,23.83283],[120.24675,23.84341],[120.25294,23.86807],[120.2575,23.8784],[120.28443,23.902],[120.29225,23.91193],[120.3025,23.93887],[120.31813,23.96581],[120.3401,24.0218],[120.35035,24.03807],[120.36183,24.04853],[120.37957,24.05801],[120.38551,24.06306],[120.38787,24.07331],[120.39235,24.08291],[120.41188,24.09691],[120.41635,24.10749],[120.41863,24.14252],[120.42205,24.15217],[120.4323,24.16108],[120.47779,24.20966],[120.48064,24.2211],[120.49317,24.23066],[120.50164,24.25287],[120.51197,24.29556],[120.54737,24.35155],[120.55812,24.38589],[120.57081,24.40493],[120.64894,24.48387],[120.65602,24.49787],[120.65935,24.5207],[120.66773,24.54002],[120.6902,24.57612],[120.6963,24.59772],[120.70216,24.607],[120.72787,24.61392],[120.73512,24.62124],[120.74464,24.63882],[120.76059,24.65396],[120.77809,24.66572],[120.79933,24.6708],[120.82667,24.66608],[120.82301,24.68504],[120.83334,24.69782],[120.8475,24.71084],[120.85768,24.73701],[120.87135,24.74323],[120.87696,24.76191],[120.89812,24.80117],[120.90919,24.81688],[120.90903,24.82274],[120.90235,24.83682],[120.90805,24.84707],[120.92058,24.8614],[120.92604,24.88471],[120.93393,24.8946],[120.94402,24.90205],[120.95362,24.90501],[120.95281,24.90925],[120.95533,24.91938],[120.96388,24.94042],[121.00554,25.00068],[121.03199,25.02924],[121.05934,25.05024],[121.12941,25.07111],[121.14576,25.08039],[121.15984,25.09162],[121.17693,25.10004],[121.21095,25.11115],[121.33025,25.13068],[121.34832,25.13711],[121.37175,25.15477],[121.37875,25.15795],[121.39894,25.15949],[121.41228,25.15546],[121.41944,25.14639],[121.42408,25.13691],[121.43002,25.13158],[121.44321,25.13642],[121.42945,25.15599],[121.4074,25.17621],[121.39576,25.18281],[121.3991,25.19213],[121.41627,25.2036],[121.42416,25.22134],[121.43629,25.24144],[121.46127,25.26191],[121.49781,25.27875],[121.53972,25.28742],[121.58082,25.28303],[121.59791,25.27143],[121.636,25.22281],[121.64967,25.19709],[121.6644,25.20136],[121.67612,25.20258],[121.68442,25.20051],[121.68393,25.19746],[121.67612,25.18692],[121.67644,25.17939],[121.68051,25.17504],[121.69459,25.16608],[121.70387,25.15689],[121.71852,25.152],[121.89283,25.11367],[121.90512,25.10659],[121.89894,25.09398],[121.89723,25.08389],[121.89991,25.06599],[121.90919,25.03604],[121.9192,25.02204],[121.92726,25.0187],[121.95387,25.02237],[121.96754,25.02106],[121.98357,25.01716],[121.99773,25.01069],[122.00538,25.0019],[121.92677,24.96662],[121.90577,24.9501]]],[[[121.56349,22.04462],[121.58106,22.03221],[121.58888,22.02351],[121.58766,22.01386],[121.56691,22.00776],[121.53891,22.01822],[121.51319,22.03506],[121.4983,22.04804],[121.50278,22.05756],[121.50262,22.06656],[121.49879,22.07477],[121.49204,22.08218],[121.56658,22.08218],[121.56658,22.07599],[121.56137,22.06367],[121.56007,22.05353],[121.56349,22.04462]]],[[[118.39422,24.52269],[118.4226,24.52797],[118.44959,24.50553],[118.46735,24.46183],[118.46336,24.43152],[118.44879,24.41551],[118.4229,24.4303],[118.40362,24.43601],[118.37286,24.4332],[118.34576,24.41758],[118.33099,24.39648],[118.31822,24.39054],[118.30323,24.39704],[118.29298,24.39765],[118.28395,24.40127],[118.27955,24.40843],[118.292,24.42499],[118.29273,24.43207],[118.28956,24.45563],[118.28297,24.47004],[118.28207,24.47736],[118.31666,24.49335],[118.35481,24.47056],[118.38141,24.47351],[118.38539,24.50382],[118.39422,24.52269]]],[[[119.64796,23.60346],[119.66578,23.57331],[119.67066,23.55923],[119.66847,23.54881],[119.66114,23.55573],[119.65138,23.56159],[119.64064,23.56354],[119.62566,23.55581],[119.615,23.55463],[119.60971,23.55195],[119.60011,23.52839],[119.58237,23.51895],[119.56397,23.51659],[119.54558,23.5231],[119.52711,23.53998],[119.53199,23.54881],[119.54217,23.53758],[119.55738,23.53461],[119.57374,23.53876],[119.58717,23.54881],[119.57057,23.55606],[119.55925,23.56932],[119.55714,23.56501],[119.55242,23.56249],[119.55242,23.5904],[119.56723,23.58853],[119.59547,23.60472],[119.61313,23.6103],[119.60629,23.5904],[119.62159,23.59467],[119.64186,23.59406],[119.64796,23.60346]]],[[[119.47706,23.60346],[119.48943,23.63963],[119.50131,23.65038],[119.51735,23.6376],[119.50782,23.63239],[119.50123,23.61982],[119.49073,23.5904],[119.49025,23.58246],[119.49285,23.57575],[119.49171,23.57103],[119.46608,23.56806],[119.45655,23.56249],[119.44239,23.56249],[119.44483,23.56977],[119.44947,23.57412],[119.46616,23.57681],[119.47446,23.57974],[119.47682,23.58682],[119.47706,23.60346]]],[[[119.5726,23.62767],[119.56935,23.63972],[119.56178,23.64985],[119.54526,23.66494],[119.56642,23.67919],[119.57887,23.67308],[119.59108,23.66352],[119.5962,23.65327],[119.58692,23.64501],[119.59067,23.6328],[119.58513,23.62474],[119.57683,23.6225],[119.5726,23.62767]]],[[[121.4607,22.659],[121.45541,22.67381],[121.45753,22.67593],[121.49578,22.67853],[121.50001,22.67626],[121.50278,22.6695],[121.49977,22.65567],[121.49496,22.64419],[121.4961,22.63467],[121.48316,22.63613],[121.48146,22.63809],[121.47145,22.64179],[121.4607,22.659]]],[[[120.02186,23.45164],[120.07493,23.50082],[120.11525,23.54791],[120.12477,23.55045],[120.0903,23.4994],[120.04791,23.45809],[120.01954,23.43838],[120.00766,23.43259],[120.02186,23.45164]]]],\"MO\":[[[[113.5586,22.16303],[113.57407,22.16075],[113.57407,22.15611],[113.57179,22.1529],[113.56788,22.15058],[113.56324,22.15058],[113.56023,22.14155],[113.57252,22.13732],[113.57797,22.13963],[113.57488,22.13504],[113.57976,22.1319],[113.58595,22.13194],[113.58749,22.12486],[113.58359,22.12332],[113.57748,22.12665],[113.57797,22.12092],[113.57407,22.11937],[113.56674,22.12336],[113.5652,22.11262],[113.56633,22.10769],[113.56324,22.10692],[113.55738,22.11115],[113.55152,22.10615],[113.5468,22.10537],[113.54371,22.10769],[113.54111,22.11327],[113.54526,22.11424],[113.54233,22.14073],[113.53411,22.14879],[113.53224,22.15262],[113.53443,22.1612],[113.55291,22.16584],[113.5586,22.16303]]],[[[113.53647,22.21707],[113.53891,22.22077],[113.5538,22.21149],[113.55681,22.20356],[113.55152,22.20441],[113.54843,22.20205],[113.54916,22.19973],[113.55071,22.19896],[113.55714,22.20246],[113.5538,22.19587],[113.55226,22.19741],[113.55071,22.19587],[113.55152,22.18956],[113.53989,22.18264],[113.53598,22.18264],[113.53289,22.1787],[113.5289,22.1787],[113.52475,22.17373],[113.52019,22.18488],[113.52215,22.1905],[113.53525,22.20755],[113.5337,22.21076],[113.53207,22.21076],[113.53289,22.20917],[113.52735,22.20755],[113.52434,22.21255],[113.5289,22.21463],[113.53525,22.21463],[113.53647,22.21707]]]]}"); +} diff --git a/src/function/createProviderEnvironment.mjs b/src/function/createProviderEnvironment.mjs new file mode 100644 index 00000000..75e37eb1 --- /dev/null +++ b/src/function/createProviderEnvironment.mjs @@ -0,0 +1,20 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; +import WAQI from "../class/WAQI.mjs"; +export default function createProviderEnvironment(parameters, settings, requestCache) { + let colorfulClouds, qWeather, waqi; + return { + country: parameters.country, + requestCache, + get colorfulClouds() { + return (colorfulClouds ??= new ColorfulClouds(parameters, settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", requestCache)); + }, + get qWeather() { + return (qWeather ??= new QWeather(parameters, settings.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", settings.API?.QWeather?.Host, requestCache)); + }, + get waqi() { + return (waqi ??= new WAQI(parameters, settings.API?.WAQI?.Token, requestCache)); + }, + }; +} diff --git a/src/function/database.mjs b/src/function/database.mjs index 1a244f02..d868d27e 100644 --- a/src/function/database.mjs +++ b/src/function/database.mjs @@ -1,8 +1,11 @@ +// Modified by hhhoratioxu, 2026-10-19: default short-lived provider response cache. export default { WeatherKit: { Settings: { // 只有插件会修改的产品允许被配置关闭;其余 Apple 数据集必须始终透传。 DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], + RequestCache: { TTL: 60, ForecastTTL: 180, MinutelyTTL: 15, AlertsTTL: 15, BudgetMs: 8000, CooldownSeconds: 30, Diagnostics: false }, + Region: { Fallback: "Auto" }, Weather: { Replace: ["CN"], Provider: "ColorfulClouds" }, WeatherAlerts: { Provider: "QWeatherWeb" }, NextHour: { Provider: "ColorfulClouds" }, diff --git a/src/function/ensureTextEncoder.mjs b/src/function/ensureTextEncoder.mjs new file mode 100644 index 00000000..df0cab8f --- /dev/null +++ b/src/function/ensureTextEncoder.mjs @@ -0,0 +1,46 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import utf8Length from "./utf8Length.mjs"; + +// FlatBuffers constructs a TextEncoder even when it only writes numbers. +// Supply its used encode() operation when a script host omits the Web API. +// Keep native implementations intact and bound the allocation before encoding. +if (typeof globalThis.TextEncoder !== "function") { + globalThis.TextEncoder = class TextEncoder { + get encoding() { + return "utf-8"; + } + encode(input = "") { + const value = String(input), + maximum = 2 * 1024 * 1024; + const size = utf8Length(value, maximum); + if (size > maximum) throw new RangeError("Weather string exceeds UTF-8 encoding limit"); + const bytes = new Uint8Array(size); + let offset = 0; + for (let index = 0; index < value.length; index++) { + let code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) << 10) + next - 0xdc00; + index++; + } else code = 0xfffd; + } else if (code >= 0xdc00 && code <= 0xdfff) code = 0xfffd; + if (code < 0x80) bytes[offset++] = code; + else if (code < 0x800) { + bytes[offset++] = 0xc0 | (code >> 6); + bytes[offset++] = 0x80 | (code & 0x3f); + } else if (code < 0x10000) { + bytes[offset++] = 0xe0 | (code >> 12); + bytes[offset++] = 0x80 | ((code >> 6) & 0x3f); + bytes[offset++] = 0x80 | (code & 0x3f); + } else { + bytes[offset++] = 0xf0 | (code >> 18); + bytes[offset++] = 0x80 | ((code >> 12) & 0x3f); + bytes[offset++] = 0x80 | ((code >> 6) & 0x3f); + bytes[offset++] = 0x80 | (code & 0x3f); + } + } + return bytes; + } + }; +} diff --git a/src/function/fetchProvider.mjs b/src/function/fetchProvider.mjs new file mode 100644 index 00000000..22ec4614 --- /dev/null +++ b/src/function/fetchProvider.mjs @@ -0,0 +1,32 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { fetch as sharedFetch } from "@nsnanocat/util"; + +// Loon's native HTTP timeout is milliseconds. The shared utility rounds short +// millisecond values through seconds, so keep this path native and exact. +export default function fetchProvider(request, options = {}) { + if (typeof $loon !== "string") return sharedFetch(request, options); + const input = typeof request === "string" ? { ...options, url: request } : { ...options, ...request }; + const method = String(input.method ?? (input.body !== undefined || input.bodyBytes !== undefined ? "POST" : "GET")).toLowerCase(); + const timeout = Number(input.timeout); + const outgoing = { url: input.url, timeout: Number.isFinite(timeout) && timeout > 0 ? Math.max(1, Math.min(5000, Math.floor(timeout))) : 5000 }; + if (input.headers) { + outgoing.headers = { ...input.headers }; + for (const key of Object.keys(outgoing.headers)) if (["host", ":authority", "content-length"].includes(key.toLowerCase())) delete outgoing.headers[key]; + } + for (const key of ["body", "body-base64", "node", "binary-mode", "auto-redirect", "auto-cookie", "insecure", "alpn"]) if (input[key] !== undefined) outgoing[key] = input[key]; + if (outgoing.body === undefined && input.bodyBytes !== undefined) outgoing.body = input.bodyBytes instanceof ArrayBuffer ? new Uint8Array(input.bodyBytes) : input.bodyBytes; + return new Promise((resolve, reject) => { + if (!["get", "post", "head", "delete", "put", "options", "patch"].includes(method) || typeof $httpClient?.[method] !== "function") { + reject(new TypeError("Unsupported provider HTTP method")); + return; + } + $httpClient[method](outgoing, (error, response, body) => { + if (error) { + reject(error); + return; + } + const status = Number(response?.status); + resolve({ ...response, status, statusCode: status, ok: status >= 200 && status < 300, headers: { ...response?.headers }, body: status === 304 ? "" : (body ?? "") }); + }); + }); +} diff --git a/src/function/mergeForecastCopy.mjs b/src/function/mergeForecastCopy.mjs new file mode 100644 index 00000000..a5abec0d --- /dev/null +++ b/src/function/mergeForecastCopy.mjs @@ -0,0 +1,16 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Preserve both inputs and share untouched rows/fields. A failed replacement can retain Apple data. +export default function mergeForecastCopy(to = [], from = []) { + let result, + j = 0; + for (let i = 0; i < to.length; i++) { + while (j < from.length && from[j].forecastStart < to[i].forecastStart) j++; + if (j >= from.length || from[j].forecastStart !== to[i].forecastStart) continue; + const source = from[j++], + row = { ...to[i], ...source }; + for (const key of ["daytimeForecast", "overnightForecast", "restOfDayForecast"]) if (Object.hasOwn(source, key)) row[key] = { ...to[i][key], ...source[key] }; + result ??= to.slice(); + result[i] = row; + } + return result ?? to; +} diff --git a/src/function/parseWeatherKitURL.mjs b/src/function/parseWeatherKitURL.mjs index 22c68dbe..38ffb185 100644 --- a/src/function/parseWeatherKitURL.mjs +++ b/src/function/parseWeatherKitURL.mjs @@ -1,6 +1,8 @@ +// Modified by hhhoratioxu, 2026-10-19: optional conservative region resolution. +import resolveWeatherRegion from "./resolveWeatherRegion.mjs"; import { Console } from "@nsnanocat/util"; -export default function parseWeatherKitURL(url = new URL($request.url)) { +export default function parseWeatherKitURL(url = new URL($request.url), regionOptions) { Console.info("☑️ parseWeatherKitURL"); const result = { version: undefined, @@ -53,6 +55,14 @@ export default function parseWeatherKitURL(url = new URL($request.url)) { break; } } - Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, `🟧latitude: ${result.latitude} 🟧longitude: ${result.longitude}`); + if (regionOptions && result.latitude !== undefined && result.longitude !== undefined) + result.country = resolveWeatherRegion({ + latitude: result.latitude, + longitude: result.longitude, + explicitCountry: url.searchParams.get("country"), + localeCountry: url.pathname === "/api/v1/weatherAlerts" ? (/^[A-Z]{2}$/i.test(result.language?.split("-").at(-1) ?? "") && result.language?.includes("-") ? result.language.split("-").at(-1) : undefined) : result.country, + ...regionOptions, + }); + Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, "coordinates: redacted"); return result; } diff --git a/src/function/planWeatherProducts.mjs b/src/function/planWeatherProducts.mjs new file mode 100644 index 00000000..8b52ae91 --- /dev/null +++ b/src/function/planWeatherProducts.mjs @@ -0,0 +1,9 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +export default function planWeatherProducts(requested, settings, country, hasNextHour = false) { + return requested.filter(product => { + if (["currentWeather", "forecastDaily", "forecastHourly"].includes(product)) return ["QWeather", "ColorfulClouds"].includes(settings.Weather?.Provider) && settings.Weather?.Replace?.includes(country); + if (product === "forecastNextHour") return settings.NextHour?.Provider !== "WeatherKit" && !hasNextHour; + if (product === "weatherAlerts") return settings.WeatherAlerts?.Provider !== "WeatherKit"; + return product === "airQuality"; + }); +} diff --git a/src/function/providerRecovery.mjs b/src/function/providerRecovery.mjs new file mode 100644 index 00000000..49cdd177 --- /dev/null +++ b/src/function/providerRecovery.mjs @@ -0,0 +1,44 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +export function providerProductAvailable(value) { + return Boolean(value && typeof value === "object" && value.metadata && value.metadata.temporarilyUnavailable !== true); +} +export function cloneWeatherProduct(value, seen = new Map(), depth = 0) { + if (!value || typeof value !== "object") return value; + if (depth > 64 || seen.size > 100000) throw new RangeError("Weather product exceeds clone bounds"); + if (seen.has(value)) return seen.get(value); + if (ArrayBuffer.isView(value)) return value instanceof DataView ? new DataView(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)) : new value.constructor(value); + if (value instanceof ArrayBuffer) return value.slice(0); + if (value instanceof Date) return new Date(value.getTime()); + if (value instanceof Map) return new Map([...value].map(([key, item]) => [key, cloneWeatherProduct(item, seen, depth + 1)])); + if (value instanceof Set) return new Set([...value].map(item => cloneWeatherProduct(item, seen, depth + 1))); + const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value)); + seen.set(value, copy); + for (const key of Object.keys(value)) copy[key] = cloneWeatherProduct(value[key], seen, depth + 1); + return copy; +} + +export function activeProviderAlerts(alerts, now = Date.now()) { + return (Array.isArray(alerts) ? alerts : []).filter(alert => { + const value = alert?.expireTime ?? alert?.eventEndTime; + if (!value || value === "9999-12-31T23:59:59Z") return true; + const timestamp = typeof value === "number" ? (value < 1e12 ? value * 1000 : value) : Date.parse(value); + return Number.isFinite(timestamp) && timestamp > now; + }); +} + +export function weatherProductsEqual(left, right, seen = new Map(), depth = 0) { + if (Object.is(left, right)) return true; + if (!left || !right || typeof left !== "object" || typeof right !== "object" || depth > 64) return false; + if (seen.get(left) === right) return true; + seen.set(left, right); + if (left instanceof Date || right instanceof Date) return left instanceof Date && right instanceof Date && left.getTime() === right.getTime(); + if (ArrayBuffer.isView(left) || left instanceof ArrayBuffer || ArrayBuffer.isView(right) || right instanceof ArrayBuffer) { + const bytes = value => (ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : value instanceof ArrayBuffer ? new Uint8Array(value) : undefined); + const a = bytes(left), + b = bytes(right); + return Boolean(a && b && a.length === b.length && a.every((value, index) => value === b[index])); + } + const keys = Object.keys(left); + if (keys.length !== Object.keys(right).length) return false; + return keys.every(key => Object.hasOwn(right, key) && weatherProductsEqual(left[key], right[key], seen, depth + 1)); +} diff --git a/src/function/recoverConfiguredProduct.mjs b/src/function/recoverConfiguredProduct.mjs new file mode 100644 index 00000000..b318e62b --- /dev/null +++ b/src/function/recoverConfiguredProduct.mjs @@ -0,0 +1,20 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import database from "./database.mjs"; +import { providerProductAvailable } from "./providerRecovery.mjs"; +const sharedTokens = Object.freeze({ QWeather: database.WeatherKit.Settings.API.QWeather.Token, ColorfulClouds: database.WeatherKit.Settings.API.ColorfulClouds.Token }); +export default async function recoverConfiguredProduct(_product, primary, original, selected, loaders, settings, runtime) { + if (providerProductAvailable(primary) || providerProductAvailable(original) || !["ColorfulClouds", "QWeather", "QWeatherWeb"].includes(selected)) return primary; + const backup = selected === "ColorfulClouds" ? "QWeather" : "ColorfulClouds"; + const token = settings?.API?.[backup]?.Token; + // Shared upstream defaults are not evidence that the user configured a backup account. + if (typeof token !== "string" || !token.trim() || token === sharedTokens[backup] || typeof loaders[backup] !== "function") return primary; + try { + const value = await loaders[backup](); + const available = providerProductAvailable(value); + runtime?.noteBackup(available); + return available ? value : primary; + } catch { + runtime?.noteBackup(false); + return primary; + } +} diff --git a/src/function/resolveWeatherRegion.mjs b/src/function/resolveWeatherRegion.mjs new file mode 100644 index 00000000..226df696 --- /dev/null +++ b/src/function/resolveWeatherRegion.mjs @@ -0,0 +1,68 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 + +import { fingerprint } from "../class/ProviderRequestCache.mjs"; +import getProviderRegions from "../data/providerRegions.mjs"; +export const REGION_HINT_KEY = "WeatherKit.RegionHints.v1"; +let polygonBounds; +function getPolygons() { + return (polygonBounds ??= Object.entries(getProviderRegions()).flatMap(([country, polygons]) => polygons.map(rings => ({ country, rings, bounds: rings[0].reduce((box, [x, y]) => [Math.min(box[0], x), Math.min(box[1], y), Math.max(box[2], x), Math.max(box[3], y)], [180, 90, -180, -90]) })))); +} +function distanceSquared(x, y, a, b) { + const dx = b[0] - a[0], + dy = b[1] - a[1], + length = dx * dx + dy * dy; + const t = length ? Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / length)) : 0; + return (x - a[0] - t * dx) ** 2 + (y - a[1] - t * dy) ** 2; +} +function ringContains(ring, x, y) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const a = ring[i], + b = ring[j]; + if (a[1] > y !== b[1] > y && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside; + } + return inside; +} +export function countryFromCoordinates(latitude, longitude) { + if (latitude === undefined || longitude === undefined || latitude === "" || longitude === "") return undefined; + const y = Number(latitude), + x = Number(longitude); + if (!Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > 180 || Math.abs(y) > 90) return undefined; + for (const { country, rings, bounds: b } of getPolygons()) { + if (x < b[0] || x > b[2] || y < b[1] || y > b[3] || !ringContains(rings[0], x, y) || rings.slice(1).some(r => ringContains(r, x, y))) continue; + // A generous exclusion exceeds simplification error; uncertain edges use other hints. + if (rings.some(r => r.some((a, i) => i > 0 && distanceSquared(x, y, a, r[i - 1]) <= (country === "CN" ? 0.05 : 0.01) ** 2))) return undefined; + return country; + } + return undefined; +} +export default function resolveWeatherRegion({ latitude, longitude, explicitCountry, localeCountry, fallback = "Auto", now = Date.now, store } = {}) { + const normalize = value => (typeof value === "string" && /^[A-Z]{2}$/i.test(value.trim()) ? value.trim().toUpperCase() : undefined); + const explicit = normalize(explicitCountry), + locale = normalize(localeCountry); + const valid = latitude !== undefined && longitude !== undefined && latitude !== "" && longitude !== "" && Number.isFinite(Number(latitude)) && Number.isFinite(Number(longitude)) && Math.abs(Number(latitude)) <= 90 && Math.abs(Number(longitude)) <= 180; + if (!valid) return explicit ?? locale; + const key = fingerprint(JSON.stringify([Number(latitude), Number(longitude)])), + timestamp = now(); + if (!store && typeof $loon === "string" && typeof $persistentStore !== "undefined") store = { read: () => $persistentStore.read(REGION_HINT_KEY), write: value => $persistentStore.write(value, REGION_HINT_KEY) }; + let entries = []; + try { + const raw = store?.read(); + if (typeof raw === "string" && raw.length <= 8192) { + const parsed = JSON.parse(raw); + if (parsed?.version === 1 && Array.isArray(parsed.entries) && parsed.entries.length <= 32) + entries = parsed.entries.filter(e => /^[a-f0-9]{64}$/.test(e.key) && normalize(e.country) && Number.isFinite(e.at) && timestamp >= e.at && timestamp - e.at < 7 * 86400000).map(e => ({ key: e.key, country: normalize(e.country), at: e.at })); + } + } catch {} + if (explicit) { + // Re-confirm stable coordinates at most hourly, not on every weather refresh. + if (entries.some(entry => entry.key === key && entry.country === explicit && timestamp - entry.at < 3600000)) return explicit; + try { + store?.write(JSON.stringify({ version: 1, entries: [{ key, country: explicit, at: timestamp }, ...entries.filter(e => e.key !== key)].slice(0, 32) })); + } catch {} + return explicit; + } + if (fallback === "Locale") return locale; + if (["CN", "HK", "MO", "TW"].includes(fallback)) return fallback; + return entries.find(e => e.key === key)?.country ?? countryFromCoordinates(latitude, longitude) ?? locale; +} diff --git a/src/function/serializeProviderCache.mjs b/src/function/serializeProviderCache.mjs new file mode 100644 index 00000000..3dbb6714 --- /dev/null +++ b/src/function/serializeProviderCache.mjs @@ -0,0 +1,41 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +const EMPTY_CACHE_LENGTH = '{"version":1,"entries":[]}'.length; + +// Count JSON string content in UTF-16 units without allocating an escaped copy. +// JSON.stringify escapes lone surrogates, but preserves valid surrogate pairs. +function escapedLength(value, maximum) { + let length = value.length; + for (let index = 0; index < value.length && length <= maximum; index++) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) length++; + else if (code < 0x20) length += code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 1 : 5; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) index++; + else length += 5; + } else if (code >= 0xdc00 && code <= 0xdfff) length += 5; + } + return length; +} + +// Entries are the cache's already validated, ordered plain records. Keep the +// same prefix as serialize-and-pop, without materializing oversized snapshots. +export default function serializeProviderCache(entries, maximum) { + const metadataLengths = entries.map(entry => JSON.stringify({ ...entry, body: "" }).length); + const upperBound = entries.reduce((length, entry, index) => length + metadataLengths[index] + entry.body.length * 6 + (index ? 1 : 0), EMPTY_CACHE_LENGTH); + if (upperBound > maximum) { + let length = EMPTY_CACHE_LENGTH, + count = 0; + for (const entry of entries) { + const metadataLength = metadataLengths[count] + (count ? 1 : 0), + remaining = maximum - length - metadataLength; + if (entry.body.length > remaining) break; + const bodyLength = escapedLength(entry.body, remaining); + if (bodyLength > remaining) break; + length += metadataLength + bodyLength; + count++; + } + entries.length = count; + } + return JSON.stringify({ version: 1, entries }); +} diff --git a/src/function/setENV.mjs b/src/function/setENV.mjs index e4bfc831..9f76b4a3 100644 --- a/src/function/setENV.mjs +++ b/src/function/setENV.mjs @@ -1,3 +1,4 @@ +import { cloneWeatherProduct } from "./providerRecovery.mjs"; import getStorage from "@nsnanocat/util/getStorage.mjs"; import { Console, Lodash as _ } from "@nsnanocat/util"; @@ -11,9 +12,9 @@ import { Console, Lodash as _ } from "@nsnanocat/util"; */ export default function setENV(name, platforms, database) { Console.info("☑️ Set Environment Variables"); - const { Settings, Caches, Configs } = getStorage(name, platforms, database); + const { Settings, Caches, Configs } = getStorage(name, platforms, cloneWeatherProduct(database)); /***************** Settings *****************/ - Console.info(`typeof Settings: ${typeof Settings}`, `Settings: ${JSON.stringify(Settings, null, 2)}`); + Console.info("WeatherKit settings loaded"); string2array("DataSets"); string2array("Weather.Replace"); string2array("AirQuality.Current.Index.Replace"); diff --git a/src/function/utf8Length.mjs b/src/function/utf8Length.mjs new file mode 100644 index 00000000..7baa92bc --- /dev/null +++ b/src/function/utf8Length.mjs @@ -0,0 +1,16 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Count UTF-8 bytes without allocating another full body or requiring TextEncoder. +export default function utf8Length(value, maximum = Number.POSITIVE_INFINITY) { + let size = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 128) size++; + else if (code < 2048) size += 2; + else if (code >= 0xd800 && code <= 0xdbff && value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) { + size += 4; + index++; + } else size += 3; + if (size > maximum) return size; + } + return size; +} diff --git a/src/function/weatherMemoryLimits.mjs b/src/function/weatherMemoryLimits.mjs new file mode 100644 index 00000000..0422eb20 --- /dev/null +++ b/src/function/weatherMemoryLimits.mjs @@ -0,0 +1,9 @@ +// Application work bounds, not limits on Loon's native HTTP buffers. +export const MAX_WEATHER_BODY_BYTES = 4 * 1024 * 1024; +export const MAX_DECODED_VECTOR_ENTRIES = 8192; +export const newDecodeBudget = () => ({ remaining: MAX_DECODED_VECTOR_ENTRIES }); +export function decodedVectorLength(length, budget) { + if (!Number.isSafeInteger(length) || length < 0 || length > budget.remaining) throw new RangeError("Weather product exceeds vector decoding budget"); + budget.remaining -= length; + return length; +} diff --git a/src/loon/request.js b/src/loon/request.js new file mode 100644 index 00000000..b451326c --- /dev/null +++ b/src/loon/request.js @@ -0,0 +1,25 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 + +import ProviderRequestCache from "../class/ProviderRequestCache.mjs"; +import database from "../function/database.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import setENV from "../function/setENV.mjs"; +import { GetWeatherAlerts } from "../process/Request.mjs"; + +(async () => { + const url = new URL($request.url), + identifier = url.searchParams.get("ids")?.trim(); + if (url.hostname !== "weatherkit.apple.com" || url.pathname !== "/api/v1/weatherAlerts" || ($request.method && !["GET", "HEAD", "OPTIONS"].includes($request.method)) || !identifier) return $request; + const { Settings } = setENV("iRingo", "WeatherKit", database); + const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const runtime = ProviderRequestCache.fromSettings(Settings); + try { + const response = await GetWeatherAlerts(identifier, parameters, Settings, $request.headers, runtime); + return response ? { response } : $request; + } finally { + runtime.report("request"); + } +})().then( + value => $done(value), + () => $done($request), +); diff --git a/src/loon/response.js b/src/loon/response.js new file mode 100644 index 00000000..3e9e001e --- /dev/null +++ b/src/loon/response.js @@ -0,0 +1,11 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { Console } from "@nsnanocat/util"; +import { Response } from "../process/Response.mjs"; + +Response($request, $response).then( + value => $done(value), + error => { + Console.error(error); + $done($response); + }, +); diff --git a/src/loon/scale.js b/src/loon/scale.js new file mode 100644 index 00000000..f3f04b23 --- /dev/null +++ b/src/loon/scale.js @@ -0,0 +1,16 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import AirQualityScale from "../class/AirQualityScale.mjs"; + +let result = $request; +try { + const url = new URL($request.url); + const match = url.pathname.match(/^\/api\/v1\/airQualityScale\/([A-Z0-9]+(?:-[A-Z0-9]+)*)\/((?:HK|CN)\.AQHI)(?:\.\d+)?$/i); + if (url.hostname === "weatherkit.apple.com" && match && (!$request.method || ["GET", "HEAD", "OPTIONS"].includes($request.method))) { + url.pathname = `/api/v1/airQualityScale/${match[1]}/${match[2]}`; + const response = new AirQualityScale().Build(match[1], match[2]); + result = response ? { response } : { ...$request, url: url.toString() }; + } +} catch { + /* Unknown requests preserve the caller's data. */ +} +$done(result); diff --git a/src/process/Request.dev.mjs b/src/process/Request.dev.mjs index 1baa5cd9..be028c28 100644 --- a/src/process/Request.dev.mjs +++ b/src/process/Request.dev.mjs @@ -1,7 +1,10 @@ +// Modified by hhhoratioxu, 2026-10-19: honor cache TTL for provider JSON alert requests. import { Lodash as _, Console, Storage } from "@nsnanocat/util"; import AirQualityScale from "../class/AirQualityScale.mjs"; import ColorfulClouds from "../class/ColorfulClouds.mjs"; import QWeather from "../class/QWeather.mjs"; +import { activeProviderAlerts } from "../function/providerRecovery.mjs"; +import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; import WeatherAlerts from "../class/WeatherAlerts.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; @@ -12,8 +15,7 @@ export async function Request($request) { let $response = undefined; // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - const parameters = parseWeatherKitURL(url); + Console.info("WeatherKit request"); // 解析格式 const FORMAT = ($request.headers?.["Content-Type"] ?? $request.headers?.["content-type"])?.split(";")?.[0]; Console.info(`FORMAT: ${FORMAT}`); @@ -22,122 +24,125 @@ export async function Request($request) { * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); - // 方法判断 - switch ($request.method) { - case "POST": - case "PUT": - case "PATCH": - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "DELETE": - // 格式判断 - 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": - //body = M3U8.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = M3U8.stringify(body); - break; - case "text/xml": - case "text/html": - case "text/plist": - case "application/xml": - case "application/plist": - case "application/x-plist": - //body = XML.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = XML.stringify(body); - break; - case "text/vtt": - case "application/vtt": - //body = VTT.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = VTT.stringify(body); - break; - case "text/json": - case "application/json": - //body = JSON.parse($request.body ?? "{}"); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = JSON.stringify(body); - break; - case "application/protobuf": - case "application/x-protobuf": - case "application/vnd.google.protobuf": - case "application/grpc": - case "application/grpc+proto": - case "application/octet-stream": { - //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); - let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); - //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); - // 写入二进制数据 - $request.body = rawBody; - break; + const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const runtime = ProviderRequestCache.fromSettings(Settings); + try { + // 方法判断 + switch ($request.method) { + case "POST": + case "PUT": + case "PATCH": + // biome-ignore lint/suspicious/noFallthroughSwitchClause: + case "DELETE": + // 格式判断 + 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": + //body = M3U8.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = M3U8.stringify(body); + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + //body = XML.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = XML.stringify(body); + break; + case "text/vtt": + case "application/vtt": + //body = VTT.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = VTT.stringify(body); + break; + case "text/json": + case "application/json": + //body = JSON.parse($request.body ?? "{}"); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = JSON.stringify(body); + break; + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + case "application/grpc": + case "application/grpc+proto": + case "application/octet-stream": { + //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); + let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); + //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); + // 写入二进制数据 + $request.body = rawBody; + break; + } } - } - //break; // 不中断,继续处理URL - case "GET": - case "HEAD": - case "OPTIONS": - default: - delete $request?.headers?.["If-None-Match"]; - delete $request?.headers?.["if-none-match"]; - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - case "/api/v1/weatherAlerts": { - $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers); - break; - } - default: - switch (true) { - case url.pathname.startsWith("/api/v1/airQualityScale/"): { - const { version, language } = parameters; - const scaleName = parameters.scale?.replace(/\.\d+$/, ""); - if (version && language && scaleName) { - url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; - switch (url.pathname) { - case `/api/v1/airQualityScale/${language}/HK.AQHI`: - case `/api/v1/airQualityScale/${language}/CN.AQHI`: - $response = new AirQualityScale().Build(language, scaleName); - break; + //break; // 不中断,继续处理URL + case "GET": + case "HEAD": + case "OPTIONS": + default: + delete $request?.headers?.["If-None-Match"]; + delete $request?.headers?.["if-none-match"]; + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + case "/api/v1/weatherAlerts": { + $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); + break; + } + default: + switch (true) { + case url.pathname.startsWith("/api/v1/airQualityScale/"): { + const { version, language } = parameters; + const scaleName = parameters.scale?.replace(/\.\d+$/, ""); + if (version && language && scaleName) { + url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; + switch (url.pathname) { + case `/api/v1/airQualityScale/${language}/HK.AQHI`: + case `/api/v1/airQualityScale/${language}/CN.AQHI`: + $response = new AirQualityScale().Build(language, scaleName); + break; + } } + break; } - break; - } - case url.pathname.startsWith("/api/v2/weather/"): { - // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 - switch (true) { - case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - if (url.searchParams.has("country")) { - //if (url.searchParams.get("country") === "CN") url.searchParams.set("country", "TW"); - } else { - const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; - if (gcc) url.searchParams.set("country", gcc); - } - break; + case url.pathname.startsWith("/api/v2/weather/"): { + // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 + switch (true) { + case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + if (url.searchParams.has("country")) { + //if (url.searchParams.get("country") === "CN") url.searchParams.set("country", "TW"); + } else { + const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; + if (gcc) url.searchParams.set("country", gcc); + } + break; + } + break; } - break; } - } - break; - } - break; - case "weather-map2.apple.com": { - // 路径判断 - switch (url.pathname) { - case "/v1/mapOverlay/precipitationRadarMap": - case "/v1/mapOverlay/precipitationForecastByFrameTime": { - /* + break; + } + break; + case "weather-map2.apple.com": { + // 路径判断 + switch (url.pathname) { + case "/v1/mapOverlay/precipitationRadarMap": + case "/v1/mapOverlay/precipitationForecastByFrameTime": { + /* switch (true) { case $request.headers["User-Agent"]?.startsWith("Weather_macOS_Version"): case $request.headers["user-agent"]?.startsWith("Weather_macOS_Version"): @@ -152,20 +157,23 @@ export async function Request($request) { break; } */ - break; + break; + } } + break; } - break; } - } - break; - case "CONNECT": - case "TRACE": - break; + break; + case "CONNECT": + case "TRACE": + break; + } + $request.url = url.toString(); + + return { $request, $response }; + } finally { + runtime.report("request"); } - $request.url = url.toString(); - Console.debug(`$request.url: ${$request.url}`); - return { $request, $response }; } /** @@ -176,44 +184,66 @@ export async function Request($request) { * @param {Record} headers - 请求头 * @returns {Promise} 天气预警响应 */ -async function GetWeatherAlerts(identifier, parameters, Settings, headers) { +async function GetWeatherAlerts(identifier, parameters, Settings, headers, runtime) { let body = []; try { switch (true) { case /^[^&#]*-[0-9]{9}$/.test(identifier): { Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); - const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers); - body = WeatherAlerts.Build(source, { - attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), - identifier, - language: parameters.language, - countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", - }); + const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers, runtime); + if (source?.metadata?.temporarilyUnavailable) { + runtime.noteFallback("request"); + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), + identifier, + language: parameters.language, + countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", + }, + ); break; } case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { switch (Settings?.WeatherAlerts?.Provider) { case "ColorfulClouds": { Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); - const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="); + const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", runtime); const source = await colorfulClouds.WeatherAlert(); - body = WeatherAlerts.Build(source, { - attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); + if (source?.metadata?.temporarilyUnavailable) { + runtime.noteFallback("request"); + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: parameters.language, + countryCode: parameters.country, + }, + ); break; } case "QWeatherWeb": case "QWeather": { - const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host); - body = WeatherAlerts.Build(await qWeather.WeatherAlert(), { - attributionUrl: "https://www.12379.cn/", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); + const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host, runtime); + const source = await qWeather.WeatherAlert(); + if (source?.metadata?.temporarilyUnavailable) { + runtime.noteFallback("request"); + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: "https://www.12379.cn/", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: parameters.language, + countryCode: parameters.country, + }, + ); break; } } @@ -221,7 +251,8 @@ async function GetWeatherAlerts(identifier, parameters, Settings, headers) { } } } catch (error) { - Console.error("WeatherAlerts", error?.stack ?? error?.message ?? String(error)); + runtime.noteFallback("request", providerErrorCode(error)); + return undefined; } Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); return { diff --git a/src/process/Request.mjs b/src/process/Request.mjs index aa4f0322..83fca286 100644 --- a/src/process/Request.mjs +++ b/src/process/Request.mjs @@ -1,7 +1,10 @@ +// Modified by hhhoratioxu, 2026-10-19: honor cache TTL for provider JSON alert requests. import { Lodash as _, Console, Storage } from "@nsnanocat/util"; import AirQualityScale from "../class/AirQualityScale.mjs"; import ColorfulClouds from "../class/ColorfulClouds.mjs"; import QWeather from "../class/QWeather.mjs"; +import { activeProviderAlerts } from "../function/providerRecovery.mjs"; +import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; import WeatherAlerts from "../class/WeatherAlerts.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; @@ -12,8 +15,7 @@ export async function Request($request) { let $response = undefined; // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - const parameters = parseWeatherKitURL(url); + Console.info("WeatherKit request"); // 解析格式 const FORMAT = ($request.headers?.["Content-Type"] ?? $request.headers?.["content-type"])?.split(";")?.[0]; Console.info(`FORMAT: ${FORMAT}`); @@ -22,124 +24,130 @@ export async function Request($request) { * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); - // 方法判断 - switch ($request.method) { - case "POST": - case "PUT": - case "PATCH": - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "DELETE": - // 格式判断 - 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": - //body = M3U8.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = M3U8.stringify(body); - break; - case "text/xml": - case "text/html": - case "text/plist": - case "application/xml": - case "application/plist": - case "application/x-plist": - //body = XML.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = XML.stringify(body); - break; - case "text/vtt": - case "application/vtt": - //body = VTT.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = VTT.stringify(body); - break; - case "text/json": - case "application/json": - //body = JSON.parse($request.body ?? "{}"); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = JSON.stringify(body); - break; - case "application/protobuf": - case "application/x-protobuf": - case "application/vnd.google.protobuf": - case "application/grpc": - case "application/grpc+proto": - case "application/octet-stream": { - //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); - let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); - //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); - // 写入二进制数据 - $request.body = rawBody; - break; + const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const runtime = ProviderRequestCache.fromSettings(Settings); + try { + // 方法判断 + switch ($request.method) { + case "POST": + case "PUT": + case "PATCH": + // biome-ignore lint/suspicious/noFallthroughSwitchClause: + case "DELETE": + // 格式判断 + 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": + //body = M3U8.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = M3U8.stringify(body); + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + //body = XML.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = XML.stringify(body); + break; + case "text/vtt": + case "application/vtt": + //body = VTT.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = VTT.stringify(body); + break; + case "text/json": + case "application/json": + //body = JSON.parse($request.body ?? "{}"); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = JSON.stringify(body); + break; + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + case "application/grpc": + case "application/grpc+proto": + case "application/octet-stream": { + //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); + let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); + //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); + // 写入二进制数据 + $request.body = rawBody; + break; + } } - } - //break; // 不中断,继续处理URL - case "GET": - case "HEAD": - case "OPTIONS": - default: - delete $request?.headers?.["If-None-Match"]; - delete $request?.headers?.["if-none-match"]; - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - case "/api/v1/weatherAlerts": { - $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers); - break; - } - default: - switch (true) { - case url.pathname.startsWith("/api/v1/airQualityScale/"): { - const { version, language } = parameters; - const scaleName = parameters.scale?.replace(/\.\d+$/, ""); - if (version && language && scaleName) { - url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; - switch (url.pathname) { - case `/api/v1/airQualityScale/${language}/HK.AQHI`: - case `/api/v1/airQualityScale/${language}/CN.AQHI`: - $response = new AirQualityScale().Build(language, scaleName); - break; + //break; // 不中断,继续处理URL + case "GET": + case "HEAD": + case "OPTIONS": + default: + delete $request?.headers?.["If-None-Match"]; + delete $request?.headers?.["if-none-match"]; + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + case "/api/v1/weatherAlerts": { + $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); + break; + } + default: + switch (true) { + case url.pathname.startsWith("/api/v1/airQualityScale/"): { + const { version, language } = parameters; + const scaleName = parameters.scale?.replace(/\.\d+$/, ""); + if (version && language && scaleName) { + url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; + switch (url.pathname) { + case `/api/v1/airQualityScale/${language}/HK.AQHI`: + case `/api/v1/airQualityScale/${language}/CN.AQHI`: + $response = new AirQualityScale().Build(language, scaleName); + break; + } } + break; } - break; - } - case url.pathname.startsWith("/api/v2/weather/"): { - // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 - switch (true) { - case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - if (url.searchParams.has("country")) { - } else { - const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; - if (gcc) url.searchParams.set("country", gcc); - } - break; + case url.pathname.startsWith("/api/v2/weather/"): { + // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 + switch (true) { + case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + if (url.searchParams.has("country")) { + } else { + const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; + if (gcc) url.searchParams.set("country", gcc); + } + break; + } + break; } - break; } - } - break; - } - break; - } - break; - case "CONNECT": - case "TRACE": - break; + break; + } + break; + } + break; + case "CONNECT": + case "TRACE": + break; + } + $request.url = url.toString(); + + return { $request, $response }; + } finally { + runtime.report("request"); } - $request.url = url.toString(); - Console.debug(`$request.url: ${$request.url}`); - return { $request, $response }; } /** @@ -150,44 +158,66 @@ export async function Request($request) { * @param {Record} headers - 请求头 * @returns {Promise} 天气预警响应 */ -async function GetWeatherAlerts(identifier, parameters, Settings, headers) { +export async function GetWeatherAlerts(identifier, parameters, Settings, headers, runtime) { let body = []; try { switch (true) { case /^[^&#]*-[0-9]{9}$/.test(identifier): { Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); - const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers); - body = WeatherAlerts.Build(source, { - attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), - identifier, - language: parameters.language, - countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", - }); + const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers, runtime); + if (source?.metadata?.temporarilyUnavailable) { + runtime.noteFallback("request"); + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), + identifier, + language: parameters.language, + countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", + }, + ); break; } case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { switch (Settings?.WeatherAlerts?.Provider) { case "ColorfulClouds": { Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); - const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="); + const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", runtime); const source = await colorfulClouds.WeatherAlert(); - body = WeatherAlerts.Build(source, { - attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); + if (source?.metadata?.temporarilyUnavailable) { + runtime.noteFallback("request"); + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: parameters.language, + countryCode: parameters.country, + }, + ); break; } case "QWeatherWeb": case "QWeather": { - const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host); - body = WeatherAlerts.Build(await qWeather.WeatherAlert(), { - attributionUrl: "https://www.12379.cn/", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }); + const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host, runtime); + const source = await qWeather.WeatherAlert(); + if (source?.metadata?.temporarilyUnavailable) { + runtime.noteFallback("request"); + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: "https://www.12379.cn/", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: parameters.language, + countryCode: parameters.country, + }, + ); break; } } @@ -195,7 +225,8 @@ async function GetWeatherAlerts(identifier, parameters, Settings, headers) { } } } catch (error) { - Console.error("WeatherAlerts", error?.stack ?? error?.message ?? String(error)); + runtime.noteFallback("request", providerErrorCode(error)); + return undefined; } Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); return { diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 2eb95155..0b6d73cf 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -1,210 +1,243 @@ +// Modified by hhhoratioxu, 2026-10-19: share one bounded provider cache per response. +import ResponseJSONV3 from "./ResponseJSONV3.mjs"; import { Console, Storage } from "@nsnanocat/util"; import * as flatbuffers from "flatbuffers"; import AirQuality from "../class/AirQuality.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; import MatchEnum from "../class/MatchEnum.mjs"; +import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; import QWeather from "../class/QWeather.mjs"; -import WAQI from "../class/WAQI.mjs"; -import Weather from "../class/Weather.mjs"; import WeatherAlerts from "../class/WeatherAlerts.mjs"; -import WeatherKit2 from "../class/WeatherKit2.mjs"; +import WeatherKit2, { hasForecastNextHour } from "../class/WeatherKit2.mjs"; +import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; +import mergeForecastCopy from "../function/mergeForecastCopy.mjs"; import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import planWeatherProducts from "../function/planWeatherProducts.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import { activeProviderAlerts, cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import recoverConfiguredProduct from "../function/recoverConfiguredProduct.mjs"; import setENV from "../function/setENV.mjs"; +import { MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; /***************** Processing *****************/ export async function Response($request, $response) { // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - // 获取连接参数 - const PATHs = url.pathname.split("/").filter(Boolean); - Console.info(`PATHs: ${PATHs}`); + // 解析格式 - const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]; - Console.info(`FORMAT: ${FORMAT}`); + const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]?.trim()?.toLowerCase(); + if (FORMAT === "application/json" || FORMAT === "text/json") { + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) return ResponseJSONV3($request, $response, { + airQuality: InjectAirQuality, currentWeather: InjectCurrentWeather, + forecastDaily: InjectForecastDaily, forecastHourly: InjectForecastHourly, + forecastNextHour: InjectForecastNextHour, weatherAlerts: InjectWeatherAlerts, + }); + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v1/availability/")) $response.body = JSON.stringify(mergeWeatherKitAvailability(JSON.parse($response.body), database.WeatherKit.Configs.Availability.v2)); + return $response; + } + if (FORMAT !== "application/vnd.apple.flatbuffer" || url.hostname !== "weatherkit.apple.com" || !url.pathname.startsWith("/api/v2/weather/")) return $response; + const input = $response.bodyBytes ?? $response.body; + if ((input?.byteLength ?? input?.length ?? 0) > MAX_WEATHER_BODY_BYTES) return $response; /** * 设置 * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches, Configs } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; - // 创建空数据 - let body = {}; - // 格式判断 - switch (FORMAT) { - case undefined: // 视为无body - break; - case "application/x-www-form-urlencoded": - case "text/plain": - default: - //Console.debug(`body: ${body}`); - break; - case "application/x-mpegURL": - case "application/x-mpegurl": - case "application/vnd.apple.mpegurl": - case "audio/mpegurl": - //body = M3U8.parse($response.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$response.body = M3U8.stringify(body); - break; - case "text/xml": - case "text/html": - case "text/plist": - case "application/xml": - case "application/plist": - case "application/x-plist": - //body = XML.parse($response.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$response.body = XML.stringify(body); - break; - case "text/vtt": - case "application/vtt": - //body = VTT.parse($response.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$response.body = VTT.stringify(body); - break; - case "text/json": - case "application/json": - body = JSON.parse($response.body); - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - default: - if (url.pathname.startsWith("/api/v1/availability/")) { - Console.debug(`body: ${JSON.stringify(body)}`); - body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); - } - break; - } - 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": { - //Console.debug(`$response: ${JSON.stringify($response, null, 2)}`); - let rawBody = $response.bodyBytes ? new Uint8Array($response.bodyBytes) : ($response.body ?? new Uint8Array()); - //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody)}`); - switch (FORMAT) { - case "application/vnd.apple.flatbuffer": { - // 解析FlatBuffer - const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - if (url.pathname.startsWith("/api/v2/weather/")) { - const parameters = parseWeatherKitURL(url); - const dataSets = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); - const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); - body = WeatherKit2.decode(ByteBuffer, rootNames); - const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; - const matchEnum = new MatchEnum(body); - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - await matchEnum.init(); + Console.info("WeatherKit response", `FORMAT: ${FORMAT}`); + let requestCache; + try { + // 创建空数据 + let body = {}; + // 格式判断 + switch (FORMAT) { + case undefined: // 视为无body + break; + case "application/x-www-form-urlencoded": + case "text/plain": + default: + //Console.debug(`body: ${body}`); + break; + case "application/x-mpegURL": + case "application/x-mpegurl": + case "application/vnd.apple.mpegurl": + case "audio/mpegurl": + //body = M3U8.parse($response.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$response.body = M3U8.stringify(body); + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + //body = XML.parse($response.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$response.body = XML.stringify(body); + break; + case "text/vtt": + case "application/vtt": + //body = VTT.parse($response.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$response.body = VTT.stringify(body); + break; + case "text/json": + case "application/json": + body = JSON.parse($response.body); + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + default: + if (url.pathname.startsWith("/api/v1/availability/")) { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body: ${JSON.stringify(body)}`); + body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); } - const enviroments = { - colorfulClouds: new ColorfulClouds(providerParameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), - qWeather: new QWeather(providerParameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host), - waqi: new WAQI(parameters, Settings?.API?.WAQI?.Token), - country: parameters.country, - }; - - await Promise.all( - dataSets.map(async dataSet => { - switch (dataSet) { - case "airQuality": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.airQuality(); - } - body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); - break; - } - case "currentWeather": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.weatherCondition(); - matchEnum.pressureTrend(); - } - body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); - break; - } - case "forecastDaily": { - body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); - break; - } - case "forecastHourly": { - body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); - break; - } - case "forecastNextHour": { - Console.debug(`body.forecastNextHour: ${JSON.stringify(body?.forecastNextHour, null, 2)}`); - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.conditionType(); - matchEnum.forecastToken(); + break; + } + 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": { + //Console.debug(`$response: ${JSON.stringify($response, null, 2)}`); + const bytes = $response.bodyBytes; + let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); + //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody)}`); + switch (FORMAT) { + case "application/vnd.apple.flatbuffer": { + // 解析FlatBuffer + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v2/weather/")) { + const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); + if (!dataSets.length) break; + requestCache = ProviderRequestCache.fromSettings(Settings); + const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); + body = WeatherKit2.decode(ByteBuffer, rootNames); + const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; + const matchEnum = new MatchEnum(body); + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + await matchEnum.init(); + } + const enviroments = createProviderEnvironment(providerParameters, Settings, requestCache); + + let patch; + await Promise.all( + dataSets.map(async dataSet => { + if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; + const original = body[dataSet]; + try { + body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; + switch (dataSet) { + case "airQuality": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.airQuality(); + } + body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); + break; + } + case "currentWeather": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.weatherCondition(); + matchEnum.pressureTrend(); + } + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); + break; + } + case "forecastNextHour": { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.forecastNextHour: ${JSON.stringify(body?.forecastNextHour, null, 2)}`); + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.conditionType(); + matchEnum.forecastToken(); + } + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); + break; + } + case "news": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.placementType(); + } + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.news: ${JSON.stringify(body?.news, null, 2)}`); + break; + } + case "weatherAlerts": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.severity(); + matchEnum.significanceType(); + matchEnum.urgency(); + matchEnum.certainty(); + matchEnum.importanceType(); + matchEnum.responseType(); + } + body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.weatherAlerts: ${JSON.stringify(body?.weatherAlerts, null, 2)}`); + break; + } + case "locationInfo": { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.locationInfo: ${JSON.stringify(body?.locationInfo, null, 2)}`); + break; + } + default: + break; } - body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); - break; - } - case "news": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.placementType(); + if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; + else { + patch ??= {}; + patch[dataSet] = body[dataSet]; } - Console.debug(`body.news: ${JSON.stringify(body?.news, null, 2)}`); - break; + } catch (error) { + body[dataSet] = original; + requestCache.noteFallback(dataSet, providerErrorCode(error)); } - case "weatherAlerts": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.severity(); - matchEnum.significanceType(); - matchEnum.urgency(); - matchEnum.certainty(); - matchEnum.importanceType(); - matchEnum.responseType(); - } - body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); - Console.debug(`body.weatherAlerts: ${JSON.stringify(body?.weatherAlerts, null, 2)}`); - break; - } - case "locationInfo": { - Console.debug(`body.locationInfo: ${JSON.stringify(body?.locationInfo, null, 2)}`); - break; - } - default: - break; - } - }), - ); - rawBody = WeatherKit2.encode(ByteBuffer, body); + }), + ); + if (patch) rawBody = WeatherKit2.encode(ByteBuffer, patch); + break; + } break; - } - 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; } - 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; } - // 写入二进制数据 - $response.body = rawBody; - break; } + return $response; + } finally { + requestCache?.report("response"); } - return $response; } /** @@ -235,12 +268,14 @@ async function InjectCurrentWeather(currentWeather, Settings, enviroments) { break; } } - if (newCurrentWeather?.metadata) { + newCurrentWeather = await recoverConfiguredProduct("currentWeather", newCurrentWeather, currentWeather, Settings?.Weather?.Provider, { QWeather: () => enviroments.qWeather.WeatherNow(), ColorfulClouds: () => enviroments.colorfulClouds.CurrentWeather() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newCurrentWeather)) { newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; currentWeather = { ...currentWeather, ...newCurrentWeather }; currentWeather.metadata.providerLogo ||= providerNameToLogo(currentWeather.metadata.providerName); //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); } + if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newCurrentWeather)) enviroments.requestCache?.noteFallback("currentWeather"); Console.info("✅ InjectCurrentWeather"); return currentWeather; } @@ -276,12 +311,25 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { break; } } - if (newForecastDaily?.metadata) { - forecastDaily.metadata = { ...forecastDaily?.metadata, ...newForecastDaily.metadata }; - Weather.mergeForecast(forecastDaily?.days, newForecastDaily?.days); + newForecastDaily = await recoverConfiguredProduct( + "forecastDaily", + newForecastDaily, + forecastDaily, + Settings?.Weather?.Provider, + { QWeather: () => enviroments.qWeather.Daily(), ColorfulClouds: () => enviroments.colorfulClouds.Daily(forecastDaily?.days?.length || 11, forecastDaily?.days?.[0]?.forecastStart) }, + Settings, + enviroments.requestCache, + ); + if (providerProductAvailable(newForecastDaily)) { + forecastDaily = { + ...forecastDaily, + metadata: { ...forecastDaily?.metadata, ...newForecastDaily.metadata }, + days: forecastDaily?.days?.length ? mergeForecastCopy(forecastDaily.days, newForecastDaily.days) : (newForecastDaily.days ?? []), + }; forecastDaily.metadata.providerLogo ||= providerNameToLogo(forecastDaily.metadata.providerName); //Console.debug(`forecastDaily: ${JSON.stringify(forecastDaily, null, 2)}`); } + if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastDaily)) enviroments.requestCache?.noteFallback("forecastDaily"); Console.info("✅ InjectForecastDaily"); return forecastDaily; } @@ -317,12 +365,25 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { break; } } - if (newForecastHourly?.metadata) { - forecastHourly.metadata = { ...forecastHourly?.metadata, ...newForecastHourly.metadata }; - forecastHourly.hours = Weather.mergeForecast(forecastHourly?.hours, newForecastHourly?.hours); + newForecastHourly = await recoverConfiguredProduct( + "forecastHourly", + newForecastHourly, + forecastHourly, + Settings?.Weather?.Provider, + { QWeather: () => enviroments.qWeather.Hourly(), ColorfulClouds: () => enviroments.colorfulClouds.ForecastHourly(forecastHourly?.hours?.length || 273, forecastHourly?.hours?.[0]?.forecastStart) }, + Settings, + enviroments.requestCache, + ); + if (providerProductAvailable(newForecastHourly)) { + forecastHourly = { + ...forecastHourly, + metadata: { ...forecastHourly?.metadata, ...newForecastHourly.metadata }, + hours: forecastHourly?.hours?.length ? mergeForecastCopy(forecastHourly.hours, newForecastHourly.hours) : (newForecastHourly.hours ?? []), + }; forecastHourly.metadata.providerLogo ||= providerNameToLogo(forecastHourly.metadata.providerName); //Console.debug(`forecastHourly: ${JSON.stringify(forecastHourly, null, 2)}`); } + if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastHourly)) enviroments.requestCache?.noteFallback("forecastHourly"); Console.info("✅ InjectForecastHourly"); return forecastHourly; } @@ -356,12 +417,14 @@ async function InjectForecastNextHour(forecastNextHour, Settings, enviroments) { break; } } - if (newForecastNextHour?.metadata) { + newForecastNextHour = await recoverConfiguredProduct("forecastNextHour", newForecastNextHour, forecastNextHour, Settings?.NextHour?.Provider ?? "ColorfulClouds", { QWeather: () => enviroments.qWeather.Minutely(), ColorfulClouds: () => enviroments.colorfulClouds.Minutely() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newForecastNextHour)) { newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; forecastNextHour.metadata.providerLogo ||= providerNameToLogo(forecastNextHour.metadata.providerName); - Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); } + if (Settings?.NextHour?.Provider !== "WeatherKit" && !providerProductAvailable(newForecastNextHour)) enviroments.requestCache?.noteFallback("forecastNextHour"); Console.info("✅ InjectForecastNextHour"); return forecastNextHour; } @@ -382,11 +445,11 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } case "ColorfulClouds": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); break; } case "QWeather": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); break; } case "QWeatherWeb": @@ -395,12 +458,16 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } } - if (newWeatherAlerts?.metadata) { + newWeatherAlerts = await recoverConfiguredProduct("weatherAlerts", newWeatherAlerts, weatherAlerts, Settings?.WeatherAlerts?.Provider, { QWeather: () => enviroments.qWeather.WeatherAlert(), ColorfulClouds: () => enviroments.colorfulClouds.WeatherAlert() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newWeatherAlerts)) { + // Only copy when a usable provider result will mutate the Apple data. + weatherAlerts = cloneWeatherProduct(weatherAlerts) ?? {}; weatherAlerts.metadata = { ...weatherAlerts?.metadata, ...newWeatherAlerts.metadata }; weatherAlerts.detailsUrl = newWeatherAlerts.detailsUrl ?? weatherAlerts.detailsUrl; - weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, newWeatherAlerts?.alerts); + weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, activeProviderAlerts(newWeatherAlerts?.alerts)); weatherAlerts.metadata.providerLogo ||= providerNameToLogo(weatherAlerts.metadata.providerName); } + if (Settings?.WeatherAlerts?.Provider !== "WeatherKit" && !providerProductAvailable(newWeatherAlerts)) enviroments.requestCache?.noteFallback("weatherAlerts"); Console.info("✅ InjectWeatherAlerts"); return weatherAlerts; } @@ -416,23 +483,44 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step1. 修复污染物单位 // Step 1. Fix pollutant units. - airQuality = AirQuality.FixPollutantsUnits(airQuality); - - // Step2. 判断原始污染物是否为空,并在需要时注入污染物数据 - const isPollutantEmpty = !Array.isArray(airQuality?.pollutants) || airQuality.pollutants.length === 0; - const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; - const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); + airQuality = AirQuality.FixPollutantsUnits(airQuality, { appleProduct: true }); + + // 只补齐缺失/无效的污染物,已有有效 Apple 浓度优先;失败不丢弃原数据。 + const originalPollutants = AirQuality.ValidPollutants(airQuality?.pollutants); + let injectedPollutants = { ...airQuality, pollutants: originalPollutants }; + let needPollutants = false; + if (AirQuality.MissingPollutants(originalPollutants).length) { + try { + const supplement = await InjectPollutants(Settings, enviroments); + if (providerProductAvailable(supplement)) { + const pollutants = AirQuality.MergePollutants(originalPollutants, supplement.pollutants); + needPollutants = pollutants.length > originalPollutants.length; + if (needPollutants) injectedPollutants = { ...supplement, ...airQuality, pollutants, metadata: { ...supplement.metadata, temporarilyUnavailable: false } }; + } + } catch (error) { + enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); + } + } - // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + // 用户选定的计算算法也适用于完整的 Apple 数据,不受原始地区标准限制。 + const calculate = Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm !== "None" && Settings?.AirQuality?.Current?.Index?.Replace?.length > 0; + const needInjectIndex = calculate || needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; + const indexAvailable = needInjectIndex && providerProductAvailable(injectedIndex); - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // 不沿用另一算法的昨日趋势;仅在配置了同算法历史计算时重算。 const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = needInjectIndex && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; - const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; - const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; + const previousDayComparison = indexAvailable && (calculate || Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange) ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN && (!calculate || Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider === "Calculate"); + const currentIndexProvider = indexAvailable ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + let injectedComparison = { ...injectedIndex, previousDayComparison }; + if (needInjectComparison) { + try { + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments); + } catch (error) { + enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); + } + } // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 const weatherKitMetadata = airQuality?.metadata; @@ -455,13 +543,14 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? 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.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings)), previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; - Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); return airQuality; } @@ -597,7 +686,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayAirQuality.metadata.temporarilyUnavailable) { - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayAirQuality, metadata: getMetadata(false), @@ -634,7 +723,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Storage.setItem("@iRingo.WeatherKit.Caches", { ...Caches, qweather: qweatherCache }); }; - const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache); + const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.requestCache); const { latitude, longitude } = enviroments.qWeather; const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); @@ -647,7 +736,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayQWeather.metadata.temporarilyUnavailable) { - const airQualityFromPollutants = pollutantsToAirQuality(yesterdayQWeather); + const airQualityFromPollutants = pollutantsToAirQuality?.(yesterdayQWeather); const yesterdayAirQuality = pollutantsToAirQuality ? { ...airQualityFromPollutants, @@ -664,7 +753,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }, }; - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayQWeather, metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), diff --git a/src/process/Response.mjs b/src/process/Response.mjs index 941e2a17..9b648b32 100644 --- a/src/process/Response.mjs +++ b/src/process/Response.mjs @@ -1,160 +1,193 @@ +// Modified by hhhoratioxu, 2026-10-19: share one bounded provider cache per response. +import ResponseJSONV3 from "./ResponseJSONV3.mjs"; import { Console, Storage } from "@nsnanocat/util"; import * as flatbuffers from "flatbuffers"; import AirQuality from "../class/AirQuality.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; import QWeather from "../class/QWeather.mjs"; -import WAQI from "../class/WAQI.mjs"; -import Weather from "../class/Weather.mjs"; import WeatherAlerts from "../class/WeatherAlerts.mjs"; -import WeatherKit2 from "../class/WeatherKit2.mjs"; +import WeatherKit2, { hasForecastNextHour } from "../class/WeatherKit2.mjs"; +import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; +import mergeForecastCopy from "../function/mergeForecastCopy.mjs"; import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import planWeatherProducts from "../function/planWeatherProducts.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; +import { activeProviderAlerts, cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import recoverConfiguredProduct from "../function/recoverConfiguredProduct.mjs"; import setENV from "../function/setENV.mjs"; +import { MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; /***************** Processing *****************/ export async function Response($request, $response) { // 解构URL const url = new URL($request.url); - Console.info(`url: ${url.toJSON()}`); - // 获取连接参数 - const PATHs = url.pathname.split("/").filter(Boolean); - Console.info(`PATHs: ${PATHs}`); + // 解析格式 - const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]; - Console.info(`FORMAT: ${FORMAT}`); + const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]?.trim()?.toLowerCase(); + if (FORMAT === "application/json" || FORMAT === "text/json") { + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) return ResponseJSONV3($request, $response, { + airQuality: InjectAirQuality, currentWeather: InjectCurrentWeather, + forecastDaily: InjectForecastDaily, forecastHourly: InjectForecastHourly, + forecastNextHour: InjectForecastNextHour, weatherAlerts: InjectWeatherAlerts, + }); + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v1/availability/")) $response.body = JSON.stringify(mergeWeatherKitAvailability(JSON.parse($response.body), database.WeatherKit.Configs.Availability.v2)); + return $response; + } + if (FORMAT !== "application/vnd.apple.flatbuffer" || url.hostname !== "weatherkit.apple.com" || !url.pathname.startsWith("/api/v2/weather/")) return $response; + const input = $response.bodyBytes ?? $response.body; + if ((input?.byteLength ?? input?.length ?? 0) > MAX_WEATHER_BODY_BYTES) return $response; /** * 设置 * @type {{Settings: import('../types').Settings}} */ const { Settings, Caches, Configs } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; - // 创建空数据 - let body = {}; - // 格式判断 - 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); - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - default: - if (url.pathname.startsWith("/api/v1/availability/")) { - body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); - } - break; - } - 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": { - // 解析FlatBuffer - const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - if (url.pathname.startsWith("/api/v2/weather/")) { - const parameters = parseWeatherKitURL(url); - const dataSets = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); - const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); - body = WeatherKit2.decode(ByteBuffer, rootNames); - const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; - const enviroments = { - colorfulClouds: new ColorfulClouds(providerParameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ=="), - qWeather: new QWeather(providerParameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host), - waqi: new WAQI(parameters, Settings?.API?.WAQI?.Token), - country: parameters.country, - }; - - await Promise.all( - dataSets.map(async dataSet => { - switch (dataSet) { - case "airQuality": { - body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); - break; - } - case "currentWeather": { - body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); - break; - } - case "forecastDaily": { - body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); - break; - } - case "forecastHourly": { - body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); - break; - } - case "forecastNextHour": { - body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); - break; - } - case "weatherAlerts": { - body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); - break; + Console.info("WeatherKit response", `FORMAT: ${FORMAT}`); + let requestCache; + try { + // 创建空数据 + let body = {}; + // 格式判断 + 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); + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + default: + if (url.pathname.startsWith("/api/v1/availability/")) { + body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); + } + break; + } + 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": { + const bytes = $response.bodyBytes; + let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); + switch (FORMAT) { + case "application/vnd.apple.flatbuffer": { + // 解析FlatBuffer + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v2/weather/")) { + const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); + if (!dataSets.length) break; + requestCache = ProviderRequestCache.fromSettings(Settings); + const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); + body = WeatherKit2.decode(ByteBuffer, rootNames); + const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; + const enviroments = createProviderEnvironment(providerParameters, Settings, requestCache); + + let patch; + await Promise.all( + dataSets.map(async dataSet => { + if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; + const original = body[dataSet]; + try { + body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; + switch (dataSet) { + case "airQuality": { + body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); + break; + } + case "currentWeather": { + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); + break; + } + case "forecastNextHour": { + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); + break; + } + case "weatherAlerts": { + body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); + break; + } + default: + break; + } + if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; + else { + patch ??= {}; + patch[dataSet] = body[dataSet]; + } + } catch (error) { + body[dataSet] = original; + requestCache.noteFallback(dataSet, providerErrorCode(error)); } - default: - break; - } - }), - ); - rawBody = WeatherKit2.encode(ByteBuffer, body); + }), + ); + if (patch) rawBody = WeatherKit2.encode(ByteBuffer, patch); + break; + } break; - } - 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; } - 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; } - // 写入二进制数据 - $response.body = rawBody; - break; } + return $response; + } finally { + requestCache?.report("response"); } - return $response; } /** @@ -185,11 +218,13 @@ async function InjectCurrentWeather(currentWeather, Settings, enviroments) { break; } } - if (newCurrentWeather?.metadata) { + newCurrentWeather = await recoverConfiguredProduct("currentWeather", newCurrentWeather, currentWeather, Settings?.Weather?.Provider, { QWeather: () => enviroments.qWeather.WeatherNow(), ColorfulClouds: () => enviroments.colorfulClouds.CurrentWeather() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newCurrentWeather)) { newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; currentWeather = { ...currentWeather, ...newCurrentWeather }; currentWeather.metadata.providerLogo ||= providerNameToLogo(currentWeather.metadata.providerName); } + if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newCurrentWeather)) enviroments.requestCache?.noteFallback("currentWeather"); Console.info("✅ InjectCurrentWeather"); return currentWeather; } @@ -224,11 +259,24 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { break; } } - if (newForecastDaily?.metadata) { - forecastDaily.metadata = { ...forecastDaily?.metadata, ...newForecastDaily.metadata }; - Weather.mergeForecast(forecastDaily?.days, newForecastDaily?.days); + newForecastDaily = await recoverConfiguredProduct( + "forecastDaily", + newForecastDaily, + forecastDaily, + Settings?.Weather?.Provider, + { QWeather: () => enviroments.qWeather.Daily(), ColorfulClouds: () => enviroments.colorfulClouds.Daily(forecastDaily?.days?.length || 11, forecastDaily?.days?.[0]?.forecastStart) }, + Settings, + enviroments.requestCache, + ); + if (providerProductAvailable(newForecastDaily)) { + forecastDaily = { + ...forecastDaily, + metadata: { ...forecastDaily?.metadata, ...newForecastDaily.metadata }, + days: forecastDaily?.days?.length ? mergeForecastCopy(forecastDaily.days, newForecastDaily.days) : (newForecastDaily.days ?? []), + }; forecastDaily.metadata.providerLogo ||= providerNameToLogo(forecastDaily.metadata.providerName); } + if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastDaily)) enviroments.requestCache?.noteFallback("forecastDaily"); Console.info("✅ InjectForecastDaily"); return forecastDaily; } @@ -263,11 +311,24 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { break; } } - if (newForecastHourly?.metadata) { - forecastHourly.metadata = { ...forecastHourly?.metadata, ...newForecastHourly.metadata }; - forecastHourly.hours = Weather.mergeForecast(forecastHourly?.hours, newForecastHourly?.hours); + newForecastHourly = await recoverConfiguredProduct( + "forecastHourly", + newForecastHourly, + forecastHourly, + Settings?.Weather?.Provider, + { QWeather: () => enviroments.qWeather.Hourly(), ColorfulClouds: () => enviroments.colorfulClouds.ForecastHourly(forecastHourly?.hours?.length || 273, forecastHourly?.hours?.[0]?.forecastStart) }, + Settings, + enviroments.requestCache, + ); + if (providerProductAvailable(newForecastHourly)) { + forecastHourly = { + ...forecastHourly, + metadata: { ...forecastHourly?.metadata, ...newForecastHourly.metadata }, + hours: forecastHourly?.hours?.length ? mergeForecastCopy(forecastHourly.hours, newForecastHourly.hours) : (newForecastHourly.hours ?? []), + }; forecastHourly.metadata.providerLogo ||= providerNameToLogo(forecastHourly.metadata.providerName); } + if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastHourly)) enviroments.requestCache?.noteFallback("forecastHourly"); Console.info("✅ InjectForecastHourly"); return forecastHourly; } @@ -301,11 +362,13 @@ async function InjectForecastNextHour(forecastNextHour, Settings, enviroments) { break; } } - if (newForecastNextHour?.metadata) { + newForecastNextHour = await recoverConfiguredProduct("forecastNextHour", newForecastNextHour, forecastNextHour, Settings?.NextHour?.Provider ?? "ColorfulClouds", { QWeather: () => enviroments.qWeather.Minutely(), ColorfulClouds: () => enviroments.colorfulClouds.Minutely() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newForecastNextHour)) { newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; forecastNextHour.metadata.providerLogo ||= providerNameToLogo(forecastNextHour.metadata.providerName); } + if (Settings?.NextHour?.Provider !== "WeatherKit" && !providerProductAvailable(newForecastNextHour)) enviroments.requestCache?.noteFallback("forecastNextHour"); Console.info("✅ InjectForecastNextHour"); return forecastNextHour; } @@ -326,11 +389,11 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } case "ColorfulClouds": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); break; } case "QWeather": { - if (isNationalWarningCenter) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); break; } case "QWeatherWeb": @@ -339,12 +402,16 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } } - if (newWeatherAlerts?.metadata) { + newWeatherAlerts = await recoverConfiguredProduct("weatherAlerts", newWeatherAlerts, weatherAlerts, Settings?.WeatherAlerts?.Provider, { QWeather: () => enviroments.qWeather.WeatherAlert(), ColorfulClouds: () => enviroments.colorfulClouds.WeatherAlert() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newWeatherAlerts)) { + // Only copy when a usable provider result will mutate the Apple data. + weatherAlerts = cloneWeatherProduct(weatherAlerts) ?? {}; weatherAlerts.metadata = { ...weatherAlerts?.metadata, ...newWeatherAlerts.metadata }; weatherAlerts.detailsUrl = newWeatherAlerts.detailsUrl ?? weatherAlerts.detailsUrl; - weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, newWeatherAlerts?.alerts); + weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, activeProviderAlerts(newWeatherAlerts?.alerts)); weatherAlerts.metadata.providerLogo ||= providerNameToLogo(weatherAlerts.metadata.providerName); } + if (Settings?.WeatherAlerts?.Provider !== "WeatherKit" && !providerProductAvailable(newWeatherAlerts)) enviroments.requestCache?.noteFallback("weatherAlerts"); Console.info("✅ InjectWeatherAlerts"); return weatherAlerts; } @@ -360,23 +427,44 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { // Step1. 修复污染物单位 // Step 1. Fix pollutant units. - airQuality = AirQuality.FixPollutantsUnits(airQuality); - - // Step2. 判断原始污染物是否为空,并在需要时注入污染物数据 - const isPollutantEmpty = !Array.isArray(airQuality?.pollutants) || airQuality.pollutants.length === 0; - const injectedPollutants = isPollutantEmpty ? await InjectPollutants(Settings, enviroments) : airQuality; - const needPollutants = isPollutantEmpty && !!(injectedPollutants?.metadata && !injectedPollutants.metadata.temporarilyUnavailable); + airQuality = AirQuality.FixPollutantsUnits(airQuality, { appleProduct: true }); + + // 只补齐缺失/无效的污染物,已有有效 Apple 浓度优先;失败不丢弃原数据。 + const originalPollutants = AirQuality.ValidPollutants(airQuality?.pollutants); + let injectedPollutants = { ...airQuality, pollutants: originalPollutants }; + let needPollutants = false; + if (AirQuality.MissingPollutants(originalPollutants).length) { + try { + const supplement = await InjectPollutants(Settings, enviroments); + if (providerProductAvailable(supplement)) { + const pollutants = AirQuality.MergePollutants(originalPollutants, supplement.pollutants); + needPollutants = pollutants.length > originalPollutants.length; + if (needPollutants) injectedPollutants = { ...supplement, ...airQuality, pollutants, metadata: { ...supplement.metadata, temporarilyUnavailable: false } }; + } + } catch (error) { + enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); + } + } - // Step3. 根据污染物补齐情况与替换配置,决定是否注入 AQI 指数 - const needInjectIndex = needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + // 用户选定的计算算法也适用于完整的 Apple 数据,不受原始地区标准限制。 + const calculate = Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm !== "None" && Settings?.AirQuality?.Current?.Index?.Replace?.length > 0; + const needInjectIndex = calculate || needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; + const indexAvailable = needInjectIndex && providerProductAvailable(injectedIndex); - // Step4. 计算昨日对比是否需要重算;若未知则注入昨日对比结果 + // 不沿用另一算法的昨日趋势;仅在配置了同算法历史计算时重算。 const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = needInjectIndex && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; - const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const currentIndexProvider = needInjectIndex ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; - const injectedComparison = needInjectComparison ? await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments) : { ...injectedIndex, previousDayComparison: weatherKitComparison }; + const previousDayComparison = indexAvailable && (calculate || Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange) ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN && (!calculate || Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider === "Calculate"); + const currentIndexProvider = indexAvailable ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + let injectedComparison = { ...injectedIndex, previousDayComparison }; + if (needInjectComparison) { + try { + injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments); + } catch (error) { + enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); + } + } // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 const weatherKitMetadata = airQuality?.metadata; @@ -399,13 +487,14 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { ...(injectedIndex?.metadata && !injectedIndex.metadata.temporarilyUnavailable ? 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.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings)), previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; - Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); return airQuality; } @@ -541,7 +630,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayAirQuality.metadata.temporarilyUnavailable) { - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayAirQuality, metadata: getMetadata(false), @@ -578,7 +667,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Storage.setItem("@iRingo.WeatherKit.Caches", { ...Caches, qweather: qweatherCache }); }; - const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache); + const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.requestCache); const { latitude, longitude } = enviroments.qWeather; const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); @@ -591,7 +680,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }); if (!yesterdayQWeather.metadata.temporarilyUnavailable) { - const airQualityFromPollutants = pollutantsToAirQuality(yesterdayQWeather); + const airQualityFromPollutants = pollutantsToAirQuality?.(yesterdayQWeather); const yesterdayAirQuality = pollutantsToAirQuality ? { ...airQualityFromPollutants, @@ -608,7 +697,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach }, }; - if (currentCategoryIndex) { + if (Number.isFinite(Number(currentCategoryIndex)) && Number(currentCategoryIndex) > 0) { const comparisonAirQuality = { ...yesterdayQWeather, metadata: getMetadata(yesterdayAirQuality.metadata.providerName, false), diff --git a/src/process/ResponseJSONV3.mjs b/src/process/ResponseJSONV3.mjs new file mode 100644 index 00000000..68045c75 --- /dev/null +++ b/src/process/ResponseJSONV3.mjs @@ -0,0 +1,248 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// v3 JSON is negotiated with Apple; never feed WK3 bytes into the WK2 schema. +import { Console } from "@nsnanocat/util"; +import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; +import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; +import database from "../function/database.mjs"; +import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; +import planWeatherProducts from "../function/planWeatherProducts.mjs"; +import { cloneWeatherProduct, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import setENV from "../function/setENV.mjs"; +import utf8Length from "../function/utf8Length.mjs"; +import { MAX_DECODED_VECTOR_ENTRIES, MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; + +export const MAX_WEATHER_JSON_CHARACTERS = 1024 * 1024; +const productNames = ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"]; +const providerMethods = new Set(["WeatherNow", "CurrentWeather", "Hourly", "ForecastHourly", "Daily", "Minutely", "WeatherAlert", "WeatherAlertWeb"]); +const object = value => value !== null && typeof value === "object" && !Array.isArray(value); +// WK3's FlatBuffer JSON and Weather's JSON reader have different shapes. +// Native macOS 27.3 logs confirm that the JSON reader expects string conditions +// and scalar precipitation amounts, even when Apple's v3 JSON contains vectors. +const percentageFields = new Set(["cloudCover", "cloudCoverLowAltPct", "cloudCoverMidAltPct", "cloudCoverHighAltPct", "humidity", "humidityMax", "humidityMin", "precipitationChance"]); +const byteFields = new Set(["uvIndex", "maxUvIndex", "uvIndexMax", "uvIndexMin", "categoryIndex"]); +const timestampFields = new Set(["asOf", "readTime", "reportedTime", "expireTime", "forecastStart", "forecastEnd", "startTime", "endTime", "date", "effectiveTime", "issuedTime", "eventOnsetTime", "eventEndTime", "moonrise", "moonset", "sunrise", "sunset", "sunriseCivil", "sunsetCivil", "sunriseNautical", "sunsetNautical", "sunriseAstronomical", "sunsetAstronomical", "solarNoon", "solarMidnight", "temperatureMaxTime", "temperatureMinTime"]); +const optionalTimestampFields = new Set(["reportedTime", "endTime", "eventOnsetTime", "eventEndTime", "moonrise", "moonset", "sunrise", "sunset", "sunriseCivil", "sunsetCivil", "sunriseNautical", "sunsetNautical", "sunriseAstronomical", "sunsetAstronomical", "solarNoon", "solarMidnight", "temperatureMaxTime", "temperatureMinTime"]); +const precipitationByTypeField = /^precipitationAmount(?:(?:Previous|Next)\d+h)?ByType$/; +const percentRange = [0, 100], byteRange = [0, 255], shortRange = [0, 65535], timeRange = [0, 0xffffffff]; +function integerRange(key) { + if (percentageFields.has(key)) return percentRange; + if (byteFields.has(key)) return byteRange; + if (key === "index" || key === "windDirection") return shortRange; + if (timestampFields.has(key)) return timeRange; +} + +// Share the existing per-product vector budget. Reject before starting provider work. +export function boundedWeatherJSON(value) { + let entries = 0, nodes = 0; + function visit(item, depth) { + if (++nodes > 100000 || depth > 64) throw new RangeError("Weather JSON exceeds traversal bounds"); + if (!item || typeof item !== "object") return; + if (Array.isArray(item)) { + entries += item.length; + if (entries > MAX_DECODED_VECTOR_ENTRIES) throw new RangeError("Weather product exceeds vector decoding budget"); + for (const child of item) visit(child, depth + 1); + } else for (const key of Object.keys(item)) visit(item[key], depth + 1); + } + visit(value, 0); + return value; +} + +// Work only on a parsed, owned product. Unknown top-level products are untouched. +// Preserve by-type totals while adding the scalar required by the native reader. +export function adaptV3ClientProduct(product) { + boundedWeatherJSON(product); + let changed = false; + function visit(value) { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { for (const item of value) visit(item); return; } + for (const key of Object.keys(value)) { + let item = value[key]; + if ((key === "conditionCode" || key === "precipitationType" || key === "condition" && item?.length > 0) && Array.isArray(item) && item.every(entry => typeof entry === "string")) { + const first = item.find(entry => typeof entry === "string" && entry); + if (first === undefined) delete value[key]; + else value[key] = first; + item = value[key]; changed = true; + } + if ((key === "precipitationAmount" || key.startsWith("snowfallAmount")) && object(item) && Number.isFinite(item.expected)) { + value[key] = item = item.expected; changed = true; + } + if (precipitationByTypeField.test(key) && Array.isArray(item)) { + const scalarKey = key.slice(0, -"ByType".length); + if (value[scalarKey] === undefined && item.every(part => object(part) && Number.isFinite(part.expected) && part.expected >= 0)) { + const amount = item.reduce((sum, part) => sum + part.expected, 0); + if (Number.isFinite(amount)) { value[scalarKey] = amount; changed = true; } + } + } + visit(item); + } + } + visit(product); + return changed; +} + +// Provider graphs are owned by their consumer. Apple input graphs stay read-only. +export function adaptV3ProviderProduct(product) { + if (!object(product)) return product; + boundedWeatherJSON(product); + function visit(value) { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + for (const key of Object.keys(value)) { + let item = value[key]; + if (item == null) { delete value[key]; continue; } + if (typeof item === "number" && !Number.isFinite(item)) { + delete value[key]; + continue; + } + // WK2 used zero as "no end"; WK3's optional timestamps must be absent. + if (item === 0 && optionalTimestampFields.has(key)) { delete value[key]; continue; } + const range = integerRange(key); + if (range && typeof item === "number") { + if (item < range[0] || item > range[1]) { delete value[key]; continue; } + value[key] = item = timestampFields.has(key) ? Math.trunc(item) : Math.round(item); + } + if (key === "metadata" && object(item)) { + for (const coordinate of ["latitude", "longitude"]) if (typeof item[coordinate] === "string") { + const number = Number(item[coordinate]); + if (item[coordinate].trim() && Number.isFinite(number)) item[coordinate] = number; + else delete item[coordinate]; + } + } + visit(item); + } + } + visit(product); + adaptV3ClientProduct(product); + return product; +} + +// Do not serialize a partly valid provider product into an otherwise valid Apple response. +// Unknown fields are left alone. Known scalar arrays and statistics must remain JSON-safe. +export function validateWeatherJSONV3(product, name) { + if (!object(product)) throw new TypeError("Missing weather JSON product"); + boundedWeatherJSON(product); + if (name === "airQuality" && product?.metadata?.temporarilyUnavailable !== true && (!Number.isInteger(product?.index) || product.index < 0 || product.index > 65535 || !Number.isInteger(product?.categoryIndex) || product.categoryIndex < 1 || product.categoryIndex > 255)) throw new TypeError("Invalid WK3 air quality index"); + function visit(value) { + const kind = typeof value; + if (kind === "bigint" || kind === "symbol" || kind === "function") throw new TypeError("Unsupported weather JSON value"); + if (kind === "string") { + // JSON.stringify escapes lone surrogates, but Apple's JSON reader rejects them. + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new TypeError("Invalid weather JSON string"); + } else if (code >= 0xdc00 && code <= 0xdfff) throw new TypeError("Invalid weather JSON string"); + } + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) { + if (item == null) throw new TypeError("Missing weather JSON array item"); + if (typeof item === "number" && (!Number.isFinite(item) || !Number.isFinite(Math.fround(item)))) throw new TypeError("Invalid weather JSON array number"); + visit(item); + } + return; + } + for (const key of Object.keys(value)) { + const item = value[key], range = integerRange(key); + if (range && item !== null && (typeof item !== "number" || !Number.isInteger(item) || item < range[0] || item > range[1])) throw new TypeError("Invalid WK3 integer field"); + if (typeof item === "number" && (!Number.isFinite(item) || !Number.isFinite(Math.fround(item)))) throw new TypeError("Invalid weather JSON number"); + if ((key === "conditionCode" || key === "precipitationType" || key === "condition" && !Array.isArray(item)) && typeof item !== "string") throw new TypeError("Invalid native weather JSON condition"); + if ((key === "precipitationAmount" || key === "snowfallAmount") && (typeof item !== "number" || !Number.isFinite(item))) throw new TypeError("Invalid native weather JSON precipitation amount"); + visit(item); + } + } + visit(product); + return product; +} + +function v3Environment(environment) { + function wrap(provider) { + return new Proxy(provider, { + get(target, key) { + const value = Reflect.get(target, key, target); + if (typeof value !== "function") return value; + if (providerMethods.has(key)) return async (...args) => adaptV3ProviderProduct(await value.apply(target, args)); + return value.bind(target); + }, + }); + } + let colorfulClouds, qWeather; + return { + country: environment.country, + requestCache: environment.requestCache, + get colorfulClouds() { return colorfulClouds ??= wrap(environment.colorfulClouds); }, + get qWeather() { return qWeather ??= wrap(environment.qWeather); }, + get waqi() { return environment.waqi; }, + }; +} + +export default async function ResponseJSONV3(request, response, injectors) { + const url = new URL(request.url); + if (url.hostname !== "weatherkit.apple.com" || !/^\/api\/v3\/weather\//.test(url.pathname) || (request.method && request.method !== "GET") || (response.status !== undefined && response.status !== 200)) return response; + const raw = response.body; + if (typeof raw !== "string" || raw.length > MAX_WEATHER_JSON_CHARACTERS || utf8Length(raw, MAX_WEATHER_BODY_BYTES) > MAX_WEATHER_BODY_BYTES) return response; + const parameters = parseWeatherKitURL(url); + const latitude = Number(parameters.latitude), longitude = Number(parameters.longitude); + if (!parameters.version || parameters.latitude === undefined || parameters.longitude === undefined || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return response; + let body; + try { body = JSON.parse(raw); } catch { return response; } + if (!object(body) || !productNames.some(name => object(body[name]) && object(body[name].metadata))) return response; + let modified = false; + for (const name of [...productNames, "forecastPeriodic", "weatherChanges"]) { + if (!object(body[name])) continue; + try { modified = adaptV3ClientProduct(body[name]) || modified; } catch { /* Keep products beyond processing bounds. */ } + } + const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); + Console.logLevel = Settings.LogLevel; + const regionParameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const requested = regionParameters.dataSets.filter(name => Settings.DataSets.includes(name)); + const selected = planWeatherProducts(requested, Settings, regionParameters.country, Boolean(body.forecastNextHour)); + const eligible = selected.filter(name => { + try { + if (body[name] !== undefined && (!object(body[name]) || !object(body[name].metadata))) return false; + boundedWeatherJSON(body[name]); + return typeof injectors[name] === "function"; + } catch { return false; } + }); + if (!eligible.length) return modified ? encodeV3Response(body, response) : response; + const cache = ProviderRequestCache.fromSettings(Settings); + const environment = v3Environment(createProviderEnvironment({ ...regionParameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }, Settings, cache)); + try { + await Promise.all(eligible.map(async name => { + const original = body[name]; + let validating = false; + try { + const input = name === "airQuality" ? cloneWeatherProduct(original) : original; + const value = name === "airQuality" ? await injectors[name](input, Settings, Caches, environment) : await injectors[name](input, Settings, environment); + if (!weatherProductsEqual(value, original)) { + validating = true; + // AQI and warning injectors can also add numeric metadata fields. + body[name] = validateWeatherJSONV3(adaptV3ProviderProduct(value), name); + modified = true; + } + } catch (error) { + body[name] = original; + cache.noteFallback(name, validating ? "invalid" : providerErrorCode(error)); + } + })); + if (!modified) return response; + return encodeV3Response(body, response); + } finally { cache.report("response-v3-json"); } +} + +function encodeV3Response(body, response) { + let encoded; + try { encoded = JSON.stringify(body); } catch { return response; } + if (encoded.length > MAX_WEATHER_JSON_CHARACTERS || utf8Length(encoded, MAX_WEATHER_BODY_BYTES) > MAX_WEATHER_BODY_BYTES) return response; + const headers = { ...response.headers }; + for (const name of Object.keys(headers)) if (["etag", "last-modified", "content-md5", "digest", "content-length", "cache-control"].includes(name.toLowerCase())) delete headers[name]; + headers["Cache-Control"] = "no-store"; + const { bodyBytes: _originalBytes, ...result } = response; + return { ...result, headers, body: encoded }; +} diff --git a/src/types.d.ts b/src/types.d.ts index b14e0be8..275820bc 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,4 +1,16 @@ +// Modified by hhhoratioxu, 2026-10-19: provider response cache settings. export interface Settings { + RequestCache?: { + /** Successful provider JSON cache lifetime in seconds, 0 disables completed-response caching; maximum 300. */ + TTL?: number | string; + ForecastTTL?: number | string; + MinutelyTTL?: number | string; + AlertsTTL?: number | string; + BudgetMs?: number | string; + CooldownSeconds?: number | string; + Diagnostics?: boolean | string; + }; + Region?: { Fallback?: "Auto" | "Locale" | "CN" | "HK" | "MO" | "TW" }; DataSets?: { /** * [数据集] 替换地区 From cb7dd3b1ee52924408646c9e79ab8399bd616522 Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:43:47 +0800 Subject: [PATCH 2/8] feat(loon): add native v3 transport and dedicated upstream release bundles --- .github/workflows/deploy.yml | 2 +- .github/workflows/loon-validation.yml | 42 ++++ arguments-builder.dev.config.ts | 7 +- arguments-builder.full.config.ts | 28 ++- arguments-builder.release.config.ts | 5 +- rollup.config.mjs | 9 + scripts/verify-loon-build.mjs | 4 + scripts/verify-loon-bundles.mjs | 340 ++++++++++++++++++++++++++ scripts/verify-loon-rewrites.mjs | 121 +++++++++ template/loon.handlebars | 16 +- 10 files changed, 562 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/loon-validation.yml create mode 100644 scripts/verify-loon-build.mjs create mode 100644 scripts/verify-loon-bundles.mjs create mode 100644 scripts/verify-loon-rewrites.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3054d5c4..fa2a2f8f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,7 +48,7 @@ jobs: token: ${{ secrets.GIST_TOKEN }} gist_id: 862db28df04be7523476d10b61b128f2 gist_description: " iRingo: 🌤 WeatherKit β" - file_path: dist/iRingo.WeatherKit.dev.plugin + file_path: dist/iRingo.WeatherKit.dev.lpx - name: Deploy Quantumult X Snippet uses: exuanbo/actions-deploy-gist@main with: diff --git a/.github/workflows/loon-validation.yml b/.github/workflows/loon-validation.yml new file mode 100644 index 00000000..b92fda20 --- /dev/null +++ b/.github/workflows/loon-validation.yml @@ -0,0 +1,42 @@ +name: Loon validation + +on: + workflow_dispatch: + pull_request: + paths: ["src/**", "tests/**", "scripts/**", "template/loon.handlebars", "arguments-builder.*.config.ts", "package*.json", "rollup.config.mjs", ".github/workflows/loon-validation.yml"] + push: + branches: ["sync/**"] + +permissions: + contents: read + packages: read + +jobs: + test-and-build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install locked dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + env: + NODE_AUTH_TOKEN: ${{ github.token }} + - name: Run regressions + run: npm test + - name: Set fixture-only build version + run: npm pkg set version=0.0.0-test + - name: Build and verify native Loon artifacts + run: npm run build:loon + - uses: actions/upload-artifact@v4 + with: + name: weatherkit-loon-validation + path: | + dist/iRingo.WeatherKit.lpx + dist/*.loon.bundle.js + if-no-files-found: error diff --git a/arguments-builder.dev.config.ts b/arguments-builder.dev.config.ts index 6f597c42..f3496064 100644 --- a/arguments-builder.dev.config.ts +++ b/arguments-builder.dev.config.ts @@ -1,5 +1,6 @@ +// Modified by hhhoratioxu, 2026-10-19: expose provider response cache lifetime. import { defineConfig } from "@iringo/arguments-builder"; -import { airQualityFull, api, calculateFull, dataSetsFull, logLevel, nextHourFull, storage, weatherAlerts, weatherFull } from "./arguments-builder.full.config"; +import { requestCache, airQualityFull, api, calculateFull, dataSetsFull, logLevel, nextHourFull, storage, weatherAlerts, weatherFull } from "./arguments-builder.full.config"; export default defineConfig({ output: { @@ -8,7 +9,7 @@ export default defineConfig({ template: "./template/surge.dev.handlebars", }, loon: { - path: "./dist/iRingo.WeatherKit.dev.plugin", + path: "./dist/iRingo.WeatherKit.dev.lpx", template: "./template/loon.dev.handlebars", }, customItems: [ @@ -26,5 +27,5 @@ export default defineConfig({ scope: "@iRingo.WeatherKit.Settings", }, }, - args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel], + args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel, ...requestCache], }); diff --git a/arguments-builder.full.config.ts b/arguments-builder.full.config.ts index 0b0ffb98..3962f6db 100644 --- a/arguments-builder.full.config.ts +++ b/arguments-builder.full.config.ts @@ -1,3 +1,4 @@ +// Modified by hhhoratioxu, 2026-10-19: expose bounded provider cache lifetime. import { defineConfig } from "@iringo/arguments-builder"; type Arg = { @@ -49,6 +50,31 @@ export const output = { }, }; +export const requestCache: Arg[] = [ + { key: "RequestCache.TTL", name: "[请求缓存] 实况与空气质量(秒)", defaultValue: "60", type: "string", description: "0 禁用所有已完成响应缓存,仍合并在途请求。最大 300 秒。" }, + { key: "RequestCache.ForecastTTL", name: "[请求缓存] 小时与每日预报(秒)", defaultValue: "180", type: "string", description: "变化较慢的预报缓存,最大 300 秒。" }, + { key: "RequestCache.MinutelyTTL", name: "[请求缓存] 分钟降雨(秒)", defaultValue: "15", type: "string", description: "分钟降雨缓存,最大 30 秒。" }, + { key: "RequestCache.AlertsTTL", name: "[请求缓存] 天气预警(秒)", defaultValue: "15", type: "string", description: "JSON 与网页预警缓存,最大 30 秒;不使用过期预警。" }, + { key: "RequestCache.BudgetMs", name: "[弱网] 网络发起预算(毫秒)", defaultValue: "8000", type: "string", description: "1000–15000 毫秒。队列与后续请求共享预算;已发出的请求仍等待原生回调,不取消请求或提前完成。" }, + { key: "RequestCache.CooldownSeconds", name: "[弱网] 失败冷却(秒)", defaultValue: "30", type: "string", description: "相同接口连续失败两次后冷却;鉴权错误与限流立即冷却。最大 120 秒;0 禁用。" }, + { key: "RequestCache.Diagnostics", name: "[诊断] 输出运行统计", defaultValue: false, type: "boolean", description: "每次执行汇总缓存、请求、耗时与回退原因,不包含令牌、原始地址或坐标。" }, + { + key: "Region.Fallback", + name: "[地区] 缺少 country 时", + defaultValue: "Auto", + type: "string", + options: [ + { key: "Auto", label: "自动保守判断" }, + { key: "Locale", label: "沿用语言区域" }, + { key: "CN", label: "中国大陆" }, + { key: "HK", label: "香港" }, + { key: "MO", label: "澳门" }, + { key: "TW", label: "台湾" }, + ], + description: "明确的 country 参数始终优先;自动模式使用同坐标确认记录与离线多边形,边界不确定时沿用语言区域。", + }, +]; + export const dataSets: Arg[] = [ { key: "DataSets", @@ -381,5 +407,5 @@ export const logLevel: Arg[] = [ export default defineConfig({ output, - args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel], + args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel, ...requestCache], }); diff --git a/arguments-builder.release.config.ts b/arguments-builder.release.config.ts index ff21cd7e..814fd035 100644 --- a/arguments-builder.release.config.ts +++ b/arguments-builder.release.config.ts @@ -1,7 +1,8 @@ +// Modified by hhhoratioxu, 2026-10-19: expose provider response cache lifetime. import { defineConfig } from "@iringo/arguments-builder"; -import { airQuality, api, calculate, dataSets, logLevel, nextHour, output, storage, weather, weatherAlerts } from "./arguments-builder.full.config"; +import { requestCache, airQuality, api, calculate, dataSets, logLevel, nextHour, output, storage, weather, weatherAlerts } from "./arguments-builder.full.config"; export default defineConfig({ output: output, - args: [...dataSets, ...weather, ...weatherAlerts, ...nextHour, ...airQuality, ...calculate, ...api, ...storage, ...logLevel], + args: [...dataSets, ...weather, ...weatherAlerts, ...nextHour, ...airQuality, ...calculate, ...api, ...storage, ...logLevel, ...requestCache], }); diff --git a/rollup.config.mjs b/rollup.config.mjs index e634ff32..1f7c93cb 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -45,4 +45,13 @@ export default [ terser(), ], }, + ...["request", "response", "scale"].map(name => ({ + input: `./src/loon/${name}.js`, + output: { file: `./dist/${name}.loon.bundle.js`, format: "es" }, + plugins: [ + alias({ entries: [{ find: "@nsringo/weatherkit", replacement: path.resolve(import.meta.dirname, "node_modules/@nsringo/weatherkit/dist/index.js") }] }), + nodeResolve(), + terser(), + ], + })), ]; diff --git a/scripts/verify-loon-build.mjs b/scripts/verify-loon-build.mjs new file mode 100644 index 00000000..df262d82 --- /dev/null +++ b/scripts/verify-loon-build.mjs @@ -0,0 +1,4 @@ +import path from "node:path"; +import verifyLoonBundles from "./verify-loon-bundles.mjs"; +const result = await verifyLoonBundles(path.resolve(import.meta.dirname, "..")); +console.log(JSON.stringify(result)); diff --git a/scripts/verify-loon-bundles.mjs b/scripts/verify-loon-bundles.mjs new file mode 100644 index 00000000..2143c6dc --- /dev/null +++ b/scripts/verify-loon-bundles.mjs @@ -0,0 +1,340 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import vm from "node:vm"; +import verifyLoonRewrites from "./verify-loon-rewrites.mjs"; + +export default async function verifyLoonBundles(root) { + const directory = path.join(root, "dist"); + const requestCode = await readFile(path.join(directory, "request.loon.bundle.js"), "utf8"), + responseCode = await readFile(path.join(directory, "response.loon.bundle.js"), "utf8"), + scaleCode = await readFile(path.join(directory, "scale.loon.bundle.js"), "utf8"); + const plugin = await readFile(path.join(directory, "iRingo.WeatherKit.lpx"), "utf8"); + const nativeRewrites = verifyLoonRewrites(plugin); + const storage = new Map(), + scenarios = []; + async function run(code, request, response, http, argument = {}, expectedErrors = false) { + let resolve, + reject, + doneCalls = 0, + nativeRequests = 0, + active = 0; + const complete = new Promise((a, b) => { + resolve = a; + reject = b; + }); + const timer = setTimeout(() => reject(new Error("Built Loon script did not complete")), 2000); + const messages = [], + errors = []; + const context = vm.createContext({ + URL, + URLSearchParams, + TextDecoder, + // Binary fixtures and scripts share the host typed-array realm in Loon. + // Preserve that relationship when exercising opaque source slots in a VM. + Uint8Array, + $loon: "simulated Loon 3.5.1", + $script: { startTime: new Date() }, + $argument: { Storage: "Argument", LogLevel: "OFF", ...argument }, + $request: request, + $response: response, + $persistentStore: { + read: key => storage.get(key), + write: (value, key) => { + storage.set(key, value); + return true; + }, + }, + $httpClient: { + get: (outgoing, callback) => { + nativeRequests++; + active++; + Promise.resolve() + .then(() => + http(outgoing, (...args) => { + active--; + callback(...args); + }), + ) + .catch(reject); + }, + }, + console: { log: value => messages.push(String(value)), info() {}, warn() {}, error: value => errors.push(String(value)) }, + $done: value => { + doneCalls++; + assert.equal(active, 0, "$done must wait for every native callback"); + resolve(value); + }, + }); + try { + vm.runInContext(code, context, { timeout: 2000 }); + const output = await complete; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(doneCalls, 1); + assert.equal(context.process, undefined); + assert.equal(context.require, undefined); + if (code === responseCode) assert.equal(typeof context.TextEncoder, "function", "The response bundle supplies its bounded encoder when the host omits it"); + if (!expectedErrors) assert.equal(errors.length, 0, errors.join("\n")); + return { output, nativeRequests, messages, errors }; + } finally { + clearTimeout(timer); + } + } + const noNetwork = () => { + throw new Error("Unexpected native network request"); + }; + const scale = await run(scaleCode, { url: "https://weatherkit.apple.com/api/v1/airQualityScale/zh-Hans-CN/HK.AQHI.2604", method: "GET", headers: {} }, undefined, noNetwork); + assert.equal(scale.output.response.status, 200); + assert.ok(JSON.parse(scale.output.response.body)); + scenarios.push("custom scale uses a standalone bundle without storage or provider requests"); + const unknownScale = await run(scaleCode, { url: "https://weatherkit.apple.com/api/v1/airQualityScale/unknown-HK/HK.AQHI.2604?v=1", method: "GET", headers: { "If-None-Match": "fixture-etag" } }, undefined, noNetwork); + assert.ok(unknownScale.output.url.endsWith("/HK.AQHI?v=1")); + assert.equal(unknownScale.output.headers["If-None-Match"], "fixture-etag"); + scenarios.push("unknown scale language falls through with conditional headers intact"); + const availability = await run(responseCode, { url: "https://weatherkit.apple.com/api/v1/availability/en-US/31.2/121.5", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(["currentWeather", "weatherMaps"]) }, noNetwork); + assert.ok(JSON.parse(availability.output.body).includes("weatherMaps")); + assert.ok(JSON.parse(availability.output.body).includes("forecastNextHour")); + scenarios.push("availability preserves Apple and appends plugin requirements"); + const plain = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/en-US/31.2/121.5", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "text/plain" }, body: "original" }, noNetwork); + assert.equal(plain.output.body, "original"); + scenarios.push("unhandled content passthrough"); + const v3Request = { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=currentWeather", method: "GET", headers: {} }; + const v3Original = ` ${JSON.stringify({ currentWeather: { metadata: { latitude: 31.2, longitude: 121.5, sourceType: "MODELED", temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLOUDY", futureField: [1, 2] }, unknownProduct: { retain: true } })}\n`; + const v3Response = { status: 200, headers: { "Content-Type": "application/json", ETag: "fixture", "Content-Length": "1" }, body: v3Original }; + const v3Replaced = await run(responseCode, v3Request, v3Response, (_, callback) => { + setImmediate(() => callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-04T00:00:00Z", now: { temp: "23", icon: "100", text: "晴", feelsLike: "24", cloud: "20", humidity: "50", precip: "0", pressure: "1000", dew: "10", vis: "10", wind360: "90", windSpeed: "2" } }))); + }, { DataSets: "currentWeather", "Weather.Provider": "QWeather" }); + const v3Body = JSON.parse(v3Replaced.output.body); + assert.equal(v3Replaced.nativeRequests, 1); + assert.equal(v3Body.currentWeather.temperature, 23); + assert.equal(v3Body.currentWeather.conditionCode, "CLEAR"); + assert.equal(typeof v3Body.currentWeather.metadata.latitude, "number"); + assert.deepEqual(v3Body.currentWeather.futureField, [1, 2]); + assert.deepEqual(v3Body.unknownProduct, { retain: true }); + assert.equal(v3Replaced.output.headers["Cache-Control"], "no-store"); + assert.equal(v3Replaced.output.headers.ETag, undefined); + assert.equal(v3Response.body, v3Original); + scenarios.push("v3 JSON replaces weather with WK3 types and preserves unknown fields after native callbacks finish"); + const nativeJSON = { currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: ["CLOUDY"] }, forecastDaily: { metadata: { temporarilyUnavailable: false }, days: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 3, minimum: 1, maximum: 4, precipitationType: ["RAIN"] }] }] }, forecastPeriodic: { metadata: {}, forecasts: [{ periods: [{ conditionCode: ["CLEAR"], snowfallAmount: { expected: 0, minimum: 0, maximum: 0 }, precipitationAmountByType: [] }] }] } }; + const nativeCompatible = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeJSON) }, noNetwork, { DataSets: "currentWeather", "Weather.Provider": "WeatherKit" }); + const nativeBody = JSON.parse(nativeCompatible.output.body); + assert.equal(nativeBody.currentWeather.conditionCode, "CLOUDY"); + assert.equal(nativeBody.forecastDaily.days[0].precipitationAmount, 3); + assert.equal(nativeBody.forecastPeriodic.forecasts[0].periods[0].conditionCode, "CLEAR"); + assert.equal(nativeBody.forecastPeriodic.forecasts[0].periods[0].snowfallAmount, 0); + assert.equal(nativeCompatible.nativeRequests, 0); + scenarios.push("Apple v3 JSON is adapted to the native Weather reader, including unselected periodic forecasts"); + const appleCO = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "QWeather" }, scale: "HJ6332012", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 0.7]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })) } }; + const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US" }); + assert.equal(JSON.parse(appleCOResult.output.body).airQuality.pollutants.find(p => p.pollutantType === "CO").amount, 700); + assert.equal(appleCOResult.nativeRequests, 0); + scenarios.push("native Apple QWeather CO is repaired at ingress with no duplicate provider lookup"); + for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "None"]) { + const pollutants = [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); + const body = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants } }; + const computed = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm }); + const actual = JSON.parse(computed.output.body).airQuality; + assert.equal(computed.nativeRequests, 0); + assert.equal(actual.pollutants.length, 6); + assert.ok(Number.isInteger(actual.index) && actual.index >= 0); + assert.ok(Number.isInteger(actual.categoryIndex) && actual.categoryIndex >= 1); + if (algorithm === "None") assert.equal(actual.index, 42); + else assert.match(actual.metadata.providerName, /iRingo/); + scenarios.push(`v3 ${algorithm} retains all six measurements with no provider request`); + } + storage.clear(); + const v3Failed = await run(responseCode, v3Request, v3Response, (_, callback) => setImmediate(() => callback("timeout", {}, "")), { DataSets: "currentWeather", "Weather.Provider": "QWeather" }, true); + assert.equal(v3Failed.output.body, v3Original); + assert.equal(v3Failed.output.headers.ETag, "fixture"); + scenarios.push("v3 provider failure preserves exact Apple JSON, whitespace and validators"); + const v3Opaque = { ...v3Response, headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" }, body: new Uint8Array([1, 2, 3, 4]) }; + const v3Kept = await run(responseCode, v3Request, v3Opaque, noNetwork); + assert.strictEqual(v3Kept.output.body, v3Opaque.body); + scenarios.push("WK3 binary stays opaque if JSON negotiation is not honoured"); + storage.clear(); + const alertRequest = { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?lang=en-US&ids=fixture-101190110", method: "GET", headers: { "User-Agent": "fixture-agent" } }; + const html = '

Fixture

Fixture warning

Issue date: 2026-10-19T00:00:00Z

'; + const first = await run(requestCode, alertRequest, undefined, (_, callback) => callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html), { "RequestCache.Diagnostics": true }); + assert.equal(first.nativeRequests, 1); + assert.equal(first.output.response.status, 200); + assert.ok(JSON.parse(first.output.response.body).length > 0); + scenarios.push("web warning parsing without TextEncoder"); + const second = await run(requestCode, alertRequest, undefined, noNetwork, { "RequestCache.Diagnostics": true }); + assert.equal(second.nativeRequests, 0); + assert.equal(second.output.response.body, first.output.response.body); + scenarios.push("parsed warning cache survives a new Loon execution"); + storage.clear(); + const failed = await run(requestCode, alertRequest, undefined, (_, callback) => callback("timeout fixture-secret", {}, ""), { "RequestCache.Diagnostics": true }, true); + assert.equal(failed.output.response, undefined); + assert.equal(failed.output.url, alertRequest.url); + const diagnostic = failed.messages.filter(message => message.startsWith("[WeatherKit]")); + assert.equal(diagnostic.length, 1); + assert.doesNotMatch(diagnostic[0], /fixture-secret|101190110|weatherkit\.apple\.com/); + scenarios.push("failed warning request passes through with safe diagnostics"); + const { default: WeatherKit2 } = await import(new URL("../src/class/WeatherKit2.mjs", import.meta.url)); + const raw = WeatherKit2.encode(undefined, { + currentWeather: { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + temperature: 27, + precipitationAmountNext1hByType: [], + precipitationAmountNext24hByType: [], + precipitationAmountNext6hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious24hByType: [], + precipitationAmountPrevious6hByType: [], + }, + forecastHourly: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, hours: [] }, + }); + const recovered = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }, + (_, callback) => callback("timeout", {}, ""), + { LogLevel: "ERROR", DataSets: "currentWeather,forecastHourly", "Weather.Provider": "QWeather", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + true, + ); + assert.equal(recovered.nativeRequests, 2, `Region fix and both provider paths must run: ${[...recovered.errors, ...recovered.messages.filter(line => /Error|Failed|失败|Unreplaced|country:|FORMAT:/.test(line))].join("\n")}`); + const { ByteBuffer } = await import("flatbuffers"); + const decoded = WeatherKit2.decode(new ByteBuffer(recovered.output.body), ["currentWeather"]); + assert.equal(decoded.currentWeather.temperature, 27); + const recoverySummary = JSON.parse(recovered.messages.find(line => line.startsWith("[WeatherKit]")).slice("[WeatherKit] ".length)); + assert.equal(recoverySummary.fallbacks, 2, "An undefined provider result must still count as a fallback"); + scenarios.push("binary weather response retains Apple values and completes after native failures"); + storage.clear(); + const empty = WeatherKit2.encode(undefined, {}); + const nativeMinute = WeatherKit2.encode(undefined, { forecastNextHour: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, condition: [], summary: [], minutes: [] } }); + const keptMinute = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: nativeMinute }, noNetwork, { + DataSets: "forecastNextHour", + "NextHour.Provider": "ColorfulClouds", + }); + assert.deepEqual(Array.from(keptMinute.output.body), Array.from(nativeMinute)); + assert.equal(keptMinute.messages.length, 0, "LogLevel OFF suppresses startup, provider and completion logs"); + scenarios.push("existing Apple minute table is preserved without provider work or routine logs"); + const disabled = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/en-US/31.2/121.5?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }, noNetwork, { + DataSets: "currentWeather,forecastHourly", + "Weather.Provider": "WeatherKit", + }); + assert.deepEqual(Array.from(disabled.output.body), Array.from(raw)); + scenarios.push("Apple-only provider preserves original binary weather without a decoder round trip"); + const large = new Uint8Array(4 * 1024 * 1024 + 1); + const bounded = await run(responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/en-US/31.2/121.5?country=CN&dataSets=forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: large }, noNetwork); + assert.strictEqual(bounded.output.body, large); + scenarios.push("oversized binary input retains the original view without decoding or provider work"); + const hourlyRaw = WeatherKit2.encode(undefined, { forecastHourly: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, hours: [0, 1].map(i => ({ forecastStart: 1792368000 + i * 3600, temperature: 20 + i, precipitationAmountByType: [] })) } }); + const replacedHourly = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=forecastHourly", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: hourlyRaw }, + (_, callback) => + callback( + null, + { status: 200, headers: {} }, + JSON.stringify({ code: "200", updateTime: "2026-10-19T00:00:00Z", hourly: [{ fxTime: new Date(1792368000000).toISOString(), temp: "30", text: "晴", cloud: "20", humidity: "50", precip: "0", pop: "10", pressure: "1000", dew: "10", wind360: "90", windSpeed: "2" }] }), + ), + { DataSets: "forecastHourly", "Weather.Provider": "QWeather" }, + ); + const hours = WeatherKit2.decode(new ByteBuffer(replacedHourly.output.body), ["forecastHourly"]).forecastHourly.hours; + assert.equal(replacedHourly.nativeRequests, 1); + assert.equal(hours[0].temperature, 30); + assert.equal(hours[1].temperature, 21); + assert.equal(WeatherKit2.decode(new ByteBuffer(hourlyRaw), ["forecastHourly"]).forecastHourly.hours[0].temperature, 20); + scenarios.push("partial hourly replacement changes only a matching row and leaves source binary intact"); + const backup = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=forecastNextHour", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: empty }, + (request, callback) => { + if (request.url.includes("caiyunapp.com")) callback("timeout", {}, ""); + else { + assert.equal(request.headers["X-QW-Api-Key"], "fixture-account"); + callback(null, { status: 200, headers: { "Content-Type": "application/json" } }, JSON.stringify({ code: "200", updateTime: "2026-10-19T08:00:00+08:00", summary: "Fixture rain", minutely: [{ fxTime: "2026-10-19T08:05:00+08:00", precip: "0.1" }] })); + } + }, + { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "fixture-account", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + true, + ); + assert.equal(backup.nativeRequests, 2); + const backedUp = WeatherKit2.decode(new ByteBuffer(backup.output.body), ["forecastNextHour"]); + assert.ok(backedUp.forecastNextHour, [...backup.errors, ...backup.messages].join("\n")); + assert.equal(backedUp.forecastNextHour.metadata.temporarilyUnavailable, false); + scenarios.push("missing minute data uses one configured backup after primary failure"); + for (const [status, temperature, reason] of [ + [502, "99", "http"], + [200, "not-a-number", "invalid"], + ]) { + storage.clear(); + const guarded = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=currentWeather", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }, + (_, callback) => callback(null, { status, headers: { "Content-Type": "application/json" } }, JSON.stringify({ code: "200", now: { temp: temperature } })), + { DataSets: "currentWeather", "Weather.Provider": "QWeather", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + true, + ); + assert.equal(guarded.nativeRequests, 1); + assert.deepEqual(Array.from(guarded.output.body), Array.from(raw)); + const summary = JSON.parse(guarded.messages.find(line => line.startsWith("[WeatherKit]")).slice("[WeatherKit] ".length)); + assert.equal(summary.fallbacks, 1); + assert.equal(summary.failureReasons[reason], 1); + scenarios.push(`HTTP ${status} provider body with ${reason} failure cannot overwrite Apple weather`); + } + storage.clear(); + const mixedRaw = WeatherKit2.encode(undefined, { + currentWeather: WeatherKit2.decode(new ByteBuffer(raw), ["currentWeather"]).currentWeather, + weatherAlerts: { metadata: { providerName: "National Early Warning Center", temporarilyUnavailable: false }, detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=32.390,119.499", alerts: [] }, + }); + const mixed = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=weatherAlerts,currentWeather", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: mixedRaw }, + (_, callback) => + callback( + null, + { status: 200, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + status: "ok", + location: [32.39, 119.499], + result: { server_time: 1792368000, alert: { status: "error" }, realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.2, skycon: "CLEAR_DAY", humidity: 0.8, precipitation: { local: { intensity: 0 } }, pressure: 100000, visibility: 10, wind: { direction: 90, speed: 1 } } }, + }), + ), + { DataSets: "weatherAlerts,currentWeather", "Weather.Provider": "ColorfulClouds", "WeatherAlerts.Provider": "ColorfulClouds", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + true, + ); + assert.equal(mixed.nativeRequests, 1); + const mixedDecoded = WeatherKit2.decode(new ByteBuffer(mixed.output.body), ["currentWeather", "weatherAlerts"]); + assert.equal(mixedDecoded.currentWeather.temperature, 23); + assert.equal(mixedDecoded.weatherAlerts.metadata.providerName, "National Early Warning Center"); + const mixedSummary = JSON.parse(mixed.messages.find(line => line.startsWith("[WeatherKit]")).slice("[WeatherKit] ".length)); + assert.equal(mixedSummary.fallbacks, 1); + scenarios.push("one shared Caiyun callback can replace healthy weather while preserving failed Apple alerts"); + storage.clear(); + const { futureHourlyFixture, futureHourlyText, readFutureHourly, weatherFixture, weatherNow } = await import("../tests/fixtures/processingMemoryFixture.mjs"); + const futureRaw = futureHourlyFixture(); + const partial = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.39/119.499?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: futureRaw }, + (request, callback) => callback(null, { status: request.url.includes("/weather/now") ? 200 : 503, headers: {} }, request.url.includes("/weather/now") ? JSON.stringify(weatherNow) : "{}"), + { DataSets: "currentWeather,forecastHourly", "Weather.Provider": "QWeather" }, + true, + ); + assert.equal(partial.nativeRequests, 2); + assert.equal(WeatherKit2.decode(new ByteBuffer(partial.output.body), ["currentWeather"]).currentWeather.temperature, 23); + assert.equal(readFutureHourly(partial.output.body), futureHourlyText); + scenarios.push("partial replacement preserves future fields inside an unchanged selected hourly table"); + storage.clear(); + const alertsRaw = weatherFixture(0, 48); + const failedAlerts = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.39/119.499?country=CN&dataSets=weatherAlerts", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: alertsRaw }, + (_, callback) => callback(null, { status: 503, headers: {} }, "{}"), + { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "ColorfulClouds" }, + true, + ); + assert.equal(failedAlerts.nativeRequests, 1); + assert.strictEqual(failedAlerts.output.body, alertsRaw); + scenarios.push("failed alert replacement retains the complete original binary response"); + return { pass: scenarios.length, fail: 0, scenarios, nativeRewrites, withoutNodeWebCryptoHostTextEncoder: true, providedWebAPIs: ["URL", "URLSearchParams", "TextDecoder"], doneExactlyOnce: true, liveWeatherRequests: false, deviceVPNValidation: false }; +} diff --git a/scripts/verify-loon-rewrites.mjs b/scripts/verify-loon-rewrites.mjs new file mode 100644 index 00000000..d697e523 --- /dev/null +++ b/scripts/verify-loon-rewrites.mjs @@ -0,0 +1,121 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Checks the emitted expressions and jq program. This is not a Loon parser or device test. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +export function readLoonRules(plugin) { + const lines = plugin.split("\n"); + const availability = lines.find(line => line.startsWith("response if ") && line.includes("response.json.jq(")); + const scale = lines.find(line => line.startsWith("request if ") && line.includes("url.replace(")); + const jsonTransport = lines.find(line => line.startsWith("request if ") && line.includes('request.header.set("Accept", "application/json")')); + assert.ok(jsonTransport, "v3 must negotiate Apple's JSON representation without a request script"); + assert.ok(jsonTransport.startsWith('request if ${request.method} == "GET" && ')); + const transportGuard = jsonTransport.match(/\$\{url\} ~= \/((?:\\.|[^/])*)\/ then /); + assert.ok(transportGuard); + const transportRegex = new RegExp(transportGuard[1]); + const userAgentGuard = /\$\{request\.header\['User-Agent'\]\} ~= \/((?:\\.|[^/])*)\//; + const transportAgent = jsonTransport.match(userAgentGuard); + assert.ok(transportAgent, "Background clients must retain Apple's original transport"); + const appAgentRegex = new RegExp(transportAgent[1]); + assert.ok(availability && scale, "Both native Rewrite actions must be present"); + const jq = JSON.parse(availability.slice(availability.indexOf("response.json.jq(") + "response.json.jq(".length, -1)); + const expression = scale.match(/\$\{url\} ~= \/((?:\\.|[^/])*)\/([i]*) as scale then url\.replace\("\$\{scale\.1\}"\)$/); + assert.ok(expression, "Use the documented named capture and a unique required URL guard"); + assert.ok(scale.startsWith(`request if (\${request.method} == "GET" || \${request.method} == "HEAD" || \${request.method} == "OPTIONS") && `)); + const regex = new RegExp(expression[1], expression[2]); + const scripts = lines + .filter(line => /^http-(request|response) /.test(line)) + .map(line => { + const [stage, pattern] = line.split(" "); + return { stage, regex: new RegExp(pattern), line }; + }); + const v3Script = lines.find(line => line.startsWith("response if ") && line.includes(" then script(") && line.includes("api.v3.weather.json.response")); + assert.ok(v3Script); + const v3Guard = v3Script.match(/\$\{url\} ~= \/((?:\\.|[^/])*)\//); + const v3Agent = v3Script.match(userAgentGuard); + const v3Format = v3Script.match(/\$\{response\.header\['Content-Type'\]\} ~= \/((?:\\.|[^/])*)\/i/); + assert.ok(v3Guard && v3Agent && v3Format); + assert.ok(v3Script.includes('${request.method} == "GET"') && v3Script.includes("${response.status} == 200")); + const v3AgentRegex = new RegExp(v3Agent[1]), v3FormatRegex = new RegExp(v3Format[1], "i"); + scripts.push({ stage: "http-response", regex: new RegExp(v3Guard[1]), line: v3Script, accepts: ({ method = "GET", status = 200, contentType, userAgent } = {}) => method === "GET" && status === 200 && v3FormatRegex.test(contentType ?? "") && v3AgentRegex.test(userAgent ?? "") }); + const declared = lines.filter(line => /^[\w.]+ = (input|select|switch),/.test(line)).map(line => line.split(" = ")[0]); + // Template declarations are generated later; actual imports must bind every parameter. + if (declared.length) assert.deepEqual([...v3Script.matchAll(/\$\{([\w.]+)\}/g)].map(match => match[1]).filter(name => !["url", "request.method", "response.status"].includes(name)), declared); + return { + jq, + scripts, + negotiatesJSON(url, method = "GET", userAgent = "") { return method === "GET" && transportRegex.test(url) && appAgentRegex.test(userAgent); }, + normalizeScale(url, method = "GET") { + return ["GET", "HEAD", "OPTIONS"].includes(method) ? url.replace(regex, (_match, prefix) => prefix) : url; + }, + mergeAvailability(value) { + return JSON.parse(execFileSync("jq", ["-c", jq], { input: JSON.stringify(value), encoding: "utf8", maxBuffer: 1024 * 1024 })); + }, + matchingScripts(url, stage = "http-request", context) { + return scripts.filter(script => script.stage === stage && script.regex.test(url) && (!script.accepts || script.accepts(context))); + }, + }; +} + +export default function verifyLoonRewrites(plugin) { + const rules = readLoonRules(plugin), + scenarios = []; + assert.equal(rules.scripts.length, 5); + const v3URL = "https://weatherkit.apple.com/api/v3/weather/en-GB/53.38/-1.47?dataSets=airQuality"; + const appAgent = "WeatherKit_Weather_iOS_Version 27.3 (Build fixture)", context = { userAgent: appAgent, contentType: "application/json" }; + assert.ok(rules.negotiatesJSON(v3URL, "GET", appAgent)); + const macAgent = "WeatherKit_Weather_macOS_Version 27.3 (Build fixture)"; + assert.ok(rules.negotiatesJSON(v3URL, "GET", macAgent)); + assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, userAgent: macAgent }).length, 1); + scenarios.push("iOS and macOS main Weather app fixtures select the same guarded v3 transport"); + for (const [url, method] of [[v3URL, "POST"], [v3URL.replace("https:", "http:"), "GET"], [v3URL.replace("weatherkit.apple.com", "example.com"), "GET"], [v3URL.replace("/v3/", "/v2/"), "GET"]]) assert.equal(rules.negotiatesJSON(url, method, appAgent), false); + scenarios.push("native v3 JSON negotiation changes only HTTPS Apple weather GET requests"); + const [v3] = rules.matchingScripts(v3URL, "http-response", context); + assert.ok(v3?.line.includes("requires_body=true")); + assert.ok(v3.line.includes("${DataSets}") && v3.line.includes("${Region.Fallback}")); + assert.doesNotMatch(v3.line, /binary_body_mode=true/); + assert.equal(rules.matchingScripts(v3URL, "http-response", context).length, 1); + assert.match(rules.matchingScripts(v3URL.replace("/v3/", "/v2/"), "http-response")[0].line, /binary-body-mode=1/); + scenarios.push("v3 text and v2 binary weather select exactly one body script each"); + for (const userAgent of ["", "WeatherKit_destinationd_iOS_Version 27.3", "WeatherKit_nanoweatherd_watchOS_Version 27.3", "WeatherKit_WidgetExtension_iOS_Version 27.3"]) { + assert.equal(rules.negotiatesJSON(v3URL, "GET", userAgent), false); + assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, userAgent }).length, 0); + } + for (const contentType of [undefined, "application/vnd.apple.flatbuffer;messageType=WK3.Weather", "text/html"]) assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, contentType }).length, 0); + assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, status: 503 }).length, 0); + scenarios.push("background, watch, non-JSON and failed v3 responses start no body script"); + const base = "https://weatherkit.apple.com/api/v1/airQualityScale/zh-Hans-CN/"; + for (const [suffix, expected] of [ + ["EU.EAQI.2604", "EU.EAQI"], + ["US.AQI.12?lang=en-US&v=1#s", "US.AQI?lang=en-US&v=1#s"], + ["HK.AQHI.8", "HK.AQHI"], + ["CN.AQHI.8", "CN.AQHI"], + ["EU.EAQI", "EU.EAQI"], + ["EU.EAQI.preview", "EU.EAQI.preview"], + ]) { + assert.equal(rules.normalizeScale(base + suffix), base + expected); + scenarios.push(`native scale URL: ${suffix}`); + } + assert.equal(rules.normalizeScale(`${base}EU.EAQI.12`, "POST"), `${base}EU.EAQI.12`); + assert.equal(rules.normalizeScale(`${base.replace("weatherkit.apple.com", "example.com")}EU.EAQI.12`), `${base.replace("weatherkit.apple.com", "example.com")}EU.EAQI.12`); + scenarios.push("URL normalization preserves POST and unrelated hosts"); + for (const suffix of ["EU.EAQI.2604", "US.AQI.12", "HK.AQHI.8", "CN.AQHI.8"]) { + const url = rules.normalizeScale(base + suffix); + assert.equal(rules.matchingScripts(url).length, /^(HK|CN)/.test(suffix) ? 1 : 0); + } + assert.equal(rules.matchingScripts("https://weatherkit.apple.com/api/v1/availability/en-US/31.2/121.5", "http-response").length, 0); + scenarios.push("availability and ordinary scales start no JavaScript; custom scales use the small entry"); + for (const prefix of ["", "lang=zh-CN&"]) { + for (const ids of ["fixture-101190110", "31.2,121.5", "-31.2,-121.5"]) assert.equal(rules.matchingScripts(`https://weatherkit.apple.com/api/v1/weatherAlerts?${prefix}ids=${ids}`).length, 1); + } + scenarios.push("alert routes accept first or subsequent ids parameters"); + const merged = rules.mergeAvailability(["currentWeather", "weatherMaps", "currentWeather"]); + assert.deepEqual(merged.slice(0, 2), ["currentWeather", "weatherMaps"]); + assert.equal(new Set(merged).size, merged.length); + assert.ok(merged.includes("forecastNextHour") && merged.includes("weatherAlerts")); + scenarios.push("native jq keeps Apple ordering and unknown capabilities without duplicates"); + for (const value of [{ error: "fixture" }, ["currentWeather", { future: true }], null, 7]) assert.deepEqual(rules.mergeAvailability(value), value); + assert.ok(rules.mergeAvailability([]).includes("airQuality")); + scenarios.push("native jq preserves unknown shapes and handles an empty capability list"); + return { pass: scenarios.length, fail: 0, scenarios, validation: "emitted expressions, JavaScript regex fixtures and system jq; not a Loon parser", deviceValidation: false }; +} diff --git a/template/loon.handlebars b/template/loon.handlebars index 3fa77afd..e761904b 100644 --- a/template/loon.handlebars +++ b/template/loon.handlebars @@ -9,6 +9,7 @@ #!date = {{now "yyyy-MM-dd HH:mm:ss"}} #!version = {{@package 'version'}} #!system_version = {{ @package 'systemVersion'}} +#!loon_version = 3.5.1(983) [Argument] {{{arguments}}} @@ -19,12 +20,17 @@ DOMAIN-SUFFIX,tthr.apple.com,REJECT-DROP DOMAIN,tether.edge.apple,REJECT-DROP AND,((OR,((IP-ASN,714,no-resolve),(IP-ASN,6185,no-resolve))),(PROTOCOL,QUIC)),REJECT-DROP +[Rewrite] +request if ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// then request.header.set("Accept", "application/json") +response if ${url} ~= /^https?:\/\/weatherkit\.apple\.com\/api\/v1\/availability\// then response.json.jq("if type == \"array\" and all(.[]; type == \"string\") then reduce (. + [\"airQuality\",\"currentWeather\",\"forecastDaily\",\"forecastHourly\",\"forecastPeriodic\",\"historicalComparisons\",\"weatherChanges\",\"forecastNextHour\",\"weatherAlerts\",\"weatherAlertNotifications\",\"news\"])[] as $item ([]; if index($item) == null then . + [$item] else . end) else . end") +request if (${request.method} == "GET" || ${request.method} == "HEAD" || ${request.method} == "OPTIONS") && ${url} ~= /^(https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/[A-Z0-9]+(?:-[A-Z0-9]+)*\/[^\/?#]+)\.\d+(?=[?#]|$)/i as scale then url.replace("${scale.1}") + [Script] -http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/availability\/ requires-body=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.bundle.js, tag=🌤 WeatherKit.api.v1.availability.response, argument={{{scriptParams}}} -http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/ script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.bundle.js, tag=🌤 WeatherKit.api.v1.airQualityScale.request, argument={{{scriptParams}}} -http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?[^#]*&ids=[^&#]*-[0-9]{9}(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.html.request, argument={{{scriptParams}}} -http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?[^#]*&ids=-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.api.request, argument={{{scriptParams}}} -http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v2\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.bundle.js, tag=🌤 WeatherKit.api.v2.weather.response, argument={{{scriptParams}}} +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\/(?:HK|CN)\.AQHI(?:\.\d+)?(?:[?#]|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/scale.loon.bundle.js, tag=🌤 WeatherKit.api.v1.airQualityScale.request +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=[^&#]*-[0-9]{9}(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.html.request, argument={{{scriptParams}}} +http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.api.request, argument={{{scriptParams}}} +http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v2\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js, tag=🌤 WeatherKit.api.v2.weather.response, argument={{{scriptParams}}} +response if ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// && ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${response.status} == 200 && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js", {${DataSets}, ${Weather.Provider}, ${WeatherAlerts.Provider}, ${NextHour.Provider}, ${AirQuality.Current.Pollutants.Provider}, ${AirQuality.Calculate.Algorithm}, ${API.ColorfulClouds.Token}, ${API.QWeather.Host}, ${API.QWeather.Token}, ${API.WAQI.Token}, ${Storage}, ${LogLevel}, ${RequestCache.TTL}, ${RequestCache.ForecastTTL}, ${RequestCache.MinutelyTTL}, ${RequestCache.AlertsTTL}, ${RequestCache.BudgetMs}, ${RequestCache.CooldownSeconds}, ${RequestCache.Diagnostics}, ${Region.Fallback}}) with requires_body=true, tag="🌤 WeatherKit.api.v3.weather.json.response" [MITM] hostname = weatherkit.apple.com From fd01194c5561af5aa489729b1470f6e00e531099 Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:44:11 +0800 Subject: [PATCH 3/8] test(weatherkit): cover v3 decoder, all AQI algorithms and provider resource boundaries --- CHANGELOG.md | 10 +- README.md | 2 + docs/loon-v3-compatibility.md | 29 ++ tests/airQualityAlgorithms.test.mjs | 145 ++++++++ tests/fixtures/cacheRuntimeEntry.mjs | 3 + tests/fixtures/processingMemoryFixture.mjs | 69 ++++ tests/loonNativeRewrites.test.mjs | 7 + tests/loonProviderFetch.test.mjs | 53 +++ tests/providerAdapterRequestCache.test.mjs | 176 +++++++++ tests/providerCacheBundle.test.mjs | 58 +++ tests/providerCacheSerialization.test.mjs | 118 ++++++ tests/providerRequestCache.test.mjs | 389 +++++++++++++++++++ tests/providerResilience.test.mjs | 411 +++++++++++++++++++++ tests/providerTransportGuards.test.mjs | 142 +++++++ tests/responseJSONV3.test.mjs | 284 ++++++++++++++ tests/responseMemoryPaths.test.mjs | 118 ++++++ tests/severeWeatherPage.test.mjs | 95 +++-- tests/severeWeatherRewriteModules.test.mjs | 15 +- tests/waqiRequestCache.test.mjs | 67 ++++ tests/weatherKitSelectiveDecode.test.mjs | 172 ++++++++- tests/weatherMemoryPaths.test.mjs | 162 ++++++++ tests/weatherPowerPaths.test.mjs | 69 ++++ 22 files changed, 2543 insertions(+), 51 deletions(-) create mode 100644 docs/loon-v3-compatibility.md create mode 100644 tests/airQualityAlgorithms.test.mjs create mode 100644 tests/fixtures/cacheRuntimeEntry.mjs create mode 100644 tests/fixtures/processingMemoryFixture.mjs create mode 100644 tests/loonNativeRewrites.test.mjs create mode 100644 tests/loonProviderFetch.test.mjs create mode 100644 tests/providerAdapterRequestCache.test.mjs create mode 100644 tests/providerCacheBundle.test.mjs create mode 100644 tests/providerCacheSerialization.test.mjs create mode 100644 tests/providerRequestCache.test.mjs create mode 100644 tests/providerResilience.test.mjs create mode 100644 tests/providerTransportGuards.test.mjs create mode 100644 tests/responseJSONV3.test.mjs create mode 100644 tests/responseMemoryPaths.test.mjs create mode 100644 tests/waqiRequestCache.test.mjs create mode 100644 tests/weatherMemoryPaths.test.mjs create mode 100644 tests/weatherPowerPaths.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 067da420..b59dd541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,22 @@ ### 🆕 New Features - * none + * 为 Loon 增加有界供应商缓存、相同请求合并、失败冷却与可选运行统计,减少重复处理与额外查询。 ### 🛠️ Bug Fixes + * 修复 iOS 27.3 Beta 主天气 App 的 WeatherKit v3 兼容:协商 Apple JSON,适配原生读取器需要的状态与降水结构,逐产品校验并保留未知数据。 + * 修复内置空气质量算法的单位、无效值、零浓度、插值与分级边界,保留六项有效污染物浓度;修正原始 Apple QWeather CO 假标、加拿大 AQHI 换算与美标 InstantCast 精度。 + * 修复来源失败隔离、预警网页错误与过期记录处理;等待原生 HTTP 回调后单次完成,保留既有 Apple 数据。 * 修复 WeatherKit Response 处理范围未同时受用户设置和 URL `dataSets` 约束的问题:现在只处理两者的交集,避免地图天气小组件只请求当前天气与空气质量时扩展处理其他数据集。 * 修复和风天气历史空气质量接口路径多余斜杠的问题,确保请求使用正确的 `/v7/historical/air` 路径。 ### 🔣 Dependencies + * 增加 @noble/hashes@1.8.0,用于无 Node/WebCrypto 依赖的缓存键哈希。 * 将 FlatBuffer Root 处理器迁移至已发布的 `@nsnanocat/flatbuffer-root@1.0.0`,移除仓库内重复实现。 ### ‼️ Breaking Changes - * none + * Loon 原生条件与参数对象要求 3.5.1(983);v3 替换限定主天气 App,其他后台客户端保留原传输。 ### 🔄 Other Changes + * Loon 使用独立的 request.loon.bundle.js、response.loon.bundle.js 和 scale.loon.bundle.js;其他客户端沿用原版 generic bundle。 + * 开发版 Loon 文件统一为 .lpx,更新生成与部署引用;增加源码回归、实际压缩脚本和原生表达式检查。 * 从 BoxJs 设置中隐藏仅供插件参数使用的 `Storage` 选项,保留运行时配置行为。 * 补充数据集交集、用户设置过滤、FlatBuffer 根槽映射和 QWeather 接口路径的回归测试。 diff --git a/README.md b/README.md index 0a7b8332..8715cd79 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ #  iRingo: 🌤 WeatherKit + +Loon WeatherKit v3 主应用兼容、空气质量算法与有界供应商处理的实现和验证边界见 [Loon v3 兼容说明](docs/loon-v3-compatibility.md)。版本、插件名称和下载地址沿用本项目发布流程。 diff --git a/docs/loon-v3-compatibility.md b/docs/loon-v3-compatibility.md new file mode 100644 index 00000000..40417647 --- /dev/null +++ b/docs/loon-v3-compatibility.md @@ -0,0 +1,29 @@ +# Loon WeatherKit v3 兼容与供应商处理边界 + +2026-11-05 核对 [Loon Rewrite](https://nsloon.app/docs/Rewrite/rewrite_v2/)、[Script](https://nsloon.app/docs/Script/script_v2/)、[Script API](https://nsloon.app/docs/Script/script_api/) 与[插件](https://nsloon.app/docs/Plugin/)。最低 Loon 版本为 3.5.1(983)。本文描述源码与自动验证范围,不包含个人订阅、设备授权或原始位置记录。 + +## v3 主应用传输 + +原生 Rewrite 只为 HTTPS Apple v3 天气 GET 请求、主天气 App 的 iOS/macOS User-Agent 前缀协商 Apple JSON。响应还要求 200 和 application/json,使用必要 URL Guard、一个 script Action 及原生参数对象。其余后台客户端与未识别的 WK3 二进制保留 Apple 原传输;不使用 WK2 schema 解码 WK3。 + +Apple 的 WK3 FlatBuffer JSON 表示与主应用 JSON 读取器不同。后者要求字符串天气状态、标量降水和每日降水总量;原生 Mac 日志曾明确报告数组类型不匹配及缺字段,并清缓存走 CloudKit 回退。现将已知原始 Apple 产品和供应商结果适配为该读取器所需表示,也处理未选择数据替换的已知分时产品;未知产品和字段保留。独立 condition 记录数组(包括空数组)不当作字符串概述处理。 + +供应商整数、时间、Unicode 与有限数值按已知字段校验;异常按产品回退。没有改动时保留原正文、空白与缓存验证头;改写后移除旧 bodyBytes、长度和校验标记,设置 no-store。JSON 最多 1,048,576 UTF-16 码元及 4 MiB UTF-8,每产品累计数组元素 8192、深度 64、节点 100000。它们是脚本工作边界,无法限制宿主已接收的 HTTP 缓冲。 + +## 空气质量与请求开销 + +11 种内置算法与 None 保留六项有效物理浓度,按模型定义参与指数计算。修复空值、负数、非有限值、零浓度、单位、分级与插值边界;加拿大 AQHI 使用正确 ppb 换算及最近整数取整,美标 InstantCast 保留 WAQI 臭氧分段并按浓度精度截断。研究模型和当前浓度换算不等于官方日均发布值,不新增后台采样。 + +原始 Apple QWeather 的 CO 存在 mg 数值配 µg 标签的历史格式,只有该入口修复假标;已归一化供应商结果依据明确单位处理,不按浓度大小猜测。已有有效浓度优先,只补缺项;完整六项数据没有补充请求。模型未定义的污染物不填 index=-1,物理浓度仍保留。更换标准后不复用另一标准的昨日趋势。 + +供应商请求合并、Loon 有界持久缓存与冷却复用相同入口。默认缓存 60/180/15/15 秒、三并发、16 个在途、8000 ms 发起预算;失败回调返回前不会提前完成脚本,不依赖取消 API 或清理计时器。预警网页缓存区分来源失败与真实无预警。诊断默认关闭,只输出不含令牌、URL 和坐标的摘要。单产品错误不阻止其他健康产品。 + +原有按需解码、实际修改产品编码、预警延迟复制和单次最终缓存序列化减少临时对象。完整六项污染物与原生功能列表无需额外供应商工作;普通空气质量标尺通过原生 Rewrite 处理,HK/CN 自定义图例使用独立小脚本。 + +## 构建与验证 + +Loon 使用 request.loon.bundle.js、response.loon.bundle.js、scale.loon.bundle.js;原版其他客户端的 generic bundle 文件名和模板继续保留。版本由原版发布流程决定,本改动不指定 Fork 版本或下载地址。CI 中的 0.0.0-test 仅用于生成验证夹具,不是可订阅发行版本。 + +`npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 35 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 + +执行逻辑相同的发行代码曾在 macOS 27.3、Loon for Mac 0.5.0(1011) 核对七种算法和六项污染物,最终轮次无解码错误、无效天气或 CloudKit 回退,11 类产品进入原生缓存;另有用户反馈 iPhone 使用成功。该事实与当前分支的静态、构建和 VM 验证分别报告,不扩展为所有算法、预警、手表、地区或长期 VPN 的原生验收。没有设备内存或电量下降比例。 diff --git a/tests/airQualityAlgorithms.test.mjs b/tests/airQualityAlgorithms.test.mjs new file mode 100644 index 00000000..41503ac1 --- /dev/null +++ b/tests/airQualityAlgorithms.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$environment = { "surge-version": "test" }; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { LogLevel: "OFF", Storage: "database" }; +const [{ default: AQ }, { default: WeatherKit2 }, { Console }] = await Promise.all([import("../src/class/AirQuality.mjs"), import("../src/class/WeatherKit2.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const ug = "MICROGRAMS_PER_CUBIC_METER", ppb = "PARTS_PER_BILLION", mg = "MILLIGRAMS_PER_CUBIC_METER"; +const algorithms = ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI"]; +const pollutant = (pollutantType, amount, units = ug) => ({ pollutantType, amount, units }); +const six = () => [pollutant("PM2_5", 35), pollutant("PM10", 60), pollutant("OZONE", 90), pollutant("NO2", 35), pollutant("SO2", 15), pollutant("CO", 700)]; +const compute = (algorithm, pollutants, extra = {}) => AQ.Pollutants2AQI({ metadata: { providerName: "fixture" }, pollutants }, {}, { algorithm, ...extra }); + +for (const algorithm of algorithms) { + test(`${algorithm}: all six concentrations survive calculation and WK2 encoding without negative subindexes`, () => { + const input = six(), original = structuredClone(input); + const output = compute(algorithm, input); + assert.equal(output.metadata.temporarilyUnavailable, false); + assert.equal(Number.isInteger(output.index) && output.index >= 0, true); + assert.equal(Number.isInteger(output.categoryIndex) && output.categoryIndex > 0, true); + assert.equal(output.pollutants.length, 6); + for (const p of output.pollutants) assert.ok(p.index === undefined || Number.isFinite(p.index) && p.index >= 0); + const decoded = WeatherKit2.decode(new ByteBuffer(WeatherKit2.encode(undefined, { airQuality: output })), ["airQuality"]).airQuality; + assert.equal(decoded.index, output.index); + assert.equal(decoded.categoryIndex, output.categoryIndex); + assert.equal(decoded.scale, output.scale); + assert.deepEqual(decoded.pollutants.map(p => p.pollutantType), input.map(p => p.pollutantType)); + assert.deepEqual(input, original, "calculation must not mutate Apple/provider objects"); + }); + test(`${algorithm}: clean zero measurements are valid; missing/invalid measurements are unavailable`, () => { + const zero = compute(algorithm, six().map(p => ({ ...p, amount: 0 }))); + assert.equal(zero.metadata.temporarilyUnavailable, false); + assert.ok(zero.categoryIndex >= 1); + for (const amount of [NaN, Infinity, -1, null, undefined, "", "bad"]) { + const result = compute(algorithm, [pollutant("PM2_5", amount)]); + assert.equal(result.metadata.temporarilyUnavailable, true, String(amount)); + assert.equal(result.index, undefined); + } + }); +} + +test("US: EPA May 2026 truncation occurs before interpolation without changing the physical concentration", () => { + for (const [type, amount, units, expected] of [["PM2_5", 9.05, ug, 50], ["PM2_5", 9.1, ug, 51], ["PM10", 54.99, ug, 50], ["NO2", 53.99, ppb, 50], ["SO2", 35.99, ppb, 50], ["CO", 4499, ppb, 50], ["CO", 4500, ppb, 51]]) { + const result = compute("WAQI_InstantCast_US", [pollutant(type, amount, units)]); + assert.equal(result.index, expected, type); + assert.equal(result.pollutants[0].amount, amount); + } +}); + +test("CN: exact breakpoint is not promoted by floating point error and SO2's capped branch remains finite", () => { + assert.equal(compute("WAQI_InstantCast_CN", [pollutant("PM2_5", 35)]).categoryIndex, 1); + assert.equal(compute("WAQI_InstantCast_CN", [pollutant("PM2_5", 75)]).index, 100); + assert.equal(compute("WAQI_InstantCast_CN_25_DRAFT", [pollutant("SO2", 900)]).index, 200); +}); + +test("above-range US interpolation is continuous and respects the optional 500 cap", () => { + assert.equal(compute("WAQI_InstantCast_US", [pollutant("PM10", 604)]).index, 500); + assert.equal(compute("WAQI_InstantCast_US", [pollutant("PM10", 605)]).index, 501); + assert.equal(compute("WAQI_InstantCast_US", [pollutant("PM10", 605)], { allowOverRange: false }).index, 500); +}); + +test("Canada: ppb and equivalent mass inputs produce the reference rounded AQHI 4", () => { + const ppbInput = [pollutant("NO2", 20, ppb), pollutant("OZONE", 30, ppb), pollutant("PM2_5", 10)]; + assert.equal(compute("CA_AQHI", ppbInput).index, 4); + const massInput = [pollutant("NO2", 20 * 1.880472698306222), pollutant("OZONE", 30 * 1.96189649169881), pollutant("PM2_5", 10)]; + assert.equal(compute("CA_AQHI", massInput).index, 4); + // AQHI rounds 3.49 down, 3.50 up; use PM2.5-only risk with measured zero gases. + for (const [index, expected] of [[3.49, 3], [3.51, 4]]) { + const concentration = Math.log1p(index * 10.4 / 1000) / 0.000487; + assert.equal(compute("CA_AQHI", [pollutant("NO2", 0, ppb), pollutant("OZONE", 0, ppb), pollutant("PM2_5", concentration)]).index, expected); + } +}); + +test("HK: official 2025 coefficients and risk bands produce AQHI 5 for this complete concentration set", () => { + const result = compute("HK_AQHI", six()); + assert.equal(result.index, 5); + assert.equal(result.primaryPollutant, "OZONE"); +}); + +test("inapplicable CO keeps its concentration but cannot manufacture a healthy EU/UBA/AQHI index", () => { + for (const algorithm of algorithms.filter(a => !a.startsWith("WAQI_"))) { + const result = compute(algorithm, [pollutant("CO", 700)]); + assert.equal(result.metadata.temporarilyUnavailable, true, algorithm); + assert.equal(result.pollutants[0].amount, 700); + assert.equal(result.pollutants[0].index, undefined); + } +}); + +test("risk algorithms require their actual gas inputs and accept the available configured PM alternative", () => { + for (const algorithm of algorithms.filter(a => a.includes("AQHI"))) { + const noOzone = compute(algorithm, six().filter(p => p.pollutantType !== "OZONE")); + assert.equal(noOzone.metadata.temporarilyUnavailable, true, algorithm); + } + assert.equal(compute("HK_AQHI", six().filter(p => p.pollutantType !== "PM2_5")).metadata.temporarilyUnavailable, false); + assert.equal(compute("CA_AQHI", six().filter(p => p.pollutantType !== "PM2_5")).metadata.temporarilyUnavailable, true); +}); + +test("sanitizing supplements preserves first valid concentrations and rejects malformed values/units", () => { + const current = [pollutant("PM2_5", 0), pollutant("NO2", null), pollutant("OZONE", "12.5"), pollutant("SO2", 4, "invalid")]; + const result = AQ.MergePollutants(current, six()); + assert.equal(result.length, 6); + assert.equal(result.find(p => p.pollutantType === "PM2_5").amount, 0); + assert.equal(result.find(p => p.pollutantType === "OZONE").amount, 12.5); + assert.equal(result.find(p => p.pollutantType === "NO2").amount, 35); + assert.deepEqual(AQ.MissingPollutants(result), []); + assert.deepEqual(AQ.MissingPollutants([pollutant("PM2_5", 0)]), ["PM10", "OZONE", "NO2", "SO2", "CO"]); +}); + +test("QWeather CO normalization is unit-aware and idempotent", () => { + const input = { metadata: { providerName: "QWeather" }, pollutants: [pollutant("CO", 0.7, mg)] }; + const fixed = AQ.FixPollutantsUnits(input); + assert.equal(fixed.pollutants[0].amount, 700); + assert.equal(fixed.pollutants[0].units, ug); + assert.deepEqual(AQ.FixPollutantsUnits(fixed), fixed); + assert.equal(input.pollutants[0].amount, 0.7); +}); + +test("raw Apple QWeather CO repairs the mg value mislabeled as ug without reprocessing normalized provider data", () => { + for (const providerName of ["QWeather", "和风天气"]) { + const rawApple = { metadata: { providerName }, pollutants: [pollutant("CO", 0.7), null] }; + const normalized = AQ.FixPollutantsUnits(rawApple, { appleProduct: true }); + assert.equal(normalized.pollutants[0].amount, 700); + assert.equal(normalized.pollutants[0].units, ug); + assert.equal(rawApple.pollutants[0].amount, 0.7); + assert.deepEqual(AQ.FixPollutantsUnits(normalized), normalized); + const provider = { metadata: { providerName }, pollutants: [pollutant("CO", 700)] }; + assert.deepEqual(AQ.FixPollutantsUnits(provider), provider); + } + const other = { metadata: { providerName: "BreezoMeter" }, pollutants: [pollutant("CO", 700)] }; + assert.strictEqual(AQ.FixPollutantsUnits(other, { appleProduct: true }), other); +}); + +test("unit conversion rejects nonfinite values; numeric strings and identity ppb work without artificial STP factors", () => { + for (const amount of [NaN, Infinity, null, undefined, "", "wrong", -1]) assert.equal(AQ.ConvertUnit(amount, ug, mg), -1); + assert.equal(AQ.ConvertUnit("700", ug, mg), 0.7); + assert.equal(AQ.ConvertUnit(30, ppb, ppb, 1.96189649169881), 30); +}); + +test("None preserves the original product and unknown algorithms fail safely", () => { + const product = { index: 42, pollutants: six() }; + assert.strictEqual(AQ.Pollutants2AQI(product, {}, { algorithm: "None" }), product); + assert.equal(compute("typo", six()).metadata.temporarilyUnavailable, true); +}); diff --git a/tests/fixtures/cacheRuntimeEntry.mjs b/tests/fixtures/cacheRuntimeEntry.mjs new file mode 100644 index 00000000..956aa23b --- /dev/null +++ b/tests/fixtures/cacheRuntimeEntry.mjs @@ -0,0 +1,3 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +export { default as ProviderRequestCache } from "../../src/class/ProviderRequestCache.mjs"; +export { default as WAQI } from "../../src/class/WAQI.mjs"; diff --git a/tests/fixtures/processingMemoryFixture.mjs b/tests/fixtures/processingMemoryFixture.mjs new file mode 100644 index 00000000..be0d924b --- /dev/null +++ b/tests/fixtures/processingMemoryFixture.mjs @@ -0,0 +1,69 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import * as WK from "@nsringo/weatherkit"; +import { Builder, ByteBuffer } from "flatbuffers"; +import WeatherKit2 from "../../src/class/WeatherKit2.mjs"; + +export const currentWeather = { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + temperature: 20, + precipitationAmountNext1hByType: [], + precipitationAmountNext24hByType: [], + precipitationAmountNext6hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious24hByType: [], + precipitationAmountPrevious6hByType: [], +}; +export function weatherFixture(hours = 273, alerts = 4) { + return WeatherKit2.encode(undefined, { + currentWeather, + forecastHourly: { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + hours: Array.from({ length: hours }, (_, index) => ({ forecastStart: 1792368000 + index * 3600, temperature: 20 + (index % 5), humidity: 50, windSpeed: 2, precipitationAmountByType: [{ precipitationType: "RAIN", expected: 0 }] })), + }, + weatherAlerts: { + metadata: { providerName: "National Early Warning Center", temporarilyUnavailable: false }, + detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=32.39,119.499", + alerts: Array.from({ length: alerts }, (_, index) => ({ + id: `00000000-0000-0000-0000-${String(index).padStart(12, "0")}`, + description: `Apple warning ${index}`, + areaId: String(index), + areaName: `Fixture area ${index}`, + severity: "UNKNOWN", + responses: [], + expireTime: 4102444800, + })), + }, + }); +} + +export const futureHourlyText = "future-hourly-field-must-survive"; +export function futureHourlyFixture() { + const builder = new Builder(); + const provider = builder.createString("Apple"); + WK.Metadata.startMetadata(builder); + WK.Metadata.addProviderName(builder, provider); + const metadata = WK.Metadata.endMetadata(builder); + const hours = WK.HourlyForecastData.createHoursVector(builder, []); + const future = builder.createString(futureHourlyText); + builder.startObject(3); + WK.HourlyForecastData.addMetadata(builder, metadata); + WK.HourlyForecastData.addHours(builder, hours); + builder.addFieldOffset(2, future, 0); + const hourly = builder.endObject(); + WK.Weather.startWeather(builder); + WK.Weather.addForecastHourly(builder, hourly); + builder.finish(WK.Weather.endWeather(builder)); + return WeatherKit2.encode(new ByteBuffer(builder.asUint8Array()), { currentWeather }); +} +export function readFutureHourly(bytes) { + const hourly = WK.Weather.getRootAsWeather(new ByteBuffer(bytes)).forecastHourly(); + const field = hourly.bb.__offset(hourly.bb_pos, 8); + return field ? hourly.bb.__string(hourly.bb_pos + field) : undefined; +} + +export const weatherNow = { + code: "200", + updateTime: "2026-11-01T00:00:00+08:00", + fxLink: "https://example.test/weather", + now: { obsTime: "2026-11-01T00:00:00+08:00", temp: "23", feelsLike: "24", text: "Sunny", icon: "100", wind360: "90", windSpeed: "1", humidity: "80", precip: "0", pressure: "1000", vis: "10", cloud: "20", dew: "15" }, +}; diff --git a/tests/loonNativeRewrites.test.mjs b/tests/loonNativeRewrites.test.mjs new file mode 100644 index 00000000..97953ac2 --- /dev/null +++ b/tests/loonNativeRewrites.test.mjs @@ -0,0 +1,7 @@ +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import verifyLoonRewrites from "../scripts/verify-loon-rewrites.mjs"; + +test("the actual Loon template retains capabilities and narrows JavaScript startup", async () => { + verifyLoonRewrites(await readFile(new URL("../template/loon.handlebars", import.meta.url), "utf8")); +}); diff --git a/tests/loonProviderFetch.test.mjs b/tests/loonProviderFetch.test.mjs new file mode 100644 index 00000000..f51c28ba --- /dev/null +++ b/tests/loonProviderFetch.test.mjs @@ -0,0 +1,53 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +globalThis.$loon = "simulated native Loon"; +const { default: fetchProvider } = await import("../src/function/fetchProvider.mjs"); +for (const timeout of [250, 499, 500, 501, 999, 1499, 5000]) { + test(`Loon provider timeout ${timeout} ms reaches the native API unchanged`, async () => { + globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.timeout, timeout); + callback(null, { status: 200, headers: {} }, "{}"); + }, + }; + assert.equal((await fetchProvider({ url: "https://provider.example/weather", timeout })).status, 200); + }); +} + +test("native provider POST bodies and copied headers keep their values and leave caller inputs intact", async () => { + const input = { url: "https://provider.example/auth", body: "token=fixture", headers: { Host: "old-host", "Content-Length": "1", Authorization: "fixture-private" }, timeout: 800 }; + globalThis.$httpClient = { + post(request, callback) { + assert.equal(request.body, input.body); + assert.equal(request.timeout, 800); + assert.deepEqual(request.headers, { Authorization: "fixture-private" }); + callback(null, { status: 200, headers: { "Content-Type": "application/json" } }, "{}"); + }, + }; + assert.equal((await fetchProvider(input)).ok, true); + assert.equal(input.headers.Host, "old-host"); + assert.equal(input.headers["Content-Length"], "1"); +}); + +test("native conditional responses normalize an omitted 304 body without treating it as success JSON", async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 304, headers: {} }); + }, + }; + const response = await fetchProvider("https://provider.example/grid"); + assert.equal(response.status, 304); + assert.equal(response.ok, false); + assert.equal(response.body, ""); +}); + +test("native request failures reject and a repeated callback cannot replace the first result", async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback("native timeout"); + callback(null, { status: 200 }, "{}"); + }, + }; + await assert.rejects(fetchProvider("https://provider.example/weather"), value => value === "native timeout"); +}); diff --git a/tests/providerAdapterRequestCache.test.mjs b/tests/providerAdapterRequestCache.test.mjs new file mode 100644 index 00000000..046bc29e --- /dev/null +++ b/tests/providerAdapterRequestCache.test.mjs @@ -0,0 +1,176 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "Mac simulated Loon"; +const values = new Map(); +globalThis.$persistentStore = { + read: key => values.get(key), + write: (value, key) => { + values.set(key, value); + return true; + }, +}; +let pending = []; +globalThis.$httpClient = { + get(request, callback) { + pending.push({ request, callback }); + }, +}; +const [{ default: ColorfulClouds }, { default: QWeather }, { default: ProviderRequestCache }, { Console }] = await Promise.all([import("../src/class/ColorfulClouds.mjs"), import("../src/class/QWeather.mjs"), import("../src/class/ProviderRequestCache.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const parameters = { latitude: 31.2, longitude: 121.5, country: "CN", language: "zh-CN", version: "v2" }; +const finish = (body, status = 200) => { + const tasks = pending; + pending = []; + for (const task of tasks) task.callback(null, { status, headers: {} }, JSON.stringify(body)); +}; +const realtime = { + status: "ok", + location: [31.2, 121.5], + server_time: 1_790_000_000, + result: { + server_time: 1_790_000_000, + realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.2, skycon: "CLEAR_DAY", humidity: 0.8, precipitation: { local: { intensity: 0 } }, pressure: 100_000, visibility: 10, wind: { direction: 90, speed: 1 } }, + alert: { status: "ok", content: [] }, + }, +}; +const weatherNow = { + code: "200", + updateTime: "2026-10-19T00:00:00+08:00", + fxLink: "https://example.test/weather", + now: { obsTime: "2026-10-19T00:00:00+08:00", temp: "23", feelsLike: "24", text: "晴", icon: "100", wind360: "90", windSpeed: "1", windScale: "1", humidity: "80", precip: "0", pressure: "1000", vis: "10", cloud: "20", dew: "15" }, +}; + +test("Caiyun real provider coalesces six requests and refreshes when expired", async () => { + values.clear(); + let now = 100_000; + const cache = new ProviderRequestCache({ now: () => now }); + const provider = new ColorfulClouds(parameters, "fixture-caiyun-token", cache); + const consumers = Array.from({ length: 6 }, () => provider.CurrentWeather()); + assert.equal(pending.length, 1); + assert.equal(pending[0].request.timeout, 5000); + finish(realtime); + assert.ok((await Promise.all(consumers)).every(product => product.temperature === 23)); + assert.equal((await provider.CurrentWeather()).temperature, 23); + assert.equal(pending.length, 0); + now += 60_000; + const refresh = provider.CurrentWeather(); + assert.equal(pending.length, 1); + finish({ status: "error" }, 503); + assert.equal((await refresh).metadata.temporarilyUnavailable, true); + const retry = provider.CurrentWeather(); + assert.equal(pending.length, 1); + finish(realtime); + assert.equal((await retry).temperature, 23); +}); + +test("Caiyun yesterday hourly queries share a stable hour key across fresh executions", async () => { + values.clear(); + const dateNow = Date.now; + let now = Date.UTC(2026, 9, 19, 0, 0, 10), + requests = 0; + Date.now = () => now; + const hourly = { status: "ok", result: { hourly: { status: "ok", air_quality: { aqi: [{ value: { usa: 42, chn: 40 } }] } } } }; + try { + for (let i = 0; i < 6; i++, now += 10000) { + const provider = new ColorfulClouds(parameters, "hourly-fixture-token", new ProviderRequestCache({ ttls: { forecast: 180 }, now: () => now })); + const result = provider.YesterdayAirQuality(); + if (pending.length) { + requests++; + assert.equal(new URL(pending[0].request.url).searchParams.get("begin"), String(Date.UTC(2026, 9, 18) / 1000)); + finish(hourly); + } + assert.equal((await result).index, 42); + } + assert.equal(requests, 1); + now = Date.UTC(2026, 9, 19, 1, 0, 0); + const result = new ColorfulClouds(parameters, "hourly-fixture-token", new ProviderRequestCache({ now: () => now })).YesterdayAirQuality(); + assert.equal(pending.length, 1); + assert.equal(new URL(pending[0].request.url).searchParams.get("begin"), String(Date.UTC(2026, 9, 18, 1) / 1000)); + finish(hourly); + assert.equal((await result).index, 42); + } finally { + Date.now = dateNow; + } +}); + +test("QWeather real provider shares requests and isolates changed authorization", async () => { + values.clear(); + const cache = new ProviderRequestCache(); + const provider = new QWeather(parameters, "fixture-qweather-token", "devapi.qweather.com", cache); + const consumers = Array.from({ length: 6 }, () => provider.WeatherNow()); + assert.equal(pending.length, 1); + assert.equal(pending[0].request.headers["X-QW-Api-Key"], "fixture-qweather-token"); + finish(weatherNow); + assert.ok((await Promise.all(consumers)).every(product => product.temperature === 23)); + const same = new QWeather(parameters, "fixture-qweather-token", "devapi.qweather.com", new ProviderRequestCache()); + assert.equal((await same.WeatherNow()).temperature, 23); + assert.equal(pending.length, 0); + const changed = new QWeather(parameters, "changed-qweather-token", "devapi.qweather.com", cache); + const next = changed.WeatherNow(); + assert.equal(pending.length, 1); + finish({ ...weatherNow, now: { ...weatherNow.now, temp: "24" } }); + assert.equal((await next).temperature, 24); +}); + +for (const order of ["alerts-first", "current-first"]) { + test(`Caiyun keeps healthy current weather when its shared alert subproduct fails (${order})`, async () => { + values.clear(); + const cache = new ProviderRequestCache(); + const provider = new ColorfulClouds(parameters, "fixture-caiyun-token", cache); + const tasks = order === "alerts-first" ? [provider.WeatherAlert(), provider.CurrentWeather()] : [provider.CurrentWeather(), provider.WeatherAlert()]; + assert.equal(pending.length, 1); + finish({ ...realtime, result: { ...realtime.result, alert: { status: "error" } } }); + const results = await Promise.all(tasks); + const [alerts, current] = order === "alerts-first" ? results : results.toReversed(); + assert.equal(alerts.metadata.temporarilyUnavailable, true); + assert.equal(current.temperature, 23); + assert.equal(cache.stats.requests, 1); + assert.equal(cache.stats.failures, 1); + }); +} + +test("QWeather rejects nonnumeric temperature even if its JSON code says 200", async () => { + values.clear(); + const cache = new ProviderRequestCache(); + const provider = new QWeather(parameters, "fixture-qweather-token", "devapi.qweather.com", cache); + const task = provider.WeatherNow(); + finish({ ...weatherNow, now: { ...weatherNow.now, temp: "not-a-number" } }); + assert.equal(await task, undefined); + assert.equal(cache.stats.failureReasons.invalid, 1); +}); + +test("an unchanged QWeather location grid survives a native 304 response", async () => { + const existing = { lastUpdated: Date.now() - 31 * 86400000, etag: "fixture-grid", data: { "31,121": [{ name: "fixture" }] } }; + const runtime = new ProviderRequestCache(); + let updated; + const task = QWeather.GetLocationsGrid( + { locationsGrid: existing }, + value => { + updated = value; + }, + runtime, + ); + assert.equal(pending.length, 1); + assert.equal(pending[0].request.headers["If-None-Match"], "fixture-grid"); + finish(undefined, 304); + assert.deepEqual(await task, existing.data); + assert.equal(updated.locationsGrid.data, existing.data); + assert.equal(updated.locationsGrid.etag, existing.etag); + assert.ok(updated.locationsGrid.lastUpdated > existing.lastUpdated); + assert.equal(runtime.stats.failures, 0); +}); + +test("a provider receives exactly 499 ms when only 499 ms of the dispatch budget remains", async () => { + values.clear(); + let now = 100000; + const runtime = new ProviderRequestCache({ now: () => now, budgetMs: 1000 }); + const provider = new QWeather(parameters, "fixture-budget-token", "devapi.qweather.com", runtime); + now += 501; + const task = provider.WeatherNow(); + assert.equal(pending.length, 1); + assert.equal(pending[0].request.timeout, 499); + finish(weatherNow); + assert.equal((await task).temperature, 23); +}); diff --git a/tests/providerCacheBundle.test.mjs b/tests/providerCacheBundle.test.mjs new file mode 100644 index 00000000..d85563f1 --- /dev/null +++ b/tests/providerCacheBundle.test.mjs @@ -0,0 +1,58 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { rollup } from "rollup"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; + +test("browser bundle works with Loon callbacks and storage, without Node, WebCrypto or TextEncoder", async () => { + const bundle = await rollup({ input: new URL("./fixtures/cacheRuntimeEntry.mjs", import.meta.url).pathname, plugins: [nodeResolve({ browser: true })] }); + const { output } = await bundle.generate({ format: "iife", name: "CacheTestRuntime" }); + await bundle.close(); + const code = output[0].code; + assert.doesNotMatch(code, /node:crypto|node:fs|require\s*\(/); + const values = new Map(); + let calls = 0; + const callbacks = []; + const makeContext = () => + vm.createContext({ + $loon: "Mac simulated Loon 3.5.1", + $persistentStore: { + read: key => values.get(key), + write: (value, key) => { + values.set(key, value); + return true; + }, + }, + $httpClient: { + get(request, callback) { + assert.equal(request.timeout, 5000); + calls++; + callbacks.push(() => callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", data: { city: { name: "Fixture", geo: [31.2, 121.5] }, aqi: "42", dominentpol: "pm25", time: { v: 1_790_000_000 }, idx: 123 } }))); + }, + }, + console: { log() {}, error() {}, warn() {} }, + }); + const context = makeContext(); + vm.runInContext(code, context); + assert.equal(context.process, undefined); + assert.equal(context.crypto, undefined); + assert.equal(context.TextEncoder, undefined); + const source = `const provider = new CacheTestRuntime.WAQI({latitude:31.2,longitude:121.5,country:"CN",language:"zh-CN",version:"v2"}, "fixture-token"); + globalThis.result = Promise.all(Array.from({length:6}, () => provider.AQI2()));`; + vm.runInContext(source, context); + assert.equal(calls, 1); + const done = callbacks.shift(); + done(); + done(); // A duplicate native callback must not produce an extra cache write or completion. + const results = await context.result; + assert.equal(results.length, 6); + assert.ok(results.every(product => product.index === 42)); + + const next = makeContext(); + vm.runInContext(code, next); + vm.runInContext(source, next); + assert.ok((await next.result).every(product => product.index === 42)); + assert.equal(calls, 1); + assert.equal([...values.values()].join("").includes("fixture-token"), false); +}); diff --git a/tests/providerCacheSerialization.test.mjs b/tests/providerCacheSerialization.test.mjs new file mode 100644 index 00000000..f6d2d84b --- /dev/null +++ b/tests/providerCacheSerialization.test.mjs @@ -0,0 +1,118 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderRequestCache, { REQUEST_CACHE_LIMITS } from "../src/class/ProviderRequestCache.mjs"; +import serializeProviderCache from "../src/function/serializeProviderCache.mjs"; + +const record = (body, index = 0) => ({ key: String(index).padStart(64, "a"), storedAt: 100000 + index, lastUsedAt: 100000 + index, expiresAt: 160000 + index, status: 200, body, group: "b".repeat(64) }); +function legacy(entries, maximum) { + let text = JSON.stringify({ version: 1, entries }); + while (entries.length && text.length > maximum) { + entries.pop(); + text = JSON.stringify({ version: 1, entries }); + } + return text; +} + +test("every UTF-16 code unit fits exactly at the same JSON boundary as serialize-and-pop", () => { + for (let code = 0; code <= 0xffff; code++) { + const entries = [record(String.fromCharCode(code))], + length = JSON.stringify({ version: 1, entries }).length; + for (const maximum of [length - 1, length, length + 1]) assert.equal(serializeProviderCache([...entries], maximum), legacy([...entries], maximum), `code unit ${code}, budget ${maximum}`); + } +}); + +test("mixed control characters, quotes and paired or lone surrogates preserve the exact retained prefix", () => { + let seed = 17; + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed; + }; + const units = ['"', "\\", "\n", "\b", "\t", "\r", "\f", "\0", "\u001f", "中", "😀", "\ud800", "\udc00", "\ud800\ud800\udc00", "\udc00\ud800", "a", "\u2028"]; + for (let sample = 0; sample < 300; sample++) { + const entries = Array.from({ length: 8 }, (_, index) => record(Array.from({ length: next() % 120 }, () => units[next() % units.length]).join(""), index)); + const cutoff = next() % 9, + boundary = JSON.stringify({ version: 1, entries: entries.slice(0, cutoff) }).length; + for (const maximum of [Math.max(25, boundary - 1), boundary, boundary + 1, REQUEST_CACHE_LIMITS.storageCharacters]) { + const selected = [...entries], + expected = [...entries]; + assert.equal(serializeProviderCache(selected, maximum), legacy(expected, maximum)); + assert.deepEqual(selected, expected); + } + } +}); + +test("a large highest-priority entry cannot be skipped in favour of smaller later entries", () => { + const entries = [record("\0".repeat(90000)), record("small", 1)]; + assert.equal(serializeProviderCache(entries, REQUEST_CACHE_LIMITS.storageCharacters), '{"version":1,"entries":[]}'); + assert.equal(entries.length, 0); +}); + +test("oversized cache candidates produce one bounded snapshot while keeping all in-memory responses usable", async () => { + let raw, + requests = 0, + snapshots = 0, + largestSnapshot = 0; + const stringify = JSON.stringify, + store = { + read: () => raw, + write: value => { + raw = value; + return true; + }, + }, + cache = new ProviderRequestCache({ store, now: () => 100000 }); + const body = "x".repeat(90000); + JSON.stringify = (value, ...rest) => { + const result = stringify(value, ...rest); + if (value?.version === 1 && Array.isArray(value.entries)) { + snapshots++; + largestSnapshot = Math.max(largestSnapshot, result.length); + } + return result; + }; + try { + const options = { + format: "text", + validate: value => value === body, + fetcher: async () => { + requests++; + return { status: 200, headers: {}, body }; + }, + }; + for (let index = 0; index < 8; index++) await cache.fetch({ url: `https://memory.invalid/${index}` }, options); + assert.equal(snapshots, 8, "Each write serializes the final snapshot once"); + assert.ok(largestSnapshot <= REQUEST_CACHE_LIMITS.storageCharacters); + assert.equal(JSON.parse(raw).entries.length, 2); + for (let index = 0; index < 8; index++) assert.equal((await cache.fetch({ url: `https://memory.invalid/${index}` }, options)).body, body); + assert.equal(requests, 8, "Memory capacity and hit behaviour are preserved"); + } finally { + JSON.stringify = stringify; + } +}); + +test("highly escaped text keeps the response and memory hit even when no disk entry fits", async () => { + let stored, + requests = 0; + const body = "\0".repeat(90000), + cache = new ProviderRequestCache({ + store: { + read: () => stored, + write: value => { + stored = value; + return true; + }, + }, + }), + options = { + format: "text", + validate: value => value === body, + fetcher: async () => { + requests++; + return { status: 200, headers: {}, body }; + }, + }; + for (let count = 0; count < 2; count++) assert.equal((await cache.fetch({ url: "https://memory.invalid/escaped" }, options)).body, body); + assert.equal(requests, 1); + assert.equal(stored, '{"version":1,"entries":[]}'); +}); diff --git a/tests/providerRequestCache.test.mjs b/tests/providerRequestCache.test.mjs new file mode 100644 index 00000000..89f62997 --- /dev/null +++ b/tests/providerRequestCache.test.mjs @@ -0,0 +1,389 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderRequestCache, { REQUEST_CACHE_LIMITS, requestCacheTTL } from "../src/class/ProviderRequestCache.mjs"; + +const request = { url: "https://provider.example/weather/31.2/121.5?token=test-secret", headers: { "X-API-Key": "header-secret", Accept: "application/json" } }; +const valid = body => body?.status === "ok"; +const response = (body = { status: "ok", temperature: 23 }, headers = {}, status = 200) => ({ status, ok: status >= 200 && status < 300, headers, body: JSON.stringify(body) }); + +function storage(initial) { + let value = initial; + return { + read: () => value, + write: next => { + value = next; + return true; + }, + value: () => value, + }; +} + +function setup({ ttlSeconds = 60, store = storage(), fetcher = async () => response() } = {}) { + let now = 100_000; + let calls = 0; + const cache = new ProviderRequestCache({ ttlSeconds, store, now: () => now }); + const options = { + partition: ["provider", "zh-CN", "CN"], + product: "weather", + validate: valid, + fetcher: async value => { + calls++; + return fetcher(value); + }, + }; + return { + cache, + options, + store, + fetch: (value = request, overrides = {}) => cache.fetch(value, { ...options, ...overrides }), + calls: () => calls, + setTime: value => { + now = value; + }, + }; +} + +test("six simultaneous consumers issue one native request and receive separate response objects", async () => { + let complete; + const state = setup({ + fetcher: () => + new Promise(resolve => { + complete = resolve; + }), + }); + const pending = Array.from({ length: 6 }, () => state.fetch()); + assert.equal(state.calls(), 1); + complete(response(undefined, { Test: "unchanged" })); + const results = await Promise.all(pending); + assert.equal(state.cache.stats.coalesced, 5); + results[0].headers.Test = "mutated"; + assert.equal(results[1].headers.Test, "unchanged"); + assert.equal(JSON.parse(results[5].body).temperature, 23); +}); + +test("a cache hit does not extend its lifetime and expiration refreshes once", async () => { + const state = setup(); + await state.fetch(); + state.setTime(159_999); + await state.fetch(); + assert.equal(state.calls(), 1); + state.setTime(160_000); + await Promise.all([state.fetch(), state.fetch(), state.fetch()]); + assert.equal(state.calls(), 2); +}); + +test("a new execution can reuse Loon storage without plaintext credentials or request URLs", async () => { + const first = setup(); + await first.fetch(); + const raw = first.store.value(); + for (const secret of ["test-secret", "header-secret", "provider.example", "Set-Cookie"]) assert.equal(raw.includes(secret), false); + const next = setup({ store: first.store }); + await next.fetch(); + assert.equal(next.calls(), 0); + assert.equal(next.cache.stats.persistentHits, 1); +}); + +test("six persisted products read one snapshot per execution and still recheck expiration", async () => { + const store = storage(); + let reads = 0, + now = 100000, + calls = 0; + const counted = { + read: () => { + reads++; + return store.read(); + }, + write: store.write, + }; + const options = { + product: "weather", + validate: valid, + fetcher: async () => { + calls++; + return response(); + }, + }; + const requests = Array.from({ length: 6 }, (_, i) => ({ ...request, url: `${request.url}&item=${i}` })); + const seed = new ProviderRequestCache({ store, now: () => now }); + for (const item of requests) await seed.fetch(item, options); + const next = new ProviderRequestCache({ store: counted, now: () => now }); + for (const item of requests) await next.fetch(item, options); + assert.equal(reads, 1); + assert.equal(calls, 6); + now += 60000; + await next.fetch(requests[0], options); + assert.equal(calls, 7); + assert.equal(reads, 2, "A successful refresh rereads storage before merging its write"); +}); + +test("a delayed execution merges another execution's newly persisted products", async () => { + const store = storage(); + const first = setup({ store }), + second = setup({ store }); + let complete; + const delayed = first.fetch(request, { + fetcher: () => + new Promise(resolve => { + complete = resolve; + }), + }); + await second.fetch({ ...request, url: `${request.url}&other=1` }); + complete(response()); + await delayed; + const next = setup({ store }); + await next.fetch(request); + await next.fetch({ ...request, url: `${request.url}&other=1` }); + assert.equal(next.calls(), 0, "A cached read snapshot cannot overwrite a later independent write"); +}); + +test("invalid persistent storage is parsed at most once until an actual write", async () => { + let reads = 0; + const cache = new ProviderRequestCache({ + store: { + read: () => { + reads++; + return "not JSON"; + }, + write: () => false, + }, + }); + for (let i = 0; i < 4; i++) + await assert.rejects( + cache.fetch( + { ...request, url: `${request.url}&item=${i}` }, + { + product: "weather", + validate: valid, + fetcher: async () => { + throw new Error("offline"); + }, + }, + ), + ); + assert.equal(reads, 1); +}); + +test("location, credential, host, language, product, and transport policy stay isolated", async () => { + const state = setup(); + await state.fetch(); + await state.fetch({ ...request, url: request.url.replace("31.2", "31.3") }); + await state.fetch({ ...request, url: request.url.replace("test-secret", "another-secret") }); + await state.fetch({ ...request, headers: { ...request.headers, "X-API-Key": "new-key" } }); + await state.fetch({ ...request, url: request.url.replace("provider.example", "other.example") }); + await state.fetch(request, { partition: ["provider", "en-US", "CN"] }); + await state.fetch(request, { product: "air-quality" }); + await state.fetch({ ...request, node: "another-policy" }); + assert.equal(state.calls(), 8); +}); + +test("header case and insertion order do not create duplicate requests", async () => { + const state = setup(); + await state.fetch(); + await state.fetch({ headers: { accept: "application/json", "x-api-key": "header-secret" }, url: request.url }); + assert.equal(state.calls(), 1); +}); + +test("zero TTL preserves pending-request coalescing and disables completed-result reuse", async () => { + const state = setup({ ttlSeconds: 0 }); + await Promise.all([state.fetch(), state.fetch()]); + assert.equal(state.calls(), 1); + await state.fetch(); + assert.equal(state.calls(), 2); + assert.equal(state.store.value(), undefined); +}); + +test("lowering the configured TTL invalidates previously longer-lived entries", async () => { + const state = setup({ ttlSeconds: 300 }); + await state.fetch(); + state.setTime(145_000); + const cache = new ProviderRequestCache({ store: state.store, ttlSeconds: 30, now: () => 145_000 }); + await cache.fetch(request, state.options); + assert.equal(state.calls(), 2); +}); + +test("minute products expire at 30 seconds even when general TTL is higher", async () => { + const state = setup({ ttlSeconds: 300 }); + await state.fetch(request, { maximumTTLSeconds: 30 }); + state.setTime(130_000); + await state.fetch(request, { maximumTTLSeconds: 30 }); + assert.equal(state.calls(), 2); +}); + +for (const [label, upstream] of [ + ["HTTP 429", response({ status: "ok" }, {}, 429)], + ["HTTP 500", response({ status: "ok" }, {}, 500)], + ["provider error", response({ status: "error" })], + ["partial provider failure", response({ status: "partial" })], + ["malformed JSON", { status: 200, body: "not JSON", headers: {} }], +]) { + test(`${label} is never cached and the next request can recover`, async () => { + let first = true; + const state = setup({ + fetcher: async () => { + if (first) { + first = false; + return upstream; + } + return response(); + }, + }); + await assert.rejects(state.fetch()); + assert.equal(state.store.value(), undefined); + await state.fetch(); + await state.fetch(); + assert.equal(state.calls(), 2); + }); +} + +test("a rejected shared request is removed so later calls retry normally", async () => { + const state = setup({ + fetcher: async () => { + throw new Error("native timeout"); + }, + }); + const failures = await Promise.allSettled([state.fetch(), state.fetch(), state.fetch()]); + assert.equal(state.calls(), 1); + assert.equal( + failures.every(item => item.status === "rejected"), + true, + ); + await state.fetch(request, { fetcher: async () => response() }); + assert.equal(state.cache.stats.requests, 2); +}); + +for (const headers of [{ "Cache-Control": "no-store" }, { "cache-control": "private, no-cache" }, { Vary: "*" }, { "Cache-Control": "max-age=0" }]) { + test(`upstream cache prohibition is respected: ${JSON.stringify(headers)}`, async () => { + const state = setup({ fetcher: async () => response(undefined, headers) }); + await state.fetch(); + await state.fetch(); + assert.equal(state.calls(), 2); + assert.equal(state.store.value(), undefined); + }); +} + +test("max-age and Age shorten the lifetime", async () => { + const state = setup({ fetcher: async () => response(undefined, { "Cache-Control": "max-age=20", Age: "15" }) }); + await state.fetch(); + state.setTime(104_999); + await state.fetch(); + state.setTime(105_000); + await state.fetch(); + assert.equal(state.calls(), 2); +}); + +for (const corrupt of ["{", "x".repeat(REQUEST_CACHE_LIMITS.storageCharacters + 1), JSON.stringify({ version: 99, entries: [] }), JSON.stringify({ version: 1, entries: [null, {}] })]) { + test(`invalid persistent data is ignored (${corrupt.length} characters)`, async () => { + const state = setup({ store: storage(corrupt) }); + await state.fetch(); + assert.equal(state.calls(), 1); + assert.equal(JSON.parse(state.store.value()).version, 1); + }); +} + +test("clock rollback invalidates cache timestamps", async () => { + const state = setup(); + await state.fetch(); + state.setTime(99_000); + await state.fetch(); + assert.equal(state.calls(), 2); +}); + +test("read and write failures do not prevent successful network responses", async () => { + const state = setup({ + store: { + read() { + throw new Error("unavailable"); + }, + write() { + throw new Error("full"); + }, + }, + }); + assert.equal(JSON.parse((await state.fetch()).body).status, "ok"); + await state.fetch(); + assert.equal(state.calls(), 1); +}); + +test("stored responses obey both entry count and serialized-size limits", async () => { + const state = setup({ fetcher: async () => response({ status: "ok", content: "x".repeat(70_000) }) }); + for (let index = 0; index < 20; index++) await state.fetch({ ...request, url: `${request.url}&city=${index}` }); + const raw = state.store.value(); + assert.ok(raw.length <= REQUEST_CACHE_LIMITS.storageCharacters); + assert.ok(JSON.parse(raw).entries.length <= REQUEST_CACHE_LIMITS.entries); + assert.ok(JSON.parse(raw).entries.length > 0); +}); + +test("cache-size overflow leaves a normal response usable but does not retain it", async () => { + const state = setup({ fetcher: async () => response({ status: "ok", content: "x".repeat(REQUEST_CACHE_LIMITS.bodyCharacters) }) }); + assert.ok((await state.fetch()).body.length > REQUEST_CACHE_LIMITS.bodyCharacters); + await state.fetch(); + assert.equal(state.calls(), 2); + assert.equal(state.store.value(), undefined); +}); + +test("binary and oversized provider responses are rejected without persisting them", async () => { + for (const body of [new Uint8Array([1, 2]), "x".repeat(REQUEST_CACHE_LIMITS.responseCharacters + 1)]) { + const state = setup({ fetcher: async () => ({ status: 200, body }) }); + await assert.rejects(state.fetch(), /Unsupported or oversized/); + assert.equal(state.store.value(), undefined); + } +}); + +test("distinct requests use at most three native slots and queued duplicates still coalesce", async () => { + const completions = []; + let active = 0; + let peak = 0; + const state = setup({ + fetcher: () => + new Promise(resolve => { + active++; + peak = Math.max(peak, active); + completions.push(() => { + active--; + resolve(response()); + }); + }), + }); + const pending = Array.from({ length: 10 }, (_, index) => state.fetch({ ...request, url: `${request.url}&city=${index}` })); + pending.push(state.fetch({ ...request, url: `${request.url}&city=9` })); + assert.equal(active, 3); + for (let done = 0; done < 10; done++) { + assert.ok(completions.length > 0); + completions.shift()(); + await new Promise(resolve => setImmediate(resolve)); + } + await Promise.all(pending); + assert.equal(peak, 3); + assert.equal(state.calls(), 10); + assert.equal(state.cache.stats.coalesced, 1); +}); + +test("pending-queue overflow fails without starting an extra native request", async () => { + const completions = []; + const state = setup({ fetcher: () => new Promise(resolve => completions.push(() => resolve(response()))) }); + const pending = Array.from({ length: REQUEST_CACHE_LIMITS.pending }, (_, index) => state.fetch({ ...request, url: `${request.url}&city=${index}` })); + await assert.rejects(state.fetch({ ...request, url: `${request.url}&city=overflow` }), /queue is full/); + for (const _pending of pending) { + completions.shift()(); + await new Promise(resolve => setImmediate(resolve)); + } + await Promise.all(pending); + assert.equal(state.calls(), REQUEST_CACHE_LIMITS.pending); +}); + +test("TTL input is bounded and invalid input falls back to 60 seconds", () => { + for (const value of [undefined, null, false, {}, "invalid", -1, Number.POSITIVE_INFINITY, "", " "]) assert.equal(requestCacheTTL(value), 60); + assert.equal(requestCacheTTL(0), 0); + assert.equal(requestCacheTTL("30"), 30); + assert.equal(requestCacheTTL(1000), 300); +}); + +test("requests with a method or a body that is not a read-only GET bypass the cache", async () => { + for (const extra of [{ method: "POST" }, { body: "payload" }, { bodyBytes: new Uint8Array([1]) }]) { + const state = setup(); + await Promise.all([state.fetch({ ...request, ...extra }), state.fetch({ ...request, ...extra })]); + assert.equal(state.calls(), 2); + assert.equal(state.store.value(), undefined); + } +}); diff --git a/tests/providerResilience.test.mjs b/tests/providerResilience.test.mjs new file mode 100644 index 00000000..c940b8e3 --- /dev/null +++ b/tests/providerResilience.test.mjs @@ -0,0 +1,411 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +globalThis.$loon = "simulated Loon"; +const persistent = new Map(); +globalThis.$persistentStore = { + read: key => persistent.get(key), + write: (value, key) => { + persistent.set(key, value); + return true; + }, +}; +const callbacks = []; +globalThis.$httpClient = { get: (request, callback) => callbacks.push({ request, callback }) }; +const [{ default: Cache }, { default: QWeather }, { default: resolveRegion, countryFromCoordinates }, { activeProviderAlerts, cloneWeatherProduct }, { default: utf8Length }, { Console }] = await Promise.all([ + import("../src/class/ProviderRequestCache.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/function/resolveWeatherRegion.mjs"), + import("../src/function/providerRecovery.mjs"), + import("../src/function/utf8Length.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; +const store = () => { + let raw; + return { + read: () => raw, + write: value => { + raw = value; + return true; + }, + value: () => raw, + }; +}; +const request = { url: "https://provider.example/31.2/121.5?token=fixture-secret", headers: { Authorization: "fixture-auth" } }; +const response = (status = 200, headers = {}) => ({ status, ok: status >= 200 && status < 300, headers, body: JSON.stringify({ status: "ok" }) }); +const options = { partition: ["provider", "zh-CN"], product: "now", validate: body => body.status === "ok" }; + +test("two transient failures cool down across Loon executions without persisting credentials", async () => { + let now = 100000, + calls = 0; + const failures = store(); + const make = () => new Cache({ now: () => now, store: store(), failureStore: failures, cooldownSeconds: 30 }); + const fetcher = async () => { + calls++; + throw new Error(`timeout ${request.url}`); + }; + for (let i = 0; i < 2; i++) await assert.rejects(make().fetch(request, { ...options, fetcher }), /timeout/); + await assert.rejects(make().fetch(request, { ...options, fetcher }), /cooldown/); + assert.equal(calls, 2); + assert.doesNotMatch(failures.value(), /fixture-secret|fixture-auth|provider\.example|31\.2/); + const changed = { ...request, headers: { Authorization: "other-account" } }; + await make().fetch(changed, { ...options, fetcher: async () => response() }); + now += 30000; + await make().fetch(request, { + ...options, + fetcher: async () => { + calls++; + return response(); + }, + }); + assert.equal(calls, 3); + await make().fetch(request, { ...options, fetcher: async () => response() }); +}); + +test("rate limits cool down immediately and Retry-After is bounded to 120 seconds", async () => { + let now = 100000, + calls = 0; + const failures = store(); + const make = () => new Cache({ now: () => now, store: store(), failureStore: failures, cooldownSeconds: 30 }); + await assert.rejects( + make().fetch(request, { + ...options, + fetcher: async () => { + calls++; + return response(429, { "Retry-After": "999999" }); + }, + }), + error => error.code === "rate_limit", + ); + now += 119999; + await assert.rejects(make().fetch(request, { ...options, fetcher: async () => response() }), /cooldown/); + now++; + await make().fetch(request, { + ...options, + fetcher: async () => { + calls++; + return response(); + }, + }); + assert.equal(calls, 2); +}); + +test("clock rollback and corrupt cooldown storage do not suppress future native requests", async () => { + const failures = store(); + failures.write(JSON.stringify({ version: 1, entries: [{ key: "a".repeat(64), at: 200000, until: 230000, count: 2 }] })); + let called = 0; + const cache = new Cache({ now: () => 100000, cooldownSeconds: 30, failureStore: failures, store: store() }); + await cache.fetch(request, { + ...options, + fetcher: async () => { + called++; + return response(); + }, + }); + assert.equal(called, 1); +}); + +test("queued work expires without cancelling active native callbacks or starting a late request", async () => { + let now = 100000; + const complete = []; + const sent = []; + const cache = new Cache({ now: () => now, budgetMs: 1000, store: store() }); + const pending = Array.from({ length: 4 }, (_, i) => + cache.fetch( + { ...request, url: `${request.url}&item=${i}` }, + { + ...options, + fetcher: req => { + sent.push(req); + return new Promise(resolve => complete.push(() => resolve(response()))); + }, + }, + ), + ); + const settled = Promise.allSettled(pending); + assert.equal(sent.length, 3); + assert.ok(sent.every(req => req.timeout === 1000)); + let finished = false; + settled.then(() => { + finished = true; + }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(finished, false); + now += 1000; + for (const done of complete) done(); + const result = await settled; + assert.equal(result.filter(r => r.status === "fulfilled").length, 3); + assert.equal(result[3].reason.code, "budget"); + assert.equal(sent.length, 3); + assert.equal(cache.stats.budgetSkips, 1); +}); + +test("per-product TTLs and the global zero switch retain distinct freshness limits", async () => { + let now = 100000, + calls = 0; + const cache = Cache.fromSettings({ RequestCache: { TTL: 60, ForecastTTL: 180, MinutelyTTL: 15, AlertsTTL: 15 } }, { now: () => now, budgetMs: 0, store: store(), failureStore: store() }); + const fetcher = async () => { + calls++; + return response(); + }; + for (const kind of ["current", "forecast", "minutely", "alerts"]) await cache.fetch({ ...request, url: `${request.url}&kind=${kind}` }, { ...options, kind, fetcher }); + now += 16000; + for (const kind of ["current", "forecast", "minutely", "alerts"]) await cache.fetch({ ...request, url: `${request.url}&kind=${kind}` }, { ...options, kind, fetcher }); + assert.equal(calls, 6); + now = 161000; + for (const kind of ["current", "forecast"]) await cache.fetch({ ...request, url: `${request.url}&kind=${kind}` }, { ...options, kind, fetcher }); + assert.equal(calls, 7); + const disabled = Cache.fromSettings({ RequestCache: { TTL: 0 } }, { budgetMs: 0, store: store() }); + let n = 0; + for (let i = 0; i < 2; i++) + await disabled.fetch(request, { + ...options, + kind: "forecast", + fetcher: async () => { + n++; + return response(); + }, + }); + assert.equal(n, 2); +}); + +test("eight cache slots are shared fairly by city while retaining a strict storage limit", async () => { + let now = 100000; + const storage = store(), + cache = new Cache({ now: () => now, store: storage }); + for (const city of ["first", "second"]) + for (let product = 0; product < 6; product++) { + now++; + await cache.fetch({ ...request, url: `${request.url}&city=${city}&product=${product}` }, { ...options, group: [city], fetcher: async () => response() }); + } + const entries = JSON.parse(storage.value()).entries; + assert.equal(entries.length, 8); + const counts = Object.values(Object.groupBy(entries, e => e.group)) + .map(a => a.length) + .sort(); + assert.deepEqual(counts, [4, 4]); + assert.ok(storage.value().length <= 256 * 1024); + assert.doesNotMatch(storage.value(), /first|second|fixture-auth/); +}); + +test("diagnostics are opt-in, emitted once, and contain only safe reasons and counters", async () => { + const messages = [], + original = console.log; + console.log = value => messages.push(value); + try { + const cache = Cache.fromSettings({ RequestCache: { Diagnostics: true } }, { store: store(), failureStore: store() }); + await cache.fetch(request, { ...options, fetcher: async () => response() }); + cache.noteFallback("currentWeather", "timeout"); + cache.report("response"); + cache.report("request"); + new Cache().report(); + } finally { + console.log = original; + } + assert.equal(messages.length, 1); + assert.match(messages[0], /currentWeather:timeout/); + assert.doesNotMatch(messages[0], /fixture-secret|fixture-auth|provider\.example|31\.2|121\.5/); +}); + +const html = '

Fixture City

Fixture warning

Issue date: 2026-10-19T01:00:00Z

'; +test("six default webpage warning consumers share one native request and reuse parsed records", async () => { + persistent.clear(); + callbacks.length = 0; + const runtime = Cache.fromSettings({}, { budgetMs: 0 }); + const all = Promise.all(Array.from({ length: 6 }, () => QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, runtime))); + assert.equal(callbacks.length, 1); + const call = callbacks.shift(); + assert.equal(call.request.timeout, 5000); + call.callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html); + const results = await all; + assert.equal(results.length, 6); + assert.ok(results.every(r => r.metadata.temporarilyUnavailable === false)); + const next = await QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, Cache.fromSettings({}, { budgetMs: 0 })); + assert.equal(next.metadata.temporarilyUnavailable, false); + assert.equal(callbacks.length, 0); + const raw = [...persistent.values()].join(""); + assert.doesNotMatch(raw, /|fixture-101190110|severe-weather/); +}); + +test("an unrecognized webpage is unavailable, never a cached no-warning success", async () => { + persistent.clear(); + callbacks.length = 0; + const runtime = Cache.fromSettings({}, { budgetMs: 0 }); + const pending = QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, runtime); + callbacks.shift().callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, "Access denied"); + const result = await pending; + assert.equal(result.metadata.temporarilyUnavailable, true); + assert.equal(runtime.stats.stores, 0); +}); + +test("expired and invalid third-party alerts are excluded without altering unknown-expiry alerts", () => { + assert.deepEqual( + activeProviderAlerts([{ id: 1, expireTime: "2000-01-01T00:00:00Z" }, { id: 2, expireTime: "2099-01-01T00:00:00Z" }, { id: 3 }, { id: 4, expireTime: "bad" }], Date.parse("2026-10-19T00:00:00Z")).map(a => a.id), + [2, 3], + ); +}); + +test("conservative polygons fix the domestic-coordinate/US-locale case without rectangular border guesses", () => { + assert.equal(countryFromCoordinates(32.39, 119.499), "CN"); + assert.equal(countryFromCoordinates(25.04, 121.56), "TW"); + assert.equal(countryFromCoordinates(22.4, 114.12), "HK"); + for (const p of [ + [47.92, 106.92], + [27.7, 85.3], + [35.68, 139.69], + [0, 0], + [91, 0], + ]) + assert.equal(countryFromCoordinates(...p), undefined); + assert.equal(resolveRegion({ latitude: 32.39, longitude: 119.499, localeCountry: "US" }), "CN"); + assert.equal(resolveRegion({ latitude: 32.39, longitude: 119.499, explicitCountry: "US", localeCountry: "CN" }), "US"); + assert.equal(resolveRegion({ latitude: 32.39, longitude: 119.499, fallback: "Locale", localeCountry: "US" }), "US"); +}); + +test("explicit per-coordinate hints are bounded, hashed, and remain separate between cities", () => { + const storage = store(); + const original = { latitude: 22.16, longitude: 113.56, localeCountry: "US", store: storage, now: () => 100000 }; + assert.equal(resolveRegion({ ...original, explicitCountry: "MO" }), "MO"); + assert.equal(resolveRegion(original), "MO"); + assert.doesNotMatch(storage.value(), /22\.16|113\.56/); + assert.equal(resolveRegion({ ...original, latitude: 35.68, longitude: 139.69 }), "US"); + assert.equal(resolveRegion({ ...original, now: () => 100000 + 7 * 86400000 }), "US"); +}); + +test("product snapshots preserve binary values and isolate partial mutations before a failed replacement", () => { + const original = { metadata: { providerName: "Apple" }, days: [{ v: 1 }], bytes: new Uint8Array([1, 2]) }, + copy = cloneWeatherProduct(original); + copy.days[0].v = 2; + copy.bytes[0] = 9; + assert.equal(original.days[0].v, 1); + assert.equal(original.bytes[0], 1); + assert.ok(copy.bytes instanceof Uint8Array); +}); + +test("UTF-8 body bounds work without TextEncoder and stop once the limit is exceeded", () => { + for (const value of ["abc", "天气", "😀", "\ud800", "a😀天"]) assert.equal(utf8Length(value), Buffer.byteLength(value)); + assert.ok(utf8Length("天".repeat(1000), 100) > 100); +}); + +test("actual response processing preserves Apple products on provider failure and waits for every native callback", async () => { + persistent.clear(); + callbacks.length = 0; + const saved = globalThis.$argument; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["currentWeather", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] }, Region: { Fallback: "Auto" }, RequestCache: { BudgetMs: 8000, CooldownSeconds: 30 } }; + try { + const [{ default: WeatherKit2 }, { Response }, { ByteBuffer }] = await Promise.all([import("../src/class/WeatherKit2.mjs"), import("../src/process/Response.mjs"), import("flatbuffers")]); + const raw = WeatherKit2.encode(undefined, { + currentWeather: { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + temperature: 27, + precipitationAmountNext1hByType: [], + precipitationAmountNext24hByType: [], + precipitationAmountNext6hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious24hByType: [], + precipitationAmountPrevious6hByType: [], + }, + forecastHourly: { metadata: { providerName: "Apple", temporarilyUnavailable: false }, hours: [] }, + }); + const before = WeatherKit2.decode(new ByteBuffer(raw), ["currentWeather", "forecastHourly"]); + assert.equal(before.currentWeather?.temperature, 27); + const pending = Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); + let settled = false; + pending.then(() => { + settled = true; + }); + assert.equal(callbacks.length, 2); + callbacks.shift().callback("timeout fixture-secret", {}, ""); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false); + callbacks.shift().callback("timeout fixture-secret", {}, ""); + const result = await pending; + const after = WeatherKit2.decode(new ByteBuffer(result.body), ["currentWeather", "forecastHourly"]); + assert.deepEqual(after, before); + assert.deepEqual(Array.from(result.body), Array.from(raw)); + assert.equal(after.currentWeather.temperature, 27); + } finally { + globalThis.$argument = saved; + } +}); + +test("backup queries require a configured dedicated credential and never replace valid Apple data", async () => { + const { default: recover } = await import("../src/function/recoverConfiguredProduct.mjs"); + let calls = 0; + const value = { metadata: { providerName: "QWeather", temporarilyUnavailable: false }, temperature: 20 }, + failed = { metadata: { temporarilyUnavailable: true } }; + const loaders = { + QWeather: async () => { + calls++; + return value; + }, + }; + const runtime = new Cache(); + assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, {}, runtime), failed); + assert.equal(calls, 0); + assert.equal(await recover("currentWeather", failed, { metadata: { providerName: "Apple" } }, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), failed); + assert.equal(calls, 0); + assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), value); + assert.equal(calls, 1); + assert.equal(runtime.stats.backupSuccesses, 1); + assert.equal(await recover("currentWeather", undefined, undefined, "WeatherKit", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), undefined); + assert.equal(calls, 1); +}); + +test("sequential requests cannot mutate shared defaults or inherit another credential", async () => { + const [{ default: setENV }, { default: database }] = await Promise.all([import("../src/function/setENV.mjs"), import("../src/function/database.mjs")]); + const token = database.WeatherKit.Settings.API.QWeather.Token, + saved = globalThis.$argument; + try { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", API: { QWeather: { Token: "dedicated-fixture" } } }; + const a = setENV("iRingo", "WeatherKit", database); + assert.equal(a.Settings.API.QWeather.Token, "dedicated-fixture"); + assert.equal(database.WeatherKit.Settings.API.QWeather.Token, token); + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; + const b = setENV("iRingo", "WeatherKit", database); + assert.equal(b.Settings.API.QWeather.Token, token); + assert.notEqual(a.Settings.API, b.Settings.API); + } finally { + globalThis.$argument = saved; + } +}); + +test("bounded fallback encoder matches Unicode replacement behavior without host TextEncoder", async () => { + const countSource = (await readFile(new URL("../src/function/utf8Length.mjs", import.meta.url), "utf8")).replace("export default function", "function"); + const encoderSource = (await readFile(new URL("../src/function/ensureTextEncoder.mjs", import.meta.url), "utf8")).replace(/^import .+$/m, ""); + const context = vm.createContext({}); + vm.runInContext(`${countSource}\n${encoderSource}`, context); + for (const value of ["", "ASCII", "天气雨雪🌦️", "a\ud800b\udc00c", "\ud83c\udf27\ud83c\udf08", "\u0000\u07ff\u0800\uffff"]) { + const actual = vm.runInContext(`new TextEncoder().encode(${JSON.stringify(value)})`, context); + assert.deepEqual(Array.from(actual), Array.from(new TextEncoder().encode(value))); + } + assert.throws(() => vm.runInContext('new TextEncoder().encode("x".repeat(2 * 1024 * 1024 + 1))', context), /encoding limit/); + const nativeContext = vm.createContext({ TextEncoder }); + vm.runInContext(`${countSource}\n${encoderSource}`, nativeContext); + assert.equal(nativeContext.TextEncoder, TextEncoder); +}); + +test("missing Apple daily and hourly roots can receive available provider forecasts", { timeout: 2000 }, async () => { + const [{ Response }, { default: WeatherKit2 }, { ByteBuffer }] = await Promise.all([import("../src/process/Response.mjs"), import("../src/class/WeatherKit2.mjs"), import("flatbuffers")]); + const savedArgument = globalThis.$argument, + daily = QWeather.prototype.Daily, + hourly = QWeather.prototype.Hourly; + const metadata = { providerName: "QWeather", temporarilyUnavailable: false }; + try { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["forecastDaily", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] }, Region: { Fallback: "Auto" } }; + QWeather.prototype.Daily = async () => ({ metadata, days: [{ forecastStart: 1792368000, temperatureMax: 29, precipitationAmountByType: [] }] }); + QWeather.prototype.Hourly = async () => ({ metadata, hours: [{ forecastStart: 1792368000, temperature: 25 }] }); + const raw = WeatherKit2.encode(undefined, {}); + const result = await Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=forecastDaily,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); + const decoded = WeatherKit2.decode(new ByteBuffer(result.body), ["forecastDaily", "forecastHourly"]); + assert.equal(decoded.forecastDaily.days[0].temperatureMax, 29); + assert.equal(decoded.forecastHourly.hours[0].temperature, 25); + } finally { + globalThis.$argument = savedArgument; + QWeather.prototype.Daily = daily; + QWeather.prototype.Hourly = hourly; + } +}); diff --git a/tests/providerTransportGuards.test.mjs b/tests/providerTransportGuards.test.mjs new file mode 100644 index 00000000..2e37fe7f --- /dev/null +++ b/tests/providerTransportGuards.test.mjs @@ -0,0 +1,142 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +import Cache from "../src/class/ProviderRequestCache.mjs"; +const request = { url: "https://provider.example/realtime", headers: { Authorization: "fixture-private" } }; +const successful = (body = { status: "ok", temperature: 23 }) => ({ status: 200, ok: true, headers: {}, body: JSON.stringify(body) }); +const storage = () => { + let value; + return { + read: () => value, + write: next => { + value = next; + return true; + }, + value: () => value, + }; +}; +const valid = body => body.status === "ok"; + +for (const [status, code] of [ + [401, "auth"], + [403, "auth"], + [429, "rate_limit"], + [500, "http"], + [302, "http"], + [undefined, "invalid"], +]) { + test(`HTTP ${status} cannot deliver a success-looking body to weather adapters`, async () => { + const cache = new Cache({ store: storage(), failureStore: storage() }); + await assert.rejects(cache.fetch(request, { validate: valid, fetcher: async () => ({ ...successful(), status, ok: false }) }), error => error.code === code); + assert.equal(cache.stats.failures, 1); + }); +} + +test("coalesced failed HTTP responses count once and never reach any of six consumers", async () => { + let calls = 0; + const cache = new Cache({ store: storage(), failureStore: storage() }); + const pending = Array.from({ length: 6 }, () => + cache.fetch(request, { + validate: valid, + fetcher: async () => { + calls++; + return { ...successful(), status: 500 }; + }, + }), + ); + const result = await Promise.allSettled(pending); + assert.equal(calls, 1); + assert.ok(result.every(item => item.status === "rejected" && item.reason.code === "http")); + assert.equal(cache.stats.failures, 1); +}); + +for (const order of ["alerts-first", "current-first"]) { + test(`coalesced callers validate their own product independently (${order})`, async () => { + const body = { status: "ok", current: { temperature: 23 }, alerts: { status: "error" } }; + const cache = new Cache({ store: storage(), failureStore: storage() }); + let calls = 0; + const fetcher = async () => { + calls++; + return successful(body); + }; + const current = () => cache.fetch(request, { product: "realtime", kind: "current", validate: value => value.current?.temperature === 23, fetcher }); + const alerts = () => cache.fetch(request, { product: "realtime", kind: "alerts", validate: value => value.alerts?.status === "ok", fetcher }); + const results = await Promise.allSettled(order === "alerts-first" ? [alerts(), current()] : [current(), alerts()]); + const [alertResult, currentResult] = order === "alerts-first" ? results : results.toReversed(); + assert.equal(alertResult.status, "rejected"); + assert.equal(alertResult.reason.code, "invalid"); + assert.equal(currentResult.status, "fulfilled"); + assert.equal(JSON.parse(currentResult.value.body).current.temperature, 23); + assert.equal(calls, 1); + assert.equal(cache.stats.failures, 1); + }); +} + +test("uncached credentials participate in cooldown without persisting or sharing token responses", async () => { + const failures = storage(), + responses = storage(); + let calls = 0; + const make = () => new Cache({ store: responses, failureStore: failures, cooldownSeconds: 30 }); + const auth = { ...request, body: "token=account-one-private&id=123" }; + const options = { + cacheable: false, + validate: valid, + fetcher: async () => { + calls++; + return { ...successful(), status: 401 }; + }, + }; + await assert.rejects(make().fetch(auth, options), error => error.code === "auth"); + await assert.rejects(make().fetch(auth, options), error => error.code === "cooldown"); + assert.equal(calls, 1); + await make().fetch( + { ...auth, body: "token=account-two-private&id=123" }, + { + ...options, + fetcher: async () => { + calls++; + return successful(); + }, + }, + ); + assert.equal(calls, 2); + assert.equal(responses.value(), undefined); + assert.doesNotMatch(failures.value(), /account-one-private|account-two-private|fixture-private|provider\.example/); +}); + +test("each coalesced consumer enforces its own response-size boundary", async () => { + let calls = 0; + const cache = new Cache({ store: storage(), failureStore: storage() }); + const fetcher = async () => { + calls++; + return successful({ status: "ok", text: "x".repeat(2048) }); + }; + const result = await Promise.allSettled([cache.fetch(request, { validate: valid, fetcher, maximumResponseCharacters: 1024 }), cache.fetch(request, { validate: valid, fetcher, maximumResponseCharacters: 4096 })]); + assert.equal(calls, 1); + assert.equal(result[0].status, "rejected"); + assert.equal(result[0].reason.code, "invalid"); + assert.equal(result[1].status, "fulfilled"); +}); + +test("semantic failure cooldown for alerts does not suppress healthy current weather", async () => { + const failures = storage(); + const cache = new Cache({ ttlSeconds: 0, store: storage(), failureStore: failures, cooldownSeconds: 30 }); + let calls = 0; + const fetcher = async () => { + calls++; + return successful({ current: true, alerts: false }); + }; + for (let i = 0; i < 2; i++) await assert.rejects(cache.fetch(request, { product: "realtime", kind: "alerts", validate: value => value.alerts, fetcher }), error => error.code === "invalid"); + await assert.rejects(cache.fetch(request, { product: "realtime", kind: "alerts", validate: value => value.alerts, fetcher }), error => error.code === "cooldown"); + await cache.fetch(request, { product: "realtime", kind: "current", validate: value => value.current, fetcher }); + assert.equal(calls, 3); +}); + +test("a conditional 304 can reuse its existing grid without being treated as provider failure", async () => { + const cache = new Cache({ store: storage(), failureStore: storage(), cooldownSeconds: 30 }); + const result = await cache.fetch(request, { cacheable: false, allowNotModified: true, format: "text", validate: () => true, fetcher: async () => ({ status: 304, ok: false, headers: {}, body: "" }) }); + assert.equal(result.status, 304); + assert.equal(cache.stats.failures, 0); + assert.equal(cache.stats.stores, 0); + await assert.rejects(cache.fetch(request, { cacheable: false, format: "text", validate: () => true, fetcher: async () => ({ status: 304, ok: false, headers: {}, body: "" }) }), error => error.code === "http"); +}); diff --git a/tests/responseJSONV3.test.mjs b/tests/responseJSONV3.test.mjs new file mode 100644 index 00000000..a9e47814 --- /dev/null +++ b/tests/responseJSONV3.test.mjs @@ -0,0 +1,284 @@ +// Synthetic protocol fixtures; never commit captured phone responses or credentials. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; +const [{ Response }, { Response: ResponseDev }, { default: QWeather }, { default: ColorfulClouds }, { default: ResponseJSONV3, adaptV3ProviderProduct, validateWeatherJSONV3, adaptV3ClientProduct }] = await Promise.all([ + import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("../src/class/QWeather.mjs"), import("../src/class/ColorfulClouds.mjs"), import("../src/process/ResponseJSONV3.mjs"), +]); +const { qs } = await import("@nsnanocat/util/polyfill/qs.mjs"); +const metadata = () => ({ latitude: 31.2, longitude: 121.5, temporarilyUnavailable: false, sourceType: "MODELED", futureMetadata: true }); +const current = () => ({ metadata: metadata(), temperature: 18, conditionCode: "CLOUDY", futureCurrent: { retain: true } }); +const request = (products = "currentWeather") => globalThis.$request = { url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=${products}`, method: "GET" }; +const response = body => ({ status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ETag: "apple", "cOnTeNt-LeNgTh": "1", "cache-control": "max-age=60", "X-Apple": "retain" }, body: typeof body === "string" ? body : JSON.stringify(body) }); +const settings = overrides => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather,forecastHourly,forecastDaily,forecastNextHour,weatherAlerts", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "WeatherKit", ...overrides }); +const allPollutants = () => [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); +const airQualityProduct = pollutants => ({ metadata: { ...metadata(), providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants }); + +test("native Weather JSON uses string conditions and scalar amounts, including unselected periodic forecasts", async () => { + settings({ DataSets: "currentWeather", "Weather.Provider": "WeatherKit" }); + const body = { + currentWeather: { ...current(), conditionCode: ["CLOUDY", "BREEZY"], snowfallAmountPrevious1h: { expected: 2, minimum: 1, maximum: 3 } }, + forecastDaily: { metadata: metadata(), days: [{ conditionCode: ["RAIN"], precipitationType: ["RAIN"], precipitationAmountByType: [{ expected: 3, minimum: 1, maximum: 4, precipitationType: ["RAIN"] }, { expected: 1, minimum: 0, maximum: 2, precipitationType: ["SNOW"] }] }] }, + forecastPeriodic: { metadata: metadata(), forecasts: [{ periods: [{ conditionCode: ["RAIN"], precipitationAmountByType: [] }] }] }, + futureProduct: { conditionCode: ["DO_NOT_CHANGE"], opaque: true }, + }; + const original = structuredClone(body); + const result = await ResponseJSONV3(request("currentWeather"), response(body), {}); + const actual = JSON.parse(result.body); + assert.equal(actual.currentWeather.conditionCode, "CLOUDY"); + assert.equal(actual.currentWeather.snowfallAmountPrevious1h, 2); + assert.equal(actual.forecastDaily.days[0].precipitationAmount, 4); + assert.equal(actual.forecastDaily.days[0].precipitationType, "RAIN"); + assert.equal(actual.forecastDaily.days[0].precipitationAmountByType[0].minimum, 1); + assert.equal(actual.forecastPeriodic.forecasts[0].periods[0].conditionCode, "RAIN"); + assert.equal(actual.forecastPeriodic.forecasts[0].periods[0].precipitationAmount, 0); + assert.deepEqual(actual.futureProduct, body.futureProduct); + assert.deepEqual(body, original); + assert.equal(result.headers["Cache-Control"], "no-store"); +}); + +test("native JSON adaptation preserves existing totals and condition-record vectors and is idempotent", () => { + const product = { precipitationAmount: 9, precipitationAmountByType: [{ expected: 4 }], condition: [{ beginCondition: "CLEAR", endCondition: "RAIN" }], summary: [{ condition: ["RAIN"] }] }; + assert.equal(adaptV3ClientProduct(product), true); + assert.equal(product.precipitationAmount, 9); + assert.deepEqual(product.condition, [{ beginCondition: "CLEAR", endCondition: "RAIN" }]); + assert.equal(product.summary[0].condition, "RAIN"); + assert.equal(adaptV3ClientProduct(product), false); + validateWeatherJSONV3(product); + const emptyConditions = { condition: [], summary: [] }; + assert.equal(adaptV3ClientProduct(emptyConditions), false); + assert.deepEqual(emptyConditions.condition, []); +}); + +test("native JSON adaptation never invents a total from missing or invalid precipitation measurements", () => { + for (const amounts of [[{}], [{ expected: NaN }], [{ expected: -1 }]]) { + const product = { precipitationAmountByType: amounts }; + adaptV3ClientProduct(product); + assert.equal(product.precipitationAmount, undefined); + } +}); + +for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { + test(`${name}: native Apple QWeather CO is correctly displayed in ug after ingress normalization`, async t => { + settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US" }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("Complete Apple concentrations require no extra query"); }); + const product = airQualityProduct(allPollutants()); + product.metadata.providerName = "QWeather"; + product.pollutants.find(p => p.pollutantType === "CO").amount = 0.7; + const actual = JSON.parse((await processResponse(request("airQuality"), response({ airQuality: product }))).body).airQuality; + assert.equal(actual.pollutants.find(p => p.pollutantType === "CO").amount, 700); + assert.equal(actual.pollutants.find(p => p.pollutantType === "CO").units, "MICROGRAMS_PER_CUBIC_METER"); + assert.equal(product.pollutants.find(p => p.pollutantType === "CO").amount, 0.7); + assert.equal(actual.index, 99); + }); + for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "None"]) { + test(`${name}: v3 ${algorithm} selects the requested algorithm on a complete non-CN Apple product`, async t => { + settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("Complete concentrations must not start provider requests"); }); + t.mock.method(QWeather.prototype, "CurrentAirQuality", () => { throw new Error("Complete concentrations must not start provider requests"); }); + const body = { airQuality: airQualityProduct(allPollutants()), futureProduct: { retained: true } }; + const result = await processResponse(request("airQuality"), response(body)); + const actual = JSON.parse(result.body).airQuality; + assert.equal(actual.pollutants.length, 6); + assert.ok(Number.isInteger(actual.index) && actual.index >= 0); + assert.ok(Number.isInteger(actual.categoryIndex) && actual.categoryIndex >= 1); + if (algorithm === "None") { + assert.equal(actual.index, 42); + assert.equal(actual.previousDayComparison, "SIMILAR"); + } else { + assert.match(actual.metadata.providerName, /iRingo/); + assert.equal(actual.previousDayComparison, "UNKNOWN", "Do not reuse a different algorithm's historical trend"); + assert.equal(result.headers["Cache-Control"], "no-store"); + } + assert.deepEqual(JSON.parse(result.body).futureProduct, body.futureProduct); + }); + } + test(`${name}: a partial pollutant array is supplemented without replacing valid Apple concentrations`, async t => { + settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + let calls = 0; + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { calls++; return { metadata: { providerName: "fixture", temporarilyUnavailable: false }, pollutants: allPollutants() }; }); + const body = { airQuality: airQualityProduct([{ ...allPollutants()[0], amount: 0 }, { ...allPollutants()[3], amount: null }]) }; + const result = JSON.parse((await processResponse(request("airQuality"), response(body))).body).airQuality; + assert.equal(calls, 1); + assert.equal(result.pollutants.length, 6); + assert.equal(result.pollutants.find(p => p.pollutantType === "PM2_5").amount, 0); + assert.equal(result.pollutants.find(p => p.pollutantType === "NO2").amount, 35); + assert.equal(result.scale, "CA.AQHI"); + }); + test(`${name}: a failed supplement and incomplete risk inputs retain the available Apple index`, async t => { + settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "HK_AQHI" }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { throw new Error("fixture provider unavailable"); }); + const body = { airQuality: airQualityProduct([allPollutants()[0]]) }; + const result = JSON.parse((await processResponse(request("airQuality"), response(body))).body).airQuality; + assert.equal(result.index, 42); + assert.equal(result.categoryIndex, 1); + assert.equal(result.scale, "EPA_NowCast"); + assert.equal(result.previousDayComparison, "SIMILAR"); + assert.equal(result.pollutants.length, 1); + }); + test(`${name}: v3 changes provider fields while retaining unknown Apple data and native JSON types`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture", latitude: "31.2", longitude: "121.5" }, temperature: 23, conditionCode: "CLEAR", windGust: NaN })); + t.mock.method(QWeather.prototype, "Hourly", async () => ({ metadata: { providerName: "Fixture" }, hours: [{ forecastStart: 100, temperature: 24, conditionCode: "RAIN", precipitationType: "RAIN", precipitationAmount: 0.4, snowfallAmount: 0 }] })); + const body = { currentWeather: current(), forecastHourly: { metadata: metadata(), hours: [100, 200].map(forecastStart => ({ forecastStart, temperature: 17, conditionCode: ["CLOUDY"], precipitationAmount: { expected: 0.1, minimum: 0, maximum: 0.2 }, newHourField: true })) }, futureProduct: { untouched: [1, 2] } }; + const original = JSON.stringify(body); + const result = await processResponse(request("currentWeather,forecastHourly"), response(body)); + const updated = JSON.parse(result.body); + assert.equal(updated.currentWeather.temperature, 23); + assert.equal(updated.currentWeather.conditionCode, "CLEAR"); + assert.equal(updated.currentWeather.metadata.latitude, 31.2); + assert.equal(updated.currentWeather.windGust, undefined); + assert.deepEqual(updated.currentWeather.futureCurrent, { retain: true }); + assert.equal(updated.currentWeather.metadata.futureMetadata, true); + assert.equal(updated.forecastHourly.hours[0].precipitationAmount, 0.4); + assert.equal(updated.forecastHourly.hours[0].conditionCode, "RAIN"); + assert.deepEqual(updated.forecastHourly.hours[1], { ...body.forecastHourly.hours[1], conditionCode: "CLOUDY", precipitationAmount: 0.1 }); + assert.deepEqual(updated.futureProduct, body.futureProduct); + assert.equal(JSON.stringify(body), original); + assert.deepEqual(result.headers, { "Content-Type": "application/json; charset=utf-8", "X-Apple": "retain", "Cache-Control": "no-store" }); + }); + test(`${name}: missing provider data and Apple-only settings retain exact original response`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => undefined); + const original = response(` ${JSON.stringify({ currentWeather: current() })}\n`); + assert.strictEqual(await processResponse(request(), original), original); + settings({ "Weather.Provider": "WeatherKit" }); + t.mock.method(ColorfulClouds.prototype, "CurrentWeather", () => { throw new Error("must not start"); }); + assert.strictEqual(await processResponse(request(), original), original); + }); + test(`${name}: v3 binary stays opaque rather than using a WK2 decoder`, async () => { + settings(); + const original = { ...response("opaque WK3"), headers: { "Content-Type": "application/vnd.apple.flatbuffer;messageType=WK3.Weather" } }; + assert.strictEqual(await processResponse(request(), original), original); + }); +} + +test("JSON provider adapter covers daily periods and minute summary without changing supported condition records", () => { + const value = adaptV3ProviderProduct({ metadata: { latitude: "31.2", longitude: "invalid" }, days: [{ conditionCode: "RAIN", precipitationType: "RAIN", snowfallAmount: 2, daytimeForecast: { conditionCode: "CLOUDY" } }], summary: [{ condition: "CLEAR" }], condition: [{ beginCondition: "CLEAR", endCondition: "RAIN", parameters: [] }] }); + assert.equal(value.days[0].daytimeForecast.conditionCode, "CLOUDY"); + assert.equal(value.days[0].snowfallAmount, 2); + assert.equal(value.summary[0].condition, "CLEAR"); + assert.equal(value.condition[0].beginCondition, "CLEAR"); + assert.equal(value.metadata.longitude, undefined); +}); + +test("v3 limits, invalid JSON, HTTP failures and unrelated URLs never start injectors", async () => { + settings(); + const good = { currentWeather: current() }; + const deep = current(); let tail = deep; + for (let i = 0; i < 66; i++) tail = tail.nested = {}; + const candidates = [response("{"), response(" ".repeat(1024 * 1024 + 1)), response({ currentWeather: { ...current(), huge: Array(8193).fill(0) } }), response({ currentWeather: deep }), response({ error: "not weather" }), { ...response(good), status: 503 }]; + let calls = 0; + const injectors = { currentWeather: () => { calls++; throw new Error("unexpected work"); } }; + for (const original of candidates) assert.strictEqual(await ResponseJSONV3(request(), original, injectors), original); + for (const url of [request().url.replace("weatherkit.apple.com", "example.com"), request().url.replace("/31.2/", "/91.2/")]) { + const original = response(good); + assert.strictEqual(await ResponseJSONV3({ url, method: "GET" }, original, injectors), original); + } + assert.equal(calls, 0); +}); + +test("a failed hourly product preserves its exact fields while a healthy current product changes", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23 })); + t.mock.method(QWeather.prototype, "Hourly", async () => { throw new Error("provider failed"); }); + const body = { currentWeather: current(), forecastHourly: { metadata: metadata(), hours: [{ forecastStart: 100, futureValue: [1, 2] }] } }; + const result = JSON.parse((await Response(request("currentWeather,forecastHourly"), response(body))).body); + assert.equal(result.currentWeather.temperature, 23); + assert.deepEqual(result.forecastHourly, body.forecastHourly); +}); + +test("v3 air-quality calculation uses symbolic enums and leaves unrelated Apple products intact", async () => { + settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "UBA", "AirQuality.Current.Index.Replace": "HJ6332012" }); + const body = { airQuality: { metadata: metadata(), scale: "HJ6332012", index: 50, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [{ pollutantType: "PM2_5", amount: 30, units: "MICROGRAMS_PER_CUBIC_METER" }, { pollutantType: "PM10", amount: 40, units: "MICROGRAMS_PER_CUBIC_METER" }] }, currentWeather: current(), futureProduct: { version: 3 } }; + const updated = JSON.parse((await Response(request("airQuality"), response(body))).body); + assert.notEqual(updated.airQuality.scale, "HJ6332012"); + assert.ok(Number.isFinite(updated.airQuality.index)); + assert.equal(typeof updated.airQuality.primaryPollutant, "string"); + assert.equal(updated.airQuality.pollutants[0].units, "MICROGRAMS_PER_CUBIC_METER"); + assert.deepEqual(updated.currentWeather, body.currentWeather); + assert.deepEqual(updated.futureProduct, body.futureProduct); +}); + +test("WK3 integer scalars are normalized without rounding continuous measurements", () => { + const product = adaptV3ProviderProduct({ metadata: { readTime: 100.9 }, hours: [{ cloudCover: 38.2, humidity: 80.7, precipitationChance: 32.5, windDirection: 183.8, uvIndex: 2.4, temperature: 17.38, pressure: 1013.42, windSpeed: 2.75 }] }); + validateWeatherJSONV3(product); + assert.deepEqual(product.hours[0], { cloudCover: 38, humidity: 81, precipitationChance: 33, windDirection: 184, uvIndex: 2, temperature: 17.38, pressure: 1013.42, windSpeed: 2.75 }); + assert.equal(product.metadata.readTime, 100); +}); + +test("bad provider integer fields keep Apple's values and obsolete bodyBytes are removed", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23.4, windDirection: -1, cloudCover: Infinity, humidity: 101, uvIndex: 2.7 })); + const original = { ...response({ currentWeather: { ...current(), windDirection: 90, cloudCover: 40, humidity: 80 } }), bodyBytes: new Uint8Array([1, 2]) }; + const result = await Response(request(), original); + const updated = JSON.parse(result.body).currentWeather; + assert.equal(updated.windDirection, 90); + assert.equal(updated.cloudCover, 40); + assert.equal(updated.humidity, 80); + assert.equal(updated.uvIndex, 3); + assert.equal(updated.temperature, 23.4); + assert.equal(result.bodyBytes, undefined); + assert.ok(original.bodyBytes); +}); + +test("a nonnumeric integer or malformed vector rejects only its replacement product", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23, windDirection: "bad" })); + t.mock.method(QWeather.prototype, "Hourly", async () => ({ metadata: { providerName: "Fixture" }, hours: [{ forecastStart: 100, temperature: 24, conditionCode: "RAIN", precipitationChance: 32.5 }] })); + const body = { currentWeather: current(), forecastHourly: { metadata: metadata(), hours: [{ forecastStart: 100, temperature: 17 }] } }; + const result = JSON.parse((await Response(request("currentWeather,forecastHourly"), response(body))).body); + assert.deepEqual(result.currentWeather, body.currentWeather); + assert.equal(result.forecastHourly.hours[0].temperature, 24); + assert.equal(result.forecastHourly.hours[0].precipitationChance, 33); +}); + +test("invalid unsigned air-quality results cannot replace a healthy Apple product", async () => { + settings({ DataSets: "airQuality,currentWeather" }); + const body = { airQuality: { metadata: metadata(), index: 30, categoryIndex: 1 }, currentWeather: current() }; + const injectors = { airQuality: async input => ({ ...input, index: -1, categoryIndex: -1 }), currentWeather: async input => ({ ...input, temperature: 23 }) }; + const updated = JSON.parse((await ResponseJSONV3(request("airQuality,currentWeather"), response(body), injectors)).body); + assert.deepEqual(updated.airQuality, body.airQuality); + assert.equal(updated.currentWeather.temperature, 23); +}); + +test("WK3 open-ended minute forecasts omit WK2 zero timestamp sentinels", () => { + const product = adaptV3ProviderProduct({ metadata: { reportedTime: 0, readTime: 100 }, forecastStart: 0, forecastEnd: 300, condition: [{ startTime: 100, endTime: 0, beginCondition: "CLEAR", endCondition: "CLEAR", parameters: [] }], summary: [{ startTime: 100, endTime: 0, condition: "CLEAR", precipitationChance: 0, precipitationIntensity: 0 }] }); + validateWeatherJSONV3(product); + assert.equal(product.metadata.reportedTime, undefined); + assert.equal(product.condition[0].endTime, undefined); + assert.equal(product.summary[0].endTime, undefined); + assert.equal(product.forecastStart, 0); + assert.equal(product.summary[0].precipitationIntensity, 0); +}); + +test("undefined provider values do not erase available Apple fields", async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { providerName: "Fixture" }, temperature: 23, windDirection: undefined, humidity: null })); + const original = { currentWeather: { ...current(), humidity: 80, windDirection: 90 } }; + const updated = JSON.parse((await Response(request(), response(original))).body).currentWeather; + assert.equal(updated.temperature, 23); + assert.equal(updated.humidity, 80); + assert.equal(updated.windDirection, 90); +}); + +test("unserializable values and malformed Unicode reject one product without discarding healthy replacements", async () => { + settings({ DataSets: "weatherAlerts,currentWeather" }); + const body = { weatherAlerts: { metadata: metadata(), alerts: [{ description: "Apple warning" }] }, currentWeather: current() }; + for (const invalid of ["bad\ud800", 1n, [undefined], [null], [1e100]]) { + const injectors = { weatherAlerts: async input => ({ ...input, futureProviderField: invalid }), currentWeather: async input => ({ ...input, temperature: 23 }) }; + const updated = JSON.parse((await ResponseJSONV3(request("weatherAlerts,currentWeather"), response(body), injectors)).body); + assert.deepEqual(updated.weatherAlerts, body.weatherAlerts); + assert.equal(updated.currentWeather.temperature, 23); + } + assert.doesNotThrow(() => validateWeatherJSONV3({ description: "雨 🌧️" })); +}); + +test("a null replacement product keeps the complete original response", async () => { + settings({ DataSets: "currentWeather" }); + const original = response({ currentWeather: current() }); + assert.strictEqual(await ResponseJSONV3(request(), original, { currentWeather: async () => null }), original); +}); diff --git a/tests/responseMemoryPaths.test.mjs b/tests/responseMemoryPaths.test.mjs new file mode 100644 index 00000000..2eea6fea --- /dev/null +++ b/tests/responseMemoryPaths.test.mjs @@ -0,0 +1,118 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "QWeatherWeb" }; +const [{ Response }, { Response: ResponseDev }, { default: WeatherKit2 }, { default: QWeather }, WK, { ByteBuffer }, fixtures] = await Promise.all([ + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("../src/class/QWeather.mjs"), + import("@nsringo/weatherkit"), + import("flatbuffers"), + import("./fixtures/processingMemoryFixture.mjs"), +]); +const request = products => { + const value = { url: `https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.39/119.499?country=CN&dataSets=${products}`, method: "GET", headers: {} }; + globalThis.$request = value; + return value; +}; +const response = body => ({ status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body }); + +for (const [name, processResponse] of [ + ["release", Response], + ["development", ResponseDev], +]) { + test(`${name}: failed hourly replacement preserves its future binary fields when current weather changes`, async () => { + const raw = fixtures.futureHourlyFixture(), + original = raw.slice(); + const savedNow = QWeather.prototype.WeatherNow, + savedHourly = QWeather.prototype.Hourly, + encode = WeatherKit2.encode; + let patchKeys; + QWeather.prototype.WeatherNow = async () => ({ metadata: { providerName: "Fixture", temporarilyUnavailable: false }, temperature: 29 }); + QWeather.prototype.Hourly = async () => undefined; + WeatherKit2.encode = function (source, patch) { + patchKeys = Object.keys(patch); + return encode.call(this, source, patch); + }; + try { + const result = await processResponse(request("currentWeather,forecastHourly"), response(raw)); + assert.deepEqual(patchKeys, ["currentWeather"]); + assert.equal(fixtures.readFutureHourly(result.body), fixtures.futureHourlyText); + assert.equal(WeatherKit2.decode(new ByteBuffer(result.body), ["currentWeather"]).currentWeather.temperature, 29); + assert.deepEqual(raw, original); + } finally { + QWeather.prototype.WeatherNow = savedNow; + QWeather.prototype.Hourly = savedHourly; + WeatherKit2.encode = encode; + } + }); + + for (const success of [false, true]) + test(`${name}: alerts are copied only after a usable provider result (${success})`, async () => { + const raw = fixtures.weatherFixture(0, 3), + originalBytes = raw.slice(); + const decode = WeatherKit2.decode, + savedWeb = QWeather.prototype.WeatherAlertWeb; + let readAlerts = 0, + original, + complete; + WeatherKit2.decode = function (...args) { + const data = decode.apply(this, args); + original = data.weatherAlerts; + const alerts = original.alerts; + Object.defineProperty(original, "alerts", { + enumerable: true, + get() { + readAlerts++; + return alerts; + }, + }); + return data; + }; + QWeather.prototype.WeatherAlertWeb = () => + new Promise(resolve => { + complete = resolve; + }); + try { + const pending = processResponse(request("weatherAlerts"), response(raw)); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(typeof complete, "function"); + assert.equal(readAlerts, 0, "Pending callbacks must not retain a deep copy of alerts"); + complete(success ? { metadata: { providerName: "Fixture", temporarilyUnavailable: false }, alerts: [{ areaId: "0", description: "Updated warning", responses: [], guidelines: [] }] } : undefined); + const result = await pending; + if (success) { + assert.equal(decode.call(WeatherKit2, new ByteBuffer(result.body), ["weatherAlerts"]).weatherAlerts.alerts[0].description, "Updated warning"); + assert.equal(original.alerts[0].description, "Apple warning 0"); + assert.notDeepEqual(result.body, raw); + } else { + assert.equal(readAlerts, 0); + assert.strictEqual(result.body, raw); + } + assert.deepEqual(raw, originalBytes); + } finally { + WeatherKit2.decode = decode; + QWeather.prototype.WeatherAlertWeb = savedWeb; + } + }); +} + +test("alert decode uses one schema view per record and preserves the full decoded payload", () => { + const raw = fixtures.weatherFixture(0, 48); + const expected = WeatherKit2.decode(new ByteBuffer(raw), ["weatherAlerts"]); + const alerts = WK.WeatherAlertCollectionData.prototype.alerts; + let calls = 0; + WK.WeatherAlertCollectionData.prototype.alerts = function (...args) { + calls++; + return alerts.apply(this, args); + }; + try { + assert.deepEqual(WeatherKit2.decode(new ByteBuffer(raw), ["weatherAlerts"]), expected); + assert.equal(calls, 48); + } finally { + WK.WeatherAlertCollectionData.prototype.alerts = alerts; + } +}); diff --git a/tests/severeWeatherPage.test.mjs b/tests/severeWeatherPage.test.mjs index 2a050a2b..1971c745 100644 --- a/tests/severeWeatherPage.test.mjs +++ b/tests/severeWeatherPage.test.mjs @@ -44,7 +44,7 @@ const qWeatherAlertAPI = { issuedTime: "2026-08-02T09:48Z", effectiveTime: "2026-08-02T09:48Z", onsetTime: "2026-08-02T09:48Z", - expiresTime: "2026-08-03T09:48Z", + expiresTime: "2099-08-03T09:48Z", eventType: { name: "高温", code: "1009" }, severity: "severe", headline: "南京市气象台发布高温橙色预警信号。", @@ -159,7 +159,7 @@ test("response WeatherAlert injection only selects and merges provider slot obje assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.colorfulClouds\.WeatherAlert\(\)/); assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.qWeather\.WeatherAlert\(\)/); assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.qWeather\.WeatherAlertWeb\(weatherAlerts\?\.detailsUrl\)/); - assert.match(injectWeatherAlerts, /if \(newWeatherAlerts\?\.metadata\) \{[\s\S]*weatherAlerts\.metadata = \{ \.\.\.weatherAlerts\?\.metadata, \.\.\.newWeatherAlerts\.metadata \}/); + assert.match(injectWeatherAlerts, /if \(providerProductAvailable\(newWeatherAlerts\)\) \{[\s\S]*weatherAlerts\.metadata = \{ \.\.\.weatherAlerts\?\.metadata, \.\.\.newWeatherAlerts\.metadata \}/); assert.doesNotMatch(injectWeatherAlerts, /QWeather\.(?:ParseWeatherAlertPageURL|BuildAppleAlertDetailsURL|FetchWeatherAlertPage)|attributionUrl|Console\.debug/); } }); @@ -252,7 +252,7 @@ test("QWeather WeatherAlertWeb returns a complete top-level slot object", async const weatherAlerts = await new QWeather({ country: "CN", language: "zh-CN", latitude: "32.115", longitude: "118.814" }, "test-token").WeatherAlertWeb(url); assert.equal(sourceUrl.toString(), url); - assert.deepEqual(weatherAlerts.metadata, { attributionUrl: url }); + assert.deepEqual(weatherAlerts.metadata, { temporarilyUnavailable: false, attributionUrl: url }); assert.equal(weatherAlerts.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=jianye-101190110&lang=zh-CN&party=qweather"); assert.equal(weatherAlerts.alerts.length, 1); assert.equal(weatherAlerts.alerts[0].areaId, "101190110"); @@ -285,7 +285,7 @@ test("QWeather Alert API is standardized by QWeather class", async () => { const originalFetch = globalThis.fetch; let sourceRequest; globalThis.fetch = async (input, init) => { - const requestUrl = typeof input === "string" ? input : input?.url ?? input; + const requestUrl = typeof input === "string" ? input : (input?.url ?? input); sourceRequest = { url: new URL(requestUrl), headers: new Headers(init?.headers ?? input?.headers ?? {}) }; return new Response(JSON.stringify(qWeatherAlertAPI), { headers: { "Content-Type": "application/json" } }); }; @@ -308,7 +308,7 @@ test("QWeather Alert API is standardized by QWeather class", async () => { } assert.equal(extracted.source, "南京市气象台"); assert.equal(extracted.areaName, "南京市"); - assert.deepEqual(extracted.metadata, { attributionUrl: "https://developer.qweather.com/attribution.html" }); + assert.deepEqual(extracted.metadata, { temporarilyUnavailable: false, attributionUrl: "https://developer.qweather.com/attribution.html" }); assert.equal(extracted.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=32.115,118.814&lang=de&party=QWeather"); assert.equal(extracted.alerts.length, 1); assert.equal(extracted.alerts[0].areaId, "320100"); @@ -318,20 +318,15 @@ test("QWeather Alert API is standardized by QWeather class", async () => { assert.deepEqual(extracted.alerts[0].responses, ["monitor"]); assert.equal(extracted.alerts[0].effectiveTime, "2026-08-02T09:48:00.000Z"); assert.equal(extracted.alerts[0].eventOnsetTime, "2026-08-02T09:48:00.000Z"); - assert.equal(extracted.alerts[0].eventEndTime, "2026-08-03T09:48:00.000Z"); - assert.equal(extracted.alerts[0].expireTime, "2026-08-03T09:48:00.000Z"); + assert.equal(extracted.alerts[0].eventEndTime, "2099-08-03T09:48:00.000Z"); + assert.equal(extracted.alerts[0].expireTime, "2099-08-03T09:48:00.000Z"); assert.equal(extracted.alerts[0].phenomenon, "Met"); assert.equal(extracted.alerts[0].source, "南京市气象台"); assert.equal(extracted.alerts[0].token, "1009"); assert.equal(extracted.alerts[0].reportedAt, "2026-08-02T09:48:00.000Z"); assert.equal(extracted.alerts[0].message, "南京市气象台2026年08月02日17时44分继续发布高温橙色预警信号:预计明天全市大部分地区的日最高气温可达37℃以上,请注意防暑降温。"); assert.equal(extracted.alerts[0].standard, ""); - assert.deepEqual(extracted.alerts[0].guidelines, [ - "有关部门和单位按照职责落实防暑降温保障措施;", - "尽量避免在高温时段进行户外活动;", - "对老、弱、病、幼人群提供防暑降温指导;", - "高温条件下作业人员应当缩短连续工作时间。", - ]); + assert.deepEqual(extracted.alerts[0].guidelines, ["有关部门和单位按照职责落实防暑降温保障措施;", "尽量避免在高温时段进行户外活动;", "对老、弱、病、幼人群提供防暑降温指导;", "高温条件下作业人员应当缩短连续工作时间。"]); } finally { globalThis.fetch = originalFetch; } @@ -386,11 +381,56 @@ test("QWeather event codes map to CAP phenomena", async () => { test("all documented QWeather event codes map to CAP categories", async () => { const originalFetch = globalThis.fetch; const documentedCodeRanges = [ - [1001, 1069], [1071, 1082], [1084, 1089], [1201, 1219], [1221, 1221], [1241, 1251], [1271, 1274], [1601, 1610], [1701, 1710], [1801, 1805], - [2001, 2007], [2029, 2033], [2050, 2054], [2070, 2085], [2100, 2109], [2111, 2111], [2120, 2135], [2150, 2150], [2152, 2168], [2190, 2193], - [2200, 2205], [2207, 2221], [2300, 2309], [2311, 2328], [2330, 2333], [2341, 2341], [2343, 2343], [2345, 2346], [2348, 2400], [2409, 2409], - [2411, 2426], [2501, 2502], [2521, 2532], [2550, 2554], [2581, 2581], [2601, 2620], [2641, 2641], [2713, 2713], [2722, 2723], [2743, 2743], - [2749, 2749], [2751, 2753], [2755, 2756], [2791, 2797], [2801, 2804], [2839, 2853], [2873, 2874], [3101, 3107], [3131, 3148], [9999, 9999], + [1001, 1069], + [1071, 1082], + [1084, 1089], + [1201, 1219], + [1221, 1221], + [1241, 1251], + [1271, 1274], + [1601, 1610], + [1701, 1710], + [1801, 1805], + [2001, 2007], + [2029, 2033], + [2050, 2054], + [2070, 2085], + [2100, 2109], + [2111, 2111], + [2120, 2135], + [2150, 2150], + [2152, 2168], + [2190, 2193], + [2200, 2205], + [2207, 2221], + [2300, 2309], + [2311, 2328], + [2330, 2333], + [2341, 2341], + [2343, 2343], + [2345, 2346], + [2348, 2400], + [2409, 2409], + [2411, 2426], + [2501, 2502], + [2521, 2532], + [2550, 2554], + [2581, 2581], + [2601, 2620], + [2641, 2641], + [2713, 2713], + [2722, 2723], + [2743, 2743], + [2749, 2749], + [2751, 2753], + [2755, 2756], + [2791, 2797], + [2801, 2804], + [2839, 2853], + [2873, 2874], + [3101, 3107], + [3131, 3148], + [9999, 9999], ]; const documentedCodes = documentedCodeRanges.flatMap(([start, end]) => Array.from({ length: end - start + 1 }, (_, index) => String(start + index))); const categories = new Set(["Geo", "Met", "Safety", "Security", "Rescue", "Fire", "Health", "Env", "Transport", "Infra", "CBRNE", "Other"]); @@ -506,7 +546,7 @@ test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", as let requestCount = 0; globalThis.fetch = async (input, init) => { requestCount++; - const requestUrl = typeof input === "string" ? input : input?.url ?? input; + const requestUrl = typeof input === "string" ? input : (input?.url ?? input); sourceRequest = { url: new URL(requestUrl), headers: new Headers(init?.headers ?? input?.headers ?? {}) }; return new Response(JSON.stringify(colorfulCloudsRealtimeAPI), { headers: { "Content-Type": "application/json" } }); }; @@ -533,7 +573,7 @@ test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", as assert.equal(extracted.source, "国家预警信息发布中心"); assert.equal(extracted.areaName, "北京市海淀区"); - assert.deepEqual(extracted.metadata, { attributionUrl: "https://www.caiyunapp.com/h5" }); + assert.deepEqual(extracted.metadata, { temporarilyUnavailable: false, attributionUrl: "https://www.caiyunapp.com/h5" }); assert.equal(extracted.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=39.976,116.3176&lang=de&party=ColorfulClouds"); assert.equal(extracted.alerts.length, 1); assert.equal(extracted.alerts[0].areaId, "110108"); @@ -626,7 +666,7 @@ test("ColorfulClouds v3 CAP alerts remain available through WeatherAlertV3CAP", const originalFetch = globalThis.fetch; let sourceRequest; globalThis.fetch = async (input, init) => { - const requestUrl = typeof input === "string" ? input : input?.url ?? input; + const requestUrl = typeof input === "string" ? input : (input?.url ?? input); sourceRequest = { url: new URL(requestUrl), headers: new Headers(init?.headers ?? input?.headers ?? {}) }; return new Response(JSON.stringify(colorfulCloudsCAPAlertAPI), { headers: { "Content-Type": "application/json" } }); }; @@ -651,7 +691,7 @@ test("ColorfulClouds v3 CAP alerts remain available through WeatherAlertV3CAP", assert.equal(extracted.source, "NWS Los Angeles/Oxnard CA"); assert.equal(extracted.areaName, "Los Angeles"); - assert.deepEqual(extracted.metadata, { attributionUrl: "https://www.caiyunapp.com/h5" }); + assert.deepEqual(extracted.metadata, { temporarilyUnavailable: false, attributionUrl: "https://www.caiyunapp.com/h5" }); assert.equal(extracted.detailsUrl, "https://weatherkit.apple.com/alertDetails/index.html?ids=34.05,-118.25&lang=de&party=ColorfulClouds"); assert.equal(extracted.alerts.length, 1); assert.equal(extracted.alerts[0].areaId, "CAC037"); @@ -711,9 +751,7 @@ test("ColorfulClouds v3 CAP categories remain mapped to phenomena", async () => }); test("QWeather source extraction falls back to the English attribution label", () => { - const englishHtml = sourceHtml - .replace("建邺区气象台发布雷暴橙色预警信号。", "Thunderstorm orange warning.") - .replace("预警数据来源:国家预警信息发布中心", "Warning data source: National Early Warning Center"); + const englishHtml = sourceHtml.replace("建邺区气象台发布雷暴橙色预警信号。", "Thunderstorm orange warning.").replace("预警数据来源:国家预警信息发布中心", "Warning data source: National Early Warning Center"); assert.equal(QWeather.ExtractWeatherAlertPage(englishHtml).source, "National Early Warning Center"); }); @@ -916,7 +954,7 @@ test("the request scripts force QWeatherWeb for page identifiers regardless of t } }); -test("the request scripts return an empty Apple-compatible array when the QWeather fetch fails", async () => { +test("the request scripts pass through when the QWeather fetch fails", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { throw new Error("QWeather unavailable"); @@ -930,10 +968,7 @@ test("the request scripts return an empty Apple-compatible array when the QWeath headers: { "Accept-Language": "zh-CN" }, }); - assert.equal($response.status, 200); - assert.equal($response.statusCode, 200); - assert.equal($response.headers["Content-Type"], "application/json"); - assert.deepEqual(JSON.parse($response.body), []); + assert.equal($response, undefined); } } finally { globalThis.fetch = originalFetch; diff --git a/tests/severeWeatherRewriteModules.test.mjs b/tests/severeWeatherRewriteModules.test.mjs index 4b5150c3..6d87b8de 100644 --- a/tests/severeWeatherRewriteModules.test.mjs +++ b/tests/severeWeatherRewriteModules.test.mjs @@ -1,3 +1,4 @@ +// Modified by hhhoratioxu, 2026-10-19: retain existing argument ordering and include the cache setting. import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; @@ -90,8 +91,8 @@ test("script module templates hook Apple weatherAlerts without QWeather page red const templates = ["surge.handlebars", "loon.handlebars", "quantumultx.handlebars", "stash.handlebars"]; for (const filename of templates) { const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); - assert.ok(content.includes(weatherAlertsPageHandlerPattern), filename); - assert.ok(content.includes(weatherAlertsCoordinateHandlerPattern), filename); + assert.ok(content.includes(filename === "loon.handlebars" ? weatherAlertsPageHandlerPattern.replace("[^#]*&ids=", "(?:[^#]*&)?ids=") : weatherAlertsPageHandlerPattern), filename); + assert.ok(content.includes(filename === "loon.handlebars" ? weatherAlertsCoordinateHandlerPattern.replace("[^#]*&ids=", "(?:[^#]*&)?ids=") : weatherAlertsCoordinateHandlerPattern), filename); assert.ok(!content.includes(mergedWeatherAlertsIdentifierPattern), filename); assert.match(content, /api\\\/v1\\\/weatherAlerts\\\?/); assert.ok(!content.includes(unsafeOpenEndedQuantifier), filename); @@ -111,8 +112,8 @@ test("script module templates hook Apple weatherAlerts without QWeather page red assert.ok(surge.includes(`pattern="${weatherAlertsCoordinateHandlerPattern}"`)); assert.doesNotMatch(surge, /weatherAlerts\.(?:html|api)\.request[^\n]+pattern=\^https/); assert.doesNotMatch(surge, /weatherAlerts\.(?:html|api)\.request[^\n]+requires-body/); - assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.bundle\.js[^\n]+weatherAlerts\.html\.request/); - assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.bundle\.js[^\n]+weatherAlerts\.api\.request/); + assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.loon\.bundle\.js[^\n]+weatherAlerts\.html\.request/); + assert.match(loon, /http-request [^\n]+weatherAlerts[^\n]+request\.loon\.bundle\.js[^\n]+weatherAlerts\.api\.request/); assert.match(quantumultX, /weatherAlerts[^\n]+url script-echo-response[^\n]+request\.bundle\.js/); assert.doesNotMatch(quantumultX, /weatherAlerts[^\n]+script-request-header/); assert.match(stash, /match: [^\n]+weatherAlerts[\s\S]*?name: WeatherKit\.api\.v1\.weatherAlerts\.request[\s\S]*?type: request/); @@ -207,7 +208,7 @@ test("Dev builds use dev templates, bundles, and output names", async () => { ...devTemplates.map(filename => readFile(new URL(`../template/${filename}`, import.meta.url), "utf8")), ]); - for (const output of ["sgmodule", "plugin", "snippet", "stoverride", "boxjs.json"]) { + for (const output of ["sgmodule", "lpx", "snippet", "stoverride", "boxjs.json"]) { assert.ok(config.includes(`iRingo.WeatherKit.dev.${output}`), output); } for (const template of devTemplates) { @@ -221,7 +222,7 @@ test("Dev builds use dev templates, bundles, and output names", async () => { assert.doesNotMatch(rollup, /\.\/dist\/(?:request|response)\.bundle\.js/); assert.match(sgmoduleTools, /\.\/dist\/response\.dev\.bundle\.js/); - for (const output of ["request.dev.bundle.js", "response.dev.bundle.js", "iRingo.WeatherKit.dev.sgmodule", "iRingo.WeatherKit.dev.plugin", "iRingo.WeatherKit.dev.snippet", "iRingo.WeatherKit.dev.stoverride"]) { + for (const output of ["request.dev.bundle.js", "response.dev.bundle.js", "iRingo.WeatherKit.dev.sgmodule", "iRingo.WeatherKit.dev.lpx", "iRingo.WeatherKit.dev.snippet", "iRingo.WeatherKit.dev.stoverride"]) { assert.ok(deployWorkflow.includes(`dist/${output}`), output); } for (const [index, template] of templates.entries()) { @@ -293,7 +294,7 @@ test("WeatherAlert provider settings expose web and user API choices", async () assert.match(release, /import \{[^}]*airQuality[^}]*\} from "\.\/arguments-builder\.full\.config"/); assert.match(release, /args: \[\.\.\.dataSets,[^\]]*\.\.\.weatherAlerts,[^\]]*\.\.\.airQuality/); assert.match(dev, /import \{[^}]*airQualityFull[^}]*dataSetsFull[^}]*weatherFull[^}]*\} from "\.\/arguments-builder\.full\.config"/); - assert.match(dev, /args: \[\.\.\.dataSetsFull, \.\.\.weatherFull, \.\.\.weatherAlerts, \.\.\.nextHourFull, \.\.\.airQualityFull, \.\.\.calculateFull, \.\.\.api, \.\.\.storage, \.\.\.logLevel\]/); + assert.match(dev, /args: \[\.\.\.dataSetsFull, \.\.\.weatherFull, \.\.\.weatherAlerts, \.\.\.nextHourFull, \.\.\.airQualityFull, \.\.\.calculateFull, \.\.\.api, \.\.\.storage, \.\.\.logLevel, \.\.\.requestCache\]/); assert.match(database, /WeatherAlerts: \{ Provider: "QWeatherWeb" \}/); assert.match(types, /WeatherAlerts\?: \{[\s\S]*Provider\?: "WeatherKit" \| "QWeatherWeb" \| "QWeather" \| "ColorfulClouds"/); assert.match(boxjs, /@iRingo\.WeatherKit\.Settings\.WeatherAlerts\.Provider[\s\S]*"val":\s*"QWeatherWeb"[\s\S]*"key":\s*"WeatherKit"/); diff --git a/tests/waqiRequestCache.test.mjs b/tests/waqiRequestCache.test.mjs new file mode 100644 index 00000000..87b31cae --- /dev/null +++ b/tests/waqiRequestCache.test.mjs @@ -0,0 +1,67 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "Mac test runtime"; +const stored = new Map(); +globalThis.$persistentStore = { + read: key => stored.get(key), + write: (value, key) => { + stored.set(key, value); + return true; + }, +}; +const callbacks = []; +let calls = 0; +globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.timeout, 5000); + calls++; + callbacks.push(() => callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", data: { city: { name: "Fixture city", geo: [31.2, 121.5], url: "https://example.test/fixture" }, aqi: "42", dominentpol: "pm25", time: { v: 1_790_000_000 }, idx: 123 } }))); + }, +}; + +const [{ default: WAQI }, { default: ProviderRequestCache }, { Console }] = await Promise.all([import("../src/class/WAQI.mjs"), import("../src/class/ProviderRequestCache.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const parameters = { latitude: 31.2, longitude: 121.5, language: "zh-CN", country: "CN", version: "v2" }; + +test("real WAQI adapter coalesces native callbacks and reuses persisted data in a new execution", async () => { + const first = new WAQI(parameters, "fixture-token", new ProviderRequestCache()); + const pending = Array.from({ length: 6 }, () => first.AQI2()); + assert.equal(calls, 1); + callbacks.shift()(); + const products = await Promise.all(pending); + assert.equal( + products.every(product => product.index === 42), + true, + ); + products[0].metadata.providerName = "changed"; + assert.notEqual(products[1].metadata.providerName, "changed"); + const next = new WAQI(parameters, "fixture-token", new ProviderRequestCache()); + assert.equal((await next.AQI2()).index, 42); + assert.equal(calls, 1); + assert.equal([...stored.values()].join("").includes("fixture-token"), false); +}); + +test("WAQI token errors cool down across executions and token bodies remain uncached", async () => { + stored.clear(); + const originalHTTP = globalThis.$httpClient; + let authCalls = 0; + globalThis.$httpClient = { + get(_request, callback) { + authCalls++; + callback(null, { status: 200, headers: {} }, JSON.stringify({ rxs: { status: "error", obs: [] } })); + }, + }; + try { + for (let index = 0; index < 3; index++) { + const provider = new WAQI(parameters, "fixture-private-token", ProviderRequestCache.fromSettings({})); + assert.equal(await provider.Token(123), undefined); + } + assert.equal(authCalls, 2); + assert.equal(stored.has("WeatherKit.ProviderResponses.v1"), false); + assert.doesNotMatch([...stored.values()].join(""), /fixture-private-token|api\.waqi\.info|obs|rxs/); + } finally { + globalThis.$httpClient = originalHTTP; + } +}); diff --git a/tests/weatherKitSelectiveDecode.test.mjs b/tests/weatherKitSelectiveDecode.test.mjs index 671c099e..01f36d73 100644 --- a/tests/weatherKitSelectiveDecode.test.mjs +++ b/tests/weatherKitSelectiveDecode.test.mjs @@ -6,14 +6,13 @@ globalThis.$environment = { "surge-version": "test" }; globalThis.$persistentStore = { read: () => null, write: () => true }; globalThis.$argument = { LogLevel: "OFF", Storage: "database" }; -const [{ default: WeatherKit2 }, { FlatBufferRootProcessor }, { News, Weather }, { Response }, { Response: ResponseDev }, { Console }, { default: database }] = await Promise.all([ +const [{ default: WeatherKit2 }, { FlatBufferRootProcessor }, { News, Weather }, { Response }, { Response: ResponseDev }, { Console }] = await Promise.all([ import("../src/class/WeatherKit2.mjs"), import("@nsnanocat/flatbuffer-root"), import("@nsringo/weatherkit"), import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("@nsnanocat/util"), - import("../src/function/database.mjs"), ]); const supportedRootDataSets = Object.getOwnPropertyNames(Weather.prototype).filter(dataSet => !["constructor", "__init"].includes(dataSet)); @@ -290,7 +289,7 @@ test("response skips requested roots disabled by the user settings", async () => assert.equal(requestCount, 0); }); -test("response does not expand a partial request to all configured roots", async () => { +test("response decodes only eligible products from a partial request", async () => { const originalArgument = globalThis.$argument; const originalDecode = WeatherKit2.decode; let decodedRootNames; @@ -298,7 +297,7 @@ test("response does not expand a partial request to all configured roots", async DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], LogLevel: "OFF", Storage: "Argument", - Weather: { Replace: [] }, + Weather: { Replace: "[]" }, }; WeatherKit2.decode = (byteBuffer, rootNames) => { decodedRootNames = rootNames; @@ -306,22 +305,24 @@ test("response does not expand a partial request to all configured roots", async }; try { for (const handler of [Response, ResponseDev]) { - await handler( + const original = createWeatherRoot([0, 1]); + const output = await handler( { url: "https://weatherkit.apple.com/api/v2/weather/en-US/22.5431/114.0579?dataSets=airQuality,currentWeather", }, { - bodyBytes: createWeatherRoot([0, 1]), + bodyBytes: original, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, }, ); + assert.deepEqual(originalDecode.call(WeatherKit2, new ByteBuffer(output.body), ["currentWeather"]), originalDecode.call(WeatherKit2, new ByteBuffer(original), ["currentWeather"])); } } finally { WeatherKit2.decode = originalDecode; globalThis.$argument = originalArgument; } - assert.deepEqual(decodedRootNames, ["airQuality", "currentWeather"]); + assert.deepEqual(decodedRootNames, ["airQuality"]); }); test("response preserves requested roots when no dataSets are configured", async () => { @@ -361,7 +362,7 @@ test("response preserves requested roots when no dataSets are configured", async assert.equal(requestCount, 0); }); -test("response maps configured dataSets to FlatBuffer root names before decoding", async () => { +test("roots without an injector preserve the exact input without decoding", async () => { const originalArgument = globalThis.$argument; const originalDecode = WeatherKit2.decode; let decodedRootNames; @@ -371,22 +372,23 @@ test("response maps configured dataSets to FlatBuffer root names before decoding return originalDecode.call(WeatherKit2, byteBuffer, rootNames); }; try { - await Response( + const original = createWeatherRoot([7, 8]); + const output = await Response( { url: "https://weatherkit.apple.com/api/v2/weather/en-US/22.5431/114.0579?dataSets=weatherChange,trendComparison", }, { - bodyBytes: createWeatherRoot([7, 8]), + bodyBytes: original, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, }, ); + assert.strictEqual(output.body, original); } finally { WeatherKit2.decode = originalDecode; globalThis.$argument = originalArgument; } - assert.deepEqual(decodedRootNames, [database.WeatherKit.Configs.DataSets.weatherChange, database.WeatherKit.Configs.DataSets.trendComparison]); - assert.deepEqual(decodedRootNames, ["weatherChanges", "historicalComparisons"]); + assert.equal(decodedRootNames, undefined); }); test("response preserves the user-supplied QWeather Alert API path", async () => { @@ -657,6 +659,152 @@ test("development response preserves a disabled non-injection root as opaque dat assert.deepEqual(decoded.news, originalDecoded.news); }); +test("QWeather yesterday comparison works without an optional pollutant converter", async () => { + const [{ default: QWeather }, { default: AirQuality }] = await Promise.all([import("../src/class/QWeather.mjs"), import("../src/class/AirQuality.mjs")]); + const argument = globalThis.$argument, + grid = QWeather.GetLocationsGrid, + location = QWeather.GetLocationInfo, + yesterday = QWeather.prototype.YesterdayAirQuality, + current = QWeather.prototype.CurrentAirQuality; + let currentCalls = 0; + QWeather.GetLocationsGrid = async () => ({}); + QWeather.GetLocationInfo = () => ({ id: "fixture" }); + QWeather.prototype.YesterdayAirQuality = async () => ({ metadata: { providerName: "Fixture QWeather", temporarilyUnavailable: false }, categoryIndex: 3, index: 130, pollutants: [], scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale) }); + QWeather.prototype.CurrentAirQuality = async () => { + currentCalls++; + return { metadata: { providerName: "Fixture QWeather", temporarilyUnavailable: false }, categoryIndex: 2, index: 70 }; + }; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "airQuality", AirQuality: { Current: { Index: { Replace: "[]" } }, Comparison: { Yesterday: { IndexProvider: "QWeather" } } } }; + try { + for (const categoryIndex of [2, -1]) { + const original = WeatherKit2.encode(undefined, { + airQuality: { + metadata: { providerName: "Apple", temporarilyUnavailable: false }, + categoryIndex, + index: 70, + scale: AirQuality.ToWeatherKitScale(AirQuality.Config.Scales.HJ6332012.weatherKitScale), + pollutants: [{ pollutantType: "PM2_5", amount: 20, units: "MICROGRAMS_PER_CUBIC_METER" }], + previousDayComparison: "UNKNOWN", + }, + }); + for (const handler of [Response, ResponseDev]) { + const output = await handler({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=airQuality" }, { body: original, headers: { "Content-Type": "application/vnd.apple.flatbuffer" } }); + const result = WeatherKit2.decode(new ByteBuffer(output.body), ["airQuality"]); + assert.equal(result.airQuality.previousDayComparison, "BETTER"); + } + } + assert.equal(currentCalls, 2, "Invalid current category -1 fetches current data instead of producing a fake comparison"); + } finally { + globalThis.$argument = argument; + QWeather.GetLocationsGrid = grid; + QWeather.GetLocationInfo = location; + QWeather.prototype.YesterdayAirQuality = yesterday; + QWeather.prototype.CurrentAirQuality = current; + } +}); + +test("failed hourly replacement does not copy unused forecast fields and keeps exact Apple bytes", async () => { + const { default: QWeather } = await import("../src/class/QWeather.mjs"); + const originalArgument = globalThis.$argument, + decode = WeatherKit2.decode, + hourly = QWeather.prototype.Hourly; + let copiedFields = 0, + calls = 0; + const untouched = Object.freeze({ + forecastStart: 1, + get largeUnusedField() { + copiedFields++; + return Array(500).fill("fixture"); + }, + }); + const product = Object.freeze({ metadata: Object.freeze({ providerName: "Apple", temporarilyUnavailable: false }), hours: Object.freeze([untouched]) }); + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "forecastHourly", Weather: { Provider: "QWeather", Replace: "CN" } }; + WeatherKit2.decode = () => ({ forecastHourly: product }); + QWeather.prototype.Hourly = async () => { + calls++; + return undefined; + }; + try { + const raw = createWeatherRoot([3]); + for (const handler of [Response, ResponseDev]) { + const output = await handler({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=forecastHourly" }, { body: raw, headers: { "Content-Type": "application/vnd.apple.flatbuffer" } }); + assert.strictEqual(output.body, raw); + } + assert.equal(calls, 2); + assert.equal(copiedFields, 0); + } finally { + globalThis.$argument = originalArgument; + WeatherKit2.decode = decode; + QWeather.prototype.Hourly = hourly; + } +}); + +test("oversized binary weather stays opaque before settings, provider work or decoding", async () => { + const { MAX_WEATHER_BODY_BYTES } = await import("../src/function/weatherMemoryLimits.mjs"); + const decode = WeatherKit2.decode, + persistent = globalThis.$persistentStore; + WeatherKit2.decode = () => { + throw new Error("oversized input must not decode"); + }; + globalThis.$persistentStore = { + read: () => { + throw new Error("oversized input must not read settings"); + }, + }; + try { + const raw = new Uint8Array(MAX_WEATHER_BODY_BYTES + 1); + for (const handler of [Response, ResponseDev]) { + const original = { body: raw, headers: { "Content-Type": "application/vnd.apple.flatbuffer" } }; + const output = await handler({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=forecastHourly" }, original); + assert.strictEqual(output, original); + assert.strictEqual(output.body, raw); + } + } finally { + WeatherKit2.decode = decode; + globalThis.$persistentStore = persistent; + } +}); + +test("hourly decoding constructs one schema view per row instead of one per field", async () => { + const { HourlyForecastData } = await import("@nsringo/weatherkit"); + const hours = HourlyForecastData.prototype.hours; + let views = 0; + const raw = WeatherKit2.encode(undefined, { forecastHourly: { metadata: {}, hours: Array.from({ length: 273 }, (_, index) => ({ forecastStart: index, temperature: index, precipitationAmountByType: [] })) } }); + HourlyForecastData.prototype.hours = function (...args) { + views++; + return hours.apply(this, args); + }; + try { + const output = WeatherKit2.decode(new ByteBuffer(raw), ["forecastHourly"]); + assert.equal(output.forecastHourly.hours.length, 273); + assert.equal(output.forecastHourly.hours[272].temperature, 272); + assert.equal(views, 273); + } finally { + HourlyForecastData.prototype.hours = hours; + } +}); + +test("a forged vector length is rejected before reading rows and cannot discard another root", async () => { + const { HourlyForecastData } = await import("@nsringo/weatherkit"); + const hours = HourlyForecastData.prototype.hours, + length = HourlyForecastData.prototype.hoursLength; + let reads = 0; + HourlyForecastData.prototype.hoursLength = () => 1000000000; + HourlyForecastData.prototype.hours = () => { + reads++; + throw new Error("must not read an unbounded row"); + }; + try { + const output = WeatherKit2.decode(new ByteBuffer(createWeatherRoot([1, 3])), ["currentWeather", "forecastHourly"]); + assert.ok(output.currentWeather); + assert.equal(output.forecastHourly, undefined); + assert.equal(reads, 0); + } finally { + HourlyForecastData.prototype.hours = hours; + HourlyForecastData.prototype.hoursLength = length; + } +}); + function createWeatherRoot(presentSlots) { const builder = new Builder(256); const tables = new Map(presentSlots.map(slot => [slot, createEmptyTable(builder)])); diff --git a/tests/weatherMemoryPaths.test.mjs b/tests/weatherMemoryPaths.test.mjs new file mode 100644 index 00000000..8daa49c1 --- /dev/null +++ b/tests/weatherMemoryPaths.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderRequestCache, { readProviderJSON } from "../src/class/ProviderRequestCache.mjs"; +import mergeForecastCopy from "../src/function/mergeForecastCopy.mjs"; + +const freeze = value => { + if (value && typeof value === "object") { + for (const item of Object.values(value)) freeze(item); + Object.freeze(value); + } + return value; +}; + +test("forecast replacement copies matching rows and preserves both nested input graphs", () => { + const vector = [{ precipitationType: "RAIN", expected: 1 }]; + const original = freeze([ + { forecastStart: 10, temperature: 20, precipitationAmountByType: vector, daytimeForecast: { temperatureMax: 24, cloudCover: 0.2 } }, + { forecastStart: 20, temperature: 21 }, + ]); + const provider = freeze([ + { forecastStart: 5, temperature: 8 }, + { forecastStart: 10, temperature: 22, daytimeForecast: { temperatureMax: 26 } }, + ]); + const merged = mergeForecastCopy(original, provider); + assert.notStrictEqual(merged, original); + assert.deepEqual(merged[0].daytimeForecast, { temperatureMax: 26, cloudCover: 0.2 }); + assert.strictEqual(merged[0].precipitationAmountByType, vector); + assert.strictEqual(merged[1], original[1]); + assert.equal(original[0].temperature, 20); + assert.deepEqual(provider[1].daytimeForecast, { temperatureMax: 26 }); +}); + +test("forecasts without overlapping timestamps keep the original array", () => { + const original = freeze([{ forecastStart: 10 }, { forecastStart: 20 }]); + assert.strictEqual(mergeForecastCopy(original, [{ forecastStart: 1 }, { forecastStart: 30 }]), original); + assert.strictEqual(mergeForecastCopy(original, []), original); +}); + +test("a forecast source that fails after one match cannot partially mutate Apple rows", () => { + const original = freeze([ + { forecastStart: 10, temperature: 20 }, + { forecastStart: 20, temperature: 21 }, + ]); + const bad = { + forecastStart: 20, + get temperature() { + throw new Error("fixture conversion failure"); + }, + }; + assert.throws(() => mergeForecastCopy(original, [{ forecastStart: 10, temperature: 99 }, bad]), /conversion failure/); + assert.deepEqual( + original.map(row => row.temperature), + [20, 21], + ); +}); + +function storage() { + let value; + return { + read: () => value, + write: next => { + value = next; + return true; + }, + }; +} +const request = { url: "https://memory.invalid/fixture" }; +const body = '{"status":"ok","memoryFixture":{"values":[1,2,3]}}'; +const validate = value => value.status === "ok"; + +test("six parsed JSON consumers validate once each, own independent graphs and persist only raw JSON", async () => { + const store = storage(), + cache = new ProviderRequestCache({ store }); + const parse = JSON.parse; + let parses = 0, + requests = 0; + JSON.parse = (value, ...args) => { + if (value === body) parses++; + return parse(value, ...args); + }; + try { + const results = await Promise.all( + Array.from({ length: 6 }, () => + cache.fetch(request, { + includeJSON: true, + validate, + fetcher: async () => { + requests++; + return { status: 200, headers: {}, body }; + }, + }), + ), + ); + const decoded = results.map(readProviderJSON); + assert.equal(requests, 1); + assert.equal(parses, 6, "Persistence and conversion do not parse validated JSON again"); + decoded[0].memoryFixture.values[0] = 99; + assert.equal(decoded[1].memoryFixture.values[0], 1); + assert.ok(results.every(value => value.body === body)); + assert.ok(Object.keys(results[0]).every(key => typeof key === "string")); + const persisted = parse(store.read()); + assert.equal(persisted.entries[0].body, body); + assert.equal(Object.getOwnPropertySymbols(persisted.entries[0]).length, 0); + const next = new ProviderRequestCache({ store }); + const hit = await next.fetch(request, { + includeJSON: true, + validate, + fetcher: () => { + throw new Error("unexpected request"); + }, + }); + assert.equal(readProviderJSON(hit).memoryFixture.values[0], 1); + assert.equal(parses, 7); + } finally { + JSON.parse = parse; + } +}); + +test("shared transport validation mutations cannot contaminate another JSON consumer", async () => { + const cache = new ProviderRequestCache({ ttlSeconds: 0, store: storage() }); + const fetcher = async () => ({ status: 200, headers: {}, body }); + const [mutated, original] = await Promise.all([ + cache.fetch(request, { + includeJSON: true, + validate: value => { + value.memoryFixture.values[0] = 99; + return true; + }, + fetcher, + }), + cache.fetch(request, { includeJSON: true, validate, fetcher }), + ]); + assert.equal(readProviderJSON(mutated).memoryFixture.values[0], 99); + assert.equal(readProviderJSON(original).memoryFixture.values[0], 1); +}); + +test("parsed response results still expire and cannot bypass a different product validator", async () => { + let now = 100000, + requests = 0; + const cache = new ProviderRequestCache({ store: storage(), now: () => now }); + const fetcher = async () => { + requests++; + return { status: 200, headers: {}, body }; + }; + const first = await cache.fetch(request, { includeJSON: true, validate, fetcher }); + readProviderJSON(first).memoryFixture.values.push(99); + const hit = await cache.fetch(request, { includeJSON: true, validate, fetcher }); + assert.deepEqual(readProviderJSON(hit).memoryFixture.values, [1, 2, 3]); + now += 60000; + await cache.fetch(request, { includeJSON: true, validate, fetcher }); + assert.equal(requests, 2); + await assert.rejects(cache.fetch(request, { includeJSON: true, validate: () => false, fetcher }), error => error.code === "invalid"); +}); + +test("the JSON helper keeps raw response compatibility and accepts a validated null", async () => { + assert.deepEqual(readProviderJSON({ body }), JSON.parse(body)); + assert.deepEqual(readProviderJSON(undefined), {}); + const cache = new ProviderRequestCache({ store: storage() }); + const result = await cache.fetch(request, { includeJSON: true, validate: value => value === null, fetcher: async () => ({ status: 200, headers: {}, body: "null" }) }); + assert.equal(readProviderJSON(result), null); + assert.equal(result.body, "null"); +}); diff --git a/tests/weatherPowerPaths.test.mjs b/tests/weatherPowerPaths.test.mjs new file mode 100644 index 00000000..60a5868d --- /dev/null +++ b/tests/weatherPowerPaths.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderRequestCache from "../src/class/ProviderRequestCache.mjs"; +import createProviderEnvironment from "../src/function/createProviderEnvironment.mjs"; +import resolveWeatherRegion, { countryFromCoordinates } from "../src/function/resolveWeatherRegion.mjs"; + +test("explicit and locale-only regions do not materialize the offline polygon table", () => { + const parse = JSON.parse; + let geometryParses = 0; + JSON.parse = (text, ...rest) => { + if (typeof text === "string" && text.length > 100000 && text.startsWith('{"CN"')) geometryParses++; + return parse(text, ...rest); + }; + try { + assert.equal(resolveWeatherRegion({ latitude: 32.39, longitude: 119.499, explicitCountry: "CN" }), "CN"); + assert.equal(resolveWeatherRegion({ latitude: 32.39, longitude: 119.499, localeCountry: "US", fallback: "Locale" }), "US"); + assert.equal(geometryParses, 0); + assert.equal(countryFromCoordinates(32.39, 119.499), "CN"); + assert.equal(countryFromCoordinates(32.4, 119.5), "CN"); + assert.equal(geometryParses, 1); + } finally { + JSON.parse = parse; + } +}); + +test("stable coordinate hints write at most hourly but country changes and expiry refresh immediately", () => { + let now = 100000, + stored, + writes = 0; + const store = { + read: () => stored, + write: value => { + stored = value; + writes++; + return true; + }, + }; + const resolve = country => resolveWeatherRegion({ latitude: 32.39, longitude: 119.499, explicitCountry: country, now: () => now, store }); + for (let i = 0; i < 6; i++, now += 10000) assert.equal(resolve("CN"), "CN"); + assert.equal(writes, 1); + assert.equal(resolve("HK"), "HK"); + assert.equal(writes, 2); + now += 3600000; + assert.equal(resolve("HK"), "HK"); + assert.equal(writes, 3); + now += 7 * 86400000; + assert.equal(resolve("HK"), "HK"); + assert.equal(writes, 4); + assert.doesNotMatch(stored, /32\.39|119\.499/); +}); + +test("provider constructors and credentials are accessed only on first use of that provider", () => { + let apiReads = 0; + const settings = { + get API() { + apiReads++; + return {}; + }, + }; + const runtime = new ProviderRequestCache({ store: undefined }); + const env = createProviderEnvironment({ country: "CN", language: "zh-CN", latitude: 32.39, longitude: 119.499 }, settings, runtime); + assert.equal(apiReads, 0); + const first = env.qWeather; + assert.equal(apiReads, 2); + assert.strictEqual(env.qWeather, first); + assert.equal(apiReads, 2); + assert.equal(env.country, "CN"); + assert.strictEqual(env.requestCache, runtime); +}); From dac6b02970c1f8a942bcab0796f68815bbfd28a9 Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:47:04 +0800 Subject: [PATCH 4/8] build(loon): include complete dependency licenses in native release artifacts --- .github/workflows/loon-validation.yml | 1 + docs/loon-v3-compatibility.md | 2 ++ rollup.config.mjs | 7 +++++++ scripts/verify-loon-build.mjs | 9 ++++++++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/loon-validation.yml b/.github/workflows/loon-validation.yml index b92fda20..b90b99f7 100644 --- a/.github/workflows/loon-validation.yml +++ b/.github/workflows/loon-validation.yml @@ -39,4 +39,5 @@ jobs: path: | dist/iRingo.WeatherKit.lpx dist/*.loon.bundle.js + dist/LICENSE.txt if-no-files-found: error diff --git a/docs/loon-v3-compatibility.md b/docs/loon-v3-compatibility.md index 40417647..e02cb7b1 100644 --- a/docs/loon-v3-compatibility.md +++ b/docs/loon-v3-compatibility.md @@ -24,6 +24,8 @@ Apple 的 WK3 FlatBuffer JSON 表示与主应用 JSON 读取器不同。后者 Loon 使用 request.loon.bundle.js、response.loon.bundle.js、scale.loon.bundle.js;原版其他客户端的 generic bundle 文件名和模板继续保留。版本由原版发布流程决定,本改动不指定 Fork 版本或下载地址。CI 中的 0.0.0-test 仅用于生成验证夹具,不是可订阅发行版本。 +构建同时生成 dist/LICENSE.txt,包含项目 Apache-2.0、@noble/hashes 的完整 MIT 许可与公开地理数据出处;沿用原版的 dist 发行打包即可携带这些说明。 + `npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 35 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 执行逻辑相同的发行代码曾在 macOS 27.3、Loon for Mac 0.5.0(1011) 核对七种算法和六项污染物,最终轮次无解码错误、无效天气或 CloudKit 回退,11 类产品进入原生缓存;另有用户反馈 iPhone 使用成功。该事实与当前分支的静态、构建和 VM 验证分别报告,不扩展为所有算法、预警、手表、地区或长期 VPN 的原生验收。没有设备内存或电量下降比例。 diff --git a/rollup.config.mjs b/rollup.config.mjs index 1f7c93cb..34fa2da8 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,4 +1,5 @@ import path from "node:path"; +import { readFileSync } from "node:fs"; import alias from "@rollup/plugin-alias"; import { nodeResolve } from "@rollup/plugin-node-resolve"; import terser from "@rollup/plugin-terser"; @@ -52,6 +53,12 @@ export default [ alias({ entries: [{ find: "@nsringo/weatherkit", replacement: path.resolve(import.meta.dirname, "node_modules/@nsringo/weatherkit/dist/index.js") }] }), nodeResolve(), terser(), + ...(name === "scale" ? [{ + name: "weatherkit-third-party-licenses", + generateBundle() { + this.emitFile({ type: "asset", fileName: "LICENSE.txt", source: `${readFileSync(new URL("./LICENSE", import.meta.url), "utf8")}\n\n@noble/hashes 1.8.0\n${readFileSync(new URL("./node_modules/@noble/hashes/LICENSE", import.meta.url), "utf8")}\n\nProvider region data: Natural Earth, public domain; see docs/provider-regions-source.json.\n` }); + }, + }] : []), ], })), ]; diff --git a/scripts/verify-loon-build.mjs b/scripts/verify-loon-build.mjs index df262d82..7fe126f6 100644 --- a/scripts/verify-loon-build.mjs +++ b/scripts/verify-loon-build.mjs @@ -1,4 +1,11 @@ import path from "node:path"; +import { readFile } from "node:fs/promises"; +import assert from "node:assert/strict"; import verifyLoonBundles from "./verify-loon-bundles.mjs"; -const result = await verifyLoonBundles(path.resolve(import.meta.dirname, "..")); +const root = path.resolve(import.meta.dirname, ".."); +const license = await readFile(path.join(root, "dist/LICENSE.txt"), "utf8"); +assert.match(license, /Apache License/); +assert.match(license, /Copyright \(c\) 2022 Paul Miller/); +assert.match(license, /Permission is hereby granted, free of charge/); +const result = await verifyLoonBundles(root); console.log(JSON.stringify(result)); From 831852ff37cdfd74cabab9c3665fefb754c037bf Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:01:38 +0800 Subject: [PATCH 5/8] fix: honor original target, comparison and provider settings --- arguments-builder.full.config.ts | 1 + docs/loon-v3-compatibility.md | 10 +- docs/original-behavior-audit.md | 56 ++++++++++ package.json | 2 +- scripts/prepare-loon-plugin.mjs | 21 ++++ scripts/verify-loon-bundles.mjs | 30 +++++- src/class/AirQuality.mjs | 4 + src/function/database.mjs | 1 + src/function/recoverConfiguredProduct.mjs | 5 +- src/process/Response.dev.mjs | 39 ++++--- src/process/Response.mjs | 39 ++++--- src/types.d.ts | 1 + template/loon.handlebars | 2 +- tests/fixtures/upstream-intent.json | 52 ++++++++++ tests/providerResilience.test.mjs | 4 +- tests/responseJSONV3.test.mjs | 15 +-- tests/upstreamIntent.test.mjs | 118 ++++++++++++++++++++++ 17 files changed, 355 insertions(+), 45 deletions(-) create mode 100644 docs/original-behavior-audit.md create mode 100644 scripts/prepare-loon-plugin.mjs create mode 100644 tests/fixtures/upstream-intent.json create mode 100644 tests/upstreamIntent.test.mjs diff --git a/arguments-builder.full.config.ts b/arguments-builder.full.config.ts index 3962f6db..3680eac8 100644 --- a/arguments-builder.full.config.ts +++ b/arguments-builder.full.config.ts @@ -73,6 +73,7 @@ export const requestCache: Arg[] = [ ], description: "明确的 country 参数始终优先;自动模式使用同坐标确认记录与离线多边形,边界不确定时沿用语言区域。", }, + { key: "ProviderFallback.Enabled", name: "[数据源] 允许备用查询", defaultValue: false, type: "boolean", description: "默认仅使用选定来源,失败保留 Apple 数据。开启后,原数据也不可用且已配置另一来源专用凭据时,才尝试一次备用查询。" }, ]; export const dataSets: Arg[] = [ diff --git a/docs/loon-v3-compatibility.md b/docs/loon-v3-compatibility.md index e02cb7b1..1a586dc7 100644 --- a/docs/loon-v3-compatibility.md +++ b/docs/loon-v3-compatibility.md @@ -14,9 +14,9 @@ Apple 的 WK3 FlatBuffer JSON 表示与主应用 JSON 读取器不同。后者 11 种内置算法与 None 保留六项有效物理浓度,按模型定义参与指数计算。修复空值、负数、非有限值、零浓度、单位、分级与插值边界;加拿大 AQHI 使用正确 ppb 换算及最近整数取整,美标 InstantCast 保留 WAQI 臭氧分段并按浓度精度截断。研究模型和当前浓度换算不等于官方日均发布值,不新增后台采样。 -原始 Apple QWeather 的 CO 存在 mg 数值配 µg 标签的历史格式,只有该入口修复假标;已归一化供应商结果依据明确单位处理,不按浓度大小猜测。已有有效浓度优先,只补缺项;完整六项数据没有补充请求。模型未定义的污染物不填 index=-1,物理浓度仍保留。更换标准后不复用另一标准的昨日趋势。 +原始 Apple QWeather 的 CO 存在 mg 数值配 µg 标签的历史格式,只有该入口修复假标;已归一化供应商结果依据明确单位处理,不按浓度大小猜测。已有有效浓度优先,只补缺项;完整六项数据没有补充请求。模型未定义的污染物不填 index=-1,物理浓度仍保留。先匹配原版指数目标列表再应用算法,默认 HJ6332012。默认保留已有昨日对比,缺失或明确开启重算时使用所选的独立昨日来源;污染物补齐不绕过指数目标。 -供应商请求合并、Loon 有界持久缓存与冷却复用相同入口。默认缓存 60/180/15/15 秒、三并发、16 个在途、8000 ms 发起预算;失败回调返回前不会提前完成脚本,不依赖取消 API 或清理计时器。预警网页缓存区分来源失败与真实无预警。诊断默认关闭,只输出不含令牌、URL 和坐标的摘要。单产品错误不阻止其他健康产品。 +供应商请求合并、Loon 有界持久缓存与冷却复用相同入口。默认缓存 60/180/15/15 秒、三并发、16 个在途、8000 ms 发起预算;失败回调返回前不会提前完成脚本,不依赖取消 API 或清理计时器。预警网页缓存区分来源失败与真实无预警。诊断默认关闭,只输出不含令牌、URL 和坐标的摘要。单产品错误不阻止其他健康产品。备用查询需要明确开启 ProviderFallback.Enabled(默认 false),且 Apple 与所选来源都不可用并有另一来源专用凭据;只填写 Token 不会自动改变来源。 原有按需解码、实际修改产品编码、预警延迟复制和单次最终缓存序列化减少临时对象。完整六项污染物与原生功能列表无需额外供应商工作;普通空气质量标尺通过原生 Rewrite 处理,HK/CN 自定义图例使用独立小脚本。 @@ -26,6 +26,8 @@ Loon 使用 request.loon.bundle.js、response.loon.bundle.js、scale.loon.bundle 构建同时生成 dist/LICENSE.txt,包含项目 Apache-2.0、@noble/hashes 的完整 MIT 许可与公开地理数据出处;沿用原版的 dist 发行打包即可携带这些说明。 -`npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 35 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 +`npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 38 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 -执行逻辑相同的发行代码曾在 macOS 27.3、Loon for Mac 0.5.0(1011) 核对七种算法和六项污染物,最终轮次无解码错误、无效天气或 CloudKit 回退,11 类产品进入原生缓存;另有用户反馈 iPhone 使用成功。该事实与当前分支的静态、构建和 VM 验证分别报告,不扩展为所有算法、预警、手表、地区或长期 VPN 的原生验收。没有设备内存或电量下降比例。 +本次参数含义修正前的发行代码曾在 macOS 27.3、Loon for Mac 0.5.0(1011) 核对七种算法和六项污染物,最终轮次无解码错误、无效天气或 CloudKit 回退,11 类产品进入原生缓存;另有用户反馈 iPhone 使用成功。该事实与当前分支的静态、构建和 VM 验证分别报告,不扩展为所有算法、预警、手表、地区或长期 VPN 的原生验收。没有设备内存或电量下降比例。 + +原版参数及默认行为核对见 [原始行为审计](original-behavior-audit.md)。本次修正不能沿用旧版运行脚本同字节的结论;新的设备复测尚未进行。 diff --git a/docs/original-behavior-audit.md b/docs/original-behavior-audit.md new file mode 100644 index 00000000..e6e07433 --- /dev/null +++ b/docs/original-behavior-audit.md @@ -0,0 +1,56 @@ +# 原版 WeatherKit 的用途、参数与 Fork 行为核对 + +核对日期:2026-11-05。基线为 NSRingo/WeatherKit 的 c40e8f8、v3.3.2 正式 Loon 文件及[官方配置指南](https://NSRingo.github.io/guide/Weather/weather-kit)。下载的原版插件为 5740 字节,SHA-256 为 e994f11d8eb987b81bda0c682e95287d4ee166cabf8a5563d2fa8aa4bdfe3e24。 + +## 原版具体做什么 + +插件运行在代理客户端中,处理 Apple 天气接口的请求与响应。它先补齐可用数据类型,再按用户配置选取要处理的产品、来源和指数标准;信息来自 Apple 或所选的第三方供应商。 + +| 原版功能 | 实际处理 | 默认与约束 | +| --- | --- | --- | +| 解锁天气数据类型 | 合并 availability 能力列表,保留 Apple 新增能力 | 不凭空制造观测值;请求未选中的数据不进行来源替换 | +| 天气数据 | 用彩云或和风合并实况、小时和每日预报,按相同时间匹配 | 默认彩云,仅 CN;WeatherKit 选项保留 Apple 来源 | +| 分钟降水 | Apple 缺少该产品时才尝试补充 | 默认彩云;已有产品保留,无降水时面板可能隐藏 | +| 天气预警 | 按来源取得并合并预警、构造详情 | 默认和风网页;WeatherKit 选项保留原预警 | +| 污染物浓度 | 从所选来源补充物理浓度 | 默认彩云;浓度补充与指数替换是两项不同处理 | +| 空气指数 | 只针对选中的原始标准,使用指定来源或算法生成指数 | 默认替换 HJ6332012,来源 Calculate、算法 EU_EAQI | +| 昨日对比 | 已有对比按开关保留,缺失或明确要求重算时才取昨日来源 | 默认不因今日改动而覆盖已有对比;昨日来源独立选择 | + +所有算法不一定使用全部六种污染物。单位转换、分项指数和总体指数各有含义,不能为了填满界面而虚构不适用的分项。None 表示不进行本地指数转换。 + +## 插件各段是什么意思 + +- `[Argument]`:正式版原有 12 项参数,选择数据集、天气/预警/分钟降水/污染物来源、内置算法、API 配置、储存优先级和日志。完整高级参数由 BoxJs 提供;本 Fork 另外提供缓存、诊断、地区回退及明确开启的备用查询参数。 +- `[Rule]`:原版包含分析请求阻断及 Apple QUIC 限制,配合天气 HTTPS 处理;本轮不扩大这些规则。 +- `[Script]`:availability 补充能力,airQualityScale 处理标尺路径及 HK/CN 自定义图例,weatherAlerts 提供详情,weather Response 处理选中的数据产品。 +- `[MITM]`:正式脚本版只声明 weatherkit.apple.com,客户端用已有证书解密相关 HTTPS 流量。 + +Storage=Argument 表示模块参数优先,未提供项可从 BoxJs 读取;PersistentStore 只使用持久设置;database 使用作者默认设置。日志默认 WARN,诊断日志不应成为正常数据处理所必需。 + +## 必须保留的设置含义 + +1. 天气替换范围与空气指数替换目标不同。天气范围使用地区代码;指数目标使用 HJ6332012、EPA_NowCast、EU.EAQI 等标准名称。 +2. 内置算法只决定“怎么算”,不能绕过指数目标决定“哪些原始标准要改”。默认转换中国标准,不等于强制转换全球全部标准。 +3. `AirQuality.Current.Index.Replace=[]` 应停用指数替换;补齐污染物也不能使外部来源的指数渗入结果。Apple 本来没有可用指数、且用户允许指数替换时,可用补充数据补出指数。 +4. `Comparison.ReplaceWhenCurrentChange=false` 应保留已有 Apple 对比。开启或对比缺失时,沿用用户所选昨日来源;来源与今日显示算法不同时,原版会按该来源的今日和昨日数据进行对比,不能一律禁止查询。 +5. 填写另一来源 Token 可能是用于预警或昨日数据,不能据此自动改变当前天气来源。备用查询属于 Fork 扩展,需要单独明确开启,默认关闭。 +6. 选择 WeatherKit 或取消数据集,应保留相应 Apple 数据;原生 JSON 读取器所需的格式适配与第三方数据替换分别处理。 + +指南把 Weather.Replace 描述为正则,当前原版源码实际先转数组再用 includes 匹配。本轮保留现有地区列表匹配,不额外引入新的匹配规则。分钟降水和历史空气接口存在供应商权限限制,插件不提供购买或授权能力;通知仍受系统与天气关注城市设置控制。 + +## 本轮发现与修正 + +| Fork 先前偏差 | 本轮修正 | +| --- | --- | +| 非空目标列表加上算法选择,被当成全局强制重算 | 恢复目标标准匹配;所有算法仍可在显式选中的标准上使用 | +| 部分污染物补齐也可能触发目标外指数替换 | 有效 Apple 指数与浓度分别保留;空目标列表明确停用指数替换 | +| 计算算法触发后主动清空已有昨日对比,并只允许同算法历史 | 恢复原版对比开关及独立来源,失败保留安全回退 | +| 仅配置另一来源凭据就允许自动备用查询 | 新的 ProviderFallback.Enabled 默认 false,明确开启且满足原数据不可用等条件后才查询 | + +保留已验证的 CO 假标修复、数值与单位校验、未知字段保留、逐产品失败回退、请求合并及有界缓存。原版标尺、天气代码转换、预警合并、分钟降水类和 availability 合并类与上游文件保持同字节。 + +## 兼容与证据边界 + +v2 原路径继续保留。iOS 27.3 的 WK3 JSON 协商目前限主天气 App;未知后台、组件和手表客户端保留 Apple 传输。此前天气可显示不代表所有消费者已获得自定义替换,这是一项兼容范围限制,不能作为永久删减原功能来描述。 + +本轮新增以原版发布默认值为基线的测试,分别检查目标名单、None、部分补齐、昨日对比开关、独立来源和备用查询的明确同意;原有算法测试改为明确选择要转换的标准。自动测试和编译产物检查与真实设备验证分别记录。上游 PR #107 在本次核对期间保持草稿。 diff --git a/package.json b/package.json index 82bf5967..59e77a18 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "submodule": "git submodule update --remote", "update": "npm update --save", "test": "node --test --test-concurrency=1 tests/*.test.mjs", - "build:loon": "rollup -c && arguments-builder loon -c arguments-builder.release.config.ts && node scripts/verify-loon-build.mjs" + "build:loon": "rollup -c && arguments-builder loon -c arguments-builder.release.config.ts && node scripts/prepare-loon-plugin.mjs && node scripts/verify-loon-build.mjs" }, "browserslist": [ "iOS >= 18" diff --git a/scripts/prepare-loon-plugin.mjs b/scripts/prepare-loon-plugin.mjs new file mode 100644 index 00000000..6a010c4d --- /dev/null +++ b/scripts/prepare-loon-plugin.mjs @@ -0,0 +1,21 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Finalize native parameter types after the shared arguments generator. +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; + +const file = new URL("../dist/iRingo.WeatherKit.lpx", import.meta.url); +let plugin = await readFile(file, "utf8"); +for (const key of ["RequestCache.TTL", "RequestCache.ForecastTTL", "RequestCache.MinutelyTTL", "RequestCache.AlertsTTL", "RequestCache.BudgetMs", "RequestCache.CooldownSeconds"]) { + const line = plugin.split("\n").find(line => line.startsWith(`${key} = input,`)); + assert.ok(line, `Missing Loon input: ${key}`); + plugin = plugin.replace(line, line.replace(/(input,"[^"]*",)/, "$1type=string,")); +} +for (const key of ["RequestCache.Diagnostics", "ProviderFallback.Enabled"]) { + const declaration = `${key} = switch,false,type=boolean,`; + plugin = plugin.replace(`${key} = switch,false,`, declaration); + assert.ok(plugin.includes(declaration), `Missing typed switch: ${key}`); +} +const region = plugin.split("\n").find(line => line.startsWith("Region.Fallback = select,")); +assert.ok(region, "Missing Loon region selector"); +plugin = plugin.replace(region, region.replace(",tag=", ",type=string,tag=")); +await writeFile(file, plugin); diff --git a/scripts/verify-loon-bundles.mjs b/scripts/verify-loon-bundles.mjs index 2143c6dc..8e9a73cd 100644 --- a/scripts/verify-loon-bundles.mjs +++ b/scripts/verify-loon-bundles.mjs @@ -11,6 +11,9 @@ export default async function verifyLoonBundles(root) { responseCode = await readFile(path.join(directory, "response.loon.bundle.js"), "utf8"), scaleCode = await readFile(path.join(directory, "scale.loon.bundle.js"), "utf8"); const plugin = await readFile(path.join(directory, "iRingo.WeatherKit.lpx"), "utf8"); + const upstreamDefaults = JSON.parse(await readFile(path.join(root, "tests/fixtures/upstream-intent.json"), "utf8")).originalArgumentDefaults; + const emittedDefaults = new Map([...plugin.matchAll(/^([^=\n]+?)\s*=\s*(?:input|select),("(?:\\.|[^"\\])*")/gm)].map(([, key, value]) => [key.trim(), JSON.parse(value)])); + for (const [key, value] of Object.entries(upstreamDefaults)) assert.equal(emittedDefaults.get(key), value, `Original argument default: ${key}`); const nativeRewrites = verifyLoonRewrites(plugin); const storage = new Map(), scenarios = []; @@ -126,14 +129,14 @@ export default async function verifyLoonBundles(root) { assert.equal(nativeCompatible.nativeRequests, 0); scenarios.push("Apple v3 JSON is adapted to the native Weather reader, including unselected periodic forecasts"); const appleCO = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "QWeather" }, scale: "HJ6332012", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 0.7]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })) } }; - const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US" }); + const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US", "AirQuality.Current.Index.Replace": "EPA_NowCast" }); assert.equal(JSON.parse(appleCOResult.output.body).airQuality.pollutants.find(p => p.pollutantType === "CO").amount, 700); assert.equal(appleCOResult.nativeRequests, 0); scenarios.push("native Apple QWeather CO is repaired at ingress with no duplicate provider lookup"); for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "None"]) { const pollutants = [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); const body = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants } }; - const computed = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm }); + const computed = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm, "AirQuality.Current.Index.Replace": "EPA_NowCast" }); const actual = JSON.parse(computed.output.body).airQuality; assert.equal(computed.nativeRequests, 0); assert.equal(actual.pollutants.length, 6); @@ -144,6 +147,14 @@ export default async function verifyLoonBundles(root) { scenarios.push(`v3 ${algorithm} retains all six measurements with no provider request`); } storage.clear(); + const preservedIndex = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ airQuality: { ...appleCO.airQuality, metadata: { providerName: "Apple fixture", temporarilyUnavailable: false }, scale: "EPA_NowCast", pollutants: appleCO.airQuality.pollutants.map(p => ({ ...p, amount: p.pollutantType === "CO" ? 700 : p.amount })) } }) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + assert.equal(JSON.parse(preservedIndex.output.body).airQuality.index, 42); + assert.equal(JSON.parse(preservedIndex.output.body).airQuality.scale, "EPA_NowCast"); + scenarios.push("the original nonmatching target list preserves Apple AQI despite a selected algorithm"); + const retainedComparison = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + assert.equal(JSON.parse(retainedComparison.output.body).airQuality.scale, "CA.AQHI"); + assert.equal(JSON.parse(retainedComparison.output.body).airQuality.previousDayComparison, "SIMILAR"); + scenarios.push("the original disabled comparison-replacement switch keeps an existing Apple trend"); const v3Failed = await run(responseCode, v3Request, v3Response, (_, callback) => setImmediate(() => callback("timeout", {}, "")), { DataSets: "currentWeather", "Weather.Provider": "QWeather" }, true); assert.equal(v3Failed.output.body, v3Original); assert.equal(v3Failed.output.headers.ETag, "fixture"); @@ -240,6 +251,17 @@ export default async function verifyLoonBundles(root) { assert.equal(hours[1].temperature, 21); assert.equal(WeatherKit2.decode(new ByteBuffer(hourlyRaw), ["forecastHourly"]).forecastHourly.hours[0].temperature, 20); scenarios.push("partial hourly replacement changes only a matching row and leaves source binary intact"); + const noBackup = await run( + responseCode, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: empty }, + (request, callback) => { assert.ok(request.url.includes("caiyunapp.com"));callback("timeout", {}, ""); }, + { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "configured-for-another-product" }, + true, + ); + assert.equal(noBackup.nativeRequests, 1); + assert.deepEqual(noBackup.output.body, empty); + scenarios.push("a dedicated token alone never switches away from the explicitly selected provider"); const backup = await run( responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=forecastNextHour", method: "GET", headers: {} }, @@ -251,7 +273,7 @@ export default async function verifyLoonBundles(root) { callback(null, { status: 200, headers: { "Content-Type": "application/json" } }, JSON.stringify({ code: "200", updateTime: "2026-10-19T08:00:00+08:00", summary: "Fixture rain", minutely: [{ fxTime: "2026-10-19T08:05:00+08:00", precip: "0.1" }] })); } }, - { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "fixture-account", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "fixture-account", "ProviderFallback.Enabled": true, "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, true, ); assert.equal(backup.nativeRequests, 2); @@ -336,5 +358,5 @@ export default async function verifyLoonBundles(root) { assert.equal(failedAlerts.nativeRequests, 1); assert.strictEqual(failedAlerts.output.body, alertsRaw); scenarios.push("failed alert replacement retains the complete original binary response"); - return { pass: scenarios.length, fail: 0, scenarios, nativeRewrites, withoutNodeWebCryptoHostTextEncoder: true, providedWebAPIs: ["URL", "URLSearchParams", "TextDecoder"], doneExactlyOnce: true, liveWeatherRequests: false, deviceVPNValidation: false }; + return { pass: scenarios.length, fail: 0, scenarios, nativeRewrites, upstreamArgumentDefaultsVerified: Object.keys(upstreamDefaults).length, withoutNodeWebCryptoHostTextEncoder: true, providedWebAPIs: ["URL", "URLSearchParams", "TextDecoder"], doneExactlyOnce: true, liveWeatherRequests: false, deviceVPNValidation: false }; } diff --git a/src/class/AirQuality.mjs b/src/class/AirQuality.mjs index dfb673dc..95356849 100644 --- a/src/class/AirQuality.mjs +++ b/src/class/AirQuality.mjs @@ -46,6 +46,10 @@ export default class AirQuality { return AirQuality.ValidPollutants([...(Array.isArray(current) ? current : []), ...(Array.isArray(supplement) ? supplement : [])]); } + static HasUsableIndex(airQuality) { + return Boolean(airQuality?.metadata && airQuality.metadata.temporarilyUnavailable !== true && typeof airQuality.scale === "string" && airQuality.scale.length && Number.isFinite(airQuality.index) && airQuality.index >= 0 && airQuality.index <= 65535 && Number.isInteger(airQuality.categoryIndex) && airQuality.categoryIndex >= 1 && airQuality.categoryIndex <= 255); + } + static #Unavailable(pollutants = []) { return { metadata: { providerName: "iRingo", temporarilyUnavailable: true }, pollutants }; } diff --git a/src/function/database.mjs b/src/function/database.mjs index d868d27e..e4917ef8 100644 --- a/src/function/database.mjs +++ b/src/function/database.mjs @@ -2,6 +2,7 @@ export default { WeatherKit: { Settings: { + ProviderFallback: { Enabled: false }, // 只有插件会修改的产品允许被配置关闭;其余 Apple 数据集必须始终透传。 DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], RequestCache: { TTL: 60, ForecastTTL: 180, MinutelyTTL: 15, AlertsTTL: 15, BudgetMs: 8000, CooldownSeconds: 30, Diagnostics: false }, diff --git a/src/function/recoverConfiguredProduct.mjs b/src/function/recoverConfiguredProduct.mjs index b318e62b..ae0d3f2c 100644 --- a/src/function/recoverConfiguredProduct.mjs +++ b/src/function/recoverConfiguredProduct.mjs @@ -3,10 +3,13 @@ import database from "./database.mjs"; import { providerProductAvailable } from "./providerRecovery.mjs"; const sharedTokens = Object.freeze({ QWeather: database.WeatherKit.Settings.API.QWeather.Token, ColorfulClouds: database.WeatherKit.Settings.API.ColorfulClouds.Token }); export default async function recoverConfiguredProduct(_product, primary, original, selected, loaders, settings, runtime) { + const enabled = settings?.ProviderFallback?.Enabled; + if (enabled !== true && enabled !== "true") return primary; if (providerProductAvailable(primary) || providerProductAvailable(original) || !["ColorfulClouds", "QWeather", "QWeatherWeb"].includes(selected)) return primary; const backup = selected === "ColorfulClouds" ? "QWeather" : "ColorfulClouds"; const token = settings?.API?.[backup]?.Token; - // Shared upstream defaults are not evidence that the user configured a backup account. + // Both explicit opt-in and a dedicated credential are required. A token used + // for a different product is not consent to replace the selected provider. if (typeof token !== "string" || !token.trim() || token === sharedTokens[backup] || typeof loaders[backup] !== "function") return primary; try { const value = await loaders[backup](); diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 0b6d73cf..6123f60c 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -502,17 +502,30 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { } } - // 用户选定的计算算法也适用于完整的 Apple 数据,不受原始地区标准限制。 - const calculate = Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm !== "None" && Settings?.AirQuality?.Current?.Index?.Replace?.length > 0; - const needInjectIndex = calculate || needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; - const indexAvailable = needInjectIndex && providerProductAvailable(injectedIndex); + // The algorithm chooses how to convert; the target list decides whether. + const targets = Settings?.AirQuality?.Current?.Index?.Replace ?? []; + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider ?? "Calculate"; + const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; + const originalIndexAvailable = AirQuality.HasUsableIndex(airQuality); + const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || !originalIndexAvailable && needPollutants); + let injectedIndex = airQuality, indexAvailable = false; + if (needInjectIndex) { + try { + const candidate = await InjectIndex(injectedPollutants, Settings, enviroments); + if (AirQuality.HasUsableIndex(candidate)) { injectedIndex = candidate; indexAvailable = true; } + else enviroments.requestCache?.noteFallback("airQuality", "invalid"); + } catch (error) { + enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); + } + } - // 不沿用另一算法的昨日趋势;仅在配置了同算法历史计算时重算。 + // Follow the original comparison switch and independently selected source. + const indexChanged = indexAvailable && ["index", "scale", "categoryIndex"].some(key => airQuality?.[key] !== injectedIndex[key]); const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = indexAvailable && (calculate || Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange) ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; - const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN && (!calculate || Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider === "Calculate"); - const currentIndexProvider = indexAvailable ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + const previousDayComparison = indexChanged && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; let injectedComparison = { ...injectedIndex, previousDayComparison }; if (needInjectComparison) { try { @@ -530,7 +543,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}`] : []), ]; @@ -540,14 +553,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 } : {}), + ...(indexAvailable ? { temporarilyUnavailable: false } : !originalIndexAvailable ? { temporarilyUnavailable: true } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings)), + pollutants: AirQuality.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings)), previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); diff --git a/src/process/Response.mjs b/src/process/Response.mjs index 9b648b32..37328820 100644 --- a/src/process/Response.mjs +++ b/src/process/Response.mjs @@ -446,17 +446,30 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { } } - // 用户选定的计算算法也适用于完整的 Apple 数据,不受原始地区标准限制。 - const calculate = Settings?.AirQuality?.Current?.Index?.Provider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm !== "None" && Settings?.AirQuality?.Current?.Index?.Replace?.length > 0; - const needInjectIndex = calculate || needPollutants || Settings?.AirQuality?.Current?.Index?.Replace?.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const injectedIndex = needInjectIndex ? await InjectIndex(injectedPollutants, Settings, enviroments) : injectedPollutants; - const indexAvailable = needInjectIndex && providerProductAvailable(injectedIndex); + // The algorithm chooses how to convert; the target list decides whether. + const targets = Settings?.AirQuality?.Current?.Index?.Replace ?? []; + const indexProvider = Settings?.AirQuality?.Current?.Index?.Provider ?? "Calculate"; + const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; + const originalIndexAvailable = AirQuality.HasUsableIndex(airQuality); + const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || !originalIndexAvailable && needPollutants); + let injectedIndex = airQuality, indexAvailable = false; + if (needInjectIndex) { + try { + const candidate = await InjectIndex(injectedPollutants, Settings, enviroments); + if (AirQuality.HasUsableIndex(candidate)) { injectedIndex = candidate; indexAvailable = true; } + else enviroments.requestCache?.noteFallback("airQuality", "invalid"); + } catch (error) { + enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); + } + } - // 不沿用另一算法的昨日趋势;仅在配置了同算法历史计算时重算。 + // Follow the original comparison switch and independently selected source. + const indexChanged = indexAvailable && ["index", "scale", "categoryIndex"].some(key => airQuality?.[key] !== injectedIndex[key]); const weatherKitComparison = airQuality?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN; - const previousDayComparison = indexAvailable && (calculate || Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange) ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; - const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN && (!calculate || Settings?.AirQuality?.Comparison?.Yesterday?.IndexProvider === "Calculate"); - const currentIndexProvider = indexAvailable ? Settings?.AirQuality?.Current?.Index?.Provider : "WeatherKit"; + const previousDayComparison = indexChanged && Settings?.AirQuality?.Comparison?.ReplaceWhenCurrentChange ? AirQuality.Config.CompareCategoryIndexes.UNKNOWN : weatherKitComparison; + const needInjectComparison = previousDayComparison === AirQuality.Config.CompareCategoryIndexes.UNKNOWN; + const currentIndexProvider = indexAvailable ? indexProvider : "WeatherKit"; let injectedComparison = { ...injectedIndex, previousDayComparison }; if (needInjectComparison) { try { @@ -474,7 +487,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}`] : []), ]; @@ -484,14 +497,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 } : {}), + ...(indexAvailable ? { temporarilyUnavailable: false } : !originalIndexAvailable ? { temporarilyUnavailable: true } : {}), providerName: providers.join("\n"), ...(providerLogo ? { providerLogo } : {}), }, - pollutants: AirQuality.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, needInjectIndex, injectedIndex, Settings)), + pollutants: AirQuality.ValidPollutants(AirQuality.ConvertPollutants(airQuality, injectedPollutants, indexAvailable, injectedIndex, Settings)), previousDayComparison: injectedComparison?.previousDayComparison ?? AirQuality.Config.CompareCategoryIndexes.UNKNOWN, }; if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`airQuality: ${JSON.stringify(airQuality, null, 2)}`); diff --git a/src/types.d.ts b/src/types.d.ts index 275820bc..ff17f899 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,5 +1,6 @@ // Modified by hhhoratioxu, 2026-10-19: provider response cache settings. export interface Settings { + ProviderFallback?: { Enabled?: boolean | string }; RequestCache?: { /** Successful provider JSON cache lifetime in seconds, 0 disables completed-response caching; maximum 300. */ TTL?: number | string; diff --git a/template/loon.handlebars b/template/loon.handlebars index e761904b..695dfb97 100644 --- a/template/loon.handlebars +++ b/template/loon.handlebars @@ -30,7 +30,7 @@ http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/[A-Za http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=[^&#]*-[0-9]{9}(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.html.request, argument={{{scriptParams}}} http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.api.request, argument={{{scriptParams}}} http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v2\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js, tag=🌤 WeatherKit.api.v2.weather.response, argument={{{scriptParams}}} -response if ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// && ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${response.status} == 200 && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js", {${DataSets}, ${Weather.Provider}, ${WeatherAlerts.Provider}, ${NextHour.Provider}, ${AirQuality.Current.Pollutants.Provider}, ${AirQuality.Calculate.Algorithm}, ${API.ColorfulClouds.Token}, ${API.QWeather.Host}, ${API.QWeather.Token}, ${API.WAQI.Token}, ${Storage}, ${LogLevel}, ${RequestCache.TTL}, ${RequestCache.ForecastTTL}, ${RequestCache.MinutelyTTL}, ${RequestCache.AlertsTTL}, ${RequestCache.BudgetMs}, ${RequestCache.CooldownSeconds}, ${RequestCache.Diagnostics}, ${Region.Fallback}}) with requires_body=true, tag="🌤 WeatherKit.api.v3.weather.json.response" +response if ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// && ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${response.status} == 200 && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js", {${DataSets}, ${Weather.Provider}, ${WeatherAlerts.Provider}, ${NextHour.Provider}, ${AirQuality.Current.Pollutants.Provider}, ${AirQuality.Calculate.Algorithm}, ${API.ColorfulClouds.Token}, ${API.QWeather.Host}, ${API.QWeather.Token}, ${API.WAQI.Token}, ${Storage}, ${LogLevel}, ${RequestCache.TTL}, ${RequestCache.ForecastTTL}, ${RequestCache.MinutelyTTL}, ${RequestCache.AlertsTTL}, ${RequestCache.BudgetMs}, ${RequestCache.CooldownSeconds}, ${RequestCache.Diagnostics}, ${Region.Fallback}, ${ProviderFallback.Enabled}}) with requires_body=true, tag="🌤 WeatherKit.api.v3.weather.json.response" [MITM] hostname = weatherkit.apple.com diff --git a/tests/fixtures/upstream-intent.json b/tests/fixtures/upstream-intent.json new file mode 100644 index 00000000..25b9b495 --- /dev/null +++ b/tests/fixtures/upstream-intent.json @@ -0,0 +1,52 @@ +{ + "upstreamCommit": "c40e8f8a13c8304a7ef500cb74acc5948af14b3f", + "releaseURL": "https://github.com/NSRingo/WeatherKit/releases/tag/v3.3.2", + "pluginSHA256": "e994f11d8eb987b81bda0c682e95287d4ee166cabf8a5563d2fa8aa4bdfe3e24", + "originalArgumentDefaults": { + "DataSets": "airQuality,currentWeather,forecastDaily,forecastHourly,forecastNextHour,weatherAlerts", + "Weather.Provider": "ColorfulClouds", + "WeatherAlerts.Provider": "QWeatherWeb", + "NextHour.Provider": "ColorfulClouds", + "AirQuality.Current.Pollutants.Provider": "ColorfulClouds", + "AirQuality.Calculate.Algorithm": "EU_EAQI", + "API.ColorfulClouds.Token": "", + "API.QWeather.Host": "devapi.qweather.com", + "API.QWeather.Token": "", + "API.WAQI.Token": "", + "Storage": "Argument", + "LogLevel": "WARN" + }, + "weather": { + "Replace": [ + "CN" + ], + "Provider": "ColorfulClouds" + }, + "airQualityIndex": { + "Replace": [ + "HJ6332012" + ], + "Provider": "Calculate", + "ForceCNPrimaryPollutants": true + }, + "airQualityComparison": { + "ReplaceWhenCurrentChange": false, + "Yesterday": { + "PollutantsProvider": "QWeather", + "IndexProvider": "ColorfulCloudsUS" + } + }, + "capabilities": [ + "airQuality", + "currentWeather", + "forecastDaily", + "forecastHourly", + "forecastPeriodic", + "historicalComparisons", + "weatherChanges", + "forecastNextHour", + "weatherAlerts", + "weatherAlertNotifications", + "news" + ] +} diff --git a/tests/providerResilience.test.mjs b/tests/providerResilience.test.mjs index c940b8e3..fbd60ebf 100644 --- a/tests/providerResilience.test.mjs +++ b/tests/providerResilience.test.mjs @@ -348,7 +348,9 @@ test("backup queries require a configured dedicated credential and never replace assert.equal(calls, 0); assert.equal(await recover("currentWeather", failed, { metadata: { providerName: "Apple" } }, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), failed); assert.equal(calls, 0); - assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), value); + assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), failed); + assert.equal(calls, 0, "A credential for another product does not enable a provider switch"); + assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, { ProviderFallback: { Enabled: true }, API: { QWeather: { Token: "configured-account" } } }, runtime), value); assert.equal(calls, 1); assert.equal(runtime.stats.backupSuccesses, 1); assert.equal(await recover("currentWeather", undefined, undefined, "WeatherKit", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), undefined); diff --git a/tests/responseJSONV3.test.mjs b/tests/responseJSONV3.test.mjs index a9e47814..db11e160 100644 --- a/tests/responseJSONV3.test.mjs +++ b/tests/responseJSONV3.test.mjs @@ -14,6 +14,7 @@ const current = () => ({ metadata: metadata(), temperature: 18, conditionCode: " const request = (products = "currentWeather") => globalThis.$request = { url: `https://weatherkit.apple.com/api/v3/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=${products}`, method: "GET" }; const response = body => ({ status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ETag: "apple", "cOnTeNt-LeNgTh": "1", "cache-control": "max-age=60", "X-Apple": "retain" }, body: typeof body === "string" ? body : JSON.stringify(body) }); const settings = overrides => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather,forecastHourly,forecastDaily,forecastNextHour,weatherAlerts", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "WeatherKit", ...overrides }); +const airQualitySettings = overrides => settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", ...overrides }); const allPollutants = () => [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); const airQualityProduct = pollutants => ({ metadata: { ...metadata(), providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants }); @@ -63,7 +64,7 @@ test("native JSON adaptation never invents a total from missing or invalid preci for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { test(`${name}: native Apple QWeather CO is correctly displayed in ug after ingress normalization`, async t => { - settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US" }); + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US" }); t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("Complete Apple concentrations require no extra query"); }); const product = airQualityProduct(allPollutants()); product.metadata.providerName = "QWeather"; @@ -75,8 +76,8 @@ for (const [name, processResponse] of [["release", Response], ["development", Re assert.equal(actual.index, 99); }); for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "None"]) { - test(`${name}: v3 ${algorithm} selects the requested algorithm on a complete non-CN Apple product`, async t => { - settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm }); + test(`${name}: v3 ${algorithm} selects the requested algorithm on an explicitly targeted Apple standard`, async t => { + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm }); t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("Complete concentrations must not start provider requests"); }); t.mock.method(QWeather.prototype, "CurrentAirQuality", () => { throw new Error("Complete concentrations must not start provider requests"); }); const body = { airQuality: airQualityProduct(allPollutants()), futureProduct: { retained: true } }; @@ -90,14 +91,14 @@ for (const [name, processResponse] of [["release", Response], ["development", Re assert.equal(actual.previousDayComparison, "SIMILAR"); } else { assert.match(actual.metadata.providerName, /iRingo/); - assert.equal(actual.previousDayComparison, "UNKNOWN", "Do not reuse a different algorithm's historical trend"); + assert.equal(actual.previousDayComparison, "SIMILAR", "Keep the original comparison when ReplaceWhenCurrentChange is off"); assert.equal(result.headers["Cache-Control"], "no-store"); } assert.deepEqual(JSON.parse(result.body).futureProduct, body.futureProduct); }); } test(`${name}: a partial pollutant array is supplemented without replacing valid Apple concentrations`, async t => { - settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); let calls = 0; t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { calls++; return { metadata: { providerName: "fixture", temporarilyUnavailable: false }, pollutants: allPollutants() }; }); const body = { airQuality: airQualityProduct([{ ...allPollutants()[0], amount: 0 }, { ...allPollutants()[3], amount: null }]) }; @@ -109,7 +110,7 @@ for (const [name, processResponse] of [["release", Response], ["development", Re assert.equal(result.scale, "CA.AQHI"); }); test(`${name}: a failed supplement and incomplete risk inputs retain the available Apple index`, async t => { - settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "HK_AQHI" }); + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "HK_AQHI" }); t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { throw new Error("fixture provider unavailable"); }); const body = { airQuality: airQualityProduct([allPollutants()[0]]) }; const result = JSON.parse((await processResponse(request("airQuality"), response(body))).body).airQuality; @@ -192,7 +193,7 @@ test("a failed hourly product preserves its exact fields while a healthy current }); test("v3 air-quality calculation uses symbolic enums and leaves unrelated Apple products intact", async () => { - settings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "UBA", "AirQuality.Current.Index.Replace": "HJ6332012" }); + airQualitySettings({ DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "UBA", "AirQuality.Current.Index.Replace": "HJ6332012" }); const body = { airQuality: { metadata: metadata(), scale: "HJ6332012", index: 50, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [{ pollutantType: "PM2_5", amount: 30, units: "MICROGRAMS_PER_CUBIC_METER" }, { pollutantType: "PM10", amount: 40, units: "MICROGRAMS_PER_CUBIC_METER" }] }, currentWeather: current(), futureProduct: { version: 3 } }; const updated = JSON.parse((await Response(request("airQuality"), response(body))).body); assert.notEqual(updated.airQuality.scale, "HJ6332012"); diff --git a/tests/upstreamIntent.test.mjs b/tests/upstreamIntent.test.mjs new file mode 100644 index 00000000..1733e243 --- /dev/null +++ b/tests/upstreamIntent.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { readLoonRules } from "../scripts/verify-loon-rewrites.mjs"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$httpClient = { get: () => { throw new Error("Unexpected real provider request"); } }; +const [{ Response }, { Response: ResponseDev }, { default: AirQuality }, { default: ColorfulClouds }, { default: QWeather }, { default: database }, { qs }] = await Promise.all([ + import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("../src/class/AirQuality.mjs"), import("../src/class/ColorfulClouds.mjs"), import("../src/class/QWeather.mjs"), import("../src/function/database.mjs"), import("@nsnanocat/util/polyfill/qs.mjs"), +]); +const baseline = JSON.parse(await readFile(new URL("./fixtures/upstream-intent.json", import.meta.url))); +const metadata = () => ({ providerName: "Apple fixture", temporarilyUnavailable: false }); +const pollutants = () => [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); +const product = () => ({ metadata: metadata(), index: 42, categoryIndex: 1, scale: "EPA_NowCast", previousDayComparison: "SIMILAR", pollutants: pollutants() }); +const settings = values => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI", ...values }); +const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=airQuality", method: "GET" }; +async function processAQ(processResponse, value) { + const result = await processResponse(request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ airQuality: value }) }); + return JSON.parse(result.body).airQuality; +} + +test("original defaults keep target selection, source selection and comparison controls", () => { + const s = database.WeatherKit.Settings; + assert.deepEqual(s.Weather, baseline.weather); + assert.deepEqual(s.AirQuality.Current.Index, baseline.airQualityIndex); + assert.deepEqual(s.AirQuality.Comparison, baseline.airQualityComparison); + assert.equal(s.NextHour.Provider, baseline.originalArgumentDefaults["NextHour.Provider"]); + assert.equal(s.WeatherAlerts.Provider, baseline.originalArgumentDefaults["WeatherAlerts.Provider"]); + assert.equal(s.ProviderFallback.Enabled, false); +}); + +test("native availability unlocks the same original capability set and preserves new Apple features", async () => { + const rules = readLoonRules(await readFile(new URL("../template/loon.handlebars", import.meta.url), "utf8")); + for (const apple of [[], ["weatherMaps", "currentWeather", "futureCapability", "currentWeather"]]) { + assert.deepEqual(rules.mergeAvailability(apple), [...new Set([...apple, ...baseline.capabilities])]); + } +}); + +for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { + test(`${name}: selecting an algorithm does not override the original default target list`, async t => { + settings({}); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("No missing pollutants"); }); + const value = await processAQ(processResponse, product()); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.doesNotMatch(value.metadata.providerName, /指数:/); + }); + + test(`${name}: an explicit replacement target applies the selected algorithm`, async () => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast" }); + const value = await processAQ(processResponse, product()); + assert.equal(value.scale, "CA.AQHI"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.match(value.metadata.providerName, /指数:iRingo/); + }); + + test(`${name}: partial pollutant completion respects an empty or nonmatching target list`, async t => { + let calls = 0; + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { calls++; return { metadata: { providerName: "Supplement", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "HJ6332012", pollutants: pollutants() }; }); + for (const target of [[], ["HJ6332012"]]) { + settings({ "AirQuality.Current.Index.Replace": target }); + const input = product();input.pollutants = input.pollutants.slice(0, 1); + const value = await processAQ(processResponse, input); + assert.equal(value.pollutants.length, 6); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.match(value.metadata.providerName, /污染物:Supplement/); + assert.doesNotMatch(value.metadata.providerName, /指数:/); + } + assert.equal(calls, 2); + }); + + test(`${name}: None preserves the Apple index after a valid pollutant supplement`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Calculate.Algorithm": "None", "AirQuality.Comparison.ReplaceWhenCurrentChange": true }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ metadata: { providerName: "Supplement", temporarilyUnavailable: false }, pollutants: pollutants() })); + const input = product();input.pollutants = input.pollutants.slice(0, 1); + const value = await processAQ(processResponse, input); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.equal(value.pollutants.length, 6); + }); + + test(`${name}: the original comparison switch keeps an existing trend without extra queries`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Comparison.ReplaceWhenCurrentChange": false }); + t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", () => { throw new Error("An existing comparison must be kept"); }); + const value = await processAQ(processResponse, product()); + assert.equal(value.scale, "CA.AQHI"); + assert.equal(value.previousDayComparison, "SIMILAR"); + assert.doesNotMatch(value.metadata.providerName, /对比昨日/); + }); + + test(`${name}: explicit comparison replacement honors the selected independent source`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Comparison.ReplaceWhenCurrentChange": true, "AirQuality.Comparison.Yesterday.IndexProvider": "ColorfulCloudsUS" }); + let currentCalls = 0, yesterdayCalls = 0; + t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", async usa => { assert.equal(usa, true);yesterdayCalls++;return { metadata: { providerName: "Yesterday fixture", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "EPA_NowCast" }; }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async usa => { assert.equal(usa, true);currentCalls++;return { metadata: { providerName: "Current fixture", temporarilyUnavailable: false }, index: 20, categoryIndex: 1, scale: "EPA_NowCast" }; }); + const value = await processAQ(processResponse, product()); + assert.equal(currentCalls, 1); + assert.equal(yesterdayCalls, 1); + assert.equal(value.previousDayComparison, AirQuality.CompareCategoryIndexes(1, 4)); + assert.match(value.metadata.providerName, /对比昨日/); + }); + + test(`${name}: a failed index source does not discard a successful pollutant supplement`, async t => { + settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Current.Index.Provider": "QWeather" }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ metadata: { providerName: "Supplement", temporarilyUnavailable: false }, pollutants: pollutants() })); + t.mock.method(QWeather.prototype, "CurrentAirQuality", async () => { throw new Error("Fixture failure"); }); + const input = product();input.pollutants = input.pollutants.slice(0, 1); + const value = await processAQ(processResponse, input); + assert.equal(value.pollutants.length, 6); + assert.equal(value.index, 42); + assert.equal(value.scale, "EPA_NowCast"); + }); +} From 4d1f1272cfed0583ac076185475d69ab8d45d7ad Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:28:45 +0800 Subject: [PATCH 6/8] fix: retain readable WeatherAlert UUID identifiers in WK3 JSON --- docs/weather-alert-id-v3.md | 11 +++ scripts/verify-loon-bundles.mjs | 17 +++++ src/process/ResponseJSONV3.mjs | 29 ++++++-- tests/weatherAlertIdentifiersV3.test.mjs | 91 ++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 docs/weather-alert-id-v3.md create mode 100644 tests/weatherAlertIdentifiersV3.test.mjs diff --git a/docs/weather-alert-id-v3.md b/docs/weather-alert-id-v3.md new file mode 100644 index 00000000..2bf54f7e --- /dev/null +++ b/docs/weather-alert-id-v3.md @@ -0,0 +1,11 @@ +# WK3 预警标识兼容修复 + +2026-11-06 在 macOS 27.3(26C5035r)、Loon for Mac 0.5.0(1011) 复测 v3.3.4。11 种算法、None、目标外保留及数据集控制已在系统天气显示,但完整系统日志发现 WeatherAlert.id 的类型错误;普通天气显示正常不能证明预警已被采用。 + +原生错误为:weatherAlerts.alerts[n].id 需要 String,实际收到 dictionary。进一步读取本机这次请求的临时天气响应,确认 WK3 将 UUID 表示为仅含 bytes 的对象,数组恰好 16 个 0–255 整数。读取器需要同一组字节对应的标准 UUID 字符串。原始响应仅留在本机忽略目录,回归使用独立合成数据。 + +修复只在已知 weatherAlerts 产品的 alerts[].id 路径转换该表示,保留 UUID 全部字节、原字符串标识、预警正文、来源、详情地址、令牌及其他字段。不生成随机标识、不删除预警,也不把任意对象转成字符串。已是原生字符串且其他字段不变时,保留正文、空白和验证头。 + +这属于新 JSON 传输的读取器兼容,原 Apple 预警在未选择来源替换、选择 WeatherKit 或第三方补充失败时也需要执行。供应商结果同样校验,未知非法标识不能覆盖已有可读预警。原版来源选择、预警合并、指数目标、昨日对比和缓存边界继续保留。 + +本次新增发布/开发入口、关闭处理、保留 Apple 来源、补充失败、无效标识、已有字符串、未知产品及重复执行的回归;实际压缩脚本另检查保留与失败回退。新的 GitHub 候选将在 Mac 重新读取预警及详情、检查完整系统日志,通过后才更新正式订阅及提交原作者审核。当前原版 PR #107 保持草稿。 diff --git a/scripts/verify-loon-bundles.mjs b/scripts/verify-loon-bundles.mjs index 8e9a73cd..80de7a84 100644 --- a/scripts/verify-loon-bundles.mjs +++ b/scripts/verify-loon-bundles.mjs @@ -128,6 +128,23 @@ export default async function verifyLoonBundles(root) { assert.equal(nativeBody.forecastPeriodic.forecasts[0].periods[0].snowfallAmount, 0); assert.equal(nativeCompatible.nativeRequests, 0); scenarios.push("Apple v3 JSON is adapted to the native Weather reader, including unselected periodic forecasts"); + const alertUUID = "00112233-4455-6677-8899-aabbccddeeff"; + const alertBytes = [0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255]; + const nativeAlerts = { weatherAlerts: { metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, detailsUrl: "https://www.qweather.com/severe-weather/fixture-101190110.html?from=AppleWeatherService", alerts: [{ id: { bytes: alertBytes }, description: "Fixture warning", detailsUrl: "https://example.com/fixture-warning", futureAlertField: true }] }, futureProduct: { id: { bytes: alertBytes } } }; + const retainedAlerts = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, { status: 200, headers: { "Content-Type": "application/json", ETag: "original" }, body: JSON.stringify(nativeAlerts) }, noNetwork, { DataSets: "", "WeatherAlerts.Provider": "WeatherKit" }); + const retainedAlertBody = JSON.parse(retainedAlerts.output.body); + assert.equal(retainedAlertBody.weatherAlerts.alerts[0].id, alertUUID); + assert.equal(retainedAlertBody.weatherAlerts.alerts[0].description, "Fixture warning"); + assert.equal(retainedAlertBody.weatherAlerts.alerts[0].futureAlertField, true); + assert.deepEqual(retainedAlertBody.futureProduct, nativeAlerts.futureProduct); + assert.equal(retainedAlerts.nativeRequests, 0); + scenarios.push("unselected Apple warning UUID bytes become native strings without changing warning content or unknown products"); + const failedNativeAlerts = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeAlerts) }, (_, callback) => setImmediate(() => callback(null, { status: 503, headers: { "Content-Type": "text/html" } }, "Source unavailable")), { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeatherWeb" }, true); + assert.equal(JSON.parse(failedNativeAlerts.output.body).weatherAlerts.alerts[0].id, alertUUID); + assert.equal(JSON.parse(failedNativeAlerts.output.body).weatherAlerts.alerts[0].description, "Fixture warning"); + assert.equal(failedNativeAlerts.nativeRequests, 1); + scenarios.push("failed warning supplementation preserves a readable original Apple warning identifier"); + storage.clear(); const appleCO = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "QWeather" }, scale: "HJ6332012", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 0.7]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })) } }; const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US", "AirQuality.Current.Index.Replace": "EPA_NowCast" }); assert.equal(JSON.parse(appleCOResult.output.body).airQuality.pollutants.find(p => p.pollutantType === "CO").amount, 700); diff --git a/src/process/ResponseJSONV3.mjs b/src/process/ResponseJSONV3.mjs index 68045c75..02674f33 100644 --- a/src/process/ResponseJSONV3.mjs +++ b/src/process/ResponseJSONV3.mjs @@ -49,9 +49,25 @@ export function boundedWeatherJSON(value) { // Work only on a parsed, owned product. Unknown top-level products are untouched. // Preserve by-type totals while adding the scalar required by the native reader. -export function adaptV3ClientProduct(product) { +export function adaptV3ClientProduct(product, name) { boundedWeatherJSON(product); let changed = false; + // WK3 UUID tables contain sixteen octets. Weather's JSON reader expects + // the same identifier as a UUID string, including for retained Apple alerts. + if (name === "weatherAlerts" && Array.isArray(product?.alerts)) { + const hex = "0123456789abcdef"; + for (const alert of product.alerts) { + const bytes = alert?.id?.bytes; + if (!Array.isArray(bytes) || bytes.length !== 16 || !bytes.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255)) continue; + let uuid = ""; + for (let i = 0; i < 16; i++) { + if (i === 4 || i === 6 || i === 8 || i === 10) uuid += "-"; + uuid += hex[bytes[i] >> 4] + hex[bytes[i] & 15]; + } + alert.id = uuid; + changed = true; + } + } function visit(value) { if (!value || typeof value !== "object") return; if (Array.isArray(value)) { for (const item of value) visit(item); return; } @@ -81,7 +97,7 @@ export function adaptV3ClientProduct(product) { } // Provider graphs are owned by their consumer. Apple input graphs stay read-only. -export function adaptV3ProviderProduct(product) { +export function adaptV3ProviderProduct(product, name) { if (!object(product)) return product; boundedWeatherJSON(product); function visit(value) { @@ -115,7 +131,7 @@ export function adaptV3ProviderProduct(product) { } } visit(product); - adaptV3ClientProduct(product); + adaptV3ClientProduct(product, name); return product; } @@ -125,6 +141,7 @@ export function validateWeatherJSONV3(product, name) { if (!object(product)) throw new TypeError("Missing weather JSON product"); boundedWeatherJSON(product); if (name === "airQuality" && product?.metadata?.temporarilyUnavailable !== true && (!Number.isInteger(product?.index) || product.index < 0 || product.index > 65535 || !Number.isInteger(product?.categoryIndex) || product.categoryIndex < 1 || product.categoryIndex > 255)) throw new TypeError("Invalid WK3 air quality index"); + if (name === "weatherAlerts" && Array.isArray(product.alerts) && product.alerts.some(alert => object(alert) && alert.id !== undefined && typeof alert.id !== "string")) throw new TypeError("Invalid native weather alert identifier"); function visit(value) { const kind = typeof value; if (kind === "bigint" || kind === "symbol" || kind === "function") throw new TypeError("Unsupported weather JSON value"); @@ -167,7 +184,7 @@ function v3Environment(environment) { get(target, key) { const value = Reflect.get(target, key, target); if (typeof value !== "function") return value; - if (providerMethods.has(key)) return async (...args) => adaptV3ProviderProduct(await value.apply(target, args)); + if (providerMethods.has(key)) return async (...args) => adaptV3ProviderProduct(await value.apply(target, args), key === "WeatherAlert" || key === "WeatherAlertWeb" ? "weatherAlerts" : undefined); return value.bind(target); }, }); @@ -196,7 +213,7 @@ export default async function ResponseJSONV3(request, response, injectors) { let modified = false; for (const name of [...productNames, "forecastPeriodic", "weatherChanges"]) { if (!object(body[name])) continue; - try { modified = adaptV3ClientProduct(body[name]) || modified; } catch { /* Keep products beyond processing bounds. */ } + try { modified = adaptV3ClientProduct(body[name], name) || modified; } catch { /* Keep products beyond processing bounds. */ } } const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; @@ -223,7 +240,7 @@ export default async function ResponseJSONV3(request, response, injectors) { if (!weatherProductsEqual(value, original)) { validating = true; // AQI and warning injectors can also add numeric metadata fields. - body[name] = validateWeatherJSONV3(adaptV3ProviderProduct(value), name); + body[name] = validateWeatherJSONV3(adaptV3ProviderProduct(value, name), name); modified = true; } } catch (error) { diff --git a/tests/weatherAlertIdentifiersV3.test.mjs b/tests/weatherAlertIdentifiersV3.test.mjs new file mode 100644 index 00000000..da85201c --- /dev/null +++ b/tests/weatherAlertIdentifiersV3.test.mjs @@ -0,0 +1,91 @@ +// Synthetic UUID protocol fixtures; no captured alert payloads or device data. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$httpClient = { get: () => { throw new Error("Unexpected provider request"); } }; +const [{ default: ResponseJSONV3, adaptV3ClientProduct, adaptV3ProviderProduct, validateWeatherJSONV3 }, { Response }, { Response: ResponseDev }, { qs }] = await Promise.all([ + import("../src/process/ResponseJSONV3.mjs"), import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("@nsnanocat/util/polyfill/qs.mjs"), +]); +const bytes = () => [0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255]; +const uuid = "00112233-4455-6677-8899-aabbccddeeff"; +const alerts = () => ({ metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, alerts: [{ id: { bytes: bytes() }, description: "Fixture warning", detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=fixture", token: "fixture-warning", futureField: { retained: true } }], futureCollection: true }); +const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=currentWeather,weatherAlerts", method: "GET" }; +const response = body => ({ status: 200, headers: { "Content-Type": "application/json", ETag: "apple-original", "Content-Length": "1", "X-Apple": "retain" }, body: JSON.stringify(body) }); +const settings = overrides => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", "Weather.Provider": "WeatherKit", ...overrides }); + +test("WK3 alert UUID bytes become the same identifier in native string form", () => { + const product = alerts(), original = structuredClone(product); + product.alerts.push({ id: "existing-provider-id", description: "Existing string" }); + assert.equal(adaptV3ClientProduct(product, "weatherAlerts"), true); + assert.equal(product.alerts[0].id, uuid); + assert.equal(product.alerts[1].id, "existing-provider-id"); + assert.equal(product.alerts[0].description, original.alerts[0].description); + assert.equal(product.alerts[0].detailsUrl, original.alerts[0].detailsUrl); + assert.equal(product.alerts[0].token, original.alerts[0].token); + assert.deepEqual(product.alerts[0].futureField, original.alerts[0].futureField); + assert.equal(adaptV3ClientProduct(product, "weatherAlerts"), false); + validateWeatherJSONV3(product, "weatherAlerts"); +}); + +test("UUID conversion is limited to known alert IDs and accepts all sixteen octets", () => { + const other = alerts(); + assert.equal(adaptV3ClientProduct(other, "currentWeather"), false); + assert.deepEqual(other.alerts[0].id, { bytes: bytes() }); + const product = alerts();product.alerts[0].id = { bytes: Array(16).fill(0) }; + assert.equal(adaptV3ProviderProduct(product, "weatherAlerts").alerts[0].id, "00000000-0000-0000-0000-000000000000"); +}); + +test("malformed or unknown identifier objects are not coerced into fake strings", () => { + for (const id of [{ bytes: bytes().slice(0, 15) }, { bytes: [...bytes().slice(0, 15), 256] }, { bytes: [...bytes().slice(0, 15), -1] }, { bytes: [...bytes().slice(0, 15), 1.5] }, { bytes: bytes().map(String) }, { unknown: "future-id" }, 123]) { + const product = alerts();product.alerts[0].id = id; + assert.equal(adaptV3ClientProduct(product, "weatherAlerts"), false); + assert.deepEqual(product.alerts[0].id, id); + assert.throws(() => validateWeatherJSONV3(product, "weatherAlerts"), /alert identifier/); + } +}); + +for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { + for (const overrides of [{ DataSets: "" }, { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "WeatherKit" }]) { + test(`${name}: retained Apple alerts are readable even when source replacement is disabled: ${JSON.stringify(overrides)}`, async () => { + settings(overrides); + const body = { weatherAlerts: alerts(), currentWeather: { metadata: {}, conditionCode: "CLEAR", temperature: 18 }, futureProduct: { alerts: [{ id: { bytes: bytes() } }] } }; + const original = structuredClone(body); + const result = await processResponse(request, response(body)); + const actual = JSON.parse(result.body); + assert.equal(actual.weatherAlerts.alerts[0].id, uuid); + assert.equal(actual.weatherAlerts.alerts[0].description, "Fixture warning"); + assert.deepEqual(actual.futureProduct, original.futureProduct); + assert.deepEqual(actual.currentWeather, original.currentWeather); + assert.deepEqual(body, original); + assert.equal(result.headers.ETag, undefined); + assert.equal(result.headers["Cache-Control"], "no-store"); + }); + } +} + +test("failed warning replacement keeps normalized Apple IDs and the healthy product", async () => { + settings({ DataSets: "weatherAlerts,currentWeather", "Weather.Provider": "QWeather", "WeatherAlerts.Provider": "QWeatherWeb" }); + const body = { weatherAlerts: alerts(), currentWeather: { metadata: {}, conditionCode: "CLEAR", temperature: 18 } }; + const result = await ResponseJSONV3(request, response(body), { weatherAlerts: async () => { throw new Error("fixture source failure"); }, currentWeather: async input => ({ ...input, temperature: 20 }) }); + const actual = JSON.parse(result.body); + assert.equal(actual.weatherAlerts.alerts[0].id, uuid); + assert.equal(actual.weatherAlerts.alerts[0].description, "Fixture warning"); + assert.equal(actual.currentWeather.temperature, 20); +}); + +test("an invalid new provider ID cannot replace readable Apple warning data", async () => { + settings({ DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeatherWeb" }); + const result = await ResponseJSONV3(request, response({ weatherAlerts: alerts() }), { weatherAlerts: async input => ({ ...input, alerts: [{ ...input.alerts[0], id: { unknown: "provider-id" } }] }) }); + assert.equal(JSON.parse(result.body).weatherAlerts.alerts[0].id, uuid); +}); + +test("already native alert strings retain the original body and cache validators", async () => { + settings({ DataSets: "" }); + const product = alerts();product.alerts[0].id = uuid; + const original = response({ weatherAlerts: product });original.body = " " + original.body + "\n"; + const result = await ResponseJSONV3(request, original, {}); + assert.strictEqual(result, original); + assert.equal(result.headers.ETag, "apple-original"); +}); From 355b5f5dad9d01cb13167949f8334a489b301811 Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:22:07 +0800 Subject: [PATCH 7/8] docs: record native Mac coverage and compatibility boundaries [skip ci] --- docs/loon-v3-compatibility.md | 8 +++++--- docs/mac-native-validation.md | 32 ++++++++++++++++++++++++++++++++ docs/original-behavior-audit.md | 2 +- docs/weather-alert-id-v3.md | 2 +- 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 docs/mac-native-validation.md diff --git a/docs/loon-v3-compatibility.md b/docs/loon-v3-compatibility.md index 1a586dc7..00eaf224 100644 --- a/docs/loon-v3-compatibility.md +++ b/docs/loon-v3-compatibility.md @@ -26,8 +26,10 @@ Loon 使用 request.loon.bundle.js、response.loon.bundle.js、scale.loon.bundle 构建同时生成 dist/LICENSE.txt,包含项目 Apache-2.0、@noble/hashes 的完整 MIT 许可与公开地理数据出处;沿用原版的 dist 发行打包即可携带这些说明。 -`npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 38 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 +`npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 40 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 -本次参数含义修正前的发行代码曾在 macOS 27.3、Loon for Mac 0.5.0(1011) 核对七种算法和六项污染物,最终轮次无解码错误、无效天气或 CloudKit 回退,11 类产品进入原生缓存;另有用户反馈 iPhone 使用成功。该事实与当前分支的静态、构建和 VM 验证分别报告,不扩展为所有算法、预警、手表、地区或长期 VPN 的原生验收。没有设备内存或电量下降比例。 +Native macOS observations on 2026-11-06 and 07 covered all eleven algorithms and None, six physical concentrations, the original target-standard restriction, and disabled datasets. Warning banners and four full warning details were visible. The system-log snapshot contained no decoding errors, invalid weather or CloudKit fallback. The actual candidate runtime files were byte-identical to the three artifacts from commit 4d1f127. See [Mac native observations](mac-native-validation.md) for the exact scope and limitations. -原版参数及默认行为核对见 [原始行为审计](original-behavior-audit.md)。本次修正不能沿用旧版运行脚本同字节的结论;新的设备复测尚未进行。 +The locked Node v22.23.4 CI passed 335 regressions, 40 compiled VM scenarios, 15 expression/route/jq checks and all twelve original argument defaults. The 0.0.0-test version is only a build fixture. Valid dedicated QWeather credentials, the final all-WeatherKit source combination, background/watch customization, long-running VPN operation and device battery or memory percentages were not comprehensively accepted. + +Original behavior and default settings are documented in [the original behavior audit](original-behavior-audit.md). diff --git a/docs/mac-native-validation.md b/docs/mac-native-validation.md new file mode 100644 index 00000000..98000997 --- /dev/null +++ b/docs/mac-native-validation.md @@ -0,0 +1,32 @@ +# Mac native observations + +Observed on 2026-11-06 and 07, macOS 27.3 (26C5035r), Loon for Mac 0.5.0 (1011). The installed candidate was fetched from a fixed GitHub commit; exactly one WeatherKit installation was enabled. Values below describe public test-city previews at the time of observation. + +| City | Algorithm | Displayed index/category | CO (ug/m3) | Concentrations | +| --- | --- | --- | --- | --- | +| Binhai | EU_EAQI | 不佳 | 400 | Six present | +| Wuxi | UBA | 普通 | 400 | Six present | +| Ningbo | WAQI_InstantCast_US | 107 | 1100 | Six present | +| Guiyang | WAQI_InstantCast_CN | 39 | 200 | Six present | +| Xianyang | WAQI_InstantCast_CN_25_DRAFT | 107 | 1200 | Six present | +| Mianyang | CA_AQHI | 5 | 700 | Six present | +| Foshan | HK_AQHI | 4 | 1000 | Six present | +| Zhuzhou | AQHI_Multi_CN | 2 | 500 | Six present | +| Huanggang | AQHI_Multi_CN_HK | 3 | 400 | Six present | +| Luoyang | CN_DEATH_AQHI | 3 | 400 | Six present | +| Wuhu | CN_DEATH_HK_AQHI | 3 | 800 | Six present | +| Yantai | None | 17 | 400 | Six present | + +Binhai showed warning banners. Native Safari displayed four full warning descriptions, issuers and source links. Manchester retained UK DAQI 3, BreezoMeter and the original comparison while CA_AQHI was selected, with no provider requests. With all replacement datasets disabled, Laoshan retained Apple current weather, hourly and ten-day forecasts, AQI 22 and two original warnings (marine strong wind and cold wave). + +The 129411-line system-log snapshot contained zero WeatherAlert/other decoding errors, invalidWeather records or CloudKit fallback. The last script snapshot recorded 72 executions, 72 finished and zero exceptions. These are snapshots at different times, not a long-running stability guarantee. + +[Source CI 4d1f127](https://github.com/hhhoratioxu/WeatherKit/actions/runs/42234270265) performed a locked Node v22.23.4 install and passed 335 regressions, 40 compiled scenarios, 15 expression checks and twelve original defaults. The downloaded five-file ZIP SHA-256 was 2c564605fd9ee25762a2bb9effefa17ed77d82d40c018c8bec0990ddfb51cd67. Its three runtime files were byte-identical to the actual Mac candidate: + +- request: d8e75cb50be818668e26fa7c08b66c471d5fac2b8d63db73a1e5eb6eed333bfd +- response: 1408002e7fa6cdded1e5aef824c1e6daeb88367e19b9651e2ef0d1b89db544a0 +- scale: 5896bada8857312ebf5a70b7519452d21c8fbd6131e0df882d8d5f83b813016f + +## Limits + +Valid dedicated QWeather credentials were not configured, so authentication-failure fallback is not reported as successful authorized API acceptance. The final combination with every source set to WeatherKit was not independently repeated on this candidate. Widgets, watch/background custom replacement, long-running VPN operation and device battery/memory percentages were not comprehensively accepted. No original device bodies, authentication headers, precise coordinates or personal configuration are included. diff --git a/docs/original-behavior-audit.md b/docs/original-behavior-audit.md index e6e07433..72d3656e 100644 --- a/docs/original-behavior-audit.md +++ b/docs/original-behavior-audit.md @@ -53,4 +53,4 @@ Storage=Argument 表示模块参数优先,未提供项可从 BoxJs 读取;Pe v2 原路径继续保留。iOS 27.3 的 WK3 JSON 协商目前限主天气 App;未知后台、组件和手表客户端保留 Apple 传输。此前天气可显示不代表所有消费者已获得自定义替换,这是一项兼容范围限制,不能作为永久删减原功能来描述。 -本轮新增以原版发布默认值为基线的测试,分别检查目标名单、None、部分补齐、昨日对比开关、独立来源和备用查询的明确同意;原有算法测试改为明确选择要转换的标准。自动测试和编译产物检查与真实设备验证分别记录。上游 PR #107 在本次核对期间保持草稿。 +本轮新增以原版发布默认值为基线的测试,分别检查目标名单、None、部分补齐、昨日对比开关、独立来源和备用查询的明确同意;原有算法测试改为明确选择要转换的标准。自动测试和编译产物检查与真实设备验证分别记录。See [Mac native observations](mac-native-validation.md) for the actual native coverage and remaining limits. diff --git a/docs/weather-alert-id-v3.md b/docs/weather-alert-id-v3.md index 2bf54f7e..b5e6fb95 100644 --- a/docs/weather-alert-id-v3.md +++ b/docs/weather-alert-id-v3.md @@ -8,4 +8,4 @@ 这属于新 JSON 传输的读取器兼容,原 Apple 预警在未选择来源替换、选择 WeatherKit 或第三方补充失败时也需要执行。供应商结果同样校验,未知非法标识不能覆盖已有可读预警。原版来源选择、预警合并、指数目标、昨日对比和缓存边界继续保留。 -本次新增发布/开发入口、关闭处理、保留 Apple 来源、补充失败、无效标识、已有字符串、未知产品及重复执行的回归;实际压缩脚本另检查保留与失败回退。新的 GitHub 候选将在 Mac 重新读取预警及详情、检查完整系统日志,通过后才更新正式订阅及提交原作者审核。当前原版 PR #107 保持草稿。 +Regression coverage includes release/development entries, disabled replacement, retained Apple source, supplementation failure, malformed identifiers, existing strings, unknown products and repeated execution. The Mac candidate showed warning banners and four full warning details; with all replacement datasets disabled, two original Apple warnings remained visible. See [Mac native observations](mac-native-validation.md). From bc3fc5c15a901cd580c628c486ab754103e390b2 Mon Sep 17 00:00:00 2001 From: Horatio <161542518+hhhoratioxu@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:41:02 +0800 Subject: [PATCH 8/8] fix(weatherkit): simplify providers and harden warning compatibility Remove the nine optional cache, coalescing, request budget, cooldown, statistics, region fallback and alternate-provider controls. Keep original source settings, per-request timeouts and Apple failure fallback. Preserve unavailable Apple warnings unless enrichment changes actual data; retain opaque JSON containers and a normalized Apple fallback on overflow. Cover periodic and minute-rain edge cases and validate AQ comparison levels. Add source-native warning language preferences with Auto preserving old behavior. Normalize reserved expiry values without changing genuine times or manufacturing an event end. Preserve the original twelve defaults. Keep the PR's upstream release URLs, original display name, independent .loon.bundle.js files, package/lockfile policy and attribution. Retain all seven existing commits. Credit the referenced input-validation direction from Patrick Yan (#108, 24c3f931) and known-container traversal from hhh2210 (#110, f4676fea), as documented in the source. Validation: 430/430 regressions; 52 compiled Loon scenarios; 15 rewrite/jq checks; 13 declarations with all twelve original defaults preserved. Native evidence and remaining limits are documented separately. --- README.md | 2 + arguments-builder.dev.config.ts | 6 +- arguments-builder.full.config.ts | 45 +- arguments-builder.release.config.ts | 6 +- docs/loon-v3-compatibility.md | 14 +- docs/original-behavior-audit.md | 2 + docs/verified-followup-2026-11-14.json | 120 ++++++ docs/verified-followup-2026-11-14.md | 40 ++ rollup.config.mjs | 2 +- scripts/prepare-loon-plugin.mjs | 26 +- scripts/verify-loon-build.mjs | 2 - scripts/verify-loon-bundles.mjs | 315 +++++++++++--- scripts/verify-loon-rewrites.mjs | 27 +- src/class/AirQuality.mjs | 13 +- src/class/ColorfulClouds.mjs | 60 ++- src/class/ForecastNextHour.mjs | 87 ++-- src/class/ProviderRequestCache.mjs | 400 ------------------ src/class/ProviderTransport.mjs | 47 +++ src/class/QWeather.mjs | 87 ++-- src/class/WAQI.mjs | 17 +- src/class/WeatherAlerts.mjs | 13 +- src/class/WeatherKit2.mjs | 12 +- src/data/providerRegions.mjs | 6 - src/function/createProviderEnvironment.mjs | 22 +- src/function/database.mjs | 7 +- src/function/fetchProvider.mjs | 14 +- src/function/getWeatherAlerts.mjs | 104 +++++ src/function/mergeWeatherAlertsProduct.mjs | 19 + src/function/nativeProviderRuntime.mjs | 3 + src/function/parseWeatherKitURL.mjs | 13 +- src/function/providerRecovery.mjs | 5 +- src/function/recoverConfiguredProduct.mjs | 23 -- src/function/resolveWeatherRegion.mjs | 68 ---- src/function/rewriteWeatherResponse.mjs | 11 + src/function/serializeProviderCache.mjs | 41 -- src/function/weatherAlertExpiry.mjs | 11 + src/function/weatherAlertLanguage.mjs | 24 ++ src/loon/request.js | 15 +- src/process/Request.dev.mjs | 347 ++++++---------- src/process/Request.mjs | 328 ++++++--------- src/process/Response.dev.mjs | 453 ++++++++++----------- src/process/Response.mjs | 369 ++++++++--------- src/process/ResponseJSONV3.mjs | 223 +++++++--- src/types.d.ts | 16 +- template/boxjs.settings.json | 152 +------ template/loon.handlebars | 3 +- tests/fixtures/cacheRuntimeEntry.mjs | 3 - tests/mergeWeatherAlertsProduct.test.mjs | 42 ++ tests/pr107ReviewRegressions.test.mjs | 171 ++++++++ tests/providerAdapterRequestCache.test.mjs | 176 -------- tests/providerCacheBundle.test.mjs | 58 --- tests/providerCacheSerialization.test.mjs | 118 ------ tests/providerRequestCache.test.mjs | 389 ------------------ tests/providerResilience.test.mjs | 284 +------------ tests/providerTransport.test.mjs | 147 +++++++ tests/providerTransportGuards.test.mjs | 142 ------- tests/qWeatherGridCache.test.mjs | 146 +++++++ tests/removedControls.test.mjs | 74 ++++ tests/responseJSONV3.test.mjs | 19 +- tests/severeWeatherPage.test.mjs | 40 +- tests/severeWeatherRewriteModules.test.mjs | 70 ++-- tests/surgeProviderRuntime.test.mjs | 37 ++ tests/upstreamIntent.test.mjs | 74 +++- tests/waqiRequestCache.test.mjs | 67 --- tests/weatherAlertExpiry.test.mjs | 133 ++++++ tests/weatherAlertLanguage.test.mjs | 183 +++++++++ tests/weatherBoundaryRegressions.test.mjs | 229 +++++++++++ tests/weatherMemoryPaths.test.mjs | 108 ----- tests/weatherPowerPaths.test.mjs | 53 +-- 69 files changed, 2985 insertions(+), 3398 deletions(-) create mode 100644 docs/verified-followup-2026-11-14.json create mode 100644 docs/verified-followup-2026-11-14.md delete mode 100644 src/class/ProviderRequestCache.mjs create mode 100644 src/class/ProviderTransport.mjs delete mode 100644 src/data/providerRegions.mjs create mode 100644 src/function/getWeatherAlerts.mjs create mode 100644 src/function/mergeWeatherAlertsProduct.mjs create mode 100644 src/function/nativeProviderRuntime.mjs delete mode 100644 src/function/recoverConfiguredProduct.mjs delete mode 100644 src/function/resolveWeatherRegion.mjs create mode 100644 src/function/rewriteWeatherResponse.mjs delete mode 100644 src/function/serializeProviderCache.mjs create mode 100644 src/function/weatherAlertExpiry.mjs create mode 100644 src/function/weatherAlertLanguage.mjs delete mode 100644 tests/fixtures/cacheRuntimeEntry.mjs create mode 100644 tests/mergeWeatherAlertsProduct.test.mjs create mode 100644 tests/pr107ReviewRegressions.test.mjs delete mode 100644 tests/providerAdapterRequestCache.test.mjs delete mode 100644 tests/providerCacheBundle.test.mjs delete mode 100644 tests/providerCacheSerialization.test.mjs delete mode 100644 tests/providerRequestCache.test.mjs create mode 100644 tests/providerTransport.test.mjs delete mode 100644 tests/providerTransportGuards.test.mjs create mode 100644 tests/qWeatherGridCache.test.mjs create mode 100644 tests/removedControls.test.mjs create mode 100644 tests/surgeProviderRuntime.test.mjs delete mode 100644 tests/waqiRequestCache.test.mjs create mode 100644 tests/weatherAlertExpiry.test.mjs create mode 100644 tests/weatherAlertLanguage.test.mjs create mode 100644 tests/weatherBoundaryRegressions.test.mjs diff --git a/README.md b/README.md index 8715cd79..fc59ca74 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ #  iRingo: 🌤 WeatherKit Loon WeatherKit v3 主应用兼容、空气质量算法与有界供应商处理的实现和验证边界见 [Loon v3 兼容说明](docs/loon-v3-compatibility.md)。版本、插件名称和下载地址沿用本项目发布流程。 + +2026-11-14 后续修复移除了附加请求缓存、在途合并、失败冷却、统计、额外地区推断和备用查询;保留原有设置和来源,新增默认 Auto 的预警正文语言偏好。失败保留、periodic、304/ETag、超时单位与未知预警到期值的回归及本次上游候选构建结果见 [后续修复与验证](docs/verified-followup-2026-11-14.md)。 diff --git a/arguments-builder.dev.config.ts b/arguments-builder.dev.config.ts index f3496064..ccadc930 100644 --- a/arguments-builder.dev.config.ts +++ b/arguments-builder.dev.config.ts @@ -1,6 +1,6 @@ -// Modified by hhhoratioxu, 2026-10-19: expose provider response cache lifetime. +// User-facing weather parameters; retain the original defaults. import { defineConfig } from "@iringo/arguments-builder"; -import { requestCache, airQualityFull, api, calculateFull, dataSetsFull, logLevel, nextHourFull, storage, weatherAlerts, weatherFull } from "./arguments-builder.full.config"; +import { airQualityFull, api, calculateFull, dataSetsFull, logLevel, nextHourFull, storage, weatherAlerts, weatherFull } from "./arguments-builder.full.config"; export default defineConfig({ output: { @@ -27,5 +27,5 @@ export default defineConfig({ scope: "@iRingo.WeatherKit.Settings", }, }, - args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel, ...requestCache], + args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel], }); diff --git a/arguments-builder.full.config.ts b/arguments-builder.full.config.ts index 3680eac8..5cd357f9 100644 --- a/arguments-builder.full.config.ts +++ b/arguments-builder.full.config.ts @@ -1,4 +1,4 @@ -// Modified by hhhoratioxu, 2026-10-19: expose bounded provider cache lifetime. +// WeatherKit user settings; preserve original parameters. import { defineConfig } from "@iringo/arguments-builder"; type Arg = { @@ -50,32 +50,6 @@ export const output = { }, }; -export const requestCache: Arg[] = [ - { key: "RequestCache.TTL", name: "[请求缓存] 实况与空气质量(秒)", defaultValue: "60", type: "string", description: "0 禁用所有已完成响应缓存,仍合并在途请求。最大 300 秒。" }, - { key: "RequestCache.ForecastTTL", name: "[请求缓存] 小时与每日预报(秒)", defaultValue: "180", type: "string", description: "变化较慢的预报缓存,最大 300 秒。" }, - { key: "RequestCache.MinutelyTTL", name: "[请求缓存] 分钟降雨(秒)", defaultValue: "15", type: "string", description: "分钟降雨缓存,最大 30 秒。" }, - { key: "RequestCache.AlertsTTL", name: "[请求缓存] 天气预警(秒)", defaultValue: "15", type: "string", description: "JSON 与网页预警缓存,最大 30 秒;不使用过期预警。" }, - { key: "RequestCache.BudgetMs", name: "[弱网] 网络发起预算(毫秒)", defaultValue: "8000", type: "string", description: "1000–15000 毫秒。队列与后续请求共享预算;已发出的请求仍等待原生回调,不取消请求或提前完成。" }, - { key: "RequestCache.CooldownSeconds", name: "[弱网] 失败冷却(秒)", defaultValue: "30", type: "string", description: "相同接口连续失败两次后冷却;鉴权错误与限流立即冷却。最大 120 秒;0 禁用。" }, - { key: "RequestCache.Diagnostics", name: "[诊断] 输出运行统计", defaultValue: false, type: "boolean", description: "每次执行汇总缓存、请求、耗时与回退原因,不包含令牌、原始地址或坐标。" }, - { - key: "Region.Fallback", - name: "[地区] 缺少 country 时", - defaultValue: "Auto", - type: "string", - options: [ - { key: "Auto", label: "自动保守判断" }, - { key: "Locale", label: "沿用语言区域" }, - { key: "CN", label: "中国大陆" }, - { key: "HK", label: "香港" }, - { key: "MO", label: "澳门" }, - { key: "TW", label: "台湾" }, - ], - description: "明确的 country 参数始终优先;自动模式使用同坐标确认记录与离线多边形,边界不确定时沿用语言区域。", - }, - { key: "ProviderFallback.Enabled", name: "[数据源] 允许备用查询", defaultValue: false, type: "boolean", description: "默认仅使用选定来源,失败保留 Apple 数据。开启后,原数据也不可用且已配置另一来源专用凭据时,才尝试一次备用查询。" }, -]; - export const dataSets: Arg[] = [ { key: "DataSets", @@ -154,7 +128,20 @@ const weatherAlertsProvider: Arg = { description: "使用选定的数据源处理天气预警数据。", }; -export const weatherAlerts = [weatherAlertsProvider]; +const weatherAlertsLanguage: Arg = { + key: "WeatherAlerts.Language", + name: "[天气预警] 正文语言偏好", + defaultValue: "Auto", + type: "string", + options: [ + { key: "Auto", label: "自动(沿用请求语言)" }, + { key: "zh-Hans", label: "简体中文(来源支持时)" }, + { key: "en", label: "English(来源支持时)" }, + ], + description: "仅向所选来源请求预警语言,不使用第三方翻译。来源不支持或返回其他语言时保留原文、签发单位与出处;Apple 原始标题和页面标签可能仍随系统语言。", +}; + +export const weatherAlerts = [weatherAlertsProvider, weatherAlertsLanguage]; const nextHourProvider: Arg = { key: "NextHour.Provider", @@ -408,5 +395,5 @@ export const logLevel: Arg[] = [ export default defineConfig({ output, - args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel, ...requestCache], + args: [...dataSetsFull, ...weatherFull, ...weatherAlerts, ...nextHourFull, ...airQualityFull, ...calculateFull, ...api, ...storage, ...logLevel], }); diff --git a/arguments-builder.release.config.ts b/arguments-builder.release.config.ts index 814fd035..98302bf2 100644 --- a/arguments-builder.release.config.ts +++ b/arguments-builder.release.config.ts @@ -1,8 +1,8 @@ -// Modified by hhhoratioxu, 2026-10-19: expose provider response cache lifetime. +// User-facing weather parameters; retain the original defaults. import { defineConfig } from "@iringo/arguments-builder"; -import { requestCache, airQuality, api, calculate, dataSets, logLevel, nextHour, output, storage, weather, weatherAlerts } from "./arguments-builder.full.config"; +import { airQuality, api, calculate, dataSets, logLevel, nextHour, output, storage, weather, weatherAlerts } from "./arguments-builder.full.config"; export default defineConfig({ output: output, - args: [...dataSets, ...weather, ...weatherAlerts, ...nextHour, ...airQuality, ...calculate, ...api, ...storage, ...logLevel, ...requestCache], + args: [...dataSets, ...weather, ...weatherAlerts, ...nextHour, ...airQuality, ...calculate, ...api, ...storage, ...logLevel], }); diff --git a/docs/loon-v3-compatibility.md b/docs/loon-v3-compatibility.md index 00eaf224..b5f7d3af 100644 --- a/docs/loon-v3-compatibility.md +++ b/docs/loon-v3-compatibility.md @@ -1,6 +1,6 @@ # Loon WeatherKit v3 兼容与供应商处理边界 -2026-11-05 核对 [Loon Rewrite](https://nsloon.app/docs/Rewrite/rewrite_v2/)、[Script](https://nsloon.app/docs/Script/script_v2/)、[Script API](https://nsloon.app/docs/Script/script_api/) 与[插件](https://nsloon.app/docs/Plugin/)。最低 Loon 版本为 3.5.1(983)。本文描述源码与自动验证范围,不包含个人订阅、设备授权或原始位置记录。 +2026-11-05 核对 [Loon Rewrite](https://nsloon.app/docs/Rewrite/rewrite_v2/)、[Script](https://nsloon.app/docs/Script/script_v2/)、[Script API](https://nsloon.app/docs/Script/script_api/) 与[插件](https://nsloon.app/docs/Plugin/);2026-11-14 重新核对 Script API 与插件参数约定。最低 Loon 版本保持 3.5.1(983)。本文描述源码与自动验证范围,不包含个人订阅、设备授权或原始位置记录;最新固定基准及检查见 [后续修复记录](verified-followup-2026-11-14.md)。 ## v3 主应用传输 @@ -16,20 +16,22 @@ Apple 的 WK3 FlatBuffer JSON 表示与主应用 JSON 读取器不同。后者 原始 Apple QWeather 的 CO 存在 mg 数值配 µg 标签的历史格式,只有该入口修复假标;已归一化供应商结果依据明确单位处理,不按浓度大小猜测。已有有效浓度优先,只补缺项;完整六项数据没有补充请求。模型未定义的污染物不填 index=-1,物理浓度仍保留。先匹配原版指数目标列表再应用算法,默认 HJ6332012。默认保留已有昨日对比,缺失或明确开启重算时使用所选的独立昨日来源;污染物补齐不绕过指数目标。 -供应商请求合并、Loon 有界持久缓存与冷却复用相同入口。默认缓存 60/180/15/15 秒、三并发、16 个在途、8000 ms 发起预算;失败回调返回前不会提前完成脚本,不依赖取消 API 或清理计时器。预警网页缓存区分来源失败与真实无预警。诊断默认关闭,只输出不含令牌、URL 和坐标的摘要。单产品错误不阻止其他健康产品。备用查询需要明确开启 ProviderFallback.Enabled(默认 false),且 Apple 与所选来源都不可用并有另一来源专用凭据;只填写 Token 不会自动改变来源。 +2026-11-14 移除附加的供应商请求缓存、在途合并、队列/发起预算、失败冷却、摘要统计、额外地区推断和备用查询。旧保存值不能重新激活这些行为。无状态 ProviderTransport 只校验当前请求的状态、格式及正文边界;原有位置网格条件请求和昨日对比缓存保留。来源失败或无有效实体的 304 不会制造可用数据,单产品错误不阻止其他健康产品;正常单请求超时仍保留,所有已发请求等待原生回调后只完成一次。 -原有按需解码、实际修改产品编码、预警延迟复制和单次最终缓存序列化减少临时对象。完整六项污染物与原生功能列表无需额外供应商工作;普通空气质量标尺通过原生 Rewrite 处理,HK/CN 自定义图例使用独立小脚本。 +原有 12 项参数和默认值保持,新增第 13 项 WeatherAlerts.Language,默认 Auto。它只向当前预警来源请求中文或英文,不调用外部翻译;来源不支持时保留原文、签发单位和出处。显式偏好使用 no-store 详情响应及 und 消息语言标记,Auto 保留原请求语言和旧行为,不改变天气/空气质量等其他产品语言。未知到期值使用安全的 UTC 午夜占位字符串,真实时间原样保留,不把未知值制造成事件结束时间。 + +原有按需解码、实际修改产品编码和预警延迟复制保留。完整六项污染物与原生功能列表无需额外供应商工作;普通空气质量标尺通过原生 Rewrite 处理,HK/CN 自定义图例使用独立小脚本。这里的处理边界不构成 CPU、内存或耗电改善比例的实测结论。 ## 构建与验证 Loon 使用 request.loon.bundle.js、response.loon.bundle.js、scale.loon.bundle.js;原版其他客户端的 generic bundle 文件名和模板继续保留。版本由原版发布流程决定,本改动不指定 Fork 版本或下载地址。CI 中的 0.0.0-test 仅用于生成验证夹具,不是可订阅发行版本。 -构建同时生成 dist/LICENSE.txt,包含项目 Apache-2.0、@noble/hashes 的完整 MIT 许可与公开地理数据出处;沿用原版的 dist 发行打包即可携带这些说明。 +构建同时生成 dist/LICENSE.txt,保留项目 Apache-2.0。已删除的缓存/离线地理实现不再打入运行文件,其旧出处记录保留为历史;原 LICENSE、贡献者与源码中引用工作的署名不变。 -`npm test` 检查算法、供应商失败、缓存边界、WK2 编解码、WK3 开发/发布入口及未知字段保留。`npm run build:loon` 编译原生入口并生成 Loon 文件,再执行 40 个压缩脚本 VM 场景与 15 个表达式/路由/jq 检查;这些检查不等于 Loon 真实配置解析器或设备耗电测量。 +当前 `npm test` 通过 430 项回归,覆盖算法、供应商失败、保留的条件缓存、WK2 编解码、WK3 开发/发布入口、预警语言及未知时间。`npm run build:loon` 编译本 PR 的原生入口并生成 Loon 文件,52 个实际编译脚本 VM 场景与 15 个表达式/路由/jq 检查通过;原 12 项默认值与新增 Auto 同时核对。这些检查不等于 Loon 真实配置解析器或设备耗电测量。 Native macOS observations on 2026-11-06 and 07 covered all eleven algorithms and None, six physical concentrations, the original target-standard restriction, and disabled datasets. Warning banners and four full warning details were visible. The system-log snapshot contained no decoding errors, invalid weather or CloudKit fallback. The actual candidate runtime files were byte-identical to the three artifacts from commit 4d1f127. See [Mac native observations](mac-native-validation.md) for the exact scope and limitations. -The locked Node v22.23.4 CI passed 335 regressions, 40 compiled VM scenarios, 15 expression/route/jq checks and all twelve original argument defaults. The 0.0.0-test version is only a build fixture. Valid dedicated QWeather credentials, the final all-WeatherKit source combination, background/watch customization, long-running VPN operation and device battery or memory percentages were not comprehensively accepted. +The earlier locked Node v22.23.4 CI passed 335 regressions and 40 compiled VM scenarios on the older artifact. Current local validation uses Node v22.23.5 and the separate counts above; it does not turn older device evidence into validation of this rebuilt artifact. The 0.0.0-test version is only a build fixture. Valid dedicated QWeather credentials, the final all-WeatherKit source combination, background/watch customization, long-running VPN operation and device battery or memory percentages remain outside comprehensive native acceptance. Original behavior and default settings are documented in [the original behavior audit](original-behavior-audit.md). diff --git a/docs/original-behavior-audit.md b/docs/original-behavior-audit.md index 72d3656e..b3bd8ed0 100644 --- a/docs/original-behavior-audit.md +++ b/docs/original-behavior-audit.md @@ -1,5 +1,7 @@ # 原版 WeatherKit 的用途、参数与 Fork 行为核对 +历史记录说明:下文保存 2026-11-05 的行为核对。2026-11-14 已移除其中所述附加缓存、合并、冷却、统计、地区回退与备用查询;保留原有 12 项默认值,新增默认 Auto 的预警正文语言选项。当前处理与验证以 [后续修复](verified-followup-2026-11-14.md) 为准,旧描述不代表这些已移除功能仍可启用。 + 核对日期:2026-11-05。基线为 NSRingo/WeatherKit 的 c40e8f8、v3.3.2 正式 Loon 文件及[官方配置指南](https://NSRingo.github.io/guide/Weather/weather-kit)。下载的原版插件为 5740 字节,SHA-256 为 e994f11d8eb987b81bda0c682e95287d4ee166cabf8a5563d2fa8aa4bdfe3e24。 ## 原版具体做什么 diff --git a/docs/verified-followup-2026-11-14.json b/docs/verified-followup-2026-11-14.json new file mode 100644 index 00000000..36871bd4 --- /dev/null +++ b/docs/verified-followup-2026-11-14.json @@ -0,0 +1,120 @@ +{ + "reportDate": "2026-11-14", + "timezone": "Asia/Taipei", + "basePRHead": "355b5f5dad9d01cb13167949f8334a489b301811", + "baseTarget": "c40e8f8a13c8304a7ef500cb74acc5948af14b3f", + "priorCommitCount": 7, + "preserveExistingHistoryAndAttribution": true, + "sharedImplementationReference": "7be5036acd8ab8b3a231471d3be695f08f52c828", + "sharedSourceFileCount": 46, + "sharedSourceSHA256": { + "src/Hono.js": "12dd8267f48c83b43c9297bb7ae659b60740567bf49edf96ebce08acdb4e1f9e", + "src/response.js": "c7f93ace394dc39e46bcc9abf05f565337f1c2f19c4f45d44fe01b5d8dcfd3de", + "src/request.js": "e28ada4797e3b38c99886ff9e60c2fcc584a43a49fc2cea6f6ad8f5d915cc8eb", + "src/types.d.ts": "804f7d3f30ca3afec7d9ff1533bc3b5ca8397c39bb0c8e59b603d659e5689047", + "src/response.dev.js": "fd4d8e0723317f3a6c59e84f1178d774a3e8cd886bca6a627d28a5097027c986", + "src/request.dev.js": "28b0d68accfcdf264dc3995e03a488d3e8132b32d028548c0bc106841b4e3808", + "src/loon/response.js": "dcf952fe6210610974f653daf56db951cc3d51e3870a7cff22418f0dead5ca9f", + "src/loon/request.js": "71f249a4e4d8427554ee7d5c8b0609059c20d76a2271fabeb7d0aa4cb7afc6b9", + "src/loon/scale.js": "fa1ed608f977081f0cf8e7f10df81b389d2c92cb0a50b1104400c6e57269ff62", + "src/function/providerRecovery.mjs": "21e8c1261a7c5ca37119a5d56f8de2fccae3e47751145257beaec9bc474b6535", + "src/function/weatherMemoryLimits.mjs": "8fb9100b9f88b157409fc4e62788c8fea0ec5c7b7d41462a96c634a1ef69f549", + "src/function/planWeatherProducts.mjs": "80aae3ecefb8a8b43b9961e42a73acb6f98eb446969456b033272e0ca6387324", + "src/function/setENV.mjs": "751da08490a0769dcb3aca14803f160fd56c5edca92a6457a237c487a870872e", + "src/function/mergeForecastCopy.mjs": "9b6676ef3923e541684c1c1bc889f870f5660e96a166e70ca7e34750867d6db1", + "src/function/fetchProvider.mjs": "ba178b37c087906c118a5389d9cd17b78d79cf0eeb5afd72b473a4564ce73c88", + "src/function/rewriteWeatherResponse.mjs": "9686d5380f5e6c94858411a20fc9a99607d9fabc2a9b6b1d91dfccf5de79a0ca", + "src/function/mergeWeatherAlertsProduct.mjs": "05ea8ae8446c297e4a767444e6d0310f6a2f14333592bb178fd13c8da985ede5", + "src/function/parseWeatherKitURL.mjs": "05cb7249a98ab416bd10b84e4c3a11bae50162be5fb0aa93f2cd44175a3369e1", + "src/function/utf8Length.mjs": "7d07252be58a2478b4c9d1f421db3037da3896d47cafb5d2a6a466a4d7dbcd81", + "src/function/getWeatherAlerts.mjs": "9668cd9f7a3df5c1cefb9b51804eaf2654251283ba3718f47d3393c3e76cc95b", + "src/function/database.mjs": "2acf7330c4e6c9a922ad60b2b1e872f0742c310a1328a070207bec4b228a6c0a", + "src/function/providerNameToLogo.mjs": "a4df024193c390b38b247bf2c4e143743e4e4baa1374b1b6c507cf4822a32a73", + "src/function/weatherAlertExpiry.mjs": "b97e61879f754462e5612cdfcccfeba4126aa6032279b68e9bba169f7062548f", + "src/function/nativeProviderRuntime.mjs": "91bd17c49dc6f11f8885140f894e5bdb95ad5ebb3df593a6f893e3e92e43883c", + "src/function/mergeWeatherKitAvailability.mjs": "237189d9d7b421fd3d72db246c836dd93ef3975e6e36d51d77f767e2b6e3e823", + "src/function/ensureTextEncoder.mjs": "6b4525858d09bdb1a734c99936350f8bf35ceb25b6d88e76ba0298e499841b05", + "src/function/createProviderEnvironment.mjs": "478441be3d71df17f79e06d55976b1155cbb03ca0f3a0812a6a1b8e07982ef1d", + "src/function/weatherAlertLanguage.mjs": "db4ef4178e6f5a44e549f60d408c0e3485098e9d609057c01c9e996ff54ae9e7", + "src/class/HonoWorkerAdapter.mjs": "e96b6d23766ea394361030c470559d33ed52a5699c59d17c930311546c654dfa", + "src/class/ForecastNextHour.mjs": "8b117edf0b365603690ea375122d4520b56dc47969a8dd6778131d2ac2327314", + "src/class/AirQuality.mjs": "c5ebd8eab4dc3ed76585151d1241e256950e674dcf80634dc234c517e1aa059c", + "src/class/AirQualityScale.mjs": "c96c2570b9a7984d64688d6c63a4a3f8d6e38d0bdba2878f013367c11f433007", + "src/class/ColorfulClouds.mjs": "017e08f4bebf98f6003127fc8f05e5d4b1deb8b3eeebb266095584dc2a853589", + "src/class/QWeather.mjs": "41730c349364ec75c2db7a3256b9f45135edf3447d2bca1fa2d6e8d4d750ec7f", + "src/class/WAQI.mjs": "27566784354a4b1af3468444daae7cbdfa44f07874c5dbece1a32d6b22ae6ad1", + "src/class/SimplePrecisionMath.mjs": "5a95761beaa6423f0925ad67d2dba9e5eb08ee03564f739b323686d22478284e", + "src/class/WeatherKit2.mjs": "a436cf8a9f636881593dd5f771fa20796a16fa7f93edb67143687c5e4bd4a4ed", + "src/class/Weather.mjs": "a5ce014260bca6a1b79ecd38466a4baaac089d05c13bcdbffff149e45ac28153", + "src/class/MatchEnum.mjs": "a780ead4fe371a7e4590affa3bffd52bb07becc3bcb0e2f565d7320b94258ff1", + "src/class/ProviderTransport.mjs": "b501f0ce51874f60cace172a75561d65f95262dc8b6c8a37d6923eaeee31cdcd", + "src/class/WeatherAlerts.mjs": "1051a31fc95b3465150cc353f9a056c10eddee00b02e33c0c933902480d6c978", + "src/process/Request.mjs": "76bc95fd7badcf22ad47d9a2b9e16a3a6bc6d84b7341303efbcb1b589805bc86", + "src/process/Response.dev.mjs": "3ad6061f8879a71b5ab82ce3f78775a7293987ecfb60d8cb1cdd767ea612fa19", + "src/process/Request.dev.mjs": "be0bad21a7bbd7183fd849671a1b6a371f33f56788e3e37a5e29813c5fc1a5f4", + "src/process/ResponseJSONV3.mjs": "2ea2a5340018d1221e056b6e87ce28daffdf6350f01ab1b506b861e2d49328a5", + "src/process/Response.mjs": "3cfcb0e5a211b2366fb4cc0acf5b532b9c4ba3295c6a1104f047621c24c767d2" + }, + "validation": { + "tests": 430, + "pass": 430, + "fail": 0, + "skipped": 0, + "compiledLoonPass": 52, + "compiledLoonFail": 0, + "nativeExpressionAndJQPass": 15, + "nativeExpressionAndJQFail": 0, + "originalArgumentDefaults": 12, + "totalArgumentDeclarations": 13, + "languageDefault": "Auto", + "BoxJSExistingSettingsPreserved": true, + "node": "v22.23.5", + "localDependencyMode": "existing recovered local dependencies; not a fresh npm ci" + }, + "PRConventions": { + "originalDisplayNamePreserved": true, + "packageVersionPolicyPreserved": true, + "packageJSONUnchanged": true, + "lockfileUnchanged": true, + "licenseUnchanged": true, + "otherClientTemplatesUnchanged": true, + "upstreamReleaseURLsPreserved": true, + "independentLoonBundleNamesPreserved": true, + "fixtureBuildVersion": "0.0.0-test", + "fixtureVersionRestoredAfterBuild": true, + "forkDistributionDirectoriesCopied": false, + "forkRuntimeURLsIntroduced": false, + "machineConfigurationOrRawPayloadsCopied": false + }, + "localBuildArtifactSHA256": { + "scale.loon.bundle.js": "5896bada8857312ebf5a70b7519452d21c8fbd6131e0df882d8d5f83b813016f", + "iRingo.WeatherKit.lpx": "97fc369dc45af5a910b98ca13788830e4123691dbdf94da6c793593324bd7e7c", + "request.loon.bundle.js": "484509dc16aac6e09a6a60bb027377854ae898f4bba4f8006120504fab1bc4d4", + "LICENSE.txt": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", + "response.loon.bundle.js": "e7e1ba8ee5bd394494e1301e6c033a5edc03dcd70b86798730feffc28a113fcb" + }, + "referencedWork": { + "PatrickYanPR108": "24c3f9311f051a9df9465238ee85b4a5f2c4f9c2", + "hhh2210PR110": "f4676feae89a27a8403c574c6086a433d5adb3c9", + "license": "Apache-2.0", + "sourceAttributionCommentsPreserved": true + }, + "nativeAcceptance": { + "installedThisPRBuild": false, + "earlier4d1f127AndF571a49EvidenceIsHistorical": true, + "sameSharedBusinessCodeForkWarningAcceptance": "2026-11-14 Chinese and English visible text; complete Chinese JSON source comparison", + "differentBuildDistributionFromThisPR": true, + "notClaimedAllFunctionsOrClientsPass": true, + "fullEnglishNativeJSONSaved": false, + "sameTransactionHTMLAndJSONCapture": false + }, + "clientUIOrVPNChangedDuringPRSync": false, + "newPRCreated": false, + "forcePushAllowed": false, + "tagsOrReleaseChanged": false, + "PRMetadataUpdatePlan": { + "title": "preserve", + "body": "synchronize current features and validation limits", + "comments": "none" + } +} diff --git a/docs/verified-followup-2026-11-14.md b/docs/verified-followup-2026-11-14.md new file mode 100644 index 00000000..8f3f707a --- /dev/null +++ b/docs/verified-followup-2026-11-14.md @@ -0,0 +1,40 @@ +# 后续修复与上游候选验证 + +日期:2026-11-14,Asia/Taipei。本次在 PR #107 实际 head `355b5f5dad9d01cb13167949f8334a489b301811` 上追加改动。核对时它仍为原有七个提交,目标为 main `c40e8f8a13c8304a7ef500cb74acc5948af14b3f`,没有后续他人提交且可合并;原有提交、作者与 Apache-2.0 保留。PR 标题保持不变;正文同步修正已移除功能及当前验证范围,并保留原有有效描述与贡献来源。 + +## 本次内容 + +- 移除九项附加控制及对应行为:短时供应商响应缓存、在途合并、队列/发起预算、失败冷却、运行统计、额外地区推断和备用来源查询。旧保存值不再激活它们。正常单请求超时、失败保留 Apple、原位置网格条件缓存与昨日对比缓存继续保留。 +- 将每次供应商回调的结果与其他请求隔离;无有效来源或没有实际合并预警时保留 Apple 集合、不可用状态、正文和验证头。来源失败、孤立 304 或无预警都不能制造可用数据。 +- 补齐既有 periodic、restOfDay、分钟降水末尾转折和空分钟表的边界;只适配已知 JSON 容器,未知子树保持形状;输出超限使用已适配且有界的 Apple 回退。 +- AQ 等级输入先检查合法正整数或对应字符串,避免布尔、数组或小数被强制转换后参与等级比较;保留原标准目标、算法、物理浓度、单位、零值、CO 入口标记与昨日设置。 +- 新增第 13 项预警正文语言偏好 Auto/zh-Hans/en,原有 12 项默认值不变。只请求所选来源已有语言,不调用翻译服务;来源返回原文或混合语言时保留正文、签发单位和链接。显式详情响应 no-store、消息 language 为 und,Auto 保持旧行为;其他天气产品语言独立。 +- 兼容旧、新未知预警到期值,输出 `9999-12-31T00:00:00Z`,避免正时区跨入 10000 年而失去旧 Apple 客户端的未知年份识别。真实来源及 Apple 时间原样保留,未知值不能制造 eventEndTime;真实已过期记录仍过滤。没有证据将该旧边界归因为历史无正文的原因。 + +## 上游集成保持 + +本候选保持本 PR 的原版显示名称、版本策略、NSRingo 发布地址与 `.loon.bundle.js` 独立文件命名。没有复制 Fork 的 Loon/script、Surge/script 分发目录、改名模板、发布版本、用户专用 AGENTS、机器配置或原始抓包。package.json、package-lock.json、LICENSE、原版 generic/dev 入口和其他客户端模板保持 PR 原字节;仅共享参数构造与必要源码适用于原有客户端。BoxJS 补充同一个语言项,其余项逐项不变。 + +运行源码和测试从已经验证的后续修复中移植,再使用本 PR 的工作树、构建脚本、模板及独立文件名重新测试。共享业务源码与已验证 Fork 提交 `7be5036acd8ab8b3a231471d3be695f08f52c828` 相同;Fork 的四项改名测试与专用分发检查不带入本 PR,不能直接把 Fork 的 434 项计数或原生安装结果冒充这份构建的验证。 + +源码保留 Patrick Yan 的 [PR #108](https://github.com/NSRingo/WeatherKit/pull/108) 输入检查方向及固定提交 `24c3f9311f051a9df9465238ee85b4a5f2c4f9c2`,以及 hhh2210 的 [PR #110](https://github.com/NSRingo/WeatherKit/pull/110) 已知容器遍历与固定提交 `f4676feae89a27a8403c574c6086a433d5adb3c9`;后者也注明源自 #107 的 JSON 适配关系。这些来源使用 Apache-2.0,没有将其作为新原创或整体替换协议架构。 + +## 当前验证 + +| 检查 | 当前结果 | +| --- | --- | +| 本 PR 工作树完整回归 | 430/430,失败 0、跳过 0;含 BoxJS 语言声明及旧参数保护 | +| 本 PR 实际 Loon 产物 | 52/52;表达式/路由/jq 15/15;13 项声明及原 12 项默认值通过 | +| 原生超时与条件响应 | 毫秒/Surge 秒边界、异步一次完成、位置网格 ETag/304 及无实体 304 的回归通过 | +| 构建身份 | Node v22.23.5;沿用已恢复的本地依赖,未将本次本地运行称作全新 npm ci | +| 版本和下载 | 构建临时使用 PR 既有 CI 约定 `0.0.0-test`,完成后恢复无 version 的原 package.json;生成文件只在忽略目录 | + +2026-11-14 重新核对 [Loon 插件参数](https://nsloon.app/docs/Plugin/) 与 [Script API](https://nsloon.app/docs/Script/script_api/) 的类型、绑定、毫秒超时与回调完成约定;没有新增宿主 API 或提高最低版本。表达式检查和 VM 模拟不能证明宿主 VPN 的内存上限。 + +## 设备证据的范围 + +之前 f571a49 的 Mac Weather 样本曾显示主天气、小时/每日、分钟降水、上海 EAQI 与 CO、昨日比较及英国 DAQI;较早 4d1f127 的原生记录继续保留于 [原有设备观察](mac-native-validation.md)。这些是旧产物证据,本次 PR 构建未重新安装至客户端。 + +同日受控 Mac Loon/Safari 窗口用共享业务实现的另一份 Fork 分发产物显示了仍有效的革吉公开预警中英文正文、签发单位及来源;完整中文 JSON 的正文、定义和五条指南逐段匹配来源,包含安全未知到期值且没有 eventEndTime。该窗口使用的 Fork request SHA-256 是 `484509dc16aac6e09a6a60bb027377854ae898f4bba4f8006120504fab1bc4d4`,response 为 `e7e1ba8ee5bd394494e1301e6c033a5edc03dcd70b86798730feffc28a113fcb`。它支持共享行为,但不是本 PR 的 `.loon.bundle.js` 设备认证。原生 AX 对英文长字段只提供可见前缀,未另存完整英文原生 JSON,也未取得同一 HTTP 事务的 HTML/JSON 成对抓包。 + +该窗口已完整恢复原插件、12 项保存值、既有脚本及当前配置并停止临时服务;本次同步 PR 没有再次使用 Mac UI、安装插件或改变 VPN/节点。Surge 真机、其他地区和来源、原文回退的完整原生链路、iOS/watchOS、真实网络切换/304 故障注入、长期稳定性和资源量测仍未验证。不得把本报告写成全功能或全部设备通过。 diff --git a/rollup.config.mjs b/rollup.config.mjs index 34fa2da8..b4f6f127 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -56,7 +56,7 @@ export default [ ...(name === "scale" ? [{ name: "weatherkit-third-party-licenses", generateBundle() { - this.emitFile({ type: "asset", fileName: "LICENSE.txt", source: `${readFileSync(new URL("./LICENSE", import.meta.url), "utf8")}\n\n@noble/hashes 1.8.0\n${readFileSync(new URL("./node_modules/@noble/hashes/LICENSE", import.meta.url), "utf8")}\n\nProvider region data: Natural Earth, public domain; see docs/provider-regions-source.json.\n` }); + this.emitFile({ type: "asset", fileName: "LICENSE.txt", source: readFileSync(new URL("./LICENSE", import.meta.url), "utf8") }); }, }] : []), ], diff --git a/scripts/prepare-loon-plugin.mjs b/scripts/prepare-loon-plugin.mjs index 6a010c4d..1e9cc411 100644 --- a/scripts/prepare-loon-plugin.mjs +++ b/scripts/prepare-loon-plugin.mjs @@ -1,21 +1,13 @@ // Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -// Finalize native parameter types after the shared arguments generator. import assert from "node:assert/strict"; import { readFile, writeFile } from "node:fs/promises"; +let plugin = await readFile(new URL("../dist/iRingo.WeatherKit.lpx", import.meta.url), "utf8"); +assert.doesNotMatch(plugin, /RequestCache|ProviderFallback|Region\.Fallback/); +const defaults = new Map([...plugin.matchAll(/^([^=\n]+?)\s*=\s*(?:input|select),("(?:\\.|[^"\\])*")/gm)].map(([, key, value]) => [key.trim(), JSON.parse(value)])); +const original = JSON.parse(await readFile(new URL("../tests/fixtures/upstream-intent.json", import.meta.url), "utf8")).originalArgumentDefaults; +assert.equal(defaults.size, 13); +assert.equal(defaults.get("WeatherAlerts.Language"), "Auto"); +for (const [key, value] of Object.entries(original)) assert.equal(defaults.get(key), value); -const file = new URL("../dist/iRingo.WeatherKit.lpx", import.meta.url); -let plugin = await readFile(file, "utf8"); -for (const key of ["RequestCache.TTL", "RequestCache.ForecastTTL", "RequestCache.MinutelyTTL", "RequestCache.AlertsTTL", "RequestCache.BudgetMs", "RequestCache.CooldownSeconds"]) { - const line = plugin.split("\n").find(line => line.startsWith(`${key} = input,`)); - assert.ok(line, `Missing Loon input: ${key}`); - plugin = plugin.replace(line, line.replace(/(input,"[^"]*",)/, "$1type=string,")); -} -for (const key of ["RequestCache.Diagnostics", "ProviderFallback.Enabled"]) { - const declaration = `${key} = switch,false,type=boolean,`; - plugin = plugin.replace(`${key} = switch,false,`, declaration); - assert.ok(plugin.includes(declaration), `Missing typed switch: ${key}`); -} -const region = plugin.split("\n").find(line => line.startsWith("Region.Fallback = select,")); -assert.ok(region, "Missing Loon region selector"); -plugin = plugin.replace(region, region.replace(",tag=", ",type=string,tag=")); -await writeFile(file, plugin); +if (!/^#!loon_version = /m.test(plugin)) plugin = plugin.replace(/^(#!system_version = .+)$/m, "$1\n#!loon_version = 3.5.1(983)"); +await writeFile(new URL("../dist/iRingo.WeatherKit.lpx", import.meta.url), plugin); diff --git a/scripts/verify-loon-build.mjs b/scripts/verify-loon-build.mjs index 7fe126f6..aa3c07e2 100644 --- a/scripts/verify-loon-build.mjs +++ b/scripts/verify-loon-build.mjs @@ -5,7 +5,5 @@ import verifyLoonBundles from "./verify-loon-bundles.mjs"; const root = path.resolve(import.meta.dirname, ".."); const license = await readFile(path.join(root, "dist/LICENSE.txt"), "utf8"); assert.match(license, /Apache License/); -assert.match(license, /Copyright \(c\) 2022 Paul Miller/); -assert.match(license, /Permission is hereby granted, free of charge/); const result = await verifyLoonBundles(root); console.log(JSON.stringify(result)); diff --git a/scripts/verify-loon-bundles.mjs b/scripts/verify-loon-bundles.mjs index 80de7a84..08ca9d5d 100644 --- a/scripts/verify-loon-bundles.mjs +++ b/scripts/verify-loon-bundles.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import vm from "node:vm"; import verifyLoonRewrites from "./verify-loon-rewrites.mjs"; -export default async function verifyLoonBundles(root) { +export default async function verifyLoonCache(root) { const directory = path.join(root, "dist"); const requestCode = await readFile(path.join(directory, "request.loon.bundle.js"), "utf8"), responseCode = await readFile(path.join(directory, "response.loon.bundle.js"), "utf8"), @@ -13,6 +13,9 @@ export default async function verifyLoonBundles(root) { const plugin = await readFile(path.join(directory, "iRingo.WeatherKit.lpx"), "utf8"); const upstreamDefaults = JSON.parse(await readFile(path.join(root, "tests/fixtures/upstream-intent.json"), "utf8")).originalArgumentDefaults; const emittedDefaults = new Map([...plugin.matchAll(/^([^=\n]+?)\s*=\s*(?:input|select),("(?:\\.|[^"\\])*")/gm)].map(([, key, value]) => [key.trim(), JSON.parse(value)])); + assert.equal(emittedDefaults.size, 13); + assert.equal(emittedDefaults.get("WeatherAlerts.Language"), "Auto"); + for (const code of [plugin, requestCode, responseCode, scaleCode]) assert.doesNotMatch(code, /RequestCache|ProviderFallback|Region\.Fallback|WeatherKit\.Provider(?:Cache|Cooldown)/); for (const [key, value] of Object.entries(upstreamDefaults)) assert.equal(emittedDefaults.get(key), value, `Original argument default: ${key}`); const nativeRewrites = verifyLoonRewrites(plugin); const storage = new Map(), @@ -105,9 +108,17 @@ export default async function verifyLoonBundles(root) { const v3Request = { url: "https://weatherkit.apple.com/api/v3/weather/zh-Hans-CN/31.2/121.5?country=CN&dataSets=currentWeather", method: "GET", headers: {} }; const v3Original = ` ${JSON.stringify({ currentWeather: { metadata: { latitude: 31.2, longitude: 121.5, sourceType: "MODELED", temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLOUDY", futureField: [1, 2] }, unknownProduct: { retain: true } })}\n`; const v3Response = { status: 200, headers: { "Content-Type": "application/json", ETag: "fixture", "Content-Length": "1" }, body: v3Original }; - const v3Replaced = await run(responseCode, v3Request, v3Response, (_, callback) => { - setImmediate(() => callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-04T00:00:00Z", now: { temp: "23", icon: "100", text: "晴", feelsLike: "24", cloud: "20", humidity: "50", precip: "0", pressure: "1000", dew: "10", vis: "10", wind360: "90", windSpeed: "2" } }))); - }, { DataSets: "currentWeather", "Weather.Provider": "QWeather" }); + const v3Replaced = await run( + responseCode, + v3Request, + v3Response, + (_, callback) => { + setImmediate(() => + callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-04T00:00:00Z", now: { temp: "23", icon: "100", text: "晴", feelsLike: "24", cloud: "20", humidity: "50", precip: "0", pressure: "1000", dew: "10", vis: "10", wind360: "90", windSpeed: "2" } })), + ); + }, + { DataSets: "currentWeather", "Weather.Provider": "QWeather" }, + ); const v3Body = JSON.parse(v3Replaced.output.body); assert.equal(v3Replaced.nativeRequests, 1); assert.equal(v3Body.currentWeather.temperature, 23); @@ -119,7 +130,11 @@ export default async function verifyLoonBundles(root) { assert.equal(v3Replaced.output.headers.ETag, undefined); assert.equal(v3Response.body, v3Original); scenarios.push("v3 JSON replaces weather with WK3 types and preserves unknown fields after native callbacks finish"); - const nativeJSON = { currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: ["CLOUDY"] }, forecastDaily: { metadata: { temporarilyUnavailable: false }, days: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 3, minimum: 1, maximum: 4, precipitationType: ["RAIN"] }] }] }, forecastPeriodic: { metadata: {}, forecasts: [{ periods: [{ conditionCode: ["CLEAR"], snowfallAmount: { expected: 0, minimum: 0, maximum: 0 }, precipitationAmountByType: [] }] }] } }; + const nativeJSON = { + currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: ["CLOUDY"] }, + forecastDaily: { metadata: { temporarilyUnavailable: false }, days: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 3, minimum: 1, maximum: 4, precipitationType: ["RAIN"] }] }] }, + forecastPeriodic: { metadata: {}, forecasts: [{ periods: [{ conditionCode: ["CLEAR"], snowfallAmount: { expected: 0, minimum: 0, maximum: 0 }, precipitationAmountByType: [] }] }] }, + }; const nativeCompatible = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeJSON) }, noNetwork, { DataSets: "currentWeather", "Weather.Provider": "WeatherKit" }); const nativeBody = JSON.parse(nativeCompatible.output.body); assert.equal(nativeBody.currentWeather.conditionCode, "CLOUDY"); @@ -128,10 +143,60 @@ export default async function verifyLoonBundles(root) { assert.equal(nativeBody.forecastPeriodic.forecasts[0].periods[0].snowfallAmount, 0); assert.equal(nativeCompatible.nativeRequests, 0); scenarios.push("Apple v3 JSON is adapted to the native Weather reader, including unselected periodic forecasts"); + for (const product of ["forecastPeriodic", "weatherChanges"]) { + const input = { + status: 200, + headers: { "Content-Type": "application/json", ETag: "old", "Content-Encoding": "gzip", "Transfer-Encoding": "chunked" }, + body: JSON.stringify({ [product]: { metadata: {}, forecasts: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 0.4 }] }] }, futureProduct: { conditionCode: ["OPAQUE"] } }), + }; + const result = await run(responseCode, { ...v3Request, url: v3Request.url.replace("currentWeather", product) }, input, noNetwork, { DataSets: "" }); + const body = JSON.parse(result.output.body); + assert.equal(body[product].forecasts[0].conditionCode, "RAIN"); + assert.equal(body[product].forecasts[0].precipitationAmount, 0.4); + assert.deepEqual(body.futureProduct.conditionCode, ["OPAQUE"]); + for (const name of ["ETag", "Content-Encoding", "Transfer-Encoding"]) assert.equal(result.output.headers[name], undefined); + assert.equal(result.nativeRequests, 0); + scenarios.push(`${product}-only JSON adapts native fields and invalidates original entity headers without supplier work`); + } + const minuteRequest = { ...v3Request, url: v3Request.url.replace("currentWeather", "forecastNextHour") }; + for (const invalid of [false, true]) { + storage.clear(); + const result = await run( + responseCode, + minuteRequest, + v3Response, + (_, callback) => + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", server_time: 1800000000, result: { minutely: { status: "ok", description: "未来有小雨", probability: [0.8, 0.8, 0.8, 0.8], precipitation_2h: invalid ? [] : Array.from({ length: 120 }, (_, i) => (i < 70 ? 0 : 1)) } } })), + { DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds" }, + invalid, + ); + assert.equal(result.nativeRequests, 1); + if (invalid) assert.equal(result.output.body, v3Original); + else { + const product = JSON.parse(result.output.body).forecastNextHour; + assert.equal(product.minutes.length, 85); + assert.deepEqual( + product.summary.map(item => item.clear), + [true, false], + ); + assert.equal(product.condition.length, 2); + } + scenarios.push(invalid ? "empty provider minutes preserve the exact original weather response" : "a rain transition at the last summarized minute survives the compiled provider path"); + } const alertUUID = "00112233-4455-6677-8899-aabbccddeeff"; const alertBytes = [0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255]; - const nativeAlerts = { weatherAlerts: { metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, detailsUrl: "https://www.qweather.com/severe-weather/fixture-101190110.html?from=AppleWeatherService", alerts: [{ id: { bytes: alertBytes }, description: "Fixture warning", detailsUrl: "https://example.com/fixture-warning", futureAlertField: true }] }, futureProduct: { id: { bytes: alertBytes } } }; - const retainedAlerts = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, { status: 200, headers: { "Content-Type": "application/json", ETag: "original" }, body: JSON.stringify(nativeAlerts) }, noNetwork, { DataSets: "", "WeatherAlerts.Provider": "WeatherKit" }); + const nativeAlerts = { + weatherAlerts: { + metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, + detailsUrl: "https://www.qweather.com/severe-weather/fixture-101190110.html?from=AppleWeatherService", + alerts: [{ id: { bytes: alertBytes }, description: "Fixture warning", detailsUrl: "https://example.com/fixture-warning", futureAlertField: true }], + }, + futureProduct: { id: { bytes: alertBytes } }, + }; + const retainedAlerts = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, { status: 200, headers: { "Content-Type": "application/json", ETag: "original" }, body: JSON.stringify(nativeAlerts) }, noNetwork, { + DataSets: "", + "WeatherAlerts.Provider": "WeatherKit", + }); const retainedAlertBody = JSON.parse(retainedAlerts.output.body); assert.equal(retainedAlertBody.weatherAlerts.alerts[0].id, alertUUID); assert.equal(retainedAlertBody.weatherAlerts.alerts[0].description, "Fixture warning"); @@ -139,21 +204,60 @@ export default async function verifyLoonBundles(root) { assert.deepEqual(retainedAlertBody.futureProduct, nativeAlerts.futureProduct); assert.equal(retainedAlerts.nativeRequests, 0); scenarios.push("unselected Apple warning UUID bytes become native strings without changing warning content or unknown products"); - const failedNativeAlerts = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeAlerts) }, (_, callback) => setImmediate(() => callback(null, { status: 503, headers: { "Content-Type": "text/html" } }, "Source unavailable")), { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeatherWeb" }, true); + const failedNativeAlerts = await run( + responseCode, + { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, + { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(nativeAlerts) }, + (_, callback) => setImmediate(() => callback(null, { status: 503, headers: { "Content-Type": "text/html" } }, "Source unavailable")), + { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeatherWeb" }, + true, + ); assert.equal(JSON.parse(failedNativeAlerts.output.body).weatherAlerts.alerts[0].id, alertUUID); assert.equal(JSON.parse(failedNativeAlerts.output.body).weatherAlerts.alerts[0].description, "Fixture warning"); assert.equal(failedNativeAlerts.nativeRequests, 1); scenarios.push("failed warning supplementation preserves a readable original Apple warning identifier"); storage.clear(); - const appleCO = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "QWeather" }, scale: "HJ6332012", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants: [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 0.7]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })) } }; - const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US", "AirQuality.Current.Index.Replace": "EPA_NowCast" }); + const appleCO = { + airQuality: { + metadata: { temporarilyUnavailable: false, providerName: "QWeather" }, + scale: "HJ6332012", + index: 42, + categoryIndex: 1, + primaryPollutant: "PM2_5", + previousDayComparison: "SIMILAR", + pollutants: [ + ["PM2_5", 35], + ["PM10", 60], + ["OZONE", 90], + ["NO2", 35], + ["SO2", 15], + ["CO", 0.7], + ].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })), + }, + }; + const appleCOResult = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { + DataSets: "airQuality", + "AirQuality.Calculate.Algorithm": "WAQI_InstantCast_US", + "AirQuality.Current.Index.Replace": "EPA_NowCast", + }); assert.equal(JSON.parse(appleCOResult.output.body).airQuality.pollutants.find(p => p.pollutantType === "CO").amount, 700); assert.equal(appleCOResult.nativeRequests, 0); scenarios.push("native Apple QWeather CO is repaired at ingress with no duplicate provider lookup"); for (const algorithm of ["UBA", "EU_EAQI", "WAQI_InstantCast_US", "WAQI_InstantCast_CN", "WAQI_InstantCast_CN_25_DRAFT", "CA_AQHI", "HK_AQHI", "AQHI_Multi_CN", "AQHI_Multi_CN_HK", "CN_DEATH_AQHI", "CN_DEATH_HK_AQHI", "None"]) { - const pollutants = [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); + const pollutants = [ + ["PM2_5", 35], + ["PM10", 60], + ["OZONE", 90], + ["NO2", 35], + ["SO2", 15], + ["CO", 700], + ].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); const body = { airQuality: { metadata: { temporarilyUnavailable: false, providerName: "Apple fixture" }, scale: "EPA_NowCast", index: 42, categoryIndex: 1, primaryPollutant: "PM2_5", previousDayComparison: "SIMILAR", pollutants } }; - const computed = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": algorithm, "AirQuality.Current.Index.Replace": "EPA_NowCast" }); + const computed = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }, noNetwork, { + DataSets: "airQuality", + "AirQuality.Calculate.Algorithm": algorithm, + "AirQuality.Current.Index.Replace": "EPA_NowCast", + }); const actual = JSON.parse(computed.output.body).airQuality; assert.equal(computed.nativeRequests, 0); assert.equal(actual.pollutants.length, 6); @@ -164,11 +268,24 @@ export default async function verifyLoonBundles(root) { scenarios.push(`v3 ${algorithm} retains all six measurements with no provider request`); } storage.clear(); - const preservedIndex = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ airQuality: { ...appleCO.airQuality, metadata: { providerName: "Apple fixture", temporarilyUnavailable: false }, scale: "EPA_NowCast", pollutants: appleCO.airQuality.pollutants.map(p => ({ ...p, amount: p.pollutantType === "CO" ? 700 : p.amount })) } }) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + const preservedIndex = await run( + responseCode, + { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, + { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ airQuality: { ...appleCO.airQuality, metadata: { providerName: "Apple fixture", temporarilyUnavailable: false }, scale: "EPA_NowCast", pollutants: appleCO.airQuality.pollutants.map(p => ({ ...p, amount: p.pollutantType === "CO" ? 700 : p.amount })) } }), + }, + noNetwork, + { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }, + ); assert.equal(JSON.parse(preservedIndex.output.body).airQuality.index, 42); assert.equal(JSON.parse(preservedIndex.output.body).airQuality.scale, "EPA_NowCast"); scenarios.push("the original nonmatching target list preserves Apple AQI despite a selected algorithm"); - const retainedComparison = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI" }); + const retainedComparison = await run(responseCode, { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=airQuality") }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(appleCO) }, noNetwork, { + DataSets: "airQuality", + "AirQuality.Calculate.Algorithm": "CA_AQHI", + }); assert.equal(JSON.parse(retainedComparison.output.body).airQuality.scale, "CA.AQHI"); assert.equal(JSON.parse(retainedComparison.output.body).airQuality.previousDayComparison, "SIMILAR"); scenarios.push("the original disabled comparison-replacement switch keeps an existing Apple trend"); @@ -183,23 +300,44 @@ export default async function verifyLoonBundles(root) { storage.clear(); const alertRequest = { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?lang=en-US&ids=fixture-101190110", method: "GET", headers: { "User-Agent": "fixture-agent" } }; const html = '

Fixture

Fixture warning

Issue date: 2026-10-19T00:00:00Z

'; - const first = await run(requestCode, alertRequest, undefined, (_, callback) => callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html), { "RequestCache.Diagnostics": true }); + const first = await run(requestCode, alertRequest, undefined, (_, callback) => callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html)); assert.equal(first.nativeRequests, 1); assert.equal(first.output.response.status, 200); assert.ok(JSON.parse(first.output.response.body).length > 0); scenarios.push("web warning parsing without TextEncoder"); - const second = await run(requestCode, alertRequest, undefined, noNetwork, { "RequestCache.Diagnostics": true }); - assert.equal(second.nativeRequests, 0); + for (const language of ["zh-Hans", "en"]) { + const text = language === "en" ? "Source English notice." : "来源中文预警。"; + const source = html.replace("", `

${text}

`); + const localized = await run(requestCode, alertRequest, undefined, (outgoing, callback) => { + assert.equal(new URL(outgoing.url).pathname.startsWith("/en/"), language === "en"); + assert.equal(outgoing.headers["Accept-Language"], language); + callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, source); + }, { "WeatherAlerts.Language": language }); + assert.equal(localized.nativeRequests, 1); + assert.equal(localized.output.response.headers["Cache-Control"], "no-store"); + const row = JSON.parse(localized.output.response.body)[0]; + assert.equal(row.messages[0].text, text); + assert.equal(row.messages[0].language, "und"); + assert.equal(row.expireTime, "9999-12-31T00:00:00Z"); + assert.equal(Object.hasOwn(row, "eventEndTime"), false); + scenarios.push(`${language}: compiled Loon preference requests source language without translating original text`); + } + const second = await run(requestCode, alertRequest, undefined, (_, callback) => callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html)); + assert.equal(second.nativeRequests, 1); assert.equal(second.output.response.body, first.output.response.body); - scenarios.push("parsed warning cache survives a new Loon execution"); + scenarios.push("a repeated warning request fetches independently without persistent response reuse"); storage.clear(); - const failed = await run(requestCode, alertRequest, undefined, (_, callback) => callback("timeout fixture-secret", {}, ""), { "RequestCache.Diagnostics": true }, true); + const failed = await run(requestCode, alertRequest, undefined, (_, callback) => callback("timeout fixture-secret", {}, ""), {}, true); assert.equal(failed.output.response, undefined); assert.equal(failed.output.url, alertRequest.url); - const diagnostic = failed.messages.filter(message => message.startsWith("[WeatherKit]")); - assert.equal(diagnostic.length, 1); - assert.doesNotMatch(diagnostic[0], /fixture-secret|101190110|weatherkit\.apple\.com/); - scenarios.push("failed warning request passes through with safe diagnostics"); + assert.equal(failed.messages.filter(message => message.startsWith("[WeatherKit]")).length, 0); + scenarios.push("failed warning request passes through without the removed runtime statistics"); + const appleDetails = { url: "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=31.2,121.5", method: "GET", headers: { "If-None-Match": "apple-entity" } }; + const retainedDetails = await run(requestCode, appleDetails, undefined, noNetwork, { "WeatherAlerts.Provider": "WeatherKit" }); + assert.equal(retainedDetails.output.response, undefined); + assert.equal(retainedDetails.output.headers["If-None-Match"], "apple-entity"); + assert.equal(retainedDetails.nativeRequests, 0); + scenarios.push("WeatherKit-selected coordinate warning details retain the original Apple request and validators"); const { default: WeatherKit2 } = await import(new URL("../src/class/WeatherKit2.mjs", import.meta.url)); const raw = WeatherKit2.encode(undefined, { currentWeather: { @@ -216,18 +354,16 @@ export default async function verifyLoonBundles(root) { }); const recovered = await run( responseCode, - { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }, (_, callback) => callback("timeout", {}, ""), - { LogLevel: "ERROR", DataSets: "currentWeather,forecastHourly", "Weather.Provider": "QWeather", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + { LogLevel: "ERROR", DataSets: "currentWeather,forecastHourly", "Weather.Provider": "QWeather" }, true, ); - assert.equal(recovered.nativeRequests, 2, `Region fix and both provider paths must run: ${[...recovered.errors, ...recovered.messages.filter(line => /Error|Failed|失败|Unreplaced|country:|FORMAT:/.test(line))].join("\n")}`); + assert.equal(recovered.nativeRequests, 2, "Both requested provider products must complete"); const { ByteBuffer } = await import("flatbuffers"); const decoded = WeatherKit2.decode(new ByteBuffer(recovered.output.body), ["currentWeather"]); assert.equal(decoded.currentWeather.temperature, 27); - const recoverySummary = JSON.parse(recovered.messages.find(line => line.startsWith("[WeatherKit]")).slice("[WeatherKit] ".length)); - assert.equal(recoverySummary.fallbacks, 2, "An undefined provider result must still count as a fallback"); scenarios.push("binary weather response retains Apple values and completes after native failures"); storage.clear(); const empty = WeatherKit2.encode(undefined, {}); @@ -272,32 +408,31 @@ export default async function verifyLoonBundles(root) { responseCode, { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-CN/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: empty }, - (request, callback) => { assert.ok(request.url.includes("caiyunapp.com"));callback("timeout", {}, ""); }, + (request, callback) => { + assert.ok(request.url.includes("caiyunapp.com")); + callback("timeout", {}, ""); + }, { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "configured-for-another-product" }, true, ); assert.equal(noBackup.nativeRequests, 1); assert.deepEqual(noBackup.output.body, empty); scenarios.push("a dedicated token alone never switches away from the explicitly selected provider"); - const backup = await run( + const legacySettings = await run( responseCode, - { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=forecastNextHour", method: "GET", headers: {} }, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=forecastNextHour", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: empty }, (request, callback) => { - if (request.url.includes("caiyunapp.com")) callback("timeout", {}, ""); - else { - assert.equal(request.headers["X-QW-Api-Key"], "fixture-account"); - callback(null, { status: 200, headers: { "Content-Type": "application/json" } }, JSON.stringify({ code: "200", updateTime: "2026-10-19T08:00:00+08:00", summary: "Fixture rain", minutely: [{ fxTime: "2026-10-19T08:05:00+08:00", precip: "0.1" }] })); - } + assert.ok(request.url.includes("caiyunapp.com"), "Legacy fallback settings must never trigger a different provider"); + callback("timeout", {}, ""); }, { LogLevel: "ERROR", DataSets: "forecastNextHour", "NextHour.Provider": "ColorfulClouds", "API.QWeather.Token": "fixture-account", "ProviderFallback.Enabled": true, "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, true, ); - assert.equal(backup.nativeRequests, 2); - const backedUp = WeatherKit2.decode(new ByteBuffer(backup.output.body), ["forecastNextHour"]); - assert.ok(backedUp.forecastNextHour, [...backup.errors, ...backup.messages].join("\n")); - assert.equal(backedUp.forecastNextHour.metadata.temporarilyUnavailable, false); - scenarios.push("missing minute data uses one configured backup after primary failure"); + assert.equal(legacySettings.nativeRequests, 1); + assert.deepEqual(legacySettings.output.body, empty); + assert.equal(legacySettings.messages.filter(line => line.startsWith("[WeatherKit]")).length, 0); + scenarios.push("legacy stored fallback, region and statistics settings cannot reactivate removed features"); for (const [status, temperature, reason] of [ [502, "99", "http"], [200, "not-a-number", "invalid"], @@ -305,17 +440,14 @@ export default async function verifyLoonBundles(root) { storage.clear(); const guarded = await run( responseCode, - { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=currentWeather", method: "GET", headers: {} }, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=currentWeather", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }, (_, callback) => callback(null, { status, headers: { "Content-Type": "application/json" } }, JSON.stringify({ code: "200", now: { temp: temperature } })), - { DataSets: "currentWeather", "Weather.Provider": "QWeather", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + { DataSets: "currentWeather", "Weather.Provider": "QWeather" }, true, ); assert.equal(guarded.nativeRequests, 1); assert.deepEqual(Array.from(guarded.output.body), Array.from(raw)); - const summary = JSON.parse(guarded.messages.find(line => line.startsWith("[WeatherKit]")).slice("[WeatherKit] ".length)); - assert.equal(summary.fallbacks, 1); - assert.equal(summary.failureReasons[reason], 1); scenarios.push(`HTTP ${status} provider body with ${reason} failure cannot overwrite Apple weather`); } storage.clear(); @@ -325,8 +457,8 @@ export default async function verifyLoonBundles(root) { }); const mixed = await run( responseCode, - { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=weatherAlerts,currentWeather", method: "GET", headers: {} }, - { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: mixedRaw }, + { url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=weatherAlerts,currentWeather", method: "GET", headers: {} }, + { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer", ETag: "original", "Content-Length": "1", "Content-Encoding": "gzip" }, body: mixedRaw }, (_, callback) => callback( null, @@ -337,16 +469,17 @@ export default async function verifyLoonBundles(root) { result: { server_time: 1792368000, alert: { status: "error" }, realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.2, skycon: "CLEAR_DAY", humidity: 0.8, precipitation: { local: { intensity: 0 } }, pressure: 100000, visibility: 10, wind: { direction: 90, speed: 1 } } }, }), ), - { DataSets: "weatherAlerts,currentWeather", "Weather.Provider": "ColorfulClouds", "WeatherAlerts.Provider": "ColorfulClouds", "Region.Fallback": "Auto", "RequestCache.Diagnostics": true }, + { DataSets: "weatherAlerts,currentWeather", "Weather.Provider": "ColorfulClouds", "WeatherAlerts.Provider": "ColorfulClouds" }, true, ); - assert.equal(mixed.nativeRequests, 1); + assert.equal(mixed.nativeRequests, 2); + for (const name of ["ETag", "Content-Length", "Content-Encoding"]) assert.equal(mixed.output.headers[name], undefined); + assert.equal(mixed.output.headers["Cache-Control"], "no-store"); + scenarios.push("modified WK2 binary invalidates original HTTP entity headers"); const mixedDecoded = WeatherKit2.decode(new ByteBuffer(mixed.output.body), ["currentWeather", "weatherAlerts"]); assert.equal(mixedDecoded.currentWeather.temperature, 23); assert.equal(mixedDecoded.weatherAlerts.metadata.providerName, "National Early Warning Center"); - const mixedSummary = JSON.parse(mixed.messages.find(line => line.startsWith("[WeatherKit]")).slice("[WeatherKit] ".length)); - assert.equal(mixedSummary.fallbacks, 1); - scenarios.push("one shared Caiyun callback can replace healthy weather while preserving failed Apple alerts"); + scenarios.push("independent Caiyun product requests replace healthy weather while preserving failed Apple alerts"); storage.clear(); const { futureHourlyFixture, futureHourlyText, readFutureHourly, weatherFixture, weatherNow } = await import("../tests/fixtures/processingMemoryFixture.mjs"); const futureRaw = futureHourlyFixture(); @@ -375,5 +508,79 @@ export default async function verifyLoonBundles(root) { assert.equal(failedAlerts.nativeRequests, 1); assert.strictEqual(failedAlerts.output.body, alertsRaw); scenarios.push("failed alert replacement retains the complete original binary response"); - return { pass: scenarios.length, fail: 0, scenarios, nativeRewrites, upstreamArgumentDefaultsVerified: Object.keys(upstreamDefaults).length, withoutNodeWebCryptoHostTextEncoder: true, providedWebAPIs: ["URL", "URLSearchParams", "TextDecoder"], doneExactlyOnce: true, liveWeatherRequests: false, deviceVPNValidation: false }; + const unavailableBody = JSON.stringify({ weatherAlerts: { metadata: { temporarilyUnavailable: true }, alerts: [] } }); + const unavailable = await run( + responseCode, + { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, + { status: 200, headers: { "Content-Type": "application/json", ETag: "keep" }, body: unavailableBody }, + (_, callback) => callback(null, { status: 200, headers: {} }, JSON.stringify({ metadata: {}, alerts: [] })), + { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeather" }, + ); + assert.equal(unavailable.nativeRequests, 1); + assert.equal(unavailable.output.body, unavailableBody); + assert.equal(unavailable.output.headers.ETag, "keep"); + scenarios.push("available provider with no native warning merge cannot clear Apple unavailability"); + const futureContainer = { conditionCode: ["OPAQUE"], index: "future", precipitationAmount: { expected: 8 } }; + const opaqueNested = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentWeather: { metadata: {}, conditionCode: ["RAIN"], future: futureContainer } }) }, noNetwork, { DataSets: "" }); + const opaqueProduct = JSON.parse(opaqueNested.output.body).currentWeather; + assert.equal(opaqueProduct.conditionCode, "RAIN"); + assert.deepEqual(opaqueProduct.future, futureContainer); + scenarios.push("known Weather fields adapt while an unknown nested container stays opaque"); + const populatedUnavailableBody = JSON.stringify({ weatherAlerts: { metadata: { temporarilyUnavailable: true }, alerts: [{ id: "00112233-4455-6677-8899-aabbccddeeff", description: "Apple fixture warning" }] } }); + const populatedUnavailable = await run( + responseCode, + { ...v3Request, url: v3Request.url.replace("dataSets=currentWeather", "dataSets=weatherAlerts") }, + { status: 200, headers: { "Content-Type": "application/json", ETag: "keep-populated" }, body: populatedUnavailableBody }, + (_, callback) => callback(null, { status: 200, headers: {} }, JSON.stringify({ metadata: {}, alerts: [] })), + { DataSets: "weatherAlerts", "WeatherAlerts.Provider": "QWeather" }, + ); + assert.equal(populatedUnavailable.nativeRequests, 1); + assert.equal(populatedUnavailable.output.body, populatedUnavailableBody); + assert.equal(populatedUnavailable.output.headers.ETag, "keep-populated"); + scenarios.push("metadata-only warning supplementation preserves populated Apple alerts and their unavailable state"); + // Both inputs fit independently; the real provider conversion makes their combined output exceed 1 Mi characters. + const oversizedApple = { + currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: ["CLOUDY"] }, + forecastPeriodic: { metadata: {}, forecasts: [{ periods: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 2, precipitationType: ["RAIN"] }] }] }] }, + futureProduct: { padding: "x".repeat(768 * 1024) }, + }; + const oversizedOriginal = JSON.stringify(oversizedApple); + const oversizedProviderBody = JSON.stringify({ code: "200", fxLink: `https://fixture.example/${"p".repeat(384 * 1024)}`, now: { pubTime: "2026-11-14T00:00:00Z", temp: "23", text: "晴", feelsLike: "24", cloud: "20", humidity: "50", precip: "0", pressure: "1000", dew: "10", vis: "10", wind360: "90", windSpeed: "2" } }); + const oversizedProvider = (_, callback) => callback(null, { status: 200, headers: {} }, oversizedProviderBody); + const acceptedControl = await run(responseCode, v3Request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentWeather: oversizedApple.currentWeather }) }, oversizedProvider, { DataSets: "currentWeather", "Weather.Provider": "QWeather" }); + const acceptedBody = JSON.parse(acceptedControl.output.body); + assert.equal(acceptedControl.nativeRequests, 1); + assert.equal(acceptedBody.currentWeather.temperature, 23, "The overflow fixture must be a valid provider replacement before combining it with the large Apple payload"); + assert.ok(acceptedBody.currentWeather.metadata.attributionUrl.length > 384 * 1024); + const outputFallback = await run( + responseCode, + v3Request, + { status: 200, headers: { "Content-Type": "application/json", ETag: "before-fallback" }, body: oversizedOriginal }, + oversizedProvider, + { DataSets: "currentWeather", "Weather.Provider": "QWeather" }, + ); + const fallbackBody = JSON.parse(outputFallback.output.body); + assert.equal(outputFallback.nativeRequests, 1); + assert.equal(fallbackBody.currentWeather.temperature, 18); + assert.equal(fallbackBody.currentWeather.conditionCode, "CLOUDY"); + assert.equal(fallbackBody.forecastPeriodic.forecasts[0].periods[0].conditionCode, "RAIN"); + assert.equal(fallbackBody.forecastPeriodic.forecasts[0].periods[0].precipitationAmount, 2); + assert.deepEqual(fallbackBody.futureProduct, oversizedApple.futureProduct); + assert.ok(outputFallback.output.body.length < 1024 * 1024); + assert.equal(outputFallback.output.headers.ETag, undefined); + assert.equal(outputFallback.output.headers["Cache-Control"], "no-store"); + assert.equal(JSON.stringify(oversizedApple), oversizedOriginal); + scenarios.push("provider output overflow returns normalized Apple weather and periodic data through the compiled Loon script"); + return { + pass: scenarios.length, + fail: 0, + scenarios, + nativeRewrites, + upstreamArgumentDefaultsVerified: Object.keys(upstreamDefaults).length, + withoutNodeWebCryptoHostTextEncoder: true, + providedWebAPIs: ["URL", "URLSearchParams", "TextDecoder"], + doneExactlyOnce: true, + liveWeatherRequests: false, + deviceVPNValidation: false, + }; } diff --git a/scripts/verify-loon-rewrites.mjs b/scripts/verify-loon-rewrites.mjs index d697e523..e91d8a4f 100644 --- a/scripts/verify-loon-rewrites.mjs +++ b/scripts/verify-loon-rewrites.mjs @@ -36,15 +36,22 @@ export function readLoonRules(plugin) { const v3Format = v3Script.match(/\$\{response\.header\['Content-Type'\]\} ~= \/((?:\\.|[^/])*)\/i/); assert.ok(v3Guard && v3Agent && v3Format); assert.ok(v3Script.includes('${request.method} == "GET"') && v3Script.includes("${response.status} == 200")); - const v3AgentRegex = new RegExp(v3Agent[1]), v3FormatRegex = new RegExp(v3Format[1], "i"); + const v3AgentRegex = new RegExp(v3Agent[1]), + v3FormatRegex = new RegExp(v3Format[1], "i"); scripts.push({ stage: "http-response", regex: new RegExp(v3Guard[1]), line: v3Script, accepts: ({ method = "GET", status = 200, contentType, userAgent } = {}) => method === "GET" && status === 200 && v3FormatRegex.test(contentType ?? "") && v3AgentRegex.test(userAgent ?? "") }); const declared = lines.filter(line => /^[\w.]+ = (input|select|switch),/.test(line)).map(line => line.split(" = ")[0]); // Template declarations are generated later; actual imports must bind every parameter. - if (declared.length) assert.deepEqual([...v3Script.matchAll(/\$\{([\w.]+)\}/g)].map(match => match[1]).filter(name => !["url", "request.method", "response.status"].includes(name)), declared); + if (declared.length) + assert.deepEqual( + [...v3Script.matchAll(/\$\{([\w.]+)\}/g)].map(match => match[1]).filter(name => !["url", "request.method", "response.status"].includes(name)), + declared, + ); return { jq, scripts, - negotiatesJSON(url, method = "GET", userAgent = "") { return method === "GET" && transportRegex.test(url) && appAgentRegex.test(userAgent); }, + negotiatesJSON(url, method = "GET", userAgent = "") { + return method === "GET" && transportRegex.test(url) && appAgentRegex.test(userAgent); + }, normalizeScale(url, method = "GET") { return ["GET", "HEAD", "OPTIONS"].includes(method) ? url.replace(regex, (_match, prefix) => prefix) : url; }, @@ -62,17 +69,25 @@ export default function verifyLoonRewrites(plugin) { scenarios = []; assert.equal(rules.scripts.length, 5); const v3URL = "https://weatherkit.apple.com/api/v3/weather/en-GB/53.38/-1.47?dataSets=airQuality"; - const appAgent = "WeatherKit_Weather_iOS_Version 27.3 (Build fixture)", context = { userAgent: appAgent, contentType: "application/json" }; + const appAgent = "WeatherKit_Weather_iOS_Version 27.3 (Build fixture)", + context = { userAgent: appAgent, contentType: "application/json" }; assert.ok(rules.negotiatesJSON(v3URL, "GET", appAgent)); const macAgent = "WeatherKit_Weather_macOS_Version 27.3 (Build fixture)"; assert.ok(rules.negotiatesJSON(v3URL, "GET", macAgent)); assert.equal(rules.matchingScripts(v3URL, "http-response", { ...context, userAgent: macAgent }).length, 1); scenarios.push("iOS and macOS main Weather app fixtures select the same guarded v3 transport"); - for (const [url, method] of [[v3URL, "POST"], [v3URL.replace("https:", "http:"), "GET"], [v3URL.replace("weatherkit.apple.com", "example.com"), "GET"], [v3URL.replace("/v3/", "/v2/"), "GET"]]) assert.equal(rules.negotiatesJSON(url, method, appAgent), false); + for (const [url, method] of [ + [v3URL, "POST"], + [v3URL.replace("https:", "http:"), "GET"], + [v3URL.replace("weatherkit.apple.com", "example.com"), "GET"], + [v3URL.replace("/v3/", "/v2/"), "GET"], + ]) + assert.equal(rules.negotiatesJSON(url, method, appAgent), false); scenarios.push("native v3 JSON negotiation changes only HTTPS Apple weather GET requests"); const [v3] = rules.matchingScripts(v3URL, "http-response", context); assert.ok(v3?.line.includes("requires_body=true")); - assert.ok(v3.line.includes("${DataSets}") && v3.line.includes("${Region.Fallback}")); + assert.ok(v3.line.includes("${DataSets}")); + assert.doesNotMatch(v3.line, /RequestCache|Region\.Fallback|ProviderFallback/); assert.doesNotMatch(v3.line, /binary_body_mode=true/); assert.equal(rules.matchingScripts(v3URL, "http-response", context).length, 1); assert.match(rules.matchingScripts(v3URL.replace("/v3/", "/v2/"), "http-response")[0].line, /binary-body-mode=1/); diff --git a/src/class/AirQuality.mjs b/src/class/AirQuality.mjs index 95356849..9ad4ef82 100644 --- a/src/class/AirQuality.mjs +++ b/src/class/AirQuality.mjs @@ -106,17 +106,18 @@ export default class AirQuality { } static CompareCategoryIndexes(todayCategoryIndex, yesterdayCategoryIndex) { - Console.info("☑️ CompareCategoryIndexes", `todayCategoryIndex: ${todayCategoryIndex}`, `yesterdayCategoryIndex: ${yesterdayCategoryIndex}`); + Console.info("☑️ CompareCategoryIndexes"); const { UNKNOWN, SAME, WORSE, BETTER } = AirQuality.Config.CompareCategoryIndexes; - const today = Number(todayCategoryIndex); - const yesterday = Number(yesterdayCategoryIndex); - - // -1、0 和 null 都表示“无可用等级”,不能参与好转/恶化比较。 - if (![today, yesterday].every(categoryIndex => Number.isFinite(categoryIndex) && categoryIndex > 0)) { + // Input-validation direction: Patrick Yan, PR #108 at 24c3f9311f051a9df9465238ee85b4a5f2c4f9c2, Apache-2.0. + // Preserve numeric strings; booleans, objects and fractional categories are not valid levels. + const validCategory = value => (typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isInteger(Number(value)) && Number(value) > 0; + if (![todayCategoryIndex, yesterdayCategoryIndex].every(validCategory)) { Console.error("CompareCategoryIndexes", "categoryIndex无效"); return UNKNOWN; } else { + const today = Number(todayCategoryIndex); + const yesterday = Number(yesterdayCategoryIndex); const diff = today - yesterday; if (diff === 0) { Console.info("✅ CompareCategoryIndexes"); diff --git a/src/class/ColorfulClouds.mjs b/src/class/ColorfulClouds.mjs index e1eea149..5d0a1de1 100644 --- a/src/class/ColorfulClouds.mjs +++ b/src/class/ColorfulClouds.mjs @@ -1,15 +1,15 @@ -// Modified by hhhoratioxu, 2026-10-19: bounded request coalescing and short-lived response caching. +// WeatherKit provider processing. import { Console } from "@nsnanocat/util"; import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; import AirQuality from "./AirQuality.mjs"; import ForecastNextHour from "./ForecastNextHour.mjs"; -import ProviderRequestCache, { providerErrorCode, readProviderJSON } from "./ProviderRequestCache.mjs"; +import ProviderTransport, { providerErrorCode, readProviderJSON } from "./ProviderTransport.mjs"; import Weather from "./Weather.mjs"; export default class ColorfulClouds { - constructor(parameters, token, requestCache = new ProviderRequestCache()) { - this.#requests = requestCache; + constructor(parameters, token, transport = new ProviderTransport()) { + this.#requests = transport; this.Name = "ColorfulClouds"; this.Version = "4.3.0"; Console.info(`🟧 ${this.Name} v${this.Version}`); @@ -29,9 +29,6 @@ export default class ColorfulClouds { this.weatherKitLanguage = String(parameters.weatherKitLanguage ?? parameters.language ?? "").trim() || "zh-CN"; } - #cache = { - alert: {}, - }; #requests; #Fetch(request, kind) { @@ -39,15 +36,25 @@ export default class ColorfulClouds { return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { fetcher: fetch, includeJSON: true, - partition: [this.Name, this.version, this.language, this.country], - product, - group: [this.latitude, this.longitude, this.country], - kind: kind ?? (product === "minutely" ? "minutely" : /hourly|daily/.test(product) ? "forecast" : product === "location" ? "alerts" : "current"), - maximumTTLSeconds: product === "minutely" ? 30 : 300, validate: body => { if (product === "location") return !body?.error && Array.isArray(body?.alerts); const result = body?.result; - return body?.status === "ok" && result?.[product]?.status === "ok" && (product !== "realtime" || kind !== "alerts" || (result.alert?.status === "ok" && Array.isArray(result.alert.content))); + if (body?.status !== "ok" || result?.[product]?.status !== "ok") return false; + if (product === "minutely") { + const { precipitation_2h: minutes, probability, description } = result.minutely; + return ( + Number.isFinite(body.server_time) && + Array.isArray(minutes) && + minutes.length > 0 && + minutes.length <= 8192 && + minutes.every(value => Number.isFinite(value) && value >= 0) && + Array.isArray(probability) && + probability.length >= Math.ceil(Math.min(85, minutes.length) / 30) && + probability.every(value => Number.isFinite(value) && value >= 0 && value <= 1) && + (description === undefined || typeof description === "string") + ); + } + return product !== "realtime" || kind !== "alerts" || (result.alert?.status === "ok" && Array.isArray(result.alert.content)); }, }); } @@ -230,8 +237,6 @@ export default class ColorfulClouds { async #RealTime(kind = "current") { Console.info("☑️ RealTime"); - this.#cache.alert = {}; - const request = { url: `${this.endpoint}/realtime?lang=${this.language}&alert=true`, headers: this.headers, @@ -248,14 +253,6 @@ export default class ColorfulClouds { case undefined: throw Error(JSON.stringify({ status: body?.result?.realtime?.status, reason: body?.result?.realtime })); } - switch (body?.result?.alert?.status) { - case "ok": - this.#cache.alert = body.result.alert; - break; - case "error": - case undefined: - break; - } Console.info("✅ RealTime"); return body; } @@ -314,7 +311,7 @@ export default class ColorfulClouds { condition: [], forecastEnd: 0, forecastStart: minuteStemp, - minutes: body?.result?.minutely?.precipitation_2h?.map((precipitationIntensity, index) => { + minutes: body.result.minutely.precipitation_2h.slice(0, 85).map((precipitationIntensity, index) => { const minute = { perceivedPrecipitationIntensity: 0, precipitationChance: 0, @@ -357,8 +354,8 @@ export default class ColorfulClouds { } /** - * 从彩云实况缓存读取 v2.6 预警,并标准化为 WeatherAlerts.mergeAlerts 可消费的来源结构。 - * Read Caiyun v2.6 alerts from the realtime cache and normalize them for WeatherAlerts.mergeAlerts. + * 拉取彩云 v2.6 预警,并标准化为 WeatherAlerts.mergeAlerts 可消费的来源结构。 + * Fetch Caiyun v2.6 alerts and normalize them for WeatherAlerts.mergeAlerts. * @link https://docs.caiyunapp.com/weather-api/v2/v2.6/5-alert.html * @returns {Promise<{metadata: object, detailsUrl: string, alerts: Array, areaName: string, source: string}>} WeatherKit 顶级预警对象 / Top-level WeatherKit alert object. */ @@ -376,9 +373,10 @@ export default class ColorfulClouds { }; let weatherAlerts = failedWeatherAlerts; try { - await this.#RealTime("alerts"); - if (!Array.isArray(this.#cache.alert?.content)) throw Error(JSON.stringify(this.#cache.alert)); - weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(this.#cache.alert) }; + const response = await this.#RealTime("alerts"); + const alert = response?.result?.alert; + if (!Array.isArray(alert?.content)) throw Error("Unavailable provider alerts"); + weatherAlerts = { ...failedWeatherAlerts, ...this.#CreateWeatherAlerts(alert) }; if (!weatherAlerts.metadata?.temporarilyUnavailable || weatherAlerts !== failedWeatherAlerts) weatherAlerts.metadata = { ...weatherAlerts.metadata, temporarilyUnavailable: false }; } catch (error) { Console.error(`WeatherAlert: ${providerErrorCode(error)}`); @@ -780,8 +778,8 @@ export default class ColorfulClouds { async CurrentWeather() { Console.info("☑️ CurrentWeather"); const realtime = await this.#RealTime(); - if (!realtime.result) { - Console.error("CurrentWeather", "无法获取realtime数"); + if (!realtime.result || !Number.isFinite(realtime.result.realtime?.temperature)) { + Console.error("CurrentWeather", "无法获取有效的实时气温"); return { metadata: this.#Metadata(undefined, undefined, true), }; diff --git a/src/class/ForecastNextHour.mjs b/src/class/ForecastNextHour.mjs index ae2ed6e8..c07f9a7d 100644 --- a/src/class/ForecastNextHour.mjs +++ b/src/class/ForecastNextHour.mjs @@ -184,68 +184,39 @@ export default class ForecastNextHour { static Summary(minutes = []) { Console.info("☑️ Summary"); - const Summaries = []; - const Summary = { - condition: "CLEAR", - startTime: 0, - precipitationChance: 0, - precipitationIntensity: 0, - //beginCondition: "", - //endCondition: "", - maxCondition: "", - clear: true, - }; - const Length = Math.min(71, minutes.length); - for (let i = 0; i < Length; i++) { + const summaries = []; + let current; + const length = Math.min(71, minutes.length); + for (let i = 0; i < length; i++) { const minute = minutes[i]; - const previousMinute = minutes[i - 1]; - switch (i) { - case 0: // 第一个 - Summary.startTime = minute.startTime; - Summary.condition = minute.summaryCondition; // condition 只关心降水类型,不关心具体强弱描述 - Summary.precipitationChance = minute.precipitationChance; - Summary.precipitationIntensity = minute.precipitationIntensity; - //Summary.beginCondition = minute.condition; - //Summary.endCondition = ""; - Summary.maxCondition = minute.condition; - Summary.clear = minute.clear; - break; - case Length - 1: // 最后一个 - Summary.endTime = 0; // ⚠️空值必须写零! - //Summary.endCondition = minute.condition; - Summary.clear = minute.clear; - Console.debug(`Summaries[${i}]`, JSON.stringify({ ...minute, ...Summary }, null, 2)); - Summaries.push({ ...Summary }); - break; - default: // 中间 - if (minute.summaryCondition !== previousMinute.summaryCondition) { - // 结束当前summary - Summary.endTime = minute.startTime; - //Summary.endCondition = previousMinute.condition; - Console.debug(`Summaries[${i}]`, JSON.stringify({ ...previousMinute, ...Summary }, null, 2)); - Summaries.push({ ...Summary }); - - // 开始新的summary - Summary.startTime = minute.startTime; - Summary.condition = minute.summaryCondition; // condition 只关心降水类型,不关心具体强弱描述 - Summary.precipitationChance = minute.precipitationChance; - Summary.precipitationIntensity = minute.precipitationIntensity; - //Summary.beginCondition = minute.condition; - //Summary.endCondition = ""; // 重置 - Summary.maxCondition = minute.condition; // 重置 - Summary.clear = minute.clear; - } else { - // 条件相同,更新最大值 - Summary.precipitationChance = Math.max(Summary.precipitationChance, minute.precipitationChance); - Summary.precipitationIntensity = Math.max(Summary.precipitationIntensity, minute.precipitationIntensity); - if (Summary.precipitationIntensity === minute.precipitationIntensity) Summary.maxCondition = minute.condition; - } - break; + if (!current || minute.summaryCondition !== current.condition) { + if (current) { + current.endTime = minute.startTime; + summaries.push(current); + } + current = { + condition: minute.summaryCondition, + startTime: minute.startTime, + precipitationChance: minute.precipitationChance, + precipitationIntensity: minute.precipitationIntensity, + maxCondition: minute.condition, + clear: minute.clear, + }; + } else { + current.precipitationChance = Math.max(current.precipitationChance, minute.precipitationChance); + current.precipitationIntensity = Math.max(current.precipitationIntensity, minute.precipitationIntensity); + if (current.precipitationIntensity === minute.precipitationIntensity) current.maxCondition = minute.condition; } } - Console.debug(`Summaries: ${JSON.stringify(Summaries, null, 2)}`); + if (current) { + // The last sample is processed like every other sample. Zero keeps + // the existing open-ended WK2 summary/condition convention. + current.endTime = 0; + summaries.push(current); + } + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`Summaries: ${JSON.stringify(summaries, null, 2)}`); Console.info("✅ Summary"); - return Summaries; + return summaries; } static Condition(summaries = []) { diff --git a/src/class/ProviderRequestCache.mjs b/src/class/ProviderRequestCache.mjs deleted file mode 100644 index 3cac0b47..00000000 --- a/src/class/ProviderRequestCache.mjs +++ /dev/null @@ -1,400 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import { sha256 } from "@noble/hashes/sha2"; -import serializeProviderCache from "../function/serializeProviderCache.mjs"; - -// Application bounds, not claims about the host VPN process's memory limits. -export const REQUEST_CACHE_LIMITS = Object.freeze({ entries: 8, bodyCharacters: 96 * 1024, responseCharacters: 1024 * 1024, storageCharacters: 256 * 1024, identityCharacters: 8192, concurrent: 3, pending: 16, maximumTTLSeconds: 300 }); -export const REQUEST_CACHE_KEY = "WeatherKit.ProviderResponses.v1"; -export const PROVIDER_FAILURE_KEY = "WeatherKit.ProviderFailures.v1"; -export function requestCacheTTL(value = 60) { - if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && value.trim() === "") || !Number.isFinite(Number(value)) || Number(value) < 0) return 60; - return Math.min(300, Number(value)); -} -export function fingerprint(identity) { - const bytes = new Uint8Array(identity.length * 2); - for (let index = 0; index < identity.length; index++) { - const code = identity.charCodeAt(index); - bytes[index * 2] = code >>> 8; - bytes[index * 2 + 1] = code & 255; - } - return Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, "0")).join(""); -} -function nativeStore(key) { - if (typeof $loon !== "string" || typeof $persistentStore === "undefined") return undefined; - return { read: () => $persistentStore.read(key), write: value => $persistentStore.write(value, key) }; -} -function bounded(value, fallback, minimum, maximum) { - const number = Number(value); - return value === undefined || value === null || value === "" || !Number.isFinite(number) ? fallback : Math.max(minimum, Math.min(maximum, number)); -} -export class ProviderRequestError extends Error { - constructor(code) { - super(code === "queue_full" ? "Provider request queue is full" : code === "invalid" ? "Unsupported or oversized provider response" : `WeatherKit provider request: ${code}`); - this.name = "ProviderRequestError"; - this.code = code; - } -} -export function providerErrorCode(error) { - if (error instanceof ProviderRequestError) return error.code; - return /timeout|timed out|超时/i.test(String(error?.message ?? error ?? "")) ? "timeout" : "network"; -} -const copyResponse = response => ({ ...response, headers: { ...response?.headers } }); -const parsedJSON = Symbol("validated provider JSON"); -// Each consumer owns this parsed value. It is never shared through pending work or persisted. -export function readProviderJSON(response) { - return response && Object.hasOwn(response, parsedJSON) ? response[parsedJSON] : JSON.parse(response?.body ?? "{}"); -} - -export default class ProviderRequestCache { - #memory = new Map(); - #snapshot; - #inFlight = new Map(); - #waiting = []; - #active = 0; - #failures = new Map(); - #ttl; - #ttls; - #now; - #store; - #failureStore; - #startedAt; - #budget; - #cooldown; - #diagnostics; - #reported = false; - #stats = { requests: 0, coalesced: 0, memoryHits: 0, persistentHits: 0, stores: 0, failures: 0, cooldownSkips: 0, budgetSkips: 0, peakActive: 0, networkMs: 0, fallbacks: 0, backupAttempts: 0, backupSuccesses: 0, reasons: {}, failureReasons: {} }; - constructor({ ttlSeconds = 60, ttls = {}, now = Date.now, store = nativeStore(REQUEST_CACHE_KEY), failureStore = nativeStore(PROVIDER_FAILURE_KEY), budgetMs = 0, cooldownSeconds = 0, diagnostics = false } = {}) { - this.#ttl = requestCacheTTL(ttlSeconds); - this.#ttls = ttls; - this.#now = now; - this.#store = store; - this.#failureStore = failureStore; - this.#startedAt = now(); - this.#budget = bounded(budgetMs, 0, 0, 15000); - this.#cooldown = bounded(cooldownSeconds, 0, 0, 120); - this.#diagnostics = diagnostics === true || diagnostics === "true"; - this.#loadFailures(); - } - static fromSettings(settings = {}, options = {}) { - const p = settings.RequestCache ?? {}; - return new ProviderRequestCache({ - ttlSeconds: p.TTL ?? 60, - ttls: { forecast: bounded(p.ForecastTTL, 180, 0, 300), minutely: bounded(p.MinutelyTTL, 15, 0, 30), alerts: bounded(p.AlertsTTL, 15, 0, 30) }, - budgetMs: bounded(p.BudgetMs, 8000, 1000, 15000), - cooldownSeconds: bounded(p.CooldownSeconds, 30, 0, 120), - diagnostics: p.Diagnostics, - ...options, - }); - } - get stats() { - return { ...this.#stats, reasons: { ...this.#stats.reasons }, failureReasons: { ...this.#stats.failureReasons }, elapsedMs: Math.max(0, this.#now() - this.#startedAt) }; - } - noteBackup(success) { - this.#stats.backupAttempts++; - if (success) this.#stats.backupSuccesses++; - } - noteFallback(product = "unknown", reason = "unavailable") { - this.#stats.fallbacks++; - const key = /^(currentWeather|forecastDaily|forecastHourly|forecastNextHour|weatherAlerts|airQuality|request)$/.test(product) ? product : "other"; - const code = /^(unavailable|invalid|budget|cooldown|network|timeout|region|http|rate_limit|auth)$/.test(reason) ? reason : "unavailable"; - const label = `${key}:${code}`; - this.#stats.reasons[label] = (this.#stats.reasons[label] ?? 0) + 1; - } - report(label = "response") { - if (!this.#diagnostics || this.#reported || typeof $loon !== "string") return; - this.#reported = true; - // Fixed labels and numeric counters only: never include request identities, bodies or error messages. - console.log(`[WeatherKit] ${JSON.stringify({ phase: label === "request" ? "request" : "response", ...this.stats })}`); - } - #lifetime(kind, maximum) { - if (this.#ttl === 0) return 0; - const preferred = this.#ttls[kind] === undefined ? this.#ttl : requestCacheTTL(this.#ttls[kind]); - return Math.min(preferred, requestCacheTTL(maximum), kind === "alerts" || kind === "minutely" ? 30 : 300); - } - async fetch(request, { fetcher, partition = [], product = "", kind = "current", group, maximumTTLSeconds = 300, validate, format = "json", maximumResponseCharacters = REQUEST_CACHE_LIMITS.responseCharacters, cacheable = true, allowNotModified = false, includeJSON = false } = {}) { - if (typeof fetcher !== "function") throw new TypeError("Provider cache requires a fetch function"); - const method = String(request.method ?? "GET").toUpperCase(); - const eligible = cacheable && method === "GET" && request.body === undefined && request.bodyBytes === undefined; - const maximum = bounded(maximumResponseCharacters, REQUEST_CACHE_LIMITS.responseCharacters, 1, 2 * 1024 * 1024); - // Transport identity also protects uncached authentication requests. Only hashes persist. - const identity = this.#key(request, partition, product); - const scope = identity ? fingerprint(JSON.stringify(["validated-product.v2", identity, kind, format, maximum])) : undefined; - const key = eligible ? identity : undefined; - const ttl = this.#lifetime(kind, maximumTTLSeconds); - let parsed; - const valid = body => { - if (typeof body !== "string" || body.length > maximum || typeof validate !== "function") return false; - try { - const value = format === "text" ? body : JSON.parse(body); - if (validate(value) !== true) return false; - if (includeJSON && format !== "text") parsed = value; - return true; - } catch { - return false; - } - }; - const result = response => { - if (includeJSON && format !== "text") Object.defineProperty(response, parsedJSON, { value: parsed }); - return response; - }; - if (key && ttl > 0) { - const memory = this.#memory.get(key); - if (this.#fresh(memory, ttl) && valid(memory.body)) { - memory.lastUsedAt = this.#now(); - this.#remember(memory); - this.#stats.memoryHits++; - return result(this.#response(memory)); - } - this.#memory.delete(key); - const persisted = this.#read().find(entry => entry.key === key && this.#fresh(entry, ttl)); - if (persisted && valid(persisted.body)) { - persisted.lastUsedAt = this.#now(); - this.#remember(persisted); - this.#stats.persistentHits++; - return result(this.#response(persisted)); - } - } - if (scope && this.#cooling(scope)) { - this.#stats.cooldownSkips++; - throw new ProviderRequestError("cooldown"); - } - const owner = eligible && identity ? identity : Symbol("uncacheable request"); - let pending = this.#inFlight.get(owner); - if (pending) this.#stats.coalesced++; - else { - if (this.#inFlight.size >= REQUEST_CACHE_LIMITS.pending) throw new ProviderRequestError("queue_full"); - pending = { invalid: new Set(), saved: new Set() }; - pending.response = this.#schedule(() => this.#dispatch(request, fetcher, identity)); - this.#inFlight.set(owner, pending); - } - try { - // Share the native response, not the first consumer's validation outcome. - const response = await pending.response; - const body = response.body; - const code = Number(response.statusCode ?? response.status) === 304 && !allowNotModified ? "http" : !valid(body) ? "invalid" : undefined; - if (code) { - const failure = scope ?? owner; - if (!pending.invalid.has(failure)) { - pending.invalid.add(failure); - this.#failure(scope, code); - } - throw new ProviderRequestError(code); - } - if (scope) this.#success(scope); - if (key && ttl > 0 && Number(response.statusCode ?? response.status) !== 304 && !pending.saved.has(key)) { - pending.saved.add(key); - this.#save(key, response, body, ttl, group); - } - const output = copyResponse(response); - output.body = body; - return result(output); - } finally { - if (this.#inFlight.get(owner) === pending) this.#inFlight.delete(owner); - } - } - async #dispatch(request, fetcher, identity) { - if (identity && this.#cooling(identity)) { - this.#stats.cooldownSkips++; - throw new ProviderRequestError("cooldown"); - } - const remaining = this.#budget ? this.#budget - (this.#now() - this.#startedAt) : Number.POSITIVE_INFINITY; - if (remaining < 250 || (this.#budget > 0 && this.#now() < this.#startedAt)) { - this.#stats.budgetSkips++; - throw new ProviderRequestError("budget"); - } - const outgoing = typeof $loon === "string" ? { ...request, timeout: Math.max(1, Math.floor(Math.min(bounded(request.timeout, 5000, 1, 5000), remaining))) } : request; - const started = this.#now(); - let headers; - this.#stats.requests++; - try { - // Await the native callback. No detached timer, cancellation or early $done. - const response = await fetcher(outgoing); - headers = response?.headers; - const status = Number(response?.statusCode ?? response?.status); - if (!Number.isInteger(status)) throw new ProviderRequestError("invalid"); - if (status !== 304 && (response?.ok === false || !(status >= 200 && status < 300))) throw new ProviderRequestError(status === 429 ? "rate_limit" : status === 401 || status === 403 ? "auth" : "http"); - if (typeof response.body !== "string" || response.body.length > 2 * 1024 * 1024) throw new ProviderRequestError("invalid"); - if (identity) this.#success(identity); - return response; - } catch (error) { - const code = providerErrorCode(error); - this.#failure(identity, code, headers); - throw new ProviderRequestError(code); - } finally { - this.#stats.networkMs += Math.max(0, this.#now() - started); - } - } - #key(request, partition, product) { - try { - const fields = Object.entries(request) - .filter(([key]) => key !== "headers" && key !== "timeout") - .sort(([a], [b]) => a.localeCompare(b)); - const headers = Object.entries(request.headers ?? {}) - .map(([key, value]) => [key.toLowerCase(), value]) - .sort(([a, av], [b, bv]) => a.localeCompare(b) || String(av).localeCompare(String(bv))); - const identity = JSON.stringify([partition, product, fields, headers]); - return identity.length > REQUEST_CACHE_LIMITS.identityCharacters ? undefined : fingerprint(identity); - } catch { - return undefined; - } - } - async #schedule(work) { - if (this.#active >= REQUEST_CACHE_LIMITS.concurrent) await new Promise(resolve => this.#waiting.push(resolve)); - else this.#active++; - this.#stats.peakActive = Math.max(this.#stats.peakActive, this.#active); - try { - return await work(); - } finally { - const next = this.#waiting.shift(); - if (next) next(); - else this.#active--; - } - } - #fresh(entry, ttl = 300) { - const now = this.#now(); - return ( - entry && - /^[a-f0-9]{64}$/.test(entry.key) && - Number.isFinite(entry.storedAt) && - Number.isFinite(entry.expiresAt) && - entry.expiresAt <= entry.storedAt + 300000 && - Number.isInteger(entry.status) && - entry.status >= 200 && - entry.status < 300 && - typeof entry.body === "string" && - entry.body.length <= REQUEST_CACHE_LIMITS.bodyCharacters && - now >= entry.storedAt && - now < entry.expiresAt && - now - entry.storedAt < ttl * 1000 - ); - } - #read(force = false) { - if (!force && this.#snapshot) return this.#snapshot; - this.#snapshot = []; - if (!this.#store) return this.#snapshot; - try { - const raw = this.#store.read(); - if (typeof raw !== "string" || raw.length > REQUEST_CACHE_LIMITS.storageCharacters) return []; - const parsed = JSON.parse(raw); - if (parsed?.version !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 8) return []; - this.#snapshot = parsed.entries - .filter(entry => this.#fresh(entry)) - .map(({ key, storedAt, expiresAt, status, body, group, lastUsedAt }) => { - // Rereading storage must merge other executions' writes. When - // the text is identical, reuse the existing immutable body. - const remembered = this.#memory.get(key); - if (remembered?.body === body) body = remembered.body; - return { key, storedAt, expiresAt, status, body, group: /^[a-f0-9]{64}$/.test(group) ? group : key, lastUsedAt: Number.isFinite(lastUsedAt) && lastUsedAt >= storedAt && lastUsedAt <= this.#now() ? lastUsedAt : storedAt }; - }); - return this.#snapshot; - } catch { - return []; - } - } - #select(entries) { - // Round-robin by hashed city, with the most recently used city and products first. - const groups = new Map(); - for (const entry of [...entries].sort((a, b) => (b.lastUsedAt ?? b.storedAt) - (a.lastUsedAt ?? a.storedAt))) { - const key = entry.group ?? entry.key; - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(entry); - } - const selected = []; - while (selected.length < 8 && groups.size) - for (const [key, queue] of groups) { - selected.push(queue.shift()); - if (!queue.length) groups.delete(key); - if (selected.length === 8) break; - } - return selected; - } - #save(key, response, body, ttl, group) { - if (body.length > REQUEST_CACHE_LIMITS.bodyCharacters) return; - const headers = Object.fromEntries(Object.entries(response.headers ?? {}).map(([name, value]) => [name.toLowerCase(), String(value)])); - if (/\b(no-store|no-cache)\b/i.test(headers["cache-control"] ?? "") || headers.vary?.split(",").some(value => value.trim() === "*")) return; - const maxAge = headers["cache-control"]?.match(/(?:^|,)\s*max-age\s*=\s*"?(\d+)/i)?.[1]; - if (maxAge !== undefined) ttl = Math.min(ttl, Math.max(0, Number(maxAge) - Math.max(0, Number(headers.age) || 0))); - if (ttl <= 0) return; - const storedAt = this.#now(), - entry = { key, storedAt, lastUsedAt: storedAt, expiresAt: storedAt + ttl * 1000, status: Number(response.statusCode ?? response.status), body, group: group ? fingerprint(JSON.stringify(group)) : key }; - this.#remember(entry); - if (!this.#store) return; - const merged = new Map(this.#read(true).map(item => [item.key, item])); - for (const item of this.#memory.values()) if (this.#fresh(item)) merged.set(item.key, item); - const entries = this.#select(merged.values()); - const serialized = serializeProviderCache(entries, REQUEST_CACHE_LIMITS.storageCharacters); - try { - if (this.#store.write(serialized) === true) this.#stats.stores++; - } catch { - /* Optional storage must not break a successful response. */ - } - } - #remember(entry) { - this.#memory.delete(entry.key); - this.#memory.set(entry.key, entry); - if (this.#memory.size > 8) this.#memory = new Map(this.#select(this.#memory.values()).map(item => [item.key, item])); - } - #response(entry) { - return { status: entry.status, statusCode: entry.status, ok: true, headers: {}, body: entry.body }; - } - #loadFailures() { - if (!this.#cooldown || !this.#failureStore) return; - try { - const raw = this.#failureStore.read(); - if (typeof raw !== "string" || raw.length > 8192) return; - const data = JSON.parse(raw); - if (data?.version !== 1 || !Array.isArray(data.entries) || data.entries.length > 32) return; - for (const entry of data.entries) - if (/^[a-f0-9]{64}$/.test(entry.key) && Number.isFinite(entry.at) && entry.at <= this.#now() && this.#now() - entry.at < 120000 && Number.isFinite(entry.until) && entry.until <= entry.at + 120000 && Number.isInteger(entry.count) && entry.count > 0 && entry.count <= 2) - this.#failures.set(entry.key, { key: entry.key, at: entry.at, until: entry.until, count: entry.count }); - } catch { - /* Corrupt optional cooldown state is ignored. */ - } - } - #persistFailures() { - if (!this.#failureStore || !this.#cooldown) return; - // Merge other completed executions' state before writing; no transaction is assumed. - const own = new Map(this.#failures); - this.#loadFailures(); - for (const [key, value] of own) this.#failures.set(key, value); - const entries = [...this.#failures.values()] - .filter(e => this.#now() >= e.at && this.#now() - e.at < 120000) - .sort((a, b) => b.at - a.at) - .slice(0, 32); - this.#failures = new Map(entries.map(entry => [entry.key, entry])); - try { - this.#failureStore.write(JSON.stringify({ version: 1, entries })); - } catch { - /* A failed write can only cause a later extra request. */ - } - } - #cooling(key) { - const value = this.#failures.get(key); - return value && this.#now() >= value.at && this.#now() < value.until; - } - #failure(key, reason, headers = {}) { - this.#stats.failures++; - const code = /^(timeout|network|http|rate_limit|auth|invalid)$/.test(reason) ? reason : "network"; - this.#stats.failureReasons[code] = (this.#stats.failureReasons[code] ?? 0) + 1; - if (!key || !this.#cooldown) return; - const at = this.#now(), - previous = this.#failures.get(key), - count = previous && at >= previous.at && at - previous.at < 120000 ? Math.min(2, previous.count + 1) : 1; - const retry = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "retry-after")?.[1]; - const retrySeconds = Number.isFinite(Number(retry)) ? Number(retry) : Number.isFinite(Date.parse(retry)) ? (Date.parse(retry) - at) / 1000 : 0; - const seconds = reason === "rate_limit" ? Math.min(120, Math.max(this.#cooldown, retrySeconds)) : this.#cooldown; - this.#failures.set(key, { key, at, count, until: at + (count >= 2 || reason === "rate_limit" || reason === "auth" ? seconds * 1000 : 0) }); - this.#persistFailures(); - } - #success(key) { - if (this.#failures.has(key)) { - this.#failures.delete(key); - if (this.#failureStore) { - try { - const entries = [...this.#failures.values()]; - this.#failureStore.write(JSON.stringify({ version: 1, entries })); - } catch {} - } - } - } -} diff --git a/src/class/ProviderTransport.mjs b/src/class/ProviderTransport.mjs new file mode 100644 index 00000000..3d480f3c --- /dev/null +++ b/src/class/ProviderTransport.mjs @@ -0,0 +1,47 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Per-request validation only. No response storage, merging, queue or retry policy. +export const PROVIDER_RESPONSE_LIMITS = Object.freeze({ responseCharacters: 1024 * 1024, maximumResponseCharacters: 2 * 1024 * 1024 }); +export class ProviderRequestError extends Error { + constructor(code) { + super(`WeatherKit provider request: ${code}`); + this.name = "ProviderRequestError"; + this.code = code; + } +} +export function providerErrorCode(error) { + if (error instanceof ProviderRequestError) return error.code; + return /timeout|timed out|超时/i.test(String(error?.message ?? error ?? "")) ? "timeout" : "network"; +} +const parsedJSON = Symbol("validated provider JSON"); +export function readProviderJSON(response) { + return response && Object.hasOwn(response, parsedJSON) ? response[parsedJSON] : JSON.parse(response?.body ?? "{}"); +} + +export default class ProviderTransport { + async fetch(request, { fetcher, validate, format = "json", maximumResponseCharacters = PROVIDER_RESPONSE_LIMITS.responseCharacters, allowNotModified = false, includeJSON = false } = {}) { + if (typeof fetcher !== "function" || typeof validate !== "function") throw new TypeError("Provider transport requires fetch and validation functions"); + let response; + try { + response = await fetcher(request); + } catch (error) { + throw new ProviderRequestError(providerErrorCode(error)); + } + const status = Number(response?.statusCode ?? response?.status); + if (!Number.isInteger(status)) throw new ProviderRequestError("invalid"); + const output = { ...response, headers: { ...response?.headers } }; + // Only the owner of an existing entity may opt into conditional reuse. + if (status === 304 && allowNotModified) return { ...output, body: "" }; + if (response?.ok === false || status < 200 || status >= 300) throw new ProviderRequestError(status === 429 ? "rate_limit" : status === 401 || status === 403 ? "auth" : "http"); + const maximum = Number.isFinite(maximumResponseCharacters) ? Math.max(1, Math.min(PROVIDER_RESPONSE_LIMITS.maximumResponseCharacters, maximumResponseCharacters)) : PROVIDER_RESPONSE_LIMITS.responseCharacters; + if (typeof response.body !== "string" || response.body.length > maximum) throw new ProviderRequestError("invalid"); + let value; + try { + value = format === "text" ? response.body : JSON.parse(response.body); + if (validate(value) !== true) throw new Error("Invalid provider data"); + } catch { + throw new ProviderRequestError("invalid"); + } + if (includeJSON && format !== "text") Object.defineProperty(output, parsedJSON, { value }); + return output; + } +} diff --git a/src/class/QWeather.mjs b/src/class/QWeather.mjs index f6f74733..b71b70e2 100644 --- a/src/class/QWeather.mjs +++ b/src/class/QWeather.mjs @@ -1,18 +1,18 @@ -// Modified by hhhoratioxu, 2026-10-19: bounded request coalescing and short-lived response caching. +// WeatherKit provider processing. import { Lodash as _, Console, time } from "@nsnanocat/util"; import AirQuality from "../class/AirQuality.mjs"; import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; import utf8Length from "../function/utf8Length.mjs"; import ForecastNextHour from "./ForecastNextHour.mjs"; -import ProviderRequestCache, { providerErrorCode, readProviderJSON } from "./ProviderRequestCache.mjs"; +import ProviderTransport, { ProviderRequestError, providerErrorCode, readProviderJSON } from "./ProviderTransport.mjs"; import Weather from "./Weather.mjs"; export default class QWeather { static #MaximumWeatherAlertPageSize = 2 * 1024 * 1024; - constructor(parameters, token, host = "devapi.qweather.com", requestCache = new ProviderRequestCache()) { - this.#requests = requestCache; + constructor(parameters, token, host = "devapi.qweather.com", transport = new ProviderTransport()) { + this.#requests = transport; this.Name = "QWeather"; this.Version = "5.3.1"; Console.info(`🟧 ${this.Name} v${this.Version}`); @@ -38,18 +38,22 @@ export default class QWeather { return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { fetcher: fetch, includeJSON: true, - partition: [this.Name, this.version, this.language, this.country], - product: path, - group: [this.latitude, this.longitude, this.country], - kind: /weatheralert/.test(path) ? "alerts" : /minutely/.test(path) ? "minutely" : /\/weather\/\d+[hd]$/.test(path) ? "forecast" : "current", - maximumTTLSeconds: path.includes("/minutely/") ? 30 : 300, validate: body => { if (body?.error) return false; if (path.startsWith("/weatheralert/")) return Boolean(body?.metadata) && Array.isArray(body?.alerts); if (path.startsWith("/airquality/")) return Array.isArray(body?.pollutants) && Array.isArray(body?.indexes) && body.indexes.some(index => typeof index?.code === "string" && index.aqi !== null && String(index.aqi).trim() !== "" && Number.isFinite(Number(index.aqi))); if (body?.code !== "200") return false; if (path.startsWith("/geo/")) return Array.isArray(body.location) && body.location.length > 0; - if (path.includes("/minutely/")) return Array.isArray(body.minutely) && body.minutely.length > 0; + if (path.includes("/minutely/")) + return ( + typeof body.updateTime === "string" && + Number.isFinite(Date.parse(body.updateTime)) && + (body.summary === undefined || typeof body.summary === "string") && + Array.isArray(body.minutely) && + body.minutely.length > 0 && + body.minutely.length <= 8192 && + body.minutely.every(row => row && typeof row.fxTime === "string" && Number.isFinite(Date.parse(row.fxTime)) && (typeof row.precip === "number" || (typeof row.precip === "string" && row.precip.trim() !== "")) && Number.isFinite(Number(row.precip)) && Number(row.precip) >= 0) + ); if (path.endsWith("/historical/air")) return Array.isArray(body.airHourly) && body.airHourly.length > 0; if (path.endsWith("/weather/now")) return body.now?.temp !== undefined && body.now.temp !== null && String(body.now.temp).trim() !== "" && Number.isFinite(Number(body.now.temp)); if (path.endsWith("/now")) return Boolean(body.now && typeof body.now === "object"); @@ -283,36 +287,55 @@ export default class QWeather { }, }; - static async GetLocationsGrid(qweatherCache, setCache, runtime = new ProviderRequestCache()) { + static #ValidLocationsGrid(data) { + if (!data || !Number.isFinite(data.gridSize) || data.gridSize <= 0 || !data.grid || typeof data.grid !== "object" || Array.isArray(data.grid)) return false; + // Application validation bounds, independent of native HTTP buffers. + let cells = 0, + locations = 0; + for (const key of Object.keys(data.grid)) { + if (++cells > 8192 || !/^-?\d+,-?\d+$/.test(key) || !Array.isArray(data.grid[key])) return false; + for (const location of data.grid[key]) { + if (++locations > 32768 || !location || !((typeof location.id === "string" && location.id.trim()) || Number.isFinite(location.id)) || !Number.isFinite(location.latitude) || Math.abs(location.latitude) > 90 || !Number.isFinite(location.longitude) || Math.abs(location.longitude) > 180) return false; + } + } + return locations > 0; + } + + static async GetLocationsGrid(qweatherCache, setCache, runtime = new ProviderTransport()) { Console.info("☑️ GetLocationsGrid"); const locationsGrid = qweatherCache?.locationsGrid; + const usable = QWeather.#ValidLocationsGrid(locationsGrid?.data); + const now = Date.now(); // cache within 30 days - if (locationsGrid?.lastUpdated && locationsGrid.lastUpdated + 30 * 24 * 60 * 60 * 1000 > Date.now()) { + if (usable && Number.isFinite(locationsGrid.lastUpdated) && now >= locationsGrid.lastUpdated && now - locationsGrid.lastUpdated < 30 * 24 * 60 * 60 * 1000) { Console.info("✅ GetLocationsGrid", "Cache found!"); return locationsGrid.data; } else { Console.info("⚠️ GetLocationsGrid", "Cache not found or stale, fetching..."); + const etag = typeof locationsGrid?.etag === "string" && locationsGrid.etag.trim() && locationsGrid.etag.length <= 1024 ? locationsGrid.etag : undefined; + const revalidate = usable && Boolean(etag); const response = await runtime.fetch( { - headers: locationsGrid?.etag ? { "If-None-Match": locationsGrid?.etag } : undefined, + headers: revalidate ? { "If-None-Match": etag } : undefined, url: "https://raw.githubusercontent.com/NSRingo/QWeather-Location-Grid/refs/heads/main/data/qweather-china-city-list-grid.json", }, - { fetcher: fetch, cacheable: false, format: "text", allowNotModified: true, validate: () => true, maximumResponseCharacters: 2 * 1024 * 1024 }, + { fetcher: fetch, includeJSON: true, allowNotModified: revalidate, validate: QWeather.#ValidLocationsGrid, maximumResponseCharacters: 2 * 1024 * 1024 }, ); - - if (response.status === 304) { + const notModified = Number(response.statusCode ?? response.status) === 304; + if (notModified && !revalidate) throw new ProviderRequestError("invalid"); + const data = notModified ? locationsGrid.data : readProviderJSON(response); + const returnedETag = Object.entries(response.headers ?? {}).find(([key]) => key.toLowerCase() === "etag")?.[1]; + const nextETag = typeof returnedETag === "string" && returnedETag.trim() && returnedETag.length <= 1024 ? returnedETag : notModified ? etag : undefined; + if (notModified) { Console.info("✅ GetLocationsGrid", "Cache not modified"); - setCache({ ...qweatherCache, locationsGrid: { ...locationsGrid, lastUpdated: Date.now() } }); - return locationsGrid.data; } - - const newLocationsGrid = readProviderJSON(response); - setCache({ - ...qweatherCache, - locationsGrid: { etag: response.headers.ETag, lastUpdated: Date.now(), data: newLocationsGrid }, - }); + try { + if (typeof setCache === "function") setCache({ ...qweatherCache, locationsGrid: { ...locationsGrid, etag: nextETag, lastUpdated: Date.now(), data } }); + } catch { + /* Optional storage must not discard a validated response. */ + } Console.info("✅ GetLocationsGrid"); - return newLocationsGrid; + return data; } } @@ -509,7 +532,7 @@ export default class QWeather { * @param {Record} requestHeaders 原请求头 / Original request headers. * @returns {Promise<{alerts: Array, areaName: string, source: string}>} 来源集合 / Source collection. */ - static async FetchWeatherAlertPage(identifier, language = "zh-CN", requestHeaders = {}, runtime = new ProviderRequestCache()) { + static async FetchWeatherAlertPage(identifier, language = "zh-CN", requestHeaders = {}, runtime = new ProviderTransport()) { const sourceUrl = QWeather.BuildWeatherAlertPageURL(identifier, language); const normalizedHeaders = Object.fromEntries(Object.entries(requestHeaders).map(([key, value]) => [key.toLowerCase(), value])); const sourceHeaders = { Accept: "text/html,application/xhtml+xml", "Accept-Language": normalizedHeaders["accept-language"] ?? language, Referer: "https://www.qweather.com/" }; @@ -520,12 +543,7 @@ export default class QWeather { response = await runtime.fetch( { url: sourceUrl.toString(), headers: sourceHeaders, "auto-cookie": false }, { - partition: ["QWeatherWeb", language], includeJSON: true, - product: "weather-alert-page.v1", - kind: "alerts", - maximumTTLSeconds: 30, - group: [identifier], validate: body => body?.pageValid === true && Array.isArray(body.alerts) && typeof body.areaName === "string", fetcher: async request => { const source = await fetch(request); @@ -838,8 +856,9 @@ export default class QWeather { condition: [], forecastEnd: 0, forecastStart: minuteStemp, - minutes: body?.minutely - ?.map(minutely => { + minutes: body.minutely + .slice(0, 17) + .map(minutely => { const minute = { perceivedPrecipitationIntensity: 0, precipitationChance: 0, @@ -923,7 +942,7 @@ export default class QWeather { // perceivedPrecipitationIntensity: "", // Not given precipitationAmount: Number.parseFloat(hourly?.precip), precipitationChance: Number.parseInt(hourly?.pop, 10), - precipitationIntensity: Number.parseInt(hourly?.precip, 10), + precipitationIntensity: Number.parseFloat(hourly?.precip), // precipitationType: "", // Not given pressure: Number.parseFloat(hourly?.pressure), // pressureTrend: "", // Not given diff --git a/src/class/WAQI.mjs b/src/class/WAQI.mjs index f079a558..58536eb6 100644 --- a/src/class/WAQI.mjs +++ b/src/class/WAQI.mjs @@ -1,13 +1,13 @@ -// Modified by hhhoratioxu, 2026-10-19: cache weather data only; token endpoints remain uncached. +// WeatherKit provider processing. import { Console } from "@nsnanocat/util"; import AirQuality from "../class/AirQuality.mjs"; import fetch from "../function/fetchProvider.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; -import ProviderRequestCache, { providerErrorCode, readProviderJSON } from "./ProviderRequestCache.mjs"; +import ProviderTransport, { providerErrorCode, readProviderJSON } from "./ProviderTransport.mjs"; export default class WAQI { - constructor(parameters, token, requestCache = new ProviderRequestCache()) { - this.#requests = requestCache; + constructor(parameters, token, transport = new ProviderTransport()) { + this.#requests = transport; this.Name = "WAQI"; this.Version = "1.4.3"; Console.info(`🟧 ${this.Name} v${this.Version}`); @@ -26,9 +26,6 @@ export default class WAQI { return this.#requests.fetch(typeof $loon === "string" ? { ...request, timeout: request.timeout ?? 5000 } : request, { fetcher: fetch, includeJSON: true, - partition: [this.Name, this.version, this.language, this.country], - group: [this.latitude, this.longitude, this.country], - product: "air-quality", validate: body => { if (request.url.includes("/mapq/")) return body?.status !== "error" && Array.isArray(body?.d) && body.d.length > 0; if (request.url.includes("/mapq2/")) return body?.status === "ok" && Array.isArray(body?.data?.stations) && body.data.stations.length > 0; @@ -161,9 +158,6 @@ export default class WAQI { const body = await this.#requests .fetch(request, { fetcher: fetch, - cacheable: false, - partition: [this.Name, this.version, this.language, this.country], - product: "token", validate: value => value?.rxs?.status === "ok" && value.rxs.obs?.[0]?.status === "ok" && typeof value.rxs.obs[0].msg?.token === "string" && value.rxs.obs[0].msg.token.trim().length > 0, }) .then(response => readProviderJSON(response)); @@ -209,9 +203,6 @@ export default class WAQI { const body = await this.#requests .fetch(request, { fetcher: fetch, - cacheable: false, - partition: [this.Name, this.version, this.language, this.country], - product: "authenticated-air-quality", validate: value => value?.rxs?.status === "ok" && value.rxs.obs?.[0]?.status === "ok" && Boolean(value.rxs.obs[0].msg?.city) && value.rxs.obs[0].msg.aqi !== null && String(value.rxs.obs[0].msg.aqi).trim() !== "" && Number.isFinite(Number(value.rxs.obs[0].msg.aqi)), }) .then(response => readProviderJSON(response)); diff --git a/src/class/WeatherAlerts.mjs b/src/class/WeatherAlerts.mjs index 4a1f0615..54c7c94f 100644 --- a/src/class/WeatherAlerts.mjs +++ b/src/class/WeatherAlerts.mjs @@ -1,4 +1,5 @@ import { Console } from "@nsnanocat/util"; +import { knownWeatherAlertExpiry, UNKNOWN_WEATHER_ALERT_EXPIRY } from "../function/weatherAlertExpiry.mjs"; /** * 从来源页面提取的预警记录。 @@ -122,13 +123,14 @@ export default class WeatherAlerts { static #FillAlert(appleAlert, sourceAlert, appleAlertIndex, sourceAlertIndex) { let before; if (WeatherAlerts.#IsDebug()) before = WeatherAlerts.#WeatherAlertDebugSnapshot(appleAlert, appleAlertIndex); - const sourceExpireTime = sourceAlert.expireTime === "9999-12-31T23:59:59Z" ? undefined : sourceAlert.expireTime; + const sourceExpireTime = knownWeatherAlertExpiry(sourceAlert.expireTime); + const sourceEndTime = knownWeatherAlertExpiry(sourceAlert.eventEndTime); WeatherAlerts.#FillText(appleAlert, "areaId", sourceAlert.areaId); WeatherAlerts.#FillText(appleAlert, "areaName", sourceAlert.areaName); WeatherAlerts.#FillTime(appleAlert, "effectiveTime", sourceAlert.effectiveTime ?? sourceAlert.issuedTime); WeatherAlerts.#FillTime(appleAlert, "eventOnsetTime", sourceAlert.eventOnsetTime ?? sourceAlert.effectiveTime ?? sourceAlert.issuedTime ?? appleAlert.effectiveTime ?? appleAlert.issuedTime); - WeatherAlerts.#FillTime(appleAlert, "eventEndTime", sourceAlert.eventEndTime ?? sourceExpireTime ?? appleAlert.expireTime); - WeatherAlerts.#FillTime(appleAlert, "expireTime", sourceExpireTime ?? sourceAlert.eventEndTime); + WeatherAlerts.#FillTime(appleAlert, "eventEndTime", sourceEndTime ?? sourceExpireTime ?? (knownWeatherAlertExpiry(appleAlert.expireTime) || undefined)); + WeatherAlerts.#FillTime(appleAlert, "expireTime", sourceExpireTime ?? sourceEndTime); WeatherAlerts.#FillTime(appleAlert, "issuedTime", sourceAlert.issuedTime ?? sourceAlert.effectiveTime); WeatherAlerts.#FillDescription(appleAlert, sourceAlert); WeatherAlerts.#FillText(appleAlert, "source", sourceAlert.source); @@ -366,9 +368,10 @@ export default class WeatherAlerts { const areaId = alert.areaId || contextAreaId; const areaName = alert.areaName || extracted.areaName; const effectiveTime = alert.effectiveTime ?? alert.issuedTime; - const expireTime = alert.expireTime ?? "9999-12-31T23:59:59Z"; + const sourceExpireTime = knownWeatherAlertExpiry(alert.expireTime); + const expireTime = sourceExpireTime ?? UNKNOWN_WEATHER_ALERT_EXPIRY; const eventOnsetTime = alert.eventOnsetTime ?? effectiveTime; - const eventEndTime = alert.eventEndTime ?? (alert.expireTime ? expireTime : undefined); + const eventEndTime = knownWeatherAlertExpiry(alert.eventEndTime) ?? sourceExpireTime; const source = alert.source || extracted.source || ""; const importance = alert.importance || WeatherAlerts.#ImportanceFromSeverity(alert.severity); const phenomenon = alert.phenomenon; diff --git a/src/class/WeatherKit2.mjs b/src/class/WeatherKit2.mjs index 2e7c438a..5a4cdc20 100644 --- a/src/class/WeatherKit2.mjs +++ b/src/class/WeatherKit2.mjs @@ -51,27 +51,27 @@ function encodeCurrentWeather(builder, data) { const metadataOffset = encodeMetadata(builder, data.metadata); const precipitationAmountNext1hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext1hByTypeVector( builder, - data.precipitationAmountNext1hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountNext1hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountNext24hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext24hByTypeVector( builder, - data.precipitationAmountNext24hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountNext24hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountNext6hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountNext6hByTypeVector( builder, - data.precipitationAmountNext6hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountNext6hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountPrevious1hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious1hByTypeVector( builder, - data.precipitationAmountPrevious1hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountPrevious1hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountPrevious24hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious24hByTypeVector( builder, - data.precipitationAmountPrevious24hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountPrevious24hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); const precipitationAmountPrevious6hByTypeOffset = WK2.CurrentWeatherData.createPrecipitationAmountPrevious6hByTypeVector( builder, - data.precipitationAmountPrevious6hByType.map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), + (data.precipitationAmountPrevious6hByType ?? []).map(p => WK2.PrecipitationAmountByType.createPrecipitationAmountByType(builder, WK2.PrecipitationType[p.precipitationType], p.expected, p.minimumSnow, p.maximumSnow, p.expectedSnow)), ); return WK2.CurrentWeatherData.createCurrentWeatherData( builder, diff --git a/src/data/providerRegions.mjs b/src/data/providerRegions.mjs deleted file mode 100644 index d8ede4a8..00000000 --- a/src/data/providerRegions.mjs +++ /dev/null @@ -1,6 +0,0 @@ -// Natural Earth public-domain data. Provider hints only; not authoritative boundaries. -// Source: https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_admin_0_countries.geojson -let regions; -export default function getProviderRegions() { - return regions ??= JSON.parse("{\"CN\":[[[[78.91769,33.38626],[78.82426,33.46106],[78.78137,33.55279],[78.78726,33.80843],[78.72112,33.99439],[78.73011,34.07926],[78.90323,34.15812],[78.97619,34.21716],[78.98849,34.26313],[78.97619,34.30917],[78.92214,34.3723],[78.80173,34.41501],[78.75068,34.47128],[78.66458,34.52642],[78.37943,34.57867],[78.29603,34.62466],[78.21138,34.84826],[78.14813,34.94314],[78.13004,35.05543],[78.00116,35.26891],[78.00984,35.34746],[78.05563,35.45288],[78.04426,35.49163],[77.98587,35.49417],[77.88313,35.43107],[77.83425,35.45215],[77.80035,35.49541],[77.76035,35.49809],[77.66061,35.4581],[77.5122,35.47846],[77.38373,35.472],[77.2811,35.52801],[77.14127,35.52538],[77.03905,35.58501],[76.91503,35.58315],[76.84299,35.64129],[76.76341,35.64708],[76.67669,35.71379],[76.57076,35.75126],[76.55112,35.78402],[76.56528,35.86546],[76.55546,35.89786],[76.53717,35.90251],[76.50017,35.87151],[76.43206,35.8605],[76.40312,35.83099],[76.34886,35.83099],[76.31196,35.80965],[76.16603,35.80624],[76.13812,35.82665],[76.11466,35.94179],[76.08996,35.97812],[75.95302,36.00726],[75.9,36.06349],[75.90144,36.11361],[75.97152,36.155],[75.97007,36.20234],[76.01441,36.2304],[75.96883,36.30197],[75.98743,36.42119],[75.97658,36.46263],[75.84977,36.64474],[75.72874,36.72319],[75.64978,36.75404],[75.57051,36.76675],[75.50798,36.74763],[75.49258,36.72071],[75.43191,36.72293],[75.41052,36.74732],[75.37796,36.89429],[75.31347,36.92876],[75.19513,36.94888],[75.10015,36.99412],[74.92497,36.97087],[74.87236,36.9071],[74.85324,36.95898],[74.79257,37.01516],[74.71919,37.01645],[74.66245,37.05448],[74.54235,37.02167],[74.47662,37.08314],[74.38257,37.12657],[74.36831,37.16706],[74.45667,37.17732],[74.50008,37.23163],[74.59,37.24336],[74.62328,37.2308],[74.72146,37.29778],[74.73738,37.29612],[74.79402,37.21393],[74.89231,37.23111],[74.95153,37.28129],[75.06842,37.31214],[75.09178,37.37844],[75.16412,37.40064],[75.05343,37.49384],[74.94522,37.54856],[74.89189,37.5992],[74.88611,37.65346],[74.96517,37.76746],[74.94925,37.79532],[74.88611,37.83056],[74.9137,37.88084],[74.89076,37.89771],[74.89282,37.99401],[74.88042,38.02166],[74.80993,38.05321],[74.79071,38.08145],[74.77107,38.29154],[74.7843,38.32567],[74.83825,38.37942],[74.83577,38.45517],[74.76032,38.51998],[74.69645,38.52398],[74.64612,38.56111],[74.3342,38.667],[74.22144,38.64971],[74.12336,38.66947],[74.0844,38.61403],[74.03727,38.59847],[74.05877,38.54703],[74.0289,38.52724],[73.93288,38.52967],[73.8164,38.58659],[73.73641,38.72167],[73.73589,38.80042],[73.6808,38.87298],[73.71719,38.91858],[73.80958,38.93266],[73.82612,38.9769],[73.70933,39.06547],[73.67378,39.14263],[73.60784,39.20732],[73.60226,39.23577],[73.6438,39.35129],[73.63264,39.44834],[73.83831,39.47568],[73.87046,39.54679],[73.92162,39.58213],[73.89351,39.71039],[73.82953,39.74698],[73.81857,39.78605],[73.83201,39.82372],[73.88069,39.86501],[73.8932,39.91813],[73.94404,39.97007],[73.9767,40.0436],[74.1676,40.10639],[74.33379,40.09373],[74.47662,40.17243],[74.5654,40.24695],[74.70575,40.33129],[74.86213,40.32617],[74.85386,40.35883],[74.7874,40.42074],[74.83536,40.51164],[75.00238,40.4473],[75.06439,40.44384],[75.13808,40.46389],[75.19389,40.44125],[75.48183,40.61411],[75.55986,40.63287],[75.58767,40.61194],[75.60565,40.56941],[75.61795,40.49396],[75.66694,40.45025],[75.63831,40.38611],[75.65629,40.32307],[75.68182,40.2917],[75.77205,40.31015],[75.90186,40.29888],[75.96242,40.35733],[76.05162,40.3902],[76.15135,40.36813],[76.24416,40.4412],[76.27341,40.43412],[76.31351,40.34333],[76.4765,40.43614],[76.55639,40.56569],[76.60941,40.59711],[76.64786,40.76485],[76.76248,40.84702],[76.76682,40.94468],[76.86097,41.01321],[77.00773,41.04421],[77.11884,41.01166],[77.24317,41.00566],[77.33298,41.02065],[77.50352,40.98107],[77.65028,40.99714],[77.83104,41.06297],[78.07496,41.03951],[78.1905,41.11775],[78.27536,41.22885],[78.34946,41.2704],[78.35969,41.28746],[78.33933,41.34404],[78.3599,41.37753],[78.51038,41.45442],[78.61921,41.47809],[78.65828,41.53245],[78.80731,41.57845],[78.91583,41.63317],[78.97619,41.6418],[79.08874,41.70255],[79.19592,41.72952],[79.28232,41.7835],[79.39084,41.7727],[79.61098,41.86763],[79.74751,41.87974],[79.78327,41.90509],[79.81283,41.97762],[79.84291,42.00183],[79.93086,42.02328],[80.18191,42.02098],[80.23121,42.03369],[80.25653,42.08436],[80.22459,42.12557],[80.21033,42.18952],[80.24909,42.20489],[80.26841,42.23797],[80.19617,42.43459],[80.21053,42.49084],[80.16237,42.55239],[80.14377,42.64476],[80.24826,42.8162],[80.33394,42.81708],[80.5486,42.87847],[80.54292,42.91666],[80.37383,42.97518],[80.35513,43.00058],[80.36815,43.02846],[80.61847,43.13427],[80.66002,43.13282],[80.73154,43.10109],[80.77339,43.11292],[80.79345,43.14946],[80.74849,43.27516],[80.64286,43.32622],[80.69423,43.39472],[80.71273,43.45887],[80.47615,43.91024],[80.42313,43.97215],[80.40381,44.04894],[80.3389,44.12149],[80.37321,44.231],[80.33042,44.45305],[80.38375,44.60281],[80.35585,44.65288],[80.37631,44.67128],[80.49196,44.70172],[80.49228,44.72797],[80.31451,44.78032],[80.24474,44.81597],[79.99174,44.79365],[79.86864,44.84936],[79.84301,44.87349],[79.8582,44.90372],[80.10336,45.03219],[80.21901,45.02604],[80.29053,45.04624],[80.39006,45.04578],[80.44535,45.10422],[80.53682,45.10107],[80.69712,45.14365],[80.85385,45.1265],[81.54203,45.29331],[81.66523,45.34798],[81.73928,45.33806],[81.76517,45.30773],[81.79364,45.19564],[81.87534,45.18107],[81.95012,45.14448],[82.24529,45.23181],[82.34183,45.20489],[82.47582,45.1157],[82.53975,45.12365],[82.58522,45.17683],[82.61457,45.24856],[82.63199,45.41583],[82.61571,45.43542],[82.51303,45.46575],[82.36265,45.4812],[82.32746,45.49335],[82.29149,45.53319],[82.37221,45.73416],[82.48895,45.90169],[82.56827,46.19651],[82.67436,46.33113],[82.83895,46.73245],[82.98551,46.94251],[82.98551,47.01253],[83.00628,47.08062],[82.99822,47.11025],[83.02147,47.20591],[83.0716,47.21851],[83.15036,47.21154],[83.22198,47.1719],[83.35339,47.14498],[83.52449,47.0671],[83.90535,46.97396],[84.06963,46.96442],[84.15815,46.9931],[84.26584,47.00013],[84.4218,46.9931],[84.52102,46.9698],[84.66354,46.98385],[84.73166,46.94776],[84.7516,46.91799],[84.74959,46.88246],[84.71553,46.83518],[84.73961,46.81477],[84.91604,46.85055],[84.97577,46.902],[85.04822,46.9207],[85.22584,47.04157],[85.49864,47.05183],[85.55682,47.17875],[85.65966,47.20652],[85.67899,47.27489],[85.67036,47.38434],[85.58457,47.49294],[85.59543,47.62407],[85.51538,47.92028],[85.56179,48.03066],[85.58638,48.14125],[85.71867,48.35883],[85.78368,48.40759],[86.00599,48.42955],[86.19203,48.41823],[86.2467,48.43735],[86.30737,48.48309],[86.43062,48.48588],[86.56508,48.52732],[86.60555,48.59011],[86.752,48.71543],[86.73918,48.78354],[86.78946,48.83914],[86.71805,48.91252],[86.70942,48.95422],[86.74352,49.00771],[86.85948,49.10532],[86.97544,49.0924],[87.11632,49.12977],[87.40043,49.07101],[87.47557,49.0876],[87.50606,49.12088],[87.81632,49.16584],[87.83555,49.10072],[87.82191,49.02874],[87.87224,49.00409],[87.8726,48.96802],[87.7569,48.92037],[87.73788,48.86937],[87.79607,48.83976],[87.82108,48.78829],[87.90758,48.75883],[87.97549,48.75599],[88.05941,48.70809],[88.05502,48.67341],[87.94283,48.59949],[87.98303,48.55233],[88.06251,48.54288],[88.16597,48.49373],[88.23563,48.49319],[88.30487,48.46901],[88.4167,48.3941],[88.50269,48.39255],[88.56512,48.34299],[88.56124,48.26827],[88.59623,48.19768],[88.63581,48.17585],[88.70919,48.16636],[88.80541,48.10595],[88.91683,48.10595],[89.04555,47.99299],[89.21872,47.97697],[89.35427,48.03877],[89.42543,48.02164],[89.5418,48.03102],[89.57188,48.02045],[89.58325,47.96204],[89.62738,47.94395],[89.65528,47.89982],[89.72339,47.87816],[89.7513,47.82432],[89.88907,47.82432],[89.93444,47.84426],[89.94669,47.88318],[90.04461,47.87977],[90.06435,47.85062],[90.05216,47.8107],[90.08285,47.7561],[90.16342,47.71001],[90.3448,47.65864],[90.32744,47.62304],[90.38159,47.53839],[90.44123,47.49305],[90.44815,47.40403],[90.48722,47.37819],[90.46872,47.30892],[90.71212,47.0147],[90.7424,46.99186],[90.82818,46.9823],[90.88513,46.94473],[90.93649,46.86443],[90.92533,46.81314],[91.00481,46.74043],[91.01556,46.7032],[90.99592,46.59463],[91.0475,46.56641],[90.93577,46.34387],[90.89619,46.30203],[90.94879,46.16829],[90.99592,46.11372],[91.00244,46.02184],[90.98621,45.99311],[90.91924,45.95007],[90.71077,45.7522],[90.65114,45.49314],[90.75325,45.42095],[90.79656,45.29284],[90.86663,45.25357],[90.85764,45.21507],[90.87324,45.18618],[90.97515,45.21486],[91.0568,45.21755],[91.13504,45.19786],[91.18434,45.15383],[91.24914,45.1297],[91.36655,45.11425],[91.43848,45.14613],[91.53398,45.0711],[91.66296,45.05953],[92.04299,45.07482],[92.2189,45.01012],[92.28535,45.01953],[92.47511,44.99746],[92.84759,45.03911],[92.94505,45.00583],[93.16571,45.00764],[93.24416,44.98154],[93.341,44.98485],[93.42058,44.95509],[93.52528,44.95126],[93.714,44.87458],[93.92742,44.67293],[94.05858,44.65128],[94.18188,44.66084],[94.27748,44.58958],[94.33846,44.51212],[94.43013,44.50447],[94.58805,44.4361],[94.69823,44.3435],[95.03681,44.25497],[95.39772,44.2805],[95.32269,44.15824],[95.31897,44.01711],[95.51027,43.97913],[95.53528,43.95768],[95.58924,43.86949],[95.69248,43.63054],[95.83284,43.4089],[95.87439,43.2471],[96.3187,42.91007],[96.35777,42.7245],[97.19327,42.78726],[99.47448,42.5642],[100.01698,42.67652],[101.21871,42.52983],[101.41074,42.54487],[101.52495,42.53746],[101.6376,42.51544],[101.73537,42.46116],[101.88647,42.27802],[101.97422,42.20789],[102.03416,42.18461],[103.07348,42.00452],[103.69143,41.75923],[104.50078,41.8706],[104.50492,41.65655],[104.89234,41.64477],[104.97383,41.58615],[105.01481,41.59614],[105.2009,41.74347],[105.3582,41.78585],[105.86861,41.99348],[106.76783,42.28662],[108.17736,42.45431],[108.31978,42.43255],[108.53646,42.43511],[108.79685,42.39837],[108.96129,42.44754],[109.25341,42.42594],[109.48513,42.4493],[109.66734,42.54898],[109.9127,42.62882],[110.06019,42.63835],[110.13987,42.68261],[110.40673,42.76861],[110.44941,42.83734],[110.60816,42.94467],[110.6801,43.05766],[110.97341,43.3156],[111.33856,43.43818],[111.4014,43.47551],[111.56769,43.50254],[111.75383,43.6632],[111.84581,43.66692],[111.93335,43.69664],[111.94431,43.78637],[111.83879,43.93879],[111.63446,44.06594],[111.54382,44.15674],[111.50371,44.22572],[111.49059,44.28262],[111.4106,44.32608],[111.39705,44.38737],[111.42413,44.44804],[111.53824,44.58343],[111.55136,44.69319],[111.73853,44.9663],[111.84333,45.03953],[111.92674,45.06092],[111.95826,45.08454],[112.10761,45.06738],[112.41281,45.06604],[112.52391,44.99327],[112.58603,44.92382],[112.7485,44.86527],[113.10486,44.79437],[113.47321,44.7707],[113.53243,44.74528],[113.60478,44.7397],[113.67247,44.76817],[113.88931,44.90827],[114.01705,44.92728],[114.08929,44.96175],[114.14934,45.03007],[114.41889,45.20148],[114.50229,45.30323],[114.53371,45.3855],[114.70331,45.42715],[114.93886,45.37377],[115.12634,45.39444],[115.33728,45.3947],[115.66739,45.45428],[116.00215,45.67556],[116.09486,45.67654],[116.16989,45.7091],[116.25164,45.80325],[116.2071,45.88087],[116.21319,45.90816],[116.35717,46.10556],[116.52511,46.22679],[116.56832,46.29087],[116.722,46.3287],[116.81461,46.38673],[116.95785,46.36642],[117.33747,46.35728],[117.3692,46.47569],[117.4125,46.52463],[117.39379,46.57137],[117.51007,46.57848],[117.56815,46.60354],[117.6252,46.53566],[117.67337,46.51602],[117.7976,46.52026],[117.90064,46.6076],[118.00554,46.62697],[118.08388,46.66826],[118.16904,46.6785],[118.26899,46.72294],[118.33555,46.72216],[118.4728,46.68568],[118.55197,46.68576],[118.60938,46.70578],[118.72007,46.67674],[118.75382,46.69116],[118.81624,46.75818],[118.84652,46.76549],[118.86745,46.75834],[118.89055,46.71353],[118.96724,46.74046],[119.03695,46.66873],[119.31693,46.61168],[119.50126,46.62801],[119.62348,46.61633],[119.68012,46.59163],[119.74027,46.61325],[119.78223,46.65563],[119.85442,46.65966],[119.90703,46.71831],[119.8831,46.75301],[119.90289,46.84241],[119.88145,46.87285],[119.89643,46.89859],[119.8523,46.91073],[119.77014,46.993],[119.75396,47.09444],[119.69996,47.15953],[119.58736,47.23836],[119.52509,47.25081],[119.47599,47.30921],[119.32065,47.40703],[119.2819,47.41486],[119.31776,47.46212],[119.31011,47.4761],[119.1339,47.52754],[119.08346,47.66156],[118.76741,47.75616],[118.54225,47.96625],[118.4728,47.98945],[118.28005,47.99813],[118.18248,48.02821],[117.97278,47.9979],[117.81516,48.00482],[117.7669,47.99314],[117.52722,47.78649],[117.43648,47.7305],[117.36083,47.65087],[117.31711,47.65407],[117.06968,47.81016],[116.82205,47.8763],[116.49969,47.83636],[116.24389,47.86289],[116.08431,47.80693],[115.9729,47.70905],[115.91451,47.68391],[115.8527,47.70556],[115.57572,47.90974],[115.51422,48.13164],[115.79999,48.24233],[115.80929,48.27395],[115.78656,48.49301],[115.8004,48.53037],[116.02819,48.76746],[116.04804,48.79976],[116.03781,48.87015],[116.68428,49.82326],[117.05759,49.67289],[117.25293,49.62245],[117.47286,49.61429],[117.75884,49.51274],[117.83687,49.50902],[117.87377,49.51321],[117.89382,49.54664],[117.93071,49.56731],[118.18847,49.68126],[118.2294,49.72921],[118.37296,49.78632],[118.39373,49.82001],[118.47704,49.83598],[118.52127,49.89406],[118.64436,49.95457],[118.74147,49.95308],[118.92378,49.99163],[118.99044,49.97933],[119.09168,49.98656],[119.20852,50.01514],[119.31621,50.09265],[119.34396,50.14242],[119.33378,50.27626],[119.36551,50.31083],[119.35662,50.34075],[119.17534,50.3561],[119.18051,50.3698],[119.14123,50.37662],[119.13756,50.39253],[119.15488,50.41139],[119.20676,50.41512],[119.24366,50.4455],[119.26458,50.48276],[119.25089,50.50451],[119.29358,50.59924],[119.37373,50.64275],[119.39166,50.67205],[119.44282,50.68507],[119.49666,50.73706],[119.51485,50.79194],[119.49749,50.83592],[119.52798,50.86677],[119.51796,50.9048],[119.60425,50.97818],[119.68322,51.01606],[119.71019,51.03854],[119.7104,51.06247],[119.74084,51.07153],[119.76523,51.14243],[119.75815,51.15801],[119.78543,51.17166],[119.75815,51.19902],[119.76218,51.21504],[119.81499,51.21589],[119.80595,51.24401],[119.81964,51.27473],[119.91566,51.33074],[119.8953,51.38397],[119.95286,51.39767],[119.98273,51.44216],[119.9772,51.48701],[120.05291,51.5522],[120.05673,51.59639],[120.10045,51.65995],[120.16562,51.66855],[120.3193,51.77604],[120.46482,51.8402],[120.49314,51.87572],[120.54115,51.88298],[120.56632,51.90533],[120.6476,51.91102],[120.7109,51.98833],[120.69731,52.04871],[120.77917,52.1176],[120.7832,52.16199],[120.75127,52.20296],[120.75809,52.23741],[120.65494,52.29911],[120.62616,52.34681],[120.69039,52.43913],[120.72398,52.54217],[120.45402,52.63904],[120.26111,52.61105],[120.17595,52.58374],[120.07937,52.58997],[120.03379,52.62537],[120.0326,52.65888],[120.05291,52.69301],[120.03296,52.76066],[120.28018,52.86592],[120.4503,53.00834],[120.61479,53.09707],[120.71406,53.18505],[120.82387,53.23523],[120.82599,53.26187],[120.87426,53.28016],[121.05486,53.29],[121.20349,53.27675],[121.341,53.32365],[121.51215,53.32982],[121.64103,53.38491],[121.88897,53.43059],[122.08162,53.4295],[122.1687,53.46914],[122.31675,53.49601],[122.4016,53.45095],[122.47121,53.46475],[122.85966,53.47395],[123.00255,53.50733],[123.1536,53.51312],[123.25256,53.56779],[123.34025,53.56144],[123.48154,53.51808],[123.49477,53.56009],[123.5144,53.56242],[123.54748,53.54051],[123.51957,53.51999],[123.54189,53.5141],[123.61429,53.56335],[123.70178,53.51007],[123.8502,53.49498],[123.97055,53.45519],[124.01737,53.40702],[124.12181,53.35269],[124.18222,53.37855],[124.25307,53.3755],[124.29343,53.35129],[124.34252,53.27249],[124.3837,53.24613],[124.50049,53.21368],[124.6516,53.20877],[124.68828,53.1936],[124.72017,53.14882],[124.80135,53.15516],[124.87014,53.108],[124.89623,53.11766],[124.87918,53.15386],[124.89768,53.18551],[125.07369,53.2122],[125.12743,53.20655],[125.47651,53.08351],[125.52131,53.04242],[125.62135,53.06214],[125.65019,53.02338],[125.73355,52.98165],[125.71985,52.9463],[125.66094,52.91936],[125.64647,52.88489],[125.83736,52.90672],[125.86372,52.88858],[125.86744,52.84936],[125.97694,52.765],[126.01782,52.78239],[126.08804,52.77541],[126.10407,52.75446],[126.03513,52.72712],[126.06861,52.67989],[125.98278,52.65304],[125.97317,52.63157],[125.99084,52.58643],[126.02805,52.56638],[126.08277,52.59335],[126.21233,52.53305],[126.20034,52.46708],[126.27599,52.46328],[126.35072,52.37691],[126.31573,52.31621],[126.43929,52.27529],[126.31222,52.22795],[126.31671,52.19976],[126.53117,52.15266],[126.55598,52.13064],[126.51443,52.04254],[126.46709,52.03166],[126.43929,51.99786],[126.64791,51.74126],[126.72558,51.70873],[126.70734,51.68563],[126.72372,51.63589],[126.6769,51.57659],[126.80743,51.53422],[126.83038,51.50812],[126.7927,51.45906],[126.8003,51.41896],[126.89011,51.40702],[126.90944,51.3911],[126.8975,51.36196],[126.82702,51.33899],[126.80898,51.28907],[126.84588,51.25147],[126.86991,51.25173],[126.89068,51.2715],[126.88531,51.30289],[126.89937,51.31651],[126.97218,51.324],[126.97776,51.29656],[126.90515,51.20812],[126.93177,51.06378],[127.03233,50.97245],[127.14426,50.90351],[127.22074,50.78817],[127.28735,50.75101],[127.28895,50.67515],[127.34399,50.62776],[127.36709,50.57779],[127.36481,50.55474],[127.28895,50.46602],[127.35851,50.41512],[127.35872,50.38132],[127.33066,50.3282],[127.35463,50.29864],[127.59064,50.20872],[127.57018,50.13022],[127.48667,50.02563],[127.49442,49.97318],[127.534,49.92248],[127.50672,49.8443],[127.53865,49.78993],[127.64428,49.76714],[127.67999,49.73919],[127.7036,49.66064],[127.85465,49.58488],[127.9207,49.58111],[127.96793,49.60323],[128.06715,49.54669],[128.1983,49.53899],[128.26786,49.55879],[128.34475,49.54798],[128.38351,49.57935],[128.55125,49.58845],[128.62867,49.57532],[128.67187,49.61144],[128.80168,49.56514],[128.79951,49.53186],[128.75413,49.50246],[128.76013,49.47088],[128.87919,49.47972],[128.99815,49.44127],[129.06688,49.36164],[129.11535,49.34877],[129.21788,49.39079],[129.34097,49.34117],[129.37322,49.36986],[129.3787,49.42505],[129.4806,49.42127],[129.52773,49.38484],[129.53207,49.32154],[129.55181,49.29048],[129.71118,49.27415],[129.74177,49.19571],[129.84151,49.16718],[129.84151,49.1092],[129.92068,49.08186],[129.91344,49.03954],[129.92647,49.02569],[129.97422,49.01515],[130.2018,48.8826],[130.24934,48.86451],[130.43972,48.8965],[130.46535,48.89133],[130.48664,48.86368],[130.5409,48.85345],[130.67433,48.87082],[130.52974,48.61626],[130.62017,48.5605],[130.62637,48.48226],[130.71588,48.49906],[130.75897,48.48572],[130.74327,48.41159],[130.82512,48.31777],[130.81944,48.28189],[130.77386,48.24419],[130.75277,48.18962],[130.67474,48.13593],[130.66337,48.11644],[130.67474,48.06394],[130.90698,47.88426],[130.9417,47.8206],[130.9418,47.71797],[131.02335,47.68228],[131.10386,47.67655],[131.21011,47.70921],[131.41837,47.74262],[131.50777,47.72153],[131.60326,47.66154],[131.703,47.69616],[131.91405,47.66763],[132.02536,47.69787],[132.16168,47.69001],[132.25077,47.72536],[132.33242,47.73652],[132.52465,47.70753],[132.57416,47.73959],[132.57643,47.79424],[132.668,47.88132],[132.66036,47.93134],[132.67462,47.95061],[132.72247,47.95893],[132.77487,47.93377],[132.80588,47.9362],[132.94075,48.02671],[133.03284,48.05676],[133.09196,48.10678],[133.22373,48.12598],[133.30146,48.10128],[133.52439,48.09544],[133.55994,48.11329],[133.59384,48.16419],[133.67022,48.18998],[133.73791,48.25109],[133.96643,48.27866],[134.05717,48.32718],[134.38635,48.38134],[134.47131,48.33989],[134.54262,48.35731],[134.61135,48.28543],[134.71873,48.26341],[134.68742,48.21985],[134.68297,48.13551],[134.58644,48.06115],[134.55936,48.00952],[134.60132,47.94413],[134.58768,47.92591],[134.65775,47.88669],[134.67646,47.82432],[134.76979,47.74101],[134.77258,47.71073],[134.69289,47.6489],[134.68266,47.58785],[134.62427,47.54963],[134.55688,47.47119],[134.48464,47.43018],[134.41498,47.43922],[134.31287,47.42013],[134.24507,47.35509],[134.18357,47.3275],[134.15412,47.25789],[134.19721,47.20534],[134.18977,47.17294],[134.22729,47.11774],[134.19711,47.08516],[134.12425,47.07051],[134.06916,46.98124],[134.06275,46.90685],[134.01666,46.81947],[134.04467,46.772],[134.0148,46.66503],[133.98803,46.62517],[133.90245,46.57364],[133.89191,46.52897],[133.84581,46.47065],[133.85439,46.44466],[133.90028,46.42657],[133.93387,46.39076],[133.92602,46.3713],[133.86338,46.33929],[133.87444,46.31978],[133.89635,46.3272],[133.91599,46.31299],[133.89377,46.30188],[133.90245,46.25899],[133.85532,46.23656],[133.8609,46.22359],[133.83011,46.19687],[133.79352,46.21429],[133.74101,46.15904],[133.68737,46.14184],[133.72003,46.06375],[133.65813,45.93451],[133.61761,45.93627],[133.58588,45.87632],[133.55229,45.88842],[133.51436,45.87927],[133.47726,45.8201],[133.51136,45.77855],[133.48491,45.76847],[133.47085,45.72889],[133.44305,45.70967],[133.4878,45.68667],[133.49152,45.67339],[133.45669,45.64879],[133.47726,45.62771],[133.42765,45.61525],[133.41349,45.57334],[133.37483,45.57308],[133.17981,45.49381],[133.1332,45.41961],[133.10188,45.28721],[133.09754,45.24354],[133.1272,45.15538],[133.09878,45.10779],[132.96618,45.02888],[132.92122,45.0279],[132.83978,45.05916],[131.96882,45.23398],[131.91456,45.26013],[131.85307,45.3376],[131.81803,45.33279],[131.78527,45.30473],[131.72315,45.21533],[131.62776,45.19848],[131.60523,45.16463],[131.62238,45.12365],[131.60905,45.0897],[131.43376,44.96129],[131.32948,44.96764],[131.17652,44.90341],[131.11006,44.91953],[131.05157,44.90904],[131.0248,44.86243],[130.93343,44.84171],[130.93013,44.82496],[130.97157,44.77561],[131.02139,44.74688],[131.06583,44.68203],[131.26447,44.05421],[131.21776,44.01607],[131.2067,43.97478],[131.21734,43.90848],[131.17032,43.7811],[131.19202,43.58863],[131.16071,43.53277],[131.27429,43.4512],[131.28091,43.38022],[131.24794,43.3442],[131.23771,43.26584],[131.16908,43.2017],[131.18396,43.12081],[131.07834,43.03182],[131.0743,42.9934],[131.10304,42.91043],[131.02387,42.89627],[130.99245,42.84873],[130.76952,42.86581],[130.73489,42.82449],[130.58648,42.81726],[130.40716,42.73538],[130.37967,42.69667],[130.40375,42.67755],[130.5039,42.67781],[130.57191,42.62182],[130.58452,42.5689],[130.53077,42.53048],[130.51651,42.59936],[130.50142,42.61078],[130.45512,42.58515],[130.47103,42.56975],[130.45253,42.54934],[130.43124,42.56151],[130.42773,42.59936],[130.32479,42.65029],[130.26267,42.70861],[130.24304,42.74336],[130.25647,42.86689],[130.23901,42.90224],[130.09876,42.92152],[130.13048,42.95947],[130.12149,42.98185],[130.00822,42.96056],[129.93411,43.00841],[129.87975,42.99603],[129.90704,42.97614],[129.85174,42.9619],[129.75728,42.7347],[129.75759,42.70365],[129.78756,42.67685],[129.77671,42.64719],[129.74157,42.629],[129.76307,42.60624],[129.7365,42.58515],[129.72886,42.47586],[129.70333,42.44237],[129.59171,42.44795],[129.58768,42.41137],[129.53517,42.36956],[129.43409,42.44111],[129.38624,42.42811],[129.34015,42.44547],[129.35214,42.42062],[129.3265,42.42677],[129.30377,42.38958],[129.23607,42.37437],[129.23628,42.32388],[129.1939,42.31824],[129.22801,42.29773],[129.18078,42.24311],[129.2093,42.23468],[129.21116,42.21996],[129.1198,42.14937],[129.06471,42.15376],[129.02657,42.10188],[128.96384,42.08852],[128.94007,42.03537],[128.90348,42.02627],[128.72096,42.04759],[128.70928,42.03017],[128.60789,42.03017],[128.56438,42.0008],[128.50237,41.99609],[128.26197,42.03286],[128.03459,41.99374],[128.02043,41.96506],[128.04782,41.86398],[128.09102,41.84174],[128.15913,41.71182],[128.25091,41.66012],[128.303,41.58341],[128.1857,41.40445],[128.14611,41.37634],[128.10394,41.37913],[128.1182,41.39344],[128.10394,41.39959],[128.06415,41.38843],[128.04689,41.39877],[128.05061,41.42073],[128.01516,41.41329],[128.00142,41.47086],[127.97423,41.45034],[127.94421,41.47086],[127.88147,41.46088],[127.84887,41.43132],[127.66268,41.44698],[127.63834,41.42988],[127.55881,41.45825],[127.54289,41.49132],[127.30513,41.49468],[127.26906,41.50496],[127.28978,41.51855],[127.28146,41.53106],[127.24451,41.52801],[127.2071,41.54594],[127.17971,41.51695],[127.15594,41.53054],[127.13465,41.52201],[127.10468,41.53741],[127.17971,41.58516],[127.09723,41.61245],[127.10468,41.62617],[127.05899,41.6433],[127.04897,41.67774],[127.07662,41.68079],[127.04266,41.73265],[126.94613,41.76619],[126.9298,41.80104],[126.88712,41.78492],[126.86479,41.74727],[126.80888,41.72983],[126.78862,41.69591],[126.72728,41.73691],[126.67948,41.73598],[126.72795,41.70125],[126.6693,41.66712],[126.61788,41.66593],[126.56703,41.61415],[126.57727,41.5618],[126.5013,41.43437],[126.52213,41.35195],[126.48766,41.3444],[126.5013,41.36611],[126.47252,41.36652],[126.44089,41.34838],[126.27475,41.15258],[126.12375,41.07553],[126.13817,41.05698],[126.10407,41.00918],[126.07564,40.99708],[126.04551,40.94468],[125.98056,40.91363],[126.01001,40.91389],[126.00784,40.89931],[125.93617,40.8774],[125.88522,40.89751],[125.82196,40.86975],[125.76093,40.88567],[125.68321,40.8479],[125.64482,40.81059],[125.68264,40.76888],[125.60993,40.76129],[125.57175,40.7849],[125.54648,40.72594],[125.46907,40.71292],[125.45299,40.67338],[125.41889,40.66656],[125.41325,40.63179],[125.29331,40.64858],[125.25611,40.61504],[125.02511,40.53437],[125.01529,40.50828],[125.04129,40.4611],[124.9353,40.45557],[124.89551,40.48363],[124.80084,40.39407],[124.7412,40.37035],[124.71247,40.3103],[124.6426,40.29429],[124.57088,40.25243],[124.54866,40.22091],[124.35825,40.08906],[124.36134,40.01118],[124.2942,39.93378],[124.13722,39.84235],[124.11817,39.83316],[124.08814,39.85187],[123.98569,39.81025],[123.94215,39.84056],[123.88754,39.8229],[123.80763,39.83759],[123.77304,39.81835],[123.72006,39.83055],[123.64308,39.87857],[123.65675,39.9195],[123.63095,39.904],[123.5914,39.78433],[123.56324,39.76879],[123.51319,39.7978],[123.42775,39.72663],[123.40846,39.746],[123.41708,39.82453],[123.3982,39.81835],[123.38673,39.78962],[123.31406,39.77611],[123.2732,39.81025],[123.25717,39.80109],[123.25204,39.81708],[123.23585,39.80244],[123.26539,39.7541],[123.20419,39.74506],[123.26914,39.70759],[123.22316,39.67976],[123.12843,39.66486],[123.108,39.69013],[123.05812,39.67475],[123.00912,39.68049],[122.97763,39.66625],[122.99635,39.64964],[122.96412,39.61164],[122.87159,39.60578],[122.84864,39.61787],[122.7798,39.56045],[122.68824,39.51862],[122.64845,39.53242],[122.5879,39.46699],[122.5621,39.48676],[122.55543,39.456],[122.46982,39.41669],[122.39633,39.41104],[122.29005,39.35098],[122.26743,39.31802],[122.25147,39.32368],[122.24025,39.28563],[122.115,39.20075],[122.1045,39.15913],[122.16269,39.14614],[122.04786,39.09919],[122.04233,39.05972],[121.98805,39.06293],[121.94296,39.03628],[121.88844,39.04247],[121.92319,39.00898],[121.82691,39.03628],[121.89527,38.98102],[121.87135,38.95063],[121.84791,38.94745],[121.81153,38.97285],[121.77247,39.02196],[121.7247,39.03018],[121.73878,39.00153],[121.68019,39.00898],[121.63575,38.99531],[121.65602,38.99063],[121.6526,38.96345],[121.58741,38.94009],[121.69361,38.90961],[121.6963,38.86294],[121.55275,38.85814],[121.48495,38.80988],[121.34474,38.81464],[121.28761,38.78807],[121.23666,38.79849],[121.21754,38.77558],[121.24472,38.77558],[121.17954,38.72093],[121.15309,38.72761],[121.11451,38.76813],[121.13925,38.7895],[121.11451,38.85814],[121.15235,38.8863],[121.14853,38.89908],[121.08619,38.90571],[121.11793,38.94343],[121.17954,38.95433],[121.21949,38.9405],[121.33432,38.96857],[121.33424,39.0266],[121.37485,39.04418],[121.42807,39.01472],[121.4673,39.01512],[121.67408,39.08804],[121.68979,39.11815],[121.681,39.13642],[121.64194,39.17621],[121.59262,39.19221],[121.60792,39.20075],[121.60165,39.22126],[121.63307,39.23615],[121.58994,39.26405],[121.68019,39.27521],[121.7361,39.31175],[121.73129,39.33047],[121.7798,39.37149],[121.83749,39.36465],[121.94296,39.39936],[121.93124,39.41718],[121.84889,39.38736],[121.70777,39.39566],[121.71778,39.37149],[121.52589,39.36465],[121.49195,39.38898],[121.43605,39.50861],[121.35402,39.5222],[121.30616,39.50178],[121.2234,39.52863],[121.30616,39.59797],[121.4498,39.6151],[121.47071,39.63215],[121.46388,39.64582],[121.49817,39.65292],[121.53329,39.6223],[121.53956,39.63898],[121.52589,39.65266],[121.55014,39.67866],[121.50685,39.69432],[121.45729,39.74848],[121.48461,39.764],[121.47071,39.81025],[121.50992,39.82403],[121.5359,39.86176],[121.61609,39.8717],[121.68491,39.92251],[121.77442,39.92084],[121.80966,39.96503],[121.87379,39.9914],[121.87477,40.0163],[121.82691,40.02253],[121.92947,40.07729],[121.93312,40.10139],[121.99567,40.12389],[122.00636,40.15632],[121.99147,40.187],[122.09165,40.22721],[122.10612,40.31012],[122.21103,40.3806],[122.20362,40.42723],[122.25009,40.43061],[122.25147,40.45392],[122.2881,40.47285],[122.30185,40.50235],[122.27564,40.54365],[122.22486,40.57852],[122.15043,40.60386],[122.13759,40.62402],[122.16798,40.65351],[122.15943,40.70161],[122.12184,40.68012],[122.04908,40.74608],[121.85108,40.83226],[121.82594,40.94819],[121.8589,40.96748],[121.86793,40.9958],[121.77771,40.93497],[121.78006,40.90791],[121.73955,40.84908],[121.57504,40.8415],[121.51043,40.87936],[121.45021,40.88593],[121.42978,40.9138],[121.36134,40.93964],[121.29933,40.8996],[121.25839,40.92064],[121.24472,40.9064],[121.22495,40.92707],[121.17579,40.92231],[121.07993,40.86298],[121.08027,40.8284],[121.04985,40.7974],[121.02963,40.82648],[120.98194,40.82681],[120.93954,40.74055],[121.02074,40.71933],[120.82532,40.67403],[120.79843,40.59968],[120.67879,40.52192],[120.65911,40.48376],[120.6041,40.46516],[120.59738,40.42242],[120.54566,40.40241],[120.54753,40.37616],[120.5884,40.38432],[120.59115,40.3673],[120.50176,40.32691],[120.50489,40.24909],[120.47202,40.2143],[120.43605,40.19449],[120.30903,40.17967],[120.26132,40.14672],[119.95028,40.0565],[119.91531,40.03615],[119.912,39.99809],[119.77442,39.95051],[119.6609,39.93268],[119.53559,39.89083],[119.51987,39.8583],[119.5307,39.8147],[119.4668,39.81485],[119.42941,39.79531],[119.34986,39.73176],[119.28492,39.59544],[119.24537,39.59447],[119.20859,39.56957],[119.209,39.5502],[119.24301,39.5707],[119.22934,39.53596],[119.2624,39.57751],[119.2814,39.57501],[119.25642,39.49311],[119.30508,39.43298],[119.27943,39.38401],[119.25566,39.36552],[119.1848,39.35481],[119.01564,39.18977],[118.92604,39.1306],[118.8943,39.12442],[118.96998,39.19331],[118.88063,39.1728],[118.87379,39.21381],[118.79599,39.15178],[118.61936,39.16104],[118.52711,39.14924],[118.52915,39.12465],[118.3261,39.03093],[118.23529,39.0531],[118.14053,39.17283],[118.0964,39.20239],[118.02802,39.2198],[117.88559,39.20112],[117.75733,39.11445],[117.71542,39.1114],[117.75148,39.06791],[117.77526,38.98106],[117.70191,38.99531],[117.70875,38.98102],[117.584,38.81159],[117.54355,38.62132],[117.56597,38.61251],[117.71608,38.37851],[117.83717,38.2709],[117.9417,38.23395],[117.97754,38.19484],[118.0072,38.19701],[118.05373,38.17325],[118.06521,38.14308],[118.21583,38.14496],[118.44893,38.10779],[118.54298,38.06835],[118.65487,38.1457],[118.76035,38.15513],[118.8401,38.15257],[118.95004,38.09726],[118.92481,38.05777],[118.95615,38.0336],[118.99659,37.96312],[119.04841,37.94798],[119.09249,37.91277],[119.11694,37.85327],[119.24308,37.76948],[119.2596,37.71585],[119.25566,37.67187],[119.21774,37.67525],[119.12014,37.73751],[119.06947,37.74063],[119.03713,37.72209],[118.96998,37.59345],[118.94459,37.37812],[118.96119,37.29564],[119.14552,37.17886],[119.28712,37.13727],[119.44117,37.12055],[119.52451,37.13907],[119.7706,37.15184],[119.89731,37.24942],[119.84628,37.34369],[119.8589,37.36017],[120.05983,37.44042],[120.18759,37.51179],[120.32252,37.63239],[120.30641,37.66519],[120.21567,37.68407],[120.3685,37.69473],[120.46339,37.74775],[120.55616,37.76138],[120.73805,37.83397],[120.83668,37.81598],[120.8777,37.82966],[120.92579,37.81928],[120.94077,37.80646],[120.9288,37.78872],[120.94093,37.77399],[121.00538,37.74429],[121.03256,37.71039],[121.13795,37.70116],[121.14853,37.6894],[121.13014,37.6518],[121.14853,37.63788],[121.13437,37.61054],[121.17164,37.57709],[121.30812,37.58124],[121.37452,37.61054],[121.35402,37.63788],[121.39356,37.62246],[121.42978,37.59687],[121.39845,37.59488],[121.38168,37.56611],[121.44679,37.5196],[121.4769,37.468],[121.53712,37.45624],[121.57032,37.42866],[121.67612,37.43919],[121.63429,37.46239],[121.58741,37.45356],[121.63502,37.48127],[121.73243,37.46174],[121.93784,37.46426],[121.95729,37.4597],[121.9498,37.4256],[121.98805,37.4256],[121.99741,37.43407],[121.97779,37.47167],[122.02564,37.52802],[122.07179,37.5445],[122.06715,37.56273],[122.09506,37.54914],[122.12184,37.56273],[122.15187,37.53286],[122.12989,37.48347],[122.1832,37.41816],[122.22421,37.45799],[122.24537,37.4597],[122.29648,37.41816],[122.47804,37.4256],[122.48805,37.41193],[122.57057,37.40156],[122.6124,37.41861],[122.68824,37.40982],[122.67897,37.37466],[122.6561,37.38459],[122.57081,37.34479],[122.59474,37.29523],[122.54965,37.31977],[122.56007,37.27473],[122.62892,37.19282],[122.57431,37.18537],[122.56324,37.17316],[122.57431,37.14444],[122.51002,37.15522],[122.48268,37.13996],[122.47071,37.1103],[122.45395,37.15428],[122.43637,37.08979],[122.45704,37.0659],[122.40235,37.02147],[122.42433,37.00365],[122.43637,37.02774],[122.54005,37.02774],[122.53272,36.92593],[122.50742,36.89802],[122.51271,36.93464],[122.49855,36.93891],[122.45664,36.92064],[122.40577,36.92219],[122.43637,36.87067],[122.37973,36.8614],[122.35523,36.82909],[122.31983,36.82294],[122.18531,36.84992],[122.19687,36.89802],[122.26588,36.89802],[122.16269,36.94579],[122.1797,37.00581],[122.16611,37.01411],[122.14999,37.00532],[122.14291,36.94579],[122.09433,36.95091],[122.0809,36.91657],[122.03305,36.89802],[122.04916,36.97993],[121.92628,36.9936],[121.91489,36.98583],[121.9498,36.93891],[121.78598,36.89118],[121.72804,36.83592],[121.64942,36.81607],[121.68417,36.84276],[121.63746,36.83137],[121.62452,36.81477],[121.62525,36.74038],[121.59425,36.76765],[121.52589,36.75397],[121.49643,36.77448],[121.60719,36.84496],[121.54648,36.83592],[121.51157,36.80858],[121.45948,36.82868],[121.47071,36.78962],[121.45338,36.75739],[121.40895,36.75397],[121.37778,36.73151],[121.37941,36.71699],[121.4017,36.71239],[121.31422,36.71182],[121.15772,36.66861],[121.08017,36.61001],[121.03248,36.62425],[121.02353,36.58393],[120.9288,36.59634],[120.93336,36.62027],[120.8506,36.65095],[120.75058,36.61685],[120.75025,36.54076],[120.77866,36.54108],[120.85035,36.60456],[120.92026,36.55329],[120.9275,36.5329],[120.96363,36.54792],[120.93629,36.49702],[120.94996,36.45917],[120.90626,36.45238],[120.89829,36.41161],[120.86744,36.37718],[120.82643,36.43252],[120.83204,36.45527],[120.80689,36.46393],[120.75058,36.45917],[120.71412,36.4287],[120.68653,36.38715],[120.72218,36.33055],[120.66196,36.33633],[120.64365,36.3225],[120.70485,36.15624],[120.62778,36.11652],[120.55161,36.11371],[120.53623,36.09146],[120.47364,36.08926],[120.35353,36.04084],[120.292,36.06196],[120.36158,36.19164],[120.32277,36.22639],[120.30641,36.27424],[120.27353,36.23428],[120.27516,36.20409],[120.29884,36.19164],[120.28452,36.17597],[120.21632,36.19912],[120.21909,36.22191],[120.11728,36.20966],[120.09718,36.22639],[120.08595,36.20913],[120.10922,36.18879],[120.08969,36.11172],[120.16847,36.06196],[120.1753,35.99364],[120.19777,35.9853],[120.22999,35.9999],[120.2295,35.97281],[120.24277,35.97358],[120.28492,36.01093],[120.30307,35.99404],[120.29884,35.97313],[120.22682,35.94184],[120.1753,35.89004],[120.18897,35.94526],[120.13575,35.90412],[120.09718,35.90082],[120.03883,35.84284],[120.00294,35.72777],[119.96998,35.73237],[119.93775,35.76114],[119.90821,35.74486],[119.90016,35.73627],[119.93564,35.70502],[119.90162,35.67154],[119.90455,35.62458],[119.8602,35.60611],[119.83408,35.63532],[119.8401,35.6579],[119.81162,35.64789],[119.74391,35.57591],[119.74838,35.60932],[119.73251,35.62084],[119.64796,35.58393],[119.55152,35.38003],[119.50766,35.33234],[119.42392,35.31346],[119.358,35.09711],[119.2212,35.06403],[119.19288,35.00043],[119.20338,34.93163],[119.1887,34.85627],[119.20191,34.78498],[119.18906,34.76655],[119.19223,34.71821],[119.20981,34.71857],[119.22934,34.75971],[119.31178,34.75507],[119.34669,34.78083],[119.38771,34.75348],[119.44223,34.74604],[119.47421,34.71528],[119.45655,34.68456],[119.64772,34.53384],[119.72885,34.52412],[119.82618,34.4704],[119.93995,34.45791],[120.0787,34.37751],[120.25709,34.31184],[120.32522,34.1113],[120.37135,34.03604],[120.41334,33.89737],[120.39967,33.86933],[120.43141,33.84602],[120.44972,33.78644],[120.50489,33.73558],[120.51173,33.65672],[120.48373,33.64997],[120.63437,33.46695],[120.66725,33.3251],[120.69508,33.29316],[120.75025,33.2696],[120.73023,33.24213],[120.7435,33.20864],[120.73023,33.2049],[120.80698,33.13776],[120.79225,33.10871],[120.81894,33.09219],[120.82594,33.03018],[120.86744,33.03238],[120.89576,33.01358],[120.9048,32.81265],[120.85239,32.75601],[120.86044,32.68977],[120.8401,32.72451],[120.83033,32.69766],[120.90661,32.65971],[120.9112,32.63012],[120.94389,32.60522],[121.08619,32.53604],[121.10084,32.49795],[121.12021,32.50739],[121.15726,32.4956],[121.35414,32.41482],[121.38941,32.38495],[121.41928,32.25031],[121.36085,32.23163],[121.39869,32.16893],[121.42514,32.16405],[121.44337,32.11433],[121.53081,32.10852],[121.65537,32.06293],[121.79864,31.97048],[121.87496,31.86372],[121.87469,31.8259],[121.92206,31.75438],[121.93038,31.72671],[121.91484,31.70643],[121.81731,31.69095],[121.60719,31.74855],[121.28642,31.89059],[121.11509,31.79633],[121.03993,31.81933],[120.97782,31.81436],[120.94056,31.84465],[120.94142,31.89252],[120.91928,31.89936],[120.8108,32.02261],[120.63438,32.07701],[120.49076,32.08245],[120.36158,32.02558],[120.2671,31.94868],[120.22373,31.93683],[120.13136,31.94318],[120.05234,31.97093],[119.99204,32.01586],[119.9122,32.12373],[119.92213,32.21743],[119.90048,32.19819],[119.87428,32.26512],[119.84213,32.29906],[119.75807,32.32697],[119.69239,32.31977],[119.64088,32.35883],[119.61289,32.35456],[119.66074,32.32722],[119.68539,32.29243],[119.64902,32.21955],[119.60605,32.19693],[119.64527,32.19522],[119.69044,32.23534],[119.78858,32.19864],[119.76376,32.1625],[119.77247,32.12678],[119.85556,32.08698],[120.0064,31.95031],[120.13095,31.9123],[120.22999,31.90888],[120.34117,31.95632],[120.45578,31.98252],[120.45541,32.02143],[120.46949,32.02513],[120.60426,31.99144],[120.68995,31.99824],[120.75035,31.97834],[120.8248,31.8287],[120.80775,31.80974],[120.75147,31.82469],[120.71713,31.86107],[120.73146,31.81387],[120.8319,31.77777],[121.01376,31.74303],[121.1444,31.68466],[121.24472,31.60041],[121.32032,31.50483],[121.54623,31.36612],[121.72516,31.28157],[121.87703,31.09914],[121.95082,30.98208],[121.97738,30.91492],[121.96008,30.87426],[121.54294,30.81649],[121.4106,30.76739],[121.33351,30.70307],[121.20289,30.66511],[121.12135,30.593],[121.0474,30.58276],[120.99815,30.55792],[120.93165,30.47305],[120.93954,30.42194],[120.89479,30.40127],[120.91513,30.37458],[120.88819,30.36856],[120.83179,30.30525],[120.78743,30.29362],[120.75399,30.32706],[120.74496,30.35961],[120.69815,30.39395],[120.47915,30.40352],[120.43031,30.38984],[120.37897,30.27352],[120.2579,30.29808],[120.14709,30.19889],[120.17897,30.20067],[120.27439,30.27762],[120.29552,30.27873],[120.35034,30.24073],[120.38472,30.24959],[120.45049,30.36886],[120.62161,30.37256],[120.65167,30.35927],[120.69869,30.2518],[120.7344,30.23221],[120.63179,30.12239],[120.68246,30.08491],[120.76498,30.08661],[120.79225,30.06607],[120.7837,30.0893],[120.688,30.09781],[120.64625,30.12694],[120.75142,30.22563],[120.81349,30.21325],[120.82862,30.18499],[120.89281,30.1698],[120.99439,30.21109],[121.10914,30.28278],[121.1497,30.32575],[121.2541,30.34839],[121.37738,30.32506],[121.42959,30.29298],[121.50685,30.22003],[121.65831,30.01839],[121.73234,29.9786],[121.72193,29.96052],[121.76748,29.97318],[121.91488,29.9187],[121.93556,29.88736],[122.05624,29.88736],[122.12914,29.90378],[122.13641,29.88453],[122.11476,29.87155],[121.98304,29.82318],[121.90217,29.75254],[121.87315,29.75383],[121.86245,29.708],[121.77647,29.65263],[121.68735,29.56265],[121.59254,29.54361],[121.57431,29.55215],[121.53082,29.51001],[121.51104,29.54899],[121.44337,29.52424],[121.45532,29.48935],[121.42784,29.46795],[121.44259,29.41537],[121.46507,29.42089],[121.47975,29.47476],[121.50489,29.48383],[121.49179,29.45596],[121.51368,29.45197],[121.51173,29.43757],[121.47763,29.41275],[121.48486,29.39054],[121.53132,29.41704],[121.56964,29.47457],[121.64267,29.49522],[121.71095,29.53864],[121.73129,29.51801],[121.764,29.53076],[121.75722,29.49905],[121.77838,29.46942],[121.81199,29.49012],[121.84482,29.5362],[121.81397,29.55215],[121.74496,29.54466],[121.79612,29.60059],[121.91578,29.64085],[121.9712,29.59333],[121.97096,29.56892],[121.94117,29.55117],[121.9498,29.53848],[121.91041,29.52456],[121.97096,29.44912],[121.95729,29.40815],[121.93629,29.42772],[121.92256,29.41815],[121.91625,29.34894],[121.93141,29.32388],[121.90895,29.31879],[121.91578,29.30512],[121.90048,29.29865],[121.93995,29.26415],[121.97438,29.28461],[121.99147,29.25731],[121.95322,29.24966],[121.95729,29.21638],[121.94296,29.22321],[121.94296,29.19587],[121.84059,29.16112],[121.78535,29.16809],[121.79965,29.30512],[121.84059,29.31879],[121.83066,29.33389],[121.79965,29.31879],[121.78818,29.32856],[121.79965,29.37397],[121.78663,29.3745],[121.75864,29.35977],[121.77431,29.25389],[121.72433,29.18203],[121.65246,29.18302],[121.63917,29.21727],[121.65455,29.29999],[121.64039,29.30663],[121.62485,29.30146],[121.62892,29.18842],[121.57431,29.30512],[121.56202,29.29584],[121.57431,29.27778],[121.52589,29.27778],[121.53639,29.25829],[121.50489,29.20893],[121.50351,29.17894],[121.49098,29.17597],[121.48023,29.19721],[121.45639,29.16112],[121.43141,29.17219],[121.40854,29.16112],[121.45639,29.13377],[121.4017,29.10651],[121.62143,29.11327],[121.61476,29.05805],[121.53688,29.06118],[121.53207,29.02049],[121.5486,29.02143],[121.53956,29.03758],[121.55738,29.04902],[121.61744,29.03998],[121.64194,29.05126],[121.68979,29.01708],[121.65382,28.98859],[121.68417,28.95563],[121.71778,28.9488],[121.68417,28.9147],[121.60532,28.94257],[121.58741,28.96312],[121.57081,28.93008],[121.51686,28.95637],[121.49179,28.92837],[121.50489,28.93521],[121.51767,28.91279],[121.58058,28.88679],[121.61085,28.89118],[121.62892,28.87714],[121.62355,28.84227],[121.66993,28.82534],[121.63575,28.80484],[121.66261,28.79059],[121.61826,28.76195],[121.62143,28.73591],[121.50278,28.70039],[121.35117,28.71186],[121.29029,28.75161],[121.23789,28.76386],[121.21445,28.80732],[121.14235,28.84585],[121.15968,28.81354],[121.20606,28.79906],[121.23121,28.76016],[121.29933,28.72289],[121.29868,28.70124],[121.25839,28.67512],[121.365,28.69603],[121.38136,28.68195],[121.41383,28.6966],[121.51013,28.65706],[121.53843,28.57145],[121.58162,28.50469],[121.51157,28.48949],[121.60774,28.4772],[121.58378,28.44295],[121.63226,28.37945],[121.63404,28.34724],[121.65634,28.3393],[121.58554,28.25682],[121.58058,28.30085],[121.54149,28.32179],[121.47309,28.3306],[121.43605,28.2971],[121.40895,28.30492],[121.39552,28.28408],[121.41879,28.26972],[121.41603,28.25617],[121.36484,28.22582],[121.35402,28.18789],[121.37452,28.19526],[121.37403,28.17259],[121.34579,28.14517],[121.28785,28.18089],[121.2617,28.22579],[121.22517,28.23886],[121.24887,28.27021],[121.23683,28.3061],[121.20314,28.33186],[121.21754,28.35236],[121.24415,28.34911],[121.26466,28.36603],[121.21412,28.36058],[121.16896,28.38483],[121.16896,28.35977],[121.1443,28.34357],[121.13025,28.30555],[121.07211,28.29169],[121.06666,28.31143],[121.02499,28.30459],[121.05812,28.27993],[121.1218,28.28436],[121.12475,28.23552],[121.08584,28.1665],[121.00938,28.10313],[120.99351,28.02578],[120.96523,27.98715],[120.9214,27.98168],[120.87998,27.99873],[120.81959,27.98924],[120.73105,28.03583],[120.60605,28.04222],[120.59018,28.05439],[120.577,28.11563],[120.55958,28.11278],[120.58017,28.08979],[120.58896,28.02668],[120.6736,28.02172],[120.75058,27.97557],[120.8353,27.95596],[120.86376,27.91811],[120.86836,27.88256],[120.80749,27.83483],[120.72261,27.71591],[120.70426,27.71284],[120.63527,27.76333],[120.67245,27.71149],[120.67563,27.65965],[120.66648,27.62536],[120.59657,27.57028],[120.61846,27.51642],[120.68198,27.48159],[120.6465,27.44449],[120.63527,27.37971],[120.54591,27.40327],[120.53964,27.36522],[120.50489,27.34496],[120.54933,27.32103],[120.54396,27.28596],[120.51783,27.26301],[120.51783,27.20161],[120.46835,27.1822],[120.45973,27.22045],[120.42172,27.22207],[120.42921,27.17365],[120.40675,27.18423],[120.37623,27.23627],[120.34726,27.24942],[120.30307,27.24226],[120.2881,27.26447],[120.31935,27.29035],[120.29884,27.2978],[120.31186,27.31769],[120.28517,27.30402],[120.26401,27.32445],[120.2715,27.30402],[120.25856,27.29999],[120.22373,27.31147],[120.24366,27.29035],[120.22568,27.26862],[120.19581,27.2978],[120.19581,27.26301],[120.1753,27.23884],[120.18238,27.21686],[120.2566,27.2569],[120.30641,27.21528],[120.34726,27.24258],[120.36549,27.20962],[120.34726,27.18049],[120.36988,27.18749],[120.42172,27.14696],[120.37387,27.14696],[120.40797,27.13955],[120.36704,27.12523],[120.38136,27.11904],[120.37387,27.10602],[120.39503,27.10602],[120.37753,27.09858],[120.28272,27.11229],[120.24366,27.13272],[120.27833,27.06379],[120.25782,27.07266],[120.21632,27.0371],[120.24155,27.034],[120.25522,26.99706],[120.2374,26.98188],[120.14869,26.98188],[120.18702,26.96353],[120.23569,26.96638],[120.2374,26.92723],[120.21909,26.91543],[120.12574,26.92731],[120.11362,26.91877],[120.1207,26.89989],[120.09555,26.88727],[120.03199,26.89989],[120.05527,26.88939],[120.02516,26.86018],[120.02451,26.83788],[120.12818,26.79694],[120.09775,26.75849],[120.10019,26.74291],[120.14869,26.72118],[120.10206,26.68915],[120.1207,26.64606],[120.13437,26.64606],[120.08041,26.61742],[120.07968,26.64606],[120.0149,26.61127],[119.95997,26.61127],[119.93564,26.56045],[119.88429,26.52318],[119.84718,26.52363],[119.83261,26.55048],[119.85377,26.56411],[119.85377,26.59829],[119.98414,26.65351],[119.97682,26.67402],[120.01148,26.72118],[120.03232,26.71662],[120.07968,26.79694],[119.92514,26.79218],[119.93564,26.75902],[119.88803,26.68724],[119.90162,26.65974],[119.87965,26.65534],[119.85694,26.65351],[119.85377,26.68769],[119.82919,26.71499],[119.79176,26.68769],[119.80226,26.76679],[119.8401,26.79002],[119.79176,26.79002],[119.79737,26.81639],[119.84628,26.8522],[119.78199,26.82852],[119.7562,26.73774],[119.73023,26.74291],[119.71201,26.80793],[119.6858,26.812],[119.67799,26.76313],[119.72739,26.71792],[119.72495,26.69782],[119.64601,26.75332],[119.62656,26.74909],[119.62184,26.78677],[119.60605,26.79694],[119.57553,26.78803],[119.55209,26.74909],[119.55893,26.7082],[119.60605,26.69452],[119.59986,26.64606],[119.55893,26.66657],[119.54111,26.65632],[119.55421,26.61518],[119.59986,26.57038],[119.67506,26.6188],[119.7352,26.61197],[119.73642,26.59146],[119.78492,26.57783],[119.78492,26.55048],[119.7706,26.56411],[119.76588,26.53437],[119.7986,26.51577],[119.78492,26.50202],[119.81886,26.45319],[119.81227,26.43378],[119.79176,26.43378],[119.79176,26.41332],[119.76197,26.40746],[119.73764,26.44367],[119.68979,26.45946],[119.65366,26.49803],[119.62037,26.46796],[119.5796,26.47089],[119.56642,26.4549],[119.57309,26.43781],[119.63087,26.44872],[119.64845,26.43745],[119.65553,26.35562],[119.68873,26.33759],[119.68189,26.31713],[119.74708,26.32762],[119.75441,26.37206],[119.7706,26.36494],[119.79176,26.40648],[119.82578,26.42695],[119.85133,26.39281],[119.8401,26.37857],[119.89479,26.35871],[119.94996,26.36494],[119.87745,26.31338],[119.85475,26.32754],[119.80112,26.31757],[119.78492,26.29662],[119.80543,26.29043],[119.79273,26.28408],[119.76441,26.29662],[119.65431,26.27241],[119.63412,26.25507],[119.66822,26.24201],[119.66822,26.2215],[119.60011,26.17634],[119.59986,26.14582],[119.56113,26.12499],[119.45851,25.98249],[119.43556,25.98135],[119.41505,26.00926],[119.32691,26.04804],[119.27711,26.05292],[119.21583,26.10008],[119.09352,26.14582],[119.15732,26.09077],[119.19516,26.08759],[119.24684,25.96723],[119.32985,25.94428],[119.37753,25.94139],[119.44093,25.96381],[119.49,25.99421],[119.51905,26.0445],[119.56642,26.05024],[119.64845,26.01545],[119.67506,26.02228],[119.6954,26.00263],[119.7024,25.98505],[119.67506,25.92609],[119.61647,25.89265],[119.59986,25.84418],[119.58408,25.87694],[119.57423,25.88491],[119.56642,25.87206],[119.62086,25.76032],[119.59645,25.73151],[119.58994,25.68716],[119.5726,25.67967],[119.53541,25.70588],[119.47706,25.67967],[119.44907,25.68716],[119.46705,25.61392],[119.48325,25.62568],[119.49244,25.56477],[119.51735,25.55679],[119.53443,25.57587],[119.55844,25.56379],[119.57944,25.58466],[119.56837,25.54743],[119.59311,25.52949],[119.56642,25.51581],[119.59986,25.50837],[119.5726,25.48786],[119.60361,25.49116],[119.59986,25.47484],[119.62802,25.46137],[119.61402,25.43879],[119.63412,25.43325],[119.64601,25.39256],[119.62037,25.36498],[119.65455,25.35814],[119.57944,25.34447],[119.59986,25.37116],[119.57602,25.46735],[119.53858,25.46117],[119.53598,25.41914],[119.5582,25.40461],[119.54444,25.37718],[119.48325,25.37116],[119.50733,25.47443],[119.46616,25.47484],[119.44703,25.43879],[119.43312,25.45954],[119.44907,25.50528],[119.35654,25.52692],[119.38087,25.59089],[119.36036,25.5773],[119.34303,25.6092],[119.30519,25.60456],[119.32138,25.58735],[119.27784,25.57046],[119.2378,25.48176],[119.10255,25.42031],[119.17319,25.32514],[119.20338,25.35814],[119.22316,25.3166],[119.18149,25.28925],[119.20126,25.25971],[119.22584,25.27969],[119.24301,25.33023],[119.26515,25.31757],[119.27784,25.33023],[119.31886,25.27936],[119.3475,25.26777],[119.34425,25.24238],[119.28126,25.24144],[119.27247,25.23176],[119.28883,25.18911],[119.25563,25.1759],[119.22938,25.19321],[119.17563,25.1765],[119.14198,25.22098],[119.1161,25.21378],[119.10418,25.18295],[119.1338,25.13841],[119.15309,25.13972],[119.1338,25.12474],[119.09327,25.13565],[119.08546,25.12474],[119.09913,25.11115],[119.06479,25.12474],[119.07919,25.15209],[119.06471,25.16254],[119.04444,25.12474],[119.02906,25.14053],[119.06886,25.21674],[118.96933,25.22639],[118.97999,25.24486],[119.02495,25.25929],[119.0273,25.27824],[118.98365,25.28303],[118.87558,25.24384],[118.86484,25.22797],[118.91114,25.21723],[118.91529,25.17939],[118.94825,25.19717],[118.97462,25.18256],[118.94296,25.10883],[118.85768,25.09805],[118.84913,25.0738],[118.87794,25.04072],[118.9537,25.003],[118.97999,25.01179],[119.00416,25.05024],[118.98325,24.9951],[118.99733,24.95344],[119.01767,24.96027],[118.97706,24.93403],[118.96371,24.95441],[118.91163,24.95344],[118.90992,24.90717],[118.93417,24.88605],[118.97324,24.89379],[118.97617,24.87775],[118.952,24.87075],[118.79208,24.8911],[118.7776,24.8852],[118.78012,24.86782],[118.71608,24.84357],[118.69809,24.85952],[118.7295,24.90054],[118.69557,24.94042],[118.68881,24.89574],[118.66773,24.95344],[118.64039,24.91559],[118.67115,24.87466],[118.62412,24.86782],[118.57203,24.8852],[118.6128,24.85578],[118.63795,24.77212],[118.70175,24.81004],[118.71998,24.76936],[118.76295,24.76268],[118.74684,24.72069],[118.68539,24.66649],[118.65406,24.69343],[118.6421,24.6787],[118.63356,24.62787],[118.66415,24.61225],[118.66147,24.59032],[118.62045,24.54548],[118.55397,24.51805],[118.55445,24.55321],[118.57814,24.56021],[118.50123,24.60814],[118.42807,24.63133],[118.43124,24.69969],[118.40089,24.67231],[118.42189,24.6245],[118.4048,24.60664],[118.35165,24.58234],[118.28403,24.59781],[118.27296,24.57441],[118.24985,24.57612],[118.24179,24.54377],[118.219,24.54145],[118.18116,24.57612],[118.18165,24.59781],[118.21518,24.61481],[118.17433,24.6245],[118.18759,24.65737],[118.16049,24.69343],[118.15748,24.66267],[118.14145,24.65697],[118.11036,24.58405],[118.04444,24.61766],[118.03736,24.56061],[118.00294,24.56245],[118.00929,24.58072],[117.97486,24.55272],[118.05812,24.51585],[117.99952,24.47085],[118.02174,24.44575],[117.86769,24.48957],[117.82594,24.48045],[117.90113,24.45319],[117.79127,24.46748],[117.90732,24.38491],[118.04298,24.40668],[118.04078,24.36563],[118.01588,24.32685],[118.05299,24.34691],[118.09059,24.34146],[118.12647,24.26203],[118.07179,24.25458],[117.98927,24.1931],[117.97877,24.1621],[117.96787,24.15982],[117.94361,24.21259],[117.90732,24.18626],[117.93775,24.16169],[117.89576,24.08661],[117.89365,24.02802],[117.83912,24.02363],[117.77198,23.95893],[117.74806,23.90754],[117.72926,23.91938],[117.73789,23.95051],[117.79811,24.0218],[117.76173,24.01484],[117.77613,24.0373],[117.76328,24.07026],[117.75001,24.0373],[117.72413,24.03376],[117.66708,24.06273],[117.6797,24.03905],[117.70875,24.03547],[117.68849,23.9975],[117.69508,23.95356],[117.64863,23.90923],[117.62554,23.84015],[117.63927,23.7941],[117.59167,23.73706],[117.59816,23.85244],[117.57106,23.90327],[117.45485,23.92561],[117.4166,23.94684],[117.40968,23.9134],[117.46852,23.91193],[117.4996,23.87407],[117.49007,23.83258],[117.46348,23.78829],[117.43433,23.77592],[117.38242,23.79523],[117.3191,23.76162],[117.29257,23.7895],[117.27735,23.78221],[117.29233,23.76215],[117.26365,23.65543],[117.27912,23.63627],[117.25709,23.61562],[117.23896,23.60547],[117.24409,23.65755],[117.2295,23.69282],[117.20851,23.66494],[117.2192,23.62963],[117.15083,23.60687],[117.12846,23.57235],[117.08423,23.55061],[117.06841,23.65127],[117.02345,23.65868],[116.9507,23.61088],[116.91757,23.63793],[116.94093,23.65868],[116.91652,23.66275],[116.87208,23.60688],[116.88331,23.55964],[116.8497,23.5113],[116.87262,23.47381],[116.86663,23.45023],[116.77322,23.34943],[116.65784,23.35445],[116.56894,23.39102],[116.54322,23.42121],[116.5232,23.41852],[116.5232,23.37751],[116.57301,23.32368],[116.64189,23.31883],[116.69124,23.33898],[116.70941,23.31977],[116.74863,23.32629],[116.77711,23.24038],[116.80482,23.23612],[116.78395,23.21239],[116.70558,23.2766],[116.66725,23.28132],[116.7151,23.2438],[116.67117,23.21464],[116.64675,23.16299],[116.61522,23.17835],[116.61151,23.23873],[116.56998,23.24697],[116.50888,23.23412],[116.52699,23.20062],[116.56397,23.22297],[116.6045,23.20956],[116.54512,23.14477],[116.53365,23.10913],[116.55166,23.08672],[116.56325,23.01877],[116.52194,22.99841],[116.49469,22.93935],[116.45851,22.94742],[116.37961,22.92898],[116.28906,22.97894],[116.21363,22.98281],[116.20102,22.97346],[116.28146,22.95786],[116.14491,22.84782],[116.09569,22.8259],[116.09125,22.86841],[116.12372,22.89473],[116.04211,22.93292],[116.08725,22.88969],[116.07618,22.86787],[115.95801,22.80474],[115.8835,22.79462],[115.81003,22.74609],[115.78804,22.79784],[115.81357,22.77627],[115.80478,22.81509],[115.73687,22.85167],[115.69704,22.85067],[115.70358,22.86932],[115.68427,22.87778],[115.69264,22.90009],[115.72128,22.90453],[115.73032,22.93423],[115.69199,22.92902],[115.65333,22.89374],[115.57789,22.87727],[115.56056,22.84931],[115.53297,22.86205],[115.54762,22.89541],[115.50563,22.89777],[115.45094,22.93187],[115.43019,22.9182],[115.40602,22.92504],[115.42726,22.90062],[115.4642,22.91201],[115.483,22.90184],[115.49415,22.86078],[115.46803,22.84931],[115.55942,22.83609],[115.61207,22.86359],[115.52702,22.76704],[115.52394,22.74177],[115.55559,22.71437],[115.56732,22.74728],[115.5901,22.73721],[115.56251,22.70563],[115.57522,22.66834],[115.53616,22.66812],[115.46482,22.70103],[115.37351,22.69418],[115.33847,22.7331],[115.42413,22.76199],[115.41121,22.78827],[115.36399,22.76365],[115.33057,22.78854],[115.23339,22.78821],[115.23277,22.80534],[115.32016,22.85053],[115.31715,22.90453],[115.3038,22.90302],[115.24562,22.83535],[115.17294,22.82119],[115.18027,22.78481],[115.13288,22.81145],[115.06548,22.79599],[115.01783,22.73704],[115.0289,22.70838],[114.98534,22.68931],[114.95467,22.70354],[114.94166,22.75385],[114.92677,22.76118],[114.91676,22.74404],[114.86252,22.76322],[114.88187,22.74752],[114.88074,22.72147],[114.92673,22.71693],[114.96663,22.67649],[114.90194,22.59806],[114.91912,22.56115],[114.89597,22.54752],[114.88022,22.55911],[114.88682,22.59087],[114.86582,22.60175],[114.75414,22.58971],[114.74378,22.6094],[114.73745,22.66905],[114.75378,22.69338],[114.74195,22.73265],[114.82125,22.79056],[114.77524,22.81582],[114.75489,22.78168],[114.69548,22.79264],[114.63779,22.75507],[114.56332,22.73273],[114.5711,22.70235],[114.52206,22.70596],[114.50661,22.64652],[114.58253,22.65526],[114.59034,22.64387],[114.49537,22.56196],[114.50929,22.54955],[114.54322,22.5694],[114.61769,22.51415],[114.51582,22.45264],[114.48512,22.46833],[114.47958,22.54621],[114.39576,22.61351],[114.31715,22.60236],[114.30714,22.58544],[114.27621,22.58796],[114.22983,22.55581],[114.16195,22.56054],[114.14872,22.53811],[114.09146,22.53439],[114.05527,22.50694],[113.9638,22.53811],[113.94776,22.49041],[113.91033,22.48583],[113.89034,22.45271],[113.86616,22.51149],[113.89389,22.52115],[113.82236,22.58723],[113.79357,22.66161],[113.77293,22.67245],[113.75807,22.7534],[113.71217,22.76333],[113.67237,22.74689],[113.65358,22.75873],[113.66261,22.79808],[113.6172,22.89029],[113.6172,22.83625],[113.60556,22.84724],[113.61036,22.90453],[113.5923,22.88906],[113.63819,23.0349],[113.61671,23.00788],[113.59669,22.9182],[113.6172,23.0349],[113.57618,22.93871],[113.58302,23.02123],[113.55397,22.98762],[113.52882,23.01081],[113.53639,23.06314],[113.58212,23.09846],[113.72022,23.13793],[113.83009,23.1175],[113.70314,23.15778],[113.50912,23.06216],[113.41749,23.09699],[113.39869,23.08332],[113.48121,23.0515],[113.50231,23.02042],[113.48398,22.92426],[113.42994,22.93134],[113.30763,22.89411],[113.21298,22.90453],[113.29225,22.87727],[113.35043,22.88972],[113.44003,22.80414],[113.49423,22.77485],[113.43954,22.73945],[113.37753,22.7674],[113.46632,22.7165],[113.59917,22.57518],[113.59474,22.52339],[113.54636,22.43758],[113.55812,22.38076],[113.61036,22.37018],[113.57358,22.30073],[113.58765,22.23788],[113.52296,22.21304],[113.52988,22.20226],[113.48374,22.15514],[113.42563,22.18623],[113.41188,22.22553],[113.36451,22.26777],[113.35703,22.30191],[113.27638,22.37206],[113.23194,22.47907],[113.16529,22.57559],[113.16017,22.48762],[113.22731,22.42528],[113.40197,22.17957],[113.30275,22.1833],[113.25099,22.11567],[113.24716,22.05427],[113.15747,22.0247],[113.11886,22.06584],[113.11061,22.09919],[113.12745,22.16096],[113.11061,22.21931],[113.08505,22.20759],[113.07729,22.14727],[113.05789,22.12109],[113.02661,22.14321],[113.02064,22.12598],[113.02562,21.94943],[112.98935,21.93732],[112.95708,21.87565],[112.91159,21.85629],[112.87941,21.87519],[112.85157,21.94831],[112.82602,21.96613],[112.74374,21.93195],[112.73048,21.89033],[112.70045,21.89033],[112.64511,21.84935],[112.64755,21.82266],[112.608,21.77509],[112.55087,21.77285],[112.42604,21.84516],[112.41114,21.90119],[112.49366,21.96613],[112.43629,21.94562],[112.39064,21.95551],[112.37631,21.98656],[112.39747,22.06851],[112.36484,22.03449],[112.35654,21.97972],[112.39064,21.90119],[112.40211,21.80142],[112.38795,21.75609],[112.36329,21.73334],[112.28623,21.70881],[112.24488,21.70653],[112.18238,21.753],[112.20314,21.80671],[112.19264,21.84317],[112.1727,21.81444],[112.02817,21.79735],[112.00709,21.81867],[112.04127,21.88418],[111.96925,21.82404],[111.93409,21.89045],[111.89039,21.92512],[111.90406,21.81525],[111.94638,21.78588],[112.00025,21.77432],[111.86793,21.6767],[111.83888,21.67813],[111.85377,21.70742],[111.87672,21.69855],[111.86061,21.7215],[111.83619,21.71699],[111.83505,21.76683],[111.80689,21.74331],[111.78793,21.76],[111.7579,21.75332],[111.67807,21.78115],[111.76059,21.72582],[111.77963,21.67544],[111.77369,21.65013],[111.74692,21.64395],[111.78793,21.61603],[111.7129,21.62344],[111.63046,21.52985],[111.58188,21.58186],[111.59555,21.53408],[111.53785,21.52045],[111.52101,21.53408],[111.47608,21.51362],[111.47267,21.55459],[111.4441,21.56362],[111.43222,21.60297],[111.41114,21.56826],[111.438,21.53408],[111.3628,21.54121],[111.3497,21.52668],[111.40431,21.52668],[111.26735,21.43936],[111.25294,21.44473],[111.30128,21.49933],[111.19239,21.5312],[111.15797,21.51362],[111.17164,21.49933],[111.22633,21.49933],[111.14365,21.46581],[111.08839,21.47264],[111.12998,21.49933],[111.08839,21.49311],[111.0276,21.54092],[110.98593,21.51362],[111.01393,21.48566],[110.97975,21.44473],[111.00815,21.4499],[111.04225,21.48456],[111.06861,21.47264],[111.04371,21.46939],[110.99293,21.42487],[110.96591,21.43285],[110.86988,21.38325],[110.76816,21.39753],[110.70485,21.34854],[110.65089,21.2604],[110.6548,21.3146],[110.67758,21.34854],[110.64332,21.32184],[110.63648,21.37027],[110.75538,21.4134],[110.76002,21.44473],[110.63559,21.38105],[110.61573,21.30121],[110.64332,21.25292],[110.63648,21.23017],[110.57521,21.24673],[110.58871,21.22557],[110.48146,21.21719],[110.43116,21.19147],[110.42579,21.26923],[110.43735,21.27338],[110.42433,21.28767],[110.47885,21.27338],[110.48634,21.30134],[110.44483,21.30756],[110.48634,21.33552],[110.46534,21.32868],[110.46534,21.34854],[110.43393,21.30878],[110.42392,21.34162],[110.43735,21.39753],[110.41953,21.37743],[110.40097,21.38703],[110.36899,21.42487],[110.3895,21.45213],[110.35849,21.43574],[110.39438,21.364],[110.3895,21.32868],[110.32814,21.33552],[110.3567,21.31916],[110.3628,21.28767],[110.40821,21.23802],[110.39942,21.17658],[110.33375,21.11636],[110.2693,21.08918],[110.21884,21.02757],[110.2046,21.08222],[110.19557,21.07127],[110.15675,20.98603],[110.14308,20.99226],[110.1548,20.97728],[110.14926,20.92398],[110.17726,20.86249],[110.15675,20.87678],[110.15675,20.84886],[110.1915,20.84886],[110.1915,20.83584],[110.28712,20.84203],[110.31967,20.85993],[110.3611,20.83588],[110.36899,20.84886],[110.40943,20.75096],[110.38356,20.71345],[110.35646,20.73233],[110.34181,20.82152],[110.3213,20.79426],[110.33212,20.76789],[110.30763,20.75324],[110.30763,20.73896],[110.33749,20.72582],[110.30079,20.65705],[110.32814,20.67756],[110.31446,20.63654],[110.3628,20.64338],[110.3628,20.6223],[110.38063,20.63377],[110.40854,20.58592],[110.47885,20.56769],[110.53419,20.47826],[110.50229,20.41275],[110.47535,20.39765],[110.40683,20.30516],[110.36207,20.30488],[110.34433,20.27924],[110.28028,20.253],[110.23715,20.27497],[110.12753,20.24799],[110.03785,20.3017],[110.00595,20.28034],[109.96583,20.28095],[109.91725,20.23933],[109.92872,20.32636],[109.91725,20.35228],[109.87412,20.37629],[109.8899,20.38272],[109.8628,20.40632],[109.90358,20.4311],[109.98243,20.35155],[110.01547,20.4184],[110.00978,20.43423],[109.96494,20.47826],[109.9581,20.45161],[109.94361,20.46068],[109.93092,20.50625],[109.91725,20.44477],[109.89609,20.49258],[109.82211,20.51655],[109.84824,20.59565],[109.82106,20.55463],[109.80201,20.61628],[109.74586,20.62979],[109.75156,20.67373],[109.80592,20.67951],[109.82773,20.69798],[109.8414,20.68439],[109.86044,20.71548],[109.78688,20.69115],[109.75644,20.7497],[109.7732,20.76691],[109.75815,20.79279],[109.7732,20.81411],[109.75204,20.84203],[109.75953,20.85505],[109.74537,20.86766],[109.73154,20.85505],[109.73902,20.80785],[109.70436,20.84203],[109.71787,20.80785],[109.66562,20.88093],[109.66123,20.92756],[109.69125,20.95873],[109.67897,20.97386],[109.69752,21.04059],[109.71168,21.03384],[109.69996,21.08299],[109.67018,21.12324],[109.70436,21.17845],[109.75204,21.16413],[109.74667,21.17967],[109.7732,21.2154],[109.76572,21.35594],[109.78688,21.32868],[109.80738,21.35594],[109.92408,21.36278],[109.9048,21.39069],[109.95126,21.4584],[109.92457,21.46503],[109.94443,21.49311],[109.85898,21.48281],[109.8414,21.50678],[109.81422,21.4584],[109.80055,21.55459],[109.77011,21.55158],[109.7732,21.58186],[109.74806,21.57494],[109.74781,21.54523],[109.7732,21.48566],[109.75953,21.49311],[109.74147,21.47403],[109.64967,21.52045],[109.67701,21.5751],[109.67701,21.62344],[109.63722,21.60163],[109.62525,21.58539],[109.636,21.56826],[109.608,21.5751],[109.61549,21.65013],[109.5853,21.63085],[109.56088,21.67129],[109.5608,21.70726],[109.59449,21.7464],[109.56772,21.76],[109.49269,21.69855],[109.5062,21.68496],[109.49269,21.67813],[109.51987,21.68496],[109.52101,21.66576],[109.46469,21.66446],[109.52988,21.63569],[109.57277,21.58173],[109.56088,21.52668],[109.53077,21.49164],[109.47218,21.46581],[109.47218,21.49933],[109.42441,21.45238],[109.34376,21.45352],[109.32081,21.48013],[109.29892,21.44326],[109.24496,21.43163],[109.23878,21.48566],[109.21461,21.41743],[109.17197,21.41755],[109.14942,21.43854],[109.15626,21.40375],[109.13868,21.4016],[109.03981,21.45026],[109.06007,21.48013],[109.13282,21.49592],[109.14259,21.5102],[109.13575,21.60297],[109.12436,21.59097],[109.10108,21.6092],[109.07765,21.59699],[109.06674,21.61603],[109.0464,21.60297],[109.06674,21.64395],[108.99244,21.6092],[108.92799,21.61506],[108.89747,21.67129],[108.85523,21.68496],[108.86948,21.70539],[108.86549,21.73973],[108.92408,21.76683],[108.89747,21.76],[108.90309,21.77432],[108.86948,21.75381],[108.87574,21.80097],[108.85523,21.78115],[108.84986,21.79169],[108.86142,21.81525],[108.80136,21.81835],[108.77955,21.81525],[108.82106,21.80097],[108.81422,21.78115],[108.83261,21.78384],[108.84352,21.74502],[108.81666,21.71638],[108.80592,21.75845],[108.79306,21.75381],[108.7999,21.73334],[108.78688,21.74018],[108.77955,21.72582],[108.7999,21.72582],[108.79306,21.71284],[108.83432,21.68574],[108.82106,21.64395],[108.86142,21.64395],[108.84995,21.62596],[108.78736,21.64143],[108.73854,21.61603],[108.74537,21.60297],[108.72755,21.64476],[108.71119,21.65013],[108.73316,21.66242],[108.75156,21.65013],[108.75156,21.69172],[108.73854,21.67129],[108.71266,21.68842],[108.72486,21.7464],[108.6832,21.70539],[108.64918,21.73334],[108.6832,21.74018],[108.64633,21.75137],[108.67701,21.81525],[108.65602,21.79483],[108.64235,21.82209],[108.63551,21.77432],[108.615,21.78791],[108.62867,21.7464],[108.59449,21.74018],[108.60751,21.78791],[108.58058,21.78132],[108.57138,21.81281],[108.6006,21.83063],[108.63551,21.89033],[108.59449,21.91767],[108.58766,21.89785],[108.56031,21.89549],[108.55299,21.91767],[108.57333,21.95181],[108.56715,21.93879],[108.55299,21.95181],[108.52564,21.90461],[108.50514,21.91083],[108.51881,21.89033],[108.48487,21.8771],[108.49147,21.85627],[108.46314,21.86298],[108.47779,21.94501],[108.45802,21.91767],[108.44874,21.86579],[108.48463,21.82209],[108.47096,21.82209],[108.47779,21.80781],[108.49708,21.80903],[108.50514,21.78791],[108.49147,21.74018],[108.46347,21.74018],[108.50514,21.71906],[108.47779,21.69855],[108.50514,21.69172],[108.50514,21.67129],[108.53305,21.71906],[108.51881,21.67129],[108.55299,21.69855],[108.56544,21.67853],[108.54518,21.66608],[108.54689,21.64305],[108.51197,21.64395],[108.53305,21.63032],[108.51197,21.6092],[108.51881,21.59553],[108.48121,21.58186],[108.48911,21.60761],[108.50514,21.60297],[108.49147,21.62344],[108.46347,21.59553],[108.4712,21.5812],[108.45802,21.56826],[108.47096,21.562],[108.42001,21.55687],[108.38966,21.5751],[108.39064,21.59536],[108.40675,21.60025],[108.43189,21.58324],[108.4437,21.62344],[108.42758,21.61669],[108.41635,21.63032],[108.47096,21.67129],[108.40968,21.66446],[108.42384,21.69855],[108.386,21.67292],[108.33766,21.69746],[108.34132,21.72582],[108.31137,21.68171],[108.32765,21.63032],[108.30649,21.63032],[108.31397,21.61603],[108.29347,21.5893],[108.31397,21.58186],[108.28378,21.54474],[108.21103,21.50064],[108.25278,21.58853],[108.22682,21.62556],[108.23829,21.64395],[108.2103,21.63032],[108.20606,21.64256],[108.2103,21.60297],[108.17636,21.57575],[108.13527,21.58186],[108.12827,21.56273],[108.11476,21.5751],[108.10581,21.55907],[108.06625,21.562],[108.07106,21.53657],[108.0171,21.55524],[107.99122,21.54841],[108.03956,21.52045],[108.01124,21.50654],[108.02223,21.48566],[108.06105,21.49616],[108.08058,21.48566],[108.01661,21.47036],[107.97071,21.43163],[107.96534,21.44733],[107.99122,21.48566],[107.81164,21.64783],[107.74482,21.65754],[107.67186,21.61765],[107.53305,21.60866],[107.50132,21.58153],[107.46102,21.59636],[107.45704,21.6315],[107.42603,21.65207],[107.34816,21.59935],[107.27266,21.71831],[107.21214,21.70524],[107.17235,21.71506],[107.06585,21.79583],[107.0029,21.81056],[106.98714,21.84482],[107.02115,21.89226],[107.00936,21.92745],[106.97123,21.93582],[106.92038,21.91696],[106.86477,21.96807],[106.78152,21.97504],[106.72876,22.00832],[106.6746,21.96217],[106.65311,21.96889],[106.67218,22.0925],[106.63967,22.1909],[106.66293,22.2233],[106.63378,22.30944],[106.5418,22.3451],[106.52691,22.43827],[106.57714,22.59531],[106.60763,22.60999],[106.64634,22.5688],[106.68019,22.57945],[106.74664,22.74435],[106.7915,22.77212],[106.78984,22.79719],[106.73036,22.79763],[106.66747,22.86752],[106.61037,22.85287],[106.58169,22.89527],[106.48779,22.92542],[106.45798,22.88897],[106.31566,22.8538],[106.2554,22.85465],[106.22709,22.87556],[106.20239,22.947],[106.1321,22.97501],[105.99925,22.97537],[105.96121,22.9324],[105.876,22.92827],[105.85388,22.90465],[105.75471,23.00568],[105.69032,23.04397],[105.55157,23.05968],[105.52827,23.15551],[105.47385,23.1945],[105.42589,23.27321],[105.38952,23.27543],[105.34404,23.30419],[105.31216,23.36581],[105.24554,23.33018],[105.2009,23.25401],[105.15563,23.26685],[105.06106,23.23246],[104.93507,23.15427],[104.89477,23.17034],[104.87006,23.16355],[104.86128,23.1196],[104.79792,23.08557],[104.82361,22.92413],[104.7742,22.89623],[104.70077,22.81825],[104.60982,22.823],[104.58843,22.80685],[104.55354,22.83605],[104.449,22.74518],[104.34017,22.68637],[104.23682,22.74208],[104.22876,22.78344],[104.24116,22.8244],[104.08686,22.79156],[104.02381,22.71918],[103.98536,22.52715],[103.95952,22.5071],[103.83633,22.60276],[103.64693,22.79905],[103.59004,22.76802],[103.54715,22.70068],[103.55092,22.64079],[103.50364,22.58141],[103.44751,22.61826],[103.40431,22.6888],[103.40173,22.73784],[103.35708,22.77603],[103.30964,22.78794],[103.24489,22.66869],[103.14175,22.60704],[103.13002,22.57795],[103.142,22.5378],[103.04418,22.48648],[103.04506,22.44054],[103.02904,22.43016],[102.90259,22.47723],[102.83184,22.59971],[102.7519,22.62529],[102.69273,22.6705],[102.61248,22.69159],[102.58669,22.71608],[102.53579,22.69582],[102.51579,22.74518],[102.46778,22.76859],[102.42561,22.7453],[102.39497,22.68053],[102.35585,22.66993],[102.38432,22.62885],[102.25255,22.49548],[102.21803,22.41068],[102.16454,22.42551],[102.10734,22.39755],[102.08553,22.42912],[101.99499,22.44742],[101.89164,22.42969],[101.86792,22.37884],[101.8178,22.40633],[101.78488,22.47222],[101.75506,22.49553],[101.68912,22.47889],[101.65455,22.44612],[101.6437,22.36468],[101.6237,22.34665],[101.60122,22.27699],[101.55099,22.27668],[101.51575,22.24536],[101.56153,22.17611],[101.55543,22.09033],[101.6068,21.9676],[101.67367,21.93138],[101.75108,21.81614],[101.72855,21.71754],[101.7548,21.68979],[101.75594,21.64865],[101.80384,21.62592],[101.79356,21.58824],[101.7362,21.57088],[101.74824,21.5145],[101.72969,21.47425],[101.71511,21.32113],[101.73651,21.29229],[101.80115,21.26707],[101.82374,21.23447],[101.80524,21.2045],[101.76007,21.19597],[101.7702,21.17044],[101.75599,21.14326],[101.70426,21.13509],[101.65512,21.1892],[101.58179,21.17566],[101.58396,21.22439],[101.51761,21.24263],[101.38149,21.22248],[101.29271,21.17607],[101.24434,21.19287],[101.209,21.24553],[101.22036,21.29627],[101.20745,21.31482],[101.23313,21.37172],[101.17143,21.4034],[101.18776,21.50587],[101.18657,21.53533],[101.15422,21.56804],[101.17386,21.61191],[101.16884,21.63785],[101.12766,21.69516],[101.11887,21.75981],[101.0828,21.76674],[100.99278,21.71144],[100.83491,21.65754],[100.70396,21.51641],[100.62174,21.46908],[100.45669,21.45502],[100.39912,21.51889],[100.34197,21.53037],[100.19789,21.43322],[100.1625,21.43637],[100.09728,21.5083],[100.08607,21.55093],[100.10395,21.61563],[100.14374,21.64002],[100.14281,21.65491],[100.08235,21.68447],[99.99672,21.68612],[99.95026,21.72116],[99.91796,21.82916],[99.96566,21.963],[99.93434,22.01886],[99.94241,22.04553],[99.84784,22.05504],[99.84174,22.02708],[99.82102,22.01778],[99.72366,22.07059],[99.68387,22.03902],[99.63354,22.08475],[99.53897,22.10671],[99.49039,22.10434],[99.43861,22.12423],[99.36492,22.09886],[99.25289,22.10279],[99.16638,22.13209],[99.14447,22.15353],[99.17506,22.16867],[99.15935,22.18469],[99.24369,22.34122],[99.24307,22.39316],[99.28276,22.41192],[99.35779,22.49548],[99.34415,22.53325],[99.355,22.60854],[99.30797,22.74518],[99.35903,22.77489],[99.41443,22.8569],[99.41339,22.93075],[99.5153,22.90881],[99.53804,22.92643],[99.49039,23.01201],[99.49308,23.05738],[99.40327,23.06834],[99.33381,23.12962],[99.21661,23.05738],[99.19418,23.09577],[99.05507,23.1096],[99.02644,23.16045],[98.85901,23.17939],[98.86015,23.21228],[98.91069,23.2976],[98.89653,23.32328],[98.85343,23.32488],[98.89012,23.3679],[98.8896,23.40679],[98.86397,23.46661],[98.84888,23.4804],[98.79999,23.48074],[98.78087,23.53285],[98.85601,23.61001],[98.8003,23.69271],[98.79317,23.75847],[98.66367,23.79695],[98.67452,23.93689],[98.65882,23.96112],[98.85622,24.0838],[98.87348,24.1145],[98.86573,24.14569],[98.71618,24.12135],[98.58523,24.07571],[98.50327,24.12127],[98.43495,24.13026],[98.33067,24.09949],[98.18153,24.11866],[98.10505,24.10153],[97.88997,24.02262],[97.81783,23.95815],[97.76781,23.94647],[97.62467,23.84671],[97.60989,23.88314],[97.51646,23.94283],[97.72006,24.14744],[97.70156,24.21093],[97.72409,24.24152],[97.71841,24.27767],[97.64451,24.30239],[97.6382,24.3281],[97.68957,24.34647],[97.6473,24.43233],[97.51325,24.43859],[97.53857,24.59726],[97.53279,24.72332],[97.53609,24.74503],[97.66394,24.82978],[97.76523,24.83466],[97.77298,24.85469],[97.70828,24.92729],[97.6996,25.06501],[97.75406,25.11356],[97.80099,25.23763],[97.82362,25.26161],[97.86031,25.24409],[97.88894,25.20565],[97.90992,25.20756],[97.97627,25.27226],[98.0449,25.31194],[98.0757,25.37608],[98.11208,25.39623],[98.09978,25.50005],[98.1342,25.53462],[98.14009,25.61146],[98.23238,25.58655],[98.27662,25.55245],[98.31207,25.55746],[98.3471,25.57782],[98.41873,25.72422],[98.4981,25.83072],[98.52683,25.83879],[98.61003,25.8006],[98.69023,25.86555],[98.68982,25.8916],[98.62533,25.96999],[98.58244,25.98229],[98.54554,26.10564],[98.56301,26.13122],[98.61933,26.14574],[98.6243,26.10849],[98.64445,26.09789],[98.68083,26.12497],[98.70553,26.17494],[98.677,26.23933],[98.64217,26.25452],[98.65778,26.31162],[98.71349,26.38945],[98.72114,26.5389],[98.75555,26.62504],[98.73199,26.71351],[98.75038,26.79201],[98.71452,26.86203],[98.73633,26.89826],[98.71266,26.99515],[98.73778,27.04869],[98.73137,27.06879],[98.69137,27.0924],[98.67256,27.1756],[98.7077,27.33725],[98.67442,27.3866],[98.67928,27.57734],[98.64724,27.61173],[98.57841,27.59173],[98.52218,27.6563],[98.49231,27.64343],[98.41025,27.68425],[98.38328,27.55579],[98.36395,27.53217],[98.3286,27.52287],[98.2947,27.53662],[98.2544,27.64813],[98.20437,27.7273],[98.19662,27.82319],[98.14009,27.87856],[98.17667,27.89812],[98.17729,27.91292],[98.10712,27.97307],[98.12252,28.01545],[98.11869,28.14079],[98.04862,28.19962],[97.99105,28.21401],[97.99291,28.26892],[97.94475,28.29884],[97.89752,28.35533],[97.85907,28.37013],[97.79726,28.34393],[97.76533,28.35243],[97.6996,28.48803],[97.67045,28.51128],[97.61361,28.48198],[97.54622,28.53847],[97.52772,28.52953],[97.44535,28.38297],[97.44783,28.2976],[97.38437,28.28484],[97.36959,28.25347],[97.3235,28.21748],[97.22965,28.27461],[97.21053,28.30809],[97.07845,28.37514],[96.96579,28.33047],[96.71692,28.43654],[96.56152,28.44729],[96.36964,28.37812],[96.30185,28.42071],[96.33646,28.47967],[96.50238,28.64448],[96.50104,28.68763],[96.59801,28.70991],[96.59258,28.75788],[96.57667,28.80853],[96.51083,28.88553],[96.49223,28.948],[96.45213,28.97038],[96.45761,28.99459],[96.36657,29.03654],[96.24859,28.94534],[96.19532,28.94111],[96.17463,29.10884],[96.21008,29.14577],[96.31642,29.17186],[96.36624,29.25723],[96.3371,29.27971],[96.26785,29.24173],[96.23529,29.24121],[96.17628,29.28646],[96.14197,29.36847],[95.95373,29.35922],[95.86313,29.32399],[95.79594,29.35289],[95.74478,29.34043],[95.74737,29.27365],[95.71716,29.21828],[95.56443,29.24561],[95.55017,29.21248],[95.51534,29.20943],[95.52143,29.13786],[95.48758,29.06899],[95.36741,29.0365],[95.22492,29.05936],[95.1997,29.09425],[94.98906,29.15398],[94.77657,29.1667],[94.76148,29.17471],[94.76768,29.21385],[94.70422,29.2847],[94.63043,29.31945],[94.59994,29.31664],[94.51478,29.22106],[94.39644,29.20708],[94.33577,29.14962],[94.2875,29.14794],[94.27079,29.09793],[94.34731,29.02435],[94.25018,28.93311],[94.17366,28.93016],[94.1354,28.89779],[93.99202,28.84465],[93.98159,28.76813],[93.92779,28.68275],[93.86308,28.70487],[93.78174,28.6805],[93.72248,28.69665],[93.62553,28.67236],[93.44621,28.67189],[93.31717,28.60346],[93.28697,28.52052],[93.18629,28.432],[93.21807,28.39465],[93.20782,28.34054],[92.97492,28.28231],[92.90382,28.2497],[92.83478,28.18353],[92.79023,28.1789],[92.77917,28.1959],[92.67861,28.13306],[92.63892,28.05749],[92.68926,28.04831],[92.73639,27.98591],[92.68367,27.91045],[92.63647,27.89537],[92.57498,27.84781],[92.47511,27.8466],[92.42757,27.82117],[92.39284,27.85603],[92.36793,27.80383],[92.35005,27.80254],[92.32918,27.83293],[92.30385,27.78616],[92.23957,27.86528],[92.12629,27.81272],[92.06488,27.76172],[91.9889,27.7694],[91.95225,27.72482],[91.86491,27.72999],[91.82659,27.80763],[91.72987,27.80414],[91.68013,27.84982],[91.62834,27.85269],[91.65283,27.91675],[91.63795,27.93974],[91.41884,27.99473],[91.34143,28.03056],[91.29017,28.09141],[91.26971,28.07286],[91.19467,28.07059],[91.09256,27.97168],[91.05194,27.96279],[90.97515,27.98209],[90.90952,28.03266],[90.7269,28.0617],[90.66747,28.09033],[90.57528,28.06583],[90.47523,28.07234],[90.48567,28.12466],[90.5976,28.20038],[90.58773,28.2333],[90.50542,28.24976],[90.45604,28.28268],[90.3869,28.29914],[90.35398,28.29914],[90.3295,28.2558],[90.29147,28.26135],[90.2618,28.29914],[90.2618,28.33535],[90.22559,28.3584],[90.15316,28.32219],[90.07086,28.34523],[89.95252,28.30825],[89.91613,28.3156],[89.86292,28.29579],[89.78086,28.22877],[89.75584,28.18438],[89.56149,28.13464],[89.44455,28.03118],[89.33603,27.86907],[89.22492,27.80781],[89.1982,27.73058],[89.12466,27.61485],[89.09153,27.62587],[89.06403,27.61595],[88.97837,27.53028],[88.98297,27.49263],[88.95106,27.43314],[88.99702,27.33087],[88.97193,27.31238],[88.91516,27.33087],[88.89233,27.31554],[88.79539,27.38593],[88.75978,27.43507],[88.75756,27.51145],[88.74102,27.54571],[88.80567,27.65511],[88.85388,27.84368],[88.85156,27.87716],[88.81068,27.92786],[88.81771,27.99488],[88.73565,28.05526],[88.65203,28.0694],[88.61049,28.10583],[88.55282,28.07805],[88.50207,28.02888],[88.45577,28.03152],[88.37877,27.98263],[88.11574,27.94726],[88.0975,27.90401],[88.11822,27.86088],[88.02995,27.89334],[87.97843,27.88052],[87.83669,27.90832],[87.77984,27.83944],[87.72682,27.80776],[87.63608,27.82365],[87.57334,27.80502],[87.53159,27.8368],[87.36912,27.80394],[87.38534,27.84941],[87.36384,27.85525],[87.29052,27.81608],[87.1558,27.8258],[87.11652,27.84458],[87.00568,27.9515],[86.91473,27.94512],[86.84047,28.01478],[86.7398,28.0215],[86.73174,28.07692],[86.68006,28.10573],[86.61231,28.08028],[86.55356,28.10415],[86.5162,28.01225],[86.51046,27.95243],[86.4253,27.90993],[86.36298,27.9461],[86.32773,27.93969],[86.27807,27.98369],[86.20304,28.00258],[86.17224,28.07183],[86.18604,28.13353],[86.15596,28.15652],[86.06873,28.07676],[86.0847,28.04387],[86.07917,28.00113],[86.09813,27.98563],[86.11286,27.9268],[86.0617,27.88905],[85.98026,27.88517],[85.95122,27.91623],[85.95659,27.97623],[85.8983,28.03602],[85.86337,28.14335],[85.73449,28.22153],[85.69976,28.2691],[85.69294,28.33522],[85.61181,28.25115],[85.5653,28.27538],[85.47569,28.2791],[85.43156,28.29706],[85.36257,28.25967],[85.3126,28.28277],[85.24341,28.26791],[85.17359,28.30432],[85.13416,28.29197],[85.0874,28.30411],[85.07758,28.43568],[85.10946,28.4593],[85.12315,28.50459],[85.15375,28.5249],[85.16098,28.595],[85.05494,28.63869],[84.95495,28.5743],[84.92849,28.53847],[84.81196,28.54198],[84.75563,28.58541],[84.67776,28.6044],[84.68297,28.65076],[84.63264,28.66879],[84.61621,28.69365],[84.45085,28.73388],[84.42273,28.79917],[84.37659,28.84884],[84.23515,28.90002],[84.20673,28.94066],[84.23143,29.02598],[84.17546,29.0574],[84.16621,29.09846],[84.13226,29.1438],[84.16166,29.19253],[84.15546,29.20664],[84.11236,29.2091],[84.08973,29.25661],[84.03521,29.24762],[84.01252,29.2702],[83.97584,29.26375],[83.95795,29.28904],[83.91439,29.2894],[83.79321,29.23475],[83.69549,29.22618],[83.63927,29.15476],[83.52372,29.18359],[83.503,29.26103],[83.39313,29.35625],[83.38724,29.3935],[83.35701,29.41536],[83.3552,29.46073],[83.32797,29.48502],[83.26394,29.47301],[83.2472,29.49474],[83.26084,29.54205],[83.17356,29.62127],[83.09336,29.61514],[83.07956,29.57964],[83.06494,29.57879],[82.9759,29.66403],[82.93192,29.66418],[82.89973,29.68842],[82.83658,29.65979],[82.69095,29.78074],[82.68899,29.82774],[82.6185,29.83983],[82.54181,29.92324],[82.45707,29.95409],[82.39443,30.00313],[82.3473,30.00344],[82.31888,30.03181],[82.25573,30.03967],[82.21067,30.06442],[82.15383,30.05817],[82.12758,30.12436],[82.15569,30.18136],[82.08499,30.23082],[82.08877,30.33009],[81.96903,30.32828],[81.94123,30.35396],[81.84439,30.37117],[81.77902,30.35805],[81.61401,30.4169],[81.53681,30.37856],[81.50937,30.32838],[81.46281,30.34135],[81.42612,30.37251],[81.38752,30.37391],[81.36804,30.30105],[81.37785,30.23263],[81.35005,30.15723],[81.32241,30.13377],[81.26696,30.12509],[81.27202,30.07992],[81.22572,30.05047],[81.21683,30.0082],[81.09777,30.01693],[80.97602,30.25521],[80.78166,30.32099],[80.72306,30.39205],[80.59635,30.4598],[80.5083,30.46233],[80.32588,30.54651],[80.25281,30.56501],[80.17984,30.55948],[80.2033,30.60635],[80.16661,30.67441],[80.20702,30.68614],[80.23141,30.72495],[80.1571,30.79316],[80.0922,30.77451],[80.06253,30.78479],[80.02863,30.83078],[79.90316,30.89021],[79.8335,30.96152],[79.73904,30.97925],[79.63031,30.9625],[79.57781,30.93837],[79.5127,30.96617],[79.47642,31.01403],[79.40159,31.02364],[79.36705,31.11156],[79.31942,31.14228],[79.28832,31.22547],[79.25286,31.24544],[79.26202,31.27851],[79.23832,31.32966],[79.1314,31.43843],[79.07021,31.46138],[78.92101,31.26968],[78.86199,31.29148],[78.78447,31.28827],[78.7451,31.30812],[78.76256,31.44511],[78.69538,31.48811],[78.69952,31.51002],[78.81889,31.60737],[78.75812,31.6666],[78.72401,31.67331],[78.69321,31.71853],[78.69559,31.74669],[78.67089,31.77052],[78.74458,31.9642],[78.72215,31.99459],[78.64929,32.03644],[78.51307,32.20757],[78.45798,32.22966],[78.46273,32.2818],[78.43452,32.38376],[78.45219,32.41678],[78.38098,32.52804],[78.38481,32.54755],[78.44754,32.56626],[78.63017,32.57801],[78.69445,32.62966],[78.73683,32.62984],[78.75099,32.61279],[78.73693,32.54579],[78.77145,32.46166],[78.91129,32.35475],[78.94343,32.34637],[79.02818,32.37709],[79.07531,32.37079],[79.11293,32.47239],[79.28741,32.53258],[79.32047,32.59227],[79.37948,32.57645],[79.40682,32.52968],[79.4435,32.53477],[79.51574,32.60812],[79.5312,32.64832],[79.57617,32.67065],[79.62067,32.72872],[79.61391,32.76503],[79.56409,32.79393],[79.41409,32.97205],[79.33359,32.99429],[79.33979,33.01326],[79.39927,33.02688],[79.37317,33.11214],[79.42347,33.15783],[79.4561,33.2504],[79.42008,33.27517],[79.26006,33.29178],[79.09803,33.28766],[78.96499,33.34425],[78.91769,33.38626]]],[[[111.01051,19.68378],[111.03468,19.64224],[110.96583,19.63597],[110.87647,19.53978],[110.84278,19.53994],[110.82179,19.56778],[110.82569,19.60326],[110.86264,19.60871],[110.84767,19.62246],[110.79444,19.60126],[110.82179,19.5541],[110.79623,19.4846],[110.76027,19.45791],[110.75758,19.43089],[110.69825,19.41694],[110.66969,19.38174],[110.67791,19.33438],[110.64137,19.31245],[110.62159,19.23066],[110.58033,19.15518],[110.5101,19.15005],[110.47918,19.16926],[110.50733,19.13223],[110.54623,19.13988],[110.55494,19.08735],[110.58229,19.15005],[110.56324,19.05565],[110.51808,18.99348],[110.51336,18.9064],[110.46559,18.8819],[110.45875,18.84833],[110.43149,18.82148],[110.44508,18.80366],[110.49285,18.79312],[110.50343,18.8819],[110.53443,18.78563],[110.469,18.77717],[110.42457,18.70376],[110.42677,18.68089],[110.40349,18.66279],[110.33522,18.67333],[110.4109,18.69013],[110.40349,18.70376],[110.30787,18.66279],[110.27084,18.66816],[110.1211,18.55581],[110.08814,18.50434],[110.03419,18.51195],[110.08253,18.47777],[110.06821,18.40892],[110.04461,18.44013],[110.05258,18.3902],[110.04029,18.38227],[109.9686,18.38984],[109.9686,18.4055],[110.01368,18.40892],[110.02052,18.45051],[110.0062,18.42939],[109.99252,18.44306],[109.96518,18.41633],[109.89829,18.41425],[109.83546,18.38227],[109.79566,18.40518],[109.7461,18.3959],[109.73178,18.27302],[109.7046,18.29222],[109.67042,18.27302],[109.73927,18.25873],[109.70875,18.20722],[109.69068,18.20917],[109.67726,18.24506],[109.60613,18.20889],[109.61573,18.17617],[109.56121,18.16934],[109.56804,18.19318],[109.54762,18.22085],[109.56625,18.24299],[109.56121,18.25873],[109.52955,18.22769],[109.53395,18.21035],[109.51075,18.21361],[109.47869,18.19269],[109.46502,18.20409],[109.50961,18.23078],[109.50652,18.25275],[109.49293,18.24506],[109.43995,18.28897],[109.21811,18.30565],[109.16261,18.28783],[109.12916,18.34748],[109.136,18.36115],[108.9909,18.36872],[108.79558,18.48656],[108.68491,18.5152],[108.67408,18.55484],[108.69093,18.60472],[108.69361,18.71987],[108.66814,18.78864],[108.62387,18.84919],[108.65626,18.944],[108.61525,19.10163],[108.6631,19.15624],[108.68344,19.15624],[108.66391,19.20962],[108.62208,19.21772],[108.63795,19.24897],[108.62696,19.27497],[108.64479,19.31037],[108.67726,19.32758],[108.66928,19.34374],[108.68653,19.37787],[108.76295,19.38898],[108.79029,19.42316],[108.8698,19.45791],[108.92449,19.50625],[108.94483,19.49201],[108.94077,19.51411],[109.00847,19.59707],[109.1565,19.64964],[109.19207,19.67845],[109.21119,19.7248],[109.25896,19.73164],[109.30738,19.71113],[109.27467,19.75723],[109.2942,19.75951],[109.28679,19.77318],[109.30738,19.78685],[109.2732,19.79365],[109.16334,19.7248],[109.16684,19.80549],[109.24871,19.86884],[109.25514,19.89834],[109.31137,19.9195],[109.3213,19.89203],[109.36883,19.88931],[109.41261,19.8614],[109.44361,19.87299],[109.46502,19.83405],[109.52703,19.86197],[109.58172,19.8557],[109.52019,19.88247],[109.51564,19.90522],[109.55746,19.9173],[109.53093,19.93012],[109.52703,19.95136],[109.59571,19.99315],[109.719,20.01065],[109.71811,19.95751],[109.73756,19.97809],[109.80763,19.95136],[109.81593,19.9912],[109.83513,19.97333],[109.83546,19.93085],[109.87037,19.95335],[109.88331,19.98485],[109.94264,19.99091],[109.9756,19.93228],[109.95151,19.93085],[109.99008,19.90595],[110.0062,19.91035],[109.9922,19.92536],[110.01466,19.94086],[110.01368,19.95751],[110.06821,19.97866],[110.10987,19.96499],[110.12273,19.97431],[110.14959,20.00658],[110.11549,19.98485],[110.06821,19.98485],[110.12973,20.01276],[110.15699,20.06737],[110.261,20.03315],[110.34474,20.05805],[110.37672,20.02643],[110.36817,20.0078],[110.39723,19.97866],[110.41773,19.92341],[110.40943,19.98005],[110.38299,20.01276],[110.38299,20.08104],[110.42457,20.04751],[110.5276,20.02021],[110.52076,20.00658],[110.55055,19.98847],[110.57545,19.99917],[110.60581,19.92316],[110.62322,19.94074],[110.62322,19.97183],[110.60092,20.02106],[110.57081,20.03067],[110.56617,20.06952],[110.59311,20.10733],[110.65203,20.11701],[110.67791,20.16356],[110.71095,20.07518],[110.78834,20.0104],[110.86988,19.99702],[110.92066,20.01203],[110.93458,19.99506],[110.97706,19.86587],[110.99439,19.69428],[111.01051,19.68378]]],[[[111.96266,21.65762],[111.99985,21.654],[111.99985,21.62116],[111.90968,21.60053],[111.86329,21.56826],[111.80934,21.5655],[111.84205,21.60684],[111.8353,21.64395],[111.92823,21.66446],[111.94516,21.65013],[111.96266,21.65762]]],[[[112.64536,21.71284],[112.65455,21.69847],[112.63795,21.66446],[112.6504,21.64045],[112.60719,21.61603],[112.55592,21.62344],[112.5254,21.59886],[112.52426,21.6269],[112.56788,21.66063],[112.55592,21.67813],[112.64536,21.71284]]],[[[112.844,21.77432],[112.86256,21.76944],[112.86256,21.74559],[112.79754,21.70588],[112.81056,21.65762],[112.79347,21.60822],[112.81056,21.5893],[112.7649,21.58186],[112.73439,21.62873],[112.77442,21.69172],[112.71559,21.68024],[112.71168,21.70254],[112.7649,21.73176],[112.77752,21.77265],[112.844,21.77432]]],[[[113.39145,22.02753],[113.40577,22.00702],[113.30942,22.02411],[113.3436,22.06851],[113.38266,22.08959],[113.39894,22.06851],[113.41261,22.07599],[113.39145,22.02753]]],[[[113.36476,22.1579],[113.31365,22.0764],[113.27914,22.07469],[113.27174,22.12002],[113.32309,22.1579],[113.32797,22.14582],[113.36476,22.1579]]],[[[113.59352,22.78482],[113.59457,22.75609],[113.55828,22.75788],[113.52858,22.80215],[113.52117,22.78791],[113.50611,22.79804],[113.48081,22.84931],[113.47316,22.82539],[113.4559,22.83076],[113.39617,22.8981],[113.43295,22.91201],[113.4756,22.9007],[113.52703,22.84662],[113.57301,22.83006],[113.59352,22.78482]]],[[[117.07154,23.47309],[117.09033,23.46108],[117.12585,23.48269],[117.12257,23.46552],[117.14179,23.44965],[117.12878,23.39937],[116.94299,23.43006],[116.98219,23.45946],[117.01002,23.43895],[117.03758,23.4469],[117.04908,23.47408],[117.07154,23.47309]]],[[[117.4689,23.75309],[117.52666,23.74011],[117.51619,23.71628],[117.48416,23.71079],[117.47983,23.66268],[117.44483,23.66494],[117.42115,23.64646],[117.41537,23.60822],[117.43156,23.58263],[117.32048,23.57402],[117.36912,23.62236],[117.39373,23.67919],[117.3405,23.68895],[117.37669,23.72886],[117.37273,23.76675],[117.41358,23.77167],[117.44778,23.74225],[117.4689,23.75309]]],[[[119.30543,26.03596],[119.40121,25.99604],[119.40496,25.97793],[119.38111,25.96699],[119.25758,26.00145],[119.20924,26.09858],[119.25758,26.04755],[119.30543,26.03596]]],[[[121.25553,28.14],[121.28535,28.10195],[121.27914,28.08544],[121.23813,28.09292],[121.23146,28.06501],[121.15157,28.04123],[121.15524,28.07137],[121.12037,28.13569],[121.16528,28.15053],[121.23059,28.21973],[121.2564,28.2148],[121.26889,28.19394],[121.25553,28.14]]],[[[122.23878,30.31314],[122.20387,30.29141],[122.23194,30.27774],[122.20232,30.24193],[122.13771,30.25463],[122.07358,30.29141],[122.21144,30.34662],[122.23878,30.31314]]],[[[122.39894,30.45649],[122.42311,30.41494],[122.34799,30.43598],[122.28541,30.42206],[122.2728,30.43598],[122.28712,30.44221],[122.27377,30.45136],[122.28004,30.48261],[122.33741,30.45848],[122.36915,30.477],[122.37468,30.45734],[122.39894,30.45649]]],[[[121.83774,31.37515],[121.86785,31.3662],[121.88185,31.33661],[121.86622,31.30419],[121.83473,31.29255],[121.7942,31.3688],[121.83774,31.37515]]],[[[119.88071,32.12519],[119.90187,32.07339],[119.792,32.12177],[119.79103,32.16547],[119.81935,32.20376],[119.74985,32.22801],[119.71949,32.24958],[119.71616,32.27265],[119.75082,32.29312],[119.8143,32.27558],[119.88071,32.12519]]],[[[122.3213,39.16596],[122.4026,39.21381],[122.38893,39.18708],[122.4026,39.15913],[122.334,39.14631],[122.3213,39.16596]]],[[[122.636,39.23428],[122.74195,39.23607],[122.69068,39.19331],[122.69752,39.22126],[122.636,39.23428]]],[[[122.68442,39.26219],[122.58619,39.258],[122.53346,39.27167],[122.50506,39.29694],[122.68442,39.26219]]],[[[121.42254,39.47451],[121.45045,39.45026],[121.41627,39.44033],[121.43556,39.4162],[121.42172,39.37368],[121.33692,39.37739],[121.37965,39.42487],[121.36427,39.44546],[121.34059,39.44033],[121.29615,39.39142],[121.25945,39.39151],[121.26588,39.43358],[121.31105,39.44794],[121.34059,39.48135],[121.42254,39.47451]]],[[[122.96437,39.51545],[122.96803,39.54108],[122.98976,39.54951],[123.04005,39.52973],[123.00571,39.4973],[122.96437,39.51545]]],[[[110.59588,20.95873],[110.6325,20.93667],[110.5892,20.88349],[110.54754,20.91771],[110.59588,20.95873]]],[[[110.54754,21.03726],[110.51694,20.93765],[110.49301,20.97797],[110.44142,21.00585],[110.36305,21.00585],[110.26661,20.97264],[110.24586,20.98603],[110.26539,21.00434],[110.24586,21.02757],[110.28736,21.03384],[110.27312,21.0611],[110.30991,21.05292],[110.33522,21.06859],[110.30795,21.06086],[110.28736,21.07445],[110.34262,21.07465],[110.38982,21.09589],[110.41773,21.04059],[110.43214,21.05728],[110.52638,21.0821],[110.54754,21.03726]]],[[[110.61695,21.19147],[110.5652,21.11262],[110.54322,21.10497],[110.52068,21.1118],[110.47918,21.16413],[110.47918,21.15046],[110.46144,21.15107],[110.44532,21.16568],[110.45191,21.17845],[110.41773,21.17845],[110.46217,21.20515],[110.49985,21.20629],[110.46339,21.19074],[110.49293,21.1839],[110.54322,21.20417],[110.56861,21.19147],[110.55494,21.15672],[110.594,21.20279],[110.61695,21.19147]]],[[[118.09254,24.55622],[118.18141,24.5351],[118.18873,24.49413],[118.13648,24.42927],[118.09205,24.43846],[118.07,24.46027],[118.10304,24.47988],[118.10621,24.49413],[118.08229,24.48233],[118.07203,24.49787],[118.09254,24.55622]]],[[[119.84653,25.53632],[119.81861,25.5458],[119.79884,25.51581],[119.84653,25.46117],[119.77198,25.45059],[119.75733,25.40493],[119.69304,25.43765],[119.70997,25.48786],[119.66847,25.48786],[119.68621,25.50772],[119.74855,25.50259],[119.77833,25.52204],[119.72633,25.52513],[119.71616,25.53632],[119.72706,25.59089],[119.70265,25.5773],[119.69272,25.59422],[119.73048,25.64615],[119.76352,25.65949],[119.78175,25.65917],[119.77589,25.63573],[119.792,25.60456],[119.81349,25.60883],[119.83286,25.59089],[119.81691,25.57982],[119.87452,25.55679],[119.84653,25.53632]]],[[[121.95753,29.11953],[121.93531,29.06289],[121.88868,29.07233],[121.90016,29.09374],[121.86817,29.14741],[121.92661,29.16673],[121.95191,29.15229],[121.95753,29.11953]]],[[[122.14324,29.73338],[122.18686,29.67341],[122.16293,29.65453],[122.16977,29.67504],[122.12745,29.66901],[122.09278,29.72577],[122.04005,29.71638],[122.03289,29.73094],[122.06821,29.77953],[122.10743,29.78962],[122.14324,29.73338]]],[[[122.39519,29.88361],[122.38282,29.86473],[122.39519,29.83332],[122.35711,29.86172],[122.36736,29.8959],[122.32521,29.92788],[122.32545,29.95393],[122.40675,29.9527],[122.39519,29.92206],[122.40805,29.89985],[122.39519,29.88361]]],[[[121.88185,30.07636],[121.91049,30.02411],[121.88966,29.98395],[121.86012,29.97809],[121.82887,29.9975],[121.83473,30.04499],[121.85304,30.08714],[121.88185,30.07636]]],[[[122.32748,30.02448],[122.29363,29.93378],[122.22918,29.97866],[122.09897,30.01447],[122.01905,29.99722],[122.01222,30.03193],[121.9734,30.05996],[121.98081,30.09007],[121.96314,30.1424],[122.10328,30.1437],[122.25611,30.06818],[122.28956,30.06733],[122.32748,30.02448]]],[[[121.84408,31.60795],[121.98009,31.5403],[121.97785,31.47777],[121.85137,31.43918],[121.68463,31.50681],[121.57154,31.52936],[121.51746,31.56839],[121.31446,31.65558],[121.2859,31.70405],[121.17629,31.78432],[121.2193,31.82947],[121.26407,31.84108],[121.32256,31.82933],[121.4753,31.77077],[121.66322,31.65235],[121.84408,31.60795]]],[[[111.19654,15.77538],[111.1941,15.77611],[111.19288,15.77823],[111.1963,15.78571],[111.19939,15.78921],[111.20753,15.78929],[111.20851,15.78628],[111.20753,15.78217],[111.2046,15.77924],[111.19654,15.77538]]],[[[111.76336,16.04344],[111.7531,16.04466],[111.75343,16.04743],[111.7614,16.04865],[111.76588,16.04531],[111.76336,16.04344]]],[[[109.10092,21.02119],[109.0905,21.04194],[109.12273,21.06542],[109.13624,21.05101],[109.1277,21.03217],[109.10092,21.02119]]],[[[120.74838,37.93871],[120.75457,37.89667],[120.72242,37.91909],[120.72706,37.94766],[120.74838,37.93871]]],[[[120.60678,37.97989],[120.62582,37.97256],[120.62289,37.9488],[120.60678,37.97989]]],[[[120.72006,37.98094],[120.72047,37.96402],[120.68824,37.98526],[120.72006,37.98094]]],[[[120.76043,38.17048],[120.77329,38.15973],[120.74659,38.16185],[120.74269,38.18757],[120.76043,38.17048]]],[[[120.80649,38.28824],[120.80738,38.30488],[120.83375,38.31257],[120.80649,38.28824]]],[[[120.91424,38.3841],[120.90756,38.38516],[120.90691,38.39256],[120.91782,38.39545],[120.92457,38.38687],[120.92319,38.38325],[120.91725,38.38443],[120.91424,38.3841]]],[[[123.18067,39.09195],[123.19557,39.05207],[123.16066,39.02924],[123.14519,39.04328],[123.16472,39.06639],[123.14356,39.08088],[123.18067,39.09195]]],[[[111.60011,16.52729],[111.59986,16.52749],[111.60084,16.53197],[111.60287,16.53335],[111.60572,16.53352],[111.60816,16.53181],[111.60963,16.52827],[111.6089,16.52725],[111.60629,16.52424],[111.60279,16.52464],[111.60011,16.52729]]],[[[112.73504,16.65795],[112.73015,16.66022],[112.72527,16.66621],[112.72625,16.66901],[112.72877,16.66926],[112.73341,16.66608],[112.73683,16.66022],[112.73504,16.65795]]],[[[112.33318,16.82502],[112.32716,16.82974],[112.32594,16.83324],[112.32789,16.8371],[112.33058,16.83763],[112.33546,16.83259],[112.33725,16.82844],[112.33318,16.82502]]],[[[112.33367,16.93325],[112.33668,16.93211],[112.33709,16.93203],[112.33758,16.93134],[112.34108,16.92699],[112.3405,16.92357],[112.33546,16.92381],[112.33367,16.92642],[112.33367,16.93325]]],[[[110.47137,20.64737],[110.47584,20.59593],[110.45729,20.58686],[110.41765,20.65477],[110.43702,20.66791],[110.47137,20.64737]]],[[[113.278,21.92504],[113.25847,21.88044],[113.22584,21.90082],[113.2317,21.93447],[113.24773,21.94318],[113.26783,21.9433],[113.278,21.92504]]],[[[113.77175,22.00034],[113.76448,21.96682],[113.74821,21.97279],[113.73628,21.99716],[113.77175,22.00034]]],[[[114.30681,22.0572],[114.3025,22.04487],[114.20672,22.02578],[114.30421,22.06928],[114.30681,22.0572]]],[[[113.47348,22.09854],[113.47153,22.11408],[113.51889,22.12727],[113.54762,22.0926],[113.48471,22.07136],[113.47022,22.07762],[113.47348,22.09854]]],[[[113.62338,22.43987],[113.65783,22.42375],[113.62119,22.39142],[113.61044,22.41334],[113.62338,22.43987]]],[[[113.54676,22.74188],[113.5732,22.7007],[113.63569,22.65648],[113.64183,22.60162],[113.60261,22.61152],[113.47651,22.71951],[113.47654,22.73127],[113.54676,22.74188]]],[[[113.52921,22.96035],[113.5536,22.91128],[113.54501,22.87996],[113.50996,22.92122],[113.50575,22.95161],[113.51538,22.96922],[113.52921,22.96035]]],[[[116.99562,23.56354],[117.00929,23.555],[116.9878,23.52411],[116.97096,23.51463],[116.93727,23.52627],[116.99562,23.56354]]],[[[119.55104,25.20946],[119.56756,25.2049],[119.52898,25.16535],[119.51271,25.1988],[119.46119,25.20331],[119.44467,25.22907],[119.47039,25.25218],[119.51197,25.21345],[119.55104,25.20946]]],[[[119.34653,25.56672],[119.33497,25.43797],[119.29689,25.42292],[119.2815,25.4829],[119.30746,25.54682],[119.33172,25.57127],[119.34653,25.56672]]],[[[119.62696,26.1079],[119.64405,26.07844],[119.62037,26.0642],[119.55397,26.09565],[119.59938,26.12812],[119.62696,26.1079]]],[[[120.32683,26.93968],[120.32219,26.9621],[120.3449,26.97195],[120.38567,26.93517],[120.35255,26.92377],[120.32683,26.93968]]],[[[121.074,27.47948],[121.10336,27.45188],[121.06649,27.45498],[121.06726,27.43677],[121.04611,27.4652],[121.074,27.47948]]],[[[121.18165,27.86465],[121.1893,27.84829],[121.17302,27.84101],[121.17123,27.81879],[121.09799,27.81782],[121.11402,27.84414],[121.18165,27.86465]]],[[[121.04933,27.95087],[121.04518,27.96748],[121.07016,27.9829],[121.11134,27.98379],[121.13966,27.96577],[121.10328,27.94619],[121.04933,27.95087]]],[[[121.86215,29.12958],[121.86736,29.11579],[121.84205,29.09504],[121.78272,29.12466],[121.78981,29.14985],[121.86215,29.12958]]],[[[122.3047,29.81745],[122.30836,29.77627],[122.25131,29.8061],[122.2317,29.83625],[122.27117,29.84683],[122.31202,29.82905],[122.3047,29.81745]]],[[[121.97218,29.94477],[121.99244,29.90827],[121.94614,29.90396],[121.94093,29.93769],[121.95997,29.95319],[121.97218,29.94477]]],[[[122.34571,30.24193],[122.2776,30.23554],[122.32398,30.27216],[122.40772,30.26594],[122.40854,30.24396],[122.39584,30.23945],[122.38063,30.26374],[122.34571,30.24193]]],[[[122.52931,30.72077],[122.52475,30.70181],[122.42726,30.70393],[122.42994,30.74307],[122.47291,30.71723],[122.52931,30.72077]]],[[[121.76238,31.37376],[121.78224,31.34178],[121.76637,31.32062],[121.58448,31.44139],[121.69125,31.41918],[121.76238,31.37376]]],[[[118.63274,39.07458],[118.64874,39.04465],[118.59432,38.9722],[118.51431,38.91945],[118.48028,38.93482],[118.4909,38.94319],[118.4707,38.97055],[118.47848,39.04286],[118.52638,39.04669],[118.55972,39.00312],[118.62871,39.04855],[118.61464,39.06765],[118.63274,39.07458]]],[[[111.68027,16.57815],[111.67888,16.58112],[111.68165,16.58019],[111.68027,16.57815]]]],\"HK\":[[[[114.22983,22.55581],[114.23471,22.55618],[114.21485,22.53449],[114.20997,22.53449],[114.20932,22.52371],[114.21795,22.52603],[114.21868,22.52985],[114.2304,22.53295],[114.2343,22.53693],[114.24204,22.5502],[114.24594,22.55097],[114.24903,22.54784],[114.25457,22.55097],[114.25766,22.54784],[114.25327,22.53946],[114.25921,22.5408],[114.26564,22.53526],[114.26515,22.52847],[114.27003,22.52558],[114.278,22.52985],[114.2798,22.52603],[114.2798,22.51972],[114.27768,22.51166],[114.28517,22.51118],[114.28924,22.50519],[114.29851,22.501],[114.32659,22.50877],[114.33277,22.51195],[114.33513,22.50642],[114.30543,22.48302],[114.28045,22.47289],[114.26873,22.46503],[114.26409,22.45808],[114.25375,22.45177],[114.24594,22.45177],[114.23121,22.46979],[114.21713,22.46735],[114.21176,22.47057],[114.20932,22.46581],[114.21095,22.4573],[114.21559,22.45498],[114.21713,22.44794],[114.20997,22.44949],[114.20932,22.45409],[114.20224,22.45808],[114.1906,22.45653],[114.1867,22.45177],[114.16749,22.44806],[114.21176,22.4206],[114.20997,22.41275],[114.21485,22.40888],[114.20932,22.39867],[114.21559,22.40034],[114.2234,22.41502],[114.23422,22.42475],[114.23821,22.43537],[114.24383,22.43553],[114.25147,22.43073],[114.26718,22.43073],[114.26938,22.42552],[114.26718,22.41596],[114.27255,22.40965],[114.27646,22.41983],[114.28899,22.42601],[114.27646,22.444],[114.28899,22.45331],[114.30161,22.45653],[114.30079,22.46503],[114.32374,22.48005],[114.32504,22.47521],[114.33074,22.47411],[114.32504,22.46426],[114.3357,22.46174],[114.33806,22.44636],[114.33277,22.44001],[114.33741,22.43159],[114.34278,22.43769],[114.34758,22.42292],[114.35231,22.42369],[114.35157,22.44099],[114.35987,22.46117],[114.36785,22.45498],[114.36736,22.44627],[114.37021,22.44001],[114.38575,22.43924],[114.39975,22.43614],[114.39739,22.42841],[114.4013,22.41275],[114.38933,22.41791],[114.38177,22.41905],[114.37021,22.39798],[114.37501,22.40054],[114.38494,22.38736],[114.38974,22.36595],[114.38657,22.36522],[114.38575,22.37018],[114.37639,22.37063],[114.37949,22.36209],[114.37558,22.35346],[114.37021,22.34882],[114.36085,22.34577],[114.3576,22.33242],[114.35231,22.33946],[114.35231,22.35192],[114.34197,22.34577],[114.33904,22.35269],[114.33277,22.35977],[114.33432,22.37531],[114.3204,22.38076],[114.31861,22.38939],[114.31007,22.38377],[114.30006,22.38158],[114.28598,22.39098],[114.278,22.39098],[114.26873,22.37844],[114.27337,22.36363],[114.2798,22.35501],[114.27646,22.34805],[114.26718,22.35578],[114.26637,22.36595],[114.25839,22.36209],[114.25554,22.3559],[114.2658,22.34634],[114.27255,22.32071],[114.29127,22.31656],[114.29208,22.30516],[114.30388,22.30671],[114.31162,22.29731],[114.30567,22.29076],[114.2977,22.28563],[114.29054,22.28795],[114.28899,22.28718],[114.28956,22.27391],[114.30006,22.27705],[114.30315,22.26919],[114.30315,22.26215],[114.29672,22.25829],[114.27833,22.27045],[114.2798,22.27936],[114.27337,22.29576],[114.26482,22.30195],[114.26637,22.31599],[114.25554,22.32233],[114.25147,22.30516],[114.25302,22.29576],[114.23894,22.28168],[114.21176,22.31057],[114.21485,22.30117],[114.21249,22.30117],[114.19549,22.31826],[114.18653,22.30874],[114.19142,22.30195],[114.19142,22.2989],[114.18369,22.30195],[114.18165,22.29442],[114.17189,22.28872],[114.16725,22.29418],[114.16822,22.30549],[114.16489,22.31521],[114.15952,22.31521],[114.15952,22.32225],[114.15219,22.32852],[114.12436,22.34223],[114.12355,22.33714],[114.11337,22.35269],[114.11101,22.36595],[114.10483,22.36986],[114.0975,22.36302],[114.07911,22.36595],[114.0783,22.36286],[114.0704,22.36286],[114.06723,22.359],[114.05795,22.36131],[114.02052,22.35037],[114.01498,22.35346],[114.01498,22.359],[113.99863,22.359],[113.99187,22.36408],[113.98268,22.36677],[113.97674,22.37922],[113.97291,22.37999],[113.97063,22.38707],[113.96437,22.38068],[113.97462,22.37206],[113.95493,22.36986],[113.94483,22.36209],[113.93067,22.36909],[113.92596,22.36909],[113.9144,22.37612],[113.91831,22.38784],[113.91057,22.39867],[113.89698,22.40876],[113.90943,22.41861],[113.93067,22.41983],[113.94101,22.42678],[113.95411,22.44949],[113.97755,22.45885],[113.99863,22.48855],[114.00489,22.48855],[114.01271,22.48611],[114.01116,22.47289],[114.01498,22.47521],[114.02988,22.47826],[114.03142,22.48224],[114.03061,22.49551],[114.04225,22.501],[114.05795,22.50487],[114.06577,22.51585],[114.0853,22.52294],[114.08237,22.52936],[114.09146,22.53439],[114.10841,22.53439],[114.1171,22.52757],[114.12888,22.53811],[114.13818,22.53935],[114.14872,22.53811],[114.15389,22.55185],[114.16195,22.56054],[114.17322,22.56395],[114.18304,22.55619],[114.22983,22.55581]]],[[[114.13738,22.17878],[114.11085,22.17707],[114.11411,22.18574],[114.11411,22.21613],[114.10426,22.21597],[114.10637,22.2224],[114.1067,22.23578],[114.11329,22.23334],[114.11744,22.22187],[114.12574,22.21345],[114.13282,22.21613],[114.13282,22.20995],[114.12574,22.20523],[114.14967,22.20604],[114.15463,22.2036],[114.14308,22.19611],[114.13909,22.19111],[114.14536,22.1787],[114.13738,22.17878]]],[[[114.21559,22.28563],[114.22177,22.27936],[114.22812,22.28091],[114.23585,22.27782],[114.24366,22.26459],[114.25147,22.25906],[114.25147,22.24889],[114.24529,22.24189],[114.25375,22.22398],[114.24903,22.2224],[114.25611,22.2036],[114.24366,22.20128],[114.23276,22.21149],[114.23268,22.22199],[114.2295,22.23359],[114.22267,22.23884],[114.21681,22.23432],[114.22267,22.23253],[114.22267,22.22476],[114.21868,22.22398],[114.21176,22.21613],[114.21868,22.206],[114.21559,22.19896],[114.21949,22.19188],[114.20769,22.18956],[114.20606,22.20523],[114.20932,22.20755],[114.20655,22.21239],[114.19606,22.20755],[114.19142,22.20995],[114.19606,22.21532],[114.18906,22.21922],[114.1937,22.22553],[114.19142,22.23098],[114.18271,22.23253],[114.18385,22.24103],[114.17115,22.2333],[114.16725,22.22785],[114.16489,22.22785],[114.16098,22.24038],[114.15219,22.24502],[114.136,22.24575],[114.12908,22.24889],[114.11883,22.26919],[114.11256,22.27151],[114.11337,22.27782],[114.11565,22.27936],[114.13909,22.28872],[114.1657,22.27936],[114.17888,22.27936],[114.18816,22.28718],[114.19679,22.29186],[114.21559,22.28563]]],[[[113.84645,22.19709],[113.8392,22.2049],[113.83733,22.22862],[113.8392,22.2342],[113.84921,22.2342],[113.84921,22.24413],[113.85678,22.246],[113.85678,22.25227],[113.8506,22.25674],[113.8676,22.27074],[113.8768,22.27228],[113.88396,22.27936],[113.89251,22.29271],[113.92148,22.29186],[113.9284,22.28486],[113.93865,22.28872],[113.96192,22.30117],[113.97674,22.29808],[113.98373,22.30903],[113.99244,22.31057],[113.99952,22.32225],[114.01735,22.33552],[114.02052,22.32852],[114.04379,22.34577],[114.05486,22.33633],[114.05022,22.31993],[114.04615,22.32315],[114.03142,22.32457],[114.02963,22.30899],[114.01271,22.31135],[114.01271,22.29808],[114.02215,22.30191],[114.01759,22.28905],[114.01889,22.26459],[114.00416,22.26459],[113.99537,22.26777],[114.00725,22.2521],[113.99244,22.2412],[114.01498,22.22708],[114.00017,22.21613],[113.98194,22.20824],[113.98064,22.23725],[113.94565,22.23725],[113.9284,22.22398],[113.92148,22.22398],[113.91831,22.21922],[113.9284,22.21691],[113.9284,22.21076],[113.91912,22.20502],[113.90626,22.20401],[113.89405,22.21532],[113.89251,22.2224],[113.88396,22.22],[113.85914,22.2014],[113.84645,22.19709]]]],\"TW\":[[[[121.90577,24.9501],[121.83473,24.87153],[121.8235,24.85493],[121.81463,24.83173],[121.80885,24.80516],[121.80681,24.77875],[121.80779,24.75312],[121.82032,24.69965],[121.81959,24.68598],[121.81422,24.67231],[121.81495,24.64436],[121.82797,24.62279],[121.85271,24.60887],[121.88185,24.60399],[121.88185,24.59781],[121.86948,24.59894],[121.85776,24.59785],[121.84791,24.59492],[121.84083,24.59032],[121.85084,24.57298],[121.85597,24.55052],[121.85279,24.53099],[121.83009,24.51923],[121.82838,24.51073],[121.83131,24.50039],[121.83774,24.49103],[121.84002,24.48135],[121.83041,24.47313],[121.80681,24.46064],[121.78004,24.43268],[121.77914,24.42178],[121.78077,24.39761],[121.7715,24.38056],[121.7684,24.36937],[121.76645,24.34736],[121.75856,24.33234],[121.7693,24.31806],[121.77272,24.30923],[121.76743,24.30093],[121.74529,24.28124],[121.72145,24.25117],[121.70387,24.23835],[121.69825,24.23151],[121.70094,24.22419],[121.69777,24.21898],[121.67237,24.20148],[121.66285,24.1931],[121.65219,24.17219],[121.64129,24.12987],[121.61305,24.09211],[121.60743,24.07685],[121.60816,24.06049],[121.615,24.0423],[121.62892,24.02619],[121.62916,24.01496],[121.62501,24.00239],[121.6111,23.98249],[121.60816,23.97036],[121.6067,23.94806],[121.59449,23.89151],[121.57032,23.8321],[121.53981,23.69282],[121.5215,23.65868],[121.46217,23.343],[121.45362,23.32599],[121.42677,23.29312],[121.41676,23.27607],[121.40887,23.25458],[121.40333,23.21475],[121.40382,23.17927],[121.40008,23.14549],[121.3816,23.11066],[121.34742,23.08332],[121.34343,23.07539],[121.33709,23.05508],[121.31886,23.02554],[121.30763,22.9798],[121.29957,22.95917],[121.26051,22.90729],[121.25563,22.89777],[121.20281,22.85651],[121.19028,22.84317],[121.18849,22.83393],[121.19093,22.81196],[121.19028,22.80215],[121.18556,22.79096],[121.17742,22.77863],[121.15935,22.75747],[121.14145,22.74177],[121.08025,22.70409],[121.03907,22.66242],[121.02906,22.65754],[121.02101,22.64789],[121.00115,22.60077],[120.99122,22.58308],[120.96062,22.5478],[120.94825,22.5268],[120.94109,22.48363],[120.92286,22.42475],[120.88657,22.36595],[120.88136,22.34593],[120.87501,22.28925],[120.87924,22.05581],[120.8733,22.02998],[120.86362,22.00894],[120.8357,21.98725],[120.83619,21.95771],[120.84156,21.92548],[120.84034,21.90461],[120.82309,21.92862],[120.79737,21.94676],[120.76743,21.9593],[120.73731,21.96613],[120.7361,21.94282],[120.72731,21.93447],[120.71437,21.93749],[120.70037,21.94843],[120.69956,21.95669],[120.70436,21.98139],[120.68336,22.01386],[120.68165,22.02253],[120.68336,22.04121],[120.68336,22.07905],[120.68556,22.09015],[120.69483,22.10261],[120.69695,22.11323],[120.62127,22.29507],[120.615,22.305],[120.59392,22.32917],[120.57732,22.36384],[120.56967,22.37385],[120.53875,22.39643],[120.51808,22.40697],[120.51197,22.42475],[120.49529,22.43476],[120.45721,22.45161],[120.41326,22.48233],[120.40211,22.4868],[120.38087,22.48387],[120.33123,22.51972],[120.28517,22.58006],[120.33367,22.52595],[120.32797,22.5517],[120.31349,22.57705],[120.29331,22.59829],[120.25896,22.62759],[120.24578,22.64252],[120.23959,22.65908],[120.24781,22.67524],[120.25213,22.68964],[120.24635,22.70991],[120.23618,22.72988],[120.21754,22.75674],[120.19996,22.79247],[120.19606,22.80834],[120.19646,22.81859],[120.20216,22.83124],[120.20346,22.83967],[120.20135,22.84414],[120.19142,22.85204],[120.18922,22.86018],[120.17555,22.8841],[120.16383,22.95384],[120.15691,22.96654],[120.1421,22.98029],[120.16017,22.99213],[120.17026,23.01337],[120.16619,23.03172],[120.1421,23.0349],[120.13746,23.02839],[120.13347,23.01655],[120.12672,23.00715],[120.11411,23.00764],[120.10914,23.01545],[120.11101,23.03998],[120.10792,23.04857],[120.09246,23.05476],[120.08375,23.04755],[120.07667,23.03742],[120.06625,23.0349],[120.05519,23.04369],[120.05242,23.05581],[120.05494,23.06778],[120.05942,23.07587],[120.07057,23.0812],[120.08473,23.08295],[120.09645,23.08641],[120.10043,23.09699],[120.09498,23.10248],[120.05942,23.11066],[120.05942,23.1175],[120.0892,23.11774],[120.08627,23.12694],[120.07008,23.1398],[120.05942,23.15103],[120.06202,23.16502],[120.08188,23.17316],[120.08692,23.18203],[120.08383,23.19575],[120.07862,23.20698],[120.08744,23.23029],[120.0961,23.23949],[120.09425,23.2475],[120.09604,23.25536],[120.1089,23.28669],[120.11492,23.29271],[120.12468,23.29499],[120.11049,23.3037],[120.11653,23.3179],[120.13118,23.31881],[120.14356,23.32388],[120.14894,23.32225],[120.14576,23.33332],[120.13734,23.32773],[120.12094,23.32974],[120.12078,23.34125],[120.13015,23.36096],[120.16458,23.36171],[120.15064,23.39814],[120.13478,23.42626],[120.1421,23.43895],[120.1421,23.44579],[120.13054,23.45099],[120.13209,23.45698],[120.13868,23.46426],[120.1421,23.47309],[120.15754,23.49148],[120.12503,23.48961],[120.12426,23.52002],[120.14169,23.53795],[120.14087,23.59526],[120.14361,23.63804],[120.15423,23.67221],[120.17081,23.6989],[120.17555,23.73383],[120.18442,23.76439],[120.18922,23.77481],[120.21266,23.80317],[120.21949,23.81794],[120.24073,23.83283],[120.24675,23.84341],[120.25294,23.86807],[120.2575,23.8784],[120.28443,23.902],[120.29225,23.91193],[120.3025,23.93887],[120.31813,23.96581],[120.3401,24.0218],[120.35035,24.03807],[120.36183,24.04853],[120.37957,24.05801],[120.38551,24.06306],[120.38787,24.07331],[120.39235,24.08291],[120.41188,24.09691],[120.41635,24.10749],[120.41863,24.14252],[120.42205,24.15217],[120.4323,24.16108],[120.47779,24.20966],[120.48064,24.2211],[120.49317,24.23066],[120.50164,24.25287],[120.51197,24.29556],[120.54737,24.35155],[120.55812,24.38589],[120.57081,24.40493],[120.64894,24.48387],[120.65602,24.49787],[120.65935,24.5207],[120.66773,24.54002],[120.6902,24.57612],[120.6963,24.59772],[120.70216,24.607],[120.72787,24.61392],[120.73512,24.62124],[120.74464,24.63882],[120.76059,24.65396],[120.77809,24.66572],[120.79933,24.6708],[120.82667,24.66608],[120.82301,24.68504],[120.83334,24.69782],[120.8475,24.71084],[120.85768,24.73701],[120.87135,24.74323],[120.87696,24.76191],[120.89812,24.80117],[120.90919,24.81688],[120.90903,24.82274],[120.90235,24.83682],[120.90805,24.84707],[120.92058,24.8614],[120.92604,24.88471],[120.93393,24.8946],[120.94402,24.90205],[120.95362,24.90501],[120.95281,24.90925],[120.95533,24.91938],[120.96388,24.94042],[121.00554,25.00068],[121.03199,25.02924],[121.05934,25.05024],[121.12941,25.07111],[121.14576,25.08039],[121.15984,25.09162],[121.17693,25.10004],[121.21095,25.11115],[121.33025,25.13068],[121.34832,25.13711],[121.37175,25.15477],[121.37875,25.15795],[121.39894,25.15949],[121.41228,25.15546],[121.41944,25.14639],[121.42408,25.13691],[121.43002,25.13158],[121.44321,25.13642],[121.42945,25.15599],[121.4074,25.17621],[121.39576,25.18281],[121.3991,25.19213],[121.41627,25.2036],[121.42416,25.22134],[121.43629,25.24144],[121.46127,25.26191],[121.49781,25.27875],[121.53972,25.28742],[121.58082,25.28303],[121.59791,25.27143],[121.636,25.22281],[121.64967,25.19709],[121.6644,25.20136],[121.67612,25.20258],[121.68442,25.20051],[121.68393,25.19746],[121.67612,25.18692],[121.67644,25.17939],[121.68051,25.17504],[121.69459,25.16608],[121.70387,25.15689],[121.71852,25.152],[121.89283,25.11367],[121.90512,25.10659],[121.89894,25.09398],[121.89723,25.08389],[121.89991,25.06599],[121.90919,25.03604],[121.9192,25.02204],[121.92726,25.0187],[121.95387,25.02237],[121.96754,25.02106],[121.98357,25.01716],[121.99773,25.01069],[122.00538,25.0019],[121.92677,24.96662],[121.90577,24.9501]]],[[[121.56349,22.04462],[121.58106,22.03221],[121.58888,22.02351],[121.58766,22.01386],[121.56691,22.00776],[121.53891,22.01822],[121.51319,22.03506],[121.4983,22.04804],[121.50278,22.05756],[121.50262,22.06656],[121.49879,22.07477],[121.49204,22.08218],[121.56658,22.08218],[121.56658,22.07599],[121.56137,22.06367],[121.56007,22.05353],[121.56349,22.04462]]],[[[118.39422,24.52269],[118.4226,24.52797],[118.44959,24.50553],[118.46735,24.46183],[118.46336,24.43152],[118.44879,24.41551],[118.4229,24.4303],[118.40362,24.43601],[118.37286,24.4332],[118.34576,24.41758],[118.33099,24.39648],[118.31822,24.39054],[118.30323,24.39704],[118.29298,24.39765],[118.28395,24.40127],[118.27955,24.40843],[118.292,24.42499],[118.29273,24.43207],[118.28956,24.45563],[118.28297,24.47004],[118.28207,24.47736],[118.31666,24.49335],[118.35481,24.47056],[118.38141,24.47351],[118.38539,24.50382],[118.39422,24.52269]]],[[[119.64796,23.60346],[119.66578,23.57331],[119.67066,23.55923],[119.66847,23.54881],[119.66114,23.55573],[119.65138,23.56159],[119.64064,23.56354],[119.62566,23.55581],[119.615,23.55463],[119.60971,23.55195],[119.60011,23.52839],[119.58237,23.51895],[119.56397,23.51659],[119.54558,23.5231],[119.52711,23.53998],[119.53199,23.54881],[119.54217,23.53758],[119.55738,23.53461],[119.57374,23.53876],[119.58717,23.54881],[119.57057,23.55606],[119.55925,23.56932],[119.55714,23.56501],[119.55242,23.56249],[119.55242,23.5904],[119.56723,23.58853],[119.59547,23.60472],[119.61313,23.6103],[119.60629,23.5904],[119.62159,23.59467],[119.64186,23.59406],[119.64796,23.60346]]],[[[119.47706,23.60346],[119.48943,23.63963],[119.50131,23.65038],[119.51735,23.6376],[119.50782,23.63239],[119.50123,23.61982],[119.49073,23.5904],[119.49025,23.58246],[119.49285,23.57575],[119.49171,23.57103],[119.46608,23.56806],[119.45655,23.56249],[119.44239,23.56249],[119.44483,23.56977],[119.44947,23.57412],[119.46616,23.57681],[119.47446,23.57974],[119.47682,23.58682],[119.47706,23.60346]]],[[[119.5726,23.62767],[119.56935,23.63972],[119.56178,23.64985],[119.54526,23.66494],[119.56642,23.67919],[119.57887,23.67308],[119.59108,23.66352],[119.5962,23.65327],[119.58692,23.64501],[119.59067,23.6328],[119.58513,23.62474],[119.57683,23.6225],[119.5726,23.62767]]],[[[121.4607,22.659],[121.45541,22.67381],[121.45753,22.67593],[121.49578,22.67853],[121.50001,22.67626],[121.50278,22.6695],[121.49977,22.65567],[121.49496,22.64419],[121.4961,22.63467],[121.48316,22.63613],[121.48146,22.63809],[121.47145,22.64179],[121.4607,22.659]]],[[[120.02186,23.45164],[120.07493,23.50082],[120.11525,23.54791],[120.12477,23.55045],[120.0903,23.4994],[120.04791,23.45809],[120.01954,23.43838],[120.00766,23.43259],[120.02186,23.45164]]]],\"MO\":[[[[113.5586,22.16303],[113.57407,22.16075],[113.57407,22.15611],[113.57179,22.1529],[113.56788,22.15058],[113.56324,22.15058],[113.56023,22.14155],[113.57252,22.13732],[113.57797,22.13963],[113.57488,22.13504],[113.57976,22.1319],[113.58595,22.13194],[113.58749,22.12486],[113.58359,22.12332],[113.57748,22.12665],[113.57797,22.12092],[113.57407,22.11937],[113.56674,22.12336],[113.5652,22.11262],[113.56633,22.10769],[113.56324,22.10692],[113.55738,22.11115],[113.55152,22.10615],[113.5468,22.10537],[113.54371,22.10769],[113.54111,22.11327],[113.54526,22.11424],[113.54233,22.14073],[113.53411,22.14879],[113.53224,22.15262],[113.53443,22.1612],[113.55291,22.16584],[113.5586,22.16303]]],[[[113.53647,22.21707],[113.53891,22.22077],[113.5538,22.21149],[113.55681,22.20356],[113.55152,22.20441],[113.54843,22.20205],[113.54916,22.19973],[113.55071,22.19896],[113.55714,22.20246],[113.5538,22.19587],[113.55226,22.19741],[113.55071,22.19587],[113.55152,22.18956],[113.53989,22.18264],[113.53598,22.18264],[113.53289,22.1787],[113.5289,22.1787],[113.52475,22.17373],[113.52019,22.18488],[113.52215,22.1905],[113.53525,22.20755],[113.5337,22.21076],[113.53207,22.21076],[113.53289,22.20917],[113.52735,22.20755],[113.52434,22.21255],[113.5289,22.21463],[113.53525,22.21463],[113.53647,22.21707]]]]}"); -} diff --git a/src/function/createProviderEnvironment.mjs b/src/function/createProviderEnvironment.mjs index 75e37eb1..319865a8 100644 --- a/src/function/createProviderEnvironment.mjs +++ b/src/function/createProviderEnvironment.mjs @@ -2,19 +2,29 @@ import ColorfulClouds from "../class/ColorfulClouds.mjs"; import QWeather from "../class/QWeather.mjs"; import WAQI from "../class/WAQI.mjs"; -export default function createProviderEnvironment(parameters, settings, requestCache) { - let colorfulClouds, qWeather, waqi; +import { weatherAlertParameters } from "./weatherAlertLanguage.mjs"; +export default function createProviderEnvironment(parameters, settings, transport) { + let colorfulClouds, qWeather, waqi, weatherAlertsColorfulClouds, weatherAlertsQWeather; + const alertParameters = weatherAlertParameters(parameters, settings); return { country: parameters.country, - requestCache, + transport, get colorfulClouds() { - return (colorfulClouds ??= new ColorfulClouds(parameters, settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", requestCache)); + return (colorfulClouds ??= new ColorfulClouds(parameters, settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", transport)); }, get qWeather() { - return (qWeather ??= new QWeather(parameters, settings.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", settings.API?.QWeather?.Host, requestCache)); + return (qWeather ??= new QWeather(parameters, settings.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", settings.API?.QWeather?.Host, transport)); + }, + get weatherAlertsColorfulClouds() { + if (alertParameters === parameters) return this.colorfulClouds; + return (weatherAlertsColorfulClouds ??= new ColorfulClouds(alertParameters, settings.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", transport)); + }, + get weatherAlertsQWeather() { + if (alertParameters === parameters) return this.qWeather; + return (weatherAlertsQWeather ??= new QWeather(alertParameters, settings.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", settings.API?.QWeather?.Host, transport)); }, get waqi() { - return (waqi ??= new WAQI(parameters, settings.API?.WAQI?.Token, requestCache)); + return (waqi ??= new WAQI(parameters, settings.API?.WAQI?.Token, transport)); }, }; } diff --git a/src/function/database.mjs b/src/function/database.mjs index e4917ef8..f4483a90 100644 --- a/src/function/database.mjs +++ b/src/function/database.mjs @@ -1,14 +1,11 @@ -// Modified by hhhoratioxu, 2026-10-19: default short-lived provider response cache. +// WeatherKit default settings. export default { WeatherKit: { Settings: { - ProviderFallback: { Enabled: false }, // 只有插件会修改的产品允许被配置关闭;其余 Apple 数据集必须始终透传。 DataSets: ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"], - RequestCache: { TTL: 60, ForecastTTL: 180, MinutelyTTL: 15, AlertsTTL: 15, BudgetMs: 8000, CooldownSeconds: 30, Diagnostics: false }, - Region: { Fallback: "Auto" }, Weather: { Replace: ["CN"], Provider: "ColorfulClouds" }, - WeatherAlerts: { Provider: "QWeatherWeb" }, + WeatherAlerts: { Provider: "QWeatherWeb", Language: "Auto" }, NextHour: { Provider: "ColorfulClouds" }, AirQuality: { Current: { Pollutants: { Provider: "ColorfulClouds", Units: { Replace: [], Mode: "Scale" } }, Index: { Replace: ["HJ6332012"], Provider: "Calculate", ForceCNPrimaryPollutants: true } }, diff --git a/src/function/fetchProvider.mjs b/src/function/fetchProvider.mjs index 22ec4614..eac73b89 100644 --- a/src/function/fetchProvider.mjs +++ b/src/function/fetchProvider.mjs @@ -1,19 +1,23 @@ // Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 import { fetch as sharedFetch } from "@nsnanocat/util"; +import { hasNativeProviderRuntime, isSurgeRuntime } from "./nativeProviderRuntime.mjs"; -// Loon's native HTTP timeout is milliseconds. The shared utility rounds short -// millisecond values through seconds, so keep this path native and exact. +// Internal budgets are milliseconds. Loon uses milliseconds and Surge seconds; +// convert only at this native boundary, without the shared utility's rounding. export default function fetchProvider(request, options = {}) { - if (typeof $loon !== "string") return sharedFetch(request, options); + if (!hasNativeProviderRuntime()) return sharedFetch(request, options); const input = typeof request === "string" ? { ...options, url: request } : { ...options, ...request }; const method = String(input.method ?? (input.body !== undefined || input.bodyBytes !== undefined ? "POST" : "GET")).toLowerCase(); const timeout = Number(input.timeout); - const outgoing = { url: input.url, timeout: Number.isFinite(timeout) && timeout > 0 ? Math.max(1, Math.min(5000, Math.floor(timeout))) : 5000 }; + const milliseconds = Number.isFinite(timeout) && timeout > 0 ? Math.max(1, Math.min(5000, Math.floor(timeout))) : 5000; + const surge = isSurgeRuntime() && typeof globalThis.$loon !== "string"; + const outgoing = { url: input.url, timeout: surge ? milliseconds / 1000 : milliseconds }; if (input.headers) { outgoing.headers = { ...input.headers }; for (const key of Object.keys(outgoing.headers)) if (["host", ":authority", "content-length"].includes(key.toLowerCase())) delete outgoing.headers[key]; } - for (const key of ["body", "body-base64", "node", "binary-mode", "auto-redirect", "auto-cookie", "insecure", "alpn"]) if (input[key] !== undefined) outgoing[key] = input[key]; + const fields = surge ? ["body", "policy", "binary-mode", "auto-redirect", "auto-cookie", "insecure"] : ["body", "body-base64", "node", "binary-mode", "auto-redirect", "auto-cookie", "insecure", "alpn"]; + for (const key of fields) if (input[key] !== undefined) outgoing[key] = input[key]; if (outgoing.body === undefined && input.bodyBytes !== undefined) outgoing.body = input.bodyBytes instanceof ArrayBuffer ? new Uint8Array(input.bodyBytes) : input.bodyBytes; return new Promise((resolve, reject) => { if (!["get", "post", "head", "delete", "put", "options", "patch"].includes(method) || typeof $httpClient?.[method] !== "function") { diff --git a/src/function/getWeatherAlerts.mjs b/src/function/getWeatherAlerts.mjs new file mode 100644 index 00000000..b57a3af1 --- /dev/null +++ b/src/function/getWeatherAlerts.mjs @@ -0,0 +1,104 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Shared by native entries and both general request processors. +import { Console } from "@nsnanocat/util"; +import ColorfulClouds from "../class/ColorfulClouds.mjs"; +import QWeather from "../class/QWeather.mjs"; +import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import { activeProviderAlerts, providerProductAvailable } from "./providerRecovery.mjs"; +import { weatherAlertLanguage, weatherAlertParameters } from "./weatherAlertLanguage.mjs"; + +/** + * 获取天气预警响应 + * @param {string | undefined} identifier - 预警标识 + * @param {ReturnType} parameters - WeatherKit 请求参数 + * @param {import('../types').Settings} Settings - 设置对象 + * @param {Record} headers - 请求头 + * @param {import("../class/ProviderTransport.mjs").default} runtime - 当前请求传输校验 + * @returns {Promise} 天气预警响应,undefined 保留 Apple 请求 + */ +export async function GetWeatherAlerts(identifier, parameters, Settings, headers, runtime) { + const preferredLanguage = weatherAlertLanguage(Settings); + parameters = weatherAlertParameters(parameters, Settings); + // Providers can return original or mixed-language notices despite a language request. + // Do not falsely label those messages as a verified translation of the selected language. + const messageLanguage = preferredLanguage ? "und" : parameters?.language; + const warningHeaders = preferredLanguage ? { ...headers, "Accept-Language": preferredLanguage, "accept-language": preferredLanguage } : headers; + let body; + try { + switch (true) { + case /^[^&#]*-[0-9]{9}$/.test(identifier): { + Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); + const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, warningHeaders, runtime); + if (!providerProductAvailable(source) || !Array.isArray(source.alerts)) { + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), + identifier, + language: messageLanguage, + countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", + }, + ); + break; + } + case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { + if (parameters.latitude === undefined || parameters.longitude === undefined) return undefined; + switch (Settings?.WeatherAlerts?.Provider) { + case "ColorfulClouds": { + Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); + const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", runtime); + const source = await colorfulClouds.WeatherAlert(); + if (!providerProductAvailable(source) || !Array.isArray(source.alerts)) { + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: messageLanguage, + countryCode: parameters.country, + }, + ); + break; + } + case "QWeatherWeb": + case "QWeather": { + const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host, runtime); + const source = await qWeather.WeatherAlert(); + if (!providerProductAvailable(source) || !Array.isArray(source.alerts)) { + return undefined; + } + body = WeatherAlerts.Build( + { ...source, alerts: activeProviderAlerts(source?.alerts) }, + { + attributionUrl: "https://www.12379.cn/", + identifier: `${parameters.latitude},${parameters.longitude}`, + language: messageLanguage, + countryCode: parameters.country, + }, + ); + break; + } + } + break; + } + } + } catch { + return undefined; + } + if (!Array.isArray(body)) return undefined; + Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); + return { + status: 200, + statusCode: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": preferredLanguage ? "no-store" : "max-age=0", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }; +} diff --git a/src/function/mergeWeatherAlertsProduct.mjs b/src/function/mergeWeatherAlertsProduct.mjs new file mode 100644 index 00000000..1d3dac30 --- /dev/null +++ b/src/function/mergeWeatherAlertsProduct.mjs @@ -0,0 +1,19 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import providerNameToLogo from "./providerNameToLogo.mjs"; +import { activeProviderAlerts, cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "./providerRecovery.mjs"; + +// Provider warnings enrich existing Apple events; they cannot manufacture native IDs. +// Keep the original availability and entity when no warning fields were installed. +export default function mergeWeatherAlertsProduct(original, supplement) { + if (!providerProductAvailable(supplement) || !Array.isArray(original?.alerts) || !original.alerts.length) return original; + const active = activeProviderAlerts(supplement.alerts); + if (!active.length) return original; + const result = cloneWeatherProduct(original); + result.alerts = WeatherAlerts.mergeAlerts(result.alerts, active); + if (weatherProductsEqual(result.alerts, original.alerts)) return original; + result.metadata = { ...original.metadata, ...supplement.metadata }; + result.detailsUrl = supplement.detailsUrl ?? original.detailsUrl; + result.metadata.providerLogo ||= providerNameToLogo(result.metadata.providerName); + return result; +} diff --git a/src/function/nativeProviderRuntime.mjs b/src/function/nativeProviderRuntime.mjs new file mode 100644 index 00000000..6d53259e --- /dev/null +++ b/src/function/nativeProviderRuntime.mjs @@ -0,0 +1,3 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +export const isSurgeRuntime = () => typeof globalThis.$environment?.["surge-version"] === "string"; +export const hasNativeProviderRuntime = () => typeof globalThis.$loon === "string" || isSurgeRuntime(); diff --git a/src/function/parseWeatherKitURL.mjs b/src/function/parseWeatherKitURL.mjs index 38ffb185..e592694a 100644 --- a/src/function/parseWeatherKitURL.mjs +++ b/src/function/parseWeatherKitURL.mjs @@ -1,8 +1,7 @@ -// Modified by hhhoratioxu, 2026-10-19: optional conservative region resolution. -import resolveWeatherRegion from "./resolveWeatherRegion.mjs"; +// Parse only the request country and locale; no stored or geographic inference. import { Console } from "@nsnanocat/util"; -export default function parseWeatherKitURL(url = new URL($request.url), regionOptions) { +export default function parseWeatherKitURL(url = new URL($request.url)) { Console.info("☑️ parseWeatherKitURL"); const result = { version: undefined, @@ -55,14 +54,6 @@ export default function parseWeatherKitURL(url = new URL($request.url), regionOp break; } } - if (regionOptions && result.latitude !== undefined && result.longitude !== undefined) - result.country = resolveWeatherRegion({ - latitude: result.latitude, - longitude: result.longitude, - explicitCountry: url.searchParams.get("country"), - localeCountry: url.pathname === "/api/v1/weatherAlerts" ? (/^[A-Z]{2}$/i.test(result.language?.split("-").at(-1) ?? "") && result.language?.includes("-") ? result.language.split("-").at(-1) : undefined) : result.country, - ...regionOptions, - }); Console.info("✅ parseWeatherKitURL", `🟧version: ${result.version} 🟧language: ${result.language} 🟧country: ${result.country}`, "coordinates: redacted"); return result; } diff --git a/src/function/providerRecovery.mjs b/src/function/providerRecovery.mjs index 49cdd177..a34432b1 100644 --- a/src/function/providerRecovery.mjs +++ b/src/function/providerRecovery.mjs @@ -1,4 +1,5 @@ // Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import { knownWeatherAlertExpiry } from "./weatherAlertExpiry.mjs"; export function providerProductAvailable(value) { return Boolean(value && typeof value === "object" && value.metadata && value.metadata.temporarilyUnavailable !== true); } @@ -19,8 +20,8 @@ export function cloneWeatherProduct(value, seen = new Map(), depth = 0) { export function activeProviderAlerts(alerts, now = Date.now()) { return (Array.isArray(alerts) ? alerts : []).filter(alert => { - const value = alert?.expireTime ?? alert?.eventEndTime; - if (!value || value === "9999-12-31T23:59:59Z") return true; + const value = knownWeatherAlertExpiry(alert?.expireTime) ?? knownWeatherAlertExpiry(alert?.eventEndTime); + if (!value) return true; const timestamp = typeof value === "number" ? (value < 1e12 ? value * 1000 : value) : Date.parse(value); return Number.isFinite(timestamp) && timestamp > now; }); diff --git a/src/function/recoverConfiguredProduct.mjs b/src/function/recoverConfiguredProduct.mjs deleted file mode 100644 index ae0d3f2c..00000000 --- a/src/function/recoverConfiguredProduct.mjs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import database from "./database.mjs"; -import { providerProductAvailable } from "./providerRecovery.mjs"; -const sharedTokens = Object.freeze({ QWeather: database.WeatherKit.Settings.API.QWeather.Token, ColorfulClouds: database.WeatherKit.Settings.API.ColorfulClouds.Token }); -export default async function recoverConfiguredProduct(_product, primary, original, selected, loaders, settings, runtime) { - const enabled = settings?.ProviderFallback?.Enabled; - if (enabled !== true && enabled !== "true") return primary; - if (providerProductAvailable(primary) || providerProductAvailable(original) || !["ColorfulClouds", "QWeather", "QWeatherWeb"].includes(selected)) return primary; - const backup = selected === "ColorfulClouds" ? "QWeather" : "ColorfulClouds"; - const token = settings?.API?.[backup]?.Token; - // Both explicit opt-in and a dedicated credential are required. A token used - // for a different product is not consent to replace the selected provider. - if (typeof token !== "string" || !token.trim() || token === sharedTokens[backup] || typeof loaders[backup] !== "function") return primary; - try { - const value = await loaders[backup](); - const available = providerProductAvailable(value); - runtime?.noteBackup(available); - return available ? value : primary; - } catch { - runtime?.noteBackup(false); - return primary; - } -} diff --git a/src/function/resolveWeatherRegion.mjs b/src/function/resolveWeatherRegion.mjs deleted file mode 100644 index 226df696..00000000 --- a/src/function/resolveWeatherRegion.mjs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 - -import { fingerprint } from "../class/ProviderRequestCache.mjs"; -import getProviderRegions from "../data/providerRegions.mjs"; -export const REGION_HINT_KEY = "WeatherKit.RegionHints.v1"; -let polygonBounds; -function getPolygons() { - return (polygonBounds ??= Object.entries(getProviderRegions()).flatMap(([country, polygons]) => polygons.map(rings => ({ country, rings, bounds: rings[0].reduce((box, [x, y]) => [Math.min(box[0], x), Math.min(box[1], y), Math.max(box[2], x), Math.max(box[3], y)], [180, 90, -180, -90]) })))); -} -function distanceSquared(x, y, a, b) { - const dx = b[0] - a[0], - dy = b[1] - a[1], - length = dx * dx + dy * dy; - const t = length ? Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / length)) : 0; - return (x - a[0] - t * dx) ** 2 + (y - a[1] - t * dy) ** 2; -} -function ringContains(ring, x, y) { - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const a = ring[i], - b = ring[j]; - if (a[1] > y !== b[1] > y && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside; - } - return inside; -} -export function countryFromCoordinates(latitude, longitude) { - if (latitude === undefined || longitude === undefined || latitude === "" || longitude === "") return undefined; - const y = Number(latitude), - x = Number(longitude); - if (!Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > 180 || Math.abs(y) > 90) return undefined; - for (const { country, rings, bounds: b } of getPolygons()) { - if (x < b[0] || x > b[2] || y < b[1] || y > b[3] || !ringContains(rings[0], x, y) || rings.slice(1).some(r => ringContains(r, x, y))) continue; - // A generous exclusion exceeds simplification error; uncertain edges use other hints. - if (rings.some(r => r.some((a, i) => i > 0 && distanceSquared(x, y, a, r[i - 1]) <= (country === "CN" ? 0.05 : 0.01) ** 2))) return undefined; - return country; - } - return undefined; -} -export default function resolveWeatherRegion({ latitude, longitude, explicitCountry, localeCountry, fallback = "Auto", now = Date.now, store } = {}) { - const normalize = value => (typeof value === "string" && /^[A-Z]{2}$/i.test(value.trim()) ? value.trim().toUpperCase() : undefined); - const explicit = normalize(explicitCountry), - locale = normalize(localeCountry); - const valid = latitude !== undefined && longitude !== undefined && latitude !== "" && longitude !== "" && Number.isFinite(Number(latitude)) && Number.isFinite(Number(longitude)) && Math.abs(Number(latitude)) <= 90 && Math.abs(Number(longitude)) <= 180; - if (!valid) return explicit ?? locale; - const key = fingerprint(JSON.stringify([Number(latitude), Number(longitude)])), - timestamp = now(); - if (!store && typeof $loon === "string" && typeof $persistentStore !== "undefined") store = { read: () => $persistentStore.read(REGION_HINT_KEY), write: value => $persistentStore.write(value, REGION_HINT_KEY) }; - let entries = []; - try { - const raw = store?.read(); - if (typeof raw === "string" && raw.length <= 8192) { - const parsed = JSON.parse(raw); - if (parsed?.version === 1 && Array.isArray(parsed.entries) && parsed.entries.length <= 32) - entries = parsed.entries.filter(e => /^[a-f0-9]{64}$/.test(e.key) && normalize(e.country) && Number.isFinite(e.at) && timestamp >= e.at && timestamp - e.at < 7 * 86400000).map(e => ({ key: e.key, country: normalize(e.country), at: e.at })); - } - } catch {} - if (explicit) { - // Re-confirm stable coordinates at most hourly, not on every weather refresh. - if (entries.some(entry => entry.key === key && entry.country === explicit && timestamp - entry.at < 3600000)) return explicit; - try { - store?.write(JSON.stringify({ version: 1, entries: [{ key, country: explicit, at: timestamp }, ...entries.filter(e => e.key !== key)].slice(0, 32) })); - } catch {} - return explicit; - } - if (fallback === "Locale") return locale; - if (["CN", "HK", "MO", "TW"].includes(fallback)) return fallback; - return entries.find(e => e.key === key)?.country ?? countryFromCoordinates(latitude, longitude) ?? locale; -} diff --git a/src/function/rewriteWeatherResponse.mjs b/src/function/rewriteWeatherResponse.mjs new file mode 100644 index 00000000..90d36995 --- /dev/null +++ b/src/function/rewriteWeatherResponse.mjs @@ -0,0 +1,11 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Validators describe Apple's original entity, not the newly encoded body. +export default function rewriteWeatherResponse(response, body) { + const headers = { ...response.headers }; + for (const name of Object.keys(headers)) { + if (["etag", "last-modified", "content-md5", "digest", "content-length", "content-encoding", "transfer-encoding", "cache-control", "expires"].includes(name.toLowerCase())) delete headers[name]; + } + headers["Cache-Control"] = "no-store"; + const { bodyBytes: _originalBytes, ...result } = response; + return { ...result, headers, body }; +} diff --git a/src/function/serializeProviderCache.mjs b/src/function/serializeProviderCache.mjs deleted file mode 100644 index 3dbb6714..00000000 --- a/src/function/serializeProviderCache.mjs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -const EMPTY_CACHE_LENGTH = '{"version":1,"entries":[]}'.length; - -// Count JSON string content in UTF-16 units without allocating an escaped copy. -// JSON.stringify escapes lone surrogates, but preserves valid surrogate pairs. -function escapedLength(value, maximum) { - let length = value.length; - for (let index = 0; index < value.length && length <= maximum; index++) { - const code = value.charCodeAt(index); - if (code === 0x22 || code === 0x5c) length++; - else if (code < 0x20) length += code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 1 : 5; - else if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (next >= 0xdc00 && next <= 0xdfff) index++; - else length += 5; - } else if (code >= 0xdc00 && code <= 0xdfff) length += 5; - } - return length; -} - -// Entries are the cache's already validated, ordered plain records. Keep the -// same prefix as serialize-and-pop, without materializing oversized snapshots. -export default function serializeProviderCache(entries, maximum) { - const metadataLengths = entries.map(entry => JSON.stringify({ ...entry, body: "" }).length); - const upperBound = entries.reduce((length, entry, index) => length + metadataLengths[index] + entry.body.length * 6 + (index ? 1 : 0), EMPTY_CACHE_LENGTH); - if (upperBound > maximum) { - let length = EMPTY_CACHE_LENGTH, - count = 0; - for (const entry of entries) { - const metadataLength = metadataLengths[count] + (count ? 1 : 0), - remaining = maximum - length - metadataLength; - if (entry.body.length > remaining) break; - const bodyLength = escapedLength(entry.body, remaining); - if (bodyLength > remaining) break; - length += metadataLength + bodyLength; - count++; - } - entries.length = count; - } - return JSON.stringify({ version: 1, entries }); -} diff --git a/src/function/weatherAlertExpiry.mjs b/src/function/weatherAlertExpiry.mjs new file mode 100644 index 00000000..c6dd987b --- /dev/null +++ b/src/function/weatherAlertExpiry.mjs @@ -0,0 +1,11 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// Apple identifies an unknown expiry by its local calendar year. UTC midnight +// stays in 9999 across UTC-12 through UTC+14; 23:59:59 crosses into year 10000. +export const UNKNOWN_WEATHER_ALERT_EXPIRY = "9999-12-31T00:00:00Z"; +const legacyUnknownExpiry = "9999-12-31T23:59:59Z"; + +// Keep genuine values verbatim, including offsets and fractional seconds. +// Both reserved strings represent absence, never a real event end. +export function knownWeatherAlertExpiry(value) { + return value == null || value === legacyUnknownExpiry || value === UNKNOWN_WEATHER_ALERT_EXPIRY ? undefined : value; +} diff --git a/src/function/weatherAlertLanguage.mjs b/src/function/weatherAlertLanguage.mjs new file mode 100644 index 00000000..0a62c456 --- /dev/null +++ b/src/function/weatherAlertLanguage.mjs @@ -0,0 +1,24 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +// A source-language preference, never a translation service or a claim about returned text. +export function weatherAlertLanguage(settings) { + const value = settings?.WeatherAlerts?.Language; + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase().replaceAll("_", "-")) { + case "zh": + case "zh-cn": + case "zh-hans": + case "zh-hans-cn": + return "zh-Hans"; + case "en": + case "en-us": + case "en-gb": + return "en"; + default: + return undefined; + } +} + +export function weatherAlertParameters(parameters, settings) { + const language = weatherAlertLanguage(settings); + return language ? { ...parameters, language, weatherKitLanguage: language } : parameters; +} diff --git a/src/loon/request.js b/src/loon/request.js index b451326c..dba5c1d7 100644 --- a/src/loon/request.js +++ b/src/loon/request.js @@ -1,6 +1,6 @@ // Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import ProviderRequestCache from "../class/ProviderRequestCache.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import setENV from "../function/setENV.mjs"; @@ -11,14 +11,11 @@ import { GetWeatherAlerts } from "../process/Request.mjs"; identifier = url.searchParams.get("ids")?.trim(); if (url.hostname !== "weatherkit.apple.com" || url.pathname !== "/api/v1/weatherAlerts" || ($request.method && !["GET", "HEAD", "OPTIONS"].includes($request.method)) || !identifier) return $request; const { Settings } = setENV("iRingo", "WeatherKit", database); - const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); - const runtime = ProviderRequestCache.fromSettings(Settings); - try { - const response = await GetWeatherAlerts(identifier, parameters, Settings, $request.headers, runtime); - return response ? { response } : $request; - } finally { - runtime.report("request"); - } + const parameters = parseWeatherKitURL(url); + const runtime = new ProviderTransport(); + + const response = await GetWeatherAlerts(identifier, parameters, Settings, $request.headers, runtime); + return response ? { response } : $request; })().then( value => $done(value), () => $done($request), diff --git a/src/process/Request.dev.mjs b/src/process/Request.dev.mjs index be028c28..33824568 100644 --- a/src/process/Request.dev.mjs +++ b/src/process/Request.dev.mjs @@ -1,14 +1,11 @@ -// Modified by hhhoratioxu, 2026-10-19: honor cache TTL for provider JSON alert requests. +// WeatherKit provider processing. import { Lodash as _, Console, Storage } from "@nsnanocat/util"; import AirQualityScale from "../class/AirQualityScale.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; -import QWeather from "../class/QWeather.mjs"; -import { activeProviderAlerts } from "../function/providerRecovery.mjs"; -import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import setENV from "../function/setENV.mjs"; +import { GetWeatherAlerts } from "../function/getWeatherAlerts.mjs"; /***************** Processing *****************/ export async function Request($request) { // 构造回复数据 @@ -24,125 +21,127 @@ export async function Request($request) { * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); - const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); - const runtime = ProviderRequestCache.fromSettings(Settings); - try { - // 方法判断 - switch ($request.method) { - case "POST": - case "PUT": - case "PATCH": - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "DELETE": - // 格式判断 - 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": - //body = M3U8.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = M3U8.stringify(body); - break; - case "text/xml": - case "text/html": - case "text/plist": - case "application/xml": - case "application/plist": - case "application/x-plist": - //body = XML.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = XML.stringify(body); - break; - case "text/vtt": - case "application/vtt": - //body = VTT.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = VTT.stringify(body); - break; - case "text/json": - case "application/json": - //body = JSON.parse($request.body ?? "{}"); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = JSON.stringify(body); - break; - case "application/protobuf": - case "application/x-protobuf": - case "application/vnd.google.protobuf": - case "application/grpc": - case "application/grpc+proto": - case "application/octet-stream": { - //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); - let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); - //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); - // 写入二进制数据 - $request.body = rawBody; - break; - } + const parameters = parseWeatherKitURL(url); + const runtime = new ProviderTransport(); + + // 方法判断 + switch ($request.method) { + case "POST": + case "PUT": + case "PATCH": + // biome-ignore lint/suspicious/noFallthroughSwitchClause: + case "DELETE": + // 格式判断 + 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": + //body = M3U8.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = M3U8.stringify(body); + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + //body = XML.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = XML.stringify(body); + break; + case "text/vtt": + case "application/vtt": + //body = VTT.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = VTT.stringify(body); + break; + case "text/json": + case "application/json": + //body = JSON.parse($request.body ?? "{}"); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = JSON.stringify(body); + break; + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + case "application/grpc": + case "application/grpc+proto": + case "application/octet-stream": { + //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); + let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); + //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); + // 写入二进制数据 + $request.body = rawBody; + break; } - //break; // 不中断,继续处理URL - case "GET": - case "HEAD": - case "OPTIONS": - default: + } + //break; // 不中断,继续处理URL + case "GET": + case "HEAD": + case "OPTIONS": + default: + if (url.pathname !== "/api/v1/weatherAlerts") { delete $request?.headers?.["If-None-Match"]; delete $request?.headers?.["if-none-match"]; - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - case "/api/v1/weatherAlerts": { - $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); - break; - } - default: - switch (true) { - case url.pathname.startsWith("/api/v1/airQualityScale/"): { - const { version, language } = parameters; - const scaleName = parameters.scale?.replace(/\.\d+$/, ""); - if (version && language && scaleName) { - url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; - switch (url.pathname) { - case `/api/v1/airQualityScale/${language}/HK.AQHI`: - case `/api/v1/airQualityScale/${language}/CN.AQHI`: - $response = new AirQualityScale().Build(language, scaleName); - break; - } - } - break; - } - case url.pathname.startsWith("/api/v2/weather/"): { - // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 - switch (true) { - case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - if (url.searchParams.has("country")) { - //if (url.searchParams.get("country") === "CN") url.searchParams.set("country", "TW"); - } else { - const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; - if (gcc) url.searchParams.set("country", gcc); - } + } + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + case "/api/v1/weatherAlerts": { + $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); + break; + } + default: + switch (true) { + case url.pathname.startsWith("/api/v1/airQualityScale/"): { + const { version, language } = parameters; + const scaleName = parameters.scale?.replace(/\.\d+$/, ""); + if (version && language && scaleName) { + url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; + switch (url.pathname) { + case `/api/v1/airQualityScale/${language}/HK.AQHI`: + case `/api/v1/airQualityScale/${language}/CN.AQHI`: + $response = new AirQualityScale().Build(language, scaleName); break; } - break; } + break; } - break; - } - break; - case "weather-map2.apple.com": { - // 路径判断 - switch (url.pathname) { - case "/v1/mapOverlay/precipitationRadarMap": - case "/v1/mapOverlay/precipitationForecastByFrameTime": { - /* + case url.pathname.startsWith("/api/v2/weather/"): { + // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 + switch (true) { + case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + if (url.searchParams.has("country")) { + //if (url.searchParams.get("country") === "CN") url.searchParams.set("country", "TW"); + } else { + const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; + if (gcc) url.searchParams.set("country", gcc); + } + break; + } + break; + } + } + break; + } + break; + case "weather-map2.apple.com": { + // 路径判断 + switch (url.pathname) { + case "/v1/mapOverlay/precipitationRadarMap": + case "/v1/mapOverlay/precipitationForecastByFrameTime": { + /* switch (true) { case $request.headers["User-Agent"]?.startsWith("Weather_macOS_Version"): case $request.headers["user-agent"]?.startsWith("Weather_macOS_Version"): @@ -157,112 +156,18 @@ export async function Request($request) { break; } */ - break; - } - } - break; - } - } - break; - case "CONNECT": - case "TRACE": - break; - } - $request.url = url.toString(); - - return { $request, $response }; - } finally { - runtime.report("request"); - } -} - -/** - * 获取天气预警响应 - * @param {string | undefined} identifier - 预警标识 - * @param {ReturnType} parameters - WeatherKit 请求参数 - * @param {import('../types').Settings} Settings - 设置对象 - * @param {Record} headers - 请求头 - * @returns {Promise} 天气预警响应 - */ -async function GetWeatherAlerts(identifier, parameters, Settings, headers, runtime) { - let body = []; - try { - switch (true) { - case /^[^&#]*-[0-9]{9}$/.test(identifier): { - Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); - const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers, runtime); - if (source?.metadata?.temporarilyUnavailable) { - runtime.noteFallback("request"); - return undefined; - } - body = WeatherAlerts.Build( - { ...source, alerts: activeProviderAlerts(source?.alerts) }, - { - attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), - identifier, - language: parameters.language, - countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", - }, - ); - break; - } - case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { - switch (Settings?.WeatherAlerts?.Provider) { - case "ColorfulClouds": { - Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); - const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", runtime); - const source = await colorfulClouds.WeatherAlert(); - if (source?.metadata?.temporarilyUnavailable) { - runtime.noteFallback("request"); - return undefined; + break; } - body = WeatherAlerts.Build( - { ...source, alerts: activeProviderAlerts(source?.alerts) }, - { - attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }, - ); - break; - } - case "QWeatherWeb": - case "QWeather": { - const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host, runtime); - const source = await qWeather.WeatherAlert(); - if (source?.metadata?.temporarilyUnavailable) { - runtime.noteFallback("request"); - return undefined; - } - body = WeatherAlerts.Build( - { ...source, alerts: activeProviderAlerts(source?.alerts) }, - { - attributionUrl: "https://www.12379.cn/", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }, - ); - break; } + break; } - break; } - } - } catch (error) { - runtime.noteFallback("request", providerErrorCode(error)); - return undefined; + break; + case "CONNECT": + case "TRACE": + break; } - Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); - return { - status: 200, - statusCode: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "max-age=0", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }; + $request.url = url.toString(); + + return { $request, $response }; } diff --git a/src/process/Request.mjs b/src/process/Request.mjs index 83fca286..f0a588e6 100644 --- a/src/process/Request.mjs +++ b/src/process/Request.mjs @@ -1,14 +1,12 @@ -// Modified by hhhoratioxu, 2026-10-19: honor cache TTL for provider JSON alert requests. +// WeatherKit provider processing. import { Lodash as _, Console, Storage } from "@nsnanocat/util"; import AirQualityScale from "../class/AirQualityScale.mjs"; -import ColorfulClouds from "../class/ColorfulClouds.mjs"; -import QWeather from "../class/QWeather.mjs"; -import { activeProviderAlerts } from "../function/providerRecovery.mjs"; -import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import setENV from "../function/setENV.mjs"; +import { GetWeatherAlerts } from "../function/getWeatherAlerts.mjs"; +export { GetWeatherAlerts }; /***************** Processing *****************/ export async function Request($request) { // 构造回复数据 @@ -24,219 +22,127 @@ export async function Request($request) { * @type {{Settings: import('./types').Settings}} */ const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); - const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); - const runtime = ProviderRequestCache.fromSettings(Settings); - try { - // 方法判断 - switch ($request.method) { - case "POST": - case "PUT": - case "PATCH": - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "DELETE": - // 格式判断 - 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": - //body = M3U8.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = M3U8.stringify(body); - break; - case "text/xml": - case "text/html": - case "text/plist": - case "application/xml": - case "application/plist": - case "application/x-plist": - //body = XML.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = XML.stringify(body); - break; - case "text/vtt": - case "application/vtt": - //body = VTT.parse($request.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = VTT.stringify(body); - break; - case "text/json": - case "application/json": - //body = JSON.parse($request.body ?? "{}"); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$request.body = JSON.stringify(body); - break; - case "application/protobuf": - case "application/x-protobuf": - case "application/vnd.google.protobuf": - case "application/grpc": - case "application/grpc+proto": - case "application/octet-stream": { - //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); - let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); - //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); - // 写入二进制数据 - $request.body = rawBody; - break; - } + const parameters = parseWeatherKitURL(url); + const runtime = new ProviderTransport(); + + // 方法判断 + switch ($request.method) { + case "POST": + case "PUT": + case "PATCH": + // biome-ignore lint/suspicious/noFallthroughSwitchClause: + case "DELETE": + // 格式判断 + 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": + //body = M3U8.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = M3U8.stringify(body); + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + //body = XML.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = XML.stringify(body); + break; + case "text/vtt": + case "application/vtt": + //body = VTT.parse($request.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = VTT.stringify(body); + break; + case "text/json": + case "application/json": + //body = JSON.parse($request.body ?? "{}"); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$request.body = JSON.stringify(body); + break; + case "application/protobuf": + case "application/x-protobuf": + case "application/vnd.google.protobuf": + case "application/grpc": + case "application/grpc+proto": + case "application/octet-stream": { + //Console.debug(`$request: ${JSON.stringify($request, null, 2)}`); + let rawBody = $request.bodyBytes ? new Uint8Array($request.bodyBytes) : ($request.body ?? new Uint8Array()); + //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody, null, 2)}`); + // 写入二进制数据 + $request.body = rawBody; + break; } - //break; // 不中断,继续处理URL - case "GET": - case "HEAD": - case "OPTIONS": - default: + } + //break; // 不中断,继续处理URL + case "GET": + case "HEAD": + case "OPTIONS": + default: + if (url.pathname !== "/api/v1/weatherAlerts") { delete $request?.headers?.["If-None-Match"]; delete $request?.headers?.["if-none-match"]; - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - case "/api/v1/weatherAlerts": { - $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); - break; - } - default: - switch (true) { - case url.pathname.startsWith("/api/v1/airQualityScale/"): { - const { version, language } = parameters; - const scaleName = parameters.scale?.replace(/\.\d+$/, ""); - if (version && language && scaleName) { - url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; - switch (url.pathname) { - case `/api/v1/airQualityScale/${language}/HK.AQHI`: - case `/api/v1/airQualityScale/${language}/CN.AQHI`: - $response = new AirQualityScale().Build(language, scaleName); - break; - } - } - break; - } - case url.pathname.startsWith("/api/v2/weather/"): { - // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 - switch (true) { - case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): - if (url.searchParams.has("country")) { - } else { - const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; - if (gcc) url.searchParams.set("country", gcc); - } + } + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + case "/api/v1/weatherAlerts": { + $response = await GetWeatherAlerts(url.searchParams.get("ids")?.trim(), parameters, Settings, $request.headers, runtime); + break; + } + default: + switch (true) { + case url.pathname.startsWith("/api/v1/airQualityScale/"): { + const { version, language } = parameters; + const scaleName = parameters.scale?.replace(/\.\d+$/, ""); + if (version && language && scaleName) { + url.pathname = `/api/${version}/airQualityScale/${language}/${scaleName}`; + switch (url.pathname) { + case `/api/v1/airQualityScale/${language}/HK.AQHI`: + case `/api/v1/airQualityScale/${language}/CN.AQHI`: + $response = new AirQualityScale().Build(language, scaleName); break; } - break; } + break; } - break; - } - break; - } - break; - case "CONNECT": - case "TRACE": - break; - } - $request.url = url.toString(); - - return { $request, $response }; - } finally { - runtime.report("request"); - } -} - -/** - * 获取天气预警响应 - * @param {string | undefined} identifier - 预警标识 - * @param {ReturnType} parameters - WeatherKit 请求参数 - * @param {import('../types').Settings} Settings - 设置对象 - * @param {Record} headers - 请求头 - * @returns {Promise} 天气预警响应 - */ -export async function GetWeatherAlerts(identifier, parameters, Settings, headers, runtime) { - let body = []; - try { - switch (true) { - case /^[^&#]*-[0-9]{9}$/.test(identifier): { - Console.info("☑️ QWeather.FetchWeatherAlertPage", `ids: ${identifier}`); - const source = await QWeather.FetchWeatherAlertPage(identifier, parameters.language, headers, runtime); - if (source?.metadata?.temporarilyUnavailable) { - runtime.noteFallback("request"); - return undefined; - } - body = WeatherAlerts.Build( - { ...source, alerts: activeProviderAlerts(source?.alerts) }, - { - attributionUrl: QWeather.BuildWeatherAlertPageURL(identifier, parameters.language, false), - identifier, - language: parameters.language, - countryCode: identifier.match(/-([0-9]+)$/)?.[1]?.startsWith("101") ? "CN" : "", - }, - ); - break; - } - case /^-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?$/.test(identifier): { - switch (Settings?.WeatherAlerts?.Provider) { - case "ColorfulClouds": { - Console.info("☑️ ColorfulClouds.WeatherAlert", `ids: ${identifier}`); - const colorfulClouds = new ColorfulClouds(parameters, Settings?.API?.ColorfulClouds?.Token || "Y2FpeXVuX25vdGlmeQ==", runtime); - const source = await colorfulClouds.WeatherAlert(); - if (source?.metadata?.temporarilyUnavailable) { - runtime.noteFallback("request"); - return undefined; - } - body = WeatherAlerts.Build( - { ...source, alerts: activeProviderAlerts(source?.alerts) }, - { - attributionUrl: source?.metadata?.attributionUrl ?? "https://www.caiyunapp.com/h5", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }, - ); - break; - } - case "QWeatherWeb": - case "QWeather": { - const qWeather = new QWeather(parameters, Settings?.API?.QWeather?.Token || "bdd98ec1d87747f3a2e8b1741a5af796", Settings?.API?.QWeather?.Host, runtime); - const source = await qWeather.WeatherAlert(); - if (source?.metadata?.temporarilyUnavailable) { - runtime.noteFallback("request"); - return undefined; - } - body = WeatherAlerts.Build( - { ...source, alerts: activeProviderAlerts(source?.alerts) }, - { - attributionUrl: "https://www.12379.cn/", - identifier: `${parameters.latitude},${parameters.longitude}`, - language: parameters.language, - countryCode: parameters.country, - }, - ); - break; + case url.pathname.startsWith("/api/v2/weather/"): { + // 解决 macOS 天气 app 如果使用国际版 Maps 时,country 丢失不显示未来一小时降水的问题 + switch (true) { + case $request.headers["User-Agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + case $request.headers["user-agent"]?.startsWith("WeatherKit_Weather_macOS_Version"): + if (url.searchParams.has("country")) { + } else { + const gcc = Storage.getItem("@iRingo.Location.Caches")?.pep?.gcc; + if (gcc) url.searchParams.set("country", gcc); + } + break; + } + break; + } + } + break; } - } - break; + break; } - } - } catch (error) { - runtime.noteFallback("request", providerErrorCode(error)); - return undefined; + break; + case "CONNECT": + case "TRACE": + break; } - Console.info("✅ WeatherAlerts", `alerts: ${body.length}`, "status: 200"); - return { - status: 200, - statusCode: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "max-age=0", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }; + $request.url = url.toString(); + + return { $request, $response }; } diff --git a/src/process/Response.dev.mjs b/src/process/Response.dev.mjs index 6123f60c..01c6dc8c 100644 --- a/src/process/Response.dev.mjs +++ b/src/process/Response.dev.mjs @@ -1,12 +1,12 @@ -// Modified by hhhoratioxu, 2026-10-19: share one bounded provider cache per response. +// WeatherKit provider processing. import ResponseJSONV3 from "./ResponseJSONV3.mjs"; import { Console, Storage } from "@nsnanocat/util"; import * as flatbuffers from "flatbuffers"; import AirQuality from "../class/AirQuality.mjs"; import MatchEnum from "../class/MatchEnum.mjs"; -import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import QWeather from "../class/QWeather.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import mergeWeatherAlertsProduct from "../function/mergeWeatherAlertsProduct.mjs"; import WeatherKit2, { hasForecastNextHour } from "../class/WeatherKit2.mjs"; import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; @@ -15,8 +15,8 @@ import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import planWeatherProducts from "../function/planWeatherProducts.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; -import { activeProviderAlerts, cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; -import recoverConfiguredProduct from "../function/recoverConfiguredProduct.mjs"; +import { cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import rewriteWeatherResponse from "../function/rewriteWeatherResponse.mjs"; import setENV from "../function/setENV.mjs"; import { MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; /***************** Processing *****************/ @@ -27,11 +27,15 @@ export async function Response($request, $response) { // 解析格式 const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]?.trim()?.toLowerCase(); if (FORMAT === "application/json" || FORMAT === "text/json") { - if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) return ResponseJSONV3($request, $response, { - airQuality: InjectAirQuality, currentWeather: InjectCurrentWeather, - forecastDaily: InjectForecastDaily, forecastHourly: InjectForecastHourly, - forecastNextHour: InjectForecastNextHour, weatherAlerts: InjectWeatherAlerts, - }); + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) + return ResponseJSONV3($request, $response, { + airQuality: InjectAirQuality, + currentWeather: InjectCurrentWeather, + forecastDaily: InjectForecastDaily, + forecastHourly: InjectForecastHourly, + forecastNextHour: InjectForecastNextHour, + weatherAlerts: InjectWeatherAlerts, + }); if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v1/availability/")) $response.body = JSON.stringify(mergeWeatherKitAvailability(JSON.parse($response.body), database.WeatherKit.Configs.Availability.v2)); return $response; } @@ -45,199 +49,198 @@ export async function Response($request, $response) { const { Settings, Caches, Configs } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; Console.info("WeatherKit response", `FORMAT: ${FORMAT}`); - let requestCache; - try { - // 创建空数据 - let body = {}; - // 格式判断 - switch (FORMAT) { - case undefined: // 视为无body - break; - case "application/x-www-form-urlencoded": - case "text/plain": - default: - //Console.debug(`body: ${body}`); - break; - case "application/x-mpegURL": - case "application/x-mpegurl": - case "application/vnd.apple.mpegurl": - case "audio/mpegurl": - //body = M3U8.parse($response.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$response.body = M3U8.stringify(body); - break; - case "text/xml": - case "text/html": - case "text/plist": - case "application/xml": - case "application/plist": - case "application/x-plist": - //body = XML.parse($response.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$response.body = XML.stringify(body); - break; - case "text/vtt": - case "application/vtt": - //body = VTT.parse($response.body); - //Console.debug(`body: ${JSON.stringify(body)}`); - //$response.body = VTT.stringify(body); - break; - case "text/json": - case "application/json": - body = JSON.parse($response.body); - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - default: - if (url.pathname.startsWith("/api/v1/availability/")) { - if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body: ${JSON.stringify(body)}`); - body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); + let transport; + + // 创建空数据 + let body = {}; + // 格式判断 + switch (FORMAT) { + case undefined: // 视为无body + break; + case "application/x-www-form-urlencoded": + case "text/plain": + default: + //Console.debug(`body: ${body}`); + break; + case "application/x-mpegURL": + case "application/x-mpegurl": + case "application/vnd.apple.mpegurl": + case "audio/mpegurl": + //body = M3U8.parse($response.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$response.body = M3U8.stringify(body); + break; + case "text/xml": + case "text/html": + case "text/plist": + case "application/xml": + case "application/plist": + case "application/x-plist": + //body = XML.parse($response.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$response.body = XML.stringify(body); + break; + case "text/vtt": + case "application/vtt": + //body = VTT.parse($response.body); + //Console.debug(`body: ${JSON.stringify(body)}`); + //$response.body = VTT.stringify(body); + break; + case "text/json": + case "application/json": + body = JSON.parse($response.body); + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + default: + if (url.pathname.startsWith("/api/v1/availability/")) { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body: ${JSON.stringify(body)}`); + body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); + } + break; + } + 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": { + //Console.debug(`$response: ${JSON.stringify($response, null, 2)}`); + const bytes = $response.bodyBytes; + let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); + //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody)}`); + switch (FORMAT) { + case "application/vnd.apple.flatbuffer": { + // 解析FlatBuffer + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v2/weather/")) { + const parameters = parseWeatherKitURL(url); + const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); + if (!dataSets.length) break; + transport = new ProviderTransport(); + const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); + body = WeatherKit2.decode(ByteBuffer, rootNames); + const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; + const matchEnum = new MatchEnum(body); + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + await matchEnum.init(); } - break; - } - 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": { - //Console.debug(`$response: ${JSON.stringify($response, null, 2)}`); - const bytes = $response.bodyBytes; - let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); - //Console.debug(`isBuffer? ${ArrayBuffer.isView(rawBody)}: ${JSON.stringify(rawBody)}`); - switch (FORMAT) { - case "application/vnd.apple.flatbuffer": { - // 解析FlatBuffer - const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - if (url.pathname.startsWith("/api/v2/weather/")) { - const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); - const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); - const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); - if (!dataSets.length) break; - requestCache = ProviderRequestCache.fromSettings(Settings); - const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); - body = WeatherKit2.decode(ByteBuffer, rootNames); - const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; - const matchEnum = new MatchEnum(body); - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - await matchEnum.init(); - } - const enviroments = createProviderEnvironment(providerParameters, Settings, requestCache); - - let patch; - await Promise.all( - dataSets.map(async dataSet => { - if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; - const original = body[dataSet]; - try { - body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; - switch (dataSet) { - case "airQuality": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.airQuality(); - } - body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); - break; - } - case "currentWeather": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.weatherCondition(); - matchEnum.pressureTrend(); - } - body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); - break; - } - case "forecastDaily": { - body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); - break; + const enviroments = createProviderEnvironment(providerParameters, Settings, transport); + + let patch; + await Promise.all( + dataSets.map(async dataSet => { + if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; + const original = body[dataSet]; + try { + body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; + switch (dataSet) { + case "airQuality": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.airQuality(); } - case "forecastHourly": { - body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); - break; - } - case "forecastNextHour": { - if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.forecastNextHour: ${JSON.stringify(body?.forecastNextHour, null, 2)}`); - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.conditionType(); - matchEnum.forecastToken(); - } - body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); - break; + body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); + break; + } + case "currentWeather": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.weatherCondition(); + matchEnum.pressureTrend(); } - case "news": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.placementType(); - } - if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.news: ${JSON.stringify(body?.news, null, 2)}`); - break; + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); + break; + } + case "forecastNextHour": { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.forecastNextHour: ${JSON.stringify(body?.forecastNextHour, null, 2)}`); + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.conditionType(); + matchEnum.forecastToken(); } - case "weatherAlerts": { - if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { - matchEnum.severity(); - matchEnum.significanceType(); - matchEnum.urgency(); - matchEnum.certainty(); - matchEnum.importanceType(); - matchEnum.responseType(); - } - body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); - if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.weatherAlerts: ${JSON.stringify(body?.weatherAlerts, null, 2)}`); - break; + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); + break; + } + case "news": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.placementType(); } - case "locationInfo": { - if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.locationInfo: ${JSON.stringify(body?.locationInfo, null, 2)}`); - break; + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.news: ${JSON.stringify(body?.news, null, 2)}`); + break; + } + case "weatherAlerts": { + if (Settings?.LogLevel === "DEBUG" || Settings?.LogLevel === "ALL") { + matchEnum.severity(); + matchEnum.significanceType(); + matchEnum.urgency(); + matchEnum.certainty(); + matchEnum.importanceType(); + matchEnum.responseType(); } - default: - break; + body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.weatherAlerts: ${JSON.stringify(body?.weatherAlerts, null, 2)}`); + break; } - if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; - else { - patch ??= {}; - patch[dataSet] = body[dataSet]; + case "locationInfo": { + if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`body.locationInfo: ${JSON.stringify(body?.locationInfo, null, 2)}`); + break; } - } catch (error) { - body[dataSet] = original; - requestCache.noteFallback(dataSet, providerErrorCode(error)); + default: + break; + } + if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; + else { + patch ??= {}; + patch[dataSet] = body[dataSet]; } - }), - ); - if (patch) rawBody = WeatherKit2.encode(ByteBuffer, patch); - break; + } catch { + body[dataSet] = original; + } + }), + ); + if (patch) { + const encoded = WeatherKit2.encode(ByteBuffer, patch); + if (encoded.buffer !== rawBody.buffer || encoded.byteOffset !== rawBody.byteOffset || encoded.byteLength !== rawBody.byteLength) return rewriteWeatherResponse($response, encoded); } break; - } - break; + } + 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; + break; } - // 写入二进制数据 - $response.body = rawBody; - 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; - } finally { - requestCache?.report("response"); } + return $response; } /** @@ -268,14 +271,14 @@ async function InjectCurrentWeather(currentWeather, Settings, enviroments) { break; } } - newCurrentWeather = await recoverConfiguredProduct("currentWeather", newCurrentWeather, currentWeather, Settings?.Weather?.Provider, { QWeather: () => enviroments.qWeather.WeatherNow(), ColorfulClouds: () => enviroments.colorfulClouds.CurrentWeather() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newCurrentWeather)) { newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; currentWeather = { ...currentWeather, ...newCurrentWeather }; currentWeather.metadata.providerLogo ||= providerNameToLogo(currentWeather.metadata.providerName); //Console.debug(`currentWeather: ${JSON.stringify(currentWeather, null, 2)}`); } - if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newCurrentWeather)) enviroments.requestCache?.noteFallback("currentWeather"); + Console.info("✅ InjectCurrentWeather"); return currentWeather; } @@ -311,15 +314,7 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { break; } } - newForecastDaily = await recoverConfiguredProduct( - "forecastDaily", - newForecastDaily, - forecastDaily, - Settings?.Weather?.Provider, - { QWeather: () => enviroments.qWeather.Daily(), ColorfulClouds: () => enviroments.colorfulClouds.Daily(forecastDaily?.days?.length || 11, forecastDaily?.days?.[0]?.forecastStart) }, - Settings, - enviroments.requestCache, - ); + if (providerProductAvailable(newForecastDaily)) { forecastDaily = { ...forecastDaily, @@ -329,7 +324,7 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { forecastDaily.metadata.providerLogo ||= providerNameToLogo(forecastDaily.metadata.providerName); //Console.debug(`forecastDaily: ${JSON.stringify(forecastDaily, null, 2)}`); } - if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastDaily)) enviroments.requestCache?.noteFallback("forecastDaily"); + Console.info("✅ InjectForecastDaily"); return forecastDaily; } @@ -365,15 +360,7 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { break; } } - newForecastHourly = await recoverConfiguredProduct( - "forecastHourly", - newForecastHourly, - forecastHourly, - Settings?.Weather?.Provider, - { QWeather: () => enviroments.qWeather.Hourly(), ColorfulClouds: () => enviroments.colorfulClouds.ForecastHourly(forecastHourly?.hours?.length || 273, forecastHourly?.hours?.[0]?.forecastStart) }, - Settings, - enviroments.requestCache, - ); + if (providerProductAvailable(newForecastHourly)) { forecastHourly = { ...forecastHourly, @@ -383,7 +370,7 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { forecastHourly.metadata.providerLogo ||= providerNameToLogo(forecastHourly.metadata.providerName); //Console.debug(`forecastHourly: ${JSON.stringify(forecastHourly, null, 2)}`); } - if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastHourly)) enviroments.requestCache?.noteFallback("forecastHourly"); + Console.info("✅ InjectForecastHourly"); return forecastHourly; } @@ -417,14 +404,14 @@ async function InjectForecastNextHour(forecastNextHour, Settings, enviroments) { break; } } - newForecastNextHour = await recoverConfiguredProduct("forecastNextHour", newForecastNextHour, forecastNextHour, Settings?.NextHour?.Provider ?? "ColorfulClouds", { QWeather: () => enviroments.qWeather.Minutely(), ColorfulClouds: () => enviroments.colorfulClouds.Minutely() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newForecastNextHour)) { newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; forecastNextHour.metadata.providerLogo ||= providerNameToLogo(forecastNextHour.metadata.providerName); if (Console.logLevel === "DEBUG" || Console.logLevel === "ALL") Console.debug(`forecastNextHour: ${JSON.stringify(forecastNextHour, null, 2)}`); } - if (Settings?.NextHour?.Provider !== "WeatherKit" && !providerProductAvailable(newForecastNextHour)) enviroments.requestCache?.noteFallback("forecastNextHour"); + Console.info("✅ InjectForecastNextHour"); return forecastNextHour; } @@ -445,29 +432,22 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } case "ColorfulClouds": { - if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsColorfulClouds ?? enviroments.colorfulClouds).WeatherAlert(); break; } case "QWeather": { - if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlert(); break; } case "QWeatherWeb": default: { - newWeatherAlerts = await enviroments.qWeather.WeatherAlertWeb(weatherAlerts?.detailsUrl); + newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlertWeb(weatherAlerts?.detailsUrl); break; } } - newWeatherAlerts = await recoverConfiguredProduct("weatherAlerts", newWeatherAlerts, weatherAlerts, Settings?.WeatherAlerts?.Provider, { QWeather: () => enviroments.qWeather.WeatherAlert(), ColorfulClouds: () => enviroments.colorfulClouds.WeatherAlert() }, Settings, enviroments.requestCache); - if (providerProductAvailable(newWeatherAlerts)) { - // Only copy when a usable provider result will mutate the Apple data. - weatherAlerts = cloneWeatherProduct(weatherAlerts) ?? {}; - weatherAlerts.metadata = { ...weatherAlerts?.metadata, ...newWeatherAlerts.metadata }; - weatherAlerts.detailsUrl = newWeatherAlerts.detailsUrl ?? weatherAlerts.detailsUrl; - weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, activeProviderAlerts(newWeatherAlerts?.alerts)); - weatherAlerts.metadata.providerLogo ||= providerNameToLogo(weatherAlerts.metadata.providerName); - } - if (Settings?.WeatherAlerts?.Provider !== "WeatherKit" && !providerProductAvailable(newWeatherAlerts)) enviroments.requestCache?.noteFallback("weatherAlerts"); + + weatherAlerts = mergeWeatherAlertsProduct(weatherAlerts, newWeatherAlerts); + Console.info("✅ InjectWeatherAlerts"); return weatherAlerts; } @@ -497,9 +477,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { needPollutants = pollutants.length > originalPollutants.length; if (needPollutants) injectedPollutants = { ...supplement, ...airQuality, pollutants, metadata: { ...supplement.metadata, temporarilyUnavailable: false } }; } - } catch (error) { - enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); - } + } catch {} } // The algorithm chooses how to convert; the target list decides whether. @@ -508,16 +486,17 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; const originalIndexAvailable = AirQuality.HasUsableIndex(airQuality); const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || !originalIndexAvailable && needPollutants); - let injectedIndex = airQuality, indexAvailable = false; + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!originalIndexAvailable && needPollutants)); + let injectedIndex = airQuality, + indexAvailable = false; if (needInjectIndex) { try { const candidate = await InjectIndex(injectedPollutants, Settings, enviroments); - if (AirQuality.HasUsableIndex(candidate)) { injectedIndex = candidate; indexAvailable = true; } - else enviroments.requestCache?.noteFallback("airQuality", "invalid"); - } catch (error) { - enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); - } + if (AirQuality.HasUsableIndex(candidate)) { + injectedIndex = candidate; + indexAvailable = true; + } + } catch {} } // Follow the original comparison switch and independently selected source. @@ -530,9 +509,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { if (needInjectComparison) { try { injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments); - } catch (error) { - enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); - } + } catch {} } // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 @@ -736,7 +713,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Storage.setItem("@iRingo.WeatherKit.Caches", { ...Caches, qweather: qweatherCache }); }; - const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.requestCache); + const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.transport); const { latitude, longitude } = enviroments.qWeather; const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); diff --git a/src/process/Response.mjs b/src/process/Response.mjs index 37328820..f43e3410 100644 --- a/src/process/Response.mjs +++ b/src/process/Response.mjs @@ -1,11 +1,11 @@ -// Modified by hhhoratioxu, 2026-10-19: share one bounded provider cache per response. +// WeatherKit provider processing. import ResponseJSONV3 from "./ResponseJSONV3.mjs"; import { Console, Storage } from "@nsnanocat/util"; import * as flatbuffers from "flatbuffers"; import AirQuality from "../class/AirQuality.mjs"; -import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import QWeather from "../class/QWeather.mjs"; -import WeatherAlerts from "../class/WeatherAlerts.mjs"; +import mergeWeatherAlertsProduct from "../function/mergeWeatherAlertsProduct.mjs"; import WeatherKit2, { hasForecastNextHour } from "../class/WeatherKit2.mjs"; import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; @@ -14,8 +14,8 @@ import mergeWeatherKitAvailability from "../function/mergeWeatherKitAvailability import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import planWeatherProducts from "../function/planWeatherProducts.mjs"; import providerNameToLogo from "../function/providerNameToLogo.mjs"; -import { activeProviderAlerts, cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; -import recoverConfiguredProduct from "../function/recoverConfiguredProduct.mjs"; +import { cloneWeatherProduct, providerProductAvailable, weatherProductsEqual } from "../function/providerRecovery.mjs"; +import rewriteWeatherResponse from "../function/rewriteWeatherResponse.mjs"; import setENV from "../function/setENV.mjs"; import { MAX_WEATHER_BODY_BYTES } from "../function/weatherMemoryLimits.mjs"; /***************** Processing *****************/ @@ -26,11 +26,15 @@ export async function Response($request, $response) { // 解析格式 const FORMAT = ($response.headers?.["Content-Type"] ?? $response.headers?.["content-type"])?.split(";")?.[0]?.trim()?.toLowerCase(); if (FORMAT === "application/json" || FORMAT === "text/json") { - if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) return ResponseJSONV3($request, $response, { - airQuality: InjectAirQuality, currentWeather: InjectCurrentWeather, - forecastDaily: InjectForecastDaily, forecastHourly: InjectForecastHourly, - forecastNextHour: InjectForecastNextHour, weatherAlerts: InjectWeatherAlerts, - }); + if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v3/weather/")) + return ResponseJSONV3($request, $response, { + airQuality: InjectAirQuality, + currentWeather: InjectCurrentWeather, + forecastDaily: InjectForecastDaily, + forecastHourly: InjectForecastHourly, + forecastNextHour: InjectForecastNextHour, + weatherAlerts: InjectWeatherAlerts, + }); if (url.hostname === "weatherkit.apple.com" && url.pathname.startsWith("/api/v1/availability/")) $response.body = JSON.stringify(mergeWeatherKitAvailability(JSON.parse($response.body), database.WeatherKit.Configs.Availability.v2)); return $response; } @@ -44,150 +48,151 @@ export async function Response($request, $response) { const { Settings, Caches, Configs } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; Console.info("WeatherKit response", `FORMAT: ${FORMAT}`); - let requestCache; - try { - // 创建空数据 - let body = {}; - // 格式判断 - 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); - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - switch (url.pathname) { - default: - if (url.pathname.startsWith("/api/v1/availability/")) { - body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); - } - break; - } - 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": { - const bytes = $response.bodyBytes; - let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); - switch (FORMAT) { - case "application/vnd.apple.flatbuffer": { - // 解析FlatBuffer - const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); - // 主机判断 - switch (url.hostname) { - case "weatherkit.apple.com": - // 路径判断 - if (url.pathname.startsWith("/api/v2/weather/")) { - const parameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); - const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); - const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); - if (!dataSets.length) break; - requestCache = ProviderRequestCache.fromSettings(Settings); - const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); - body = WeatherKit2.decode(ByteBuffer, rootNames); - const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; - const enviroments = createProviderEnvironment(providerParameters, Settings, requestCache); - - let patch; - await Promise.all( - dataSets.map(async dataSet => { - if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; - const original = body[dataSet]; - try { - body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; - switch (dataSet) { - case "airQuality": { - body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); - break; - } - case "currentWeather": { - body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); - break; - } - case "forecastDaily": { - body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); - break; - } - case "forecastHourly": { - body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); - break; - } - case "forecastNextHour": { - body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); - break; - } - case "weatherAlerts": { - body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); - break; - } - default: - break; + let transport; + + // 创建空数据 + let body = {}; + // 格式判断 + 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); + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + switch (url.pathname) { + default: + if (url.pathname.startsWith("/api/v1/availability/")) { + body = mergeWeatherKitAvailability(body, Configs?.Availability?.v2); + } + break; + } + 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": { + const bytes = $response.bodyBytes; + let rawBody = bytes ? (bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)) : ($response.body ?? new Uint8Array()); + switch (FORMAT) { + case "application/vnd.apple.flatbuffer": { + // 解析FlatBuffer + const ByteBuffer = new flatbuffers.ByteBuffer(rawBody); + // 主机判断 + switch (url.hostname) { + case "weatherkit.apple.com": + // 路径判断 + if (url.pathname.startsWith("/api/v2/weather/")) { + const parameters = parseWeatherKitURL(url); + const requested = parameters.dataSets.filter(dataSet => Settings.DataSets.includes(dataSet)); + const dataSets = planWeatherProducts(requested, Settings, parameters.country, requested.includes("forecastNextHour") && hasForecastNextHour(ByteBuffer)); + if (!dataSets.length) break; + transport = new ProviderTransport(); + const rootNames = dataSets.map(dataSet => Configs.DataSets[dataSet] ?? dataSet); + body = WeatherKit2.decode(ByteBuffer, rootNames); + const providerParameters = { ...parameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }; + const enviroments = createProviderEnvironment(providerParameters, Settings, transport); + + let patch; + await Promise.all( + dataSets.map(async dataSet => { + if (!["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"].includes(dataSet)) return; + const original = body[dataSet]; + try { + body[dataSet] = dataSet === "airQuality" ? cloneWeatherProduct(original) : original; + switch (dataSet) { + case "airQuality": { + body.airQuality = await InjectAirQuality(body.airQuality, Settings, Caches, enviroments); + break; + } + case "currentWeather": { + body.currentWeather = await InjectCurrentWeather(body.currentWeather, Settings, enviroments); + break; + } + case "forecastDaily": { + body.forecastDaily = await InjectForecastDaily(body.forecastDaily, Settings, enviroments); + break; + } + case "forecastHourly": { + body.forecastHourly = await InjectForecastHourly(body.forecastHourly, Settings, enviroments); + break; + } + case "forecastNextHour": { + body.forecastNextHour = await InjectForecastNextHour(body.forecastNextHour, Settings, enviroments); + break; } - if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; - else { - patch ??= {}; - patch[dataSet] = body[dataSet]; + case "weatherAlerts": { + body.weatherAlerts = await InjectWeatherAlerts(body.weatherAlerts, Settings, enviroments); + break; } - } catch (error) { - body[dataSet] = original; - requestCache.noteFallback(dataSet, providerErrorCode(error)); + default: + break; } - }), - ); - if (patch) rawBody = WeatherKit2.encode(ByteBuffer, patch); - break; + if (weatherProductsEqual(body[dataSet], original)) body[dataSet] = original; + else { + patch ??= {}; + patch[dataSet] = body[dataSet]; + } + } catch { + body[dataSet] = original; + } + }), + ); + if (patch) { + const encoded = WeatherKit2.encode(ByteBuffer, patch); + // The pinned root processor returns a view of the original + // bytes when every product fails to compile. + if (encoded.buffer !== rawBody.buffer || encoded.byteOffset !== rawBody.byteOffset || encoded.byteLength !== rawBody.byteLength) return rewriteWeatherResponse($response, encoded); } break; - } - break; + } + 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; + break; } - // 写入二进制数据 - $response.body = rawBody; - 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; - } finally { - requestCache?.report("response"); } + return $response; } /** @@ -218,13 +223,13 @@ async function InjectCurrentWeather(currentWeather, Settings, enviroments) { break; } } - newCurrentWeather = await recoverConfiguredProduct("currentWeather", newCurrentWeather, currentWeather, Settings?.Weather?.Provider, { QWeather: () => enviroments.qWeather.WeatherNow(), ColorfulClouds: () => enviroments.colorfulClouds.CurrentWeather() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newCurrentWeather)) { newCurrentWeather.metadata = { ...currentWeather?.metadata, ...newCurrentWeather.metadata }; currentWeather = { ...currentWeather, ...newCurrentWeather }; currentWeather.metadata.providerLogo ||= providerNameToLogo(currentWeather.metadata.providerName); } - if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newCurrentWeather)) enviroments.requestCache?.noteFallback("currentWeather"); + Console.info("✅ InjectCurrentWeather"); return currentWeather; } @@ -259,15 +264,7 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { break; } } - newForecastDaily = await recoverConfiguredProduct( - "forecastDaily", - newForecastDaily, - forecastDaily, - Settings?.Weather?.Provider, - { QWeather: () => enviroments.qWeather.Daily(), ColorfulClouds: () => enviroments.colorfulClouds.Daily(forecastDaily?.days?.length || 11, forecastDaily?.days?.[0]?.forecastStart) }, - Settings, - enviroments.requestCache, - ); + if (providerProductAvailable(newForecastDaily)) { forecastDaily = { ...forecastDaily, @@ -276,7 +273,7 @@ async function InjectForecastDaily(forecastDaily, Settings, enviroments) { }; forecastDaily.metadata.providerLogo ||= providerNameToLogo(forecastDaily.metadata.providerName); } - if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastDaily)) enviroments.requestCache?.noteFallback("forecastDaily"); + Console.info("✅ InjectForecastDaily"); return forecastDaily; } @@ -311,15 +308,7 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { break; } } - newForecastHourly = await recoverConfiguredProduct( - "forecastHourly", - newForecastHourly, - forecastHourly, - Settings?.Weather?.Provider, - { QWeather: () => enviroments.qWeather.Hourly(), ColorfulClouds: () => enviroments.colorfulClouds.ForecastHourly(forecastHourly?.hours?.length || 273, forecastHourly?.hours?.[0]?.forecastStart) }, - Settings, - enviroments.requestCache, - ); + if (providerProductAvailable(newForecastHourly)) { forecastHourly = { ...forecastHourly, @@ -328,7 +317,7 @@ async function InjectForecastHourly(forecastHourly, Settings, enviroments) { }; forecastHourly.metadata.providerLogo ||= providerNameToLogo(forecastHourly.metadata.providerName); } - if (["ColorfulClouds", "QWeather"].includes(Settings?.Weather?.Provider) && !providerProductAvailable(newForecastHourly)) enviroments.requestCache?.noteFallback("forecastHourly"); + Console.info("✅ InjectForecastHourly"); return forecastHourly; } @@ -362,13 +351,13 @@ async function InjectForecastNextHour(forecastNextHour, Settings, enviroments) { break; } } - newForecastNextHour = await recoverConfiguredProduct("forecastNextHour", newForecastNextHour, forecastNextHour, Settings?.NextHour?.Provider ?? "ColorfulClouds", { QWeather: () => enviroments.qWeather.Minutely(), ColorfulClouds: () => enviroments.colorfulClouds.Minutely() }, Settings, enviroments.requestCache); + if (providerProductAvailable(newForecastNextHour)) { newForecastNextHour.metadata = { ...forecastNextHour?.metadata, ...newForecastNextHour.metadata }; forecastNextHour = { ...forecastNextHour, ...newForecastNextHour }; forecastNextHour.metadata.providerLogo ||= providerNameToLogo(forecastNextHour.metadata.providerName); } - if (Settings?.NextHour?.Provider !== "WeatherKit" && !providerProductAvailable(newForecastNextHour)) enviroments.requestCache?.noteFallback("forecastNextHour"); + Console.info("✅ InjectForecastNextHour"); return forecastNextHour; } @@ -389,29 +378,22 @@ async function InjectWeatherAlerts(weatherAlerts, Settings, enviroments) { break; } case "ColorfulClouds": { - if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.colorfulClouds.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsColorfulClouds ?? enviroments.colorfulClouds).WeatherAlert(); break; } case "QWeather": { - if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await enviroments.qWeather.WeatherAlert(); + if (isNationalWarningCenter || !providerProductAvailable(weatherAlerts)) newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlert(); break; } case "QWeatherWeb": default: { - newWeatherAlerts = await enviroments.qWeather.WeatherAlertWeb(weatherAlerts?.detailsUrl); + newWeatherAlerts = await (enviroments.weatherAlertsQWeather ?? enviroments.qWeather).WeatherAlertWeb(weatherAlerts?.detailsUrl); break; } } - newWeatherAlerts = await recoverConfiguredProduct("weatherAlerts", newWeatherAlerts, weatherAlerts, Settings?.WeatherAlerts?.Provider, { QWeather: () => enviroments.qWeather.WeatherAlert(), ColorfulClouds: () => enviroments.colorfulClouds.WeatherAlert() }, Settings, enviroments.requestCache); - if (providerProductAvailable(newWeatherAlerts)) { - // Only copy when a usable provider result will mutate the Apple data. - weatherAlerts = cloneWeatherProduct(weatherAlerts) ?? {}; - weatherAlerts.metadata = { ...weatherAlerts?.metadata, ...newWeatherAlerts.metadata }; - weatherAlerts.detailsUrl = newWeatherAlerts.detailsUrl ?? weatherAlerts.detailsUrl; - weatherAlerts.alerts = WeatherAlerts.mergeAlerts(weatherAlerts?.alerts, activeProviderAlerts(newWeatherAlerts?.alerts)); - weatherAlerts.metadata.providerLogo ||= providerNameToLogo(weatherAlerts.metadata.providerName); - } - if (Settings?.WeatherAlerts?.Provider !== "WeatherKit" && !providerProductAvailable(newWeatherAlerts)) enviroments.requestCache?.noteFallback("weatherAlerts"); + + weatherAlerts = mergeWeatherAlertsProduct(weatherAlerts, newWeatherAlerts); + Console.info("✅ InjectWeatherAlerts"); return weatherAlerts; } @@ -441,9 +423,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { needPollutants = pollutants.length > originalPollutants.length; if (needPollutants) injectedPollutants = { ...supplement, ...airQuality, pollutants, metadata: { ...supplement.metadata, temporarilyUnavailable: false } }; } - } catch (error) { - enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); - } + } catch {} } // The algorithm chooses how to convert; the target list decides whether. @@ -452,16 +432,17 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { const calculateDisabled = indexProvider === "Calculate" && Settings?.AirQuality?.Calculate?.Algorithm === "None"; const originalIndexAvailable = AirQuality.HasUsableIndex(airQuality); const targetMatches = targets.some(scaleName => AirQuality.ScaleMatches(airQuality?.scale, scaleName)); - const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || !originalIndexAvailable && needPollutants); - let injectedIndex = airQuality, indexAvailable = false; + const needInjectIndex = targets.length > 0 && !calculateDisabled && (targetMatches || (!originalIndexAvailable && needPollutants)); + let injectedIndex = airQuality, + indexAvailable = false; if (needInjectIndex) { try { const candidate = await InjectIndex(injectedPollutants, Settings, enviroments); - if (AirQuality.HasUsableIndex(candidate)) { injectedIndex = candidate; indexAvailable = true; } - else enviroments.requestCache?.noteFallback("airQuality", "invalid"); - } catch (error) { - enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); - } + if (AirQuality.HasUsableIndex(candidate)) { + injectedIndex = candidate; + indexAvailable = true; + } + } catch {} } // Follow the original comparison switch and independently selected source. @@ -474,9 +455,7 @@ async function InjectAirQuality(airQuality, Settings, Caches, enviroments) { if (needInjectComparison) { try { injectedComparison = await InjectComparison(injectedIndex, currentIndexProvider, Settings, Caches, enviroments); - } catch (error) { - enviroments.requestCache?.noteFallback("airQuality", providerErrorCode(error)); - } + } catch {} } // Step5. 收集各阶段元数据,拼接最终 providerName 展示文案 @@ -680,7 +659,7 @@ async function InjectComparison(airQuality, currentIndexProvider, Settings, Cach Storage.setItem("@iRingo.WeatherKit.Caches", { ...Caches, qweather: qweatherCache }); }; - const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.requestCache); + const locationsGrid = await QWeather.GetLocationsGrid(Caches?.qweather, setQWeatherCache, enviroments.transport); const { latitude, longitude } = enviroments.qWeather; const locationInfo = QWeather.GetLocationInfo(locationsGrid, latitude, longitude); diff --git a/src/process/ResponseJSONV3.mjs b/src/process/ResponseJSONV3.mjs index 02674f33..c8c99aa9 100644 --- a/src/process/ResponseJSONV3.mjs +++ b/src/process/ResponseJSONV3.mjs @@ -1,11 +1,12 @@ // Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 // v3 JSON is negotiated with Apple; never feed WK3 bytes into the WK2 schema. import { Console } from "@nsnanocat/util"; -import ProviderRequestCache, { providerErrorCode } from "../class/ProviderRequestCache.mjs"; +import ProviderTransport from "../class/ProviderTransport.mjs"; import createProviderEnvironment from "../function/createProviderEnvironment.mjs"; import database from "../function/database.mjs"; import parseWeatherKitURL from "../function/parseWeatherKitURL.mjs"; import planWeatherProducts from "../function/planWeatherProducts.mjs"; +import rewriteWeatherResponse from "../function/rewriteWeatherResponse.mjs"; import { cloneWeatherProduct, weatherProductsEqual } from "../function/providerRecovery.mjs"; import setENV from "../function/setENV.mjs"; import utf8Length from "../function/utf8Length.mjs"; @@ -13,6 +14,10 @@ import { MAX_DECODED_VECTOR_ENTRIES, MAX_WEATHER_BODY_BYTES } from "../function/ export const MAX_WEATHER_JSON_CHARACTERS = 1024 * 1024; const productNames = ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"]; +const clientProductNames = [...productNames, "forecastPeriodic", "weatherChanges"]; +// Known-container traversal adapted from hhh2210/WeatherKit PR #110, +// f4676feae89a27a8403c574c6086a433d5adb3c9, Apache-2.0; its JSON adapter credits PR #107. +const clientContainers = new Set(["metadata", "days", "hours", "minutes", "daytimeForecast", "overnightForecast", "restOfDayForecast", "series", "forecasts", "periods", "condition", "parameters", "summary", "alerts", "pollutants"]); const providerMethods = new Set(["WeatherNow", "CurrentWeather", "Hourly", "ForecastHourly", "Daily", "Minutely", "WeatherAlert", "WeatherAlertWeb"]); const object = value => value !== null && typeof value === "object" && !Array.isArray(value); // WK3's FlatBuffer JSON and Weather's JSON reader have different shapes. @@ -20,10 +25,60 @@ const object = value => value !== null && typeof value === "object" && !Array.is // and scalar precipitation amounts, even when Apple's v3 JSON contains vectors. const percentageFields = new Set(["cloudCover", "cloudCoverLowAltPct", "cloudCoverMidAltPct", "cloudCoverHighAltPct", "humidity", "humidityMax", "humidityMin", "precipitationChance"]); const byteFields = new Set(["uvIndex", "maxUvIndex", "uvIndexMax", "uvIndexMin", "categoryIndex"]); -const timestampFields = new Set(["asOf", "readTime", "reportedTime", "expireTime", "forecastStart", "forecastEnd", "startTime", "endTime", "date", "effectiveTime", "issuedTime", "eventOnsetTime", "eventEndTime", "moonrise", "moonset", "sunrise", "sunset", "sunriseCivil", "sunsetCivil", "sunriseNautical", "sunsetNautical", "sunriseAstronomical", "sunsetAstronomical", "solarNoon", "solarMidnight", "temperatureMaxTime", "temperatureMinTime"]); -const optionalTimestampFields = new Set(["reportedTime", "endTime", "eventOnsetTime", "eventEndTime", "moonrise", "moonset", "sunrise", "sunset", "sunriseCivil", "sunsetCivil", "sunriseNautical", "sunsetNautical", "sunriseAstronomical", "sunsetAstronomical", "solarNoon", "solarMidnight", "temperatureMaxTime", "temperatureMinTime"]); +const timestampFields = new Set([ + "asOf", + "readTime", + "reportedTime", + "expireTime", + "forecastStart", + "forecastEnd", + "startTime", + "endTime", + "date", + "effectiveTime", + "issuedTime", + "eventOnsetTime", + "eventEndTime", + "moonrise", + "moonset", + "sunrise", + "sunset", + "sunriseCivil", + "sunsetCivil", + "sunriseNautical", + "sunsetNautical", + "sunriseAstronomical", + "sunsetAstronomical", + "solarNoon", + "solarMidnight", + "temperatureMaxTime", + "temperatureMinTime", +]); +const optionalTimestampFields = new Set([ + "reportedTime", + "endTime", + "eventOnsetTime", + "eventEndTime", + "moonrise", + "moonset", + "sunrise", + "sunset", + "sunriseCivil", + "sunsetCivil", + "sunriseNautical", + "sunsetNautical", + "sunriseAstronomical", + "sunsetAstronomical", + "solarNoon", + "solarMidnight", + "temperatureMaxTime", + "temperatureMinTime", +]); const precipitationByTypeField = /^precipitationAmount(?:(?:Previous|Next)\d+h)?ByType$/; -const percentRange = [0, 100], byteRange = [0, 255], shortRange = [0, 65535], timeRange = [0, 0xffffffff]; +const percentRange = [0, 100], + byteRange = [0, 255], + shortRange = [0, 65535], + timeRange = [0, 0xffffffff]; function integerRange(key) { if (percentageFields.has(key)) return percentRange; if (byteFields.has(key)) return byteRange; @@ -33,7 +88,8 @@ function integerRange(key) { // Share the existing per-product vector budget. Reject before starting provider work. export function boundedWeatherJSON(value) { - let entries = 0, nodes = 0; + let entries = 0, + nodes = 0; function visit(item, depth) { if (++nodes > 100000 || depth > 64) throw new RangeError("Weather JSON exceeds traversal bounds"); if (!item || typeof item !== "object") return; @@ -70,26 +126,34 @@ export function adaptV3ClientProduct(product, name) { } function visit(value) { if (!value || typeof value !== "object") return; - if (Array.isArray(value)) { for (const item of value) visit(item); return; } + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } for (const key of Object.keys(value)) { let item = value[key]; - if ((key === "conditionCode" || key === "precipitationType" || key === "condition" && item?.length > 0) && Array.isArray(item) && item.every(entry => typeof entry === "string")) { + if ((key === "conditionCode" || key === "precipitationType" || (key === "condition" && item?.length > 0)) && Array.isArray(item) && item.every(entry => typeof entry === "string")) { const first = item.find(entry => typeof entry === "string" && entry); if (first === undefined) delete value[key]; else value[key] = first; - item = value[key]; changed = true; + item = value[key]; + changed = true; } if ((key === "precipitationAmount" || key.startsWith("snowfallAmount")) && object(item) && Number.isFinite(item.expected)) { - value[key] = item = item.expected; changed = true; + value[key] = item = item.expected; + changed = true; } if (precipitationByTypeField.test(key) && Array.isArray(item)) { const scalarKey = key.slice(0, -"ByType".length); if (value[scalarKey] === undefined && item.every(part => object(part) && Number.isFinite(part.expected) && part.expected >= 0)) { const amount = item.reduce((sum, part) => sum + part.expected, 0); - if (Number.isFinite(amount)) { value[scalarKey] = amount; changed = true; } + if (Number.isFinite(amount)) { + value[scalarKey] = amount; + changed = true; + } } } - visit(item); + if (clientContainers.has(key) || precipitationByTypeField.test(key)) visit(item); } } visit(product); @@ -108,26 +172,36 @@ export function adaptV3ProviderProduct(product, name) { } for (const key of Object.keys(value)) { let item = value[key]; - if (item == null) { delete value[key]; continue; } + if (item == null) { + if (item === undefined || integerRange(key) || clientContainers.has(key) || ["latitude", "longitude", "conditionCode", "precipitationType", "precipitationAmount", "snowfallAmount"].includes(key)) delete value[key]; + continue; + } if (typeof item === "number" && !Number.isFinite(item)) { delete value[key]; continue; } // WK2 used zero as "no end"; WK3's optional timestamps must be absent. - if (item === 0 && optionalTimestampFields.has(key)) { delete value[key]; continue; } + if (item === 0 && optionalTimestampFields.has(key)) { + delete value[key]; + continue; + } const range = integerRange(key); if (range && typeof item === "number") { - if (item < range[0] || item > range[1]) { delete value[key]; continue; } + if (item < range[0] || item > range[1]) { + delete value[key]; + continue; + } value[key] = item = timestampFields.has(key) ? Math.trunc(item) : Math.round(item); } if (key === "metadata" && object(item)) { - for (const coordinate of ["latitude", "longitude"]) if (typeof item[coordinate] === "string") { - const number = Number(item[coordinate]); - if (item[coordinate].trim() && Number.isFinite(number)) item[coordinate] = number; - else delete item[coordinate]; - } + for (const coordinate of ["latitude", "longitude"]) + if (typeof item[coordinate] === "string") { + const number = Number(item[coordinate]); + if (item[coordinate].trim() && Number.isFinite(number)) item[coordinate] = number; + else delete item[coordinate]; + } } - visit(item); + if (clientContainers.has(key) || precipitationByTypeField.test(key)) visit(item); } } visit(product); @@ -140,9 +214,10 @@ export function adaptV3ProviderProduct(product, name) { export function validateWeatherJSONV3(product, name) { if (!object(product)) throw new TypeError("Missing weather JSON product"); boundedWeatherJSON(product); - if (name === "airQuality" && product?.metadata?.temporarilyUnavailable !== true && (!Number.isInteger(product?.index) || product.index < 0 || product.index > 65535 || !Number.isInteger(product?.categoryIndex) || product.categoryIndex < 1 || product.categoryIndex > 255)) throw new TypeError("Invalid WK3 air quality index"); + if (name === "airQuality" && product?.metadata?.temporarilyUnavailable !== true && (!Number.isInteger(product?.index) || product.index < 0 || product.index > 65535 || !Number.isInteger(product?.categoryIndex) || product.categoryIndex < 1 || product.categoryIndex > 255)) + throw new TypeError("Invalid WK3 air quality index"); if (name === "weatherAlerts" && Array.isArray(product.alerts) && product.alerts.some(alert => object(alert) && alert.id !== undefined && typeof alert.id !== "string")) throw new TypeError("Invalid native weather alert identifier"); - function visit(value) { + function visit(value, known = true) { const kind = typeof value; if (kind === "bigint" || kind === "symbol" || kind === "function") throw new TypeError("Unsupported weather JSON value"); if (kind === "string") { @@ -159,19 +234,20 @@ export function validateWeatherJSONV3(product, name) { if (!value || typeof value !== "object") return; if (Array.isArray(value)) { for (const item of value) { - if (item == null) throw new TypeError("Missing weather JSON array item"); - if (typeof item === "number" && (!Number.isFinite(item) || !Number.isFinite(Math.fround(item)))) throw new TypeError("Invalid weather JSON array number"); - visit(item); + if (item === undefined || (known && item === null)) throw new TypeError("Missing weather JSON array item"); + if (typeof item === "number" && (!Number.isFinite(item) || (known && !Number.isFinite(Math.fround(item))))) throw new TypeError("Invalid weather JSON array number"); + visit(item, known); } return; } for (const key of Object.keys(value)) { - const item = value[key], range = integerRange(key); - if (range && item !== null && (typeof item !== "number" || !Number.isInteger(item) || item < range[0] || item > range[1])) throw new TypeError("Invalid WK3 integer field"); - if (typeof item === "number" && (!Number.isFinite(item) || !Number.isFinite(Math.fround(item)))) throw new TypeError("Invalid weather JSON number"); - if ((key === "conditionCode" || key === "precipitationType" || key === "condition" && !Array.isArray(item)) && typeof item !== "string") throw new TypeError("Invalid native weather JSON condition"); - if ((key === "precipitationAmount" || key === "snowfallAmount") && (typeof item !== "number" || !Number.isFinite(item))) throw new TypeError("Invalid native weather JSON precipitation amount"); - visit(item); + const item = value[key], + range = integerRange(key); + if (known && range && item !== null && (typeof item !== "number" || !Number.isInteger(item) || item < range[0] || item > range[1])) throw new TypeError("Invalid WK3 integer field"); + if (typeof item === "number" && (!Number.isFinite(item) || (known && !Number.isFinite(Math.fround(item))))) throw new TypeError("Invalid weather JSON number"); + if (known && (key === "conditionCode" || key === "precipitationType" || (key === "condition" && !Array.isArray(item))) && typeof item !== "string") throw new TypeError("Invalid native weather JSON condition"); + if (known && (key === "precipitationAmount" || key === "snowfallAmount") && (typeof item !== "number" || !Number.isFinite(item))) throw new TypeError("Invalid native weather JSON precipitation amount"); + visit(item, known && (clientContainers.has(key) || precipitationByTypeField.test(key))); } } visit(product); @@ -189,13 +265,25 @@ function v3Environment(environment) { }, }); } - let colorfulClouds, qWeather; + let colorfulClouds, qWeather, weatherAlertsColorfulClouds, weatherAlertsQWeather; return { country: environment.country, - requestCache: environment.requestCache, - get colorfulClouds() { return colorfulClouds ??= wrap(environment.colorfulClouds); }, - get qWeather() { return qWeather ??= wrap(environment.qWeather); }, - get waqi() { return environment.waqi; }, + transport: environment.transport, + get colorfulClouds() { + return (colorfulClouds ??= wrap(environment.colorfulClouds)); + }, + get qWeather() { + return (qWeather ??= wrap(environment.qWeather)); + }, + get weatherAlertsColorfulClouds() { + return (weatherAlertsColorfulClouds ??= wrap(environment.weatherAlertsColorfulClouds)); + }, + get weatherAlertsQWeather() { + return (weatherAlertsQWeather ??= wrap(environment.weatherAlertsQWeather)); + }, + get waqi() { + return environment.waqi; + }, }; } @@ -205,19 +293,28 @@ export default async function ResponseJSONV3(request, response, injectors) { const raw = response.body; if (typeof raw !== "string" || raw.length > MAX_WEATHER_JSON_CHARACTERS || utf8Length(raw, MAX_WEATHER_BODY_BYTES) > MAX_WEATHER_BODY_BYTES) return response; const parameters = parseWeatherKitURL(url); - const latitude = Number(parameters.latitude), longitude = Number(parameters.longitude); + const latitude = Number(parameters.latitude), + longitude = Number(parameters.longitude); if (!parameters.version || parameters.latitude === undefined || parameters.longitude === undefined || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return response; let body; - try { body = JSON.parse(raw); } catch { return response; } - if (!object(body) || !productNames.some(name => object(body[name]) && object(body[name].metadata))) return response; + try { + body = JSON.parse(raw); + } catch { + return response; + } + if (!object(body) || !clientProductNames.some(name => object(body[name]) && object(body[name].metadata))) return response; let modified = false; - for (const name of [...productNames, "forecastPeriodic", "weatherChanges"]) { + for (const name of clientProductNames) { if (!object(body[name])) continue; - try { modified = adaptV3ClientProduct(body[name], name) || modified; } catch { /* Keep products beyond processing bounds. */ } + try { + modified = adaptV3ClientProduct(body[name], name) || modified; + } catch { + /* Keep products beyond processing bounds. */ + } } const { Settings, Caches } = setENV("iRingo", "WeatherKit", database); Console.logLevel = Settings.LogLevel; - const regionParameters = parseWeatherKitURL(url, { fallback: Settings.Region?.Fallback ?? "Auto" }); + const regionParameters = parameters; const requested = regionParameters.dataSets.filter(name => Settings.DataSets.includes(name)); const selected = planWeatherProducts(requested, Settings, regionParameters.country, Boolean(body.forecastNextHour)); const eligible = selected.filter(name => { @@ -225,41 +322,45 @@ export default async function ResponseJSONV3(request, response, injectors) { if (body[name] !== undefined && (!object(body[name]) || !object(body[name].metadata))) return false; boundedWeatherJSON(body[name]); return typeof injectors[name] === "function"; - } catch { return false; } + } catch { + return false; + } }); if (!eligible.length) return modified ? encodeV3Response(body, response) : response; - const cache = ProviderRequestCache.fromSettings(Settings); - const environment = v3Environment(createProviderEnvironment({ ...regionParameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }, Settings, cache)); - try { - await Promise.all(eligible.map(async name => { + // Keep one bounded, normalized Apple fallback before provider replacements. + // Output-size failure must not restore the incompatible pre-normalization JSON. + const fallbackResponse = modified ? encodeV3Response(body, response) : response; + const transport = new ProviderTransport(); + const environment = v3Environment(createProviderEnvironment({ ...regionParameters, weatherKitLanguage: body.weatherAlerts?.metadata?.language }, Settings, transport)); + + await Promise.all( + eligible.map(async name => { const original = body[name]; - let validating = false; + try { const input = name === "airQuality" ? cloneWeatherProduct(original) : original; const value = name === "airQuality" ? await injectors[name](input, Settings, Caches, environment) : await injectors[name](input, Settings, environment); if (!weatherProductsEqual(value, original)) { - validating = true; // AQI and warning injectors can also add numeric metadata fields. body[name] = validateWeatherJSONV3(adaptV3ProviderProduct(value, name), name); modified = true; } - } catch (error) { + } catch { body[name] = original; - cache.noteFallback(name, validating ? "invalid" : providerErrorCode(error)); } - })); - if (!modified) return response; - return encodeV3Response(body, response); - } finally { cache.report("response-v3-json"); } + }), + ); + if (!modified) return response; + return encodeV3Response(body, fallbackResponse); } function encodeV3Response(body, response) { let encoded; - try { encoded = JSON.stringify(body); } catch { return response; } + try { + encoded = JSON.stringify(body); + } catch { + return response; + } if (encoded.length > MAX_WEATHER_JSON_CHARACTERS || utf8Length(encoded, MAX_WEATHER_BODY_BYTES) > MAX_WEATHER_BODY_BYTES) return response; - const headers = { ...response.headers }; - for (const name of Object.keys(headers)) if (["etag", "last-modified", "content-md5", "digest", "content-length", "cache-control"].includes(name.toLowerCase())) delete headers[name]; - headers["Cache-Control"] = "no-store"; - const { bodyBytes: _originalBytes, ...result } = response; - return { ...result, headers, body: encoded }; + return rewriteWeatherResponse(response, encoded); } diff --git a/src/types.d.ts b/src/types.d.ts index ff17f899..a2a1ebf9 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,17 +1,5 @@ -// Modified by hhhoratioxu, 2026-10-19: provider response cache settings. +// WeatherKit settings. export interface Settings { - ProviderFallback?: { Enabled?: boolean | string }; - RequestCache?: { - /** Successful provider JSON cache lifetime in seconds, 0 disables completed-response caching; maximum 300. */ - TTL?: number | string; - ForecastTTL?: number | string; - MinutelyTTL?: number | string; - AlertsTTL?: number | string; - BudgetMs?: number | string; - CooldownSeconds?: number | string; - Diagnostics?: boolean | string; - }; - Region?: { Fallback?: "Auto" | "Locale" | "CN" | "HK" | "MO" | "TW" }; DataSets?: { /** * [数据集] 替换地区 @@ -71,6 +59,8 @@ export interface Settings { }; WeatherAlerts?: { Provider?: "WeatherKit" | "QWeatherWeb" | "QWeather" | "ColorfulClouds"; + /** Source-native alert text preference; missing/Auto preserves existing behavior. No machine translation. */ + Language?: "Auto" | "zh-Hans" | "en"; }; NextHour?: { /** diff --git a/template/boxjs.settings.json b/template/boxjs.settings.json index 85ce3410..a991f649 100644 --- a/template/boxjs.settings.json +++ b/template/boxjs.settings.json @@ -1,151 +1 @@ -[ - { - "id": "@iRingo.WeatherKit.Settings.DataSets", - "name": "[数据集]", - "type": "checkboxes", - "val": ["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "news", "weatherAlerts", "weatherChange", "trendComparison", "locationInfo"], - "items": [ - { "key": "airQuality", "label": "空气质量" }, - { "key": "currentWeather", "label": "当前天气" }, - { "key": "forecastDaily", "label": "每日预报" }, - { "key": "forecastHourly", "label": "每小时预报" }, - { "key": "forecastNextHour", "label": "未来一小时降水强度" }, - { "key": "news", "label": "新闻" }, - { "key": "weatherAlerts", "label": "天气预警" }, - { "key": "weatherChange", "label": "天气变化" }, - { "key": "trendComparison", "label": "历史对比" }, - { "key": "locationInfo", "label": "位置信息" } - ], - "desc": "选择需要解析并处理的数据集;未选择的数据槽始终保持原样透传,不作修改。" - }, - { "id": "@iRingo.WeatherKit.Settings.Weather.Replace", "name": "[天气] 替换范围", "type": "text", "val": "CN", "desc": "正则表达式,只替换指定地区的天气。" }, - { - "id": "@iRingo.WeatherKit.Settings.Weather.Provider", - "name": "[天气] 数据源", - "type": "selects", - "val": "ColorfulClouds", - "items": [{ "key": "WeatherKit", "label": "WeatherKit(不替换)" }, { "key": "ColorfulClouds", "label": "彩云天气" }, { "key": "QWeather", "label": "和风天气" }], - "desc": "使用选定的数据源替换天气数据。" - }, - { - "id": "@iRingo.WeatherKit.Settings.WeatherAlerts.Provider", - "name": "[天气预警] 数据源", - "type": "selects", - "val": "QWeatherWeb", - "items": [{ "key": "WeatherKit", "label": "WeatherKit(不修改)" }, { "key": "QWeatherWeb", "label": "和风天气网页" }, { "key": "QWeather", "label": "和风天气 API" }, { "key": "ColorfulClouds", "label": "彩云天气 API" }], - "desc": "使用选定的数据源处理天气预警数据。" - }, - { - "id": "@iRingo.WeatherKit.Settings.NextHour.Provider", - "name": "[未来一小时降水强度] 数据源", - "type": "selects", - "val": "ColorfulClouds", - "items": [{ "key": "WeatherKit", "label": "WeatherKit(不添加)" }, { "key": "ColorfulClouds", "label": "彩云天气" }, { "key": "QWeather", "label": "和风天气" }], - "desc": "使用选定的数据源填充未来一小时降水强度的数据。" - }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Provider", "name": "[今日污染物] 数据源", "type": "selects", "val": "ColorfulClouds", "items": [{ "key": "ColorfulClouds", "label": "彩云天气" }, { "key": "QWeather", "label": "和风天气" }], "desc": "使用选定的数据源填补污染物数据。" }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Replace", - "name": "[今日污染物 - 单位转换] 替换目标", - "type": "checkboxes", - "val": [], - "items": [{ "key": "EPA_NowCast", "label": "美国AQI(EPA_NowCast)" }, { "key": "EU.EAQI", "label": "欧盟EAQI(EU.EAQI)" }, { "key": "HJ6332012", "label": "中国AQI(HJ6332012)" }, { "key": "UBA", "label": "德国LQI(UBA)" }], - "desc": "转换污染物的单位,方便与空气质量标准比对。单位转换会产生小数,有略微精度损失,且小数部分可能会被省略。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Mode", - "name": "[今日污染物 - 单位转换] 模式", - "type": "selects", - "val": "Scale", - "items": [ - { "key": "Scale", "label": "与空气质量标准的要求相同" }, - { "key": "ugm3", "label": "除非标准要求,都转为µg/m³" }, - { "key": "EU_ppb", "label": "除非标准要求,都转为欧盟ppb" }, - { "key": "US_ppb", "label": "除非标准要求,都转为美标ppb" }, - { "key": "Force_ugm3", "label": "µg/m³" }, - { "key": "Force_EU_ppb", "label": "欧盟ppb" }, - { "key": "Force_US_ppb", "label": "美标ppb" } - ], - "desc": "污染物单位的转换目标。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Replace", - "name": "[今日空气指数] 替换目标", - "type": "checkboxes", - "val": ["HJ6332012"], - "items": [ - { "key": "HJ6332012", "label": "中国AQI(HJ6332012)" }, - { "key": "IE.AQIH", "label": "爱尔兰AQIH(IE.AQIH)" }, - { "key": "AT.AQI", "label": "奥地利AQI(AT.AQI)" }, - { "key": "BE.BelAQI", "label": "比利时BelAQI(BE.BelAQI)" }, - { "key": "UBA", "label": "德国LQI(UBA)" }, - { "key": "FR.ATMO", "label": "法国IQA(FR.ATMO)" }, - { "key": "KR.CAI", "label": "韩国CAI(KR.CAI)" }, - { "key": "CA.AQHI", "label": "加拿大AQHI(CA.AQHI)" }, - { "key": "CZ.AQI", "label": "捷克AQI(CZ.AQI)" }, - { "key": "NL.LKI", "label": "荷兰LKI(NL.LKI)" }, - { "key": "EPA_NowCast", "label": "美国AQI(EPA_NowCast)" }, - { "key": "ICARS", "label": "墨西哥ICARS(ICARS)" }, - { "key": "EU.EAQI", "label": "欧盟EAQI(EU.EAQI)" }, - { "key": "CH.KBI", "label": "瑞士KBI(CH.KBI)" }, - { "key": "ES.MITECO", "label": "西班牙ICA(ES.MITECO)" }, - { "key": "SG.NEA", "label": "新加坡PSI(SG.NEA)" }, - { "key": "NAQI", "label": "印度NAQI(NAQI)" }, - { "key": "DAQI", "label": "英国DAQI(DAQI)" } - ], - "desc": "替换指定标准的空气质量指数。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Provider", - "name": "[今日空气指数] 数据源", - "type": "selects", - "val": "Calculate", - "items": [{ "key": "Calculate", "label": "iRingo内置算法" }, { "key": "ColorfulCloudsUS", "label": "彩云天气(美标,18年9月版)" }, { "key": "ColorfulCloudsCN", "label": "彩云天气(国标,12年2月版)" }, { "key": "QWeather", "label": "和风天气(国标,12年2月版)" }], - "desc": "使用选定的数据源填补和替换空气质量指数。" - }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants", "name": "[今日空气指数] 强制主要污染物", "type": "boolean", "val": true, "desc": "忽略国标(HJ 633—2012)的AQI > 50规定,始终将IAQI最大的空气污染物作为主要污染物。" }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Comparison.ReplaceWhenCurrentChange", "name": "[空气质量 - 对比昨日] 变化时替换", "type": "boolean", "val": false, "desc": "即使已有对比昨日数据,当今日空气质量指数发生变化时,替换对比昨日数据。" }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.PollutantsProvider", "name": "[昨日污染物] 数据源", "type": "selects", "val": "QWeather", "items": [{ "key": "QWeather", "label": "和风天气" }], "desc": "为iRingo内置算法提供污染物数据,计算出昨日的空气质量指数。" }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.IndexProvider", - "name": "[昨日空气指数] 数据源", - "type": "selects", - "val": "ColorfulCloudsUS", - "items": [{ "key": "Calculate", "label": "iRingo内置算法" }, { "key": "ColorfulCloudsUS", "label": "彩云天气(美标,18年9月版)" }, { "key": "ColorfulCloudsCN", "label": "彩云天气(国标,12年2月版)" }, { "key": "QWeather", "label": "和风天气(国标,12年2月版)" }], - "desc": "用来和今日空气质量指数对比的数据。" - }, - { - "id": "@iRingo.WeatherKit.Settings.AirQuality.Calculate.Algorithm", - "name": "[iRingo内置算法] 算法", - "type": "selects", - "val": "EU_EAQI", - "items": [ - { "key": "None", "label": "不转换" }, - { "key": "UBA", "label": "德国LQI(FB001846)" }, - { "key": "EU_EAQI", "label": "欧盟EAQI(ETC HE Report 2024/17)" }, - { "key": "WAQI_InstantCast_US", "label": "美标InstantCast(EPA-454/B-24-002)" }, - { "key": "WAQI_InstantCast_CN", "label": "国标InstantCast(HJ 633—2012)" }, - { "key": "WAQI_InstantCast_CN_25_DRAFT", "label": "国标InstantCast(HJ 633 2025年草案)" }, - { "key": "CA_AQHI", "label": "加拿大AQHI(10.17269/s41997-019-00237-w)" }, - { "key": "HK_AQHI", "label": "香港AQHI" }, - { "key": "AQHI_Multi_CN", "label": "AQHI-Multi(中国)" }, - { "key": "AQHI_Multi_CN_HK", "label": "AQHI-Multi(中国+香港)" }, - { "key": "CN_DEATH_AQHI", "label": "中国(致死风险)AQHI" }, - { "key": "CN_DEATH_HK_AQHI", "label": "中国(致死风险)+香港AQHI" } - ], - "desc": "使用内置算法,通过污染物数据本地计算空气指数。InstantCast源自于WAQI,美标版本使用了WAQI的臭氧标准。" - }, - { "id": "@iRingo.WeatherKit.Settings.AirQuality.Calculate.AllowOverRange", "name": "[iRingo内置算法] 允许指数超标", "type": "boolean", "val": true, "desc": "允许美标和国标的指数超过500。超过500时,指示颜色的小圆点会消失。" }, - { "id": "@iRingo.WeatherKit.Settings.API.ColorfulClouds.Token", "name": "[API] 彩云天气令牌", "type": "text", "val": "", "desc": "彩云天气 API 令牌", "placeholder": "123456789123456789abcdefghijklmnopqrstuv" }, - { "id": "@iRingo.WeatherKit.Settings.API.QWeather.Host", "name": "[API] 和风天气主机", "type": "text", "val": "devapi.qweather.com", "desc": "和风天气 API 使用的主机名", "placeholder": "devapi.qweather.com" }, - { "id": "@iRingo.WeatherKit.Settings.API.QWeather.Token", "name": "[API] 和风天气令牌", "type": "text", "val": "", "desc": "和风天气 API 令牌", "placeholder": "123456789123456789abcdefghijklmnopqrstuv" }, - { "id": "@iRingo.WeatherKit.Settings.API.WAQI.Token", "name": "[API] WAQI 令牌", "type": "text", "val": "", "desc": "WAQI API 令牌,填写此字段将自动使用WAQI高级API", "placeholder": "123456789123456789abcdefghijklmnopqrstuv" }, - { - "id": "@iRingo.WeatherKit.Settings.LogLevel", - "name": "[调试] 日志等级", - "type": "selects", - "val": "WARN", - "items": [{ "key": "OFF", "label": "关闭" }, { "key": "ERROR", "label": "❌ 错误" }, { "key": "WARN", "label": "⚠️ 警告" }, { "key": "INFO", "label": "ℹ️ 信息" }, { "key": "DEBUG", "label": "🅱️ 调试" }, { "key": "ALL", "label": "全部" }], - "desc": "选择脚本日志的输出等级,低于所选等级的日志将全部输出。" - } -] +[{"id":"@iRingo.WeatherKit.Settings.DataSets","name":"[数据集]","type":"checkboxes","val":["airQuality","currentWeather","forecastDaily","forecastHourly","forecastNextHour","news","weatherAlerts","weatherChange","trendComparison","locationInfo"],"items":[{"key":"airQuality","label":"空气质量"},{"key":"currentWeather","label":"当前天气"},{"key":"forecastDaily","label":"每日预报"},{"key":"forecastHourly","label":"每小时预报"},{"key":"forecastNextHour","label":"未来一小时降水强度"},{"key":"news","label":"新闻"},{"key":"weatherAlerts","label":"天气预警"},{"key":"weatherChange","label":"天气变化"},{"key":"trendComparison","label":"历史对比"},{"key":"locationInfo","label":"位置信息"}],"desc":"选择需要解析并处理的数据集;未选择的数据槽始终保持原样透传,不作修改。"},{"id":"@iRingo.WeatherKit.Settings.Weather.Replace","name":"[天气] 替换范围","type":"text","val":"CN","desc":"正则表达式,只替换指定地区的天气。"},{"id":"@iRingo.WeatherKit.Settings.Weather.Provider","name":"[天气] 数据源","type":"selects","val":"ColorfulClouds","items":[{"key":"WeatherKit","label":"WeatherKit(不替换)"},{"key":"ColorfulClouds","label":"彩云天气"},{"key":"QWeather","label":"和风天气"}],"desc":"使用选定的数据源替换天气数据。"},{"id":"@iRingo.WeatherKit.Settings.WeatherAlerts.Provider","name":"[天气预警] 数据源","type":"selects","val":"QWeatherWeb","items":[{"key":"WeatherKit","label":"WeatherKit(不修改)"},{"key":"QWeatherWeb","label":"和风天气网页"},{"key":"QWeather","label":"和风天气 API"},{"key":"ColorfulClouds","label":"彩云天气 API"}],"desc":"使用选定的数据源处理天气预警数据。"},{"id":"@iRingo.WeatherKit.Settings.WeatherAlerts.Language","name":"[天气预警] 正文语言偏好","type":"selects","val":"Auto","items":[{"key":"Auto","label":"自动(沿用请求语言)"},{"key":"zh-Hans","label":"简体中文(来源支持时)"},{"key":"en","label":"English(来源支持时)"}],"desc":"仅向所选来源请求预警语言,不使用第三方翻译。来源不支持或返回其他语言时保留原文、签发单位与出处;Apple 原始标题和页面标签可能仍随系统语言。"},{"id":"@iRingo.WeatherKit.Settings.NextHour.Provider","name":"[未来一小时降水强度] 数据源","type":"selects","val":"ColorfulClouds","items":[{"key":"WeatherKit","label":"WeatherKit(不添加)"},{"key":"ColorfulClouds","label":"彩云天气"},{"key":"QWeather","label":"和风天气"}],"desc":"使用选定的数据源填充未来一小时降水强度的数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Provider","name":"[今日污染物] 数据源","type":"selects","val":"ColorfulClouds","items":[{"key":"ColorfulClouds","label":"彩云天气"},{"key":"QWeather","label":"和风天气"}],"desc":"使用选定的数据源填补污染物数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Replace","name":"[今日污染物 - 单位转换] 替换目标","type":"checkboxes","val":[],"items":[{"key":"EPA_NowCast","label":"美国AQI(EPA_NowCast)"},{"key":"EU.EAQI","label":"欧盟EAQI(EU.EAQI)"},{"key":"HJ6332012","label":"中国AQI(HJ6332012)"},{"key":"UBA","label":"德国LQI(UBA)"}],"desc":"转换污染物的单位,方便与空气质量标准比对。单位转换会产生小数,有略微精度损失,且小数部分可能会被省略。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Pollutants.Units.Mode","name":"[今日污染物 - 单位转换] 模式","type":"selects","val":"Scale","items":[{"key":"Scale","label":"与空气质量标准的要求相同"},{"key":"ugm3","label":"除非标准要求,都转为µg/m³"},{"key":"EU_ppb","label":"除非标准要求,都转为欧盟ppb"},{"key":"US_ppb","label":"除非标准要求,都转为美标ppb"},{"key":"Force_ugm3","label":"µg/m³"},{"key":"Force_EU_ppb","label":"欧盟ppb"},{"key":"Force_US_ppb","label":"美标ppb"}],"desc":"污染物单位的转换目标。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Replace","name":"[今日空气指数] 替换目标","type":"checkboxes","val":["HJ6332012"],"items":[{"key":"HJ6332012","label":"中国AQI(HJ6332012)"},{"key":"IE.AQIH","label":"爱尔兰AQIH(IE.AQIH)"},{"key":"AT.AQI","label":"奥地利AQI(AT.AQI)"},{"key":"BE.BelAQI","label":"比利时BelAQI(BE.BelAQI)"},{"key":"UBA","label":"德国LQI(UBA)"},{"key":"FR.ATMO","label":"法国IQA(FR.ATMO)"},{"key":"KR.CAI","label":"韩国CAI(KR.CAI)"},{"key":"CA.AQHI","label":"加拿大AQHI(CA.AQHI)"},{"key":"CZ.AQI","label":"捷克AQI(CZ.AQI)"},{"key":"NL.LKI","label":"荷兰LKI(NL.LKI)"},{"key":"EPA_NowCast","label":"美国AQI(EPA_NowCast)"},{"key":"ICARS","label":"墨西哥ICARS(ICARS)"},{"key":"EU.EAQI","label":"欧盟EAQI(EU.EAQI)"},{"key":"CH.KBI","label":"瑞士KBI(CH.KBI)"},{"key":"ES.MITECO","label":"西班牙ICA(ES.MITECO)"},{"key":"SG.NEA","label":"新加坡PSI(SG.NEA)"},{"key":"NAQI","label":"印度NAQI(NAQI)"},{"key":"DAQI","label":"英国DAQI(DAQI)"}],"desc":"替换指定标准的空气质量指数。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Index.Provider","name":"[今日空气指数] 数据源","type":"selects","val":"Calculate","items":[{"key":"Calculate","label":"iRingo内置算法"},{"key":"ColorfulCloudsUS","label":"彩云天气(美标,18年9月版)"},{"key":"ColorfulCloudsCN","label":"彩云天气(国标,12年2月版)"},{"key":"QWeather","label":"和风天气(国标,12年2月版)"}],"desc":"使用选定的数据源填补和替换空气质量指数。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Current.Index.ForceCNPrimaryPollutants","name":"[今日空气指数] 强制主要污染物","type":"boolean","val":true,"desc":"忽略国标(HJ 633—2012)的AQI > 50规定,始终将IAQI最大的空气污染物作为主要污染物。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Comparison.ReplaceWhenCurrentChange","name":"[空气质量 - 对比昨日] 变化时替换","type":"boolean","val":false,"desc":"即使已有对比昨日数据,当今日空气质量指数发生变化时,替换对比昨日数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.PollutantsProvider","name":"[昨日污染物] 数据源","type":"selects","val":"QWeather","items":[{"key":"QWeather","label":"和风天气"}],"desc":"为iRingo内置算法提供污染物数据,计算出昨日的空气质量指数。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Comparison.Yesterday.IndexProvider","name":"[昨日空气指数] 数据源","type":"selects","val":"ColorfulCloudsUS","items":[{"key":"Calculate","label":"iRingo内置算法"},{"key":"ColorfulCloudsUS","label":"彩云天气(美标,18年9月版)"},{"key":"ColorfulCloudsCN","label":"彩云天气(国标,12年2月版)"},{"key":"QWeather","label":"和风天气(国标,12年2月版)"}],"desc":"用来和今日空气质量指数对比的数据。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Calculate.Algorithm","name":"[iRingo内置算法] 算法","type":"selects","val":"EU_EAQI","items":[{"key":"None","label":"不转换"},{"key":"UBA","label":"德国LQI(FB001846)"},{"key":"EU_EAQI","label":"欧盟EAQI(ETC HE Report 2024/17)"},{"key":"WAQI_InstantCast_US","label":"美标InstantCast(EPA-454/B-24-002)"},{"key":"WAQI_InstantCast_CN","label":"国标InstantCast(HJ 633—2012)"},{"key":"WAQI_InstantCast_CN_25_DRAFT","label":"国标InstantCast(HJ 633 2025年草案)"},{"key":"CA_AQHI","label":"加拿大AQHI(10.17269/s41997-019-00237-w)"},{"key":"HK_AQHI","label":"香港AQHI"},{"key":"AQHI_Multi_CN","label":"AQHI-Multi(中国)"},{"key":"AQHI_Multi_CN_HK","label":"AQHI-Multi(中国+香港)"},{"key":"CN_DEATH_AQHI","label":"中国(致死风险)AQHI"},{"key":"CN_DEATH_HK_AQHI","label":"中国(致死风险)+香港AQHI"}],"desc":"使用内置算法,通过污染物数据本地计算空气指数。InstantCast源自于WAQI,美标版本使用了WAQI的臭氧标准。"},{"id":"@iRingo.WeatherKit.Settings.AirQuality.Calculate.AllowOverRange","name":"[iRingo内置算法] 允许指数超标","type":"boolean","val":true,"desc":"允许美标和国标的指数超过500。超过500时,指示颜色的小圆点会消失。"},{"id":"@iRingo.WeatherKit.Settings.API.ColorfulClouds.Token","name":"[API] 彩云天气令牌","type":"text","val":"","desc":"彩云天气 API 令牌","placeholder":"123456789123456789abcdefghijklmnopqrstuv"},{"id":"@iRingo.WeatherKit.Settings.API.QWeather.Host","name":"[API] 和风天气主机","type":"text","val":"devapi.qweather.com","desc":"和风天气 API 使用的主机名","placeholder":"devapi.qweather.com"},{"id":"@iRingo.WeatherKit.Settings.API.QWeather.Token","name":"[API] 和风天气令牌","type":"text","val":"","desc":"和风天气 API 令牌","placeholder":"123456789123456789abcdefghijklmnopqrstuv"},{"id":"@iRingo.WeatherKit.Settings.API.WAQI.Token","name":"[API] WAQI 令牌","type":"text","val":"","desc":"WAQI API 令牌,填写此字段将自动使用WAQI高级API","placeholder":"123456789123456789abcdefghijklmnopqrstuv"},{"id":"@iRingo.WeatherKit.Settings.LogLevel","name":"[调试] 日志等级","type":"selects","val":"WARN","items":[{"key":"OFF","label":"关闭"},{"key":"ERROR","label":"❌ 错误"},{"key":"WARN","label":"⚠️ 警告"},{"key":"INFO","label":"ℹ️ 信息"},{"key":"DEBUG","label":"🅱️ 调试"},{"key":"ALL","label":"全部"}],"desc":"选择脚本日志的输出等级,低于所选等级的日志将全部输出。"}] \ No newline at end of file diff --git a/template/loon.handlebars b/template/loon.handlebars index 695dfb97..47cf6eb5 100644 --- a/template/loon.handlebars +++ b/template/loon.handlebars @@ -9,7 +9,6 @@ #!date = {{now "yyyy-MM-dd HH:mm:ss"}} #!version = {{@package 'version'}} #!system_version = {{ @package 'systemVersion'}} -#!loon_version = 3.5.1(983) [Argument] {{{arguments}}} @@ -30,7 +29,7 @@ http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/airQualityScale\/[A-Za http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=[^&#]*-[0-9]{9}(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.html.request, argument={{{scriptParams}}} http-request ^https?:\/\/weatherkit\.apple\.com\/api\/v1\/weatherAlerts\?(?:[^#]*&)?ids=-?[0-9]+(?:\.[0-9]+)?,-?[0-9]+(?:\.[0-9]+)?(?:&|$) script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/request.loon.bundle.js, tag=🌤 WeatherKit.api.v1.weatherAlerts.api.request, argument={{{scriptParams}}} http-response ^https?:\/\/weatherkit\.apple\.com\/api\/v2\/weather\/ requires-body=1, binary-body-mode=1, script-path=https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js, tag=🌤 WeatherKit.api.v2.weather.response, argument={{{scriptParams}}} -response if ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// && ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${response.status} == 200 && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js", {${DataSets}, ${Weather.Provider}, ${WeatherAlerts.Provider}, ${NextHour.Provider}, ${AirQuality.Current.Pollutants.Provider}, ${AirQuality.Calculate.Algorithm}, ${API.ColorfulClouds.Token}, ${API.QWeather.Host}, ${API.QWeather.Token}, ${API.WAQI.Token}, ${Storage}, ${LogLevel}, ${RequestCache.TTL}, ${RequestCache.ForecastTTL}, ${RequestCache.MinutelyTTL}, ${RequestCache.AlertsTTL}, ${RequestCache.BudgetMs}, ${RequestCache.CooldownSeconds}, ${RequestCache.Diagnostics}, ${Region.Fallback}, ${ProviderFallback.Enabled}}) with requires_body=true, tag="🌤 WeatherKit.api.v3.weather.json.response" +response if ${url} ~= /^https:\/\/weatherkit\.apple\.com\/api\/v3\/weather\// && ${request.method} == "GET" && ${request.header['User-Agent']} ~= /^WeatherKit_Weather_(iOS|macOS)_/ && ${response.status} == 200 && ${response.header['Content-Type']} ~= /^application\/json(?:;|$)/i then script("https://github.com/NSRingo/WeatherKit/releases/download/v{{@package 'version'}}/response.loon.bundle.js", {${DataSets}, ${Weather.Provider}, ${WeatherAlerts.Provider}, ${WeatherAlerts.Language}, ${NextHour.Provider}, ${AirQuality.Current.Pollutants.Provider}, ${AirQuality.Calculate.Algorithm}, ${API.ColorfulClouds.Token}, ${API.QWeather.Host}, ${API.QWeather.Token}, ${API.WAQI.Token}, ${Storage}, ${LogLevel}}) with requires_body=true, tag="🌤 WeatherKit.api.v3.weather.json.response" [MITM] hostname = weatherkit.apple.com diff --git a/tests/fixtures/cacheRuntimeEntry.mjs b/tests/fixtures/cacheRuntimeEntry.mjs deleted file mode 100644 index 956aa23b..00000000 --- a/tests/fixtures/cacheRuntimeEntry.mjs +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -export { default as ProviderRequestCache } from "../../src/class/ProviderRequestCache.mjs"; -export { default as WAQI } from "../../src/class/WAQI.mjs"; diff --git a/tests/mergeWeatherAlertsProduct.test.mjs b/tests/mergeWeatherAlertsProduct.test.mjs new file mode 100644 index 00000000..3bc9c249 --- /dev/null +++ b/tests/mergeWeatherAlertsProduct.test.mjs @@ -0,0 +1,42 @@ +// Focused, synthetic regressions for the independently reviewable alert-state fix. +import assert from "node:assert/strict"; +import test from "node:test"; +import mergeWeatherAlertsProduct from "../src/function/mergeWeatherAlertsProduct.mjs"; +import { Console } from "@nsnanocat/util"; +Console.logLevel = "OFF"; + +const original = (alerts = []) => ({ metadata: { providerName: "Apple", temporarilyUnavailable: true }, detailsUrl: "https://weatherkit.apple.com/original", alerts, future: { retain: true } }); +const nativeAlert = () => ({ id: "00112233-4455-6677-8899-aabbccddeeff", description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", message: "" }); +const supplements = [ + undefined, + { metadata: { temporarilyUnavailable: false, providerName: "provider" } }, + { metadata: { temporarilyUnavailable: false }, alerts: [] }, + { metadata: { temporarilyUnavailable: true }, alerts: [{ description: "unavailable source" }] }, + { metadata: { temporarilyUnavailable: false }, alerts: [{ expireTime: "2000-01-01T00:00:00Z", description: "expired" }] }, +]; +for (const [index, source] of supplements.entries()) for (const filled of [false, true]) test(`no usable alert enrichment preserves the original product (${index}, existing=${filled})`, () => { + const apple = original(filled ? [nativeAlert()] : []); + const before = structuredClone(apple); + assert.strictEqual(mergeWeatherAlertsProduct(apple, source), apple); + assert.deepEqual(apple, before); +}); + +test("a provider warning cannot manufacture an Apple native event when its destination is empty", () => { + const apple = original(); + const source = { metadata: { temporarilyUnavailable: false }, alerts: [{ ...nativeAlert(), message: "source" }] }; + assert.strictEqual(mergeWeatherAlertsProduct(apple, source), apple); + assert.equal(apple.metadata.temporarilyUnavailable, true); +}); + +test("an installed warning field permits metadata enrichment while preserving both inputs and the native ID", () => { + const apple = original([nativeAlert()]); + const source = { metadata: { providerName: "provider", temporarilyUnavailable: false, providerLogo: "https://fixture.example/logo" }, alerts: [{ description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", expireTime: "2099-01-01T00:00:00Z" }] }; + const beforeApple = structuredClone(apple), beforeSource = structuredClone(source); + const result = mergeWeatherAlertsProduct(apple, source); + assert.equal(result.metadata.temporarilyUnavailable, false); + assert.equal(result.alerts[0].expireTime, Date.parse("2099-01-01T00:00:00Z") / 1000); + assert.equal(result.alerts[0].id, beforeApple.alerts[0].id); + assert.deepEqual(result.future, beforeApple.future); + assert.deepEqual(apple, beforeApple); + assert.deepEqual(source, beforeSource); +}); diff --git a/tests/pr107ReviewRegressions.test.mjs b/tests/pr107ReviewRegressions.test.mjs new file mode 100644 index 00000000..aa31a4c2 --- /dev/null +++ b/tests/pr107ReviewRegressions.test.mjs @@ -0,0 +1,171 @@ +// Synthetic fixtures. Review cases tracked in NSRingo/WeatherKit #107 and #110. +// Unknown-container and output-size cases reference #110 at f4676feae89a27a8403c574c6086a433d5adb3c9 (hhh2210, Apache-2.0). +import assert from "node:assert/strict"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; +const [{ Response }, { Response: ResponseDev }, { default: QWeather }, { default: ColorfulClouds }, { default: WeatherKit2 }, json, { Console }] = await Promise.all([ + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/class/ColorfulClouds.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("../src/process/ResponseJSONV3.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; +const metadata = { temporarilyUnavailable: false, providerName: "Apple fixture" }; +const request = (products = "weatherAlerts", version = "v3") => (globalThis.$request = { method: "GET", url: `https://weatherkit.apple.com/api/${version}/weather/en-US/31.2/121.5?country=CN&dataSets=${products}`, headers: {} }); +const response = body => ({ status: 200, headers: { "Content-Type": "application/json", ETag: "apple-entity" }, body: JSON.stringify(body) }); +const settings = () => (globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "weatherAlerts,currentWeather,airQuality", Weather: { Provider: "QWeather", Replace: ["CN"] }, WeatherAlerts: { Provider: "QWeather" } }); +const activeWarning = { description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", expireTime: "2099-01-01T00:00:00Z" }; +const { default: AirQuality } = await import("../src/class/AirQuality.mjs"); + +for (const invalid of [true, false, 1.2, "1.5", {}, [], [2], null, undefined, "", 0, -1, Infinity, NaN]) + test(`AQ comparison rejects a malformed category: ${String(invalid)} (${typeof invalid})`, () => { + assert.equal(AirQuality.CompareCategoryIndexes(invalid, 2), AirQuality.Config.CompareCategoryIndexes.UNKNOWN); + assert.equal(AirQuality.CompareCategoryIndexes(2, invalid), AirQuality.Config.CompareCategoryIndexes.UNKNOWN); + }); +test("AQ comparison keeps numeric strings and valid integer category semantics", () => { + assert.equal(AirQuality.CompareCategoryIndexes("2", 2), AirQuality.Config.CompareCategoryIndexes.SAME); + assert.equal(AirQuality.CompareCategoryIndexes(1, 2), AirQuality.Config.CompareCategoryIndexes.BETTER); + assert.equal(AirQuality.CompareCategoryIndexes(2, 1), AirQuality.Config.CompareCategoryIndexes.WORSE); +}); + +test("malformed JSON category objects are rejected before coercion or diagnostic formatting", () => { + const invalid = JSON.parse('{"toString":null,"valueOf":null}'); + assert.equal(AirQuality.CompareCategoryIndexes(invalid, 2), AirQuality.Config.CompareCategoryIndexes.UNKNOWN); +}); + +for (const [label, process] of [ + ["release", Response], + ["development", ResponseDev], +]) { + for (const sourceAlerts of [[activeWarning], [], [{ ...activeWarning, expireTime: "2000-01-01T00:00:00Z" }]]) + test(`${label}: an unavailable empty Apple alert collection stays unavailable when no native warning can be merged (${sourceAlerts.length}, ${sourceAlerts[0]?.expireTime ?? "none"})`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "provider" }, alerts: sourceAlerts, detailsUrl: "https://fixture.example/alert" })); + const body = { weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [], detailsUrl: "https://weatherkit.apple.com/original" } }; + const original = response(body); + const output = await process(request(), original); + assert.equal(output.body, original.body); + assert.equal(output.headers.ETag, "apple-entity"); + assert.deepEqual(JSON.parse(output.body).weatherAlerts, body.weatherAlerts); + }); + + test(`${label}: a failed alert source keeps Apple's unavailable state and exact entity`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: true }, alerts: [] })); + const original = response({ weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [] } }); + assert.equal((await process(request(), original)).body, original.body); + }); + + test(`${label}: healthy no-warning data stays byte-identical when a source also reports no warnings`, async t => { + settings(); + const fetch = t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "provider" }, alerts: [] })); + const original = response({ weatherAlerts: { metadata: { ...metadata, providerName: "National Early Warning Center" }, alerts: [] } }); + assert.equal((await process(request(), original)).body, original.body); + assert.equal(fetch.mock.callCount(), 1); + }); + + test(`${label}: an actual warning merge can restore availability without replacing the native ID`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "provider", providerLogo: "https://fixture.example/logo.png" }, alerts: [activeWarning] })); + const warning = { id: "00112233-4455-6677-8899-aabbccddeeff", description: "暴雨橙色预警", phenomenon: "Rain", severity: "severe", message: "" }; + const original = response({ weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [warning] } }); + const output = JSON.parse((await process(request(), original)).body).weatherAlerts; + assert.equal(output.metadata.temporarilyUnavailable, false); + assert.equal(output.alerts[0].id, warning.id); + assert.equal(output.metadata.providerLogo, "https://fixture.example/logo.png"); + }); + + test(`${label}: WK2 empty unavailable alerts retain original binary bytes`, async t => { + settings(); + t.mock.method(QWeather.prototype, "WeatherAlert", async () => ({ metadata: { temporarilyUnavailable: false }, alerts: [activeWarning] })); + const raw = WeatherKit2.encode(undefined, { weatherAlerts: { metadata: { ...metadata, temporarilyUnavailable: true }, alerts: [] } }); + const original = { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer", ETag: "apple-binary" }, body: raw }; + const output = await process(request("weatherAlerts", "v2"), original); + assert.deepEqual(output.body, raw); + assert.equal(WeatherKit2.decode(new ByteBuffer(output.body), ["weatherAlerts"]).weatherAlerts.metadata.temporarilyUnavailable, true); + assert.equal(output.headers.ETag, "apple-binary"); + }); + + test(`${label}: EAQI omits unsupported CO sub-indices without losing the normalized concentration`, async () => { + settings(); + globalThis.$argument.DataSets = "airQuality"; + globalThis.$argument.AirQuality = { Calculate: { Algorithm: "EU_EAQI" }, Comparison: { ReplaceWhenCurrentChange: false } }; + const pollutants = [["PM10", 40], ["NO2", 57], ["CO", 0.6], ["OZONE", 46], ["PM2_5", 26], ["SO2", 9]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); + const original = response({ airQuality: { metadata: { ...metadata, providerName: "QWeather" }, index: 40, categoryIndex: 1, scale: "HJ6332012.2604", pollutants } }); + const output = JSON.parse((await process(request("airQuality"), original)).body).airQuality; + assert.match(output.scale, /^EU\.EAQI/); + const co = output.pollutants.find(p => p.pollutantType === "CO"); + assert.equal(co.amount, 600); + assert.equal(co.index, undefined); + assert.equal(co.categoryIndex, undefined); + }); +} + +const future = () => ({ conditionCode: ["FUTURE"], precipitationAmount: { expected: 8 }, index: "opaque", humidity: 101, metadata: { latitude: "opaque" }, hours: [{ conditionCode: ["ALSO_FUTURE"] }], values: [null] }); +test("Apple unknown nested containers keep their original types while known rest-of-day and periodic rows adapt", () => { + const unknown = future(); + const value = { days: [{ restOfDayForecast: { conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 3, precipitationType: ["RAIN"] }] }, future: structuredClone(unknown) }], future: structuredClone(unknown) }; + json.adaptV3ClientProduct(value, "forecastDaily"); + assert.equal(value.days[0].restOfDayForecast.conditionCode, "RAIN"); + assert.equal(value.days[0].restOfDayForecast.precipitationAmount, 3); + assert.deepEqual(value.future, unknown); + assert.deepEqual(value.days[0].future, unknown); +}); + +test("provider adaptation and validation preserve opaque nested containers", () => { + const unknown = future(); + const value = { metadata: { ...metadata }, temperature: 23, conditionCode: ["RAIN"], future: structuredClone(unknown), futureNull: null }; + json.adaptV3ProviderProduct(value, "currentWeather"); + assert.equal(value.conditionCode, "RAIN"); + assert.doesNotThrow(() => json.validateWeatherJSONV3(value, "currentWeather")); + assert.deepEqual(value.future, unknown); + assert.equal(value.futureNull, null); +}); + +test("a provider result exceeding the final output limit falls back to normalized Apple JSON", async () => { + settings(); + const apple = { currentWeather: { metadata, temperature: 18, conditionCode: ["CLOUDY"] }, forecastPeriodic: { metadata, forecasts: [{ periods: [{ conditionCode: ["RAIN"] }] }] } }; + const output = await json.default(request("currentWeather"), response(apple), { currentWeather: async () => ({ metadata, temperature: 23, conditionCode: "RAIN", future: "x".repeat(json.MAX_WEATHER_JSON_CHARACTERS) }) }); + const actual = JSON.parse(output.body); + assert.equal(actual.currentWeather.temperature, 18); + assert.equal(actual.currentWeather.conditionCode, "CLOUDY"); + assert.equal(actual.forecastPeriodic.forecasts[0].periods[0].conditionCode, "RAIN"); + assert.equal(output.headers.ETag, undefined); +}); + +test("periodic-only responses normalize without querying a provider", async () => { + settings(); + const value = { forecastPeriodic: { metadata, forecasts: [{ periods: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 2, precipitationType: ["RAIN"] }] }] }] } }; + const result = JSON.parse((await json.default(request("forecastPeriodic"), response(value), { currentWeather: () => assert.fail("No replacement target") })).body); + assert.equal(result.forecastPeriodic.forecasts[0].periods[0].precipitationAmount, 2); +}); + +test("Caiyun concurrent current weather cannot supply alerts after the alert-specific request fails", async () => { + const pending = []; + globalThis.$httpClient = { get: (outgoing, callback) => pending.push({ outgoing, callback }) }; + const provider = new ColorfulClouds({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en" }, "fixture"); + const current = provider.CurrentWeather(); + const alerts = provider.WeatherAlert(); + assert.equal(pending.length, 2); + const body = { + status: "ok", + server_time: 1800000000, + result: { + alert: { status: "ok", content: [{ code: "0202", title: "fixture rain", pubtimestamp: 1800000000 }] }, + realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.8, skycon: "CLOUDY", humidity: 0.5, precipitation: { local: { intensity: 0 } }, pressure: 100000, visibility: 10, wind: { direction: 90, speed: 2 } }, + }, + }; + pending[0].callback(null, { status: 200, headers: {} }, JSON.stringify(body)); + pending[1].callback("timeout", {}, ""); + assert.equal((await current).cloudCover, 80); + const result = await alerts; + assert.equal(result.metadata.temporarilyUnavailable, true); + assert.deepEqual(result.alerts, []); +}); diff --git a/tests/providerAdapterRequestCache.test.mjs b/tests/providerAdapterRequestCache.test.mjs deleted file mode 100644 index 046bc29e..00000000 --- a/tests/providerAdapterRequestCache.test.mjs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import test from "node:test"; - -globalThis.$loon = "Mac simulated Loon"; -const values = new Map(); -globalThis.$persistentStore = { - read: key => values.get(key), - write: (value, key) => { - values.set(key, value); - return true; - }, -}; -let pending = []; -globalThis.$httpClient = { - get(request, callback) { - pending.push({ request, callback }); - }, -}; -const [{ default: ColorfulClouds }, { default: QWeather }, { default: ProviderRequestCache }, { Console }] = await Promise.all([import("../src/class/ColorfulClouds.mjs"), import("../src/class/QWeather.mjs"), import("../src/class/ProviderRequestCache.mjs"), import("@nsnanocat/util")]); -Console.logLevel = "OFF"; -const parameters = { latitude: 31.2, longitude: 121.5, country: "CN", language: "zh-CN", version: "v2" }; -const finish = (body, status = 200) => { - const tasks = pending; - pending = []; - for (const task of tasks) task.callback(null, { status, headers: {} }, JSON.stringify(body)); -}; -const realtime = { - status: "ok", - location: [31.2, 121.5], - server_time: 1_790_000_000, - result: { - server_time: 1_790_000_000, - realtime: { status: "ok", temperature: 23, apparent_temperature: 24, cloudrate: 0.2, skycon: "CLEAR_DAY", humidity: 0.8, precipitation: { local: { intensity: 0 } }, pressure: 100_000, visibility: 10, wind: { direction: 90, speed: 1 } }, - alert: { status: "ok", content: [] }, - }, -}; -const weatherNow = { - code: "200", - updateTime: "2026-10-19T00:00:00+08:00", - fxLink: "https://example.test/weather", - now: { obsTime: "2026-10-19T00:00:00+08:00", temp: "23", feelsLike: "24", text: "晴", icon: "100", wind360: "90", windSpeed: "1", windScale: "1", humidity: "80", precip: "0", pressure: "1000", vis: "10", cloud: "20", dew: "15" }, -}; - -test("Caiyun real provider coalesces six requests and refreshes when expired", async () => { - values.clear(); - let now = 100_000; - const cache = new ProviderRequestCache({ now: () => now }); - const provider = new ColorfulClouds(parameters, "fixture-caiyun-token", cache); - const consumers = Array.from({ length: 6 }, () => provider.CurrentWeather()); - assert.equal(pending.length, 1); - assert.equal(pending[0].request.timeout, 5000); - finish(realtime); - assert.ok((await Promise.all(consumers)).every(product => product.temperature === 23)); - assert.equal((await provider.CurrentWeather()).temperature, 23); - assert.equal(pending.length, 0); - now += 60_000; - const refresh = provider.CurrentWeather(); - assert.equal(pending.length, 1); - finish({ status: "error" }, 503); - assert.equal((await refresh).metadata.temporarilyUnavailable, true); - const retry = provider.CurrentWeather(); - assert.equal(pending.length, 1); - finish(realtime); - assert.equal((await retry).temperature, 23); -}); - -test("Caiyun yesterday hourly queries share a stable hour key across fresh executions", async () => { - values.clear(); - const dateNow = Date.now; - let now = Date.UTC(2026, 9, 19, 0, 0, 10), - requests = 0; - Date.now = () => now; - const hourly = { status: "ok", result: { hourly: { status: "ok", air_quality: { aqi: [{ value: { usa: 42, chn: 40 } }] } } } }; - try { - for (let i = 0; i < 6; i++, now += 10000) { - const provider = new ColorfulClouds(parameters, "hourly-fixture-token", new ProviderRequestCache({ ttls: { forecast: 180 }, now: () => now })); - const result = provider.YesterdayAirQuality(); - if (pending.length) { - requests++; - assert.equal(new URL(pending[0].request.url).searchParams.get("begin"), String(Date.UTC(2026, 9, 18) / 1000)); - finish(hourly); - } - assert.equal((await result).index, 42); - } - assert.equal(requests, 1); - now = Date.UTC(2026, 9, 19, 1, 0, 0); - const result = new ColorfulClouds(parameters, "hourly-fixture-token", new ProviderRequestCache({ now: () => now })).YesterdayAirQuality(); - assert.equal(pending.length, 1); - assert.equal(new URL(pending[0].request.url).searchParams.get("begin"), String(Date.UTC(2026, 9, 18, 1) / 1000)); - finish(hourly); - assert.equal((await result).index, 42); - } finally { - Date.now = dateNow; - } -}); - -test("QWeather real provider shares requests and isolates changed authorization", async () => { - values.clear(); - const cache = new ProviderRequestCache(); - const provider = new QWeather(parameters, "fixture-qweather-token", "devapi.qweather.com", cache); - const consumers = Array.from({ length: 6 }, () => provider.WeatherNow()); - assert.equal(pending.length, 1); - assert.equal(pending[0].request.headers["X-QW-Api-Key"], "fixture-qweather-token"); - finish(weatherNow); - assert.ok((await Promise.all(consumers)).every(product => product.temperature === 23)); - const same = new QWeather(parameters, "fixture-qweather-token", "devapi.qweather.com", new ProviderRequestCache()); - assert.equal((await same.WeatherNow()).temperature, 23); - assert.equal(pending.length, 0); - const changed = new QWeather(parameters, "changed-qweather-token", "devapi.qweather.com", cache); - const next = changed.WeatherNow(); - assert.equal(pending.length, 1); - finish({ ...weatherNow, now: { ...weatherNow.now, temp: "24" } }); - assert.equal((await next).temperature, 24); -}); - -for (const order of ["alerts-first", "current-first"]) { - test(`Caiyun keeps healthy current weather when its shared alert subproduct fails (${order})`, async () => { - values.clear(); - const cache = new ProviderRequestCache(); - const provider = new ColorfulClouds(parameters, "fixture-caiyun-token", cache); - const tasks = order === "alerts-first" ? [provider.WeatherAlert(), provider.CurrentWeather()] : [provider.CurrentWeather(), provider.WeatherAlert()]; - assert.equal(pending.length, 1); - finish({ ...realtime, result: { ...realtime.result, alert: { status: "error" } } }); - const results = await Promise.all(tasks); - const [alerts, current] = order === "alerts-first" ? results : results.toReversed(); - assert.equal(alerts.metadata.temporarilyUnavailable, true); - assert.equal(current.temperature, 23); - assert.equal(cache.stats.requests, 1); - assert.equal(cache.stats.failures, 1); - }); -} - -test("QWeather rejects nonnumeric temperature even if its JSON code says 200", async () => { - values.clear(); - const cache = new ProviderRequestCache(); - const provider = new QWeather(parameters, "fixture-qweather-token", "devapi.qweather.com", cache); - const task = provider.WeatherNow(); - finish({ ...weatherNow, now: { ...weatherNow.now, temp: "not-a-number" } }); - assert.equal(await task, undefined); - assert.equal(cache.stats.failureReasons.invalid, 1); -}); - -test("an unchanged QWeather location grid survives a native 304 response", async () => { - const existing = { lastUpdated: Date.now() - 31 * 86400000, etag: "fixture-grid", data: { "31,121": [{ name: "fixture" }] } }; - const runtime = new ProviderRequestCache(); - let updated; - const task = QWeather.GetLocationsGrid( - { locationsGrid: existing }, - value => { - updated = value; - }, - runtime, - ); - assert.equal(pending.length, 1); - assert.equal(pending[0].request.headers["If-None-Match"], "fixture-grid"); - finish(undefined, 304); - assert.deepEqual(await task, existing.data); - assert.equal(updated.locationsGrid.data, existing.data); - assert.equal(updated.locationsGrid.etag, existing.etag); - assert.ok(updated.locationsGrid.lastUpdated > existing.lastUpdated); - assert.equal(runtime.stats.failures, 0); -}); - -test("a provider receives exactly 499 ms when only 499 ms of the dispatch budget remains", async () => { - values.clear(); - let now = 100000; - const runtime = new ProviderRequestCache({ now: () => now, budgetMs: 1000 }); - const provider = new QWeather(parameters, "fixture-budget-token", "devapi.qweather.com", runtime); - now += 501; - const task = provider.WeatherNow(); - assert.equal(pending.length, 1); - assert.equal(pending[0].request.timeout, 499); - finish(weatherNow); - assert.equal((await task).temperature, 23); -}); diff --git a/tests/providerCacheBundle.test.mjs b/tests/providerCacheBundle.test.mjs deleted file mode 100644 index d85563f1..00000000 --- a/tests/providerCacheBundle.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import test from "node:test"; -import vm from "node:vm"; -import { rollup } from "rollup"; -import { nodeResolve } from "@rollup/plugin-node-resolve"; - -test("browser bundle works with Loon callbacks and storage, without Node, WebCrypto or TextEncoder", async () => { - const bundle = await rollup({ input: new URL("./fixtures/cacheRuntimeEntry.mjs", import.meta.url).pathname, plugins: [nodeResolve({ browser: true })] }); - const { output } = await bundle.generate({ format: "iife", name: "CacheTestRuntime" }); - await bundle.close(); - const code = output[0].code; - assert.doesNotMatch(code, /node:crypto|node:fs|require\s*\(/); - const values = new Map(); - let calls = 0; - const callbacks = []; - const makeContext = () => - vm.createContext({ - $loon: "Mac simulated Loon 3.5.1", - $persistentStore: { - read: key => values.get(key), - write: (value, key) => { - values.set(key, value); - return true; - }, - }, - $httpClient: { - get(request, callback) { - assert.equal(request.timeout, 5000); - calls++; - callbacks.push(() => callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", data: { city: { name: "Fixture", geo: [31.2, 121.5] }, aqi: "42", dominentpol: "pm25", time: { v: 1_790_000_000 }, idx: 123 } }))); - }, - }, - console: { log() {}, error() {}, warn() {} }, - }); - const context = makeContext(); - vm.runInContext(code, context); - assert.equal(context.process, undefined); - assert.equal(context.crypto, undefined); - assert.equal(context.TextEncoder, undefined); - const source = `const provider = new CacheTestRuntime.WAQI({latitude:31.2,longitude:121.5,country:"CN",language:"zh-CN",version:"v2"}, "fixture-token"); - globalThis.result = Promise.all(Array.from({length:6}, () => provider.AQI2()));`; - vm.runInContext(source, context); - assert.equal(calls, 1); - const done = callbacks.shift(); - done(); - done(); // A duplicate native callback must not produce an extra cache write or completion. - const results = await context.result; - assert.equal(results.length, 6); - assert.ok(results.every(product => product.index === 42)); - - const next = makeContext(); - vm.runInContext(code, next); - vm.runInContext(source, next); - assert.ok((await next.result).every(product => product.index === 42)); - assert.equal(calls, 1); - assert.equal([...values.values()].join("").includes("fixture-token"), false); -}); diff --git a/tests/providerCacheSerialization.test.mjs b/tests/providerCacheSerialization.test.mjs deleted file mode 100644 index f6d2d84b..00000000 --- a/tests/providerCacheSerialization.test.mjs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import test from "node:test"; -import ProviderRequestCache, { REQUEST_CACHE_LIMITS } from "../src/class/ProviderRequestCache.mjs"; -import serializeProviderCache from "../src/function/serializeProviderCache.mjs"; - -const record = (body, index = 0) => ({ key: String(index).padStart(64, "a"), storedAt: 100000 + index, lastUsedAt: 100000 + index, expiresAt: 160000 + index, status: 200, body, group: "b".repeat(64) }); -function legacy(entries, maximum) { - let text = JSON.stringify({ version: 1, entries }); - while (entries.length && text.length > maximum) { - entries.pop(); - text = JSON.stringify({ version: 1, entries }); - } - return text; -} - -test("every UTF-16 code unit fits exactly at the same JSON boundary as serialize-and-pop", () => { - for (let code = 0; code <= 0xffff; code++) { - const entries = [record(String.fromCharCode(code))], - length = JSON.stringify({ version: 1, entries }).length; - for (const maximum of [length - 1, length, length + 1]) assert.equal(serializeProviderCache([...entries], maximum), legacy([...entries], maximum), `code unit ${code}, budget ${maximum}`); - } -}); - -test("mixed control characters, quotes and paired or lone surrogates preserve the exact retained prefix", () => { - let seed = 17; - const next = () => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; - return seed; - }; - const units = ['"', "\\", "\n", "\b", "\t", "\r", "\f", "\0", "\u001f", "中", "😀", "\ud800", "\udc00", "\ud800\ud800\udc00", "\udc00\ud800", "a", "\u2028"]; - for (let sample = 0; sample < 300; sample++) { - const entries = Array.from({ length: 8 }, (_, index) => record(Array.from({ length: next() % 120 }, () => units[next() % units.length]).join(""), index)); - const cutoff = next() % 9, - boundary = JSON.stringify({ version: 1, entries: entries.slice(0, cutoff) }).length; - for (const maximum of [Math.max(25, boundary - 1), boundary, boundary + 1, REQUEST_CACHE_LIMITS.storageCharacters]) { - const selected = [...entries], - expected = [...entries]; - assert.equal(serializeProviderCache(selected, maximum), legacy(expected, maximum)); - assert.deepEqual(selected, expected); - } - } -}); - -test("a large highest-priority entry cannot be skipped in favour of smaller later entries", () => { - const entries = [record("\0".repeat(90000)), record("small", 1)]; - assert.equal(serializeProviderCache(entries, REQUEST_CACHE_LIMITS.storageCharacters), '{"version":1,"entries":[]}'); - assert.equal(entries.length, 0); -}); - -test("oversized cache candidates produce one bounded snapshot while keeping all in-memory responses usable", async () => { - let raw, - requests = 0, - snapshots = 0, - largestSnapshot = 0; - const stringify = JSON.stringify, - store = { - read: () => raw, - write: value => { - raw = value; - return true; - }, - }, - cache = new ProviderRequestCache({ store, now: () => 100000 }); - const body = "x".repeat(90000); - JSON.stringify = (value, ...rest) => { - const result = stringify(value, ...rest); - if (value?.version === 1 && Array.isArray(value.entries)) { - snapshots++; - largestSnapshot = Math.max(largestSnapshot, result.length); - } - return result; - }; - try { - const options = { - format: "text", - validate: value => value === body, - fetcher: async () => { - requests++; - return { status: 200, headers: {}, body }; - }, - }; - for (let index = 0; index < 8; index++) await cache.fetch({ url: `https://memory.invalid/${index}` }, options); - assert.equal(snapshots, 8, "Each write serializes the final snapshot once"); - assert.ok(largestSnapshot <= REQUEST_CACHE_LIMITS.storageCharacters); - assert.equal(JSON.parse(raw).entries.length, 2); - for (let index = 0; index < 8; index++) assert.equal((await cache.fetch({ url: `https://memory.invalid/${index}` }, options)).body, body); - assert.equal(requests, 8, "Memory capacity and hit behaviour are preserved"); - } finally { - JSON.stringify = stringify; - } -}); - -test("highly escaped text keeps the response and memory hit even when no disk entry fits", async () => { - let stored, - requests = 0; - const body = "\0".repeat(90000), - cache = new ProviderRequestCache({ - store: { - read: () => stored, - write: value => { - stored = value; - return true; - }, - }, - }), - options = { - format: "text", - validate: value => value === body, - fetcher: async () => { - requests++; - return { status: 200, headers: {}, body }; - }, - }; - for (let count = 0; count < 2; count++) assert.equal((await cache.fetch({ url: "https://memory.invalid/escaped" }, options)).body, body); - assert.equal(requests, 1); - assert.equal(stored, '{"version":1,"entries":[]}'); -}); diff --git a/tests/providerRequestCache.test.mjs b/tests/providerRequestCache.test.mjs deleted file mode 100644 index 89f62997..00000000 --- a/tests/providerRequestCache.test.mjs +++ /dev/null @@ -1,389 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import test from "node:test"; -import ProviderRequestCache, { REQUEST_CACHE_LIMITS, requestCacheTTL } from "../src/class/ProviderRequestCache.mjs"; - -const request = { url: "https://provider.example/weather/31.2/121.5?token=test-secret", headers: { "X-API-Key": "header-secret", Accept: "application/json" } }; -const valid = body => body?.status === "ok"; -const response = (body = { status: "ok", temperature: 23 }, headers = {}, status = 200) => ({ status, ok: status >= 200 && status < 300, headers, body: JSON.stringify(body) }); - -function storage(initial) { - let value = initial; - return { - read: () => value, - write: next => { - value = next; - return true; - }, - value: () => value, - }; -} - -function setup({ ttlSeconds = 60, store = storage(), fetcher = async () => response() } = {}) { - let now = 100_000; - let calls = 0; - const cache = new ProviderRequestCache({ ttlSeconds, store, now: () => now }); - const options = { - partition: ["provider", "zh-CN", "CN"], - product: "weather", - validate: valid, - fetcher: async value => { - calls++; - return fetcher(value); - }, - }; - return { - cache, - options, - store, - fetch: (value = request, overrides = {}) => cache.fetch(value, { ...options, ...overrides }), - calls: () => calls, - setTime: value => { - now = value; - }, - }; -} - -test("six simultaneous consumers issue one native request and receive separate response objects", async () => { - let complete; - const state = setup({ - fetcher: () => - new Promise(resolve => { - complete = resolve; - }), - }); - const pending = Array.from({ length: 6 }, () => state.fetch()); - assert.equal(state.calls(), 1); - complete(response(undefined, { Test: "unchanged" })); - const results = await Promise.all(pending); - assert.equal(state.cache.stats.coalesced, 5); - results[0].headers.Test = "mutated"; - assert.equal(results[1].headers.Test, "unchanged"); - assert.equal(JSON.parse(results[5].body).temperature, 23); -}); - -test("a cache hit does not extend its lifetime and expiration refreshes once", async () => { - const state = setup(); - await state.fetch(); - state.setTime(159_999); - await state.fetch(); - assert.equal(state.calls(), 1); - state.setTime(160_000); - await Promise.all([state.fetch(), state.fetch(), state.fetch()]); - assert.equal(state.calls(), 2); -}); - -test("a new execution can reuse Loon storage without plaintext credentials or request URLs", async () => { - const first = setup(); - await first.fetch(); - const raw = first.store.value(); - for (const secret of ["test-secret", "header-secret", "provider.example", "Set-Cookie"]) assert.equal(raw.includes(secret), false); - const next = setup({ store: first.store }); - await next.fetch(); - assert.equal(next.calls(), 0); - assert.equal(next.cache.stats.persistentHits, 1); -}); - -test("six persisted products read one snapshot per execution and still recheck expiration", async () => { - const store = storage(); - let reads = 0, - now = 100000, - calls = 0; - const counted = { - read: () => { - reads++; - return store.read(); - }, - write: store.write, - }; - const options = { - product: "weather", - validate: valid, - fetcher: async () => { - calls++; - return response(); - }, - }; - const requests = Array.from({ length: 6 }, (_, i) => ({ ...request, url: `${request.url}&item=${i}` })); - const seed = new ProviderRequestCache({ store, now: () => now }); - for (const item of requests) await seed.fetch(item, options); - const next = new ProviderRequestCache({ store: counted, now: () => now }); - for (const item of requests) await next.fetch(item, options); - assert.equal(reads, 1); - assert.equal(calls, 6); - now += 60000; - await next.fetch(requests[0], options); - assert.equal(calls, 7); - assert.equal(reads, 2, "A successful refresh rereads storage before merging its write"); -}); - -test("a delayed execution merges another execution's newly persisted products", async () => { - const store = storage(); - const first = setup({ store }), - second = setup({ store }); - let complete; - const delayed = first.fetch(request, { - fetcher: () => - new Promise(resolve => { - complete = resolve; - }), - }); - await second.fetch({ ...request, url: `${request.url}&other=1` }); - complete(response()); - await delayed; - const next = setup({ store }); - await next.fetch(request); - await next.fetch({ ...request, url: `${request.url}&other=1` }); - assert.equal(next.calls(), 0, "A cached read snapshot cannot overwrite a later independent write"); -}); - -test("invalid persistent storage is parsed at most once until an actual write", async () => { - let reads = 0; - const cache = new ProviderRequestCache({ - store: { - read: () => { - reads++; - return "not JSON"; - }, - write: () => false, - }, - }); - for (let i = 0; i < 4; i++) - await assert.rejects( - cache.fetch( - { ...request, url: `${request.url}&item=${i}` }, - { - product: "weather", - validate: valid, - fetcher: async () => { - throw new Error("offline"); - }, - }, - ), - ); - assert.equal(reads, 1); -}); - -test("location, credential, host, language, product, and transport policy stay isolated", async () => { - const state = setup(); - await state.fetch(); - await state.fetch({ ...request, url: request.url.replace("31.2", "31.3") }); - await state.fetch({ ...request, url: request.url.replace("test-secret", "another-secret") }); - await state.fetch({ ...request, headers: { ...request.headers, "X-API-Key": "new-key" } }); - await state.fetch({ ...request, url: request.url.replace("provider.example", "other.example") }); - await state.fetch(request, { partition: ["provider", "en-US", "CN"] }); - await state.fetch(request, { product: "air-quality" }); - await state.fetch({ ...request, node: "another-policy" }); - assert.equal(state.calls(), 8); -}); - -test("header case and insertion order do not create duplicate requests", async () => { - const state = setup(); - await state.fetch(); - await state.fetch({ headers: { accept: "application/json", "x-api-key": "header-secret" }, url: request.url }); - assert.equal(state.calls(), 1); -}); - -test("zero TTL preserves pending-request coalescing and disables completed-result reuse", async () => { - const state = setup({ ttlSeconds: 0 }); - await Promise.all([state.fetch(), state.fetch()]); - assert.equal(state.calls(), 1); - await state.fetch(); - assert.equal(state.calls(), 2); - assert.equal(state.store.value(), undefined); -}); - -test("lowering the configured TTL invalidates previously longer-lived entries", async () => { - const state = setup({ ttlSeconds: 300 }); - await state.fetch(); - state.setTime(145_000); - const cache = new ProviderRequestCache({ store: state.store, ttlSeconds: 30, now: () => 145_000 }); - await cache.fetch(request, state.options); - assert.equal(state.calls(), 2); -}); - -test("minute products expire at 30 seconds even when general TTL is higher", async () => { - const state = setup({ ttlSeconds: 300 }); - await state.fetch(request, { maximumTTLSeconds: 30 }); - state.setTime(130_000); - await state.fetch(request, { maximumTTLSeconds: 30 }); - assert.equal(state.calls(), 2); -}); - -for (const [label, upstream] of [ - ["HTTP 429", response({ status: "ok" }, {}, 429)], - ["HTTP 500", response({ status: "ok" }, {}, 500)], - ["provider error", response({ status: "error" })], - ["partial provider failure", response({ status: "partial" })], - ["malformed JSON", { status: 200, body: "not JSON", headers: {} }], -]) { - test(`${label} is never cached and the next request can recover`, async () => { - let first = true; - const state = setup({ - fetcher: async () => { - if (first) { - first = false; - return upstream; - } - return response(); - }, - }); - await assert.rejects(state.fetch()); - assert.equal(state.store.value(), undefined); - await state.fetch(); - await state.fetch(); - assert.equal(state.calls(), 2); - }); -} - -test("a rejected shared request is removed so later calls retry normally", async () => { - const state = setup({ - fetcher: async () => { - throw new Error("native timeout"); - }, - }); - const failures = await Promise.allSettled([state.fetch(), state.fetch(), state.fetch()]); - assert.equal(state.calls(), 1); - assert.equal( - failures.every(item => item.status === "rejected"), - true, - ); - await state.fetch(request, { fetcher: async () => response() }); - assert.equal(state.cache.stats.requests, 2); -}); - -for (const headers of [{ "Cache-Control": "no-store" }, { "cache-control": "private, no-cache" }, { Vary: "*" }, { "Cache-Control": "max-age=0" }]) { - test(`upstream cache prohibition is respected: ${JSON.stringify(headers)}`, async () => { - const state = setup({ fetcher: async () => response(undefined, headers) }); - await state.fetch(); - await state.fetch(); - assert.equal(state.calls(), 2); - assert.equal(state.store.value(), undefined); - }); -} - -test("max-age and Age shorten the lifetime", async () => { - const state = setup({ fetcher: async () => response(undefined, { "Cache-Control": "max-age=20", Age: "15" }) }); - await state.fetch(); - state.setTime(104_999); - await state.fetch(); - state.setTime(105_000); - await state.fetch(); - assert.equal(state.calls(), 2); -}); - -for (const corrupt of ["{", "x".repeat(REQUEST_CACHE_LIMITS.storageCharacters + 1), JSON.stringify({ version: 99, entries: [] }), JSON.stringify({ version: 1, entries: [null, {}] })]) { - test(`invalid persistent data is ignored (${corrupt.length} characters)`, async () => { - const state = setup({ store: storage(corrupt) }); - await state.fetch(); - assert.equal(state.calls(), 1); - assert.equal(JSON.parse(state.store.value()).version, 1); - }); -} - -test("clock rollback invalidates cache timestamps", async () => { - const state = setup(); - await state.fetch(); - state.setTime(99_000); - await state.fetch(); - assert.equal(state.calls(), 2); -}); - -test("read and write failures do not prevent successful network responses", async () => { - const state = setup({ - store: { - read() { - throw new Error("unavailable"); - }, - write() { - throw new Error("full"); - }, - }, - }); - assert.equal(JSON.parse((await state.fetch()).body).status, "ok"); - await state.fetch(); - assert.equal(state.calls(), 1); -}); - -test("stored responses obey both entry count and serialized-size limits", async () => { - const state = setup({ fetcher: async () => response({ status: "ok", content: "x".repeat(70_000) }) }); - for (let index = 0; index < 20; index++) await state.fetch({ ...request, url: `${request.url}&city=${index}` }); - const raw = state.store.value(); - assert.ok(raw.length <= REQUEST_CACHE_LIMITS.storageCharacters); - assert.ok(JSON.parse(raw).entries.length <= REQUEST_CACHE_LIMITS.entries); - assert.ok(JSON.parse(raw).entries.length > 0); -}); - -test("cache-size overflow leaves a normal response usable but does not retain it", async () => { - const state = setup({ fetcher: async () => response({ status: "ok", content: "x".repeat(REQUEST_CACHE_LIMITS.bodyCharacters) }) }); - assert.ok((await state.fetch()).body.length > REQUEST_CACHE_LIMITS.bodyCharacters); - await state.fetch(); - assert.equal(state.calls(), 2); - assert.equal(state.store.value(), undefined); -}); - -test("binary and oversized provider responses are rejected without persisting them", async () => { - for (const body of [new Uint8Array([1, 2]), "x".repeat(REQUEST_CACHE_LIMITS.responseCharacters + 1)]) { - const state = setup({ fetcher: async () => ({ status: 200, body }) }); - await assert.rejects(state.fetch(), /Unsupported or oversized/); - assert.equal(state.store.value(), undefined); - } -}); - -test("distinct requests use at most three native slots and queued duplicates still coalesce", async () => { - const completions = []; - let active = 0; - let peak = 0; - const state = setup({ - fetcher: () => - new Promise(resolve => { - active++; - peak = Math.max(peak, active); - completions.push(() => { - active--; - resolve(response()); - }); - }), - }); - const pending = Array.from({ length: 10 }, (_, index) => state.fetch({ ...request, url: `${request.url}&city=${index}` })); - pending.push(state.fetch({ ...request, url: `${request.url}&city=9` })); - assert.equal(active, 3); - for (let done = 0; done < 10; done++) { - assert.ok(completions.length > 0); - completions.shift()(); - await new Promise(resolve => setImmediate(resolve)); - } - await Promise.all(pending); - assert.equal(peak, 3); - assert.equal(state.calls(), 10); - assert.equal(state.cache.stats.coalesced, 1); -}); - -test("pending-queue overflow fails without starting an extra native request", async () => { - const completions = []; - const state = setup({ fetcher: () => new Promise(resolve => completions.push(() => resolve(response()))) }); - const pending = Array.from({ length: REQUEST_CACHE_LIMITS.pending }, (_, index) => state.fetch({ ...request, url: `${request.url}&city=${index}` })); - await assert.rejects(state.fetch({ ...request, url: `${request.url}&city=overflow` }), /queue is full/); - for (const _pending of pending) { - completions.shift()(); - await new Promise(resolve => setImmediate(resolve)); - } - await Promise.all(pending); - assert.equal(state.calls(), REQUEST_CACHE_LIMITS.pending); -}); - -test("TTL input is bounded and invalid input falls back to 60 seconds", () => { - for (const value of [undefined, null, false, {}, "invalid", -1, Number.POSITIVE_INFINITY, "", " "]) assert.equal(requestCacheTTL(value), 60); - assert.equal(requestCacheTTL(0), 0); - assert.equal(requestCacheTTL("30"), 30); - assert.equal(requestCacheTTL(1000), 300); -}); - -test("requests with a method or a body that is not a read-only GET bypass the cache", async () => { - for (const extra of [{ method: "POST" }, { body: "payload" }, { bodyBytes: new Uint8Array([1]) }]) { - const state = setup(); - await Promise.all([state.fetch({ ...request, ...extra }), state.fetch({ ...request, ...extra })]); - assert.equal(state.calls(), 2); - assert.equal(state.store.value(), undefined); - } -}); diff --git a/tests/providerResilience.test.mjs b/tests/providerResilience.test.mjs index fbd60ebf..f13fe93d 100644 --- a/tests/providerResilience.test.mjs +++ b/tests/providerResilience.test.mjs @@ -1,8 +1,12 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; import test from "node:test"; import vm from "node:vm"; import { readFile } from "node:fs/promises"; +import ProviderTransport from "../src/class/ProviderTransport.mjs"; +import QWeather from "../src/class/QWeather.mjs"; +import { activeProviderAlerts, cloneWeatherProduct } from "../src/function/providerRecovery.mjs"; +import utf8Length from "../src/function/utf8Length.mjs"; +import { Console } from "@nsnanocat/util"; globalThis.$loon = "simulated Loon"; const persistent = new Map(); globalThis.$persistentStore = { @@ -14,231 +18,15 @@ globalThis.$persistentStore = { }; const callbacks = []; globalThis.$httpClient = { get: (request, callback) => callbacks.push({ request, callback }) }; -const [{ default: Cache }, { default: QWeather }, { default: resolveRegion, countryFromCoordinates }, { activeProviderAlerts, cloneWeatherProduct }, { default: utf8Length }, { Console }] = await Promise.all([ - import("../src/class/ProviderRequestCache.mjs"), - import("../src/class/QWeather.mjs"), - import("../src/function/resolveWeatherRegion.mjs"), - import("../src/function/providerRecovery.mjs"), - import("../src/function/utf8Length.mjs"), - import("@nsnanocat/util"), -]); Console.logLevel = "OFF"; -const store = () => { - let raw; - return { - read: () => raw, - write: value => { - raw = value; - return true; - }, - value: () => raw, - }; -}; -const request = { url: "https://provider.example/31.2/121.5?token=fixture-secret", headers: { Authorization: "fixture-auth" } }; -const response = (status = 200, headers = {}) => ({ status, ok: status >= 200 && status < 300, headers, body: JSON.stringify({ status: "ok" }) }); -const options = { partition: ["provider", "zh-CN"], product: "now", validate: body => body.status === "ok" }; - -test("two transient failures cool down across Loon executions without persisting credentials", async () => { - let now = 100000, - calls = 0; - const failures = store(); - const make = () => new Cache({ now: () => now, store: store(), failureStore: failures, cooldownSeconds: 30 }); - const fetcher = async () => { - calls++; - throw new Error(`timeout ${request.url}`); - }; - for (let i = 0; i < 2; i++) await assert.rejects(make().fetch(request, { ...options, fetcher }), /timeout/); - await assert.rejects(make().fetch(request, { ...options, fetcher }), /cooldown/); - assert.equal(calls, 2); - assert.doesNotMatch(failures.value(), /fixture-secret|fixture-auth|provider\.example|31\.2/); - const changed = { ...request, headers: { Authorization: "other-account" } }; - await make().fetch(changed, { ...options, fetcher: async () => response() }); - now += 30000; - await make().fetch(request, { - ...options, - fetcher: async () => { - calls++; - return response(); - }, - }); - assert.equal(calls, 3); - await make().fetch(request, { ...options, fetcher: async () => response() }); -}); - -test("rate limits cool down immediately and Retry-After is bounded to 120 seconds", async () => { - let now = 100000, - calls = 0; - const failures = store(); - const make = () => new Cache({ now: () => now, store: store(), failureStore: failures, cooldownSeconds: 30 }); - await assert.rejects( - make().fetch(request, { - ...options, - fetcher: async () => { - calls++; - return response(429, { "Retry-After": "999999" }); - }, - }), - error => error.code === "rate_limit", - ); - now += 119999; - await assert.rejects(make().fetch(request, { ...options, fetcher: async () => response() }), /cooldown/); - now++; - await make().fetch(request, { - ...options, - fetcher: async () => { - calls++; - return response(); - }, - }); - assert.equal(calls, 2); -}); - -test("clock rollback and corrupt cooldown storage do not suppress future native requests", async () => { - const failures = store(); - failures.write(JSON.stringify({ version: 1, entries: [{ key: "a".repeat(64), at: 200000, until: 230000, count: 2 }] })); - let called = 0; - const cache = new Cache({ now: () => 100000, cooldownSeconds: 30, failureStore: failures, store: store() }); - await cache.fetch(request, { - ...options, - fetcher: async () => { - called++; - return response(); - }, - }); - assert.equal(called, 1); -}); - -test("queued work expires without cancelling active native callbacks or starting a late request", async () => { - let now = 100000; - const complete = []; - const sent = []; - const cache = new Cache({ now: () => now, budgetMs: 1000, store: store() }); - const pending = Array.from({ length: 4 }, (_, i) => - cache.fetch( - { ...request, url: `${request.url}&item=${i}` }, - { - ...options, - fetcher: req => { - sent.push(req); - return new Promise(resolve => complete.push(() => resolve(response()))); - }, - }, - ), - ); - const settled = Promise.allSettled(pending); - assert.equal(sent.length, 3); - assert.ok(sent.every(req => req.timeout === 1000)); - let finished = false; - settled.then(() => { - finished = true; - }); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(finished, false); - now += 1000; - for (const done of complete) done(); - const result = await settled; - assert.equal(result.filter(r => r.status === "fulfilled").length, 3); - assert.equal(result[3].reason.code, "budget"); - assert.equal(sent.length, 3); - assert.equal(cache.stats.budgetSkips, 1); -}); - -test("per-product TTLs and the global zero switch retain distinct freshness limits", async () => { - let now = 100000, - calls = 0; - const cache = Cache.fromSettings({ RequestCache: { TTL: 60, ForecastTTL: 180, MinutelyTTL: 15, AlertsTTL: 15 } }, { now: () => now, budgetMs: 0, store: store(), failureStore: store() }); - const fetcher = async () => { - calls++; - return response(); - }; - for (const kind of ["current", "forecast", "minutely", "alerts"]) await cache.fetch({ ...request, url: `${request.url}&kind=${kind}` }, { ...options, kind, fetcher }); - now += 16000; - for (const kind of ["current", "forecast", "minutely", "alerts"]) await cache.fetch({ ...request, url: `${request.url}&kind=${kind}` }, { ...options, kind, fetcher }); - assert.equal(calls, 6); - now = 161000; - for (const kind of ["current", "forecast"]) await cache.fetch({ ...request, url: `${request.url}&kind=${kind}` }, { ...options, kind, fetcher }); - assert.equal(calls, 7); - const disabled = Cache.fromSettings({ RequestCache: { TTL: 0 } }, { budgetMs: 0, store: store() }); - let n = 0; - for (let i = 0; i < 2; i++) - await disabled.fetch(request, { - ...options, - kind: "forecast", - fetcher: async () => { - n++; - return response(); - }, - }); - assert.equal(n, 2); -}); - -test("eight cache slots are shared fairly by city while retaining a strict storage limit", async () => { - let now = 100000; - const storage = store(), - cache = new Cache({ now: () => now, store: storage }); - for (const city of ["first", "second"]) - for (let product = 0; product < 6; product++) { - now++; - await cache.fetch({ ...request, url: `${request.url}&city=${city}&product=${product}` }, { ...options, group: [city], fetcher: async () => response() }); - } - const entries = JSON.parse(storage.value()).entries; - assert.equal(entries.length, 8); - const counts = Object.values(Object.groupBy(entries, e => e.group)) - .map(a => a.length) - .sort(); - assert.deepEqual(counts, [4, 4]); - assert.ok(storage.value().length <= 256 * 1024); - assert.doesNotMatch(storage.value(), /first|second|fixture-auth/); -}); - -test("diagnostics are opt-in, emitted once, and contain only safe reasons and counters", async () => { - const messages = [], - original = console.log; - console.log = value => messages.push(value); - try { - const cache = Cache.fromSettings({ RequestCache: { Diagnostics: true } }, { store: store(), failureStore: store() }); - await cache.fetch(request, { ...options, fetcher: async () => response() }); - cache.noteFallback("currentWeather", "timeout"); - cache.report("response"); - cache.report("request"); - new Cache().report(); - } finally { - console.log = original; - } - assert.equal(messages.length, 1); - assert.match(messages[0], /currentWeather:timeout/); - assert.doesNotMatch(messages[0], /fixture-secret|fixture-auth|provider\.example|31\.2|121\.5/); -}); - -const html = '

Fixture City

Fixture warning

Issue date: 2026-10-19T01:00:00Z

'; -test("six default webpage warning consumers share one native request and reuse parsed records", async () => { - persistent.clear(); - callbacks.length = 0; - const runtime = Cache.fromSettings({}, { budgetMs: 0 }); - const all = Promise.all(Array.from({ length: 6 }, () => QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, runtime))); - assert.equal(callbacks.length, 1); - const call = callbacks.shift(); - assert.equal(call.request.timeout, 5000); - call.callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html); - const results = await all; - assert.equal(results.length, 6); - assert.ok(results.every(r => r.metadata.temporarilyUnavailable === false)); - const next = await QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, Cache.fromSettings({}, { budgetMs: 0 })); - assert.equal(next.metadata.temporarilyUnavailable, false); - assert.equal(callbacks.length, 0); - const raw = [...persistent.values()].join(""); - assert.doesNotMatch(raw, /|fixture-101190110|severe-weather/); -}); - test("an unrecognized webpage is unavailable, never a cached no-warning success", async () => { persistent.clear(); callbacks.length = 0; - const runtime = Cache.fromSettings({}, { budgetMs: 0 }); + const runtime = new ProviderTransport(); const pending = QWeather.FetchWeatherAlertPage("fixture-101190110", "en-US", {}, runtime); callbacks.shift().callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, "Access denied"); const result = await pending; assert.equal(result.metadata.temporarilyUnavailable, true); - assert.equal(runtime.stats.stores, 0); }); test("expired and invalid third-party alerts are excluded without altering unknown-expiry alerts", () => { @@ -248,33 +36,6 @@ test("expired and invalid third-party alerts are excluded without altering unkno ); }); -test("conservative polygons fix the domestic-coordinate/US-locale case without rectangular border guesses", () => { - assert.equal(countryFromCoordinates(32.39, 119.499), "CN"); - assert.equal(countryFromCoordinates(25.04, 121.56), "TW"); - assert.equal(countryFromCoordinates(22.4, 114.12), "HK"); - for (const p of [ - [47.92, 106.92], - [27.7, 85.3], - [35.68, 139.69], - [0, 0], - [91, 0], - ]) - assert.equal(countryFromCoordinates(...p), undefined); - assert.equal(resolveRegion({ latitude: 32.39, longitude: 119.499, localeCountry: "US" }), "CN"); - assert.equal(resolveRegion({ latitude: 32.39, longitude: 119.499, explicitCountry: "US", localeCountry: "CN" }), "US"); - assert.equal(resolveRegion({ latitude: 32.39, longitude: 119.499, fallback: "Locale", localeCountry: "US" }), "US"); -}); - -test("explicit per-coordinate hints are bounded, hashed, and remain separate between cities", () => { - const storage = store(); - const original = { latitude: 22.16, longitude: 113.56, localeCountry: "US", store: storage, now: () => 100000 }; - assert.equal(resolveRegion({ ...original, explicitCountry: "MO" }), "MO"); - assert.equal(resolveRegion(original), "MO"); - assert.doesNotMatch(storage.value(), /22\.16|113\.56/); - assert.equal(resolveRegion({ ...original, latitude: 35.68, longitude: 139.69 }), "US"); - assert.equal(resolveRegion({ ...original, now: () => 100000 + 7 * 86400000 }), "US"); -}); - test("product snapshots preserve binary values and isolate partial mutations before a failed replacement", () => { const original = { metadata: { providerName: "Apple" }, days: [{ v: 1 }], bytes: new Uint8Array([1, 2]) }, copy = cloneWeatherProduct(original); @@ -294,7 +55,7 @@ test("actual response processing preserves Apple products on provider failure an persistent.clear(); callbacks.length = 0; const saved = globalThis.$argument; - globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["currentWeather", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] }, Region: { Fallback: "Auto" }, RequestCache: { BudgetMs: 8000, CooldownSeconds: 30 } }; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["currentWeather", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] } }; try { const [{ default: WeatherKit2 }, { Response }, { ByteBuffer }] = await Promise.all([import("../src/class/WeatherKit2.mjs"), import("../src/process/Response.mjs"), import("flatbuffers")]); const raw = WeatherKit2.encode(undefined, { @@ -312,7 +73,7 @@ test("actual response processing preserves Apple products on provider failure an }); const before = WeatherKit2.decode(new ByteBuffer(raw), ["currentWeather", "forecastHourly"]); assert.equal(before.currentWeather?.temperature, 27); - const pending = Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); + const pending = Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=currentWeather,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); let settled = false; pending.then(() => { settled = true; @@ -332,31 +93,6 @@ test("actual response processing preserves Apple products on provider failure an } }); -test("backup queries require a configured dedicated credential and never replace valid Apple data", async () => { - const { default: recover } = await import("../src/function/recoverConfiguredProduct.mjs"); - let calls = 0; - const value = { metadata: { providerName: "QWeather", temporarilyUnavailable: false }, temperature: 20 }, - failed = { metadata: { temporarilyUnavailable: true } }; - const loaders = { - QWeather: async () => { - calls++; - return value; - }, - }; - const runtime = new Cache(); - assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, {}, runtime), failed); - assert.equal(calls, 0); - assert.equal(await recover("currentWeather", failed, { metadata: { providerName: "Apple" } }, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), failed); - assert.equal(calls, 0); - assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), failed); - assert.equal(calls, 0, "A credential for another product does not enable a provider switch"); - assert.equal(await recover("currentWeather", failed, undefined, "ColorfulClouds", loaders, { ProviderFallback: { Enabled: true }, API: { QWeather: { Token: "configured-account" } } }, runtime), value); - assert.equal(calls, 1); - assert.equal(runtime.stats.backupSuccesses, 1); - assert.equal(await recover("currentWeather", undefined, undefined, "WeatherKit", loaders, { API: { QWeather: { Token: "configured-account" } } }, runtime), undefined); - assert.equal(calls, 1); -}); - test("sequential requests cannot mutate shared defaults or inherit another credential", async () => { const [{ default: setENV }, { default: database }] = await Promise.all([import("../src/function/setENV.mjs"), import("../src/function/database.mjs")]); const token = database.WeatherKit.Settings.API.QWeather.Token, @@ -397,11 +133,11 @@ test("missing Apple daily and hourly roots can receive available provider foreca hourly = QWeather.prototype.Hourly; const metadata = { providerName: "QWeather", temporarilyUnavailable: false }; try { - globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["forecastDaily", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] }, Region: { Fallback: "Auto" } }; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: ["forecastDaily", "forecastHourly"], Weather: { Provider: "QWeather", Replace: ["CN"] } }; QWeather.prototype.Daily = async () => ({ metadata, days: [{ forecastStart: 1792368000, temperatureMax: 29, precipitationAmountByType: [] }] }); QWeather.prototype.Hourly = async () => ({ metadata, hours: [{ forecastStart: 1792368000, temperature: 25 }] }); const raw = WeatherKit2.encode(undefined, {}); - const result = await Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?dataSets=forecastDaily,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); + const result = await Response({ url: "https://weatherkit.apple.com/api/v2/weather/zh-Hans-US/32.390/119.499?country=CN&dataSets=forecastDaily,forecastHourly", method: "GET", headers: {} }, { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: raw }); const decoded = WeatherKit2.decode(new ByteBuffer(result.body), ["forecastDaily", "forecastHourly"]); assert.equal(decoded.forecastDaily.days[0].temperatureMax, 29); assert.equal(decoded.forecastHourly.hours[0].temperature, 25); diff --git a/tests/providerTransport.test.mjs b/tests/providerTransport.test.mjs new file mode 100644 index 00000000..152a7319 --- /dev/null +++ b/tests/providerTransport.test.mjs @@ -0,0 +1,147 @@ +// Stateless transport regressions: validation is retained, request policy is removed. +import assert from "node:assert/strict"; +import test from "node:test"; +import ProviderTransport, { PROVIDER_RESPONSE_LIMITS, readProviderJSON } from "../src/class/ProviderTransport.mjs"; + +const request = { url: "https://fixture.example/weather", headers: { Authorization: "fixture" } }; +const valid = value => Number.isFinite(value?.temperature); +const response = (temperature = 18) => ({ status: 200, headers: { ETag: "fixture" }, body: JSON.stringify({ temperature, nested: { value: 1 } }) }); +const errorCode = code => error => error.code === code; + +test("identical sequential requests always fetch and never reuse response objects", async () => { + const transport = new ProviderTransport(); + let calls = 0; + const options = { fetcher: async () => response(++calls), validate: valid, includeJSON: true }; + const first = await transport.fetch(request, options); + const second = await transport.fetch(request, options); + assert.equal(calls, 2); + assert.equal(readProviderJSON(first).temperature, 1); + assert.equal(readProviderJSON(second).temperature, 2); +}); + +test("identical parallel requests remain independent and each waits for its own fetch", async () => { + const transport = new ProviderTransport(); + const callbacks = []; + const options = { fetcher: () => new Promise(resolve => callbacks.push(resolve)), validate: valid }; + const first = transport.fetch(request, options); + const second = transport.fetch(request, options); + assert.equal(callbacks.length, 2); + let firstFinished = false; + first.then(() => { + firstFinished = true; + }); + callbacks[1](response(22)); + assert.equal(JSON.parse((await second).body).temperature, 22); + assert.equal(firstFinished, false); + callbacks[0](response(18)); + assert.equal(JSON.parse((await first).body).temperature, 18); +}); + +test("a shared native response does not share mutable headers or parsed JSON between requests", async () => { + const native = response(); + const transport = new ProviderTransport(); + const options = { fetcher: async () => native, validate: valid, includeJSON: true }; + const first = await transport.fetch(request, options); + first.headers.ETag = "changed"; + readProviderJSON(first).nested.value = 9; + const second = await transport.fetch(request, options); + assert.equal(native.headers.ETag, "fixture"); + assert.equal(second.headers.ETag, "fixture"); + assert.equal(readProviderJSON(second).nested.value, 1); +}); + +for (const [status, code] of [ + [401, "auth"], + [403, "auth"], + [429, "rate_limit"], + [500, "http"], + [304, "http"], +]) { + test(`HTTP ${status} is rejected and repeated failures never create a cooldown`, async () => { + const transport = new ProviderTransport(); + let calls = 0; + const options = { + fetcher: async () => { + calls++; + return { ...response(), status: calls < 4 ? status : 200 }; + }, + validate: valid, + }; + for (let i = 0; i < 3; i++) await assert.rejects(transport.fetch(request, options), errorCode(code)); + assert.equal((await transport.fetch(request, options)).status, 200); + assert.equal(calls, 4); + }); +} + +for (const [failure, code] of [ + [new Error("timeout"), "timeout"], + [new Error("network changed"), "network"], +]) { + test(`${code} failures never suppress the next request`, async () => { + const transport = new ProviderTransport(); + let calls = 0; + const options = { + fetcher: async () => { + if (++calls < 4) throw failure; + return response(); + }, + validate: valid, + }; + for (let i = 0; i < 3; i++) await assert.rejects(transport.fetch(request, options), errorCode(code)); + await transport.fetch(request, options); + assert.equal(calls, 4); + }); +} + +for (const status of [undefined, "bad", 200.5, Infinity]) + test(`invalid HTTP status ${String(status)} cannot replace Apple data`, async () => { + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => ({ ...response(), status }), validate: valid }), errorCode("invalid")); + }); + +for (const body of ["", "null", "[]", "{broken", new Uint8Array([123, 125]), "x".repeat(PROVIDER_RESPONSE_LIMITS.responseCharacters + 1)]) + test(`invalid provider body (${typeof body}, ${body?.length}) is rejected`, async () => { + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => ({ ...response(), body }), validate: valid }), errorCode("invalid")); + }); + +test("a consumer validation exception is contained as an invalid provider response", async () => { + await assert.rejects( + new ProviderTransport().fetch(request, { + fetcher: async () => response(), + validate: () => { + throw new Error("fixture"); + }, + }), + errorCode("invalid"), + ); +}); + +test("native ok=false cannot turn an HTTP 200 body into a successful provider result", async () => { + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => ({ ...response(), ok: false }), validate: valid }), errorCode("http")); +}); + +test("conditional 304 reuse is opt-in, never parses or validates an empty body", async () => { + const options = { + fetcher: async () => ({ status: 304, ok: false, headers: { ETag: "unchanged" }, body: null }), + validate: () => { + throw new Error("304 is not JSON"); + }, + }; + const transport = new ProviderTransport(); + await assert.rejects(transport.fetch(request, options), errorCode("http")); + const notModified = await transport.fetch(request, { ...options, allowNotModified: true }); + assert.equal(notModified.status, 304); + assert.equal(notModified.body, ""); + assert.equal(notModified.headers.ETag, "unchanged"); +}); + +test("text responses retain explicit validation and size bounds", async () => { + const transport = new ProviderTransport(); + const options = { fetcher: async () => ({ status: 200, body: "fixture" }), format: "text", validate: text => text.includes("fixture") }; + assert.equal((await transport.fetch(request, options)).body, "fixture"); + await assert.rejects(transport.fetch(request, { ...options, maximumResponseCharacters: 3 }), errorCode("invalid")); +}); + +test("every provider request must supply a fetch function and data validator", async () => { + await assert.rejects(new ProviderTransport().fetch(request), TypeError); + await assert.rejects(new ProviderTransport().fetch(request, { fetcher: async () => response() }), TypeError); +}); diff --git a/tests/providerTransportGuards.test.mjs b/tests/providerTransportGuards.test.mjs deleted file mode 100644 index 2e37fe7f..00000000 --- a/tests/providerTransportGuards.test.mjs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import test from "node:test"; -import Cache from "../src/class/ProviderRequestCache.mjs"; -const request = { url: "https://provider.example/realtime", headers: { Authorization: "fixture-private" } }; -const successful = (body = { status: "ok", temperature: 23 }) => ({ status: 200, ok: true, headers: {}, body: JSON.stringify(body) }); -const storage = () => { - let value; - return { - read: () => value, - write: next => { - value = next; - return true; - }, - value: () => value, - }; -}; -const valid = body => body.status === "ok"; - -for (const [status, code] of [ - [401, "auth"], - [403, "auth"], - [429, "rate_limit"], - [500, "http"], - [302, "http"], - [undefined, "invalid"], -]) { - test(`HTTP ${status} cannot deliver a success-looking body to weather adapters`, async () => { - const cache = new Cache({ store: storage(), failureStore: storage() }); - await assert.rejects(cache.fetch(request, { validate: valid, fetcher: async () => ({ ...successful(), status, ok: false }) }), error => error.code === code); - assert.equal(cache.stats.failures, 1); - }); -} - -test("coalesced failed HTTP responses count once and never reach any of six consumers", async () => { - let calls = 0; - const cache = new Cache({ store: storage(), failureStore: storage() }); - const pending = Array.from({ length: 6 }, () => - cache.fetch(request, { - validate: valid, - fetcher: async () => { - calls++; - return { ...successful(), status: 500 }; - }, - }), - ); - const result = await Promise.allSettled(pending); - assert.equal(calls, 1); - assert.ok(result.every(item => item.status === "rejected" && item.reason.code === "http")); - assert.equal(cache.stats.failures, 1); -}); - -for (const order of ["alerts-first", "current-first"]) { - test(`coalesced callers validate their own product independently (${order})`, async () => { - const body = { status: "ok", current: { temperature: 23 }, alerts: { status: "error" } }; - const cache = new Cache({ store: storage(), failureStore: storage() }); - let calls = 0; - const fetcher = async () => { - calls++; - return successful(body); - }; - const current = () => cache.fetch(request, { product: "realtime", kind: "current", validate: value => value.current?.temperature === 23, fetcher }); - const alerts = () => cache.fetch(request, { product: "realtime", kind: "alerts", validate: value => value.alerts?.status === "ok", fetcher }); - const results = await Promise.allSettled(order === "alerts-first" ? [alerts(), current()] : [current(), alerts()]); - const [alertResult, currentResult] = order === "alerts-first" ? results : results.toReversed(); - assert.equal(alertResult.status, "rejected"); - assert.equal(alertResult.reason.code, "invalid"); - assert.equal(currentResult.status, "fulfilled"); - assert.equal(JSON.parse(currentResult.value.body).current.temperature, 23); - assert.equal(calls, 1); - assert.equal(cache.stats.failures, 1); - }); -} - -test("uncached credentials participate in cooldown without persisting or sharing token responses", async () => { - const failures = storage(), - responses = storage(); - let calls = 0; - const make = () => new Cache({ store: responses, failureStore: failures, cooldownSeconds: 30 }); - const auth = { ...request, body: "token=account-one-private&id=123" }; - const options = { - cacheable: false, - validate: valid, - fetcher: async () => { - calls++; - return { ...successful(), status: 401 }; - }, - }; - await assert.rejects(make().fetch(auth, options), error => error.code === "auth"); - await assert.rejects(make().fetch(auth, options), error => error.code === "cooldown"); - assert.equal(calls, 1); - await make().fetch( - { ...auth, body: "token=account-two-private&id=123" }, - { - ...options, - fetcher: async () => { - calls++; - return successful(); - }, - }, - ); - assert.equal(calls, 2); - assert.equal(responses.value(), undefined); - assert.doesNotMatch(failures.value(), /account-one-private|account-two-private|fixture-private|provider\.example/); -}); - -test("each coalesced consumer enforces its own response-size boundary", async () => { - let calls = 0; - const cache = new Cache({ store: storage(), failureStore: storage() }); - const fetcher = async () => { - calls++; - return successful({ status: "ok", text: "x".repeat(2048) }); - }; - const result = await Promise.allSettled([cache.fetch(request, { validate: valid, fetcher, maximumResponseCharacters: 1024 }), cache.fetch(request, { validate: valid, fetcher, maximumResponseCharacters: 4096 })]); - assert.equal(calls, 1); - assert.equal(result[0].status, "rejected"); - assert.equal(result[0].reason.code, "invalid"); - assert.equal(result[1].status, "fulfilled"); -}); - -test("semantic failure cooldown for alerts does not suppress healthy current weather", async () => { - const failures = storage(); - const cache = new Cache({ ttlSeconds: 0, store: storage(), failureStore: failures, cooldownSeconds: 30 }); - let calls = 0; - const fetcher = async () => { - calls++; - return successful({ current: true, alerts: false }); - }; - for (let i = 0; i < 2; i++) await assert.rejects(cache.fetch(request, { product: "realtime", kind: "alerts", validate: value => value.alerts, fetcher }), error => error.code === "invalid"); - await assert.rejects(cache.fetch(request, { product: "realtime", kind: "alerts", validate: value => value.alerts, fetcher }), error => error.code === "cooldown"); - await cache.fetch(request, { product: "realtime", kind: "current", validate: value => value.current, fetcher }); - assert.equal(calls, 3); -}); - -test("a conditional 304 can reuse its existing grid without being treated as provider failure", async () => { - const cache = new Cache({ store: storage(), failureStore: storage(), cooldownSeconds: 30 }); - const result = await cache.fetch(request, { cacheable: false, allowNotModified: true, format: "text", validate: () => true, fetcher: async () => ({ status: 304, ok: false, headers: {}, body: "" }) }); - assert.equal(result.status, 304); - assert.equal(cache.stats.failures, 0); - assert.equal(cache.stats.stores, 0); - await assert.rejects(cache.fetch(request, { cacheable: false, format: "text", validate: () => true, fetcher: async () => ({ status: 304, ok: false, headers: {}, body: "" }) }), error => error.code === "http"); -}); diff --git a/tests/qWeatherGridCache.test.mjs b/tests/qWeatherGridCache.test.mjs new file mode 100644 index 00000000..0f3935b8 --- /dev/null +++ b/tests/qWeatherGridCache.test.mjs @@ -0,0 +1,146 @@ +// Synthetic location grids and native callbacks; no live provider requests. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +const [{ default: QWeather }, { default: ProviderTransport }, { Console }] = await Promise.all([import("../src/class/QWeather.mjs"), import("../src/class/ProviderTransport.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const NOW = 1_800_000_000_000; +const MONTH = 30 * 86400000; +const grid = () => ({ gridSize: 1, grid: { "121,31": [{ id: "101020100", latitude: 31.2, longitude: 121.5 }] } }); +const runtime = () => new ProviderTransport(); + +test("a recent valid grid is reused without a network request or a write", async t => { + t.mock.method(Date, "now", () => NOW); + globalThis.$httpClient = { + get() { + assert.fail("Fresh grid must not fetch"); + }, + }; + const data = grid(); + assert.strictEqual(await QWeather.GetLocationsGrid({ locationsGrid: { data, lastUpdated: NOW - 1 } }, () => assert.fail("Fresh grid must not write"), runtime()), data); +}); + +for (const name of ["ETag", "etag", "eTaG"]) { + test(`a grid 200 stores the case-insensitive ${name} validator and revalidates with 304`, async t => { + let now = NOW, + requests = 0, + saved; + t.mock.method(Date, "now", () => now); + globalThis.$httpClient = { + get(request, callback) { + requests++; + if (requests === 1) callback(null, { status: 200, headers: { [name]: 'W/"grid-v1"' } }, JSON.stringify(grid())); + else { + assert.equal(request.headers["If-None-Match"], 'W/"grid-v1"'); + callback(null, { status: 304, headers: {} }); + } + }, + }; + await QWeather.GetLocationsGrid({ retained: true }, value => (saved = value), runtime()); + assert.equal(saved.locationsGrid.etag, 'W/"grid-v1"'); + const data = saved.locationsGrid.data; + now += MONTH; + const cache = runtime(); + assert.strictEqual(await QWeather.GetLocationsGrid(saved, value => (saved = value), cache), data); + assert.equal(saved.locationsGrid.lastUpdated, now); + assert.equal(saved.retained, true); + assert.equal(requests, 2); + }); +} + +for (const data of [undefined, null, {}, { gridSize: 0, grid: {} }, { gridSize: 1, grid: { bad: null } }]) { + test(`an orphan validator cannot turn a 304 into usable grid data: ${JSON.stringify(data)}`, async t => { + t.mock.method(Date, "now", () => NOW); + let writes = 0; + globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.headers?.["If-None-Match"], undefined); + callback(null, { status: 304, headers: {} }); + }, + }; + await assert.rejects(QWeather.GetLocationsGrid({ locationsGrid: { data, etag: '"orphan"', lastUpdated: NOW - MONTH } }, () => writes++, runtime())); + assert.equal(writes, 0); + }); +} + +for (const data of [undefined, {}, { gridSize: 1, grid: { "121,31": [null] } }]) { + test(`a fresh timestamp does not hide a missing or corrupt grid: ${JSON.stringify(data)}`, async t => { + t.mock.method(Date, "now", () => NOW); + let calls = 0, + saved; + globalThis.$httpClient = { + get(request, callback) { + calls++; + assert.equal(request.headers?.["If-None-Match"], undefined); + callback(null, { status: 200, headers: {} }, JSON.stringify(grid())); + }, + }; + assert.deepEqual(await QWeather.GetLocationsGrid({ locationsGrid: { data, lastUpdated: NOW - 1, etag: '"bad"' } }, value => (saved = value), runtime()), grid()); + assert.equal(calls, 1); + assert.deepEqual(saved.locationsGrid.data, grid()); + }); +} + +test("clock rollback forces revalidation rather than extending a grid indefinitely", async t => { + t.mock.method(Date, "now", () => NOW); + let calls = 0; + globalThis.$httpClient = { + get(_request, callback) { + calls++; + callback(null, { status: 200, headers: {} }, JSON.stringify(grid())); + }, + }; + await QWeather.GetLocationsGrid({ locationsGrid: { data: grid(), lastUpdated: NOW + 86400000 } }, () => {}, runtime()); + assert.equal(calls, 1); +}); + +for (const body of ["", "{}", "{broken", '{"gridSize":1,"grid":[]}', '{"gridSize":1,"grid":{"121,31":[null]}}']) { + test(`malformed downloaded grids never replace existing cache state: ${body}`, async t => { + t.mock.method(Date, "now", () => NOW); + let writes = 0; + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, body); + }, + }; + await assert.rejects(QWeather.GetLocationsGrid({ locationsGrid: { data: grid(), lastUpdated: NOW - MONTH, etag: '"old"' } }, () => writes++, runtime())); + assert.equal(writes, 0); + }); +} + +test("a failed optional storage write does not discard a validated live grid", async t => { + t.mock.method(Date, "now", () => NOW); + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify(grid())); + }, + }; + assert.deepEqual( + await QWeather.GetLocationsGrid( + undefined, + () => { + throw new Error("Storage unavailable"); + }, + runtime(), + ), + grid(), + ); +}); + +test("304 has no JSON representation to validate and remains opt-in", async () => { + const cache = runtime(); + let validations = 0; + const options = { + cacheable: false, + fetcher: async () => ({ status: 304, body: "", headers: {} }), + validate: () => { + validations++; + return false; + }, + }; + assert.equal((await cache.fetch({ url: "https://fixture.example/grid" }, { ...options, allowNotModified: true })).status, 304); + assert.equal(validations, 0); + await assert.rejects(cache.fetch({ url: "https://fixture.example/grid" }, options), error => error.code === "http"); +}); diff --git a/tests/removedControls.test.mjs b/tests/removedControls.test.mjs new file mode 100644 index 00000000..ddfbed4e --- /dev/null +++ b/tests/removedControls.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +const store = new Map(); +globalThis.$persistentStore = { + read: key => store.get(key) ?? null, + write: (value, key) => { + store.set(key, value); + return true; + }, +}; +const [{ Response }, { default: parseWeatherKitURL }, { Console }] = await Promise.all([import("../src/process/Response.mjs"), import("../src/function/parseWeatherKitURL.mjs"), import("@nsnanocat/util")]); +Console.logLevel = "OFF"; +const removedSettings = { RequestCache: { TTL: 300, ForecastTTL: 300, MinutelyTTL: 30, AlertsTTL: 30, BudgetMs: 1, CooldownSeconds: 120, Diagnostics: true }, Region: { Fallback: "CN" }, ProviderFallback: { Enabled: true } }; + +test("legacy settings cannot restore provider switching, dispatch budgets or failure cooldown", async () => { + store.set("iRingo", JSON.stringify({ WeatherKit: { Settings: removedSettings } })); + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", Weather: { Provider: "QWeather", Replace: ["CN"] }, API: { ColorfulClouds: { Token: "configured-other-provider" }, QWeather: { Token: "selected-provider" } }, ...removedSettings }; + const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=currentWeather", method: "GET", headers: {} }; + const original = { status: 200, headers: { "Content-Type": "application/json", ETag: "apple" }, body: ' {"currentWeather":{"metadata":{"temporarilyUnavailable":false},"temperature":18,"conditionCode":"CLOUDY"}}\n' }; + let calls = 0; + globalThis.$httpClient = { + get(outgoing, callback) { + calls++; + assert.ok(outgoing.url.includes("/weather/now")); + assert.equal(outgoing.headers["X-QW-Api-Key"], "selected-provider"); + assert.equal(outgoing.timeout, 5000); + callback(null, { status: 429, headers: {} }, "{}"); + }, + }; + for (let i = 0; i < 3; i++) { + const output = await Response(request, { ...original, headers: { ...original.headers } }); + assert.equal(output.body, original.body); + assert.equal(output.headers.ETag, "apple"); + } + assert.equal(calls, 3); + assert.deepEqual([...store.keys()], ["iRingo"], "No removed feature may write new response or cooldown stores"); +}); + +test("legacy region fallback does not override the explicit country or locale", () => { + globalThis.$argument = { LogLevel: "OFF", ...removedSettings }; + const base = "https://weatherkit.apple.com/api/v3/weather/"; + assert.equal(parseWeatherKitURL(new URL(base + "en-US/31.2/121.5")).country, "US"); + assert.equal(parseWeatherKitURL(new URL(base + "zh-Hans/31.2/121.5")).country, undefined); + assert.equal(parseWeatherKitURL(new URL(base + "en-US/31.2/121.5?country=HK")).country, "HK"); +}); + +test("legacy region settings never cause a China-only weather replacement for a US locale", async () => { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", Weather: { Provider: "QWeather", Replace: ["CN"] }, ...removedSettings }; + globalThis.$httpClient = { + get() { + assert.fail("No inferred-country request should be made"); + }, + }; + const response = { status: 200, headers: { "Content-Type": "application/json" }, body: '{"currentWeather":{"metadata":{"temporarilyUnavailable":false},"temperature":18,"conditionCode":"CLOUDY"}}' }; + assert.equal((await Response({ url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?dataSets=currentWeather", method: "GET", headers: {} }, response)).body, response.body); +}); + +test("active runtime and argument definitions contain none of the removed controls", async () => { + const forbidden = /RequestCache|ProviderFallback|Region\.Fallback|ProviderRequestCache|resolveWeatherRegion|recoverConfiguredProduct/; + async function verify(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = new URL(entry.name + (entry.isDirectory() ? "/" : ""), directory); + if (entry.isDirectory()) await verify(file); + else if (/\.(?:mjs|js|ts)$/.test(entry.name)) assert.doesNotMatch(await readFile(file, "utf8"), forbidden, file.pathname); + } + } + await verify(new URL("../src/", import.meta.url)); + for (const name of ["full", "release", "dev"]) assert.doesNotMatch(await readFile(new URL(`../arguments-builder.${name}.config.ts`, import.meta.url), "utf8"), forbidden); + const settings = JSON.parse(await readFile(new URL("../template/boxjs.settings.json", import.meta.url), "utf8")); + assert.doesNotMatch(JSON.stringify(settings), forbidden); +}); diff --git a/tests/responseJSONV3.test.mjs b/tests/responseJSONV3.test.mjs index db11e160..5beaa64d 100644 --- a/tests/responseJSONV3.test.mjs +++ b/tests/responseJSONV3.test.mjs @@ -267,9 +267,9 @@ test("undefined provider values do not erase available Apple fields", async t => }); test("unserializable values and malformed Unicode reject one product without discarding healthy replacements", async () => { - settings({ DataSets: "weatherAlerts,currentWeather" }); + settings({ DataSets: "weatherAlerts,currentWeather", "WeatherAlerts.Provider": "QWeather" }); const body = { weatherAlerts: { metadata: metadata(), alerts: [{ description: "Apple warning" }] }, currentWeather: current() }; - for (const invalid of ["bad\ud800", 1n, [undefined], [null], [1e100]]) { + for (const invalid of ["bad\ud800", 1n, [undefined]]) { const injectors = { weatherAlerts: async input => ({ ...input, futureProviderField: invalid }), currentWeather: async input => ({ ...input, temperature: 23 }) }; const updated = JSON.parse((await ResponseJSONV3(request("weatherAlerts,currentWeather"), response(body), injectors)).body); assert.deepEqual(updated.weatherAlerts, body.weatherAlerts); @@ -278,6 +278,21 @@ test("unserializable values and malformed Unicode reject one product without dis assert.doesNotThrow(() => validateWeatherJSONV3({ description: "雨 🌧️" })); }); +test("unknown JSON arrays retain null and finite double values without native field coercion", async () => { + settings({ DataSets: "currentWeather" }); + for (const values of [[null], [1e100]]) { + const original = { currentWeather: { ...current(), futureProviderField: values } }; + const result = await ResponseJSONV3(request(), response(original), { currentWeather: async input => ({ ...input, temperature: 23 }) }); + const actual = JSON.parse(result.body).currentWeather; + assert.equal(actual.temperature, 23); + assert.deepEqual(actual.futureProviderField, values); + } +}); + +test("known native vectors still reject null entries and invalid float values", () => { + for (const invalid of [[null], [undefined], [1e100]]) assert.throws(() => validateWeatherJSONV3({ hours: invalid }, "forecastHourly")); +}); + test("a null replacement product keeps the complete original response", async () => { settings({ DataSets: "currentWeather" }); const original = response({ currentWeather: current() }); diff --git a/tests/severeWeatherPage.test.mjs b/tests/severeWeatherPage.test.mjs index 1971c745..94f1822d 100644 --- a/tests/severeWeatherPage.test.mjs +++ b/tests/severeWeatherPage.test.mjs @@ -150,22 +150,21 @@ test("WeatherAlerts standardization stays provider-agnostic", async () => { assert.doesNotMatch(source, /\bqWeather\b|\bQWeather\b/); }); -test("response WeatherAlert injection only selects and merges provider slot objects", async () => { +test("response WeatherAlert injection selects provider slot objects", async () => { for (const path of ["../src/process/Response.mjs", "../src/process/Response.dev.mjs"]) { const source = await readFile(new URL(path, import.meta.url), "utf8"); const injectWeatherAlerts = source.match(/async function InjectWeatherAlerts\([\s\S]*?(?=\n\/\*\*)/)?.[0] ?? ""; assert.match(source, /case "weatherAlerts": \{[\s\S]*body\.weatherAlerts = await InjectWeatherAlerts\(body\.weatherAlerts, Settings, enviroments\)/); - assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.colorfulClouds\.WeatherAlert\(\)/); - assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.qWeather\.WeatherAlert\(\)/); - assert.match(injectWeatherAlerts, /newWeatherAlerts = await enviroments\.qWeather\.WeatherAlertWeb\(weatherAlerts\?\.detailsUrl\)/); - assert.match(injectWeatherAlerts, /if \(providerProductAvailable\(newWeatherAlerts\)\) \{[\s\S]*weatherAlerts\.metadata = \{ \.\.\.weatherAlerts\?\.metadata, \.\.\.newWeatherAlerts\.metadata \}/); + assert.match(injectWeatherAlerts, /newWeatherAlerts = await \(enviroments\.weatherAlertsColorfulClouds \?\? enviroments\.colorfulClouds\)\.WeatherAlert\(\)/); + assert.match(injectWeatherAlerts, /newWeatherAlerts = await \(enviroments\.weatherAlertsQWeather \?\? enviroments\.qWeather\)\.WeatherAlert\(\)/); + assert.match(injectWeatherAlerts, /newWeatherAlerts = await \(enviroments\.weatherAlertsQWeather \?\? enviroments\.qWeather\)\.WeatherAlertWeb\(weatherAlerts\?\.detailsUrl\)/); assert.doesNotMatch(injectWeatherAlerts, /QWeather\.(?:ParseWeatherAlertPageURL|BuildAppleAlertDetailsURL|FetchWeatherAlertPage)|attributionUrl|Console\.debug/); } }); test("response provider logos are finalized inside Inject methods", async () => { - const injectMethods = ["InjectCurrentWeather", "InjectForecastDaily", "InjectForecastHourly", "InjectForecastNextHour", "InjectWeatherAlerts"]; + const injectMethods = ["InjectCurrentWeather", "InjectForecastDaily", "InjectForecastHourly", "InjectForecastNextHour"]; for (const path of ["../src/process/Response.mjs", "../src/process/Response.dev.mjs"]) { const source = await readFile(new URL(path, import.meta.url), "utf8"); const topLevel = source.slice(0, source.indexOf("async function InjectCurrentWeather")); @@ -206,7 +205,7 @@ test("QWeather HTML extraction is separated from WeatherAlert construction", asy assert.equal(alerts[0].description, "雷暴橙色预警"); assert.equal(alerts[0].effectiveTime, "2026-07-31T03:00:00.000Z"); assert.equal(alerts[0].eventOnsetTime, "2026-07-31T03:00:00.000Z"); - assert.equal(alerts[0].expireTime, "9999-12-31T23:59:59Z"); + assert.equal(alerts[0].expireTime, "9999-12-31T00:00:00Z"); assert.equal(alerts[0].eventSource, "CN"); assert.equal(alerts[0].importance, "high"); assert.equal(alerts[0].reportedAt, "2026-07-31T03:00:00.000Z"); @@ -540,7 +539,7 @@ test("updated Chinese headlines replace generic FlatBuffer titles", () => { assert.equal(appleAlerts[1].description, "暴雨橙色预警"); }); -test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", async () => { +test("ColorfulClouds fetches each realtime product independently and standardizes v2.6 alerts", async () => { const originalFetch = globalThis.fetch; let sourceRequest; let requestCount = 0; @@ -568,7 +567,7 @@ test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", as assert.equal(sourceRequest.url.toString(), `https://api.caiyunapp.com/v2.6/test-token/116.3176,39.976/realtime?lang=${colorfulCloudsLanguage}&alert=true`); assert.equal(sourceRequest.headers.get("Referer"), "https://caiyunapp.com/"); - assert.equal(requestCount, index + 1); + assert.equal(requestCount, (index + 1) * 2); } assert.equal(extracted.source, "国家预警信息发布中心"); @@ -600,13 +599,11 @@ test("ColorfulClouds reuses its realtime cache and standardizes v2.6 alerts", as } }); -test("only ColorfulClouds realtime requests and caches v2.6 alerts", async () => { +test("only ColorfulClouds realtime requests v2.6 alerts", async () => { const source = await readFile(new URL("../src/class/ColorfulClouds.mjs", import.meta.url), "utf8"); assert.match(source, /\/realtime\?[^\n]*alert=true/); for (const endpoint of ["minutely", "hourly", "daily"]) assert.doesNotMatch(source, new RegExp(`/${endpoint}\\?[^\\n]*alert=true`), endpoint); - assert.match(source, /switch \(body\?\.result\?\.realtime\?\.status\)[\s\S]*switch \(body\?\.result\?\.alert\?\.status\) \{\s*case "ok":\s*this\.#cache\.alert = body\.result\.alert;/); - assert.doesNotMatch(source, /result\?\.alert\?\.status === "ok"/); }); test("ColorfulClouds v2.6 alert codes map event names and severities", async () => { @@ -856,12 +853,13 @@ test("Pages routes coordinate WeatherAlert identifiers through QWeather API for } }); -test("Pages returns an empty Apple-compatible array for native WeatherAlert identifiers", async () => { +test("Pages forwards native WeatherAlert identifiers to Apple", async () => { const originalFetch = globalThis.fetch; let fetchCount = 0; + const originalAlerts = [{ id: "35889ee6-fa82-5f9f-8e49-fad78c4f383a", description: "Original Apple warning" }]; globalThis.fetch = async () => { fetchCount++; - return new Response("[]", { headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify(originalAlerts), { headers: { "Content-Type": "application/json" } }); }; try { @@ -870,8 +868,8 @@ test("Pages returns an empty Apple-compatible array for native WeatherAlert iden env: {}, }); assert.equal(response.status, 200); - assert.deepEqual(await response.json(), []); - assert.equal(fetchCount, 0); + assert.deepEqual(await response.json(), originalAlerts); + assert.equal(fetchCount, 1); } finally { globalThis.fetch = originalFetch; } @@ -1052,7 +1050,7 @@ test("the request scripts route coordinate identifiers through configured Colorf } }); -test("the request scripts return an empty Apple-compatible array for coordinate identifiers when WeatherKit is selected", async () => { +test("the request scripts pass coordinate identifiers through when WeatherKit is selected", async () => { const originalArgument = globalThis.$argument; const originalFetch = globalThis.fetch; let fetchCount = 0; @@ -1074,8 +1072,7 @@ test("the request scripts return an empty Apple-compatible array for coordinate headers: {}, }); - assert.equal($response.status, 200); - assert.deepEqual(JSON.parse($response.body), []); + assert.equal($response, undefined); } assert.equal(fetchCount, 0); } finally { @@ -1084,15 +1081,14 @@ test("the request scripts return an empty Apple-compatible array for coordinate } }); -test("the request scripts return an empty Apple-compatible array for non-QWeather identifiers", async () => { +test("the request scripts pass native Apple identifiers through", async () => { for (const handler of [processRequest, processRequestDev]) { const { $response } = await handler({ method: "GET", url: "https://weatherkit.apple.com/api/v1/weatherAlerts?lang=zh-CN&ids=35889ee6-fa82-5f9f-8e49-fad78c4f383a", headers: {}, }); - assert.equal($response.status, 200); - assert.deepEqual(JSON.parse($response.body), []); + assert.equal($response, undefined); } }); diff --git a/tests/severeWeatherRewriteModules.test.mjs b/tests/severeWeatherRewriteModules.test.mjs index 6d87b8de..117b1e9c 100644 --- a/tests/severeWeatherRewriteModules.test.mjs +++ b/tests/severeWeatherRewriteModules.test.mjs @@ -130,35 +130,34 @@ test("Stash weather-alert requests declare the required request type", async () }); test("Rewrite templates stay aligned with fixed Rewrite modules", async () => { - const fixedTemplates = ["loon.rewrite.handlebars", "stash.rewrite.handlebars"]; - for (const filename of rewriteTemplates) { + const fixedTemplates = ["loon.rewrite.handlebars", "stash.rewrite.handlebars"]; + for (const filename of rewriteTemplates) { const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); assert.ok(content.includes("(Rewrite)"), filename); assert.ok(content.includes(weatherAlertsPagePattern), filename); assert.ok(content.includes(weatherAlertsCoordinatePattern), filename); assert.ok(!content.includes(mergedWeatherAlertsIdentifierPattern), filename); - assert.doesNotMatch(content, /www\.qweather\.com/); - assert.doesNotMatch(content, /party=qweather/); - assert.match(content, /weatherkit\.apple\.com/); - assert.doesNotMatch(content, /weatherkit\.nanocat\.cloud/); - } - + assert.doesNotMatch(content, /www\.qweather\.com/); + assert.doesNotMatch(content, /party=qweather/); + assert.match(content, /weatherkit\.apple\.com/); + assert.doesNotMatch(content, /weatherkit\.nanocat\.cloud/); + } - const surge = await readFile(new URL("../template/surge.rewrite.handlebars", import.meta.url), "utf8"); - assert.ok(surge.includes("#!arguments = {{{arguments}}}")); - assert.ok(surge.includes("#!arguments-desc = {{{argumentsDesc}}}")); - assert.ok(surge.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); + const surge = await readFile(new URL("../template/surge.rewrite.handlebars", import.meta.url), "utf8"); + assert.ok(surge.includes("#!arguments = {{{arguments}}}")); + assert.ok(surge.includes("#!arguments-desc = {{{argumentsDesc}}}")); + assert.ok(surge.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); - const shadowrocket = await readFile(new URL("../template/shadowrocket.rewrite.handlebars", import.meta.url), "utf8"); - assert.ok(shadowrocket.includes("#!arguments = endpoint:weatherkit.pages.dev")); - assert.ok(shadowrocket.includes("#!arguments-desc = endpoint: [重写] 服务端点\\n")); - assert.ok(shadowrocket.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); + const shadowrocket = await readFile(new URL("../template/shadowrocket.rewrite.handlebars", import.meta.url), "utf8"); + assert.ok(shadowrocket.includes("#!arguments = endpoint:weatherkit.pages.dev")); + assert.ok(shadowrocket.includes("#!arguments-desc = endpoint: [重写] 服务端点\\n")); + assert.ok(shadowrocket.includes("https://\\{{{endpoint}}}/api/v1/weatherAlerts?$1")); - for (const filename of fixedTemplates) { - const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); - assert.ok(content.includes("https://weatherkit.pages.dev/api/v1/weatherAlerts?$1"), filename); - assert.doesNotMatch(content, /\{\{\{endpoint\}\}\}/, filename); - } + for (const filename of fixedTemplates) { + const content = await readFile(new URL(`../template/${filename}`, import.meta.url), "utf8"); + assert.ok(content.includes("https://weatherkit.pages.dev/api/v1/weatherAlerts?$1"), filename); + assert.doesNotMatch(content, /\{\{\{endpoint\}\}\}/, filename); + } const loon = await readFile(new URL("../template/loon.rewrite.handlebars", import.meta.url), "utf8"); assert.match(loon, /^\[Rewrite\]$/m); @@ -177,11 +176,11 @@ test("Loon Rewrite modules use the legacy Rewrite section", async () => { }); test("Rewrite builder outputs use Rewrite names", async () => { - const content = await readFile(new URL("../arguments-builder.rewrite.config.ts", import.meta.url), "utf8"); - assert.match(content, /iRingo\.WeatherKit\.Rewrite\.(?:sgmodule|lpx|srmodule|stoverride|yaml)/); - assert.match(content, /key: "endpoint"[\s\S]*defaultValue: "weatherkit\.pages\.dev"/); - assert.match(content, /weather\.nanocat\.cloud", label: "Worker 版;需要代理"/); - assert.doesNotMatch(content, /Workers/); + const content = await readFile(new URL("../arguments-builder.rewrite.config.ts", import.meta.url), "utf8"); + assert.match(content, /iRingo\.WeatherKit\.Rewrite\.(?:sgmodule|lpx|srmodule|stoverride|yaml)/); + assert.match(content, /key: "endpoint"[\s\S]*defaultValue: "weatherkit\.pages\.dev"/); + assert.match(content, /weather\.nanocat\.cloud", label: "Worker 版;需要代理"/); + assert.doesNotMatch(content, /Workers/); assert.doesNotMatch(content, /\.workers\.handlebars/); }); @@ -269,7 +268,7 @@ test("WeatherAlert provider settings expose web and user API choices", async () ]); assert.match(full, /key: "WeatherAlerts\.Provider"[\s\S]*defaultValue: "QWeatherWeb"[\s\S]*key: "WeatherKit"[\s\S]*key: "QWeatherWeb"[\s\S]*key: "QWeather"[\s\S]*key: "ColorfulClouds"/); - assert.match(full, /export const weatherAlerts = \[weatherAlertsProvider\]/); + assert.match(full, /export const weatherAlerts = \[weatherAlertsProvider, weatherAlertsLanguage\]/); assert.match(full, /export const airQuality = \[airQualityCurrentPollutantsProvider\]/); assert.match(full, /export const dataSets: Arg\[\] = \[[\s\S]*defaultValue: \["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "weatherAlerts"\]/); const releaseDataSets = full.slice(full.indexOf("export const dataSets:"), full.indexOf("export const dataSetsFull:")); @@ -286,7 +285,10 @@ test("WeatherAlert provider settings expose web and user API choices", async () assert.match(releaseDataSets, new RegExp(`key: "${key}", label: "${label}"`)); } assert.doesNotMatch(releaseDataSets, /locationInfo|trendComparison|weatherChange/); - assert.match(full, /export const dataSetsFull: Arg\[\] = \[[\s\S]*defaultValue: \["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "news", "weatherAlerts", "weatherChange", "trendComparison", "locationInfo"\][\s\S]*key: "weatherChange", label: "天气变化"[\s\S]*key: "trendComparison", label: "历史对比"/); + assert.match( + full, + /export const dataSetsFull: Arg\[\] = \[[\s\S]*defaultValue: \["airQuality", "currentWeather", "forecastDaily", "forecastHourly", "forecastNextHour", "news", "weatherAlerts", "weatherChange", "trendComparison", "locationInfo"\][\s\S]*key: "weatherChange", label: "天气变化"[\s\S]*key: "trendComparison", label: "历史对比"/, + ); assert.doesNotMatch(full, /export const dataSetsFull: Arg\[\] = \[[\s\S]*\.\.\.dataSets\[0\]/); assert.match(full, /key: "Weather\.Replace"[\s\S]*defaultValue: "CN"[\s\S]*type: "string"/); assert.match(full, /args: \[\.\.\.dataSetsFull/); @@ -294,17 +296,23 @@ test("WeatherAlert provider settings expose web and user API choices", async () assert.match(release, /import \{[^}]*airQuality[^}]*\} from "\.\/arguments-builder\.full\.config"/); assert.match(release, /args: \[\.\.\.dataSets,[^\]]*\.\.\.weatherAlerts,[^\]]*\.\.\.airQuality/); assert.match(dev, /import \{[^}]*airQualityFull[^}]*dataSetsFull[^}]*weatherFull[^}]*\} from "\.\/arguments-builder\.full\.config"/); - assert.match(dev, /args: \[\.\.\.dataSetsFull, \.\.\.weatherFull, \.\.\.weatherAlerts, \.\.\.nextHourFull, \.\.\.airQualityFull, \.\.\.calculateFull, \.\.\.api, \.\.\.storage, \.\.\.logLevel, \.\.\.requestCache\]/); - assert.match(database, /WeatherAlerts: \{ Provider: "QWeatherWeb" \}/); + assert.match(dev, /args: \[\.\.\.dataSetsFull, \.\.\.weatherFull, \.\.\.weatherAlerts, \.\.\.nextHourFull, \.\.\.airQualityFull, \.\.\.calculateFull, \.\.\.api, \.\.\.storage, \.\.\.logLevel\]/); + assert.match(database, /WeatherAlerts: \{ Provider: "QWeatherWeb", Language: "Auto" \}/); assert.match(types, /WeatherAlerts\?: \{[\s\S]*Provider\?: "WeatherKit" \| "QWeatherWeb" \| "QWeather" \| "ColorfulClouds"/); assert.match(boxjs, /@iRingo\.WeatherKit\.Settings\.WeatherAlerts\.Provider[\s\S]*"val":\s*"QWeatherWeb"[\s\S]*"key":\s*"WeatherKit"/); const boxjsSettings = JSON.parse(boxjs); + const alertLanguage = boxjsSettings.find(({ id }) => id === "@iRingo.WeatherKit.Settings.WeatherAlerts.Language"); + assert.equal(alertLanguage.val, "Auto"); + assert.deepEqual(alertLanguage.items.map(({ key }) => key), ["Auto", "zh-Hans", "en"]); const dataSetsSetting = boxjsSettings.find(({ id }) => id === "@iRingo.WeatherKit.Settings.DataSets"); const weatherReplaceSetting = boxjsSettings.find(({ id }) => id === "@iRingo.WeatherKit.Settings.Weather.Replace"); assert.equal(dataSetsSetting.desc, "选择需要解析并处理的数据集;未选择的数据槽始终保持原样透传,不作修改。"); assert.equal(weatherReplaceSetting.type, "text"); assert.equal(weatherReplaceSetting.val, "CN"); - assert.equal(boxjsSettings.some(({ id }) => id === "@iRingo.WeatherKit.Settings.Storage"), false); + assert.equal( + boxjsSettings.some(({ id }) => id === "@iRingo.WeatherKit.Settings.Storage"), + false, + ); assert.equal( boxjsSettings.some(({ type, val }) => type === "text" && Array.isArray(val)), false, diff --git a/tests/surgeProviderRuntime.test.mjs b/tests/surgeProviderRuntime.test.mjs new file mode 100644 index 00000000..377c2902 --- /dev/null +++ b/tests/surgeProviderRuntime.test.mjs @@ -0,0 +1,37 @@ +// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import test from "node:test"; +globalThis.$environment = { "surge-version": "fixture" }; +const [{ default: fetchProvider }, { hasNativeProviderRuntime }] = await Promise.all([import("../src/function/fetchProvider.mjs"), import("../src/function/nativeProviderRuntime.mjs")]); +for (const milliseconds of [250, 499, 500, 501, 999, 1499, 5000]) + test(`Surge converts ${milliseconds} internal milliseconds to native seconds without rounding`, async () => { + globalThis.$httpClient = { + get(request, callback) { + assert.equal(request.timeout, milliseconds / 1000); + callback(null, { status: 200, headers: {} }, "{}"); + }, + }; + assert.equal((await fetchProvider({ url: "https://provider.example/weather", timeout: milliseconds })).status, 200); + }); +test("Surge copies supported HTTP fields and caller headers without passing Loon fields", async () => { + const request = { url: "https://provider.example/weather", headers: { HOST: "old", "Content-Length": "1", Authorization: "fixture" }, node: "Loon-only", policy: "DIRECT", "auto-cookie": false }; + globalThis.$httpClient = { + get(outgoing, callback) { + assert.deepEqual(outgoing.headers, { Authorization: "fixture" }); + assert.equal(outgoing.policy, "DIRECT"); + assert.equal(outgoing.node, undefined); + assert.equal(outgoing["auto-cookie"], false); + callback(null, { status: 304, headers: {} }); + }, + }; + const actual = await fetchProvider(request); + assert.equal(actual.body, ""); + assert.equal(request.headers.HOST, "old"); +}); + +test("unrecognized clients use the shared HTTP adapter", () => { + const original = globalThis.$environment; + delete globalThis.$environment; + assert.equal(hasNativeProviderRuntime(), false); + globalThis.$environment = original; +}); diff --git a/tests/upstreamIntent.test.mjs b/tests/upstreamIntent.test.mjs index 1733e243..16bf04e9 100644 --- a/tests/upstreamIntent.test.mjs +++ b/tests/upstreamIntent.test.mjs @@ -5,15 +5,33 @@ import { readLoonRules } from "../scripts/verify-loon-rewrites.mjs"; globalThis.$loon = "simulated Loon"; globalThis.$persistentStore = { read: () => null, write: () => true }; -globalThis.$httpClient = { get: () => { throw new Error("Unexpected real provider request"); } }; +globalThis.$httpClient = { + get: () => { + throw new Error("Unexpected real provider request"); + }, +}; const [{ Response }, { Response: ResponseDev }, { default: AirQuality }, { default: ColorfulClouds }, { default: QWeather }, { default: database }, { qs }] = await Promise.all([ - import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("../src/class/AirQuality.mjs"), import("../src/class/ColorfulClouds.mjs"), import("../src/class/QWeather.mjs"), import("../src/function/database.mjs"), import("@nsnanocat/util/polyfill/qs.mjs"), + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/class/AirQuality.mjs"), + import("../src/class/ColorfulClouds.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/function/database.mjs"), + import("@nsnanocat/util/polyfill/qs.mjs"), ]); const baseline = JSON.parse(await readFile(new URL("./fixtures/upstream-intent.json", import.meta.url))); const metadata = () => ({ providerName: "Apple fixture", temporarilyUnavailable: false }); -const pollutants = () => [["PM2_5", 35], ["PM10", 60], ["OZONE", 90], ["NO2", 35], ["SO2", 15], ["CO", 700]].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); +const pollutants = () => + [ + ["PM2_5", 35], + ["PM10", 60], + ["OZONE", 90], + ["NO2", 35], + ["SO2", 15], + ["CO", 700], + ].map(([pollutantType, amount]) => ({ pollutantType, amount, units: "MICROGRAMS_PER_CUBIC_METER" })); const product = () => ({ metadata: metadata(), index: 42, categoryIndex: 1, scale: "EPA_NowCast", previousDayComparison: "SIMILAR", pollutants: pollutants() }); -const settings = values => globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI", ...values }); +const settings = values => (globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "airQuality", "AirQuality.Calculate.Algorithm": "CA_AQHI", ...values })); const request = { url: "https://weatherkit.apple.com/api/v3/weather/en-US/31.2/121.5?country=CN&dataSets=airQuality", method: "GET" }; async function processAQ(processResponse, value) { const result = await processResponse(request, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ airQuality: value }) }); @@ -27,7 +45,7 @@ test("original defaults keep target selection, source selection and comparison c assert.deepEqual(s.AirQuality.Comparison, baseline.airQualityComparison); assert.equal(s.NextHour.Provider, baseline.originalArgumentDefaults["NextHour.Provider"]); assert.equal(s.WeatherAlerts.Provider, baseline.originalArgumentDefaults["WeatherAlerts.Provider"]); - assert.equal(s.ProviderFallback.Enabled, false); + for (const key of ["RequestCache", "Region", "ProviderFallback"]) assert.equal(Object.hasOwn(s, key), false); }); test("native availability unlocks the same original capability set and preserves new Apple features", async () => { @@ -37,10 +55,15 @@ test("native availability unlocks the same original capability set and preserves } }); -for (const [name, processResponse] of [["release", Response], ["development", ResponseDev]]) { +for (const [name, processResponse] of [ + ["release", Response], + ["development", ResponseDev], +]) { test(`${name}: selecting an algorithm does not override the original default target list`, async t => { settings({}); - t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { throw new Error("No missing pollutants"); }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", () => { + throw new Error("No missing pollutants"); + }); const value = await processAQ(processResponse, product()); assert.equal(value.index, 42); assert.equal(value.scale, "EPA_NowCast"); @@ -58,10 +81,14 @@ for (const [name, processResponse] of [["release", Response], ["development", Re test(`${name}: partial pollutant completion respects an empty or nonmatching target list`, async t => { let calls = 0; - t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { calls++; return { metadata: { providerName: "Supplement", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "HJ6332012", pollutants: pollutants() }; }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => { + calls++; + return { metadata: { providerName: "Supplement", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "HJ6332012", pollutants: pollutants() }; + }); for (const target of [[], ["HJ6332012"]]) { settings({ "AirQuality.Current.Index.Replace": target }); - const input = product();input.pollutants = input.pollutants.slice(0, 1); + const input = product(); + input.pollutants = input.pollutants.slice(0, 1); const value = await processAQ(processResponse, input); assert.equal(value.pollutants.length, 6); assert.equal(value.index, 42); @@ -76,7 +103,8 @@ for (const [name, processResponse] of [["release", Response], ["development", Re test(`${name}: None preserves the Apple index after a valid pollutant supplement`, async t => { settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Calculate.Algorithm": "None", "AirQuality.Comparison.ReplaceWhenCurrentChange": true }); t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ metadata: { providerName: "Supplement", temporarilyUnavailable: false }, pollutants: pollutants() })); - const input = product();input.pollutants = input.pollutants.slice(0, 1); + const input = product(); + input.pollutants = input.pollutants.slice(0, 1); const value = await processAQ(processResponse, input); assert.equal(value.index, 42); assert.equal(value.scale, "EPA_NowCast"); @@ -86,7 +114,9 @@ for (const [name, processResponse] of [["release", Response], ["development", Re test(`${name}: the original comparison switch keeps an existing trend without extra queries`, async t => { settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Comparison.ReplaceWhenCurrentChange": false }); - t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", () => { throw new Error("An existing comparison must be kept"); }); + t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", () => { + throw new Error("An existing comparison must be kept"); + }); const value = await processAQ(processResponse, product()); assert.equal(value.scale, "CA.AQHI"); assert.equal(value.previousDayComparison, "SIMILAR"); @@ -95,9 +125,18 @@ for (const [name, processResponse] of [["release", Response], ["development", Re test(`${name}: explicit comparison replacement honors the selected independent source`, async t => { settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Comparison.ReplaceWhenCurrentChange": true, "AirQuality.Comparison.Yesterday.IndexProvider": "ColorfulCloudsUS" }); - let currentCalls = 0, yesterdayCalls = 0; - t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", async usa => { assert.equal(usa, true);yesterdayCalls++;return { metadata: { providerName: "Yesterday fixture", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "EPA_NowCast" }; }); - t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async usa => { assert.equal(usa, true);currentCalls++;return { metadata: { providerName: "Current fixture", temporarilyUnavailable: false }, index: 20, categoryIndex: 1, scale: "EPA_NowCast" }; }); + let currentCalls = 0, + yesterdayCalls = 0; + t.mock.method(ColorfulClouds.prototype, "YesterdayAirQuality", async usa => { + assert.equal(usa, true); + yesterdayCalls++; + return { metadata: { providerName: "Yesterday fixture", temporarilyUnavailable: false }, index: 199, categoryIndex: 4, scale: "EPA_NowCast" }; + }); + t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async usa => { + assert.equal(usa, true); + currentCalls++; + return { metadata: { providerName: "Current fixture", temporarilyUnavailable: false }, index: 20, categoryIndex: 1, scale: "EPA_NowCast" }; + }); const value = await processAQ(processResponse, product()); assert.equal(currentCalls, 1); assert.equal(yesterdayCalls, 1); @@ -108,8 +147,11 @@ for (const [name, processResponse] of [["release", Response], ["development", Re test(`${name}: a failed index source does not discard a successful pollutant supplement`, async t => { settings({ "AirQuality.Current.Index.Replace": "EPA_NowCast", "AirQuality.Current.Index.Provider": "QWeather" }); t.mock.method(ColorfulClouds.prototype, "CurrentAirQuality", async () => ({ metadata: { providerName: "Supplement", temporarilyUnavailable: false }, pollutants: pollutants() })); - t.mock.method(QWeather.prototype, "CurrentAirQuality", async () => { throw new Error("Fixture failure"); }); - const input = product();input.pollutants = input.pollutants.slice(0, 1); + t.mock.method(QWeather.prototype, "CurrentAirQuality", async () => { + throw new Error("Fixture failure"); + }); + const input = product(); + input.pollutants = input.pollutants.slice(0, 1); const value = await processAQ(processResponse, input); assert.equal(value.pollutants.length, 6); assert.equal(value.index, 42); diff --git a/tests/waqiRequestCache.test.mjs b/tests/waqiRequestCache.test.mjs deleted file mode 100644 index 87b31cae..00000000 --- a/tests/waqiRequestCache.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2026 hhhoratioxu. SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; -import test from "node:test"; - -globalThis.$loon = "Mac test runtime"; -const stored = new Map(); -globalThis.$persistentStore = { - read: key => stored.get(key), - write: (value, key) => { - stored.set(key, value); - return true; - }, -}; -const callbacks = []; -let calls = 0; -globalThis.$httpClient = { - get(request, callback) { - assert.equal(request.timeout, 5000); - calls++; - callbacks.push(() => callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", data: { city: { name: "Fixture city", geo: [31.2, 121.5], url: "https://example.test/fixture" }, aqi: "42", dominentpol: "pm25", time: { v: 1_790_000_000 }, idx: 123 } }))); - }, -}; - -const [{ default: WAQI }, { default: ProviderRequestCache }, { Console }] = await Promise.all([import("../src/class/WAQI.mjs"), import("../src/class/ProviderRequestCache.mjs"), import("@nsnanocat/util")]); -Console.logLevel = "OFF"; -const parameters = { latitude: 31.2, longitude: 121.5, language: "zh-CN", country: "CN", version: "v2" }; - -test("real WAQI adapter coalesces native callbacks and reuses persisted data in a new execution", async () => { - const first = new WAQI(parameters, "fixture-token", new ProviderRequestCache()); - const pending = Array.from({ length: 6 }, () => first.AQI2()); - assert.equal(calls, 1); - callbacks.shift()(); - const products = await Promise.all(pending); - assert.equal( - products.every(product => product.index === 42), - true, - ); - products[0].metadata.providerName = "changed"; - assert.notEqual(products[1].metadata.providerName, "changed"); - const next = new WAQI(parameters, "fixture-token", new ProviderRequestCache()); - assert.equal((await next.AQI2()).index, 42); - assert.equal(calls, 1); - assert.equal([...stored.values()].join("").includes("fixture-token"), false); -}); - -test("WAQI token errors cool down across executions and token bodies remain uncached", async () => { - stored.clear(); - const originalHTTP = globalThis.$httpClient; - let authCalls = 0; - globalThis.$httpClient = { - get(_request, callback) { - authCalls++; - callback(null, { status: 200, headers: {} }, JSON.stringify({ rxs: { status: "error", obs: [] } })); - }, - }; - try { - for (let index = 0; index < 3; index++) { - const provider = new WAQI(parameters, "fixture-private-token", ProviderRequestCache.fromSettings({})); - assert.equal(await provider.Token(123), undefined); - } - assert.equal(authCalls, 2); - assert.equal(stored.has("WeatherKit.ProviderResponses.v1"), false); - assert.doesNotMatch([...stored.values()].join(""), /fixture-private-token|api\.waqi\.info|obs|rxs/); - } finally { - globalThis.$httpClient = originalHTTP; - } -}); diff --git a/tests/weatherAlertExpiry.test.mjs b/tests/weatherAlertExpiry.test.mjs new file mode 100644 index 00000000..52d8955d --- /dev/null +++ b/tests/weatherAlertExpiry.test.mjs @@ -0,0 +1,133 @@ +// Synthetic public protocol fixtures. Time zones are changed only in child processes. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => { throw new Error("Shared storage writes are forbidden in this fixture"); } }; +globalThis.$argument = { Storage: "Argument", LogLevel: "OFF" }; +const [{ default: WeatherAlerts }, { activeProviderAlerts }, { Response }, { default: QWeather }, { default: WeatherKit2 }, { Console }] = await Promise.all([ + import("../src/class/WeatherAlerts.mjs"), + import("../src/function/providerRecovery.mjs"), + import("../src/process/Response.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; + +const legacy = "9999-12-31T23:59:59Z"; +const safe = "9999-12-31T00:00:00Z"; +const unknown = [["missing", undefined], ["null", null], ["legacy", legacy], ["safe", safe]]; +const genuine = [["future", "2099-06-01T01:02:03.456+08:00"], ["expired", "2000-06-01T01:02:03.456+08:00"]]; +const context = { identifier: "fixture-101000001", language: "en-GB", countryCode: "CN", attributionUrl: "https://fixture.example/alert" }; +const sourceAlert = fields => ({ description: "Fixture wind warning", eventName: "Wind warning", issuedTime: "2026-11-14T04:09:00Z", reportedAt: "2026-11-14T04:09:00Z", message: "Original notice", standard: "Definition", guidelines: ["Original guidance"], severity: "moderate", source: "Fixture issuer", ...fields }); +const build = fields => WeatherAlerts.Build({ areaName: "Fixture", source: "Fixture issuer", alerts: [sourceAlert(fields)] }, context)[0]; + +for (const [label, value] of unknown) { + test(`REST unknown expiry (${label}) uses the safe string and cannot invent an event end`, () => { + const input = { expireTime: value }; + const before = structuredClone(input); + const row = build(input); + assert.equal(row.expireTime, safe); + assert.equal(typeof row.expireTime, "string"); + assert.equal(Object.hasOwn(row, "eventEndTime"), false); + assert.deepEqual(input, before); + }); +} +for (const value of [legacy, safe]) { + test(`unknown explicit event end is omitted (${value})`, () => { + const row = build({ expireTime: value, eventEndTime: value }); + assert.equal(row.expireTime, safe); + assert.equal(Object.hasOwn(row, "eventEndTime"), false); + }); +} +for (const [label, value] of genuine) { + test(`REST retains the exact ${label} source expiry, including its offset and milliseconds`, () => { + const row = build({ expireTime: value }); + assert.equal(row.expireTime, value); + assert.equal(row.eventEndTime, value); + assert.equal(row.issuedTime, "2026-11-14T04:09:00Z"); + }); +} +test("a genuine event end remains distinct from an unknown notice expiry", () => { + const end = "2099-06-01T02:03:04.567+08:00"; + const row = build({ expireTime: legacy, eventEndTime: end }); + assert.equal(row.expireTime, safe); + assert.equal(row.eventEndTime, end); +}); +test("both sentinels are unknown during filtering, while a genuine end still determines availability", () => { + const afterSafeSentinel = Date.parse("9999-12-31T12:00:00Z"); + for (const value of [legacy, safe]) { + assert.equal(activeProviderAlerts([{ expireTime: value }], afterSafeSentinel).length, 1); + assert.equal(activeProviderAlerts([{ eventEndTime: value }], afterSafeSentinel).length, 1); + assert.equal(activeProviderAlerts([{ expireTime: value, eventEndTime: "2000-01-01T00:00:00Z" }], Date.parse("2026-11-14T00:00:00Z")).length, 0); + } + const future = { expireTime: genuine[0][1] }, expired = { expireTime: genuine[1][1] }; + assert.deepEqual(activeProviderAlerts([future, expired], Date.parse("2026-11-14T00:00:00Z")), [future]); +}); + +for (const zone of ["UTC", "Asia/Taipei", "Pacific/Kiritimati", "Etc/GMT+12"]) { + test(`${zone}: the existing Apple local-year guard clears unknown expiry and retains real times`, () => { + const script = ` + import assert from 'node:assert/strict'; + import WeatherAlerts from './src/class/WeatherAlerts.mjs'; + const values = [undefined, null, '${legacy}', '${safe}']; + const make = value => WeatherAlerts.Build({areaName:'Fixture', source:'Fixture', alerts:[{description:'Wind warning', issuedTime:'2026-11-14T04:09:00Z', reportedAt:'2026-11-14T04:09:00Z', message:'Original notice', guidelines:['Original guidance'], severity:'moderate', expireTime:value}]}, ${JSON.stringify(context)})[0]; + for (const value of values) { + const row = make(value); + const parsed = new Date(row.expireTime); + assert.equal(parsed.getFullYear(), 9999); + // Contract used by the actual cached Apple consumer, independent of our producer. + const consumedExpiry = parsed.getFullYear() === 9999 ? null : parsed; + assert.equal(consumedExpiry, null); + assert.equal(Object.hasOwn(row, 'eventEndTime'), false); + assert.equal(row.messages[0].text, 'Original notice'); + } + for (const value of ${JSON.stringify(genuine.map(([, value]) => value))}) { + const row = make(value); + assert.equal(row.expireTime, value); + const parsed = new Date(row.expireTime); + assert.notEqual(parsed.getFullYear(), 9999); + assert.equal(parsed.getTime(), Date.parse(value)); + } + console.log('EXPIRY_CHECK=' + JSON.stringify({zone:process.env.TZ, unknown:values.length, genuine:2})); + `; + const before = process.env.TZ; + const stdout = execFileSync(process.execPath, ["--input-type=module", "-e", script], { cwd: new URL("..", import.meta.url), encoding: "utf8", env: { ...process.env, TZ: zone } }); + assert.equal(process.env.TZ, before); + const result = JSON.parse(stdout.split("\n").find(line => line.startsWith("EXPIRY_CHECK=")).slice("EXPIRY_CHECK=".length)); + assert.deepEqual(result, { zone, unknown: 4, genuine: 2 }); + }); +} + +const realAppleTimes = { issuedTime: 1_794_628_800, effectiveTime: 1_794_629_000, eventOnsetTime: 1_794_629_200, eventEndTime: 1_794_632_000, expireTime: 1_794_633_000 }; +const nativeWarning = fields => ({ id: "00112233-4455-6677-8899-aabbccddeeff", description: "Fixture wind warning", phenomenon: "Wind", severity: "moderate", responses: [], ...fields }); +for (const version of ["v2", "v3"]) { + for (const populated of [false, true]) { + test(`${version}: unknown source times cannot replace genuine Apple times or manufacture end times (existing=${populated})`, async t => { + let supplied; + t.mock.method(QWeather.prototype, "WeatherAlert", async () => structuredClone(supplied)); + for (const [label, value] of unknown) { + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "weatherAlerts", WeatherAlerts: { Provider: "QWeather" } }; + const request = globalThis.$request = { method: "GET", url: `https://weatherkit.apple.com/api/${version}/weather/en-US/31.2/121.5?country=CN&dataSets=weatherAlerts`, headers: {} }; + const warning = nativeWarning(populated ? realAppleTimes : {}); + const body = { weatherAlerts: { metadata: { temporarilyUnavailable: false, providerName: "National Early Warning Center" }, alerts: [warning] } }; + supplied = { metadata: { temporarilyUnavailable: false, providerName: "Fixture" }, alerts: [sourceAlert({ expireTime: value, eventEndTime: value, areaName: "Source area" })] }; + const response = version === "v2" + ? { status: 200, headers: { "Content-Type": "application/vnd.apple.flatbuffer" }, body: WeatherKit2.encode(undefined, body) } + : { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; + const output = await Response(request, response); + const actual = version === "v2" ? WeatherKit2.decode(new ByteBuffer(output.body), ["weatherAlerts"]).weatherAlerts.alerts[0] : JSON.parse(output.body).weatherAlerts.alerts[0]; + assert.equal(actual.areaName, "Source area", `${version}/${label}: actual enrichment occurred`); + if (populated) { + for (const [key, expected] of Object.entries(realAppleTimes)) assert.equal(actual[key], expected, `${version}/${label}/${key}`); + } else { + assert.ok(actual.expireTime == null || actual.expireTime === 0, `${version}/${label}: no unknown expiry installed`); + assert.ok(actual.eventEndTime == null || actual.eventEndTime === 0, `${version}/${label}: no unknown end installed`); + } + } + }); + } +} diff --git a/tests/weatherAlertLanguage.test.mjs b/tests/weatherAlertLanguage.test.mjs new file mode 100644 index 00000000..5141fe92 --- /dev/null +++ b/tests/weatherAlertLanguage.test.mjs @@ -0,0 +1,183 @@ +// Synthetic source responses test language routing; no translation service or live account is used. +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +globalThis.$httpClient = { get() { throw new Error("Unexpected network request"); } }; +const [{ weatherAlertLanguage, weatherAlertParameters }, { default: createProviderEnvironment }, { GetWeatherAlerts }, { default: ProviderTransport }, { default: database }, { Console }] = await Promise.all([ + import("../src/function/weatherAlertLanguage.mjs"), + import("../src/function/createProviderEnvironment.mjs"), + import("../src/function/getWeatherAlerts.mjs"), + import("../src/class/ProviderTransport.mjs"), + import("../src/function/database.mjs"), + import("@nsnanocat/util"), +]); +const [{ Response }, { Response: ResponseDev }, { default: QWeather }] = await Promise.all([import("../src/process/Response.mjs"), import("../src/process/Response.dev.mjs"), import("../src/class/QWeather.mjs")]); +Console.logLevel = "OFF"; +const parameters = { latitude: 31.2, longitude: 121.5, country: "CN", version: "v1", language: "en-GB", weatherKitLanguage: "en-GB" }; +const settings = (language, provider = "QWeatherWeb") => ({ WeatherAlerts: { Provider: provider, ...(language === undefined ? {} : { Language: language }) }, API: {} }); +const html = english => `

Fixture

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

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

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

`; + +test("missing, Auto and unsupported saved language settings retain the original parameter object", () => { + for (const value of [undefined, "Auto", "auto", "", null, false, {}, ["en"], "fr", "zh-Hant", ""]) { + assert.equal(weatherAlertLanguage(settings(value)), undefined); + assert.equal(weatherAlertParameters(parameters, settings(value)), parameters); + } + assert.equal(database.WeatherKit.Settings.WeatherAlerts.Language, "Auto"); +}); + +test("supported language aliases only override warning locale fields", () => { + for (const [value, expected] of [["zh-Hans", "zh-Hans"], ["zh_CN", "zh-Hans"], ["zh-CN", "zh-Hans"], ["en", "en"], ["en-US", "en"], ["en_GB", "en"]]) { + const actual = weatherAlertParameters(parameters, settings(value)); + assert.deepEqual(actual, { ...parameters, language: expected, weatherKitLanguage: expected }); + assert.notEqual(actual, parameters); + assert.equal(parameters.language, "en-GB"); + } +}); + +test("Auto shares existing lazy provider instances", () => { + const env = createProviderEnvironment(parameters, settings("Auto"), new ProviderTransport()); + assert.equal(env.weatherAlertsQWeather, env.qWeather); + assert.equal(env.weatherAlertsColorfulClouds, env.colorfulClouds); +}); + +test("explicit warning language is isolated from other weather and AQ provider instances", () => { + const env = createProviderEnvironment(parameters, settings("zh-Hans"), new ProviderTransport()); + assert.notEqual(env.weatherAlertsQWeather, env.qWeather); + assert.notEqual(env.weatherAlertsColorfulClouds, env.colorfulClouds); + assert.equal(env.qWeather.language, "en"); + assert.equal(env.colorfulClouds.language, "en_GB"); + assert.equal(env.weatherAlertsQWeather.language, "zh-hans"); + assert.equal(env.weatherAlertsColorfulClouds.language, "zh_CN"); + assert.equal(env.qWeather.weatherKitLanguage, "en-GB"); + assert.equal(env.weatherAlertsQWeather.weatherKitLanguage, "zh-Hans"); +}); + +for (const [preference, english] of [["zh-Hans", false], ["en", true]]) { + test(`${preference}: page details request a native source locale and preserve original text and attribution`, async t => { + const seen = []; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + seen.push(request); + callback(null, { status: 200, headers: { "Content-Type": "text/html;charset=UTF-8" } }, html(english)); + }); + const headers = { "Accept-Language": english ? "zh-CN" : "en-GB", "If-None-Match": "original-apple-validator" }; + const originalHeaders = structuredClone(headers); + const result = await GetWeatherAlerts("fixture-101010100", parameters, settings(preference), headers, new ProviderTransport()); + assert.equal(seen.length, 1); + assert.equal(new URL(seen[0].url).pathname.startsWith("/en/"), english); + assert.equal(seen[0].headers["Accept-Language"], preference); + assert.deepEqual(headers, originalHeaders); + assert.equal(result.status, 200); + assert.equal(result.headers["Cache-Control"], "no-store"); + const rows = JSON.parse(result.body); + assert.equal(rows.length, 1); + assert.equal(rows[0].messages[0].text, english ? "Original source fog notice." : "来源原文:请防范大雾。"); + assert.equal(rows[0].messages[0].language, "und", "requested language does not certify returned warning text"); + assert.equal(new URL(rows[0].attributionURL).hostname, "www.qweather.com"); + assert.ok(rows[0].source); + }); +} + +test("different language source URLs stay distinct and a shared transport reuses no completed alert data", async t => { + const URLs = []; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + URLs.push(request.url); + callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html(new URL(request.url).pathname.startsWith("/en/"))); + }); + const transport = new ProviderTransport(); + const zh = await GetWeatherAlerts("fixture-101010100", parameters, settings("zh-Hans"), {}, transport); + const en = await GetWeatherAlerts("fixture-101010100", parameters, settings("en"), {}, transport); + assert.equal(URLs.length, 2); + assert.notEqual(URLs[0], URLs[1]); + assert.notEqual(JSON.parse(zh.body)[0].messages[0].text, JSON.parse(en.body)[0].messages[0].text); +}); + +test("old settings and explicit Auto retain exactly the same detail response and request language", async t => { + const seen = []; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + seen.push(request); + callback(null, { status: 200, headers: { "Content-Type": "text/html" } }, html(true)); + }); + const a = await GetWeatherAlerts("fixture-101010100", parameters, settings(undefined), {}, new ProviderTransport()); + const b = await GetWeatherAlerts("fixture-101010100", parameters, settings("Auto"), {}, new ProviderTransport()); + assert.deepEqual(a, b); + assert.equal(a.headers["Cache-Control"], "max-age=0"); + assert.equal(JSON.parse(a.body)[0].messages[0].language, "en-GB"); + assert.equal(seen[0].url, seen[1].url); +}); + +test("an API returning another language keeps the source original instead of inventing English", async t => { + let requested; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + requested = request; + callback(null, { status: 200, headers: {} }, JSON.stringify({ metadata: {}, alerts: [{ id: "source-original", senderName: "原签发单位", issuedTime: "2026-11-14T00:00:00Z", effectiveTime: "2026-11-14T00:00:00Z", expireTime: "2099-01-01T00:00:00Z", headline: "来源中文预警", description: "该来源没有提供英文,保留此原文。", severity: "moderate", responseTypes: [] }] })); + }); + const result = await GetWeatherAlerts("31.2,121.5", parameters, settings("en", "QWeather"), {}, new ProviderTransport()); + assert.equal(new URL(requested.url).searchParams.get("lang"), "en"); + const row = JSON.parse(result.body)[0]; + assert.equal(row.messages[0].text, "该来源没有提供英文,保留此原文。"); + assert.equal(row.messages[0].language, "und"); + assert.equal(row.source, "原签发单位"); +}); + +for (const [name, status, body] of [["HTTP failure", 403, "blocked"], ["missing fields", 200, "unrecognized"], ["empty source body", 200, ""]]) { + test(`${name}: preferred language never manufactures an available response`, async t => { + let calls = 0; + t.mock.method(globalThis.$httpClient, "get", (_, callback) => { + calls++; + callback(null, { status, headers: { "Content-Type": "text/html" } }, body); + }); + assert.equal(await GetWeatherAlerts("fixture-101010100", parameters, settings("en"), {}, new ProviderTransport()), undefined); + assert.equal(calls, 1); + }); +} + +test("WeatherKit-selected coordinate details retain the Apple path for every preference", async () => { + for (const value of ["Auto", "zh-Hans", "en"]) assert.equal(await GetWeatherAlerts("31.2,121.5", parameters, settings(value, "WeatherKit"), {}, new ProviderTransport()), undefined); +}); + +test("missing coordinate parameters fall back without throwing or contacting a provider", async () => { + for (const value of [undefined, null, {}]) { + for (const preference of ["Auto", "en"]) assert.equal(await GetWeatherAlerts("31.2,121.5", value, settings(preference), undefined, new ProviderTransport()), undefined); + } +}); + +test("Caiyun language requests retain an untranslated original when that is what the source supplies", async t => { + let outgoing; + t.mock.method(globalThis.$httpClient, "get", (request, callback) => { + outgoing = request; + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", result: { realtime: { status: "ok" }, alert: { status: "ok", content: [{ code: "0501", description: "原始中文预警,请保留原意。", pubtimestamp: 1794615840, source: "测试签发单位", regionId: "fixture-region", city: "测试区域" }] } } })); + }); + const result = await GetWeatherAlerts("31.2,121.5", parameters, settings("en", "ColorfulClouds"), {}, new ProviderTransport()); + assert.equal(new URL(outgoing.url).searchParams.get("lang"), "en_US"); + const row = JSON.parse(result.body)[0]; + assert.ok(row.messages.some(message => message.text === "原始中文预警,请保留原意。")); + assert.ok(row.messages.every(message => message.language === "und")); +}); + +for (const [name, process] of [["release", Response], ["development", ResponseDev]]) { + test(`${name}: WK3 weather and alerts use independent locales in the same response`, async t => { + const seen = []; + t.mock.method(QWeather.prototype, "WeatherNow", async function () { + seen.push(["weather", this.language]); + return { metadata: { providerName: "Fixture Weather", temporarilyUnavailable: false }, temperature: 22, conditionCode: "CLEAR" }; + }); + t.mock.method(QWeather.prototype, "WeatherAlertWeb", async function () { + seen.push(["alerts", this.weatherKitLanguage]); + return { metadata: { temporarilyUnavailable: false }, alerts: [], detailsUrl: "https://weatherkit.apple.com/alertDetails/index.html?ids=fixture-101010100&lang=zh-Hans" }; + }); + const previous = globalThis.$argument; + globalThis.$argument = { Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather,weatherAlerts", Weather: { Provider: "QWeather", Replace: ["CN"] }, WeatherAlerts: { Provider: "QWeatherWeb", Language: "zh-Hans" } }; + try { + const response = await process({ method: "GET", url: "https://weatherkit.apple.com/api/v3/weather/en-GB/31.2/121.5?country=CN&dataSets=currentWeather,weatherAlerts", headers: {} }, { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLEAR" }, weatherAlerts: { metadata: { providerName: "National Early Warning Center", temporarilyUnavailable: true }, alerts: [] }, futureProduct: { keep: true } }) }); + assert.deepEqual(seen.sort(), [["alerts", "zh-Hans"], ["weather", "en"]]); + const body = JSON.parse(response.body); + assert.equal(body.currentWeather.temperature, 22); + assert.equal(body.weatherAlerts.metadata.temporarilyUnavailable, true); + assert.deepEqual(body.futureProduct, { keep: true }); + } finally { + globalThis.$argument = previous; + } + }); +} diff --git a/tests/weatherBoundaryRegressions.test.mjs b/tests/weatherBoundaryRegressions.test.mjs new file mode 100644 index 00000000..29d6af34 --- /dev/null +++ b/tests/weatherBoundaryRegressions.test.mjs @@ -0,0 +1,229 @@ +// All payloads are synthetic. Exercise shared release/development paths. +import assert from "node:assert/strict"; +import test from "node:test"; +import { ByteBuffer } from "flatbuffers"; + +globalThis.$loon = "simulated Loon"; +globalThis.$persistentStore = { read: () => null, write: () => true }; +const [{ default: ForecastNextHour }, { Response }, { Response: ResponseDev }, { Request }, { Request: RequestDev }, { default: WeatherKit2 }, { default: QWeather }, { default: ColorfulClouds }, { qs }, { Console }] = await Promise.all([ + import("../src/class/ForecastNextHour.mjs"), + import("../src/process/Response.mjs"), + import("../src/process/Response.dev.mjs"), + import("../src/process/Request.mjs"), + import("../src/process/Request.dev.mjs"), + import("../src/class/WeatherKit2.mjs"), + import("../src/class/QWeather.mjs"), + import("../src/class/ColorfulClouds.mjs"), + import("@nsnanocat/util/polyfill/qs.mjs"), + import("@nsnanocat/util"), +]); +Console.logLevel = "OFF"; +const BASE = 1_800_000_000; +const minute = (index, rain, intensity = 1) => ({ startTime: BASE + index * 60, precipitationChance: rain ? 80 : 0, precipitationIntensity: rain ? intensity : 0 }); +const summary = minutes => ForecastNextHour.Summary(ForecastNextHour.Minute(minutes, "有小雨")); +const settings = overrides => (globalThis.$argument = qs.parse({ Storage: "Argument", LogLevel: "OFF", DataSets: "currentWeather", "Weather.Provider": "WeatherKit", ...overrides })); +const request = (version = "v3", dataSets = "currentWeather") => (globalThis.$request = { method: "GET", url: `https://weatherkit.apple.com/api/${version}/weather/en-US/31.2/121.5?country=CN&dataSets=${dataSets}`, headers: {} }); +const headers = type => ({ "Content-Type": type, eTaG: '"apple"', "Last-Modified": "yesterday", "cOnTeNt-LeNgTh": "1", "Content-Encoding": "gzip", "Transfer-Encoding": "chunked", Digest: "old", "Cache-Control": "max-age=60", "X-Apple": "retain" }); + +test("a single minute still produces a summary and a condition", () => { + const value = summary([minute(0, true)]); + assert.equal(value.length, 1); + assert.equal(value[0].startTime, BASE); + assert.equal(value[0].endTime, 0); + assert.equal(ForecastNextHour.Condition(value)[0].forecastToken, "CONSTANT"); + assert.deepEqual(summary([]), []); +}); + +for (const rainAtEnd of [true, false]) + test(`a transition on the last minute is retained (${rainAtEnd ? "start" : "stop"})`, () => { + const values = summary([minute(0, !rainAtEnd), minute(1, !rainAtEnd), minute(2, rainAtEnd)]); + assert.deepEqual( + values.map(value => value.clear), + [rainAtEnd, !rainAtEnd], + ); + assert.equal(values[0].endTime, BASE + 120); + assert.equal(values[1].startTime, BASE + 120); + assert.equal(values[1].endTime, 0); + assert.equal(ForecastNextHour.Condition(values).length, 2); + }); + +test("the final minute contributes its probability, intensity and strongest condition", () => { + const value = summary([minute(0, true, 0.2), { ...minute(1, true, 20), precipitationChance: 100 }])[0]; + assert.equal(value.precipitationIntensity, 20); + assert.equal(value.precipitationChance, 100); + assert.equal(value.maxCondition, "HEAVY_RAIN"); +}); + +test("the existing 71-minute summary horizon excludes later samples", () => { + const value = summary(Array.from({ length: 72 }, (_, index) => minute(index, index === 71))); + assert.equal(value.length, 1); + assert.equal(value[0].clear, true); + assert.equal(value[0].endTime, 0); +}); + +for (const [name, processResponse, processRequest] of [ + ["release", Response, Request], + ["development", ResponseDev, RequestDev], +]) { + for (const product of ["forecastPeriodic", "weatherChanges"]) + test(`${name}: ${product}-only JSON still adapts known native fields without supplier work`, async () => { + settings({ DataSets: "" }); + globalThis.$httpClient = { + get() { + assert.fail("No supplier request is needed"); + }, + }; + const body = { [product]: { metadata: {}, forecasts: [{ conditionCode: ["RAIN"], precipitationAmountByType: [{ expected: 0.4, precipitationType: ["RAIN"] }] }] }, futureProduct: { conditionCode: ["UNKNOWN_FUTURE"] } }; + const input = { status: 200, headers: headers("application/json"), body: JSON.stringify(body) }; + const actual = await processResponse(request("v3", product), input); + const parsed = JSON.parse(actual.body); + assert.equal(parsed[product].forecasts[0].conditionCode, "RAIN"); + assert.equal(parsed[product].forecasts[0].precipitationAmount, 0.4); + assert.deepEqual(parsed.futureProduct, body.futureProduct); + assert.equal(actual.headers["Cache-Control"], "no-store"); + for (const key of ["eTaG", "Content-Encoding", "Transfer-Encoding", "cOnTeNt-LeNgTh"]) assert.equal(actual.headers[key], undefined); + }); + + test(`${name}: a changed WK2 body invalidates original HTTP validators and stale bodyBytes`, async t => { + settings({ "Weather.Provider": "QWeather" }); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { temporarilyUnavailable: false, providerName: "fixture" }, temperature: 23 })); + const bytes = WeatherKit2.encode(undefined, { + currentWeather: { + metadata: { temporarilyUnavailable: false }, + conditionCode: "CLEAR", + temperature: 18, + precipitationAmountNext1hByType: [], + precipitationAmountNext6hByType: [], + precipitationAmountNext24hByType: [], + precipitationAmountPrevious1hByType: [], + precipitationAmountPrevious6hByType: [], + precipitationAmountPrevious24hByType: [], + }, + }); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body: bytes, bodyBytes: bytes }; + const actual = await processResponse(request("v2"), input); + assert.equal(WeatherKit2.decode(new ByteBuffer(bytes), ["currentWeather"]).currentWeather.temperature, 18); + assert.equal(WeatherKit2.decode(new ByteBuffer(actual.body), ["currentWeather"]).currentWeather.temperature, 23); + assert.equal(actual.headers["Cache-Control"], "no-store"); + for (const key of ["eTaG", "Last-Modified", "Content-Encoding", "Transfer-Encoding", "cOnTeNt-LeNgTh", "Digest"]) assert.equal(actual.headers[key], undefined); + assert.equal(actual.headers["X-Apple"], "retain"); + assert.equal(actual.bodyBytes, undefined); + assert.equal(input.headers.eTaG, '"apple"'); + assert.strictEqual(input.body, bytes); + }); + + test(`${name}: unchanged WK2 output keeps its validators`, async () => { + settings({ DataSets: "" }); + const body = WeatherKit2.encode(undefined, {}); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body }; + assert.strictEqual((await processResponse(request("v2"), input)).body, body); + assert.equal(input.headers.eTaG, '"apple"'); + }); + + test(`${name}: a rejected WK2 product preserves the original body and validators`, async t => { + settings({ "Weather.Provider": "QWeather" }); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { temporarilyUnavailable: false }, temperature: 23, precipitationAmountNext1hByType: 42 })); + const body = WeatherKit2.encode(undefined, { currentWeather: { metadata: {}, temperature: 18 } }); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body }; + assert.strictEqual(await processResponse(request("v2"), input), input); + assert.strictEqual(input.body, body); + assert.equal(input.headers.eTaG, '"apple"'); + }); + + test(`${name}: provider-only WK2 current weather encodes absent optional precipitation vectors`, async t => { + settings({ "Weather.Provider": "QWeather" }); + t.mock.method(QWeather.prototype, "WeatherNow", async () => ({ metadata: { temporarilyUnavailable: false }, temperature: 23 })); + const input = { status: 200, headers: headers("application/vnd.apple.flatbuffer"), body: WeatherKit2.encode(undefined, {}) }; + const actual = await processResponse(request("v2"), input); + const product = WeatherKit2.decode(new ByteBuffer(actual.body), ["currentWeather"]).currentWeather; + assert.equal(product.temperature, 23); + assert.deepEqual(product.precipitationAmountNext1hByType, []); + assert.equal(actual.headers["Cache-Control"], "no-store"); + }); + + test(`${name}: native Apple warning IDs pass through with their conditional headers`, async () => { + settings(); + globalThis.$httpClient = { + get() { + assert.fail("Opaque Apple IDs must pass through"); + }, + }; + const input = { method: "GET", url: "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=00112233-4455-6677-8899-aabbccddeeff", headers: { "If-None-Match": '"apple"', "If-Modified-Since": "yesterday" } }; + const actual = await processRequest(input); + assert.equal(actual.$response, undefined); + assert.equal(actual.$request.headers["If-None-Match"], '"apple"'); + }); + + test(`${name}: WeatherKit-selected coordinate warning details pass through`, async () => { + settings({ "WeatherAlerts.Provider": "WeatherKit" }); + globalThis.$httpClient = { + get() { + assert.fail("WeatherKit source must pass through"); + }, + }; + const actual = await processRequest({ method: "GET", url: "https://weatherkit.apple.com/api/v1/weatherAlerts?ids=31.2,121.5", headers: {} }); + assert.equal(actual.$response, undefined); + }); +} + +test("fractional QWeather hourly rainfall is not truncated to an integer", async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-13T00:00:00Z", hourly: [{ fxTime: "2026-11-13T01:00:00Z", temp: "18", precip: "0.4" }] })); + }, + }; + const value = await new QWeather({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture").Hourly(); + assert.equal(value.hours[0].precipitationIntensity, 0.4); + assert.equal(value.hours[0].precipitationAmount, 0.4); +}); + +test("invalid Caiyun temperature cannot replace healthy Apple current weather", async () => { + settings({ "Weather.Provider": "ColorfulClouds" }); + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", result: { realtime: { status: "ok", temperature: null, skycon: "RAIN", precipitation: { local: { intensity: 0 } }, wind: { direction: 90, speed: 2 } } } })); + }, + }; + const input = { status: 200, headers: { "Content-Type": "application/json", ETag: '"apple"' }, body: JSON.stringify({ currentWeather: { metadata: { temporarilyUnavailable: false }, temperature: 18, conditionCode: "CLEAR" } }) }; + assert.strictEqual(await Response(request(), input), input); + const provider = new ColorfulClouds({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture"); + assert.equal((await provider.CurrentWeather()).metadata.temporarilyUnavailable, true); +}); + +for (const precipitation of [[], [null], [-1], ["bad"]]) + test(`invalid Caiyun minute measurements are unavailable: ${JSON.stringify(precipitation)}`, async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ status: "ok", server_time: BASE, result: { minutely: { status: "ok", precipitation_2h: precipitation, probability: [0.8, 0.8, 0.8, 0.8], description: "有小雨" } } })); + }, + }; + const provider = new ColorfulClouds({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture"); + assert.equal(await provider.Minutely(), undefined); + }); + +for (const row of [ + { fxTime: "invalid", precip: "0.2" }, + { fxTime: "2026-11-13T01:00:00Z", precip: "" }, + { fxTime: "2026-11-13T01:00:00Z", precip: "-1" }, +]) + test(`invalid QWeather minute measurements are unavailable: ${JSON.stringify(row)}`, async () => { + globalThis.$httpClient = { + get(_request, callback) { + callback(null, { status: 200, headers: {} }, JSON.stringify({ code: "200", updateTime: "2026-11-13T00:00:00Z", summary: "有小雨", minutely: [row] })); + }, + }; + assert.equal(await new QWeather({ country: "CN", latitude: 31.2, longitude: 121.5, language: "en", version: "v2" }, "fixture").Minutely(), undefined); + }); + +for (const [name, Provider] of [ + ["Caiyun", ColorfulClouds], + ["QWeather", QWeather], +]) + test(`${name} minute source retains its existing region restriction`, async () => { + globalThis.$httpClient = { + get() { + assert.fail("Unsupported regions require no supplier request"); + }, + }; + assert.equal(await new Provider({ country: "US", latitude: 40, longitude: -74, language: "en", version: "v2" }, "fixture").Minutely(), undefined); + }); diff --git a/tests/weatherMemoryPaths.test.mjs b/tests/weatherMemoryPaths.test.mjs index 8daa49c1..a63c99b0 100644 --- a/tests/weatherMemoryPaths.test.mjs +++ b/tests/weatherMemoryPaths.test.mjs @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import test from "node:test"; -import ProviderRequestCache, { readProviderJSON } from "../src/class/ProviderRequestCache.mjs"; import mergeForecastCopy from "../src/function/mergeForecastCopy.mjs"; const freeze = value => { @@ -53,110 +52,3 @@ test("a forecast source that fails after one match cannot partially mutate Apple [20, 21], ); }); - -function storage() { - let value; - return { - read: () => value, - write: next => { - value = next; - return true; - }, - }; -} -const request = { url: "https://memory.invalid/fixture" }; -const body = '{"status":"ok","memoryFixture":{"values":[1,2,3]}}'; -const validate = value => value.status === "ok"; - -test("six parsed JSON consumers validate once each, own independent graphs and persist only raw JSON", async () => { - const store = storage(), - cache = new ProviderRequestCache({ store }); - const parse = JSON.parse; - let parses = 0, - requests = 0; - JSON.parse = (value, ...args) => { - if (value === body) parses++; - return parse(value, ...args); - }; - try { - const results = await Promise.all( - Array.from({ length: 6 }, () => - cache.fetch(request, { - includeJSON: true, - validate, - fetcher: async () => { - requests++; - return { status: 200, headers: {}, body }; - }, - }), - ), - ); - const decoded = results.map(readProviderJSON); - assert.equal(requests, 1); - assert.equal(parses, 6, "Persistence and conversion do not parse validated JSON again"); - decoded[0].memoryFixture.values[0] = 99; - assert.equal(decoded[1].memoryFixture.values[0], 1); - assert.ok(results.every(value => value.body === body)); - assert.ok(Object.keys(results[0]).every(key => typeof key === "string")); - const persisted = parse(store.read()); - assert.equal(persisted.entries[0].body, body); - assert.equal(Object.getOwnPropertySymbols(persisted.entries[0]).length, 0); - const next = new ProviderRequestCache({ store }); - const hit = await next.fetch(request, { - includeJSON: true, - validate, - fetcher: () => { - throw new Error("unexpected request"); - }, - }); - assert.equal(readProviderJSON(hit).memoryFixture.values[0], 1); - assert.equal(parses, 7); - } finally { - JSON.parse = parse; - } -}); - -test("shared transport validation mutations cannot contaminate another JSON consumer", async () => { - const cache = new ProviderRequestCache({ ttlSeconds: 0, store: storage() }); - const fetcher = async () => ({ status: 200, headers: {}, body }); - const [mutated, original] = await Promise.all([ - cache.fetch(request, { - includeJSON: true, - validate: value => { - value.memoryFixture.values[0] = 99; - return true; - }, - fetcher, - }), - cache.fetch(request, { includeJSON: true, validate, fetcher }), - ]); - assert.equal(readProviderJSON(mutated).memoryFixture.values[0], 99); - assert.equal(readProviderJSON(original).memoryFixture.values[0], 1); -}); - -test("parsed response results still expire and cannot bypass a different product validator", async () => { - let now = 100000, - requests = 0; - const cache = new ProviderRequestCache({ store: storage(), now: () => now }); - const fetcher = async () => { - requests++; - return { status: 200, headers: {}, body }; - }; - const first = await cache.fetch(request, { includeJSON: true, validate, fetcher }); - readProviderJSON(first).memoryFixture.values.push(99); - const hit = await cache.fetch(request, { includeJSON: true, validate, fetcher }); - assert.deepEqual(readProviderJSON(hit).memoryFixture.values, [1, 2, 3]); - now += 60000; - await cache.fetch(request, { includeJSON: true, validate, fetcher }); - assert.equal(requests, 2); - await assert.rejects(cache.fetch(request, { includeJSON: true, validate: () => false, fetcher }), error => error.code === "invalid"); -}); - -test("the JSON helper keeps raw response compatibility and accepts a validated null", async () => { - assert.deepEqual(readProviderJSON({ body }), JSON.parse(body)); - assert.deepEqual(readProviderJSON(undefined), {}); - const cache = new ProviderRequestCache({ store: storage() }); - const result = await cache.fetch(request, { includeJSON: true, validate: value => value === null, fetcher: async () => ({ status: 200, headers: {}, body: "null" }) }); - assert.equal(readProviderJSON(result), null); - assert.equal(result.body, "null"); -}); diff --git a/tests/weatherPowerPaths.test.mjs b/tests/weatherPowerPaths.test.mjs index 60a5868d..05e94e8a 100644 --- a/tests/weatherPowerPaths.test.mjs +++ b/tests/weatherPowerPaths.test.mjs @@ -1,54 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import ProviderRequestCache from "../src/class/ProviderRequestCache.mjs"; +import ProviderTransport from "../src/class/ProviderTransport.mjs"; import createProviderEnvironment from "../src/function/createProviderEnvironment.mjs"; -import resolveWeatherRegion, { countryFromCoordinates } from "../src/function/resolveWeatherRegion.mjs"; - -test("explicit and locale-only regions do not materialize the offline polygon table", () => { - const parse = JSON.parse; - let geometryParses = 0; - JSON.parse = (text, ...rest) => { - if (typeof text === "string" && text.length > 100000 && text.startsWith('{"CN"')) geometryParses++; - return parse(text, ...rest); - }; - try { - assert.equal(resolveWeatherRegion({ latitude: 32.39, longitude: 119.499, explicitCountry: "CN" }), "CN"); - assert.equal(resolveWeatherRegion({ latitude: 32.39, longitude: 119.499, localeCountry: "US", fallback: "Locale" }), "US"); - assert.equal(geometryParses, 0); - assert.equal(countryFromCoordinates(32.39, 119.499), "CN"); - assert.equal(countryFromCoordinates(32.4, 119.5), "CN"); - assert.equal(geometryParses, 1); - } finally { - JSON.parse = parse; - } -}); - -test("stable coordinate hints write at most hourly but country changes and expiry refresh immediately", () => { - let now = 100000, - stored, - writes = 0; - const store = { - read: () => stored, - write: value => { - stored = value; - writes++; - return true; - }, - }; - const resolve = country => resolveWeatherRegion({ latitude: 32.39, longitude: 119.499, explicitCountry: country, now: () => now, store }); - for (let i = 0; i < 6; i++, now += 10000) assert.equal(resolve("CN"), "CN"); - assert.equal(writes, 1); - assert.equal(resolve("HK"), "HK"); - assert.equal(writes, 2); - now += 3600000; - assert.equal(resolve("HK"), "HK"); - assert.equal(writes, 3); - now += 7 * 86400000; - assert.equal(resolve("HK"), "HK"); - assert.equal(writes, 4); - assert.doesNotMatch(stored, /32\.39|119\.499/); -}); - test("provider constructors and credentials are accessed only on first use of that provider", () => { let apiReads = 0; const settings = { @@ -57,7 +10,7 @@ test("provider constructors and credentials are accessed only on first use of th return {}; }, }; - const runtime = new ProviderRequestCache({ store: undefined }); + const runtime = new ProviderTransport(); const env = createProviderEnvironment({ country: "CN", language: "zh-CN", latitude: 32.39, longitude: 119.499 }, settings, runtime); assert.equal(apiReads, 0); const first = env.qWeather; @@ -65,5 +18,5 @@ test("provider constructors and credentials are accessed only on first use of th assert.strictEqual(env.qWeather, first); assert.equal(apiReads, 2); assert.equal(env.country, "CN"); - assert.strictEqual(env.requestCache, runtime); + assert.strictEqual(env.transport, runtime); });