NTTさんの177電話天気予報サービスが終了したので自VoIP局で177サービスを開始する

# はじめに
2025年3月31日を以てNTTさんによる177電話天気予報サービスが終了してしまいました。
もうかけられないと思うと、何故かかけたくなるものです。
では、自分で作ってしまいましょう。

# 気象庁APIから各地の天気予報を取得する

まずは、以前私が作った[天気予報をする装置](https://qiita.com/CIB-MC/items/6be72f96532c67a799ce)でも使った気象庁APIを使って47都道府県庁所在地と小笠原諸島の天気予報を取得しましょう。

とはいえ、気象庁のホームページで使うことしか想定されていないAPIなので、データ取得はやっかいです。

北海道は道なのでまだ予想はしていたのですが、鹿児島県と沖縄県も離島の関係で通常とは異なるパスにデータがありました。

しょうがないので、これらと小笠原諸島についてはAPIのJSON番号やJSON内で目的のデータを探索するのに必要なキーを羅列したデータを用意して対応しました。

```python:177.py(抜粋)
_PREF_OFFICE_READ = {
    "00":{
        "json_number": "130000",
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 3,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 3, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 3, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 3, "temps"],
    },
    "01":{
        "json_number": None,
        "publishingOffice": [2, "srf", "publishingOffice"],
        "timeDefines": [2, "srf", "timeSeries", 0, "timeDefines"],
        "areaName": [2, "srf", "timeSeries", 0, "areas", "area","name"],
        "weatherCodes": [2, "srf", "timeSeries", 0, "areas", "weatherCodes"],
        "weathers": [2, "srf", "timeSeries", 0, "areas", "weathers"],
        "temp_timeDefines": [2, "srf", "timeSeries", 2, "timeDefines"],
        "temps": [2, "srf", "timeSeries", 2, "areas", "temps"],
    },
    "46":{
        "json_number": "460100",
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 0,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 0, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 0, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 0, "temps"],
    },
    "47":{
        "json_number": "471000",
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 0,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 0, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 0, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 0, "temps"],
    },
    "default":{
        "json_number": None,
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 0,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 0, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 0, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 0, "temps"],
    },
}
```

先程のデータをもとにJSONを取得・探索し、必要なデータを取得します。
```python:177.py(抜粋)
def getWeather(pref_id):
    code = "%02d" % pref_id
    read = code if (code in _PREF_OFFICE_READ) else "default"
    keys = _PREF_OFFICE_READ[read]


    json_code = keys["json_number"] if (keys["json_number"]) else ("%s0000" % code)

    uri = _JMA_URI_BASE % json_code
    res = requests.get(uri)
    if not res.status_code == 200:
        print("getWeather error:"+ code)
        time.sleep(5)
        return None

    json = res.json()

    publishingOffice = json
    for key in keys["publishingOffice"]:
        publishingOffice = publishingOffice[key]
    
    timeDefines = json
    for key in keys["timeDefines"]:
        timeDefines = timeDefines[key]
    
    areaName = json
    for key in keys["areaName"]:
        areaName = areaName[key]

    weatherCodes = json
    for key in keys["weatherCodes"]:
        weatherCodes = weatherCodes[key]
    
    weathers = json
    for key in keys["weathers"]:
        weathers = weathers[key]

    temp_timeDefines = json
    for key in keys["temp_timeDefines"]:
        temp_timeDefines = temp_timeDefines[key]
    
    temps = json
    for key in keys["temps"]:
        temps = temps[key]
    
    time.sleep(0.2)
    result = pickle.dumps({
        "pref_id": pref_id,
        "publishing_office": publishingOffice,
        "time_defines": timeDefines,
        "area_name": areaName,
        "weather_codes": weatherCodes,
        "weathers": weathers,
        "temp_time_defines": temp_timeDefines,
        "temps": temps
    })
    time.sleep(5)
    return result 
```

# データ取得と合成音声データ生成を並列処理する

PythonのProcessPoolExecutorを用いて、データ取得と合成音声データ生成を並列処理します。
気象庁APIでのデータ取得は最大ワーカー数1、音声データ生成は最大ワーカー数3としました。
天気のテキストデータは元のままだと読みが不自然になるので、一部置換えを行っています。
データ生成は[nginxでロードバランシングしたVOICEVOX APIサーバー](https://qiita.com/CIB-MC/items/7bb1cfdec13edae7e02d)に任せます。
取ってきたデータは、PBX側のsoxでAsterisk用に変換をかけています。

```python:177.py(抜粋)
weathers_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
voices_executor = concurrent.futures.ProcessPoolExecutor(max_workers=3)
futures = []

# ======= 中略 =======

dt_now = datetime.datetime.now()
hour_num = (dt_now.hour + 1) % 24
hour = "%02d" % hour_num

for pref_id in range(0, 48, 1):
    dir_path = "%s/%02d" % (_DIR_BASE, pref_id)
    if not os.path.isdir(dir_path):
        os.makedirs(dir_path)

for pref_id in range(0, 48, 1):
    future = weathers_executor.submit(getWeather, pref_id)
    futures.append(future)

for future in concurrent.futures.as_completed(futures):
    result = future.result()
    if not result:
        continue

    data = pickle.loads(result)
    pref_id = data["pref_id"]
    publishing_office = data["publishing_office"]
    time_defines = data["time_defines"]
    area_name = data["area_name"]
    weathers = data["weathers"]
    temp_time_defines = data["temp_time_defines"]
    temps = data["temps"]

    dir_path = "%s/%02d" % (_DIR_BASE, pref_id)
    pre_filename = dir_path + ("/%s_pre" % hour)
    pre_str = "%d時の気象庁ホームページより、%s発表、%sの天気予報なのだ。" % (hour_num, publishing_office, area_name)
   
    weather_filename = dir_path + ("/%s_weather" % hour)
    weather_str = ""
    for j in range(0, len(time_defines), 1):
        timedef = time_defines[j]
        match_res = _TIME_REGEXP.match(timedef)
        month_day = str(int(match_res.group(1))) + "月" + str(int(match_res.group(2))) + "日"
        description = weathers[j]
        description = description.replace("後", "のち")
        description = description.replace("所", "ところ")
        description = description.replace("雨", "あめ")
        description = description.replace("　", "")
        weather_str = weather_str + ("%s。%sなのだ。" % (month_day, description))

    temp_filename = dir_path + ("/%s_temp" % hour)
    temp_str = "続いて、気温の予報をお伝えするのだ。"
    for i in range(0, len(temp_time_defines), 2):
        timedef = temp_time_defines[i]
        match_res = _TIME_REGEXP.match(timedef)
        month_day = str(int(match_res.group(1))) + "月" + str(int(match_res.group(2))) + "日。"
        temp_str = temp_str + month_day
        temp_min = int(temps[i])
        temp_max = int(temps[i + 1])
        if temp_min == temp_max:
            temp_str = temp_str + ("最高気温%d度。" % temp_max)
        else:
            temp_str = temp_str + ("最低気温%d度。最高気温%d度。" % (temp_min, temp_max))
    
    voices_executor.submit(createVoice , pre_str, pre_filename)
    voices_executor.submit(createVoice , weather_str, weather_filename)
    voices_executor.submit(createVoice , temp_str, temp_filename)
```

# FreePBX(Asterisk)に読み上げてもらう

あとはAsterisk語で内線の記述を書くだけです。
都道府県コードを後ろにつけると、その都道府県庁所在地の天気予報が聞けます。
または`00`と後ろにつけると小笠原諸島の天気予報が聞けます。

```text:extensions_custom.conf
[from-internal-custom]
include => applications

[from-pstn-custom]
include => applications

[applications]
exten => _177,1,wait(1)
same => n,Answer()
same => n,PlayBack(/var/177/static/credit)
same => n,Set(PREF=40)
same => n(start),NoOp()
same => n,Set(HOUR=${STRFTIME(${EPOCH},,%H)})
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_pre)
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_weather)
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_temp)
same => n,wait(1)
same => n,Goto(start)

exten => _177[0-3][0-9],1,wait(1)
same => n,Answer()
same => n,PlayBack(/var/177/static/credit)
same => n,Set(PREF=${EXTEN:3})
same => n(start),NoOp()
same => n,Set(HOUR=${STRFTIME(${EPOCH},,%H)})
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_pre)
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_weather)
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_temp)
same => n,wait(1)
same => n,Goto(start)

exten => _1774[0-7],1,wait(1)
same => n,Answer()
same => n,PlayBack(/var/177/static/credit)
same => n,Set(PREF=${EXTEN:3})
same => n(start),NoOp()
same => n,Set(HOUR=${STRFTIME(${EPOCH},,%H)})
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_pre)
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_weather)
same => n,PlayBack(/var/177/dynamic/${PREF}/${HOUR}_temp)
same => n,wait(1)
same => n,Goto(start)
```

# 実際の動作
<iframe width="483" height="858" src="https://www.youtube.com/embed/FmQRn1W6Ys4" title="自局で177電話天気予報サービス(改良)　#ずんだもん" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# 本記事内容のライセンス(動画を除く)
```text:LICENSE
MIT License

Copyright (c) 2025 CIB-MC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

# Appendix:Pythonコード全文

```python:177.py
import os
import time
import requests
import re
import subprocess
import datetime
import pickle
import concurrent.futures

_ENGINE_HOST = "http://172.16.0.nn"
_DIR_BASE = "/var/177/dynamic"

_JMA_URI_BASE = "https://www.jma.go.jp/bosai/forecast/data/forecast/%s.json"
_TIME_REGEXP = re.compile('^[0-9]{4}-([0-9]{2})-([0-9]{2})')
_PREF_OFFICE_READ = {
    "00":{
        "json_number": "130000",
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 3,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 3, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 3, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 3, "temps"],
    },
    "01":{
        "json_number": None,
        "publishingOffice": [2, "srf", "publishingOffice"],
        "timeDefines": [2, "srf", "timeSeries", 0, "timeDefines"],
        "areaName": [2, "srf", "timeSeries", 0, "areas", "area","name"],
        "weatherCodes": [2, "srf", "timeSeries", 0, "areas", "weatherCodes"],
        "weathers": [2, "srf", "timeSeries", 0, "areas", "weathers"],
        "temp_timeDefines": [2, "srf", "timeSeries", 2, "timeDefines"],
        "temps": [2, "srf", "timeSeries", 2, "areas", "temps"],
    },
    "46":{
        "json_number": "460100",
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 0,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 0, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 0, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 0, "temps"],
    },
    "47":{
        "json_number": "471000",
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 0,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 0, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 0, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 0, "temps"],
    },
    "default":{
        "json_number": None,
        "publishingOffice": [0, "publishingOffice"],
        "timeDefines": [0, "timeSeries", 0, "timeDefines"],
        "areaName": [0, "timeSeries", 0, "areas", 0,"area","name"],
        "weatherCodes": [0, "timeSeries", 0, "areas", 0, "weatherCodes"],
        "weathers": [0, "timeSeries", 0, "areas", 0, "weathers"],
        "temp_timeDefines": [0, "timeSeries", 2, "timeDefines"],
        "temps": [0, "timeSeries", 2, "areas", 0, "temps"],
    },
}


weathers_executor = concurrent.futures.ProcessPoolExecutor(max_workers=1)
voices_executor = concurrent.futures.ProcessPoolExecutor(max_workers=3)

futures = []

def createVoice(text, filename):
    filename_tmp = filename + ".tmp"
    filename = filename + ".wav"
    audio_query_params = {'text': text, 'speaker': 3}
    audio_query_res = requests.post(_ENGINE_HOST + '/audio_query', params=audio_query_params, data=None)
    if audio_query_res.status_code != 200:
        return

    synth_params = {'speaker': 3}
    synth_res = requests.post(_ENGINE_HOST + '/synthesis', params=synth_params, data=audio_query_res.text.encode('utf-8'))
    if synth_res.status_code != 200:
        return

    with open(filename_tmp, mode='wb') as f:
        f.write(synth_res.content)

    subprocess.run(["sox", filename_tmp, "-r", "8000", "-c", "1", filename])
    subprocess.run(["rm", filename_tmp])
    return

def getWeather(pref_id):
    code = "%02d" % pref_id
    read = code if (code in _PREF_OFFICE_READ) else "default"
    keys = _PREF_OFFICE_READ[read]


    json_code = keys["json_number"] if (keys["json_number"]) else ("%s0000" % code)

    uri = _JMA_URI_BASE % json_code
    res = requests.get(uri)
    if not res.status_code == 200:
        print("getWeather error:"+ code)
        time.sleep(5)
        return None

    json = res.json()

    publishingOffice = json
    for key in keys["publishingOffice"]:
        publishingOffice = publishingOffice[key]
    
    timeDefines = json
    for key in keys["timeDefines"]:
        timeDefines = timeDefines[key]
    
    areaName = json
    for key in keys["areaName"]:
        areaName = areaName[key]

    weatherCodes = json
    for key in keys["weatherCodes"]:
        weatherCodes = weatherCodes[key]
    
    weathers = json
    for key in keys["weathers"]:
        weathers = weathers[key]

    temp_timeDefines = json
    for key in keys["temp_timeDefines"]:
        temp_timeDefines = temp_timeDefines[key]
    
    temps = json
    for key in keys["temps"]:
        temps = temps[key]
    
    time.sleep(0.2)
    result = pickle.dumps({
        "pref_id": pref_id,
        "publishing_office": publishingOffice,
        "time_defines": timeDefines,
        "area_name": areaName,
        "weather_codes": weatherCodes,
        "weathers": weathers,
        "temp_time_defines": temp_timeDefines,
        "temps": temps
    })
    time.sleep(5)
    return result 

dt_now = datetime.datetime.now()
hour_num = (dt_now.hour + 1) % 24
hour = "%02d" % hour_num

for pref_id in range(0, 48, 1):
    dir_path = "%s/%02d" % (_DIR_BASE, pref_id)
    if not os.path.isdir(dir_path):
        os.makedirs(dir_path)

for pref_id in range(0, 48, 1):
    future = weathers_executor.submit(getWeather, pref_id)
    futures.append(future)

for future in concurrent.futures.as_completed(futures):
    result = future.result()
    if not result:
        continue

    data = pickle.loads(result)
    pref_id = data["pref_id"]
    publishing_office = data["publishing_office"]
    time_defines = data["time_defines"]
    area_name = data["area_name"]
    weathers = data["weathers"]
    temp_time_defines = data["temp_time_defines"]
    temps = data["temps"]


    dir_path = "%s/%02d" % (_DIR_BASE, pref_id)
    pre_filename = dir_path + ("/%s_pre" % hour)
    pre_str = "%d時の気象庁ホームページより、%s発表、%sの天気予報なのだ。" % (hour_num, publishing_office, area_name)
   
    weather_filename = dir_path + ("/%s_weather" % hour)
    weather_str = ""
    for j in range(0, len(time_defines), 1):
        timedef = time_defines[j]
        match_res = _TIME_REGEXP.match(timedef)
        month_day = str(int(match_res.group(1))) + "月" + str(int(match_res.group(2))) + "日"
        description = weathers[j]
        description = description.replace("後", "のち")
        description = description.replace("所", "ところ")
        description = description.replace("雨", "あめ")
        description = description.replace("　", "")
        weather_str = weather_str + ("%s。%sなのだ。" % (month_day, description))

    temp_filename = dir_path + ("/%s_temp" % hour)
    temp_str = "続いて、気温の予報をお伝えするのだ。"
    for i in range(0, len(temp_time_defines), 2):
        timedef = temp_time_defines[i]
        match_res = _TIME_REGEXP.match(timedef)
        month_day = str(int(match_res.group(1))) + "月" + str(int(match_res.group(2))) + "日。"
        temp_str = temp_str + month_day
        temp_min = int(temps[i])
        temp_max = int(temps[i + 1])
        if temp_min == temp_max:
            temp_str = temp_str + ("最高気温%d度。" % temp_max)
        else:
            temp_str = temp_str + ("最低気温%d度。最高気温%d度。" % (temp_min, temp_max))
    
    voices_executor.submit(createVoice , pre_str, pre_filename)
    voices_executor.submit(createVoice , weather_str, weather_filename)
    voices_executor.submit(createVoice , temp_str, temp_filename)

weathers_executor.shutdown(wait=True)
voices_executor.shutdown(wait=True)
```