イントロダクション
どうしてあなたがこのページに辿り着いたかは、私にはわかりません。なんとなく検索していて見つけたのか、もしくはタイトルにご興味を持っていただいたのか、いずれにせよもっと学びたいという、意識の高い方なのかと想定します。
AI革命が起こった昨今、フロントとかバックエンドとか分ける必要がなくなってきたように最近感じています。だって、7、80点くらいのコードはAIがほとんど書いてくれますから。「フルスタックでなければ、価値がない」。言い過ぎかもしれませんが、その傾向は今後ますます強くなっていくように思います。
そんなフルスタックを志す方に向け、何かのヒントになればと思い、ゼロからアプリを開発する方法を公開したいと思います。本当にゼロから、でも極力冗長な説明は省き、最小文字で。
路線アプリを開発します。AIエージェント元年と言われる今年(2025)ですから、人の役に立つAIを活用した路線アプリにしたいと思います。それでは、参ります。
オープンデータの活用
地元札幌市のオープンデータを例に、Pythonでデータ抽出し、それをPostgreSQLに格納し、LambdaでAPIを作り、React Nativeで表示します。名付けて、PPLR作戦。
PPLRのP
札幌市のオープンデータはPDFかHTMLで開示されています。「なぜJsonではないんだぁ〜」、という心の声はここに留め、HTMLからBeautifulSoupで抜き出します。
import requests
from bs4 import BeautifulSoup
import re
def format_timetable(hours_minutes):
formatted = []
current_hour = None
for hour, minutes in hours_minutes:
if hour != current_hour:
if formatted:
formatted.append(' ')
formatted.append(f"{hour} ")
current_hour = hour
formatted.extend(minutes.split())
return ' '.join(formatted)
def extract_route_and_station(title):
route_parts = title.split('線')
route_name = route_parts[0] + '線'
station_name = route_parts[1].split('時刻表')[0].strip()
return route_name, station_name
def scrape_timetable(url):
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
try:
response = requests.get(url, headers=headers)
response.encoding = 'utf-8'
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
title = soup.find('h1').text.strip()
route_name, station_name = extract_route_and_station(title)
# ページから方面を取得
directions = [h2.text.strip() for h2 in soup.find_all('h2')]
if not directions:
return
# 動的に取得した方面で辞書を初期化
timetables = {direction: {'平日': [], '土曜日・日曜日・祝日': []} for direction in directions}
current_direction = None
current_schedule = None
for element in soup.find_all(['h2', 'h3', 'table']):
if element.name == 'h2':
current_direction = element.text.strip()
elif element.name == 'h3':
schedule_type = element.text.strip()
if '平日' in schedule_type:
current_schedule = '平日'
elif '土曜日・日曜日・祝日' in schedule_type:
current_schedule = '土曜日・日曜日・祝日'
elif element.name == 'table' and 'list' in element.get('class', []):
if current_direction and current_schedule:
times = []
for row in element.find_all('tr'):
hour = row.find('th').text.strip()
minutes = row.find('td').text.strip()
times.append((hour, minutes))
timetables[current_direction][current_schedule] = times
# 各方面・曜日区分の時刻表を出力
for direction in directions:
for schedule_type in ['平日', '土曜日・日曜日・祝日']:
times = timetables[direction][schedule_type]
if times:
timetable_str = format_timetable(times)
print(f"{route_name},{station_name},{direction},{schedule_type},{timetable_str}")
except Exception as e:
print(f"Error processing {url}: {str(e)}")
# 路線ごとの駅数
line_stations = {
'n': range(1, 17), # 南北線:N01-N16
't': range(1, 20), # 東西線:T01-T19
'h': range(1, 15) # 東豊線:H01-H14
}
base_url = 'https://www.city.sapporo.jp/st/subway/route_time/h26/'
# 各路線の全駅の時刻表を抽出
for line, stations in line_stations.items():
for i in stations:
url = f'{base_url}{line}{str(i).zfill(2)}.html'
scrape_timetable(url)
49ページ分一気にスクレイピング可能です。これで改訂があっても微調整で最新の情報を引っ張ってこれます。ちなみに、以下のコマンドで、クリップボードにコピーできるのでオススメ。
魔法コマンドpbcopy
python tmp.py | pbcopy
visual studioでカンマ区切りをタブ区切りにします。
スプレッドシートに貼付します。
スプレッドシート関数
データベースに入れる為のSQLを関数で生成。
// SQL用
="INSERT INTO ""Timetable"" (id, linetype, neareststation, direction, weekdayorend, timetable) VALUES("&A2&", '"&B2&"', '"&C2&"', '"&D2&"', '"&E2&"', '"&F2&"');"
// ID用
=row()-1
PPLRのPP
Prismaを使います。
DBスキーマ
model Timetable {
id Int @id @default(autoincrement())
linetype String // 南北線
neareststation String // 麻生駅
direction String // 真駒内方面
weekdayorend String // 平日
timetable String @db.Text // 6時 00 10 20... のような長い時刻表文字列
@@index([linetype, neareststation, direction, weekdayorend])
}
npx prisma migrate dev --name update_timetable_schemaコマンドで反映します。
psql
先ほどのSQLでデータベースにインサートし、184行のデータが入りました。
prisma=> select count(1) from "Timetable" ;
count
-------
184
(1 row)
PPLRのL
次に、入ったデータをAPIで抽出できるようにします。先ほど申し上げた通りLambdaを使います。オンデマンドで動いてほしいのと、マネージド環境で後々API制限しやすくしておく意図があります。
const { Client } = require('pg');
const dbConfig = {
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT || 5432,
};
exports.handler = async (event) => {
let client = null;
try {
let nearestStation, walkingMinutes;
if (typeof event === 'string') {
const parsedEvent = JSON.parse(event);
nearestStation = parsedEvent.nearestStation;
walkingMinutes = parsedEvent.walkingMinutes;
} else if (event.body) {
const parsedBody = JSON.parse(event.body);
nearestStation = parsedBody.nearestStation;
walkingMinutes = parsedBody.walkingMinutes;
} else {
nearestStation = event.nearestStation;
walkingMinutes = event.walkingMinutes;
}
if (!nearestStation || typeof walkingMinutes !== 'number') {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Invalid parameters. Required: nearestStation (string) and walkingMinutes (number)',
}),
};
}
const jstNow = new Date(new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }));
const arrivalTime = new Date(jstNow);
arrivalTime.setMinutes(arrivalTime.getMinutes() + walkingMinutes);
const currentHour = arrivalTime.getHours();
const arrivalMinute = arrivalTime.getMinutes();
client = new Client(dbConfig);
await client.connect();
const query = `
SELECT
linetype,
neareststation,
direction,
weekdayorend,
timetable
FROM "Timetable"
WHERE neareststation = $1
AND weekdayorend = $2;`;
const params = [
nearestStation,
arrivalTime.getDay() === 0 || arrivalTime.getDay() === 6 ? '土曜日・日曜日・祝日' : '平日'
];
const res = await client.query(query, params);
if (res.rows.length > 0) {
const schedules = res.rows.map(row => {
const timeTable = row.timetable;
const timeSegments = timeTable.split(/(\d+時\s)/);
let relevantTimes = [];
let currentHourFound = false;
let nextHourIncluded = false;
let currentHourString = '';
let currentHourTimes = '';
let nextHourTimes = '';
for (let i = 0; i < timeSegments.length; i++) {
const segment = timeSegments[i];
if (segment.includes('時')) {
const hour = parseInt(segment);
if (hour === currentHour) {
currentHourFound = true;
currentHourString = `${hour}時`;
const minutes = timeSegments[i + 1].trim();
currentHourTimes = minutes;
relevantTimes = minutes.split(/\s+/).filter(m => parseInt(m) >= arrivalMinute);
} else if (currentHourFound && !nextHourIncluded && (hour === (currentHour + 1) || (currentHour === 23 && hour === 0))) {
nextHourIncluded = true;
nextHourTimes = timeSegments[i + 1].trim();
const minutes = timeSegments[i + 1].trim().split(/\s+/);
relevantTimes = relevantTimes.concat(minutes);
}
}
}
const nextTrains = relevantTimes.slice(0, 3);
return {
direction: row.direction,
linetype: row.linetype,
nearestStation: row.neareststation,
weekdayOrEnd: row.weekdayorend,
currentHour: currentHourString,
nextHour: currentHour === 23 ? "0時" : `${currentHour + 1}時`,
currentHourTimes: currentHourTimes,
nextHourTimes: nextHourTimes,
nextTrains: nextTrains,
searchInfo: {
queryTime: jstNow.toLocaleTimeString("ja-JP"),
arrivalTime: arrivalTime.toLocaleTimeString("ja-JP"),
walkingMinutes: walkingMinutes,
}
};
});
return {
statusCode: 200,
body: JSON.stringify(schedules),
};
} else {
return {
statusCode: 404,
body: JSON.stringify({
error: 'No schedules found',
searchParams: {
station: nearestStation,
searchTime: jstNow.toLocaleTimeString('ja-JP'),
arrivalTime: arrivalTime.toLocaleTimeString('ja-JP'),
walkingMinutes: walkingMinutes,
dayType: arrivalTime.getDay() === 0 || arrivalTime.getDay() === 6 ? '土曜日・日曜日・祝日' : '平日'
},
}),
};
}
} catch (error) {
console.error('Error processing request:', error);
return {
statusCode: 500,
body: JSON.stringify({
error: 'Internal server error',
message: error.message,
type: error.name,
}),
};
} finally {
if (client) {
try {
await client.end();
} catch (error) {
console.error('Error closing database connection:', error);
}
}
}
};
PPLRのR
スキーマに合わせ、前回のフロントエンドコードを更新します。
import React, { useEffect, useState } from "react";
import * as Location from "expo-location";
import { ThemedText } from "./ThemedText";
import { Alert, Button, View } from "react-native";
import Constants from "expo-constants";
interface Station {
coords: {
lat: number;
lng: number;
};
name: string;
}
interface Coordinates {
latitude: number;
longitude: number;
}
interface APIError {
message: string;
status?: number;
}
interface SubwaySchedule {
direction: string;
linetype: string;
nearestStation: string;
weekdayOrEnd: string;
currentHour: string;
nextHour: string;
currentHourTimes: string;
nextHourTimes: string;
nextTrains: string[];
searchInfo: {
queryTime: string;
arrivalTime: string;
walkingMinutes: number;
};
}
const SubwayTimer: React.FC = () => {
const [location, setLocation] = useState<Location.LocationObject | null>(
null
);
const [nearestStation, setNearestStation] = useState<Station | null>(null);
const [walkingTime, setWalkingTime] = useState<number | null>(null);
const [subwaySchedules, setSubwaySchedules] = useState<SubwaySchedule[]>([]);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const apiKey = Constants.expoConfig?.extra?.API_KEY;
const subwayApiKey = Constants.expoConfig?.extra?.SUBWAY_API_KEY;
useEffect(() => {
initializeSubwayTimer();
}, []);
const initializeSubwayTimer = async () => {
try {
if (!apiKey || !subwayApiKey) {
throw new Error("必要なAPIキーが設定されていません");
}
await requestLocationPermission();
const currentLocation = await getCurrentLocation();
setLocation(currentLocation);
const station = await findNearestSubwayStation(
currentLocation.coords.latitude,
currentLocation.coords.longitude
);
setNearestStation(station);
const walkTime = await calculateWalkingTime(
currentLocation.coords,
station.coords
);
setWalkingTime(walkTime);
await postNearestStationAndWalkingTime(station.name, walkTime);
} catch (err) {
const error = err as Error;
console.error("初期化エラー:", error);
setError(error.message);
Alert.alert("エラー", error.message || "予期せぬエラーが発生しました");
} finally {
setIsLoading(false);
}
};
const handleRefresh = () => {
initializeSubwayTimer();
};
const requestLocationPermission = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
throw new Error("位置情報の許可が必要です");
}
};
const getCurrentLocation = async () => {
try {
return await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
} catch (error) {
throw new Error("現在位置を取得できませんでした");
}
};
const findNearestSubwayStation = async (
lat: number,
lng: number
): Promise<Station> => {
try {
const response = await fetch(
`https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=1000&type=subway_station&key=${apiKey}`
);
const data = await response.json();
if (!response.ok) {
throw new Error(
`最寄り駅の検索に失敗しました: ${data.status || response.status} - ${
data.error_message || "不明なエラー"
}`
);
}
if (!data.results || data.results.length === 0) {
throw new Error("近くに地下鉄駅が見つかりませんでした");
}
const station = data.results[0];
if (
!station.geometry?.location?.lat ||
!station.geometry?.location?.lng
) {
throw new Error("駅の座標情報が不正です");
}
return {
name: station.name,
coords: {
lat: station.geometry.location.lat,
lng: station.geometry.location.lng,
},
};
} catch (error) {
if (error instanceof Error) {
console.error("Station search error:", error.message);
if (__DEV__) {
console.error("Full error:", error);
}
throw error;
}
throw new Error("駅情報の取得に失敗しました");
}
};
const calculateWalkingTime = async (
origin: Coordinates,
destination: Station["coords"]
): Promise<number> => {
try {
const response = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${origin.latitude},${origin.longitude}&destination=${destination.lat},${destination.lng}&mode=walking&key=${apiKey}`
);
if (!response.ok) {
throw new Error("経路の取得に失敗しました");
}
const data = await response.json();
if (
!data.routes?.length ||
!data.routes[0].legs?.length ||
!data.routes[0].legs[0].duration
) {
throw new Error("経路情報が不正です");
}
return Math.ceil(data.routes[0].legs[0].duration.value / 60);
} catch (error) {
throw new Error("徒歩時間の計算に失敗しました");
}
};
const postNearestStationAndWalkingTime = async (
stationName: string,
walkingMinutes: number
): Promise<void> => {
try {
if (!subwayApiKey) {
throw new Error("SUBWAY_API_KEYが設定されていません");
}
const requestBody = JSON.stringify({
nearestStation: stationName,
walkingMinutes: walkingMinutes,
});
const response = await fetch(
"https://udtetq5gol.execute-api.ap-northeast-1.amazonaws.com/staging",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": subwayApiKey.trim(),
},
body: requestBody,
}
);
const responseText = await response.text();
if (!response.ok) {
let errorMessage = "データの送信に失敗しました";
try {
const errorData = JSON.parse(responseText) as APIError;
errorMessage = errorData.message || errorMessage;
} catch (e) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
const jsonResponse = JSON.parse(responseText);
const scheduleData = JSON.parse(jsonResponse.body);
setSubwaySchedules(scheduleData);
} catch (error) {
if (error instanceof Error) {
throw new Error(`データ送信エラー: ${error.message}`);
}
throw new Error("データの送信に失敗しました");
}
};
const formatDepartureInfo = (schedule: SubwaySchedule) => {
if (!schedule.nextTrains || schedule.nextTrains.length === 0) {
return "本日の運行は終了しました";
}
const [first, ...rest] = schedule.nextTrains;
return `${schedule.currentHour} ${first}分 ${
rest.length > 0 ? `(${rest.join("分 ")}分)` : ""
}`;
};
if (isLoading) {
return <ThemedText>読み込み中...</ThemedText>;
}
if (error) {
return <ThemedText>エラー: {error}</ThemedText>;
}
return (
<View>
<ThemedText>
最寄り駅:{nearestStation?.name || "取得できません"}
</ThemedText>
<ThemedText>
徒歩時間: {walkingTime ? `${walkingTime}分` : "計算中..."}
</ThemedText>
{Array.isArray(subwaySchedules) &&
subwaySchedules.map((schedule, index) => (
<ThemedText key={index}>
{schedule.linetype} {schedule.direction}:
{formatDepartureInfo(schedule)}
</ThemedText>
))}
<Button title="🔁" onPress={handleRefresh} />
</View>
);
};
export default SubwayTimer;
GPSで最寄り駅を取得し、徒歩時間を逆算。そして、乗車予定時刻を表示できています。
AI要素をまぶす
次に、行かない方面の情報はいらないので、それを自動学習するように実装します。
// モデル
model GpsLog {
id Int @id @default(autoincrement())
latitude Float
longitude Float
nearest_station String @unique
walking_minutes Int
arrival_time DateTime
query_time DateTime
created_at DateTime @default(now())
updated_at DateTime @default(now())
}
// 反映コマンド
npx prisma migrate dev --name create_gpslog_schema
// バックエンド更新:GpsLogテーブルにデータをUPSERTするクエリ
const upsertGpsLogQuery = `
INSERT INTO "GpsLog" (
nearest_station,
walking_minutes,
latitude,
longitude,
arrival_time,
query_time,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (nearest_station)
DO UPDATE SET
walking_minutes = $2,
latitude = $3,
longitude = $4,
arrival_time = $5,
query_time = $6,
updated_at = NOW()
;
`;
// フロントエンド更新:GpsLogテーブル保存用のlocation情報を追加
const requestBody = JSON.stringify({
nearestStation: stationName,
walkingMinutes: walkingMinutes,
location: {
latitude: latitude,
longitude: longitude,
},
});
Upsertされるようになりました。
方角算定
次に、ログデータが2つあれば、最新のupdated_atと次に新しいupdate_atの2点間を結んで方角を算出するようにします。
// バックエンド:方位計算関数
function calculateDirection(lat1, lon1, lat2, lon2) {
const toRadians = (degrees) => degrees * (Math.PI / 180);
const toDegrees = (radians) => radians * (180 / Math.PI);
const dLon = toRadians(lon2 - lon1);
const y = Math.sin(dLon) * Math.cos(toRadians(lat2));
const x = Math.cos(toRadians(lat1)) * Math.sin(toRadians(lat2)) -
Math.sin(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.cos(dLon);
let bearing = toDegrees(Math.atan2(y, x));
bearing = (bearing + 360) % 360; // 方位角を0°~360°に正規化
return bearing;
}
方角テーブルも作成し、該当の方角であればそこをカラーリングします。
model DirectionAngle {
id Int @id @default(autoincrement())
direction String @unique // 真駒内方面、麻生方面など
angle Float // 方角(度数)
description String? // 方角の説明(南、北など)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
// バックエンド:方向判定
const targetAngle = directionAngles[row.direction];
const angleDiff = direction && targetAngle != null ? getAngleDifference(direction, targetAngle) : null;
const colored = direction && targetAngle != null ? angleDiff <= ANGLE_THRESHOLD : false;
それから、色無しは初期表示で隠すようにします。
// フロントエンド:表示コントロール
{/* 進行方向の路線(colored: true)は常に表示 */}
{Array.isArray(subwaySchedules) &&
subwaySchedules
.filter((schedule) => schedule.colored)
.map((schedule, index) => (
<Text
key={`colored-${index}`}
style={{
color: "#4A90E2",
marginVertical: 4,
fontWeight: "bold",
}}
>
{schedule.linetype} {schedule.direction}:
{formatDepartureInfo(schedule)}
</Text>
))}
{/* その他の方向の路線がある場合、トグルボタンを表示 */}
{hasColored && (
<TouchableOpacity
onPress={() => setShowOtherDirections(!showOtherDirections)}
style={{
padding: 8,
marginVertical: 8,
backgroundColor: "#f0f0f0",
borderRadius: 4,
}}
>
<Text style={{ color: "gray" }}>
{showOtherDirections
? "▼ その他の方向を隠す"
: "▶ その他の方向を表示"}
</Text>
</TouchableOpacity>
)}
{/* その他の方向の路線(colored: false)は条件付きで表示 */}
{Array.isArray(subwaySchedules) &&
(!hasColored || showOtherDirections) && // 進行方向の路線がない場合は常に表示
subwaySchedules
.filter((schedule) => !schedule.colored)
.map((schedule, index) => (
<Text
key={`other-${index}`}
style={{
color: "black",
marginVertical: 4,
}}
>
{schedule.linetype} {schedule.direction}:
{formatDepartureInfo(schedule)}
</Text>
))}
成果物
結果は以下です。
「その他の方向を隠す」ボタンを押した時。
リリースノート
一旦これでリリースノートを書いて、1/12予定でリリースすることにします。
それではまたいつか会う日まで、ごきげんよう🍀