228 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"database/sql"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
dbpkg "weatherstation/internal/database"
"weatherstation/internal/rain"
)
// 简单的小时雨量CMPA按时间范围下载器
// - 输入时间为北京时间Asia/Shanghai
// - 构造下载路径使用 UTC本地整点 -8h
// - 入库前通过 rain.StoreTileBytes 使用 URL 解析将 UTC 还原为北京时间并写库
// 用法示例:
//
// go run ./cmd/rainfetch --from "2025-10-07 09:00:00" --to "2025-10-07 11:00:00" \
// --tiles "7/40/102,7/40/104" --outdir rain_data
func main() {
var (
fromStr = flag.String("from", "", "起始时间北京时间YYYY-MM-DD HH:MM:SS 或 YYYY-MM-DD")
toStr = flag.String("to", "", "结束时间北京时间YYYY-MM-DD HH:MM:SS 或 YYYY-MM-DD")
tiles = flag.String("tiles", "7/40/102,7/40/104", "瓦片列表,逗号分隔,每项为 z/y/x如 7/40/102")
outDir = flag.String("outdir", "rain_data", "保存目录(同时也会写入数据库)")
baseURL = flag.String("base", "https://image.data.cma.cn/tiles/China/CMPA_RT_China_0P01_HOR-PRE_GISJPG_Tiles/%Y%m%d/%H/%M/{z}/{y}/{x}.bin", "下载基础URL模板UTC路径时间")
dryRun = flag.Bool("dry", false, "仅打印将要下载的URL与目标不实际下载写库")
)
flag.Parse()
if strings.TrimSpace(*fromStr) == "" || strings.TrimSpace(*toStr) == "" {
log.Fatalln("必须提供 --from 与 --to北京时间")
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
parseCST := func(s string) (time.Time, error) {
s = strings.TrimSpace(s)
var t time.Time
var err error
if len(s) == len("2006-01-02") {
// 日期:按 00:00:00 处理
if tm, e := time.ParseInLocation("2006-01-02", s, loc); e == nil {
t = tm
} else {
err = e
}
} else {
t, err = time.ParseInLocation("2006-01-02 15:04:05", s, loc)
}
return t, err
}
start, err1 := parseCST(*fromStr)
end, err2 := parseCST(*toStr)
if err1 != nil || err2 != nil {
log.Fatalf("解析时间失败: from=%v to=%v", err1, err2)
}
if end.Before(start) {
log.Fatalln("结束时间需不小于起始时间")
}
// 小时步进(包含端点):先对齐到小时
cur := start.Truncate(time.Hour)
end = end.Truncate(time.Hour)
// 解析 tiles 参数
type tcoord struct{ z, y, x int }
var tlist []tcoord
for _, part := range strings.Split(*tiles, ",") {
p := strings.TrimSpace(part)
if p == "" {
continue
}
var z, y, x int
if _, err := fmt.Sscanf(p, "%d/%d/%d", &z, &y, &x); err != nil {
log.Fatalf("无效的 tiles 项: %s", p)
}
tlist = append(tlist, tcoord{z, y, x})
}
if len(tlist) == 0 {
log.Fatalln("tiles 解析后为空")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
log.Fatalf("创建输出目录失败: %v", err)
}
ctx := context.Background()
total := 0
success := 0
for !cur.After(end) {
// 本地整点CST→ UTC 路径时间
slotLocal := cur
slotUTC := slotLocal.Add(-8 * time.Hour).In(time.UTC)
dateStr := slotUTC.Format("20060102")
hh := slotUTC.Format("15")
mm := "00"
log.Printf("[rainfetch] 时次 local=%s, utc=%s", slotLocal.Format("2006-01-02 15:04"), slotUTC.Format("2006-01-02 15:04"))
for _, tc := range tlist {
total++
// 构造 URL
url := *baseURL
url = strings.ReplaceAll(url, "%Y%m%d", dateStr)
url = strings.ReplaceAll(url, "%H", hh)
url = strings.ReplaceAll(url, "%M", mm)
url = strings.ReplaceAll(url, "{z}", fmt.Sprintf("%d", tc.z))
url = strings.ReplaceAll(url, "{y}", fmt.Sprintf("%d", tc.y))
url = strings.ReplaceAll(url, "{x}", fmt.Sprintf("%d", tc.x))
fname := fmt.Sprintf("rain_z%d_y%d_x%d_%s.bin", tc.z, tc.y, tc.x, slotLocal.Format("20060102_1504"))
dest := filepath.Join(*outDir, fname)
if *dryRun {
log.Printf("[rainfetch] DRY url=%s -> %s", url, dest)
continue
}
// 若 DB 已有,则跳过
if ref, e := rain.ParseCMPATileURL(url); e == nil {
exists, e2 := databaseHas(ctx, ref.Product, ref.DT, tc.z, tc.y, tc.x)
if e2 == nil && exists {
log.Printf("[rainfetch] skip exists in DB z=%d y=%d x=%d dt=%s", tc.z, tc.y, tc.x, ref.DT.Format("2006-01-02 15:04"))
continue
}
}
if err := httpDownloadTo(ctx, url, dest); err != nil {
log.Printf("[rainfetch] 下载失败 z=%d y=%d x=%d: %v", tc.z, tc.y, tc.x, err)
continue
}
log.Printf("[rainfetch] 保存 %s", dest)
// 写库(使用 URL 解析 UTC → CST 后 upsert
b, rerr := os.ReadFile(dest)
if rerr != nil {
log.Printf("[rainfetch] 读文件失败: %v", rerr)
continue
}
if err := rain.StoreTileBytes(ctx, url, b); err != nil {
log.Printf("[rainfetch] 入库失败: %v", err)
continue
}
success++
}
cur = cur.Add(1 * time.Hour)
}
log.Printf("[rainfetch] 完成:尝试 %d成功 %d", total, success)
}
// 轻量 DB 存在性检查(避免引入内部 database 包到该命令):
// 为了避免循环依赖,这里复制一份最小 SQL 调用;实际工程也可抽取共享函数。
// 但当前 repo 中 database.GetDB 在 internal/database 包内,雨量 API 直接使用它。
// 注意:为保持最小侵入,这里通过 rain.StoreTileBytes 完成入库;
// 仅在下载前进行“是否已存在”查询,避免重复下载。为此需要访问 internal/database。
func databaseHas(ctx context.Context, product string, dt time.Time, z, y, x int) (bool, error) {
const q = `SELECT 1 FROM rain_tiles WHERE product=$1 AND dt=$2 AND z=$3 AND y=$4 AND x=$5 LIMIT 1`
var one int
err := dbpkg.GetDB().QueryRowContext(ctx, q, product, dt, z, y, x).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func httpDownloadTo(ctx context.Context, url, dest string) error {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Referer", "https://data.cma.cn/")
req.Header.Set("Origin", "https://data.cma.cn")
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
tmp := dest + ".part"
f, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
_, copyErr := io.Copy(f, resp.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write body: %w", copyErr)
}
if closeErr != nil {
_ = os.Remove(tmp)
return fmt.Errorf("close temp: %w", closeErr)
}
if err := os.Rename(tmp, dest); err != nil {
// Cross-device fallback
data, rerr := os.ReadFile(tmp)
if rerr != nil {
return fmt.Errorf("read temp: %w", rerr)
}
if werr := os.WriteFile(dest, data, 0o644); werr != nil {
return fmt.Errorf("write final: %w", werr)
}
_ = os.Remove(tmp)
}
return nil
}