package radarfetch import ( "fmt" "os" "path/filepath" "time" ) // StartJob launches a background ticker to download NMC images and CMA bin using Huanan time. type Options struct { OutRoot string TZOffset int Interval time.Duration NMCChinaURL string NMCHuananURL string NMCNanningURL string CMABase string Z, Y, X int } // RunOnce executes a single download-render-save cycle. func RunOnce(opts Options) error { if opts.OutRoot == "" { opts.OutRoot = "./radar_data" } if opts.TZOffset == 0 { opts.TZOffset = 8 } if opts.NMCChinaURL == "" { opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html" } if opts.NMCHuananURL == "" { opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html" } if opts.NMCNanningURL == "" { opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm" } if opts.CMABase == "" { opts.CMABase = "https://image.data.cma.cn" } if opts.Z == 0 && opts.Y == 0 && opts.X == 0 { opts.Z, opts.Y, opts.X = 7, 40, 102 } fmt.Printf("[radar] start run: out=%s z/y/x=%d/%d/%d tz=%d\n", opts.OutRoot, opts.Z, opts.Y, opts.X, opts.TZOffset) err := runDownload(opts.OutRoot, opts.TZOffset, opts.NMCChinaURL, opts.NMCHuananURL, opts.NMCNanningURL, opts.CMABase, opts.Z, opts.Y, opts.X) if err != nil { fmt.Printf("[radar] run failed: %v\n", err) return err } fmt.Println("[radar] run done") return nil } // Run starts the periodic downloader (blocking ticker loop). func Run(opts Options) { if opts.OutRoot == "" { opts.OutRoot = "./radar_data" } if opts.TZOffset == 0 { opts.TZOffset = 8 } if opts.Interval <= 0 { opts.Interval = 10 * time.Minute } if opts.NMCChinaURL == "" { opts.NMCChinaURL = "https://www.nmc.cn/publish/radar/chinaall.html" } if opts.NMCHuananURL == "" { opts.NMCHuananURL = "https://www.nmc.cn/publish/radar/huanan.html" } if opts.NMCNanningURL == "" { opts.NMCNanningURL = "https://www.nmc.cn/publish/radar/guang-xi/nan-ning.htm" } if opts.CMABase == "" { opts.CMABase = "https://image.data.cma.cn" } if opts.Z == 0 && opts.Y == 0 && opts.X == 0 { opts.Z, opts.Y, opts.X = 7, 40, 102 } _ = RunOnce(opts) t := time.NewTicker(opts.Interval) for range t.C { _ = RunOnce(opts) } } func runDownload(outRoot string, tzOffset int, chinaURL, huananURL, nanningURL, cmaBase string, z, y, x int) error { // 1) Fetch NMC pages and parse image/time (time from Huanan) fmt.Println("[radar] fetch NMC China page ...") chinaHTML, err := GetWithUA(chinaURL, DefaultUA, 15*time.Second, nil) if err != nil { return fmt.Errorf("fetch NMC China: %w", err) } chinaImg, _, ok := ExtractFirstImageAndTime(chinaHTML) if !ok { return fmt.Errorf("parse China page: data-img not found") } fmt.Println("[radar] fetch NMC Huanan page ...") huananHTML, err := GetWithUA(huananURL, DefaultUA, 15*time.Second, nil) if err != nil { return fmt.Errorf("fetch NMC Huanan: %w", err) } huananImg, huananTime, ok := ExtractFirstImageAndTime(huananHTML) if !ok { return fmt.Errorf("parse Huanan page: data-img not found") } date, hour, minute, tsLocal := ParseNmcTime(huananTime, tzOffset) fmt.Println("[radar] fetch NMC Nanning page ...") nanningHTML, err := GetWithUA(nanningURL, DefaultUA, 15*time.Second, nil) if err != nil { return fmt.Errorf("fetch NMC Nanning: %w", err) } nanningImg, _, ok := ExtractFirstImageAndTime(nanningHTML) if !ok { return fmt.Errorf("parse Nanning page: data-img not found") } // Prepare out directory outDir := filepath.Join(outRoot, fmt.Sprintf("%04d%02d%02d", date/10000, (date/100)%100, date%100), fmt.Sprintf("%02d", hour), fmt.Sprintf("%02d", minute)) if err := os.MkdirAll(outDir, 0o755); err != nil { return fmt.Errorf("mkdir outDir: %w", err) } // Download three images (with Referer) imgHeaders := map[string]string{ "Referer": "https://www.nmc.cn/", "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", } fmt.Println("[radar] download China/Huanan/Nanning images ...") if b, err := GetWithUA(chinaImg, DefaultUA, 20*time.Second, imgHeaders); err == nil { _ = os.WriteFile(filepath.Join(outDir, "nmc_chinaall.png"), b, 0o644) } if b, err := GetWithUA(huananImg, DefaultUA, 20*time.Second, imgHeaders); err == nil { _ = os.WriteFile(filepath.Join(outDir, "nmc_huanan.png"), b, 0o644) } if b, err := GetWithUA(nanningImg, DefaultUA, 20*time.Second, imgHeaders); err == nil { _ = os.WriteFile(filepath.Join(outDir, "nmc_nanning.png"), b, 0o644) } // 2) Fetch CMA BIN with Huanan time binURL := BuildCMAURL(cmaBase, date, hour, minute, z, y, x) binHeaders := map[string]string{ "Referer": "https://data.cma.cn/", "Origin": "https://data.cma.cn", "User-Agent": DefaultUA, "Accept": "*/*", } fmt.Printf("[radar] download CMA bin z/y/x=%d/%d/%d ...\n", z, y, x) binBytes, err := GetWithUA(binURL, DefaultUA, 30*time.Second, binHeaders) if err != nil { return fmt.Errorf("fetch BIN: %w", err) } binPath := filepath.Join(outDir, fmt.Sprintf("%d-%d-%d.bin", z, y, x)) if err := os.WriteFile(binPath, binBytes, 0o644); err != nil { return fmt.Errorf("save BIN: %w", err) } // Render BIN -> PNG cmaPNG := filepath.Join(outDir, fmt.Sprintf("cma_%d-%d-%d.png", z, y, x)) fmt.Println("[radar] render bin -> png ...") if err := RenderBinToPNG(binPath, cmaPNG, true); err != nil { return fmt.Errorf("render PNG: %w", err) } // Decode grid and detect clusters (>=40 dBZ) fmt.Println("[radar] decode grid & detect clusters ...") grid := make([][]*float64, 256) { const w, h = 256, 256 if len(binBytes) == w*h*2 { for row := 0; row < h; row++ { line := make([]*float64, w) for col := 0; col < w; col++ { off := (row*w + col) * 2 u := uint16(binBytes[off])<<8 | uint16(binBytes[off+1]) vv := int16(u) if vv == 32767 || vv < 0 { line[col] = nil continue } dbz := float64(vv) / 10.0 line[col] = &dbz } grid[row] = line } } } // 3) Write metadata and update latest w, s, e, n, res := Bounds4326(z, y, x) meta := Metadata{ TimestampLocal: tsLocal, Date: date, Hour: hour, Minute: minute, Z: z, Y: y, X: x, Bounds: Bounds{West: w, South: s, East: e, North: n}, ResDeg: res, Sources: Sources{NmcHTML: huananURL, NmcImg: huananImg, CmaBin: binURL}, Files: Files{HTML: "", PNG: filepath.Join(outDir, "nmc_huanan.png"), BIN: binPath, Metadata: filepath.Join(outDir, "metadata.json"), CMAPNG: cmaPNG}, Sizes: Sizes{PNG: fileSize(filepath.Join(outDir, "nmc_huanan.png")), BIN: int64(len(binBytes))}, CreatedAt: time.Now().Format(time.RFC3339), } // Attach clusters if grid decoded if grid[0] != nil { meta.Clusters = SegmentClusters(grid, Bounds{West: w, South: s, East: e, North: n}, res, 40.0) // Render small PNGs per cluster if len(meta.Clusters) > 0 { if updated, err2 := AttachClusterPNGs(grid, 40.0, meta.Clusters, outDir); err2 == nil { meta.Clusters = updated } } meta.AnalysisNote = "clusters>=40dBZ; samples=center+4rays (N/S/E/W)" // Build wind query plan with defaults meta.QueryParams = QueryParams{MinAreaPx: 9, StrongDBZOverride: 50, MaxSamplesPerCluster: 5, MaxCandidatesTotal: 25} cl2, cands := PlanWindQuery(meta.Clusters, meta.QueryParams) meta.Clusters = cl2 meta.QueryCandidates = cands } if err := WriteMetadata(filepath.Join(outDir, "metadata.json"), &meta); err != nil { return fmt.Errorf("write metadata: %w", err) } fmt.Println("[radar] update latest snapshot ...") if err := UpdateLatest(outRoot, outDir, &meta); err != nil { return fmt.Errorf("update latest: %w", err) } return nil } func fileSize(p string) int64 { fi, err := os.Stat(p) if err != nil { return 0 } return fi.Size() }