自動情報収集アプリを作ってみた(天気, 株価, 為替, 遅延情報, 主要ニュースの自動取得)
はじめに
朝起きたときに天気見て、遅延情報見て、ニュース見て…、色々な情報がちらばっていて忙しい朝にいちいち色々なページを開くのが面倒!って思ったので、朝これを見れば全てがわかるアプリを作ってみました!コードはすべて載せますが、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