一次个人项目中对公共数据源"礼貌使用"的探索
关键词:Go、爬虫、本地缓存、行政区归档、双语文案、农业气象
写在最前面:数据伦理
做个人项目,第一条规矩是——不要打扰别人,必须尊重为主,不要为了自己的利益毁灭别人!!!
具体到这个项目:
- 国家气象局(nmc.cn)的接口是给页面访客用的,不是给你做数据管道用的
- 你每分钟请求一次,等于白嫖别人的服务器
- 公共接口随时可能加频率限制、加 WAF、封 IP,你的脚本会突然挂
- 同一个城市同一天的数据本来就不会变,反复请求毫无意义
我们的原则:
每一次请求,都要对得起别人的服务器。
本文方案的三级策略:
用户请求
↓
[1] 本地 JSON 缓存(按行政区 + 日期归档)
├── 命中 → 直接返回,不发任何请求
└── 未命中
↓
[2] 官方接口请求(每天最多 1 次/城市)
├── 成功 → 写缓存 → 返回
└── 失败
↓
[3] 本地兜底算法(不发任何请求)
落地做法:
- 缓存文件名带日期(
20260923),同一天内所有请求都命中缓存 - 一个城市一天最多请求一次,第二天才会请求新数据
- 缓存文件天然是历史归档,可做趋势分析
- 断网也能读,只要缓存文件在
本文不讨论任何 AI 生成文案方案,只做数据抓取、结构化、缓存、双语文案模板化。
一、背景与目标
1.1 场景
新疆采棉季,采棉工需要精准的天气预报和看得懂的文字描述。
- 精准:温度、湿度、风、日出日落、明天预报
- 看得懂:不能是"27℃ / 12℃"这种冷冰冰的数字,要是"白天舒适,夜间寒冷,早晚穿厚,中午穿少"
- 双语:维吾尔族采棉工需要维语版本
1.2 数据源
国家气象局官网(https://www.nmc.cn)
- 有权威数据
- 页面是 JS 渲染,直接解析 HTML 拿不全
- 背后有一个 REST 接口,但参数需要挖
我们的目标:
- 从官方接口拿到结构化数据
- 存成本地 JSON,按行政区归档
- 通过模板化文案生成中维双语描述
- 不依赖任何 AI,不花钱
二、找到真实接口
2.1 踩坑记录
错误的接口:
https://www.nmc.cn/rest/weather?stationid=YECheng ❌ 返回空
https://www.nmc.cn/rest/weather?stationid=51814 ❌ 返回空
data 是空字符串:
{"msg":"success","code":0,"data":""}
原因:stationid 不是拼音,也不是气象站号,而是内部随机码。
2.2 正确做法
从页面 HTML 里挖 scode:
<script>
var scode = 'baeXx';
// 或
<input type="hidden" name="stationId" value="baeXx">
</script>
baeXx 就是叶城的内部码。
正确接口:
https://www.nmc.cn/rest/weather?stationid=baeXx ✅
全省城市列表:
https://www.nmc.cn/rest/province/AXJ
返回:
[
{ "code": "baeXx", "province": "新疆维吾尔自治区", "city": "叶城", "url": "/publish/forecast/AXJ/yecheng.html" },
{ "code": "aHjQA", "province": "新疆维吾尔自治区", "city": "喀什", "url": "/publish/forecast/AXJ/kashi.html" },
...
]
2.3 请求头必须带
req.Header.Set("User-Agent", "Mozilla/5.0 ...")
req.Header.Set("Referer", "https://www.nmc.cn/publish/forecast/AXJ/"+code+".html")
否则会被拒。
三、接口返回的关键字段
{
"code": 0,
"data": {
"real": {
"station": { "city": "叶城" },
"weather": {
"temperature": 28.6,
"humidity": 18.0,
"info": "晴"
},
"wind": { "direct": "西北风", "power": "微风" },
"sunriseSunset": {
"sunrise": "2026-09-23 08:38",
"sunset": "2026-09-23 20:46"
}
},
"predict": {
"detail": [
{
"date": "2026-09-23",
"day": { "weather": { "info": "9999", "temperature": "9999" } },
"night": { "weather": { "info": "多云", "temperature": "13" } }
},
{
"date": "2026-09-24",
"day": { "weather": { "info": "多云", "temperature": "27" } },
"night": { "weather": { "info": "多云", "temperature": "12" } }
}
]
}
}
}
坑点:
| 字段 | 说明 |
|---|---|
9999 | 无数据哨兵值,不是温度 |
predict.detail[0] | 今天(傍晚后白天数据变 9999) |
predict.detail[1] | 明天 |
real.weather.temperature | 实况温度,可作为今天温度兜底 |
四、缓存设计
4.1 目录结构
data/weather/
└── 2026/
└── 09/
├── 喀什_叶城县_20260923.json
├── 喀什_莎车县_20260923.json
└── 阿克苏_库车市_20260923.json
命名规则:{地州}_{县市}_{YYYYMMDD}.json
4.2 流程
1. 用户输入"叶城"
2. 查城市表得到 {ShiZH: "喀什", XianZH: "叶城县", Code: "baeXx"}
3. 拼路径 data/weather/2026/09/喀什_叶城县_20260923.json
4. 文件存在 → 直接读返回(from_cache=true)
5. 文件不存在 → 抓官方接口 → 写文件 → 返回
4.3 效果
- 一个城市一天一次请求,第二天才会请求新数据
- 响应速度毫秒级(本地读文件)
- 断网可用(缓存文件在就能读)
- 历史可查(缓存文件天然是归档)
五、完整脚本
零依赖,只用 Go 标准库,无任何 AI 调用。
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ============================================================
// 1. 城市列表
// ============================================================
type CityInfo struct {
Code string // nmc 接口 code
Value string // 简称,用户输入用
ShiZH string // 地州中文名
ShiUG string // 地州维语名
XianZH string // 县级中文名
XianUG string // 县级维语名
}
var XinjiangCities = []CityInfo{
// 乌鲁木齐市
{"lIPam", "乌鲁木齐", "乌鲁木齐", "ئۈرۈمچى", "乌鲁木齐市", "ئۈرۈمچى"},
{"wmimk", "达坂城", "乌鲁木齐", "ئۈرۈمچى", "达坂城区", "داۋانچىڭ"},
{"gIolY", "米东", "乌鲁木齐", "ئۈرۈمچى", "米东区", "مىدۇڭ"},
// 克拉玛依市
{"RHxwy", "克拉玛依", "克拉玛依", "قاراماي", "克拉玛依市", "قاراماي"},
// 吐鲁番市
{"DGEdf", "吐鲁番", "吐鲁番", "تۇرپان", "吐鲁番市", "تۇرپان"},
{"zVdtO", "鄯善", "吐鲁番", "تۇرپان", "鄯善县", "پىچان"},
{"xKqfo", "托克逊", "吐鲁番", "تۇرپان", "托克逊县", "توقسۇن"},
// 哈密市
{"LFhJu", "哈密", "哈密", "قومۇل", "哈密市", "قومۇل"},
{"ELQMW", "巴里坤", "哈密", "قومۇل", "巴里坤哈萨克自治县", "باركۆل"},
{"fvHql", "伊吾", "哈密", "قومۇل", "伊吾县", "ئارا تۈرۈك"},
// 昌吉回族自治州
{"bIfiH", "昌吉", "昌吉", "سانجى", "昌吉市", "سانجى"},
{"FoOEx", "阜康", "昌吉", "سانجى", "阜康市", "فۇكاڭ"},
{"RYXfs", "呼图壁", "昌吉", "سانجى", "呼图壁县", "قۇتۇبى"},
{"YQXUS", "玛纳斯", "昌吉", "سانجى", "玛纳斯县", "ماناس"},
{"AWxpU", "奇台", "昌吉", "سانجى", "奇台县", "گۇچۇڭ"},
{"IaCfC", "吉木萨尔", "昌吉", "سانجى", "吉木萨尔县", "جىمىسار"},
{"HvGJL", "木垒", "昌吉", "سانجى", "木垒哈萨克自治县", "مورى"},
// 博尔塔拉蒙古自治州
{"hSwcs", "博乐", "博尔塔拉", "بورتالا", "博乐市", "بورتالا"},
{"LrfAz", "阿拉山口", "博尔塔拉", "بورتالا", "阿拉山口市", "ئاتاۋ"},
{"bpMAs", "精河", "博尔塔拉", "بورتالا", "精河县", "جىڭ"},
{"QGHxV", "温泉", "博尔塔拉", "بورتالا", "温泉县", "ئارشاڭ"},
// 巴音郭楞蒙古自治州
{"qQedw", "库尔勒", "巴音郭楞", "بايىنغولىن", "库尔勒市", "كورلا"},
{"QMLyD", "轮台", "巴音郭楞", "بايىنغولىن", "轮台县", "بۈگۈر"},
{"RpLvF", "尉犁", "巴音郭楞", "بايىنغولىن", "尉犁县", "لوپنۇر"},
{"ropjq", "若羌", "巴音郭楞", "بايىنغولىن", "若羌县", "چاقىلىق"},
{"vsSYw", "且末", "巴音郭楞", "بايىنغولىن", "且末县", "چەرچەن"},
{"QiKzF", "焉耆", "巴音郭楞", "بايىنغولىن", "焉耆回族自治县", "يەنجى"},
{"ZsUMo", "和静", "巴音郭楞", "بايىنغولىن", "和静县", "خېجىڭ"},
{"XpzXk", "和硕", "巴音郭楞", "بايىنغولىن", "和硕县", "خوشۇت"},
{"GPrnI", "博湖", "巴音郭楞", "بايىنغولىن", "博湖县", "باغراش"},
// 阿克苏地区
{"wPpQt", "阿克苏", "阿克苏", "ئاقسۇ", "阿克苏市", "ئاقسۇ"},
{"BVaDD", "库车", "阿克苏", "ئاقسۇ", "库车市", "كۇچا"},
{"oVenj", "温宿", "阿克苏", "ئاقسۇ", "温宿县", "ئۇنسۇ"},
{"lHafw", "沙雅", "阿克苏", "ئاقسۇ", "沙雅县", "شايار"},
{"QGLHi", "新和", "阿克苏", "ئاقسۇ", "新和县", "شىنخا"},
{"jcFzJ", "拜城", "阿克苏", "ئاقسۇ", "拜城县", "باي"},
{"CUUUw", "乌什", "阿克苏", "ئاقسۇ", "乌什县", "ئۇچتۇرپان"},
{"EvmKC", "阿瓦提", "阿克苏", "ئاقسۇ", "阿瓦提县", "ئاۋات"},
{"SkZBu", "柯坪", "阿克苏", "ئاقسۇ", "柯坪县", "كەلپىن"},
// 克孜勒苏柯尔克孜自治州
{"SzKmL", "阿图什", "克孜勒苏", "قىزىلسۇ", "阿图什市", "ئاتۇش"},
{"LEeJY", "阿克陶", "克孜勒苏", "قىزىلسۇ", "阿克陶县", "ئاقتۇ"},
{"yPnAd", "阿合奇", "克孜勒苏", "قىزىلسۇ", "阿合奇县", "ئاقچى"},
{"fNxHG", "乌恰", "克孜勒苏", "قىزىلسۇ", "乌恰县", "ئۇلۇغچات"},
// 喀什地区
{"aHjQA", "喀什", "喀什", "قەشقەر", "喀什市", "قەشقەر"},
{"Wupea", "疏附", "喀什", "قەشقەر", "疏附县", "كونا شەھەر"},
{"Wupeb", "疏勒", "喀什", "قەشقەر", "疏勒县", "يېڭىشەھەر"},
{"zXMMB", "英吉沙", "喀什", "قەشقەر", "英吉沙县", "يېڭىسار"},
{"aFKNj", "泽普", "喀什", "قەشقەر", "泽普县", "پوسكام"},
{"fhaom", "莎车", "喀什", "قەشقەر", "莎车县", "يەكەن"},
{"baeXx", "叶城", "喀什", "قەشقەر", "叶城县", "قاغىلىق"},
{"BCzpJ", "麦盖提", "喀什", "قەشقەر", "麦盖提县", "مەكىت"},
{"zEUul", "岳普湖", "喀什", "قەشقەر", "岳普湖县", "يوپۇغا"},
{"lTSgf", "伽师", "喀什", "قەشقەر", "伽师县", "پەيزىۋات"},
{"maCEn", "巴楚", "喀什", "قەشقەر", "巴楚县", "مارالبېشى"},
{"jhHpN", "塔什库尔干", "喀什", "قەشقەر", "塔什库尔干塔吉克自治县", "تاشقورغان"},
// 和田地区
{"HDzeq", "和田", "和田", "خوتەن", "和田市", "خوتەن"},
{"nbFjx", "墨玉", "和田", "خوتەن", "墨玉县", "قاراقاش"},
{"WupeJ", "皮山", "和田", "خوتەن", "皮山县", "گۇما"},
{"HQoif", "洛浦", "和田", "خوتەن", "洛浦县", "لوپ"},
{"LqeGf", "策勒", "和田", "خوتەن", "策勒县", "چىرا"},
{"eKceY", "于田", "和田", "خوتەن", "于田县", "كېرىيە"},
{"zxIeh", "民丰", "和田", "خوتەن", "民丰县", "نىيە"},
// 伊犁哈萨克自治州
{"Zfpil", "伊宁", "伊犁", "ئىلى", "伊宁市", "غۇلجا"},
{"ZuhSh", "奎屯", "伊犁", "ئىلى", "奎屯市", "كۈيتۈن"},
{"QEpMD", "霍尔果斯", "伊犁", "ئىلى", "霍尔果斯市", "قورغاس"},
{"ZuktQ", "伊宁县", "伊犁", "ئىلى", "伊宁县", "غۇلجا"},
{"qqWkF", "察布查尔", "伊犁", "ئىلى", "察布查尔锡伯自治县", "چاپچال"},
{"cuwlP", "霍城", "伊犁", "ئىلى", "霍城县", "قورغاس"},
{"FsktL", "巩留", "伊犁", "ئىلى", "巩留县", "توققۇزتارا"},
{"hrqYY", "新源", "伊犁", "ئىلى", "新源县", "كۈنەس"},
{"SQKGX", "昭苏", "伊犁", "ئىلى", "昭苏县", "مۇڭغۇلكۈرە"},
{"piUzn", "特克斯", "伊犁", "ئىلى", "特克斯县", "تېكەس"},
{"eGeWh", "尼勒克", "伊犁", "ئىلى", "尼勒克县", "نىلقا"},
// 塔城地区
{"qHPXG", "塔城", "塔城", "تارباغاتاي", "塔城市", "چۆچەك"},
{"VDvKy", "乌苏", "塔城", "تارباغاتاي", "乌苏市", "شىخو"},
{"NcLcb", "沙湾", "塔城", "تارباغاتاي", "沙湾市", "ساۋەن"},
{"BFpxB", "额敏", "塔城", "تارباغاتاي", "额敏县", "دۆربىلجىن"},
{"eseSA", "托里", "塔城", "تارباغاتاي", "托里县", "تولى"},
{"EDyyx", "裕民", "塔城", "تارباغاتاي", "裕民县", "چاغانتوقاي"},
{"UXCKY", "和布克赛尔", "塔城", "تارباغاتاي", "和布克赛尔蒙古自治县", "قۇبۇقسار"},
// 阿勒泰地区
{"SnOYy", "阿勒泰", "阿勒泰", "ئالتاي", "阿勒泰市", "ئالتاي"},
{"OCkda", "布尔津", "阿勒泰", "ئالتاي", "布尔津县", "بۇرچىن"},
{"JUKYd", "富蕴", "阿勒泰", "ئالتاي", "富蕴县", "كۆكتوقاي"},
{"dzMlI", "福海", "阿勒泰", "ئالتاي", "福海县", "بۇرۇلتوقاي"},
{"vuVKP", "哈巴河", "阿勒泰", "ئالتاي", "哈巴河县", "قابا"},
{"KUgXP", "青河", "阿勒泰", "ئالتاي", "青河县", "چىڭگىل"},
{"Evjap", "吉木乃", "阿勒泰", "ئالتاي", "吉木乃县", "جېمىنەي"},
// 自治区直辖县级市
{"JGBUK", "石河子", "自治区直辖", "ئاپتونوم رايون", "石河子市", "شىخەنزە"},
{"uWWmy", "阿拉尔", "自治区直辖", "ئاپتونوم رايون", "阿拉尔市", "ئارال"},
{"FmuYb", "图木舒克", "自治区直辖", "ئاپتونوم رايون", "图木舒克市", "تۇمشۇق"},
{"hkZNG", "五家渠", "自治区直辖", "ئاپتونوم رايون", "五家渠市", "ۋۇجياچۈ"},
{"FDOdh", "北屯", "自治区直辖", "ئاپتونوم رايون", "北屯市", "بېيتۈن"},
{"GXsRg", "铁门关", "自治区直辖", "ئاپتونوم رايون", "铁门关市", "باشئەگىم"},
{"ACUGo", "可克达拉", "自治区直辖", "ئاپتونوم رايون", "可克达拉市", "كۆكدالا"},
{"XiaDH", "昆玉", "自治区直辖", "ئاپتونوم رايون", "昆玉市", "قۇرۇمقاش"},
}
// 按 Value(简称)匹配
func lookupCity(name string) (CityInfo, bool) {
for _, c := range XinjiangCities {
if c.Value == name {
return c, true
}
}
return CityInfo{}, false
}
// ============================================================
// 2. 输出结构
// ============================================================
type WeatherData struct {
City string `json:"city"`
CityUG string `json:"city_ug"`
ShiZH string `json:"shi_zh"`
ShiUG string `json:"shi_ug"`
Date string `json:"date"`
WeekdayZH string `json:"weekday_zh"`
WeekdayUG string `json:"weekday_ug"`
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
Weather string `json:"weather"`
TemNow float64 `json:"tem_now"`
Humidity float64 `json:"humidity"`
WindDir string `json:"wind_dir"`
WindPower string `json:"wind_power"`
TemHigh *float64 `json:"tem_high"`
TemLow *float64 `json:"tem_low"`
TmrW string `json:"tomorrow_weather"`
TmrHigh float64 `json:"tomorrow_high"`
TmrLow float64 `json:"tomorrow_low"`
ZH string `json:"zh"`
UG string `json:"ug"`
FetchedAt string `json:"fetched_at"`
FromCache bool `json:"from_cache"`
}
// ============================================================
// 3. 城市 + 星期 + 天气 维语映射
// ============================================================
var weekdayZH = [...]string{
"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六",
}
var weekdayUG = map[string]string{
"星期日": "يەكشەنبە",
"星期一": "دۈشەنبە",
"星期二": "سەيشەنبە",
"星期三": "چارشەنبە",
"星期四": "پەيشەنبە",
"星期五": "جۈمە",
"星期六": "شەنبە",
}
var weatherUG = map[string]string{
"晴": "ئوچۇق",
"多云": "بۇلۇتلۇق",
"阴": "تۇتۇق",
"阵雨": "يامغۇر",
"雷阵雨": "چاقماقلىق يامغۇر",
"雨": "يامغۇر",
"小雨": "ئاز يامغۇر",
"中雨": "ئوتتۇرا يامغۇر",
"大雨": "قويۇق يامغۇر",
"暴雨": "شىددەتلىك يامغۇر",
"雪": "قار",
"小雪": "ئاز قار",
"中雪": "ئوتتۇرا قار",
"大雪": "قويۇق قار",
"暴雪": "قويۇق قار",
"雾": "تۇمان",
"霾": "تۇمان",
"沙尘": "قۇم بوران",
"沙尘暴": "قۇم بوران",
"浮尘": "قۇم",
"扬沙": "قۇم بوران",
}
func weatherToUG(zh string) string {
zh = strings.TrimSpace(zh)
if zh == "" {
return ""
}
if v, ok := weatherUG[zh]; ok {
return v
}
for k, v := range weatherUG {
if strings.Contains(zh, k) {
return v
}
}
return zh
}
func weekdayToUG(zh string) string {
if v, ok := weekdayUG[zh]; ok {
return v
}
return zh
}
// ============================================================
// 4. nmc API 响应结构
// ============================================================
type nmcResp struct {
Code int `json:"code"`
Data struct {
Real struct {
Station struct {
City string `json:"city"`
} `json:"station"`
Weather struct {
Temperature float64 `json:"temperature"`
Humidity float64 `json:"humidity"`
Info string `json:"info"`
} `json:"weather"`
Wind struct {
Direct string `json:"direct"`
Power string `json:"power"`
} `json:"wind"`
SunriseSunset struct {
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
} `json:"sunriseSunset"`
} `json:"real"`
Predict struct {
Detail []struct {
Date string `json:"date"`
Day struct {
Weather struct {
Info string `json:"info"`
Temperature string `json:"temperature"`
} `json:"weather"`
} `json:"day"`
Night struct {
Weather struct {
Info string `json:"info"`
Temperature string `json:"temperature"`
} `json:"weather"`
} `json:"night"`
} `json:"detail"`
} `json:"predict"`
} `json:"data"`
}
// ============================================================
// 5. 工具函数
// ============================================================
func timeOnly(s string) string {
if i := strings.Index(s, " "); i >= 0 {
return s[i+1:]
}
return s
}
func clean(s string) string {
if s == "9999" {
return ""
}
return s
}
func parseFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" || s == "9999" {
return 0
}
var f float64
fmt.Sscanf(s, "%f", &f)
return f
}
func parseDate(s string) (int, int) {
parts := strings.Split(s, "-")
if len(parts) != 3 {
return 0, 0
}
m, _ := strconv.Atoi(parts[1])
d, _ := strconv.Atoi(parts[2])
return m, d
}
// ============================================================
// 6. 文案模板(纯模板,无 AI)
// ============================================================
// 白天/夜间冷暖
func tempFeelZH(t float64) string {
switch {
case t <= 5:
return "冷"
case t <= 15:
return "凉"
case t <= 26:
return "暖和"
default:
return "热"
}
}
func tempFeelUG(t float64) string {
switch {
case t <= 5:
return "سوغۇق"
case t <= 15:
return "سالقىن"
case t <= 26:
return "ئىللىق"
default:
return "ئىسسىق"
}
}
// 空气干湿
func humidityFeelZH(h float64) string {
switch {
case h < 30:
return "干燥"
case h <= 70:
return "适中"
default:
return "潮湿"
}
}
func humidityFeelUG(h float64) string {
switch {
case h < 30:
return "قۇرغاق"
case h <= 70:
return "مۇۋاپىق"
default:
return "نەم"
}
}
// 温差
func tempDiffZH(diff float64) string {
switch {
case diff >= 10:
return "昼夜温差大"
case diff >= 5:
return "昼夜温差有点大"
default:
return "昼夜温差不大"
}
}
func tempDiffUG(diff float64) string {
switch {
case diff >= 10:
return "كېچە-كۈندۈز تېمپېراتۇرا پەرقى چوڭ"
case diff >= 5:
return "كېچە-كۈندۈز تېمپېراتۇرا پەرقى سەل چوڭ"
default:
return "كېچە-كۈندۈز تېمپېراتۇرا پەرقى چوڭ ئەمەس"
}
}
// 穿衣建议
func clothingZH(dayFeel string, diff float64) string {
if diff >= 10 {
return "早晚穿厚,中午穿少"
}
switch dayFeel {
case "热":
return "干活别穿太厚"
case "暖和":
return "中午可以穿少点"
case "凉":
return "穿件外套"
case "冷":
return "出门穿厚点"
}
return "早上出门穿厚点"
}
func clothingUG(dayFeel string, diff float64) string {
if diff >= 10 {
return "ئەتىگەن-كەچتە قېلىن، چۈشتە نىپىز كىيىڭ"
}
switch dayFeel {
case "热":
return "ئىش قىلغاندا بەك قېلىن كىيمەڭ"
case "暖和":
return "چۈشتە ئازراق كىيىڭ"
case "凉":
return "چاپان كىيىڭ"
case "冷":
return "قېلىنراق كىيىڭ"
}
return "ئەتىگەن قېلىنراق كىيىڭ"
}
// ============================================================
// 7. 生成中维文案
// ============================================================
func generateZH(city CityInfo, w *WeatherData) string {
month, day := parseDate(w.Date)
dayFeel := tempFeelZH(w.TemNow)
humidity := humidityFeelZH(w.Humidity)
tmrFeel := tempFeelZH(w.TmrHigh)
diff := w.TmrHigh - w.TmrLow
diffDesc := tempDiffZH(diff)
advice := clothingZH(tmrFeel, diff)
return fmt.Sprintf(
"今天是%d月%d日 %s,%s早上%s日出,%s,白天%s,空气%s,%s日落,明天%s,白天%s,%s,%s。",
month, day, w.WeekdayZH,
city.Value, w.Sunrise,
w.Weather,
dayFeel,
humidity,
w.Sunset,
w.TmrW,
tmrFeel,
diffDesc,
advice,
)
}
func generateUG(city CityInfo, w *WeatherData) string {
month, day := parseDate(w.Date)
dayFeel := tempFeelUG(w.TemNow)
humidity := humidityFeelUG(w.Humidity)
tmrFeel := tempFeelUG(w.TmrHigh)
diff := w.TmrHigh - w.TmrLow
diffDesc := tempDiffUG(diff)
advice := clothingUG(tmrFeel, diff)
return fmt.Sprintf(
"بۈگۈن %d-ئاينىڭ %d-كۈنى %s، %s سەھەر سائەت %s دە كۈن چىقىدۇ. ھاۋا %s، ھاۋا نەملىكى %s، سائەت %s دە كۈن پاتىدۇ. ئەتە ھاۋا %s، كۈندۈزى %s، %s، %s.",
month, day, w.WeekdayUG,
city.XianUG, w.Sunrise,
weatherToUG(w.Weather),
humidity,
w.Sunset,
weatherToUG(w.TmrW),
tmrFeel,
diffDesc,
advice,
)
}
// ============================================================
// 8. 缓存
// ============================================================
const WeatherDataDir = "data/weather"
func cachePath(city CityInfo, date time.Time) string {
dir := filepath.Join(WeatherDataDir, date.Format("2006"), date.Format("01"))
name := fmt.Sprintf("%s_%s_%s.json", city.ShiZH, city.XianZH, date.Format("20060102"))
return filepath.Join(dir, name)
}
// ============================================================
// 9. 抓 nmc
// ============================================================
func fetchWeather(city CityInfo) (*WeatherData, error) {
now := time.Now()
// 1. 缓存命中
cp := cachePath(city, now)
if data, err := os.ReadFile(cp); err == nil {
var cached WeatherData
if err := json.Unmarshal(data, &cached); err == nil {
cached.FromCache = true
log.Printf("✅ 命中缓存: %s", cp)
return &cached, nil
}
}
// 2. 缓存未命中 → 抓官方接口
log.Printf("🌐 抓取官方接口: %s (%s)", city.Value, city.Code)
url := "https://www.nmc.cn/rest/weather?stationid=" + city.Code
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
req.Header.Set("Referer", "https://www.nmc.cn/publish/forecast/AXJ/"+city.Code+".html")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var api nmcResp
if err := json.Unmarshal(body, &api); err != nil {
return nil, fmt.Errorf("解析失败: %w", err)
}
if api.Code != 0 {
return nil, fmt.Errorf("接口 code=%d", api.Code)
}
r := api.Data.Real
w := &WeatherData{
City: city.Value,
CityUG: city.XianUG,
ShiZH: city.ShiZH,
ShiUG: city.ShiUG,
Date: now.Format("2006/01/02"),
WeekdayZH: weekdayZH[now.Weekday()],
WeekdayUG: weekdayToUG(weekdayZH[now.Weekday()]),
Sunrise: timeOnly(r.SunriseSunset.Sunrise),
Sunset: timeOnly(r.SunriseSunset.Sunset),
Weather: r.Weather.Info,
TemNow: r.Weather.Temperature,
Humidity: r.Weather.Humidity,
WindDir: r.Wind.Direct,
WindPower: r.Wind.Power,
FetchedAt: now.Format("2006-01-02 15:04:05"),
FromCache: false,
}
// 今天温度
if len(api.Data.Predict.Detail) >= 1 {
today := api.Data.Predict.Detail[0]
h := parseFloat(today.Day.Weather.Temperature)
l := parseFloat(today.Night.Weather.Temperature)
// 兜底:今天白天温度缺失 → 用实况温度
if h <= 0 && r.Weather.Temperature > 0 {
h = r.Weather.Temperature
}
if h > 0 {
w.TemHigh = &h
}
if l > 0 {
w.TemLow = &l
}
}
// 明天
if len(api.Data.Predict.Detail) > 1 {
t := api.Data.Predict.Detail[1]
w.TmrW = clean(t.Day.Weather.Info)
w.TmrHigh = parseFloat(t.Day.Weather.Temperature)
w.TmrLow = parseFloat(t.Night.Weather.Temperature)
}
// 3. 生成中维文案
w.ZH = generateZH(city, w)
w.UG = generateUG(city, w)
// 4. 写缓存
_ = os.MkdirAll(filepath.Dir(cp), 0755)
data, _ := json.MarshalIndent(w, "", " ")
if err := os.WriteFile(cp, data, 0644); err != nil {
log.Printf("⚠️ 写缓存失败: %v", err)
} else {
log.Printf("💾 已缓存: %s", cp)
}
return w, nil
}
// ============================================================
// 10. main
// ============================================================
func main() {
var cities []string
for _, a := range os.Args[1:] {
cities = append(cities, a)
}
if len(cities) == 0 {
cities = []string{"叶城"}
}
seen := map[string]bool{}
var results []*WeatherData
for _, name := range cities {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
city, ok := lookupCity(name)
if !ok {
log.Printf("跳过 %s:城市不存在", name)
continue
}
w, err := fetchWeather(city)
if err != nil {
log.Printf("处理 %s 失败: %v", name, err)
continue
}
results = append(results, w)
}
if len(results) == 0 {
log.Fatal("无数据")
}
filename := time.Now().Format("20060102") + ".json"
f, err := os.Create(filename)
if err != nil {
log.Fatalf("创建文件失败: %v", err)
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
enc.Encode(results)
abs, _ := filepath.Abs(filename)
fmt.Println("已保存:", abs)
}
六、运行
# 单城市
go run tianqi.go
# 多城市
go run tianqi.go 喀什 莎车 和田 库车
输出(20260923.json):
[
{
"city": "叶城",
"city_ug": "قاغىلىق",
"shi_zh": "喀什",
"shi_ug": "قەشقەر",
"date": "2026/09/23",
"weekday_zh": "星期三",
"weekday_ug": "چارشەنبە",
"sunrise": "08:38",
"sunset": "20:46",
"weather": "晴",
"tem_now": 28.6,
"humidity": 18,
"wind_dir": "西北风",
"wind_power": "微风",
"tem_high": 28.6,
"tem_low": 13,
"tomorrow_weather": "多云",
"tomorrow_high": 27,
"tomorrow_low": 12,
"zh": "今天是9月23日 星期三,叶城早上08:38日出,晴,白天热,空气干燥,20:46日落,明天多云,白天暖和,昼夜温差大,早晚穿厚,中午穿少。",
"ug": "بۈگۈن 9-ئاينىڭ 23-كۈنى چارشەنبە، قاغىلىق سەھەر سائەت 08:38 دە كۈن چىقىدۇ. ھاۋا ئوچۇق، ھاۋا نەملىكى قۇرغاق، سائەت 20:46 دە كۈن پاتىدۇ. ئەتە ھاۋا بۇلۇتلۇق، كۈندۈزى ئىللىق، كېچە-كۈندۈز تېمپېراتۇرا پەرقى چوڭ، ئەتىگەن-كەچتە قېلىن، چۈشتە نىپىز كىيىڭ.",
"fetched_at": "2026-09-23 18:20:00",
"from_cache": false
}
]
第二次运行同一城市:
go run tianqi.go 叶城
日志:
✅ 命中缓存: data/weather/2026/09/喀什_叶城县_20260923.json
不请求任何接口。
七、踩坑记录
| # | 坑 | 解决 |
|---|---|---|
| 1 | stationid 不是拼音也不是站号 | 从页面 HTML 挖 scode |
| 2 | 9999 是哨兵值 | parseFloat 特判 |
| 3 | 傍晚后今天白天温度是 9999 | 用 real.weather.temperature 兜底 |
| 4 | os.ReadFile 相对路径 | 用绝对路径,或用 //go:embed |
| 5 | 缓存文件写不进去 | os.MkdirAll 先建目录 |
| 6 | 维语星期/天气没翻译 | 映射表 weekdayToUG / weatherToUG |
八、扩展方向
- 定时抓取:
cron每天 8:00、17:00 抓一次 - 微信推送:抓完调公众号模板消息
- 语音播报:文本转 TTS
- 趋势分析:缓存文件天然是历史
- 多省份:改
XinjiangCities即可
九、做生产项目怎么办
如果项目要规模化,请用付费 API,别滥用公共资源。
推荐:
| 场景 | 推荐 |
|---|---|
| 中小项目 | 阿里云市场「天气 API」、腾讯云市场「天气服务」 |
| 大型项目 | 和风天气商业版、彩云天气、心知天气 |
| 农业专业 | 中国天气网行业接口、华风爱科 |
| 海外 | OpenWeather One Call、WeatherAPI |
付费 API 的好处:
- 稳定、不封 IP
- 有 SLA 保障
- 有技术支持和商业授权
- 对得起别人的服务器,也是对自己项目的负责
十、免责声明
- 本文代码仅供学习交流,请勿用于商业用途
- 国家气象局数据版权归中国气象局所有
- 请遵守数据源使用条款,缓存优先,不要高频请求
- 生产环境请自行评估稳定性,接口随时可能变化
- 本文不涉及任何 AI 生成,全部为模板化文案
十一、写在最后
做个人项目,最难得的不是技术,是自觉。
- 缓存优先,是尊重
- 用付费 API,是对等
- 不滥用公共资源,是成年人该有的样子
技术可以炫,但底线不能破。
标签:Go 爬虫 天气API 本地缓存 双语文案 新疆 农业气象 数据伦理
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_55636632/article/details/166492806




