utils/
weather.rs

1//! 気象情報。
2//! 気象庁の非公式 API へアクセスする。
3//!
4//! * office-code: 気象台のコード。北海道以外は概ね都道府県ごと。
5//! * class10-code: 一次細分区域。天気予報を行う区分。
6//! * class20-code: 二次細分区域。天気予報を行う区分。
7//!
8//! <https://www.jma.go.jp/jma/kishou/know/saibun/>
9//!
10//! 参考
11//! <https://github.com/misohena/el-jma/blob/main/docs/how-to-get-jma-forecast.org>
12
13use anyhow::{Result, anyhow, ensure};
14use chrono::{DateTime, Local};
15use serde::{Deserialize, Serialize};
16use std::{
17    collections::{BTreeMap, HashMap},
18    sync::LazyLock,
19};
20
21/// <https://www.jma.go.jp/bosai/common/const/area.json>
22///
23/// 例: 東京都: 130000
24///
25/// 移転または何らかの理由で 404 のものがある。
26/// 復活するかもしれないので削除はしないものとする。
27///
28/// Note: 2023/10/29
29///
30/// ```text
31/// Not found: JmaOfficeInfo { code: "014030", name: "十勝地方", en_name: "Tokachi", office_name: "帯広測候所" }
32/// Not found: JmaOfficeInfo { code: "460040", name: "奄美地方", en_name: "Amami", office_name: "名瀬測候所" }
33/// ```
34const JMA_AREA_JSON: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/res/area.json"));
35
36/// <https://www.jma.go.jp/bosai/forecast/>
37///
38/// JavaScript 上の定数データ。
39/// ブラウザのコンソールで Forecast.Const.TELOPS を JSON.stringify() して入手。
40///
41/// `[昼画像,夜画像,?,日本語,英語]`
42const JMA_TELOPLS_JSON: &str = include_str!(concat!(
43    env!("CARGO_MANIFEST_DIR"),
44    "/res/forecast_telops.json"
45));
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
48struct JmaAreaDef {
49    offices: BTreeMap<String, JmaOfficeInfo>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct JmaOfficeInfo {
55    /// JSON 中には存在しない。後でキーを入れる。
56    #[serde(default)]
57    pub code: String,
58    pub name: String,
59    pub en_name: String,
60    pub office_name: String,
61}
62
63static OFFICE_LIST: LazyLock<Vec<JmaOfficeInfo>> = LazyLock::new(office_list);
64static WEATHER_CODE_MAP: LazyLock<HashMap<String, String>> = LazyLock::new(weather_code_map);
65
66fn office_list() -> Vec<JmaOfficeInfo> {
67    let root: JmaAreaDef = serde_json::from_str(JMA_AREA_JSON).unwrap();
68
69    let list: Vec<_> = root
70        .offices
71        .iter()
72        .map(|(code, info)| {
73            let mut modified = info.clone();
74            modified.code = code.to_string();
75            modified
76        })
77        .collect();
78
79    list
80}
81
82pub fn offices() -> &'static Vec<JmaOfficeInfo> {
83    &OFFICE_LIST
84}
85
86pub fn office_name_to_code(name: &str) -> Option<String> {
87    offices()
88        .iter()
89        .find(|&info| info.name == name)
90        .map(|info| info.code.to_string())
91}
92
93fn weather_code_map() -> HashMap<String, String> {
94    let mut result = HashMap::new();
95
96    type RawObj = HashMap<String, [String; 5]>;
97    let obj: RawObj = serde_json::from_str(JMA_TELOPLS_JSON).unwrap();
98    for (k, v) in obj.iter() {
99        // 日本語名称
100        result.insert(k.to_string(), v[3].to_string());
101    }
102
103    result
104}
105
106pub fn weather_code_to_string(code: &str) -> Result<&str> {
107    WEATHER_CODE_MAP
108        .get(code)
109        .map(String::as_str)
110        .ok_or_else(|| anyhow!("Weather code not found: {code}"))
111}
112
113#[derive(Clone, Debug, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct OverviewForecast {
116    pub publishing_office: String,
117    pub report_datetime: String,
118    pub target_area: String,
119    pub headline_text: String,
120    pub text: String,
121}
122
123pub type ForecastRoot = Vec<Forecast>;
124
125#[derive(Clone, Debug, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct Forecast {
128    pub publishing_office: String,
129    pub report_datetime: String,
130    pub time_series: Vec<TimeSeries>,
131}
132
133#[derive(Clone, Debug, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct TimeSeries {
136    pub time_defines: Vec<String>,
137    pub areas: Vec<AreaArrayElement>,
138    pub temp_average: Option<TempPrecipAverage>,
139    pub precip_average: Option<TempPrecipAverage>,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct AreaArrayElement {
145    pub area: Area,
146    #[serde(flatten)]
147    pub data: AreaData,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct Area {
153    pub name: String,
154    pub code: String,
155}
156
157#[derive(Clone, Debug, Serialize, Deserialize)]
158#[serde(untagged)]
159pub enum AreaData {
160    // [1]
161    /// 明日から7日間
162    #[serde(rename_all = "camelCase")]
163    WheatherPop {
164        weather_codes: Vec<String>,
165        pops: Vec<String>,
166        reliabilities: Vec<String>,
167    },
168    /// 明日から7日間
169    #[serde(rename_all = "camelCase")]
170    DetailedTempreture {
171        temps_min: Vec<String>,
172        temps_min_upper: Vec<String>,
173        temps_min_lower: Vec<String>,
174        temps_max: Vec<String>,
175        temps_max_upper: Vec<String>,
176        temps_max_lower: Vec<String>,
177    },
178
179    // [0]
180    // 今日から3日分
181    #[serde(rename_all = "camelCase")]
182    Wheather {
183        weather_codes: Vec<String>,
184        weathers: Vec<String>,
185        winds: Vec<String>,
186        // 海がない地方がある
187        #[serde(default)]
188        waves: Vec<String>,
189    },
190    /// 今日から6時間ごと、5回分
191    #[serde(rename_all = "camelCase")]
192    Pop { pops: Vec<String> },
193    /// 明日の 0:00+9:00 と 9:00+9:00 (最低気温と最高気温)
194    #[serde(rename_all = "camelCase")]
195    Tempreture { temps: Vec<String> },
196}
197
198#[derive(Clone, Debug, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct TempPrecipAverage {
201    area: Area,
202    min: String,
203    max: String,
204}
205
206/// office_code から overview_forecast URL を得る。
207pub fn url_overview_forecast(office_code: &str) -> String {
208    format!("https://www.jma.go.jp/bosai/forecast/data/overview_forecast/{office_code}.json")
209}
210
211/// office_code から forecast URL を得る。
212pub fn url_forecast(office_code: &str) -> String {
213    format!("https://www.jma.go.jp/bosai/forecast/data/forecast/{office_code}.json")
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize)]
217pub struct AiReadableWeather {
218    url_for_more_info: String,
219    now: String,
220
221    publishing_office: String,
222    report_datetime: String,
223
224    /// DateStr => DateDataElem
225    date_data: BTreeMap<String, DateDataElem>,
226
227    target_area: String,
228    headline: String,
229    overview: String,
230}
231
232#[derive(Default, Clone, Debug, Serialize, Deserialize)]
233struct DateDataElem {
234    #[serde(skip_serializing_if = "Option::is_none")]
235    weather_pop_area: Option<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    weather: Option<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pop: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    tempreture_area: Option<String>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    temp_min: Option<String>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    temp_max: Option<String>,
246}
247
248/// AI にも読みやすい JSON に整形する。
249pub fn weather_to_ai_readable(
250    office_code: &str,
251    ov: &OverviewForecast,
252    fcr: &ForecastRoot,
253) -> Result<AiReadableWeather> {
254    let mut date_data: BTreeMap<String, DateDataElem> = BTreeMap::new();
255
256    for fc in fcr.iter() {
257        for ts in fc.time_series.iter() {
258            let td = &ts.time_defines;
259            let areas = &ts.areas;
260            if areas.is_empty() {
261                continue;
262            }
263            // "areas" データの中で最初のものを代表して使う
264            let area = &areas[0];
265            for (i, dt_str) in td.iter().enumerate() {
266                fill_by_element(&mut date_data, i, dt_str, area)?;
267            }
268        }
269    }
270
271    let now: DateTime<Local> = Local::now();
272    Ok(AiReadableWeather {
273        url_for_more_info: format!(
274            "https://www.jma.go.jp/bosai/forecast/#area_type=offices&area_code={office_code}"
275        ),
276        now: now.to_string(),
277
278        publishing_office: ov.publishing_office.to_string(),
279        report_datetime: ov.report_datetime.to_string(),
280
281        date_data,
282
283        target_area: ov.target_area.to_string(),
284        headline: ov.headline_text.to_string(),
285        overview: ov.text.to_string(),
286    })
287}
288
289fn fill_by_element(
290    result: &mut BTreeMap<String, DateDataElem>,
291    idx: usize,
292    dt_str: &str,
293    area: &AreaArrayElement,
294) -> Result<()> {
295    match &area.data {
296        AreaData::WheatherPop {
297            weather_codes,
298            pops,
299            reliabilities: _,
300        } => {
301            // 日時文字列で検索、なければデフォルトで新規作成する
302            let v = result.entry(dt_str.to_string()).or_default();
303            let weather_code = weather_codes
304                .get(idx)
305                .ok_or_else(|| anyhow!("Parse error"))?;
306            let pop = pops.get(idx).ok_or_else(|| anyhow!("Parse error"))?;
307
308            v.weather_pop_area = Some(area.area.name.to_string());
309            if v.weather.is_none() && !weather_code.is_empty() {
310                v.weather = Some(weather_code_to_string(weather_code)?.to_string());
311            }
312            if v.pop.is_none() && !pop.is_empty() {
313                v.pop = Some(format!("{}%", &pop));
314            }
315        }
316        AreaData::DetailedTempreture {
317            temps_min,
318            temps_min_upper: _,
319            temps_min_lower: _,
320            temps_max,
321            temps_max_upper: _,
322            temps_max_lower: _,
323        } => {
324            let v = result.entry(dt_str.to_string()).or_default();
325            let min = temps_min.get(idx).ok_or_else(|| anyhow!("Parse error"))?;
326            let max = temps_max.get(idx).ok_or_else(|| anyhow!("Parse error"))?;
327
328            v.tempreture_area = Some(area.area.name.to_string());
329            if !min.is_empty() {
330                v.temp_min = Some(min.to_string());
331            }
332            if !max.is_empty() {
333                v.temp_max = Some(max.to_string());
334            }
335        }
336        AreaData::Wheather {
337            weather_codes: _,
338            weathers,
339            winds: _,
340            waves: _,
341        } => {
342            let v = result.entry(dt_str.to_string()).or_default();
343            let weather = weathers.get(idx).ok_or_else(|| anyhow!("Parse error"))?;
344
345            v.weather_pop_area = Some(area.area.name.to_string());
346            v.weather = Some(weather.to_string());
347        }
348        AreaData::Pop { pops } => {
349            let pop = pops.get(idx).ok_or_else(|| anyhow!("Parse error"))?;
350
351            // 既にキーがあるときのみ
352            result.entry(dt_str.to_string()).and_modify(|v| {
353                v.pop = Some(pop.to_string());
354            });
355        }
356        AreaData::Tempreture { temps } => {
357            let dt = format!("{}T00:00:00+09:00", &dt_str[0..10]);
358            let temp = temps.get(idx).ok_or_else(|| anyhow!("Parse error"))?;
359            result.entry(dt).and_modify(|v| {
360                if idx == 0 {
361                    v.temp_min = Some(temp.to_string());
362                } else if idx == 1 {
363                    v.temp_max = Some(temp.to_string());
364                }
365            });
366        }
367    }
368    Ok(())
369}
370
371#[allow(unused)]
372fn edit_distance_normalized(a: &str, b: &str) -> Result<f32> {
373    let maxlen = a.len().max(b.len());
374    if maxlen == 0 {
375        return Ok(0.0);
376    }
377
378    let dis = edit_distance(a, b)?;
379    Ok(dis as f32 / maxlen as f32)
380}
381
382/// <https://ja.wikipedia.org/wiki/%E3%83%AC%E3%83%BC%E3%83%99%E3%83%B3%E3%82%B7%E3%83%A5%E3%82%BF%E3%82%A4%E3%83%B3%E8%B7%9D%E9%9B%A2>
383///
384/// O(mn)
385#[allow(unused)]
386fn edit_distance(a: &str, b: &str) -> Result<u32> {
387    ensure!(a.len() < 1024);
388    ensure!(b.len() < 1024);
389
390    let a: Vec<_> = a.chars().collect();
391    let b: Vec<_> = b.chars().collect();
392    let pitch = b.len() + 1;
393    let mut dp = vec![0u16; (a.len() + 1) * (b.len() + 1)];
394    let idx = |ia: usize, ib: usize| -> usize { ia * pitch + ib };
395
396    for ia in 0..=a.len() {
397        dp[idx(ia, 0)] = ia as u16;
398    }
399    for ib in 0..=b.len() {
400        dp[idx(0, ib)] = ib as u16;
401    }
402
403    for ia in 1..=a.len() {
404        for ib in 1..=b.len() {
405            let cost = if a[ia - 1] == b[ib - 1] { 0 } else { 1 };
406            let d1 = dp[idx(ia - 1, ib)] + 1;
407            let d2 = dp[idx(ia, ib - 1)] + 1;
408            let d3 = dp[idx(ia - 1, ib - 1)] + cost;
409            dp[idx(ia, ib)] = d1.min(d2).min(d3);
410        }
411    }
412
413    Ok(dp[idx(a.len(), b.len())] as u32)
414}
415
416#[cfg(test)]
417mod tests {
418    use std::{
419        fs::{self, File},
420        io::Write,
421    };
422
423    use super::*;
424    use reqwest::Client;
425    use serde_json::Value;
426
427    #[tokio::test]
428    #[ignore]
429    // cargo test weather_test_json -- --ignored --nocapture
430    async fn weather_test_json() -> Result<()> {
431        let olist = offices();
432        println!("Office count: {}", olist.len());
433        let client = Client::new();
434        for info in olist.iter() {
435            let url = format!(
436                "https://www.jma.go.jp/bosai/forecast/data/overview_forecast/{}.json",
437                info.code
438            );
439            let resp = client.get(url).send().await?;
440            if resp.status().is_success() {
441                let mut file = File::create(format!(
442                    "{}/res/test/weather/overview_forecast/{}.json",
443                    env!("CARGO_MANIFEST_DIR"),
444                    info.code
445                ))?;
446                let value: Value = serde_json::from_str(&resp.text().await?)?;
447                let text = serde_json::to_string_pretty(&value)? + "\n";
448                file.write_all(text.as_bytes())?;
449            } else {
450                println!("overview_forecast not found: {info:?}");
451            }
452
453            let url = format!(
454                "https://www.jma.go.jp/bosai/forecast/data/forecast/{}.json",
455                info.code
456            );
457            let resp = client.get(url).send().await?;
458            if resp.status().is_success() {
459                let mut file = File::create(format!(
460                    "{}/res/test/weather/forecast/{}.json",
461                    env!("CARGO_MANIFEST_DIR"),
462                    info.code
463                ))?;
464                let value: Value = serde_json::from_str(&resp.text().await?)?;
465                let text = serde_json::to_string_pretty(&value)? + "\n";
466                file.write_all(text.as_bytes())?;
467            } else {
468                println!("forecast not found: {info:?}");
469            }
470
471            let url = format!(
472                "https://www.jma.go.jp/bosai/forecast/data/overview_week/{}.json",
473                info.code
474            );
475            let resp = client.get(url).send().await?;
476            if resp.status().is_success() {
477                let mut file = File::create(format!(
478                    "{}/res/test/weather/overview_week/{}.json",
479                    env!("CARGO_MANIFEST_DIR"),
480                    info.code
481                ))?;
482                let value: Value = serde_json::from_str(&resp.text().await?)?;
483                let text = serde_json::to_string_pretty(&value)? + "\n";
484                file.write_all(text.as_bytes())?;
485            } else {
486                println!("overview_week not found: {info:?}");
487            }
488        }
489
490        Ok(())
491    }
492
493    #[test]
494    fn parse_overview_forecast() -> Result<()> {
495        let ents = fs::read_dir(concat!(
496            env!("CARGO_MANIFEST_DIR"),
497            "/res/test/weather/overview_forecast"
498        ))?;
499
500        let mut count = 0;
501        for ent in ents {
502            let src = fs::read_to_string(ent?.path())?;
503            let _: OverviewForecast = serde_json::from_str(&src)?;
504            count += 1;
505        }
506        assert!(count > 40);
507
508        Ok(())
509    }
510
511    #[test]
512    fn parse_forecast() -> Result<()> {
513        let ents = fs::read_dir(concat!(
514            env!("CARGO_MANIFEST_DIR"),
515            "/res/test/weather/forecast"
516        ))?;
517
518        let mut count = 0;
519        for ent in ents {
520            let src = fs::read_to_string(ent?.path())?;
521            let _: ForecastRoot = serde_json::from_str(&src)?;
522            count += 1;
523        }
524        assert!(count > 40);
525
526        Ok(())
527    }
528
529    #[test]
530    fn weather_code() -> Result<()> {
531        assert_eq!("晴", weather_code_to_string("100")?);
532        assert_eq!("晴時々曇", weather_code_to_string("101")?);
533        assert_eq!("雪で雷を伴う", weather_code_to_string("450")?);
534
535        Ok(())
536    }
537
538    #[test]
539    // cargo test ai_readable -- --nocapture
540    fn ai_readable() -> Result<()> {
541        let src = include_str!(concat!(
542            env!("CARGO_MANIFEST_DIR"),
543            "/res/test/weather/overview_forecast/130000.json"
544        ));
545        let ov: OverviewForecast = serde_json::from_str(src)?;
546        let src = include_str!(concat!(
547            env!("CARGO_MANIFEST_DIR"),
548            "/res/test/weather/forecast/130000.json"
549        ));
550        let fcr: ForecastRoot = serde_json::from_str(src)?;
551
552        let obj = weather_to_ai_readable("130000", &ov, &fcr)?;
553        println!("{}", serde_json::to_string_pretty(&obj).unwrap());
554
555        Ok(())
556    }
557
558    #[test]
559    fn edit_distance_test() {
560        assert_eq!(3, edit_distance("", "abc").unwrap());
561        assert_eq!(3, edit_distance("def", "").unwrap());
562        assert_eq!(3, edit_distance("kitten", "sitting").unwrap());
563
564        assert_eq!(4, edit_distance("カラクリ", "ボンゴレ").unwrap());
565        assert_eq!(4, edit_distance("テスト", "テストパターン").unwrap());
566    }
567}