MSBOOKS

プログラミングとか深層学習とか日常の出来事とか

自動情報収集アプリを作ってみた(天気, 株価, 為替, 遅延情報, 主要ニュースの自動取得)

はじめに

朝起きたときに天気見て、遅延情報見て、ニュース見て…、色々な情報がちらばっていて忙しい朝にいちいち色々なページを開くのが面倒!って思ったので、朝これを見れば全てがわかるアプリを作ってみました!
コードはすべて載せますが、webアプリ開発歴まだ2年なので、意味不明なところがあるかもしれないのはご了承ください。動くってところしか確認してないです…。webアプリにした理由はスマホからも手軽にアクセスできるようにするためにしました。
完成形のイメージがこんな流れです。
1.ページを開いて、get timesを押すと、

2.ロード中に色々なところからデータをかき集めてくれて、

3.いい感じに表示してくれて、見るのも面倒なときのために音声でも話してくれる

※AsahinaTimesの理由:合成音声で読み上げる人の名前を決めるときに「朝日奈」という名前が朝っぽいのと、かわいらしい名前だったので採用しました。timesはNew York Timesとかで使われているTimes(新聞という意味)から取っていて、新聞の一面を見ているイメージです。朝日奈さんが音声で伝えてくれるTimesです。

概要

動作環境

動作環境は以下のようになっています.

  • Python 3.10.9 (その他ライブラリのバージョンは省略)
  • OS : Windows10 (64bit)
  • CPU : Intel Core i5 10400
  • Memory : 16GB
  • GPU : NIVIDIA GeForce GTX 1060 6GB

構成図

今回メイン処理にはPythonのFast APIを利用し、UI側はHTML、JavaScriptを利用しました。音声生成はvoicevoxを利用しています。
voicevox.hiroshiba.jp

全体の流れとしては、ユーザがボタンを押したら、FastAPI内で各種情報を取りに行き、集まった情報をいい感じにまとめてUIに表示され、さらに音声でもその情報を聞くことができるようにするという感じです。

ディレクトリ構造

root/
 ├ infomation/
 │ ├  exchange_rate.py
 │ ├  news.py
 │ ├  stock_price.py
 │ ├  train_operation.py
 │ └  weather.py
 ├ util/
 │ ├  util.py
 │ └  voicevox_engine.py
 ├ static/
 │ ├  controller.js
 │ ├  favicon.ico
 │ ├  index.html
 │ ├  style.css
 │ ├  loading.gif
 │ └  その他画像諸々
 ├ main.py
 └ open_jtalkの諸々

ディレクトリ構成は上記のような感じです。infomationディレクトリにあるコードで色々な情報を取ってきてくれます。utilには定数値などを入れてます。staticにはUI側のHTMLやJavasciprt、画像等が入ってます。rootにあるmain.pyがFastAPIのメインのコードです。pipなどの各種ライブラリは色々使っているのでエラーになったら適宜インストールしてください。同じ構成にすればおそらく動くようになっているはずです。
voicevoxの諸々のコードや設定は以下のgithubの手順を参考に設定しました。
github.com

以下のqiitaの記事も参考になるかもです。
qiita.com

バックエンドコード


バックエンドの詳しい構成はこんな感じです。controller.jsからAPI GET /messageが呼ばれると各種情報用の関数を呼び、その関数が色々なサイトからデータを取ってきて、読み上げ用のテキストデータ+色々な情報JSONを返します。このうち読み上げ用textをVOICEVOXに投げて、音声データを返してもらい、その音声データと先程もらった色々な情報JSONをまとめてcontroller.jsに返します。あとはフロントエンド側でいい感じに仕上げるという流れです。

FastAPIメイン

  • main.py
import datetime
import matplotlib

from infomation.stock_price import get_stock_price_infomation
from infomation.weather import get_wether_infomation
from infomation.exchange_rate import get_exchange_rate_infomation
from infomation.train_operation import get_train_operation_information
from infomation.news import get_news_infomation

from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
# from pprint import pprint
from starlette.staticfiles import StaticFiles
from util.util import *
from util.voicevox_engine import text_to_speech
from voicevox_core import AccelerationMode, VoicevoxCore, METAS

# matplotlibのバックエンドを指定
matplotlib.use('Agg')

# fastapiの設定
app = FastAPI()

# CORSミドルウェア設定
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8000"],  # 許可するオリジンを指定
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 静的ファイル(HTMLファイル)を提供するためのディレクトリを指定
static_dir: Path = Path("static")
app.mount("/static", StaticFiles(directory=static_dir, html=True), name="static")

# VoicevoxCoreの初期化(ローカルコードを使用する場合)
if USE_VOICEVOX_OPTION == UseVoicevoxOption.LOCALCODE:
    # どのspeaker_idを使いたいか調べる場合はこのprintを使う
    # pprint(METAS)
    core: VoicevoxCore = VoicevoxCore(acceleration_mode=AccelerationMode.GPU, open_jtalk_dict_dir=Path("open_jtalk_dic_utf_8-1.11"))
    if not core.is_model_loaded(SPEAKER_ID):  # モデルが読み込まれていない場合
        core.load_model(SPEAKER_ID)  # 指定したidのモデルを読み込む

"""
HTMLを返す
"""
@app.get("/", response_class=HTMLResponse)
def index():
    return FileResponse(static_dir / "index.html")

"""
メッセージを生成する
"""
@app.get("/message")
def get_message():
    # 本日の日時、明日の日時を取得
    now: datetime = datetime.datetime.now()
    tomorrow: datetime = now + datetime.timedelta(days=1)
    
    text = ""
    text += f"{greet_by_time(now)}、朝日奈です。\n"
    text += f"本日は{now.year}年{now.month}月{now.day}日{WEEKDAY_NAMES[now.weekday()]}、時刻は{now.hour}時{now.minute}分です。\n"
    text += "天気、株価、為替、遅延情報、主なニュースについてお伝えします。\n"

    # 天気情報を取得
    weather_text, weather_info = get_wether_infomation()
    text += weather_text

    # 株価情報を取得
    text += "\n次に株価情報です。\n"
    stock_price1_text, stock_price1_info = get_stock_price_infomation("^N225", "日経平均")
    text += stock_price1_text
    stock_price2_text, stock_price2_info = get_stock_price_infomation("9984.T", "ソフトバンクグループ")
    text += stock_price2_text
    
    # 為替情報を取得
    text += '\n次に為替情報です。\n'
    exchange_rate1_text, exchange_rate1_info = get_exchange_rate_infomation("USD", "ドル")
    text += exchange_rate1_text
    exchange_rate2_text, exchange_rate2_info = get_exchange_rate_infomation("EUR", "ユーロ")
    text += exchange_rate2_text

    # 遅延情報の取得
    text += "\n次に遅延情報です。\n"
    train_operation_text, train_operation_info = get_train_operation_information()
    text += train_operation_text
    
    # ニュースの取得
    text += "\n最後にニュース情報です。\n"
    news_text, news_info = get_news_infomation()
    text += news_text
    
    text += "\nこちらで以上になります。\n"
    text += f"{last_message_by_time(now)}"
    print("text作成完了")

    try:
        voice_url = "./static/output.wav"
        voice_data = None
        # 音声合成を行う
        if USE_VOICEVOX_OPTION == UseVoicevoxOption.LOCALCODE:
            voice_data = core.tts(text=text, speaker_id=SPEAKER_ID)
        elif USE_VOICEVOX_OPTION == UseVoicevoxOption.ENGINE:
            voice_data = text_to_speech(text=text, speaker=SPEAKER_ID)
        else:
            print("use_voicevox_optionを正しく選択してください")
            
        with open(voice_url, "wb") as f:
            f.write(voice_data)  # ファイルに書き出す
        print("合成音声作成完了")
    except Exception as e:
        voice_url = ""
        print(e.message)
        print("合成音声エラー")

    return {
        "text": text, 
        "today_with_time": f"{now.year}年{now.month}月{now.day}日 ({WEEKDAY_NAMES[now.weekday()]}) {now.hour}時{now.minute}分",
        "today": f"{now.year}年{now.month}月{now.day}日 ({WEEKDAY_NAMES[now.weekday()]})",
        "tomorrow": f"{tomorrow.year}年{tomorrow.month}月{tomorrow.day}日 ({WEEKDAY_NAMES[tomorrow.weekday()]})",
        "weather_info": weather_info,
        "stock_price1_info": stock_price1_info,
        "stock_price2_info": stock_price2_info,
        "exchange_rate1_info": exchange_rate1_info,
        "exchange_rate2_info": exchange_rate2_info,
        "train_operation_info": train_operation_info,
        "news_info": news_info,
        "voice_url": voice_url
    }

APIのルートが呼ばれたらstaticに入っているHTMLコードを返し、APIの"/message"が呼ばれたら情報をかき集めて、音声を生成してくれます。UI上からも情報が見れるように、かき集めた情報はすべてjsonで返します。株価の銘柄と為替の通貨は無駄にここで選べるようにしました。みなさんが見たい銘柄をここでセットしてみてください。
音声生成の際に、VoicevoxCoreのエンジンを使う場合と、githubからコードをそのままもってくる場合の2パターンがあったので、どっちにも対応しました。ちなみにエンジンを使うパターンは以下のページを参考にしました。
note.com
一点補足で、このコードはGPUがある前提になっています。理由としては、音声合成する文章量が非常に多いのでCPUだけだと相当な時間がかかってしまうためです。

各種設定値は次の項でコードを載せます。

各種設定値情報

  • util.py
import datetime
from enum import Enum

class UseVoicevoxOption(str, Enum):
    """
    VOICEVOXはどの方法を使うかの設定値
    """

    # エンジンを使用する
    # この場合エンジンのディレクトリで以下を先に実行しておく必要がある
    # run.exe --use_gpu
    ENGINE = "ENGINE"
    
    # ローカルコードを使用する
    # 事前準備は不要
    LOCALCODE = "LOCALCODE"

# VoicevoxCoreで使用する設定値
USE_VOICEVOX_OPTION: UseVoicevoxOption = UseVoicevoxOption.LOCALCODE
SPEAKER_ID: str = 20

# datetimeの曜日情報
WEEKDAY_NAMES: list[str] = ["月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日", "日曜日"]

# はじめの挨拶(時間ごとに変動)
def greet_by_time(current_time: datetime) -> str:
    if 5 <= current_time.hour < 10:
        return "おはようございます"
    elif 10 <= current_time.hour < 17:
        return "こんにちは"
    else:
        return "こんばんは"

# 締めの挨拶(時間ごとに変動)
def last_message_by_time(current_time: datetime) -> str:
    if 5 <= current_time.hour < 12:
        return "今日も一日頑張りましょう!"
    elif 12 <= current_time.hour < 14:
        return "午後からも頑張りましょう!"
    elif 14 <= current_time.hour < 17:
        return "今日も残り少し、頑張りましょう!"
    elif 17 <= current_time.hour < 24:
        return "今日も一日お疲れ様でした!"
    else:
        return "夜遅くまでご苦労さまです!"

各種設定値を入れたものです。

  • voicevox_engine.py
import requests
import json

host = "127.0.0.1" 
port = 50021

def audio_query(text, speaker):
    query_payload = {"text": text, "speaker": speaker}
    r = requests.post(f"http://{host}:{port}/audio_query", 
                    params=query_payload)
    if r.status_code == 200:
        query_data = r.json()
        return query_data
    return None

def synthesis(speaker, query_data):
    synth_payload = {"speaker": speaker}
    r = requests.post(f"http://{host}:{port}/synthesis", params=synth_payload, 
                        data=json.dumps(query_data))
    if r.status_code == 200:
        return r.content
    return None

def text_to_speech(text, speaker = 20):
    query_data = audio_query(text,speaker)
    voice_data=synthesis(speaker,query_data)
    
    return voice_data

voicevoxのエンジンを操作するためのコードです。完成するとvoice_dataが返ってきます。

天気情報取得

  • infomation/weather.py
import requests
from requests import Response

"""
天気情報を取得する
city_numは都市番号を入れる(130010は東京都東京)
APIの詳細は以下を参照
https://weather.tsukumijima.net/
"""
def get_wether_infomation(city_num: int = 130010):
    text = ""
    try:
        # 天気APIを呼び出す
        response: Response = requests.get(f"https://weather.tsukumijima.net/api/forecast/city/{city_num}")
        response.raise_for_status()
        json = response.json()
    except Exception as e:
        # 読み込めなかった場合は、メッセージだけ返す
        print(e.message)
        text = "気象庁が発信しているデータが読み込めませんでした。すみません。"
        return text, {
            "location": "エラー",
            "today_weather": None,
            "today_temp": None,
            "today_mark": None,
            "tomorrow_weather": None,
            "tomorrow_temp": None,
            "tomorrow_mark": None
        }
    
    # どこの天気情報かを示す
    text += f'気象庁が発信している{json["title"]}情報です。'
    
    # 本日の天気を読み込む
    today_forecast = json["forecasts"][0]
    text += f'本日の天気は{today_forecast["telop"]}で、'
    
    # 15時頃を過ぎると最高気温情報がなくなるため条件分岐させる
    if (today_forecast["temperature"]["max"]["celsius"] == None):
        text += f'最高気温は読み込めませんでした。すみません。'
        today_forecast["temperature"]["max"]["celsius"] = "--"
    else:
        text += f'最高気温は{today_forecast["temperature"]["max"]["celsius"]}度です。'

    # 明日の天気を読み込む
    tomorrow_forecast = json["forecasts"][1]
    text += f'明日の天気は{tomorrow_forecast["telop"]}で、'
    
    # ここの最高気温は基本は読み込めるはずだが念のため条件分岐させる
    if (tomorrow_forecast["temperature"]["max"]["celsius"] == None):
        text += f'最高気温は読み込めませんでした。すみません。'
        tomorrow_forecast["temperature"]["max"]["celsius"] = "--"
    else:
        text += f'最高気温は{tomorrow_forecast["temperature"]["max"]["celsius"]}度です。'
        
    # 気象の詳細情報、分量が多いため省略
    # text += f'気象庁からの文章を読み上げます。{json["description"]["text"]'
    # text += ' 以上が天気情報でした。  \n'
    
    return text, {
        "location": json["title"].split("の")[0],
        "today_weather": today_forecast["telop"],
        "today_temp": today_forecast["temperature"]["max"]["celsius"],
        "today_mark": today_forecast["image"]["url"],
        "tomorrow_weather": tomorrow_forecast["telop"],
        "tomorrow_temp": tomorrow_forecast["temperature"]["max"]["celsius"],
        "tomorrow_mark": tomorrow_forecast["image"]["url"],
    }

天気情報を読み込みます。APIの詳細は以下を利用しました。
weather.tsukumijima.net

株価情報取得

  • infomation/stock_price.py
import math
import datetime as datetime
import matplotlib.pyplot as plt
import pandas as pd

from yahoo_finance_api2 import share
from yahoo_finance_api2.exceptions import YahooFinanceError

def stock_price_percentage_message(percentage: str) -> str:
    text = ""
    # 文字列から数値を取得
    percentage = float(percentage.strip('%'))
    # 前日終値が読み込めない等の問題でpercentageが計算できなかった場合
    if math.isnan(percentage):
        text += "前回終値との比較はできませんでした。すみません。"
    else:
        if percentage > 0:
            text += f'前回終値と比較するとプラス{percentage}%上昇しています。'
        elif percentage < 0:
            # 読み上げの時にマイナスを消すためにマイナスを掛け算する
            percentage *= -1
            text += f'前回終値と比較するとマイナス{percentage}%下落しています。'
        else:
            text += f'前回終値と同じ株価になっています。'

    return text

"""
株価情報を取得する
share_numには銘柄番号、share_nameには銘柄の名称を入れる
名称は表示名になるため何でも良い
株価情報取得にはyahoo_finance_api2を使用
"""
def get_stock_price_infomation(share_num: str = '^N225', share_name: str = '日経平均'):
    text = ""
    try:
        my_share = share.Share(share_num)
        # 株価を読み込む
        # 7日前までのデータを5分おきに取得する(取引時間外の場合データが取得できないため多めに取ってくる)
        # 1日おきのデータも取得する理由は、正確な終値のデータが欲しいため
        # ※ 5分おきのデータの最後の要素は14:55~14:59までのデータしかなく15:00の終値が取得できない
        price_data_5min = my_share.get_historical(share.PERIOD_TYPE_DAY, 7, share.FREQUENCY_TYPE_MINUTE, 5)
        price_data_1day = my_share.get_historical(share.PERIOD_TYPE_DAY, 7, share.FREQUENCY_TYPE_DAY, 1)
    except YahooFinanceError as e:
        print(e.message)
        text = f"{share_name}の株価情報は読み込めませんでした。すみません。"
        return text, {
            "stock_price_name": share_name,
            "latest_close_price": "エラー",
            "latest_close_price_date": None,
            "percentage_change": None
        }

    # 日本時間にするためにプラス9時間にし、datetimeをindexとして登録する
    df_price_data_5min = pd.DataFrame(price_data_5min)
    df_price_data_5min["datetime"] = pd.to_datetime(df_price_data_5min.timestamp, unit="ms") + datetime.timedelta(hours=9)
    df_price_data_5min.index = pd.to_datetime(df_price_data_5min['datetime'], format='%Y/%m月%d日-%H:%M').values

    # 1日おきのデータに対しても同様の処理をする
    df_price_data_1day = pd.DataFrame(price_data_1day)
    df_price_data_1day ["datetime"] = pd.to_datetime(df_price_data_1day.timestamp, unit="ms") + datetime.timedelta(hours=9)
    df_price_data_1day.index = pd.to_datetime(df_price_data_1day['datetime'], format='%Y/%m月%d日-%H:%M').values

    now = datetime.datetime.now()
    # 最新日のデータを取得
    latest_price_data_5min = df_price_data_5min.iloc[-1]
    latest_price_data_1day = df_price_data_1day.iloc[-1]
    # 最新のデータが15時以降のものもしくは、昨日のデータであれば15:00の終値を5分おきのデータに追記する
    if((latest_price_data_1day['datetime'].hour >= 15) or (latest_price_data_5min['datetime'].day < now.day)):
        data_to_add = {
            'close': latest_price_data_1day["close"],
            'datetime': latest_price_data_5min['datetime'] + datetime.timedelta(minutes=5)
        }
        df_data_to_add = pd.DataFrame([data_to_add])
        df_data_to_add.index = pd.to_datetime(df_data_to_add['datetime'], format='%Y/%m月%d日-%H:%M').values
        df_price_data_5min = pd.concat([df_price_data_5min, df_data_to_add])

    # 最新の終値を取得
    latest_close_price = latest_price_data_1day['close']

    # 前日の終値を取得
    previous_close_price = df_price_data_1day.iloc[-2]['close']
    
    # 最新のデータ時刻-6時間~最新のデータ時刻の間の株価を取り出してグラフ化する
    # マイナス6時間の理由は株式市場が閉じる15時の時点で、開場時間の9時からのデータが取得できるようにするため
    ax = df_price_data_5min[df_price_data_5min.iloc[-1]['datetime'] - datetime.timedelta(hours=6) : df_price_data_5min.iloc[-1]['datetime']].plot(x="datetime",y="close")
    plt.title(f"The stock price of {share_num}")
    # 前日終値が読み込めていたら、破線を描く
    if previous_close_price is not None:
        ax.axhline(y=previous_close_price, color='r', linestyle='--', label='Previous Day Close')
    # HTML表示用に書き出す
    plt.savefig(f"./static/{share_num}_stock_price.png", format="png", dpi=65)
    
    # 前日終値との変化率を計算
    percentage_change = ((latest_close_price - previous_close_price) / previous_close_price) * 100
    
    # 現在株価と変化率のメッセージを作成
    latest_price_data_datetime = latest_price_data_1day['datetime']
    text += f'{share_name}株価は、'
    text += f'{latest_price_data_datetime.year}年{latest_price_data_datetime.month}月{latest_price_data_datetime.day}日{latest_price_data_datetime.hour}時{latest_price_data_datetime.minute}分時点で、'
    text += f'{round(latest_close_price, 2)}円となっています。'
    text += stock_price_percentage_message(f'{percentage_change:.2f}%')

    return text, {
        "stock_price_name": share_name,
        "latest_close_price": f"{round(latest_close_price, 2)}",
        "latest_close_price_date": f'{latest_price_data_datetime.year}年{latest_price_data_datetime.month}月{latest_price_data_datetime.day}日{latest_price_data_datetime.hour}時{latest_price_data_datetime.minute}分',
        "percentage_change": f'+{percentage_change:.2f}%' if percentage_change >= 0 else f'{percentage_change:.2f}%',
        "stock_price_image": f'./static/{share_num}_stock_price.png'
    }

株価情報を読み込みます。ここはかなりややこしいことしています。株価情報取得にはyahoo_finance_api2を使用しているのですが、1日おきのデータと、グラフ表示用に5分おきのデータの両方を取得しています。5分おきのデータのみでも良いのですが、この情報にはその日の終値が格納されていないため、それを取得するために1日おきのデータも併せて取得しているという感じになります。5分おきのデータを使って株価変動グラフを作ってます(これがやりたかっただけ)。生成されたグラフは画像として書き出して、そのリンクを返しています。

為替情報取得

  • infomation/exchange_rate.py
import datetime
import pandas_datareader.data as pdr
import yfinance as yf

# pdrでyfが使えるように設定
# 参考:https://yottagin.com/?p=9963
yf.pdr_override()

def get_date_range(days: int) -> tuple[str, str]:
    dt_end = datetime.datetime.now()
    dt_start = dt_end - datetime.timedelta(days=days)
    return dt_start.strftime('%Y-%m-%d'), dt_end.strftime('%Y-%m-%d')

def get_exchange_rate(pair: str) -> float:
    # 7日前まで取得する(取引時間外の場合データが取得できないため)
    start, end = get_date_range(7)
    
    # 為替pairを所定の形に変更
    code = f'{pair}=X'

    # dataの取得
    data = pdr.get_data_yahoo(code, start, end)

    # 最新日の終値を返す
    return data['Close'][-1]

"""
為替情報を取得する
exchangeには円から変換したい通貨コードを、nameには通貨の名称を入れる
名称は表示名になるため何でも良い
為替情報取得にはyfinanceを使用
"""
def get_exchange_rate_infomation(exchange: str = "USD", name: str = "ドル"):
    try:
        exchange_rate = round(get_exchange_rate(f"{exchange}JPY"), 2)
        text = f'{name}に対しては現在1{name}{exchange_rate}円で取引されています。'
    except Exception as e:
        print(e.message)
        text = f"{name}に対する為替情報が読み込めませんでした。すみません。"
        exchange_rate = None
        
    return text, {
        "name": name,
        "exchange_rate": exchange_rate
    }

為替情報を読み込みます。為替情報取得にはyfinanceを使用しています。基本的には、yfinanceのAPIの形に合わせて、投げているだけです。為替情報もグラフ化できそうですが、株価の方で力尽きたので、こちらは最新の値だけにしました。

遅延情報取得

  • infomation/train_operation.py
from typing import List
import requests
from bs4 import BeautifulSoup

def categorize_routes(route_list: dict) -> tuple[list, list, list]:
    delay_list = []
    suspend_list = []
    trouble_list = []

    for name, status in route_list.items():
        if status in ["運転状況", "運転情報", "列車遅延", "運転再開"]:
            delay_list.append(name)
        elif status == "運転見合わせ":
            suspend_list.append(name)
        else:
            trouble_list.append(name)
    
    return suspend_list, delay_list, trouble_list

def generate_message(route_type: str, route_names: List[str]) -> str:
    if not route_names:
        return ""
    
    message = f"{route_type}は次のとおりです。"
    message += "、".join(route_names)
    message += "、以上です。"
    return message

"""
遅延情報を取得する
area_numには地域の番号を入れる(関東地方は4)
遅延情報取得は以下のyahooのHTMLページから遅延している路線をピックアップしている
https://transit.yahoo.co.jp/diainfo
"""
def get_train_operation_information(area_num: int = 4):
    html_data = None
    try:
        # 運行状況のURL
        url = f'https://transit.yahoo.co.jp/diainfo/area/{area_num}'
        html_data = requests.get(url)
        html_data.raise_for_status()
    except Exception as e:
        print(e.message)
        text = "遅延情報が読み込めませんでした。すみません。"
        return text, {
            "suspend_list": None, 
            "delay_list": None, 
            "trouble_list": None
        }

    # BeautifulSoupで解析
    soup = BeautifulSoup(html_data.text, 'html.parser')

    # 現在運行情報のある路線の<li>要素を抜き出す
    current_routes = soup.find('dt', class_='title trouble').find_next('dd').find_all('li')

    route_list = {}
    # 路線名とその状態を抜き出す
    for route in current_routes:
        route_name = route.find('dt', class_='title').get_text(strip=True)
        alert = route.find('dd', class_=lambda x: x and 'subText colTrouble' in x).text
        route_list[route_name] = alert

    if len(route_list) == 0:
        text = "現在、遅延情報はありません。"
    else:
        # 路線名と状態が入っているリストを運転見合わせ、遅延、お知らせで分類する
        suspend_list, delay_list, trouble_list = categorize_routes(route_list)
        text = ""
        text += generate_message("運転を見合わせている路線", suspend_list)
        text += generate_message("遅延をしている路線", delay_list)
        text += generate_message("何らかのお知らせが出ている路線", trouble_list)

    return text, {
        "suspend_list": suspend_list, 
        "delay_list": delay_list, 
        "trouble_list": trouble_list
    }

遅延情報を読み込みます。遅延情報取得は以下のyahooページからHTMLを解析して、遅延している路線をピックアップしました。ちなみにtrouble_listに入っている何らかのお知らせが出ている路線とは、運転見合わせ、運転状況、運転情報、列車遅延、運転再開のいずれでもない状態が表示されていた時に入ります。どちらかというと想定外のものが入るイメージです。
transit.yahoo.co.jp

ニュース取得

  • infomation/news.py
from bs4 import BeautifulSoup
import requests

"""
ニュース情報を取得する
ニュース情報取得は以下のyahooのHTMLページから主要の項目からピックアップしている
https://news.yahoo.co.jp/
"""
def get_news_infomation():
    html_data = None
    try:
        # ニュースのURL
        url = 'https://news.yahoo.co.jp/'
        html_data = requests.get(url)
        html_data.raise_for_status()
    except Exception as e:
        print(e.message)
        text = "ニュース情報が読み込めませんでした。すみません。"
        return text, {"news_list": ["読み込めませんでした"]}

    # BeautifulSoupでHTMLを解析
    soup = BeautifulSoup(html_data.text, 'html.parser')

    # ニュースに関連するセクションを特定
    news_section = soup.find('section', class_='topics')

    # セクション内のすべてのリンクを取得
    news_links = news_section.find_all('a')

    # ニュースのテキストを取り出す
    news_text_list = [link.get_text() for link in news_links]

    news_list = []
    text = "この時間の主要なニュースは次の通りです。"
    for itr, news_text in enumerate(news_text_list):
        if (news_text == "もっと見る" or itr >= 6):
            break
        text += f"「{news_text}」、"
        news_list.append(news_text)
    text += f"などのニュースが入っています。\n" 
    return text, {
        "news_list": news_list
    }

ニュース情報を読み込みます。ニュース情報取得は以下のyahooページからHTMLを解析して、主要ニュースの項目からピックアップしました。おそらく主要の項目に8つ程ニュースがあるのですが、たくさん表示すると情報量が増えそうだったので、6つに制限しました。HTML解析時に「もっと見る」が入ってしまったので、6つ以内にそれが来た場合は弾く処理を入れています。
news.yahoo.co.jp

バックエンド(FastAPI)側のコードは以上です。

フロントエンドコード

フロントエンドは知識が特に少ないので意味分からないコードが多いと思います。ご了承ください。
全体の構成図は以下のような感じです。

メインのindex.htmlを中心に、ボタンが押されたらcontroller.jsにあるget_times関数が呼ばれ、Fast API側に情報を取りに行き、返ってきたjsonをいい感じにしてindex.htmlに埋め込むという流れです。

画面表示用HTMLコード

  • static/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="static/style.css" />
    <link href="static/favicon.ico" rel="icon">
    <title>AsahinaTimes</title>
</head>

<body>
<div class="body"></div>
    <div class="grad"></div>
    <div class="header">
        <div id="header">Asahina<span>Times</span></div>
    </div>
    <div class="start">
        <input id="getTimes" type="button" value="GET TIMES" onclick="get_times()">
        <div id="loading"></div>
    </div>
    <div class="data" id="timesdata"></div>
    <script src="static/controller.js"></script>
</body>
</html>

HTMLのコードとしてはシンプルにしました。

↑のような画面はここで作ってます。タイトルが表示されて、get timesのボタンを押せるようにしてます。get timesはボタンが押されるとcontroller.jsにかかれているget_times関数が呼ばれます。
色々なところから取得した情報を入れるHTMLはjavascript側から、id="timesdata"のところに入れてます。

CSSコード

  • static/style.css
@import url(https://fonts.googleapis.com/css?family=Exo:100,200,400);

/* 背景設定 */
body{
  margin: 0;
  padding: 0;
  background: #fff;
  color: #fff;
  font-family: "Times New Roman", Arial;
  font-size: 12px;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}

.body{
  position: absolute;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  width: auto;
  height: auto;
  background-image: url(./背景画像.jpg);
  background-size: cover;
  -webkit-filter: blur(5px);
  z-index: 0;
}

.grad{
  position: absolute;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  width: auto;
  height: auto;
  background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(0,0,0,0)), color-stop(100%,rgba(0,0,0,0.65))); /* Chrome,Safari4+ */
  z-index: 1;
  opacity: 0.7;
}

/* スタートページ画面 */
.header{
  position: absolute;
  top: calc(50% - 35px);
  left: calc(50% - 305px);
  z-index: 2;
}

.header div{
  float: left;
  color: #fff;
  font-family: 'Exo', sans-serif;
  font-size: 35px;
  font-weight: 200;
}

.header div span{
  color: #5379fa !important;
}

.start{
  position: absolute;
  top: calc(50% - 50px);
  left: calc(50% - 50px);
  height: 150px;
  width: 350px;
  padding: 10px;
  z-index: 2;
}

.start input[type=button]{
  width: 260px;
  height: 35px;
  background: #fff;
  border: 1px solid #fff;
  cursor: pointer;
  border-radius: 2px;
  color: #a18d6c;
  font-family: 'Exo', sans-serif;
  font-size: 16px;
  font-weight: 400;
  padding: 6px;
  margin-top: 10px;
}

.start input[type=button]:hover{
  opacity: 0.8;
}

/* GET TIMESボタンを押下後の画面 */
.data {
  display: flex;
  flex-direction: row;
  z-index: 2;
}

/* 左右に分割して情報を表示できるように設定 */
.left-side,
.right-side {
    flex: 1;
    padding: 20px;
    color: #fff;
}

.left-side {
  min-width: 460px; 
  max-width: 460px;
}

.right-side {
  min-width: 460px; 
  max-width: 460px;
}

/* 左側半分のエリア */
/* 日付情報エリア */
.today {
  position: relative;
  padding: 0.5rem 3rem 0.5rem 3rem;
  color: #fff;
  border-radius: 100vh 100vh 100vh 100vh;
  background: #5591b9;
  width:100%;
  text-align: center;
}

/* 天気情報エリア */
.container {
  display: flex;
  flex-direction: row;
  border-radius: 25px;
	color: #ffffff;
	height: 250px;
}

:root {
	--gradient: linear-gradient( 135deg, #72EDF2 10%, #5151E5 100%);
}

* {
	-webkit-box-sizing: border-box;
	        box-sizing: border-box;
	line-height: 1.25em;
}

.weather-side1 {
  flex: 1;
	position: relative;
	height: 100%;
	border-radius: 25px;
	background-image: url("./天気予報背景画像1.jpg");
  width: calc(50% - 5px);
  margin-right: 2px;
	-webkit-box-shadow: 0 0 20px -10px rgba(0, 0, 0, 0.2);
	        box-shadow: 0 0 20px -10px rgba(0, 0, 0, 0.2);
}

.weather-side2 {
  flex: 1;
	position: relative;
	height: 100%;
	border-radius: 25px;
	background-image: url("./天気予報背景画像2.jpg");
  width: calc(50% - 5px);
  margin-left: 2px;
	-webkit-box-shadow: 0 0 20px -10px rgba(0, 0, 0, 0.2);
	        box-shadow: 0 0 20px -10px rgba(0, 0, 0, 0.2);
}

.weather-gradient {
	position: relative;
	width: 100%;
	height: 100%;
	top: 0;
	left: 0;
	background-image: var(--gradient);
	border-radius: 25px;
	opacity: 0.8;
}

.date-container {
	position: absolute;
	top: 25px;
	left: 25px;
}

.date-dayname {
	margin: 0;
}

.date-day {
	display: block;
}

.location {
	display: inline-block;
	margin-top: 10px;
}

.location-icon {
	display: inline-block;
	height: 0.8em;
	width: auto;
	margin-right: 5px;
}

.weather-container {
	position: absolute;
	bottom: 25px;
	left: 25px;
}

.weather-icon.feather {
	height: 60px;
	width: auto;
}

.weather-temp {
	margin: 0;
	font-weight: 700;
	font-size: 4em;
}

.weather-desc {
	margin: 0;
}

/* 為替情報エリア */
.exchange-rate-info {
  padding: 0;
  position: relative;
}

.exchange-rate-info-list {
  position: relative;
  padding: 0.1em 0.1em 0.1em 1em;
  line-height: 1.5em;
  background: #333333;
  margin-bottom: 3px;
  list-style-type: none!important;
  border-radius: 15px 15px 15px 15px;
}

/* 遅延情報エリア */
.train-operation-info {
  padding: 0;
  position: relative;
}

.train-operation-info-red {
  color: #dd2d2d;
  border-left: solid 6px #dd2d2d;
  background: #f1f8ff;
  margin-bottom: 3px;
  line-height: 1.5;
  padding: 0.1em 0.1em 0.1em 1em;
  list-style-type: none!important;
}

.train-operation-info-blue {
  color: #2d8fdd;
  border-left: solid 6px #2d8fdd;
  background: #f1f8ff;
  margin-bottom: 3px;
  line-height: 1.5;
  padding: 0.1em 0.1em 0.1em 1em;
  list-style-type: none!important;
}

.train-operation-info-yellow {
  color: #a7b815;
  border-left: solid 6px #a6b420;
  background: #f1f8ff;
  margin-bottom: 3px;
  line-height: 1.5;
  padding: 0.1em 0.1em 0.1em 1em;
  list-style-type: none!important;
}

/* ニュース情報エリア */
.news-info {
  position: relative;
  padding: 1em 0.1em 1em 1em;
  list-style-type: none!important;
  white-space: pre-line;
  background: #f1f8ff;
  box-shadow: 0px 0px 0px 5px #f1f8ff;
  border: dashed 2px #668ad8;
  border-radius: 9px;
  margin-left: 10px;
  margin-right: 10px;
  color: #2d8fdd;
}

/* 右側半分のエリア */
/* 株価情報エリア */
.stock-price-info {
  position: relative;
  line-height: 1.5;
  padding: 0.1em 0.5em 0.1em 1.7em;
  list-style-type: none!important;
  background: -webkit-linear-gradient(top, rgb(110, 226, 255) 0%, #517791 100%);
  background: linear-gradient(to bottom, rgb(110, 226, 255) 0%, #517791 100%);
  color: #fff;
  border-radius: 15px 15px 15px 15px;
  margin-bottom: 13px; 
}

.latestClosePrice {
  color: rgb(255, 255, 112)
}

CSSはあまり良くわかってないです、色々なところの記事を参考にしながらなんとか形になったって感じなので、ツッコミどころが多いかもしれないです…。

コントローラー用JavaScriptコード

  • static/controller.js
function showLoading() {
    // get timesボタンを隠す
    const getTimes = document.getElementById('getTimes');
    getTimes.style.display = 'none';
    // ローディング用のgifを表示する
    const loadingDiv = document.getElementById('loading');
    loadingDiv.innerHTML = 'データを読み込んでいます...<img src="static/loading.gif" alt="loading_icon">';
}

function hideLoading() {
    // loading用のgifを隠す
    const loadingDiv = document.getElementById('loading');
    loadingDiv.style.display = 'none';
    // ヘッダー画面を隠す(タイトル画面)
    const header = document.getElementById('header');
    header.style.display = 'none';}

function hideLoadingError(error) {
    // loading用のgifを消し、エラーの旨を返す
    const loadingDiv = document.getElementById('loading');
    loadingDiv.innerHTML = `<p>読み込みでエラーが発生しました。</p><p>再読み込みしてください。</p><p>${error}</p>`;
    loadingDiv.style.display = 'block';
    // get timesボタンをもとに戻す
    const getTimes = document.getElementById('getTimes');
    getTimes.style.display = 'block';
}

function get_times() {
    // ローディング画面を表示
    showLoading();

    // Fast API側へリクエストを投げる
    const param  = {
        method: "GET",
        headers: {
            "Content-Type": "application/json; charset=utf-8"
        }
    };

    fetch(`${location.origin}/message`, param)
    .then((res)=>{
        if (!res.ok) {
            // HTTP500等のエラーの場合はエラーを返す
            throw new Error(`HTTP error! Status: ${res.status}`);
        }
        return res.json();
    })
    .then((json)=>{
        // 読み上げ原稿をconsoleに表示する
        console.log(json.text)

        // ローディング画面を消す
        hideLoading();

        // 情報画面にHTMLを埋め込む
        const data = document.getElementById("timesdata");
        data.innerHTML = `
        <div class="left-side">
            <div><h3>現在時刻 (情報取得時刻)</h3></div>
                <div><h2 class="today">${json.today_with_time}</h2></div>
            <div><h3>天気情報</h3></div>
            <div class="container">
                <div class="weather-side1">
                    <div class="weather-gradient"></div>
                    <div class="date-container">
                        <h2 class="date-dayname">今日</h2><span class="date-day">${json.today}</span>
                        <i class="location-icon" data-feather="map-pin"></i><span class="location">${json.weather_info.location}</span>
                    </div>
                    <div class="weather-container"><i class="weather-icon"><img src="${json.weather_info.today_mark}" alt="image"></i>
                        <h1 class="weather-temp">${json.weather_info.today_temp}℃</h1>
                        <h3 class="weather-desc">${json.weather_info.today_weather}</h3>
                    </div>
                </div>
                <div class="weather-side2">
                    <div class="weather-gradient"></div>
                    <div class="date-container">
                        <h2 class="date-dayname">明日</h2><span class="date-day">${json.tomorrow}</span>
                        <i class="location-icon" data-feather="map-pin"></i><span class="location">${json.weather_info.location}</span>
                    </div>
                    <div class="weather-container"><i class="weather-icon"><img src="${json.weather_info.tomorrow_mark}" alt="image"></i>
                        <h1 class="weather-temp">${json.weather_info.tomorrow_temp}℃</h1>
                        <h3 class="weather-desc">${json.weather_info.tomorrow_weather}</h3>
                    </div>
                </div>
            </div>
            <div><h3>為替情報</h3></div>
            <div><ul class="exchange-rate-info">
                <li class="exchange-rate-info-list"><h3>${json.exchange_rate1_info.name} / 円:${json.exchange_rate1_info.exchange_rate}円</h3></li>
                <li class="exchange-rate-info-list"><h3>${json.exchange_rate2_info.name} / 円:${json.exchange_rate2_info.exchange_rate}円</h3></li>
            </ul></div>
            <div><h3>遅延情報</h3></div>
            <div><ul class="train-operation-info">
                <li class="train-operation-info-red"><h3>${json.train_operation_info.suspend_list.length === 0
                ? "✕ 運転見合わせ路線:なし"
                : "✕ 運転見合わせ路線:" + json.train_operation_info.suspend_list.join("、")}</h3></li>
                <li class="train-operation-info-blue"><h3>${json.train_operation_info.delay_list.length === 0
                    ? "△ 遅延路線:なし"
                    : "△ 遅延路線:" + json.train_operation_info.delay_list.join("、")}</h3></li>
                <li class="train-operation-info-yellow"><h3>${json.train_operation_info.trouble_list.length === 0
                    ? "! お知らせのある路線:なし"
                    : "! お知らせのある路線:" + json.train_operation_info.trouble_list.join("、")}</h3></li>
            </ul></div>
            <div><h3>主要ニュース</h3></div>
            <div><h3 class="news-info">${Object.values(json.news_info.news_list).map(name => `「${name}\n`).join('')}</h3></div>
        </div>
        <div class="right-side">
            <div><h3>${json.stock_price1_info.stock_price_name}株価</h3>
                <div class="stock-price-info">
                    <h2 class="latestClosePrice">${json.stock_price1_info.latest_close_price}円</h2>
                    <p>前日比${json.stock_price1_info.percentage_change} (${json.stock_price1_info.latest_close_price_date}現在)</p>
                </div>
                <div><img src="${json.stock_price1_info.stock_price_image}" alt="Image"></div>
            </div>
            <div><h3>${json.stock_price2_info.stock_price_name}株価</h3>
                <div class="stock-price-info">
                    <h2 class="latestClosePrice">${json.stock_price2_info.latest_close_price}円</h2>
                    <p>前日比${json.stock_price2_info.percentage_change} (${json.stock_price2_info.latest_close_price_date}現在)</p>
                </div>
                <div><img src="${json.stock_price2_info.stock_price_image}" alt="Image"></div>
            </div>
        </div>
        `

        // 画面をクリックしたら音声が流れるようにする
        const music = new Audio(json.voice_url);
        document.body.addEventListener('click', () => {
            music.play();
        });
    })
    .catch(error => {
        // エラーが発生したら再度読み込んでもらうよう表示
        hideLoadingError(error);
    });
}

コード書くのが上手い人からみたらなんだコレ!ってなりそうなコードです…。おそらくこのあたりはReactで書いたらすごく綺麗にかける気がしますが、Reactがまだ全然慣れてなかったので、APIの"/message"で返ってきたデータを画面上にいい感じに表示するHTMLをここでまるっと生成して、innerHTMLに格納しています。ユーザサイドから今何やっているのか見た目からわかりやすくするように、ローディングのGIFの表示や、エラーメッセージの表示なども入れています。
なぜ画面をクリックしないと音声を流せないようにしているかというと、自動再生にしておくと、このwebページをiPhoneから開いても音声が流れなかったので、クリックをトリガーに流れるようにしています。以下のページを参考にしました。
webfrontend.ninja


↑ここではこのページのHTMLの組み立てをしています。全体としてはleft-sideとright-sideの2つに分割していて、left-sideには時刻、天気、為替、遅延情報、主要ニュース、right-sideには株価情報と株価変動グラフを載せています。読み上げの順序的に左上から表示していきたかったのはあったのですが、株価変動グラフがだいぶ大きく占めるので、右側は株価情報でまとめました。


↑このローディングもここで作ってます。

フロントエンド側のコードは以上です。

動作確認

必要なコードが一通り完成したら、rootディレクトリで以下のコマンドを実行してfast APIを起動させます。

uvicorn main:app --host 0.0.0.0 --port 8000

実際にアプリへのアクセスの仕方は、

ipconfig

で自分のIPアドレスを確認して、webブラウザを開き、以下のURLを打てばホーム画面に飛べると思います。
「http://{自分のIPアドレス}:8000/」

この画像がホーム画面です(背景画像は設定したものによって変わります)。GET TIMESを押せば情報がゲットできます。


同じLAN内であれば、スマホからもアクセスできると思います。iPhoneで試してみましたが、無事繋がりました。
動作させている動画を載せたかったんですが、youtubeに投稿するのはちょっとためらったのでやめました。動いているのを見たい方はコードをコピペもしくは以下のgitからcloneしてみてください。(voicevoxの設定は別途必要です)
github.com

おわりに

今回初めてwebアプリケーションを1人で設計してコーディングしてみたのですが、サーバサイドとUIサイドの絡みで複合的に発生しているエラーが多くて、切り分けていく大変さを学びました。でも実際に完成して動かしてみると、とっても便利なもの作れたなって達成感が得られたので、個人的には作ってよかったと思ってます。外出先からも使えるようにGCP(Google Cloud Platform)の無料枠を使って、実際にwebアプリを公開してみるのもありかなって思ったりしてます。フロントエンドは特に全然慣れていないのでどんどんコーディング技術を上げていきたいです…。