码艺-Alimjan头像
关注
用 Go 抓取精准天气预报并做本地缓存:一次非侵入式的数据实践封面图

用 Go 抓取精准天气预报并做本地缓存:一次非侵入式的数据实践

一次个人项目中对公共数据源"礼貌使用"的探索
关键词: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 接口,但参数需要挖

我们的目标:

  1. 从官方接口拿到结构化数据
  2. 存成本地 JSON,按行政区归档
  3. 通过模板化文案生成中维双语描述
  4. 不依赖任何 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

不请求任何接口。


七、踩坑记录

#坑解决
1stationid 不是拼音也不是站号从页面 HTML 挖 scode
29999 是哨兵值parseFloat 特判
3傍晚后今天白天温度是 9999用 real.weather.temperature 兜底
4os.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 保障
  • 有技术支持和商业授权
  • 对得起别人的服务器,也是对自己项目的负责

十、免责声明

  1. 本文代码仅供学习交流,请勿用于商业用途
  2. 国家气象局数据版权归中国气象局所有
  3. 请遵守数据源使用条款,缓存优先,不要高频请求
  4. 生产环境请自行评估稳定性,接口随时可能变化
  5. 本文不涉及任何 AI 生成,全部为模板化文案

十一、写在最后

做个人项目,最难得的不是技术,是自觉。

  • 缓存优先,是尊重
  • 用付费 API,是对等
  • 不滥用公共资源,是成年人该有的样子

技术可以炫,但底线不能破。


标签:Go 爬虫 天气API 本地缓存 双语文案 新疆 农业气象 数据伦理

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_55636632/article/details/166492806

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--