アプリを作ろう--妙高市雪情報グラフ--第2回:AIエージェントにコードを書いてもらう
アプリを作ろう──妙高市雪情報グラフ
第2回:AIエージェントにコードを書いてもらう
こんにちは。
前回は、要件定義書を作るところまで進めました。
今回はいよいよ、その要件定義書をもとに
AIエージェントにコードを書いてもらう回です。
:::message
この連載は、Webアプリ開発初心者が、同じく初心者の方に向けて書いている記事です。
私自身も学びながら進めているので、一緒に「できた!」を積み重ねていけたら嬉しいです。
:::
この連載でやっていること
おさらいです。
この連載では、新潟県妙高市の「雪情報ホームページ」に掲載されている
降雪量・積雪量の表データを、グラフで見られるWebアプリを作っています。
使う技術は、とてもシンプルです。
Python
Streamlit(PythonだけでWebアプリが作れるライブラリ)
そして今回の主役は、AIエージェント(Cursor Agent)です。
要件定義からコードを生成してもらう
前回作った要件定義書(requirements.md)を、
CursorのAIエージェント機能に渡しました。
プロンプトは、とてもシンプルです。
requirements.md の要件定義に基づいて、必要なコード、ファイル、を作成してください。たったこれだけ。
要件定義書を 「設計図」として渡す ことで、
AIが自分で考えて、必要なファイルを一式作ってくれます。
1〜2分で、アプリが完成した
驚くべきことに、わずか1〜2分で
以下のファイルが自動生成されました。
main.py … メインアプリケーション
data_urls.csv … データ取得先URL一覧(44件)
requirements.txt … 依存パッケージ一覧
README.md … ドキュメント
.gitignore … Git除外設定
要件定義で書いた内容が、ほぼそのままコードになっています。
「こうしてほしい」と言葉で書いたことが、動くコードに変わる。
この体験は、やっぱり感動します。
作成されたファイルを見てみよう
1. main.py(メインアプリケーション)
一番大事なファイルです。
346行のPythonコードが生成されました。
実装されている機能:
✅ CSVファイルからURL情報を自動読み込み
✅ HTMLページから表データを自動取得・解析
✅ サイドバーで年・月・地点を最大3件選択可能
✅ 降雪量(折れ線グラフ)と積雪量(棒グラフ)を同一グラフに表示
✅ グラフを縦に3つまで並べて表示
✅ デフォルトで最新データの3地点を表示
✅ キャッシュ機能による高速化
✅ エラー処理と適切なメッセージ表示
要件定義書に書いた「受入条件」を、ほぼすべて満たしています。
main.pyの全体構成
コードは、大きく分けて 4つの関数 で構成されています。
# 1. URL一覧CSVファイルを読み込む
def load_url_data() -> pd.DataFrame:
# 2. 指定URLから降雪・積雪データを取得してDataFrame化する
def fetch_snow_data(url: str, year: int, month: int) -> Optional[pd.DataFrame]:
# 3. 降雪・積雪のグラフを作成
def create_snow_graph(df: pd.DataFrame, year: int, month: int, location: str) -> go.Figure:
# 4. データが存在する最新の年月を取得
def get_latest_available_month(url_df: pd.DataFrame) -> Tuple[int, int]:そして、これらを呼び出す `main()` 関数があります。
初心者の方には、この構成がとても参考になると思います。
「データを読む」「加工する」「表示する」を、
それぞれ 別の関数に分ける ことで、
コードが読みやすく、変更しやすくなっています。
コードのポイント① キャッシュ機能
Streamlitには、キャッシュ機能があります。
同じURLへ何度もアクセスしないように、結果を保存しておく仕組みです。
@st.cache_data(ttl=3600)
def load_url_data() -> pd.DataFrame:
"""URL一覧CSVファイルを読み込む"""
# ...`@st.cache_data` というデコレータを関数の上に書くだけで、
1時間(3600秒)はキャッシュが有効になります。
要件定義書に「キャッシュ機能を使う」と書いておいたので、
AIがちゃんと実装してくれました。
コードのポイント② HTMLの解析
妙高市のホームページから、表データを取り出す部分です。
# BeautifulSoupでパース
soup = BeautifulSoup(response.text, 'lxml')
# テーブルを探す
tables = soup.find_all('table')`BeautifulSoup` というライブラリを使って、
HTMLの中から `<table>` タグを探しています。
このあたりは、ちょっと難しく見えるかもしれませんが、
「HTML = 構造化された文書」
「BeautifulSoup = その構造から情報を取り出す道具」
と思っておけばOKです。
コードのポイント③ グラフの作成
Plotlyというライブラリで、グラフを作成しています。
# 積雪量(棒グラフ)
fig.add_trace(
go.Bar(
x=filtered_df['day'],
y=filtered_df['snowdepth_cm'],
name='積雪量',
marker_color='lightblue',
opacity=0.7
),
secondary_y=False
)
# 降雪量(折れ線グラフ)
fig.add_trace(
go.Scatter(
x=filtered_df['day'],
y=filtered_df['snowfall_cm'],
name='降雪量',
mode='lines+markers',
line=dict(color='red', width=2),
marker=dict(size=6)
),
secondary_y=False
)要件定義に書いた
「降雪量は折れ線グラフ、積雪量は棒グラフ、同一グラフに重ねて表示」
が、そのまま実装されています。
2. data_urls.csv(URL一覧)
年度・年・月ごとのページURLを、44件登録しています。
年度,年,月,URL
令和1年度,2019,1,https://www.city.myoko.niigata.jp/life-info/snow-info/snow/r1-1.html
令和1年度,2019,2,https://www.city.myoko.niigata.jp/life-info/snow-info/snow/r1-2.html
...(中略)...
令和8年度,2026,11,https://www.city.myoko.niigata.jp/life-info/snow-info/snow/r8-11.html要件定義書で「令和1年度〜令和8年度」と指定していたので、
AIがURLを推測して一覧を作ってくれました。
(実際のURLが正しいかどうかは、動作確認時に検証します)
3. requirements.txt(依存パッケージ)
アプリを動かすために必要なライブラリの一覧です。
streamlit>=1.28.0
pandas>=2.0.0
requests>=2.31.0
beautifulsoup4>=4.12.0
plotly>=5.17.0
lxml>=4.9.0要件定義書に書いた「使用ライブラリ」が、
そのままバージョン指定付きで書かれています。
4. README.md(ドキュメント)
170行のドキュメントが生成されました。
アプリの概要と使い方
セットアップ手順
トラブルシューティング
技術スタック情報
今後の拡張案
まで書いてくれています。
感じたこと:AIは「設計図」があると強い
今回、改めて感じたのは、
要件定義書(設計図)があると、AIの出力精度がぐっと上がる
ということです。
「雪のデータをグラフにして」と曖昧に頼むより、
「こういう構成で、こういう機能があって、こういうエラー処理をして」
と具体的に書いてあるほうが、AIは正確に動きます。
前回、要件定義を先に作ったのは、このためでした。
main.pyの全コード
参考までに、生成された `main.py` の全コードを掲載します。
約350行と長いですが、構造はシンプルなので、
ぜひ眺めてみてください。
import streamlit as st
import pandas as pd
import requests
from bs4 import BeautifulSoup
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from typing import Optional, Tuple, List
import logging
from datetime import datetime
# ロギング設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 定数
LOCATIONS = ["新井消防署", "頸南消防署", "妙高市役所 妙高支所"]
CSV_FILE = "data_urls.csv"
@st.cache_data(ttl=3600)
def load_url_data() -> pd.DataFrame:
"""URL一覧CSVファイルを読み込む"""
try:
df = pd.read_csv(CSV_FILE)
return df
except Exception as e:
logger.error(f"CSVファイルの読み込みに失敗: {e}")
st.error("データURLの読み込みに失敗しました")
return pd.DataFrame()
@st.cache_data(ttl=3600)
def fetch_snow_data(url: str, year: int, month: int) -> Optional[pd.DataFrame]:
"""
指定URLから降雪・積雪データを取得してDataFrame化する
Args:
url: データ取得元のURL
year: 年
month: 月
Returns:
tidyデータ形式のDataFrame、取得失敗時はNone
"""
try:
# HTMLを取得
response = requests.get(url, timeout=10)
response.raise_for_status()
response.encoding = response.apparent_encoding
# BeautifulSoupでパース
soup = BeautifulSoup(response.text, 'lxml')
# テーブルを探す
tables = soup.find_all('table')
if not tables:
logger.warning(f"テーブルが見つかりません: {url}")
return None
# 最初のテーブルを使用(通常、データテーブルは最初にある)
table = tables[0]
# データを格納するリスト
data_rows = []
# テーブルの行を解析
rows = table.find_all('tr')
# ヘッダー行をスキップして、データ行を処理
for row in rows[1:]: # 最初の行はヘッダーと仮定
cols = row.find_all(['td', 'th'])
if len(cols) < 7: # 日、降雪×3、積雪×3 の最低7列必要
continue
# テキストを抽出
cols_text = [col.get_text(strip=True) for col in cols]
# 日付を取得
try:
day = int(cols_text[0])
except (ValueError, IndexError):
continue
# 各観測地点のデータを処理
# 想定: 列順が [日, 降雪1, 降雪2, 降雪3, 積雪1, 積雪2, 積雪3]
if len(cols_text) >= 7:
for i, location in enumerate(LOCATIONS):
# 降雪量(インデックス 1, 2, 3)
snowfall_idx = i + 1
# 積雪量(インデックス 4, 5, 6)
snowdepth_idx = i + 4
snowfall = cols_text[snowfall_idx] if snowfall_idx < len(cols_text) else "-"
snowdepth = cols_text[snowdepth_idx] if snowdepth_idx < len(cols_text) else "-"
# "-" を NaN に変換
snowfall_cm = None if snowfall == "-" or snowfall == "" else float(snowfall)
snowdepth_cm = None if snowdepth == "-" or snowdepth == "" else float(snowdepth)
data_rows.append({
'year': year,
'month': month,
'day': day,
'location': location,
'snowfall_cm': snowfall_cm,
'snowdepth_cm': snowdepth_cm
})
if not data_rows:
logger.warning(f"データが抽出できませんでした: {url}")
return None
df = pd.DataFrame(data_rows)
return df
except requests.RequestException as e:
logger.error(f"HTTPリクエストエラー: {url} - {e}")
return None
except Exception as e:
logger.error(f"データ取得エラー: {url} - {e}")
return None
def create_snow_graph(df: pd.DataFrame, year: int, month: int, location: str) -> go.Figure:
"""
降雪・積雪のグラフを作成
Args:
df: 降雪・積雪データ
year: 年
month: 月
location: 観測地点
Returns:
Plotlyのグラフオブジェクト
"""
# 指定された条件でフィルタ
filtered_df = df[
(df['year'] == year) &
(df['month'] == month) &
(df['location'] == location)
].sort_values('day')
# グラフ作成
fig = make_subplots(specs=[[{"secondary_y": True}]])
# 積雪量(棒グラフ)
fig.add_trace(
go.Bar(
x=filtered_df['day'],
y=filtered_df['snowdepth_cm'],
name='積雪量',
marker_color='lightblue',
opacity=0.7
),
secondary_y=False
)
# 降雪量(折れ線グラフ)
fig.add_trace(
go.Scatter(
x=filtered_df['day'],
y=filtered_df['snowfall_cm'],
name='降雪量',
mode='lines+markers',
line=dict(color='red', width=2),
marker=dict(size=6)
),
secondary_y=False
)
# レイアウト設定
fig.update_layout(
title=f"{year}年{month}月 / {location}",
xaxis_title="日",
yaxis_title="cm",
hovermode='x unified',
height=400,
showlegend=True,
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1
)
)
# X軸の範囲を1-31に設定
fig.update_xaxes(range=[0.5, 31.5], dtick=5)
return fig
def get_latest_available_month(url_df: pd.DataFrame) -> Tuple[int, int]:
"""
データが存在する最新の年月を取得
Args:
url_df: URL一覧データ
Returns:
(年, 月) のタプル
"""
if url_df.empty:
current_date = datetime.now()
return current_date.year, current_date.month
# 年月でソート
sorted_df = url_df.sort_values(['年', '月'], ascending=False)
latest = sorted_df.iloc[0]
return int(latest['年']), int(latest['月'])
def main():
"""メインアプリケーション"""
# ページ設定
st.set_page_config(
page_title="妙高市 降雪・積雪データ可視化",
page_icon="❄️",
layout="wide"
)
# タイトル
st.title("❄️ 妙高市 降雪・積雪データ可視化")
st.markdown("---")
# URL一覧の読み込み
url_df = load_url_data()
if url_df.empty:
st.error("データURLが読み込めません。data_urls.csv を確認してください。")
return
# 利用可能な年月のリストを作成
available_years = sorted(url_df['年'].unique())
# 最新の年月を取得
latest_year, latest_month = get_latest_available_month(url_df)
# サイドバー
st.sidebar.header("📊 表示条件設定")
st.sidebar.markdown("最大3件まで選択できます")
# 条件選択(最大3件)
selections = []
for i in range(3):
st.sidebar.markdown(f"### 条件 {i+1}")
col1, col2 = st.sidebar.columns(2)
with col1:
# デフォルト値を設定(最新月)
default_year_idx = available_years.index(latest_year) if latest_year in available_years else 0
year = st.selectbox(
"年",
options=available_years,
index=default_year_idx,
key=f"year_{i}"
)
with col2:
# 選択された年で利用可能な月を取得
available_months = sorted(url_df[url_df['年'] == year]['月'].unique())
default_month_idx = available_months.index(latest_month) if latest_month in available_months else 0
month = st.selectbox(
"月",
options=available_months,
index=default_month_idx,
key=f"month_{i}"
)
# 観測地点(デフォルトで各地点を割り当て)
default_location_idx = i if i < len(LOCATIONS) else 0
location = st.sidebar.selectbox(
"観測地点",
options=LOCATIONS,
index=default_location_idx,
key=f"location_{i}"
)
selections.append({
'year': year,
'month': month,
'location': location
})
st.sidebar.markdown("---")
# 重複チェック
unique_selections = []
seen = set()
for sel in selections:
key = (sel['year'], sel['month'], sel['location'])
if key not in seen:
unique_selections.append(sel)
seen.add(key)
else:
st.sidebar.warning(f"⚠️ {sel['year']}年{sel['month']}月 / {sel['location']} が重複しています")
# メイン表示エリア
st.markdown("## 📈 グラフ表示")
# 各条件についてグラフを表示
for idx, sel in enumerate(unique_selections):
year = sel['year']
month = sel['month']
location = sel['location']
# URLを取得
url_row = url_df[(url_df['年'] == year) & (url_df['月'] == month)]
if url_row.empty:
st.warning(f"⚠️ {year}年{month}月のデータはありません")
continue
url = url_row.iloc[0]['URL']
# データ取得
with st.spinner(f"{year}年{month}月のデータを取得中..."):
df = fetch_snow_data(url, year, month)
if df is None or df.empty:
st.error(f"❌ {year}年{month}月 / {location} のデータ取得に失敗しました")
continue
# グラフ作成
fig = create_snow_graph(df, year, month, location)
st.plotly_chart(fig, use_container_width=True)
# フッター
st.markdown("---")
st.markdown("""
<div style='text-align: center; color: #888; font-size: 0.9em;'>
データ出典: <a href='https://www.city.myoko.niigata.jp/life-info/snow-info/snow/' target='_blank'>妙高市 雪情報ホームページ</a><br>
観測時刻: 9時 | 降雪量: 前日分 | 積雪量: 当日分
</div>
""", unsafe_allow_html=True)
if __name__ == "__main__":
main()GitHubへのコミット
生成されたファイルは、GitHubにコミットしました。
今回のコミットは以下のリンクから確認できます。
次回予告:動作確認と修正
さて、ここまでは「AIが書いてくれた」話です。
でも、実は このコードはまだ正しく動きません。
(READMEの冒頭にも「動作確認中です」と書いてあります)
次回は、
実際に動かしてみて、問題点を確認し、修正していく
工程に進みます。
AIが書いたコードを、人間が確認して直していく。
その過程こそが、一番の学びになるはずです。
お楽しみに。
今回のまとめ
要件定義書をAIに渡すと、1〜2分でコード一式が生成される
生成されたのは5ファイル:main.py, data_urls.csv, requirements.txt, README.md, .gitignore
コードは「データを読む」「加工する」「表示する」を関数で分けた構造
キャッシュ機能、エラー処理など、要件通りの実装がされていた
ただし、まだ動作確認前。次回で修正していく
学びポイント 💡
「設計図があると、AIの精度が上がる」
今回、AIエージェントがスムーズにコードを書けたのは、
前回しっかり要件定義書を作っておいたからです。
曖昧なお願いより、具体的な設計図。
これは、AIとの協業で一番大事なポイントかもしれません。
続く
書いている人について
投稿者の紹介です。
noteでは、次の3つを軸に、日々の学びや記録を残しています。
Python・AI
趣味の開発で試したこと、つまずいた点、学びの記録健康・からだ
自分自身の不調や気づきをもとにした、学びと養生の記録生まれ故郷への想い
新潟・妙高(小局)にまつわる歴史や記憶を残すための記録
👉 マイペースで続けている記録はこちら
👉アプリ開発に関するマガジン
