C
Cloudflare Weather DingTalk Bot
作者:董保全v4
Deploy a Cloudflare Workers weather bot that pushes daily weather + cycling safety reminders to DingTalk groups, with an optional Amap (Gaode) secondary-source cross-check that suppresses single-run false alarms (e.g. hail). Use when the user wants to create a weather push bot, DingTalk weather robot, Cloudflare Workers scheduled weather notifications, daily cycling safety alerts for delivery riders, or to add/verify/tune the Amap cross-check (二源复核) on the existing bot.
下载量
414
点赞
99
价格
免费
技能文档
---
name: cloudflare-weather-dingtalk-bot
title: Cloudflare Weather DingTalk Bot
description: Deploy a Cloudflare Workers weather bot that pushes daily weather + cycling safety reminders to DingTalk groups, with an optional Amap (Gaode) secondary-source cross-check that suppresses single-run false alarms (e.g. hail). Use when the user wants to create a weather push bot, DingTalk weather robot, Cloudflare Workers scheduled weather notifications, daily cycling safety alerts for delivery riders, or to add/verify/tune the Amap cross-check (二源复核) on the existing bot.
version: 1.4.1
category: 工具
---
# Cloudflare Weather DingTalk Bot
A serverless weather bot running on Cloudflare Workers with Cron triggers. Pushes daily weather + cycling safety advice to DingTalk groups via Webhook. Designed for field teams (e.g. BD) who commute by electric scooter daily.
## Prerequisites
- Node.js installed (on THIS machine node/python are NOT on PATH — use the full path `/c/Users/TBSG/AppData/Local/openclaw-bundle/node/node.exe`, v22; see Environment pitfalls)
- Wrangler CLI installed (`npm install -g wrangler`)
- Cloudflare account (free tier sufficient)
- DingTalk group robot with Webhook URL and signing secret
- (Optional) An Amap Open Platform **Web service key** (高德开放平台 Web服务 key) — only needed to enable the secondary-source cross-check (二源复核). Stored as a Wrangler secret `AMAP_KEY`, never in plaintext `[vars]`. Without it the bot runs Open-Meteo-only and fail-open.
## Project Structure
```
weather-bot/
├── src/index.js # Worker entry point (Open-Meteo + optional Amap cross-check)
├── wrangler.toml # Config + Cron + env vars
├── package.json # Project metadata
├── local-trigger.mjs # Local resend (imports built worker, calls fetch in Node v22)
├── render-test.mjs # Format stub: intercepts oapi.dingtalk.com, renders without sending
└── test-xcheck.mjs # Cross-check stub test: 6 scenarios / 17 assertions for crossCheckCode
```
## Setup Steps
### 1. Login to Cloudflare
> **All `wrangler` commands need a PATH prefix on this machine** — `npx`/`node` are NOT on PATH (see Environment pitfalls). Prepend the bundled node dir before any wrangler call (`login`, `deploy`, `secret put`, `deployments list`, `tail`):
>
> ```bash
> export PATH="/c/Users/TBSG/AppData/Local/openclaw-bundle/node:$PATH"
> ```
```bash
export PATH="/c/Users/TBSG/AppData/Local/openclaw-bundle/node:$PATH" && npx -y wrangler login
```
The browser will open for OAuth authorization. Click **Allow**.
If `wrangler deploy` later fails with "register a workers.dev subdomain", open the Cloudflare Dashboard Workers & Pages page once to auto-create the subdomain.
### 2. Create Project Files
**wrangler.toml:**
```toml
name = "weather-bot"
main = "src/index.js"
compatibility_date = "2026-05-19"
[triggers]
crons = ["0 23 * * *"] # UTC 23:00 = Beijing 07:00
[vars]
DINGTALK_WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN"
DINGTALK_SECRET = "SECyour_secret_here"
TRIGGER_TOKEN = "a-strong-random-token" # guards the /trigger manual resend endpoint
```
**package.json:**
```json
{
"name": "weather-bot",
"version": "1.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy"
}
}
```
### 3. Worker Code (src/index.js)
Use the template from the current project. Core features:
- **Weather source**: Open-Meteo API (free, no API key)
- **Cities**: configurable `CITIES` array with name + lat/lng
- **Weather map**: WMO code to Chinese label + emoji
- **Data validation**: shower/thunderstorm codes with precipitation < 2mm are downgraded to "cloudy" to avoid Open-Meteo false alarms
- **Cycling advice**: safety reminders by weather type, not ride/don't-ride binary
- Only "don't ride" when ALL cities have real thunderstorms (code 95-99 + precip >= 2mm)
- All other weather shows "cycling safety reminder" with specific tips
- **Three-part reminder format**: [Must-do] -> [Weather] -> [Tips by type]
- **Clothing advice**: based on max temperature
- **UV advice**: based on daily max UV index
- **DingTalk signing**: HMAC-SHA256 with timestamp
### 4. Deploy
```bash
# node/npx are NOT on PATH — prefix it, cd into the project, then deploy
export PATH="/c/Users/TBSG/AppData/Local/openclaw-bundle/node:$PATH" \
&& cd "<...>/outputs/weather-bot" \
&& npx -y wrangler deploy
```
Deploying without an `AMAP_KEY` secret is safe: the cross-check stays dormant (fail-open), so production behavior is identical to the pre-cross-check version and the morning push is unaffected.
Verify in Cloudflare Dashboard -> Workers & Pages that the Cron schedule is active. On this machine the `*.workers.dev` URL is unreachable (network block), so verify the version programmatically instead — see the `deployments list` caveat under "Local resend without wrangler dev".
## Reliability (Retry + Manual Resend + Monitoring)
After a missed 07:00 push (2026-08-14, transient Worker execution failure), three safety layers were added. Keep all three when modifying the bot:
### 1. Built-in retry (`runWithRetry`)
`scheduled()` calls `runWithRetry(env)` via `ctx.waitUntil`. It runs `handleRequest` once; if ALL groups fail (errors present and zero successes), it waits 3 minutes and retries once:
```javascript
async function runWithRetry(env) {
let results = await handleRequest(env);
const failed = results.errors.length > 0 && results.success.length === 0;
if (failed) {
await new Promise((r) => setTimeout(r, 18e4));
results = await handleRequest(env);
}
return results;
}
```
### 2. Token-guarded manual resend endpoint (`/trigger`)
The `fetch` handler accepts `GET /trigger?token=<TRIGGER_TOKEN>` and runs `handleRequest` synchronously, returning JSON `{ triggered, success, errors }`. Wrong or missing token → 403. Any other path returns a static status JSON. Never expose the token in reports or logs.
### 3. Daily monitoring cron (QoderWork scheduled task)
A QoderWork cron task runs daily at 07:30 (Asia/Shanghai): search today's DingTalk messages for the push (e.g. `dws chat message search --query "早安天气"`); if today's message is missing, resend via `node local-trigger.mjs` from the weather-bot folder, re-verify arrival, and append a status line to `check-log.md`. The task must not modify code/config or redeploy.
### Local resend without wrangler dev (workerd blocked)
On this Windows machine `workerd.exe` (used by `wrangler dev`) is blocked by system policy (`spawn UNKNOWN`, errno -4094; Unblock-File does not fix). Workaround: `local-trigger.mjs` parses `[vars]` from `wrangler.toml`, imports the built `src/index.js` directly in Node v22 (which natively provides fetch/crypto.subtle/btoa/Request/Response), and calls `worker.fetch(new Request('http://localhost/trigger?token=...'), vars, { waitUntil(){} })`. Same code path as production. Verify with a wrong token first (expect 403) before the real resend.
Note: the deployed `*.workers.dev` URL is also unreachable from this machine (network block) — verify deployments with `npx wrangler deployments list` (PATH-prefixed like all wrangler calls) and confirm push arrival via DingTalk message history, not HTTP calls. **`deployments list` ordering gotcha:** the newest deployment is NOT reliably at the top — `head` can show a stale entry (e.g. an old version at 100% from months ago) and mislead you. Read the **tail** (`... | tail -12`) to find today's version, and confirm it shows the expected Version ID at **100% traffic**.
## Amap Secondary-Source Cross-Check (高德二源复核)
Open-Meteo's early-morning single run occasionally emits an implausible code — e.g. on 2026-09-16 the 07:00 run returned code 96 (雷暴伴冰雹) + 6.4mm for 广州, which reached the group verbatim because `downgradeCode` only fires at precip < 2mm. Hail in 广州 in September is climatologically near-impossible; later Open-Meteo runs retracted it to drizzle and 高德 only ever reported 雷阵雨. The cross-check adds a **second data source (Amap) inside the Worker** to veto/adjust the storm codes *before* pushing.
### Key architecture decision — do NOT reuse the MCP
The `cloudmap-gateway` MCP (`amap_aggregator_local_weather_query`) goes through QoderWork's OAuth channel. The Worker runs on Cloudflare's public network and **cannot reuse that OAuth token**. The correct approach is to have the Worker call the Amap Open Platform public REST endpoint directly:
```
https://restapi.amap.com/v3/weather/weatherInfo?key=<AMAP_KEY>&city=<adcode>&extensions=all
```
This requires an Amap Open Platform **Web服务 key**. Parse the response at `forecasts[0].casts[0]` and read `dayweather` + `nightweather` (Chinese text like 雷阵雨 / 中雨 / 晴).
### CITIES must carry adcode
Query Amap by **adcode, not city name** (the Chinese name for 佛山 is often not recognized):
```javascript
var CITIES = [
{ name: "广州", adcode: "440100", latitude: 23.1291, longitude: 113.2644 },
{ name: "佛山", adcode: "440600", latitude: 23.0218, longitude: 113.1219 }
];
```
### AMAP_KEY gating + fail-open
- The cross-check runs **only when `env.AMAP_KEY` exists**. Set it as a Wrangler **secret**, not plaintext `[vars]`:
```bash
export PATH="/c/Users/TBSG/AppData/Local/openclaw-bundle/node:$PATH" \
&& cd "<...>/outputs/weather-bot" \
&& npx -y wrangler secret put AMAP_KEY
```
Pipe the key via stdin (never echo it into a report or leave a temp file behind). **Secrets take effect at runtime — no redeploy is required.** So the safe order is: deploy the cross-check code first (it stays dormant without the key), then `secret put AMAP_KEY` whenever the key is available; the cross-check auto-enables on the next scheduled run.
- If the key is missing OR the Amap call throws, the bot **fail-opens**: it keeps the Open-Meteo code unchanged and records a `warnings` entry — the push is never blocked by a cross-check failure.
### crossCheckCode rules (降雹 + 条件升雷)
Design principle chosen by the user: **hail = intersection (conservative veto), thunder = union (don't under-report)**. `RAIN_CODES = [51, 53, 55, 61, 63, 65, 80, 81, 82]`.
```javascript
function crossCheckCode(code, precipitation, amap) {
if (!amap) return code; // fail-open: no Amap data → unchanged
const text = `${amap.day}${amap.night}`;
const hasHail = text.includes("雹");
const hasThunder = text.includes("雷");
if ((code === 96 || code === 99) && !hasHail) {
code = hasThunder ? 95 : 61; // hail not corroborated → thunderstorm or light rain
} else if (code === 95 && !hasThunder) {
code = 61; // thunder not corroborated → light rain
} else if (hasThunder && !hasHail && precipitation > 1 && RAIN_CODES.includes(code)) {
code = 95; // Amap sees thunder + OM has real rain → upgrade to thunderstorm
}
return code;
}
```
- **降雹 (hail intersection):** code 96/99 with no "雹" in Amap → downgrade to 95 (if Amap has "雷") or 61. This is what kills the false "雷暴伴冰雹".
- **降雷:** code 95 with no "雷" in Amap → downgrade to 61.
- **条件升雷 (thunder union):** Amap has "雷" but no "雹", AND Open-Meteo daily precip > 1mm, AND the OM code is a rain type in `RAIN_CODES` → upgrade to 95. Prevents under-reporting when Open-Meteo's latest run drops the thunder that Amap still sees.
### Audit trail (xcheck + warnings)
`handleRequest`'s result object carries `warnings: []` and `xcheck: []`. Each city appends an `xcheck` row `{ city, omCode (original), amapDay, amapNight, finalCode }`. Both are surfaced in the `/trigger` JSON summary and logged via `console.log` at the end of `runWithRetry`, so every scheduled push's cross-check decisions are auditable in Cloudflare logs. Never log the AMAP_KEY itself.
## Local Stub Test (test-xcheck.mjs)
Before deploying any cross-check change, run the local stub harness — it validates `crossCheckCode` behavior **without sending a real DingTalk message**.
- **What it stubs:** Open-Meteo (feeds this morning's 96/95 codes), Amap (雷阵雨 / 中雨 / call-failure variants), and the DingTalk send (intercepts `oapi.dingtalk.com`, renders only).
- **Coverage:** 6 scenarios / 17 assertions — hail downgrade (96→95 and 96→61), thunder downgrade (95→61), conditional thunder upgrade (rain code→95), no-key fail-open, Amap-failure fail-open. All 17 passed on the reference implementation.
- **Run it (note the full node path — node is NOT on PATH on this machine):**
```bash
cd "<...>/outputs/weather-bot"
/c/Users/TBSG/AppData/Local/openclaw-bundle/node/node.exe test-xcheck.mjs
```
- **Deploy discipline:** the user may explicitly halt deployment ("先不部署,先本地验证"). Only deploy after the stub test passes AND the user gives the go-ahead, using the PATH-prefixed form (`export PATH="/c/Users/TBSG/AppData/Local/openclaw-bundle/node:$PATH" && cd "<...>/outputs/weather-bot" && npx -y wrangler deploy`). Confirm with `npx -y wrangler deployments list | tail -12` (newest entry is at the tail, not the head — see the ordering gotcha above). Do not fire a real `/trigger` during verification (avoids spamming the group).
## Core Logic
### Weather Code Downgrade Rule
Open-Meteo sometimes reports thunderstorm/shower codes (80-82, 95-99) with near-zero precipitation. Downgrade to cloudy (code 2) when precipitation < 2mm to avoid false alarms:
```javascript
if ((code >= 80 && code <= 82) || (code >= 95 && code <= 99)) {
if (precip < 2) {
code = 2;
}
}
```
`isRealStorm` check must stay consistent (`precipitation >= 2`):
```javascript
const isRealStorm = [95, 96, 99].includes(f.code) && f.precipitation >= 2;
```
**Two-stage code pipeline:** `downgradeCode` runs first (precip < 2mm guard against near-zero false storms), then — only when `env.AMAP_KEY` is present — `crossCheckCode` applies the Amap二源复核 (降雹 + 条件升雷). The `isRealStorm` / "不建议骑行" check reads the *final* post-cross-check code, so a hail code vetoed by Amap no longer forces "don't ride". See the "Amap Secondary-Source Cross-Check" section for the full rules.
### Cycling Advice Logic
```
All cities real storm -> "Don't ride" (public transport)
Any other weather -> "Cycling safety reminder" + specific tips
```
Tips are selected by weather type:
- Rain: brake distance, slow down, stay away from trucks
- Snow/ice: low speed, extra distance, no hard braking
- Fog: headlights on, slow down, keep distance
- Hot (>33C): hydrate, avoid noon rides, battery heat
- Cold (<10C): stay warm, battery range drop, check tire pressure
- Windy (>=39km/h): grip handlebars, avoid falling objects
- Storm: seek shelter immediately
- Good weather: daily safety habits (blind spots, distance, no speeding)
Always includes: **wear 3C-certified helmet, fasten strap**.
### Message Format
```
☀️ 早安天气 | 5月26日 星期二
【广州】 ⛅ 多云
温度:27C ~ 35C | 降水:1mm | 最大风力:15km/h
穿衣建议:短袖短裤,注意防晒
防晒:紫外线极强,尽量避免外出,出门务必涂防晒+戴帽子/太阳镜
【佛山】 ⛅ 多云
温度:27C ~ 35C | 降水:0.8mm | 最大风力:17km/h
穿衣建议:短袖短裤,注意防晒
防晒:紫外线极强,尽量避免外出,出门务必涂防晒+戴帽子/太阳镜
---
🔥 骑行安全提醒
【必做】戴好3C认证头盔,系紧束带
【天气】今天广州高温(35C),佛山高温(35C)
【防暑技巧】注意防暑降温多喝水,避免正午长时间骑行,注意电动车电池散热
💡 今日安全锦囊:大风天骑行身体压低减少受风面积
<font color=#FF0000>**✨ 今日金句:你越努力越幸运,越学习越强大**</font>
```
The quote line is intentionally red bold: DingTalk markdown supports `<font color=#RRGGBB>` and `**bold**`, and the combination renders as red bold text.
## Critical Pitfall: Newline in DingTalk Markdown
**NEVER** use `parts.join('\\n')`. JavaScript parses `\\n` as a literal backslash + n, which displays as `\n` text in DingTalk.
**CORRECT**: `parts.join('\n')` — produces an actual newline character that DingTalk renders properly.
## Push Accuracy Verification (推送准确性核验)
When the user asks "今天的推送准不准 / 天气报得对不对" (often with a screenshot of the morning push), run this reproducible cross-check pipeline. Do NOT redeploy or trigger a real push while verifying. The bot faithfully transcribes its data source, so a "wrong" push is usually a data-source issue, not a code bug — this pipeline is how you tell them apart.
### 4-step cross-check pipeline
**Step 1 — 高德 (Amap) real-time + forecast.** Use MCP `mcp__cloudmap-gateway__amap_aggregator_local_weather_query` with `query_type: "all"` (live + forecast). Query by **adcode, not city name** — the Chinese city name for 佛山 is often not recognized:
| 城市 | adcode |
|------|--------|
| 广州 | `440100` |
| 佛山 | `440600` |
**Step 2 — Open-Meteo current run.** `curl` the latest run for both cities so you compare against the *current* model output, not the 07:00 one the bot used. Coordinates: 广州 `23.1291,113.2644`, 佛山 `23.0218,113.1219`. Request `daily` + `hourly` + UV + precip probability:
```bash
cd "<workspace dir>"
curl -s "https://api.open-meteo.com/v1/forecast?latitude=23.1291&longitude=113.2644&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,uv_index_max,precipitation_probability_max&hourly=weather_code,temperature_2m,precipitation_probability&timezone=Asia%2FShanghai&forecast_days=1" -o gz_wx.json
curl -s "https://api.open-meteo.com/v1/forecast?latitude=23.0218&longitude=113.1219&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,uv_index_max,precipitation_probability_max&hourly=weather_code,temperature_2m,precipitation_probability&timezone=Asia%2FShanghai&forecast_days=1" -o fs_wx.json
```
Key check: does the current run's `weather_code` still contain a thunderstorm/hail code (95/96/99)? If the hourly series has NO storm code but the bot pushed "雷暴/冰雹", the morning run was a single-run false alarm.
**Step 3 — Compare against worker source thresholds.** Read `src/index.js` in the weather-bot folder and confirm the bot's decision rules, so you can attribute a discrepancy correctly:
- `downgradeCode`: a storm/shower code (80-82, 95-99) is downgraded to cloudy ONLY when `precipitation < 2mm`. So any code with precip >= 2mm passes through verbatim (this is how "雷暴伴冰雹" reached the group).
- "不建议骑行" (don't ride) fires only when **ALL** cities are real storms: `[95,96,99].includes(code) && precipitation >= 2` for every city.
- UV advice tiers come from `uv_index_max` (e.g. >=8 → "很强 SPF30+", >=6 → "较强 SPF15+").
**Step 4 — Attribute the deviation and output a comparison table.** Classify each mismatched field into one of three causes:
| 归因类别 | 含义 | 典型信号 |
|----------|------|----------|
| 转写错误 | Bot logic/transcription bug | Pushed value doesn't match ANY source; threshold applied wrongly |
| 模型单跑误报 | The 07:00 run gave a false positive; later runs retracted it | Current Open-Meteo run dropped the storm/hail code; Amap never reported it; climatologically implausible (e.g. hail in 广州 in September) |
| 模型更新 | Normal drift as the model refines over hours | Small numeric changes (wind, temp, UV one tier) with the same weather direction |
Output a `推送项 | 当前核验 | 判定` table (判定 = 准确 / 基本准确 / 高估 / 误报), then state the root cause in one sentence and the practical impact for the day (e.g. "冰雹字眼不必当真,但午后降水概率仍 86%,保守的雨天减速提醒依然有效").
### Environment pitfalls (verified on this Windows/bash machine)
- **No `python3`/`python` in the Bash tool.** Don't pipe curl into python. Instead `curl -o file.json` then parse with the **Read** tool.
- **No `node` on PATH either.** `node script.mjs` fails in the Bash tool. Use the full path `/c/Users/TBSG/AppData/Local/openclaw-bundle/node/node.exe` (v22) to run `local-trigger.mjs`, `render-test.mjs`, and `test-xcheck.mjs`. If that path is missing, `find /c/Users/TBSG/AppData/Local -maxdepth 3 -name node.exe`.
- **`/tmp` is a different filesystem than the Read tool.** Files written to `/tmp/...` in Bash cannot be Read. Always `curl -o` into the **workspace directory** (e.g. `cd "C:\Users\TBSG\.qoderwork\workspace\<id>"` first), then Read the relative/absolute Windows path.
- **`%%2F` is wrong in bash.** `timezone=Asia%%2FShanghai` produces a literal `%%2F` and a ~42-byte error response. In bash use a single percent: `timezone=Asia%2FShanghai`. (The `%%` escaping only applies to cmd/printf.)
- **佛山 by city name fails** on the Amap MCP — always use adcode `440600`.
- The weather-bot project may live in a *different* workspace dir than the current session (e.g. `...\workspace\ms3ghwc7lgp715xr\outputs\weather-bot`). If not found, glob `**/weather-bot/src/index.js` under `.qoderwork\workspace`.
## Customization
### Change Cities
Edit the `CITIES` array in `src/index.js` (keep the `adcode` field — it's required by the Amap cross-check; look up city adcodes at https://lbs.amap.com):
```javascript
const CITIES = [
{ name: '广州', adcode: '440100', latitude: 23.1291, longitude: 113.2644 },
{ name: '佛山', adcode: '440600', latitude: 23.0218, longitude: 113.1219 },
];
```
### Change Push Time
`wrangler.toml` Cron uses UTC. Beijing time = UTC + 8.
| Beijing | UTC Cron |
|---------|----------|
| 07:00 | `0 23 * * *` |
| 08:00 | `0 0 * * *` |
| 09:00 | `0 1 * * *` |
| 10:00 | `0 2 * * *` |
### Update Webhook / Secret
Edit `wrangler.toml` `[vars]` section, then re-run the PATH-prefixed deploy (`export PATH="/c/Users/TBSG/AppData/Local/openclaw-bundle/node:$PATH" && cd "<...>/outputs/weather-bot" && npx -y wrangler deploy`). Note: `AMAP_KEY` is a **secret**, not a `[vars]` entry — set it via `wrangler secret put AMAP_KEY` (runtime effect, no redeploy), never in plaintext `[vars]`.
## Troubleshooting
- **DingTalk errcode 310000**: Check that the signing secret matches the robot's security setting ("加签" mode).
- **Worker not triggering**: Verify Cron schedule in Cloudflare Dashboard; check Workers logs with `npx wrangler tail`.
- **Missed daily push**: Resend with `GET /trigger?token=<TRIGGER_TOKEN>` on the Worker URL, or locally via `node local-trigger.mjs` (the workers.dev URL is blocked on this machine, so local trigger is the reliable path). Then verify arrival in DingTalk and log to `check-log.md`.
- **`wrangler dev` fails with `spawn UNKNOWN` / errno -4094**: `workerd.exe` is blocked by Windows policy and Unblock-File doesn't help. Don't waste time on it — run the worker code directly in Node v22 via `local-trigger.mjs`.
- **Unknown weather emoji**: Add missing WMO codes to `WEATHER_MAP` in `src/index.js`.
- **Weather mismatch with phone app**: Open-Meteo is ECMWF-driven and may have local deviations. The built-in downgrade rule (precip < 2mm) filters most false thunderstorm reports. For serious discrepancies, cross-check with China Weather Network (weather.com.cn).
- **A push looks wrong (e.g. implausible "雷暴伴冰雹", over-stated precipitation/UV)**: Don't assume a code bug — run the "Push Accuracy Verification" pipeline (高德 adcode + Open-Meteo current run + worker source thresholds) to classify it as 转写错误 / 模型单跑误报 / 模型更新 before changing anything. If it's a recurring single-run false alarm, the fix is the Amap二源复核 (crossCheckCode), not the manual pipeline.
- **Cross-check seems inactive (still pushing "冰雹")**: The `AMAP_KEY` secret is probably not set — the cross-check is gated on `env.AMAP_KEY` and fail-opens without it. Set it with the PATH-prefixed `npx -y wrangler secret put AMAP_KEY` (secrets apply at runtime — **no redeploy needed**; it activates on the next scheduled run). Confirm via the `xcheck` array in `/trigger` output or the Cloudflare log line from `runWithRetry`. Remember the key must be an Amap Open Platform **Web服务** key (the `cloudmap-gateway` MCP OAuth token cannot be reused by the Worker).
- **Amap call failing / warnings in logs**: By design this is fail-open — the push still goes out with the raw Open-Meteo code and a `warnings` entry is recorded. Check the key validity/quota and that `city` is the adcode (广州 `440100`, 佛山 `440600`), not the city name. Never let an Amap outage block the morning push.
- **Newlines show as `\n` text**: You used `join('\\n')` instead of `join('\n')`. Fix and redeploy.支持平台:Qoder · QoderWork · Claude · Codex 等 AI 编程助手