Zenn
🦔

花粉飛散情報を取得・表示するCLI、Pollensoをリリースしました 🎉

2025/02/15に公開
1

最近花粉すごいですよね。僕もアレルギー性鼻炎を患っており、辛いです。ところで、仕事では「ほうれんそう」が大事とよくいいます。

そこで、花粉がどれぐらい飛んでいるか確認するCLI、 Pollensoを作成しました。

$ pollenso --help
Usage of pollenso:
  -cityname string
        都市名 (default "東京都千代田区")
  -end string
        取得終了年月日 (YYYYMMDD) 例: 20250214 (default "20250213")
  -start string
        取得開始年月日 (YYYYMMDD) 例: 20250208 (default "20250213")

こんな感じで検索できます。ためしに検索してみましょう。(デフォルトはコマンド実行時の日付に自動でセットされます)

-cityname 品川区 -start 20250212 -end 20250212 
リクエストURL: https://wxtech.weathernews.com/opendata/v1/pollen?citycode=13109&start=20250212&end=20250212
日時: 2025-02-12 00:00:00, 花粉飛散数: 0
日時: 2025-02-12 01:00:00, 花粉飛散数: 0
日時: 2025-02-12 02:00:00, 花粉飛散数: 0
日時: 2025-02-12 03:00:00, 花粉飛散数: 0
日時: 2025-02-12 04:00:00, 花粉飛散数: 0
日時: 2025-02-12 05:00:00, 花粉飛散数: 0
日時: 2025-02-12 06:00:00, 花粉飛散数: 0
日時: 2025-02-12 07:00:00, 花粉飛散数: 0
日時: 2025-02-12 08:00:00, 花粉飛散数: 0
日時: 2025-02-12 09:00:00, 花粉飛散数: 0
日時: 2025-02-12 10:00:00, 花粉飛散数: 0
日時: 2025-02-12 11:00:00, 花粉飛散数: 0
日時: 2025-02-12 12:00:00, 花粉飛散数: 0
日時: 2025-02-12 13:00:00, 花粉飛散数: 0
日時: 2025-02-12 14:00:00, 花粉飛散数: 0
日時: 2025-02-12 15:00:00, 花粉飛散数: 2
日時: 2025-02-12 16:00:00, 花粉飛散数: 1
日時: 2025-02-12 17:00:00, 花粉飛散数: 3
日時: 2025-02-12 18:00:00, 花粉飛散数: 1
日時: 2025-02-12 19:00:00, 花粉飛散数: 2
日時: 2025-02-12 20:00:00, 花粉飛散数: 1
日時: 2025-02-12 21:00:00, 花粉飛散数: 1
日時: 2025-02-12 22:00:00, 花粉飛散数: 0
日時: 2025-02-12 23:00:00, 花粉飛散数: 0

参照しているAPI

全国各地に配置されたポルーンロボが採取したデータです。

https://weathernews.jp/s/topics/202111/280065/

ウェザーニュースが公開しているAPIを利用することができます。商用の場合は申請が必要なので注意してください。
https://wxtech.weathernews.com/pollen/index.html

開発言語

今回はGo言語を利用しました。GoのCLIを公開している方が周りにいて便利そうだなーと思ったからです。調査的な意味があるので特段比較検討はしていません。ただ、後述するFuzzy Searchを入れたかった関係で、パッケージがあるかだけは先に確認しました。

CLI Parser

GoのCLI Parseはものすごく簡単です。なんとこれだけで設定しています。

// start, endのデフォルト値を今日の日付に設定
today := time.Now().Format("20060102")

// コマンドライン引数の取得
cityName := flag.String("cityname", "東京都千代田区", "都市名")
start := flag.String("start", today, "取得開始年月日 (YYYYMMDD) 例: 20250208")
end := flag.String("end", today, "取得終了年月日 (YYYYMMDD) 例: 20250214")
flag.Parse()

普段CLIを作るときはRustの Clap かPythonの argparse を利用しているのですが、そのなかでもコード記述量が少なく感じました。一番使っているのは Clap なのですが、Rustはミニマムな言語でCLI ParserがBuilt-inではないです。なので、色々作っているとDependabotの連絡がそこそこ来ます。

Fuzzy Finder

ところで、pollenso -cityname 品川区 に違和感を持った方もいらっしゃると思います。 help messageだと 東京都中央区 ですもんね。

$ pollenso -cityname 東京都品川区 # ✅: OK
$ pollenso -cityname 品川区 # ✅: これもOK

この機能はいわゆるFuzzy Finderを使用しています。今回は検索対象のハッシュマップのKeyをLevenshtein距離 (編集距離) で順位付けして、1番小さいもののValueを検索結果として出力します。

単一のバイナリにしたかったので、あえてCSVなどのデータ形式を読みとる方法は使わず、Goのコードにベタ書きしています。左側がKey, 右側がValueです。

var cityCodeMap = map[string]string{
	"東京都千代田区":     "13101",
	"東京都中央区":      "13102",
	"東京都港区":       "13103",
	...
	"東京都八丈島八丈町":   "13401",
	"東京都青ヶ島村":     "13402",
	"東京都小笠原村":     "13421",
}

Goのfuzzyパッケージの次の関数はLevenshtein距離 (編集距離) でランクを付けてくれるので、それを使って実装します。

func RankMatch(source, target string) int

実際のコードです。パッケージのおかげで簡単に実装できます。

func getCityCodeFuzzy(input string) (string, error) {
	cityNames := make([]string, 0, len(cityCodeMap))
	for name := range cityCodeMap {
		cityNames = append(cityNames, name)
	}

	ranks := fuzzy.RankFind(input, cityNames)
	if len(ranks) == 0 {
		return "", errors.New("一致する市区名が見つかりません")
	}

	bestMatch := ranks[0].Target
	return cityCodeMap[bestMatch], nil
}

補足

Levenshtein距離を知らない方向け。Wikipediaより引用。

レーベンシュタイン距離(レーベンシュタインきょり、英: Levenshtein distance)は、二つの文字列がどの程度異なっているかを示す距離の一種である。編集距離(へんしゅうきょり、英: edit distance)とも呼ばれる。具体的には、1文字の挿入・削除・置換によって、一方の文字列をもう一方の文字列に変形するのに必要な手順の最小回数として定義される[1]。

https://ja.wikipedia.org/wiki/レーベンシュタイン距離

API Fetch

ウェザーニューズが独自に開発した花粉観測機「ポールンロボ」によって観測・解析された、2025年の花粉飛散数データをAPI経由で取得します。CSV形式で帰ってくるので、パースします。

パラメータ名 名称 必須 値の例 備考
citycode 市区町村コード 13103 <リスト>
start 取得開始年月日 - 20250201 YYYYMMDD
end 取得終了年月日 - 20250314 YYYYMMDD
package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"log"
	"net/http"
	"strconv"
	"time"
)

type PollenData struct {
	CityCode string
	Date     time.Time
	Pollen   int
}

func getPollenData(cityCode, start, end string) ([]PollenData, error) {
	// APIエンドポイントのURL作成
	url := fmt.Sprintf("https://wxtech.weathernews.com/opendata/v1/pollen?citycode=%s&start=%s&end=%s", cityCode, start, end)
	fmt.Println("リクエストURL:", url)

	// HTTP GETリクエスト
	resp, err := http.Get(url)
	if err != nil {
		return nil, fmt.Errorf("HTTPリクエスト失敗: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("リクエストが失敗しました。ステータスコード: %d", resp.StatusCode)
	}

	// CSVリーダーの作成
	reader := csv.NewReader(resp.Body)
	var data []PollenData

	// 最初の行を読み込み、ヘッダー行かどうか確認
	firstRow, err := reader.Read()
	if err != nil {
		return nil, fmt.Errorf("CSVの読み込みに失敗: %w", err)
	}
	if len(firstRow) > 0 && firstRow[0] == "citycode" {
		// ヘッダー行の場合は、次の行から読み込み開始
	} else {
		// ヘッダーがない場合は、最初の行もデータとして処理
		record, err := parseRecord(firstRow)
		if err != nil {
			log.Printf("行のパースに失敗: %v", err)
		} else {
			data = append(data, record)
		}
	}

	// 残りのレコードを読み込み
	for {
		row, err := reader.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Printf("CSV読み込み中のエラー: %v", err)
			continue
		}
		record, err := parseRecord(row)
		if err != nil {
			log.Printf("行のパースに失敗: %v", err)
			continue
		}
		data = append(data, record)
	}
	return data, nil
}

// CSVの1行を受け取り PollenData に変換
func parseRecord(record []string) (PollenData, error) {
	if len(record) < 3 {
		return PollenData{}, fmt.Errorf("行の要素数が不足しています: %v", record)
	}

	// 1列目: 市区町村コード
	citycode := record[0]

	// 2列目: 日時(ISO8601形式: 例 "2025-02-03T12:00:00+09:00")
	parsedDate, err := time.Parse(time.RFC3339, record[1])
	if err != nil {
		return PollenData{}, fmt.Errorf("日付のパースに失敗: %s, エラー: %v", record[1], err)
	}

	// 3列目: 花粉飛散数(整数に変換、欠測値は -9999 として扱う)
	pollen, err := strconv.Atoi(record[2])
	if err != nil {
		return PollenData{}, fmt.Errorf("花粉飛散数のパースに失敗: %s, エラー: %v", record[2], err)
	}

	return PollenData{
		CityCode: citycode,
		Date:     parsedDate,
		Pollen:   pollen,
	}, nil
}

おわりに

Go製のCLI手軽に作れることが分かったのでこれからも使おうかなと思っています。よければスター押していってください。

https://github.com/shunsock/pollenso

1

Discussion

ログインするとコメントできます