1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

オープンデータをBeautifulSoupにするの巻 第11話

Last updated at Posted at 2025-01-05

イントロダクション

どうしてあなたがこのページに辿り着いたかは、私にはわかりません。なんとなく検索していて見つけたのか、もしくはタイトルにご興味を持っていただいたのか、いずれにせよもっと学びたいという、意識の高い方なのかと想定します。

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でカンマ区切りをタブ区切りにします。

スクリーンショット 2025-01-05 16.55.38.png

スプレッドシートに貼付します。

スクリーンショット 2025-01-05 16.36.10.png

スプレッドシート関数

データベースに入れる為の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で最寄り駅を取得し、徒歩時間を逆算。そして、乗車予定時刻を表示できています。

IMG_54A808EB7FC7-1.jpeg

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されるようになりました。

スクリーンショット 2025-01-05 19.16.48.png

方角算定

次に、ログデータが2つあれば、最新のupdated_atと次に新しいupdate_atの2点間を結んで方角を算出するようにします。

スクリーンショット 2025-01-05 21.09.20.png

// バックエンド:方位計算関数
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>
          ))}

成果物

結果は以下です。

IMG_B81537F8ED80-1.jpeg

「その他の方向を隠す」ボタンを押した時。

IMG_1287.PNG

リリースノート

一旦これでリリースノートを書いて、1/12予定でリリースすることにします。

IMG_8A7626A9648B-1.jpeg

それではまたいつか会う日まで、ごきげんよう🍀

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?