MSBOOKS

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

RenderとFastAPIでwebアプリを公開してみた

はじめに

前回の記事でPythonのFastAPIで作ったWebアプリを作ったので、公開する方法がないか色々探していたのですが有料のものが多いのが現状です…。GoogleGCPも一時期使っていたのですが、クレジットカードを登録する必要があるので間違えてリクエストを大量に送ってしまったり、不正大量アクセスがもし発生すると、高額な請求が発生するのが怖くてやめてしまっていました。そこで探していたら無料でPythonのWebアプリが公開できるRenderというものを見つけたので、Pythonアプリを公開してみました。
msteacher.hatenablog.jp

公開するWebアプリ

ディレクトリ構成

今回はいきなり大きなアプリを公開するのではなく、簡易的なアプリを公開しようと思います。ディレクトリ構成は以下のようにしました。

root/
 ├ main.py
 ├ requirements.txt
 ├ README.md
 └  static/
   ├  index.html
   └  favicon.ico

サーバー側のコード

まずサーバー側のコードを書きます。FastAPIを起動してそのままHTMLを返すコードです。

  • main.py
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse
from pathlib import Path
from starlette.staticfiles import StaticFiles

# fastapiの設定
app = FastAPI()

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

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

フロント側のコード

フロントエンド側はHello Worldというボタンを押すと、Hello Worldというポップアップがでるだけの簡単な作りにしました。

  • index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Hello Page</title>
    <style>
        body {
            text-align: center;
        }
    </style>
</head>
<body>
    <div class="header">
        <div id="header">
            <h1>Hello Page</h1>
        </div>
    </div>
    <input id="helloworld-button" type="button" value="Hello World !" onclick="displayHelloWorld()">
    <script>
        function displayHelloWorld() {
            alert("Hello World !");
        }
    </script>
</body>
</html>

動作確認

ここまで出来たら以下のコマンドを打って、FastAPIを起動させます。

$ uvicorn main:app

以下のようなINFOが表示されるので、任意のブラウザでhttp://127.0.0.1:8000にアクセスすると、先程作ったページが開けると思います。

INFO:     Started server process [23000]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)


ボタンを押すと…

依存関係の記述

これをwebアプリとしてデプロイするためには、サーバー側でpythonのライブラリをインストールしないといけないため、ライブラリの依存関係ファイルを用意しておく必要があります。今回はRender側で読み込むrequirements.txtを以下のように作成しました。もし何をインストールしたか忘れた場合は"pip freeze"コマンドを打てばわかるのでそこから抽出します。

  • requirements.txt
fastapi == 0.88.0
pathlib == 1.0.1
starlette == 0.22.0
uvicorn == 0.20.0

READMEを作成

必要に応じてREADME.mdを作っておきます。

  • README.md
# practice
PythonのFastAPIでHTMLを返すだけのテスト用のリポジトリ

GitHubソースコードをPush

GitHubのアカウントを作成

RenderにデプロイするためにはGitHubアカウントが必要なので、アカウントを作成します。私はすでにアカウントがあったのでそれをそのまま使います。
github.co.jp

GitHubリポジトリを作成

今回はpracticeというリポジトリを作成しました。作成方法はgithub公式のガイドが参考になると思います。
docs.github.com

GitHubソースコードをPush

ローカルで作成したコードをGitHubリポジトリにpushします。
私の場合は以下のような感じです。

github.com
コミット履歴が汚いですがそれはご愛嬌ということで…。

Renderにwebアプリをデプロイ

Renderアカウントを作成

Renderのホームページにアクセスし、GitHubアカウントを連携させます。

dashboard.render.com

Renderにwebアプリをデプロイ

ログインができたら右上の"NEW+"を押して、メニューから"Web Searvice"を押します。

次に、下のような画面が出るので"Build and deploy from a Git repository"を選択します。これでGitHubリポジトリからデプロイできます。

次に、先程作ったpracticeのリポジトリを選択します。もしない場合は検索して追加します。

次はデプロイに関する細かい設定になります。以下のように設定します。

Name:デプロイするサービス名を入れます。これがURLに入ります。(例) https://{サービス名}.onrender.com/
Region:日本があればよかったのですが、なかったので近いSingaporeを選択します。
Branch:デプロイしたいGitのブランチを選択します。今回はmainのままにします。
Root Directory:リポジトリのルートディレクトリを選択できます、今回はルートのままで良いので無記入。
Runtime:Python 3を選択。
Build Command:"pip install -r requirements.txt"を記載。これにより必要なライブラリを取得できます。
Start Command:"uvicorn main:app --host 0.0.0.0 --port $PORT"を記載。uvicornでmainを実行します。ポートはrender側で設定されます。
Instance Type:"Free"を選択。
Environment Variables:環境変数は今回はなし。

これらは公式にガイドがあります。
docs.render.com

動作確認

設定が完了するとデプロイが始まります。以下のように"Your service is live 🎉"と表示されれば、無事デプロイされて動いています。

https://{サービス名}.onrender.com/にアクセスして動作確認してみます。

無事開けました。

おわりに

Renderを使うことで、簡単にGitHubからPythonで作ったFastAPIのアプリをデプロイすることができました。Freeプランだと色々制約はありますが、無料でwebアプリを公開できるのはありがたいですね。外出先からでもアクセスできたとき、こんなくだらないサイトですが開けたとき感動しました。昔つくったアプリとかも公開してみたいですね。

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

はじめに

朝起きたときに天気見て、遅延情報見て、ニュース見て…、色々な情報がちらばっていて忙しい朝にいちいち色々なページを開くのが面倒!って思ったので、朝これを見れば全てがわかるアプリを作ってみました!
コードはすべて載せますが、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アプリを公開してみるのもありかなって思ったりしてます。フロントエンドは特に全然慣れていないのでどんどんコーディング技術を上げていきたいです…。

【ラブライブ!サンシャイン!!】名古屋聖地巡礼

はじめに

テレビアニメ1期13話、2期7話で舞台になった名古屋の聖地を巡ってみました。観光にもちょうど良いと思って巡ったのですが、さすがは都会で改装されて跡形もないところが結構ありました。
名古屋の聖地巡礼は半日あれば使えれば周れると思います。そんなにスポットが多くないのと近くにまとまっているので、一日乗車券など買わなくても問題ないですが、ついでに名古屋城など周るならあってもいいかもしれません。

飛翔

名古屋駅桜通口から出て、大名古屋ビルヂング前あたりから、JRセントラルタワーズと飛翔が両方入るように撮りました。この飛翔ですが、もうすぐ撤去されてしまうようで、工事のため囲いが設置されてました。

ナナちゃん人形

コロナの影響か、マスクをしていました。3枚目はだいぶ下からのアングルになりますが、やっぱり観光スポット&待ち合わせスポットということもあって人が多かったので断念しました。

金の時計

定番の待ち合わせスポットです。人通りがとっても多いので撮影は至難の業です。望遠カメラなどがある人は遠くから撮れるといいかもですね。

ゆりの噴水

名古屋駅金の時計とは反対側の太閤口側にあり、花弁の形をした彫像です。ここも定番の待ち合わせスポットですね。リニア中央新幹線の駅がこの近くにできるようで、このあたりもだいぶ変わってしまうのでしょうか。1枚目はおそらく看板が新たに設置されて微妙な感じになってます…。

もちの木広場

ここは完全にリニューアルして跡形もなくなっていました。途中にあるくぼみだけはそのまま残っているようですが、それ以外はまったく別物でした。おそらくこのあたりだろうという部分を撮ってみました。

日本ガイシホール

ラブライブ東海地区予選の会場のモデルとなったところで、実際にAqours 2nd ライブツアーの名古屋公演などでライブが開催されたところですね。最近だとLiella! 2nd ライブツアーが開催され、ラブライブシリーズには馴染み深い場所になってますね。

おわりに(聖地巡礼ルート)

最後に巡礼ルートを紹介します。

名古屋駅
↓徒歩
★飛翔
↓徒歩
★ナナちゃん人形
↓徒歩
★金の時計
↓徒歩
★ゆりの噴水
↓地下鉄(東山線 名古屋→栄)
★もちの木広場
↓地下鉄(名城線 栄→金山)
金山駅
↓電車(東海道線 金山→笠寺)
日本ガイシホール
↓電車(東海道線 笠寺→名古屋)
名古屋駅

聖地巡礼の参考になれば嬉しいです。

【ラブライブ!サンシャイン!!】北海道・函館聖地巡礼

はじめに

2017年末に放送されたテレビアニメ2期8・9話で舞台になった函館の聖地を巡ってみました。アニメ放映から5年たった今も聖地に訪れる人はまだまだいるそうで、そんな函館の聖地をまとめてみました。もともと観光地のところも多いので、観光にもちょうど良いですね。
函館の聖地巡礼ですが、1日まるまる使えれば周れると思います。私は函館への移動時間があったので、午後からとなったためそういった場合は2日あるとちょうど良いと思います。少し移動が必要な箇所もあるので、私は市電・函館バス2日乗車券を買って観光&聖地巡礼しました。
www.city.hakodate.hokkaido.jp

函館駅

新幹線の新函館北斗駅から15分ほどにある函館の玄関口となる駅です。

函館国際ホテル

函館駅からベイエリアの方向へ歩いて行く途中にあるホテルです。メンバーが泊まっていたホテルです。外観は改装されていてだいぶ変わってしまいましたが面影はありますね。客室も再現されているそうですが、私はここには宿泊しませんでした…。

金森赤レンガ倉庫

1869年頃の倉庫群で、レストランやお土産屋、オルゴールショップなどショッピングが充実しています。ルビィと理亞が話していたところです。夜は行けなかったのですが、ライトアップが綺麗みたいです。

ラッキーピエロベイエリア本店

函館発のご当地のハンバーガー屋です。お昼はここで食べたのですが訪れた日は大変混雑していたので、店内の写真は撮れませんでした…。はなまるちゃんが食べていたフトッチョバーガーは土日祝日は販売していないようなので、食べたい方は平日を狙っていきましょう。

新島襄ブロンズ像公園

ラッキーピエロベイエリア本店から歩いてすぐのところにあります。最近改装されてしまったので黒澤姉妹が座っていたベンチはなくなってしまっていました…。

旧函館区公会堂

1910年に建てられた公会堂で、函館港を一望できます。アニメではクリスマスイベントの選考会場として登場しました。

船見児童公園

旧函館区公会堂から横に移動した住宅地の中にある公園です。一年生組がシーソーで遊んでいた公園ですね。

ヨハネ教会

1878年に建設されましたが、度重なる大火で現在の聖堂は1979年に完成したものだそうです。予約をすれば中の見学ができるそうです。ヨハネとはサンシャインにすごくピッタリな聖地…!

茶房 菊泉

1921年に建てられたお茶屋。聖良と理亞の実家のお店としてでてきましたね。白玉ぜんざいもそのまんまでした!理亞ちゃんの部屋は見学可能で、空いていれば理亞ちゃんの部屋でお食事が楽しめます。

八幡坂(坂上)

函館港が一望できる長い坂道。函館では有名な観光スポットで多くの人が訪れていました。鹿角姉妹が通う函館聖泉女子高等学院はここのちょうど一番上にあたるところにあります。

八幡坂(HakoBA THE SHARE HOTELS付近)

Awaken The Powerの舞台となった坂道の路面電車の線路付近にあるところです。

函館山ロープウェイ山麓

函館山ロープウェイ山麓側の乗り場です。アニメでラジオを配信していたところは、FMいるかというところだそうです。

函館山

函館を一望できる展望台です。日本三大夜景のひとつで、夜景はとても綺麗です。夜間はいつもは混んでいて聖地の写真がとれるようなところではないようなのですが、私がいったときは雨天で夜景が見れるか見れないかといった状況だったので比較的空いていました。カメラをもって霧が晴れるのを粘り強く待っていたら、霧がすーっと流れていき美しい夜景が撮れました。

五稜郭タワー

地上90mの高さから五稜郭を一望できるタワーです。はなまるちゃんが星型が美味しそうと言っていましたが、なんだかかわいい感じがしますね。

杉並町(市電停留所)

五稜郭タワーから歩いていける場所です。函館アリーナで予選を見たメンバーが帰るときに写ったところです。

函館アリーナ

メインアリーナは5000人を収容できるホールで、外見は若干異なります。ラブライブ地区予選会場として登場し、Saint Snowの2人が実際に舞台に立ったところです。2018年の4月にはSaint Snow PRESENTS LOVELIVE! SUNSHINE!! HAKODATE UNIT CARNIVALで実際にキャストがライブで訪れています。

函館空港

東京、名古屋、大阪などを結ぶ飛行機が来る空港です。私は新幹線で訪れたので、函館駅からバスで向かいました。レンガ造りでおしゃれです。

おわりに(聖地巡礼ルート)

聖地巡礼していると、函館の有名なスポットが周れてとても楽しかったです。最後に巡礼ルートを紹介します。

【1日目】
新函館北斗駅
↓電車
函館駅
路面電車
★金森赤レンガ倉庫
↓徒歩
★函館国際ホテル
↓徒歩
ラッキーピエロベイエリア本店(ランチ)
↓徒歩
新島襄ブロンズ像公園
↓徒歩
★旧函館区公会堂
↓徒歩
★船見児童公園
↓徒歩
★茶房 菊泉(カフェ休憩)
↓徒歩
★聖ヨハネ教会
↓徒歩
★八幡坂
↓バス
函館山ロープウェイ山麓
↓バス
ベイエリア(十字街)で夕食
↓バス
函館山
↓バス
函館駅


【2日目】
函館駅
路面電車
五稜郭タワー
↓徒歩
★杉並町(市電停留所)
路面電車
★函館アリーナ
↓バス
函館空港(ランチ)
↓バス
函館駅
↓電車
新函館北斗駅

聖地巡礼の参考になれば嬉しいです。

【ラブライブ!虹ヶ咲学園スクールアイドル同好会】アニメ2期のOP(Colorful Dreams! Colorful Smiles!)の聖地巡礼してみた

はじめに

ニジガクアニメ2期OP ( Colorful Dreams! Colorful Smiles! ) の聖地巡礼してみたのでまとめてみました。OPの聖地だけでもお台場のいろいろなところを周れるのでとてもオススメです!
私はゆりかもめ1日乗車券を買ってみました、全部一日で周るならとってもオトクです!今回の記事ではゆりかもめからの最寄り駅も一緒に紹介します。
www.yurikamome.co.jp


虹のベンチ

歩夢ちゃんのところです。東京ビッグサイトから公園のほうに少し下っていくとあります。実際はこんなにきれいな虹色ではないですが、再現度高くするために加工してます…。


東京ガーデンシアター

R3BIRTHのライブもやった東京ガーデンシアターですね!5thはここでやるとのことで、聖地でライブ…!有明駅から歩いてすぐのところです。写真はココからです(一般の人は入って撮れないです)
東京ガーデンシアター | 高橋カーテンウォール工業


ヒルトン東京お台場

ランジュちゃんのお家があるヒルトンです。台場駅2A出口からすぐにあるヒルトンのウエストプロムナード寄りのところにランジュちゃんが歩いてたところがあります。


10号地その1西側多目的埠頭

東京ビッグサイトから有明西ふ頭公園を通り抜けた先にあります。10分ほど歩きます。


青海南ふ頭公園 壁泉

栞子ちゃんのところです。テレコムセンターから見てテレコムセンターの裏側にある公園にあります。


KUA`AINA AQUACITYお台場店

ミアちゃんのところです。りなミア回で食べてたハンバーガーはおそらくここです!台場駅からすぐのアクアシティの海側の階段を上がったすぐのところにあります。


豊洲六丁目公園

かすみちゃんのところです。新豊洲駅からテプコ豊洲を通り過ぎた先にある公園です。マンション寄りのところでかすみちゃんが紙飛行機を飛ばしている感じです。


新豊洲Brilliaランニングスタジアム

愛さんのところです。新豊洲駅から、先程のテプコ豊洲のちょうど反対側の公園の海寄りにあります。


台場駅1A出口前(Bakery & Pastry Shopの前)

果林さんのところです。台場駅1A出口すぐのところにあります。Bakery & Pastry Shopの前あたりから左にフジテレビのビルが少し入る辺りで取るとバッチリの画角で撮れます。


晴海トリトンスクエア

しずくちゃんのところです。今回唯一ゆりかもめの駅から離れているところになります。豊洲駅新豊洲駅からだと徒歩20分くらいになります。歩くのが大変でしたら、愛さんで紹介した公園前にある新豊洲駅前バス停からバスを利用するほうが便利です。都05-2系統の東京駅丸の内南口行きのバスで1駅目の晴海三丁目バス停を降りると目の前に晴海トリトンのビルが見えます。晴海トリトンのビルを抜けた憩いエリアにあります。レクサス晴海寄りにある赤い橋です。


有明ガーデン ウエルカムデッキ(タリーズコーヒー前)

彼方ちゃんのところです。有明駅から歩いてすぐにある有明ガーデンのタリーズコーヒーの前あたりにある白と黄色のベンチのあたりです。


アクアシティお台場4F(カフェ ラ・ボエム お台場前)

エマちゃんのところです。台場駅を出てすぐのところにあるアクアシティの4階です。カフェ ラ・ボエムの辺りです。

海浜公園入口交差点

璃奈ちゃんのところです。お台場海浜公園駅からすぐのところにあります。海浜公園入口交差点からシーリアお台場三番街5号棟を見る方向です。


東京クルーズ乗船券売場前(メモリアルドック)

せつ菜ちゃんのところです。豊洲駅直結のららぽーと豊洲の海寄りにある東京クルーズ乗船券売場前あたりです。階段を少し登ったところから撮ると画角がピッタリだと思います。

ラウンドワン ダイバーシティ東京プラザ

侑ちゃんのところです。台場駅青海駅から5~6分のところにあるダイバーシティ東京にあります。その中にあるラウンドワンのカラオケNo.15ルームが舞台だそうです。私は行けてないです…。

おわりに(聖地巡礼ルート)

ここまでまとめてみましたがいかがでしたでしょうか?実際に聖地に訪問してみると、虹ヶ咲の世界に入ったような雰囲気を感じられてとっても楽しかったです。
ゆりかもめ1日乗車券ですべて周りました、聖地に行った順番ですが、

豊洲駅からスタート
↓ 徒歩
1.せつ菜:東京クルーズ乗船券売場前(メモリアルドック)
↓ 徒歩
2.しずく:晴海トリトンスクエア
↓ 徒歩
3.愛:新豊洲Brilliaランニングスタジアム
4.かすみ:豊洲六丁目公園
↓ 新豊洲駅台場駅
5.果林:アクアシティお台場4F(カフェ ラ・ボエム お台場前)
6.ランジュ:ヒルトン東京お台場
7.ミア:KUA`AINA AQUACITYお台場店
↓ ランチ ハンバーガ
8.エマ:アクアシティお台場4F(カフェ ラ・ボエム お台場前)
↓ 徒歩 (途中せっかくなのでお台場ゲーマーズにも寄りました)
9.璃奈:海浜公園入口交差点
↓ お台場海浜公園駅テレコムセンター
10.栞子:青海南ふ頭公園 壁泉
↓ テレコムセンター駅→青海駅
11.侑:ラウンドワン ダイバーシティ東京プラザ
↓ 青海駅東京ビッグサイト
12.歩夢:虹のベンチ
13.10号地その1西側多目的埠頭
↓ 東京ビッグサイト駅→有明駅
14.彼方:有明ガーデン ウエルカムデッキ(タリーズコーヒー前)
↓ 有明駅豊洲駅
豊洲駅へゴール

ランチを間に挟まないのであれば、豊洲から1つずつ周って新橋に抜けるっていうこともできそうです。聖地巡礼の参考になれば嬉しいです。

【新型コロナワクチン接種・ファイザー2回目】副反応で高熱出る?どれくらい続く?

はじめに

前回の記事に引き続いて地域の集団接種会場でファイザー製の新型コロナワクチン2回目を打ったので、副反応について書いていきたいと思います。特に若年層で副反応が強く出るのと、2回目はかなりひどくなるって話も良く聞くのでグラフを示しながら時系列ごとに書いていきます。以下今回の細かい情報です。

  • 接種場所:地域の集団接種会場
  • メーカー:ファイザー
  • タイプ:メッセンジャーRNA(mRNA)ワクチン(モデルナも同様のタイプ)
  • 回数:2回目
  • 年代:20代

接種の流れ

1回目と同様だったので省略します。詳しくは1回目の記事を見てください。

副反応(体温)の時系列データ

やっぱり2回目は高熱が出たので、まずは接種後のどのタイミングで出たのか時系列データにしてみました。
f:id:msteacher:20210904115224p:plain

近くの薬局の薬剤師さんと相談して解熱剤としてロキソニンを服用しました。これをみるとロキソニンを服用したらすぐに体温が低下していて解熱剤ってかなり効果あるって実感しました笑
副反応を時系列ごとにその時の体調や食事などを以下まとめていきます。

接種直後(PM6:00)~接種3時間後

症状:なし
打った部位も特に痛みはなく、いたって健康でした。ワクチン副反応なしでおわるかも!?って思ったぐらいには健康的でした笑 夕食はさすがに油もの等は避けてお魚のお弁当を食べました。お風呂もシャワーのみで入りました。

AM8:00(接種14時間後)

症状:腕の痛み、腫れ、倦怠感
1回目同様腕に筋肉痛のような痛みがあり、1回目よりも腫れがひどく、寝返りをうつと痛みがひどくて起きてしまいました。また1回目にはなかった倦怠感があり、これはやばいのきたかって思いましたが熱を測ると37.3度で平熱よりやや高めぐらいでした。食欲はあまりなかったですが何も食べないのもなって思って家にあったフルーツグラノーラをやや無理やり食べました。牛乳で流し込む系は食べれました。しかし倦怠感がけっこうひどかったので出勤は避けてお休みを貰いました。

PM0:00(接種18時間後)

症状:腕の痛み、腫れ、倦怠感、熱感、ふらつき、食欲不振、関節痛
朝からずっと寝ていたのですが目が覚めると、倦怠感に加えて体がすごく熱いと感じました。熱を測ろうと立ち上がるとふらつきもあり、これはとうとう…。熱を測ると38.5度あり、お昼の時間でしたが食欲もまったくないのでウイダーインゼリーとポカリスエットで済ませました。熱がすごいので事前に買っていたロキソニンを服用して再度寝ました。関節痛もあり、インフルエンザにかかったときのような症状だと思います。

PM3:00(接種21時間後)

症状:腕の痛み、腫れ、倦怠感、熱感は多少抑えられてきた、関節痛
熱感は抑えられてきましたが、倦怠感がひどいので特にスマホをみたりすることもなく熱を測ってバナナだけ食べて寝ました。

PM6:00(接種24時間後)

症状:腕の痛み、腫れ、倦怠感
だいぶ熱が下がってきてグラフを見ると、深層学習でいう局所最適解みたいなところの37.0度に落ち着きました。ふー、これで副反応が終わりかー結構短くて助かったーって思っていました。熱感もだいぶ改善したので、晩御飯はお魚のお弁当を食べました。それ以降は布団の上でスマホいじりながらごろごろしてました。

PM9:00(接種27時間後)

症状:腕の痛み、腫れ、倦怠感、熱感
この時間になるとなんか熱感あるなーって思って熱を測るとまた38.0度に逆戻りしてました。倦怠感もより増した気がしました。お風呂は入らずロキソニンだけ飲んで即寝ました。

AM8:00(接種38時間後)

症状:多少の腕の痛み、腹痛
腕の痛みはだいぶ改善し、倦怠感もほぼなくなっていました。ロキソニンのせいか腹痛はしました。朝食は昨日と同じフルーツグラノーラにしました。熱も36.6度でだいぶ落ち着いてきたので出勤(テレワーク)しました。

PM0:00(接種42時間後)

症状:多少の腕の痛み
食欲もだいぶ改善し、お昼ご飯はうどんを食べました。

PM3:00(接種45時間後)

症状:多少の腕の痛み、少しの熱感
テレワークをしてるとなんか熱っぽいなーって思って、再度熱を測ると37.5度とやや微熱気味でした。この時間に早退するのもなんだしってことでロキソニンを飲んで勤務を続けました。

PM6:00(接種48時間後)

症状:多少の腕の痛み
テレワークも終わり、熱もやや下がってきたので、夕食はカレーを食べてごろごろしてました。

PM9:00(接種51時間後)

症状:多少の腕の痛み
特に大きな症状がなかったのでシャワーを浴び、寝ました。

AM8:00(接種62時間後)

症状:ほぼなし
熱も36.7度と平熱まで下がり、腕の痛みもほぼなくなりました。

副反応の期間

私の場合は接種後からおおよそ2.5日(60時間)ぐらいで副反応がほぼなくなりました。接種後18時間ぐらいが高熱で最もつらい副反応が出て、接種後38時間ぐらいには大きな副反応は改善し、テレワークができるぐらいにはなりました。感想としては接種後翌日は休みを取り、翌々日はテレワークや場合によっては休みにするのが安心だと思いました。

接種前もしくは接種直後に用意しておくべきもの

高熱が出たときのために

  • スポーツ飲料(ポカリなど)
  • ゼリーなどの流し込める食べ物
  • 解熱剤(ロキソニンなど)

多少元気が出てきたときのために

おわりに

2回目ということでやっぱり強めの副反応で、正直けっこうつらかった印象です。ただ副反応は予告された風邪やインフルエンザのような印象で、対策をうっておけるのが強みだと思います。これから打つ人はぜひ対策をしっかりしてみるといいかと思います。日本産のワクチン開発もされているみたいですが、もっと副反応が少ないワクチンだといいなー。

【新型コロナワクチン接種・ファイザー1回目】接種の流れ、副反応は?

はじめに

今回、地域の集団接種会場でファイザー製の新型コロナワクチンを打ったので、接種全体がどんな流れなのかや副反応について書いていきたいと思います。特に若年層で副反応が強く出るって話も聞くので、今後新型コロナワクチンを受ける人の参考になれば嬉しいです。以下今回の細かい情報です。

  • 接種場所:地域の集団接種会場
  • メーカー:ファイザー
  • タイプ:メッセンジャーRNA(mRNA)ワクチン(モデルナも同様のタイプ)
  • 回数:1回目
  • 年代:20代

予約

集団接種会場の予約は地域にもよりますが、私が住んでいるところではwebサイトで決められた時間に予約が開始するので、その時間になったらすぐにwebサイトにアクセスして申し込みました。わりとすぐに埋まってしまうらしいので、受けたい会場と接種希望日をあらかじめ決めておいて、早めに申し込むのがポイントです。

事前準備

当日接種に受けに行く前に予診票の記入が必要です。持ち物は、その記入済予診票、接種券(シールのやつ)、本人確認書類が必要になります。
f:id:msteacher:20210813161732j:plain

入口前待機

ワクチン接種会場に着くと、予約時間まで会場前の席で待機させられます。間隔のあいた席が容易されているので待機時間も基本的には安心です。ここで事前に記入した予診票、接種券、本人確認書類をすぐに提示できるよう準備しておきます。

受付

時間になり、会場内に案内されると、まず初めに検温と消毒があります。ここで検温した結果を報告するので覚えておきましょう。受付では予約の確認、本人確認書類の確認などが行われます。バインダーで予診票を留めてもらい、そのまま予診票確認へ移ります。

予診票確認

記入内容を確認されます。書いていないところがあった場合や当日の検温結果はここで書いてもらいます。特に問題がなければ、ここの会場で受けますよっていうスタンプを予診票に押されます。そしてそのまま予診室に案内されます。

予診室

予診室では地域の医師が「今日の体調は普段とお変わりないですか?」や「今までワクチンで副反応が出たことがありますか?」、「アレルギーはありますか?」などといった質問がされます。特に問題がなければ予診は完了し、接種室に案内されます。

接種室

まず口頭での本人確認をされました。その後、利き手がどちらか聞かれ、利き手ではない方向に打つのでいいか確認されます。アルコールが大丈夫か聞かれたあと、アルコールで消毒後すぐに接種されます。筋肉注射なので、針が長く、かなり深く刺さりますが痛みとしては多少チクっとするぐらいでそこまで痛くはなかったです。むしろインフルエンザの予防接種のほうが痛いくらいの印象でした。最後に接種部に絆創膏を張られ、接種証明書受付へ進みます。

接種証明書受付

接種後すぐに接種証明書の発行(シールのやつ)と、2回目の予約を受け付けされました。2回目の予約は3週間後の同じ日時に設定されていました。発行と予約が完了すると、経過観察待機所に移り、15分待機させられます。

経過観察待機所

アナフィラキシーがあった場合はここで対応するのだと思います。周りを見渡すとみんなスマホいじったり本読んだり、自由に過ごしていました。何事もない人はそのまま帰宅になります。私が受けたときは特にやばそうな人はいませんでした。
f:id:msteacher:20210813163156j:plain

副反応について

今回は1回目接種だったのですが、接種直後から就寝前までは多少の違和感はあるものの発熱や特別痛みはなかったです。
翌朝、接種から約15時間後、発熱等はなかったですが腕に筋肉痛のような痛みを感じました。寝返りをうって腕があたると少し痛みました。起床して実際に生活してみると、腕を上げるのが痛い程度で、力を入れずにだらりとさせていればそんなに痛みもなく、私は普通にパソコンで作業をしていました。ただ髪を洗ったり、荷物を持ち上げたりすると痛みが出るので、腕を上げる作業や力仕事は避けておくのが無難ですね。
さらに翌朝、接種から約40時間後、腕の痛みはほとんどなくなり、ほぼ元通りになりました。私は一回目はそこまで激しい副反応は出ずに終えられました。

おわりに

接種の流れや副反応について記事にしましたがどうでしたでしょうか。私自身実際集団接種ってどうやっていくのか不安だったので、これを見て安心して受けられれば幸いです。また今回はそこまで大きな副反応は起きず安心しているところですが、2回目は激しい副反応が出るって聞いているので怖いなぁと感じています。2回目を打った後副反応がどうだったかについても記事にしていきたいと思います。それではまた。