京阪電車の「列車走行位置」ページを解析して、列車位置ライブラリを作った話

この記事は、私が中3の社会科探究の授業で個人として作成した探究レポートを、公開用にリライトしたものです。
はじめに
⚠️ この記事で扱うAPIは京阪電車が公式に提供・公開しているものではなく、公開Webページの内部で読み込まれているJSONを解析したものです。仕様は予告なく変わり得ますし、二次利用には制約があります(末尾の「注意点と免責」を参照)。あくまで技術的な調査記録として読んでください。
学校の最寄り駅と校舎の間が徒歩1.5kmほどあり、下校時にどの電車が来るのかを掲示するデジタルサイネージを作りたい、というのが出発点だった。そのためにはまず「いま列車がどこを走っているか」を機械可読な形で取得する必要がある。
京阪電車には列車走行位置というページがあり、リアルタイムで列車の位置が路線図上に表示される。ただし当然ながら「◯◯駅に次に来る列車を教えてくれるAPI」がドキュメント付きで公開されているわけではない。そこで、このページがどうやってデータを取得しているかをブラウザから解析し、その結果を keihan_tracker というPythonライブラリにまとめた。

この記事では、その調査過程と、特にハマった「次の停車駅の算出」について書く。同じように公開Webページの内部APIを解析したい人にも参考になると思う。
エンドポイントを見つける
やることは単純で、Chromeの開発者ツールでページが裏で叩いているエンドポイントを探すだけだ。
- 「列車走行位置」ページを開く
F12で開発者ツールを開き、Network タブへ- フィルターを Fetch/XHR に絞る
- ページをリロード
こうすると、JavaScriptから fetch されているファイルだけが並ぶ。京阪のページからは以下のJSONが読み込まれていた。
| エンドポイント | 役割 |
|---|---|
select_station.json | 路線ごとの駅リスト(駅番号・駅名・多言語) |
startTimeList.json | その日の列車ごとの停車駅・時刻(ダイヤ) |
transferGuideInfo.json | 駅ごとの乗り換え路線情報 |
trainPositionList.json | 走行中の列車ごとの位置・種別・遅延 |
このうち、リアルタイム性があるのは trainPositionList.json だけで、駅リストやダイヤは静的データに近い。ライブラリでも、駅・ダイヤは初回のみ取得し、位置情報だけをポーリングで更新する構造にした。ちなみに中身こんな感じ:
{
"delay": "",
"delayEn": "",
"delayKo": "",
"delayZhCn": "",
"delayZhTw": "",
"locationCol": "4",
"locationRow": "64",
"trainDirection": "1",
"trainIconTypeImageJp": "JP21_0_0_Pre_D.png",
"trainInfoObjects": [
{
"carsOfTrain": "8",
"delayMinutes": "",
"delayMinutesEn": "",
"delayMinutesKo": "",
"delayMinutesZhCn": "",
"delayMinutesZhTw": "",
"destStationCode": "1",
"destStationNameEn": "Yodoyabashi",
"destStationNameJp": "淀屋橋",
"destStationNameKo": "요도야바시",
"destStationNameZhCn": "淀屋桥",
"destStationNameZhTw": "淀屋橋",
"destStationNumber": "01",
"lastPassStation": "21",
"trainNumber": "0807",
"trainTypeEn": "Limited Exp.",
"trainTypeIcon": "JP21_0_0_Pre_D.png",
"trainTypeJp": "特急",
"trainTypeKo": "특급",
"trainTypeZhCn": "特急",
"trainTypeZhTw": "特急",
"wdfBlockNo": "246"
}
],
"trainTypeVisIconVis": "EN21_0_0_Pre_D.png"
}, ...ダイヤデータのstartTimeList.jsonはこんな感じ:
{
"fileCreatedTime": "20260706190215",
"fileVersion": "1.0.0",
"TrainInfo": [
{
"wdfBlockNo": "1",
"extTrain": "0",
"premiumCar": "0",
"trainCar": "13016",
"diaStationInfoObjects": [
{
"stationNumber": "401",
"stationDepTime": "-",
"stationNameJp": "三条",
"stationNameEn": "Sanjo",
"stationNameZhTw": "三條",
"stationNameZhCn": "三条",
"stationNameKo": "산조"
},
{
"stationNumber": "392",
"stationDepTime": "05:42",
"stationNameJp": "祇園四条",
"stationNameEn": "Gion-shijo",
"stationNameZhTw": "祇園四條",
"stationNameZhCn": "祇园四条",
"stationNameKo": "기온시조"
},
{
"stationNumber": "372",
"stationDepTime": "05:44",
"stationNameJp": "七条",
"stationNameEn": "Shichijo",
"stationNameZhTw": "七條",
"stationNameZhCn": "七条",
"stationNameKo": "시치조"
},
{
"stationNumber": "330",
"stationDepTime": "99:99",
"stationNameJp": "龍谷大前深草",
"stationNameEn": "Ryukokudai-mae-fukakusa",
"stationNameZhTw": "龍谷大前深草",
"stationNameZhCn": "龙谷大前深草",
"stationNameKo": "류코쿠다이마에 후카쿠사"
},
{
"stationNumber": "301",
"stationDepTime": "05:50",
"stationNameJp": "丹波橋",
"stationNameEn": "Tambabashi",
"stationNameZhTw": "丹波橋",
"stationNameZhCn": "丹波桥",
"stationNameKo": "단바바시"
},...JSONを型で固める
生のJSONを辞書のまま扱うと data["locationObjects"][0]["trainInfoObjects"][0]["delayMinutes"] のようなアクセスが増えて、キー名のtypoやAPI側の構造変更に気づけない。そこで Pydantic でモデルを定義し、取得したJSONを必ずバリデートしてから使うようにした。
例えば trainPositionList.json の1列車ぶんは、こんなモデルに落とし込んでいる(一部抜粋)。
from pydantic import BaseModel, model_validator
class trainInfoObject(BaseModel):
wdfBlockNo: int # 列車の内部管理番号
carsOfTrain: int # 車両数
delayMinutes: str # 遅延("約5分" など)
destStationCode: int # 行先の駅コード
destStationNameJp: str # 行先(日本語)
lastPassStation: int # ← 後述する曲者
trainNumber: str # 列車番号
trainTypeJp: TrainType # 種別(Enumに変換)
is_special: bool # 臨時列車か
型を付けておくとエディタの補完が効くし、API側が想定外の値を返したときにバリデーションエラーで即座に気づける。動的型付けのPythonで外部データを扱うなら、この「入口でバリデートする」パターンはほぼ必須だと思う。
種別(trainTypeJp)は文字列そのままではなく Enum に変換している。臨時列車は種別名の頭に「臨時」が付く仕様だったので、バリデータ内でフラグを立てつつ余分な文字列を除去している。
@model_validator(mode="before")
@classmethod
def check_type_special(cls, d: dict):
if "臨時" in d["trainTypeJp"]:
d["is_special"] = True
d["trainTypeJp"] = d["trainTypeJp"].replace("臨時 ", "")
else:
d["is_special"] = False
return d
なお通信は httpx の非同期クライアントを使っている。最終的にサイネージのバックエンド(FastAPI)から呼ぶことを見越して、最初から async/await 前提の設計にした。
本題:「次の停車駅」をどう出すか
ここが一番苦労した部分だ。
下校時のサイネージでやりたいことは「最寄り駅に、あと何分後に、どの列車が来るか」を出すこと。そのためには、走行中の全列車の中から「次に停車する駅が最寄り駅の列車」を抽出する必要がある。つまり各列車について「次の停車駅」が確定していないと始まらない。
罠1:lastPassStation は当てにならない
最初に目を付けたのが lastPassStation というパラメータだった。名前からして「最後に通過した駅」に見えるし、それが分かれば次の駅も出せそうに思える。
香里園駅で試したところ、確かにこの値は「現在停車している駅 or 次に停車する駅」を指しているように見えた。ところが別の駅で「次にこの駅に停まる列車」を取得しようとすると、来るはずの列車が全くヒットしない現象が起きた。
Google AI Studioも使いながら「列車走行位置」ページのJavaScriptを読み解いたところ、原因が分かった。このパラメータは列車詳細で停車時刻を「どの駅から表示し始めるか」の基準に使われている内部値で、「最後に通過した駅」として厳密に管理されているわけではなかった。香里園ではたまたま期待どおりに動いていただけで、少なくとも京橋〜萱島の各駅停車区間では、萱島や京橋、守口市などの手前の大きな駅の値を返す。

このスクリーンショットは大和田 (KH15) - 古川橋間 (KH14)を走行中の各駅停車で、lastPassStationが萱島駅である16となっているために既に通過したはずの大和田駅の到着予定時刻から表示されている。
プロジェクトとしては香里園か寝屋川市で動けば十分なので放置してもよかったが、将来の仕様変更で香里園でも壊れる可能性を考えて、別ロジックで算出することにした。ライブラリ側では lastpass_station プロパティは残しつつ「使用非推奨」と明記している。
罠2:座標から駅を割り出す
次に使ったのが locationCol / locationRow というパラメータ。これは路線図をグリッドとして描画するための座標で、本来は表示位置を決めるためのものだ。だが、位置情報として使えるのは実質これしかなかった。
京阪の路線図は縦方向のグリッドになっていて、row の値の範囲で路線と駅がだいたい決まる。例えば本線・鴨東線の通常区間では、3行ぶんが1駅ブロックに対応していて、ブロック内の位置(先頭/中間/末尾)で「停車中か、次の駅へ移動中か」が変わる。座標→駅区間の変換ロジックは、いわゆるバイブコーディングで組み立てた。おおまかにはこんな構造になっている。
def calc_position(col: int, row: int):
"""座標から、停車中の駅番号、もしくは走行中の2駅を返す"""
# 1. rowの範囲で路線を判定
if 1 <= row <= 131:
line = "京阪本線・鴨東線" # ※中之島線の分岐は別途判定
elif 132 <= row <= 153:
line = "宇治線"
elif 154 <= row <= 175:
line = "交野線"
# 2. 本線通常区間なら 3行 = 1駅ブロック
if row <= 117:
block_index = (row - 1) // 3
current_station = 42 - block_index # KH42 出町柳 から逆算
pos_in_block = (row - 1) % 3 # 0:停車, それ以外:走行中
if pos_in_block == 0:
return (line, current_station, None) # 停車中
else:
return (line, current_station, current_station - 1) # 移動中
...
ただし地下区間(天満橋〜淀屋橋、中之島線)は路線図上で隙間なく詰まっているため通常のブロック計算が効かず、row の範囲で個別に駅を割り当てるハードコーディングで対応している。ここは京阪の路線図の描画都合に完全に依存しているので、泥臭くやるほかなかった。
罠3:駅番号の +1/-1 では隣の駅にたどり着けない
走行中の区間が分かったら、そこから進行方向へ「停車駅リストに含まれる最初の駅」を探せば次の停車駅が出る。最初は単純に駅番号を +1 / -1 しながら探索しようとした。
これは本線の連続した区間ではうまくいくが、駅番号が飛ぶ接続部分で破綻する。京阪の駅ナンバリングは路線ごとに振られていて、
- 中之島線(KH51〜KH54)は本線の天満橋(KH03)に接続する
- 交野線(KH61〜KH67)は枚方市(KH21)に接続する
- 宇治線(KH71〜KH77)は中書島(KH28)に接続する
というように、接続駅で番号が不連続にジャンプする。番号を単純にインクリメントする方式だと、天満橋(3番)の次に中之島線のなにわ橋(51番)へ進むような遷移を正しく追えない。
そこで、番号探索をやめて路線ごとに実際の駅の並び順を配列で定義する方式に変えた。これが stations_map.py だ。
KYOBASHI_TO_DEMACHIYANAGI = [i for i in range(4, 42 + 1)]
UJI_UP = [77, 76, 75, 74, 73, 72, 71, 28]
KATANO_UP = [67, 66, 65, 64, 63, 62, 61, 21]
HONNSEN_UP = [1, 2, 3] + KYOBASHI_TO_DEMACHIYANAGI
NAKANOSHIMA_UP = [54, 53, 52, 51, 3] + KYOBASHI_TO_DEMACHIYANAGI
NAKANOSHIMA_UP の [54, 53, 52, 51, 3, 4, 5, ...] を見ると、なにわ橋(51番)の次が天満橋(3番)という不連続な接続も、配列上では単に「隣の要素」として表現できているのが分かる。番号ではなく順序で持つことで、接続部分の飛びを気にせず「次の要素」を辿れる。
最終的な「次の停車駅」算出ロジックはこうなった。
def next_stop_station(self):
next_station = self.next_station.station_number
stop_stations = [s.station.station_number for s in self.stop_stations]
# 1. 路線に対応する駅順リストを取得
line = {
"中之島線": NAKANOSHIMA_UP,
"交野線": KATANO_UP,
"京阪本線・鴨東線": HONNSEN_UP,
"宇治線": UJI_UP,
}[self.line]
# 2. 下りならリストを反転
if self.direction == "down":
line = list(reversed(line))
# 3. 現在地がすでに停車駅ならそれを返す
if next_station in stop_stations:
return self.master.stations[next_station]
# 4. リストを前方に探索し、停車駅に最初にヒットした駅を返す
index = line.index(next_station)
for i in range(index, len(line)):
if line[i] in stop_stations:
return self.master.stations[line[i]]
return None
流れをまとめると、
- 座標から走行中の路線と方向(上り/下り)を特定する
- その路線の駅順リストを取得する(下りは反転)
- 現在地がリストの何番目かを求める
- そこから順に、その列車の停車駅リストに含まれる最初の駅を「次の停車駅」とする
これで、接続部分をまたぐ列車でも正しく次の停車駅を出せるようになった。
ダイヤと位置を統合する
trainPositionList.json(走行中の位置)と startTimeList.json(ダイヤ)は別々のJSONだが、どちらも wdfBlockNo という列車の内部管理番号を持っている。これをキーに両者を突き合わせることで、「いまどこを走っているか」と「この先どの駅に何時に停まるか」を1つの列車オブジェクトとして扱えるようにした。
ライブラリ上では、走行中の列車を ActiveTrainData、ダイヤ上の予定・運行終了ぶんを TrainData として区別し、前者が後者を継承する形にしている。位置情報を持つのは ActiveTrainData だけで、isinstance() で判別できる。
面白いのは、ダイヤデータ側には「種別」や「進行方向」が入っていないこと。これらは停車駅のパターンから推定するしかない。例えば「野江(KH05)に停車するなら普通」「京橋を通らず鳥羽街道に停まるなら普通」といったルールを積み上げて種別を判定している。方向は始発駅と終着駅の番号の大小から推定する(目的地の番号のほうが若ければ大阪方面=下り、など)。走行中の ActiveTrainData はAPIから正確な種別・方向が返るので、そちらを優先する。
得られたもの
最終的に、駅を指定すれば「今後この駅に停車する列車と到着時刻」がリストで取れるところまで実装できた。
import asyncio
from keihan_tracker import KHTracker
async def main():
tracker = KHTracker()
await tracker.fetch_pos() # これを呼ばないとデータは空
station = tracker.stations[17] # KH17 寝屋川市
next_train, arrival = station.upcoming_trains[0]
print(next_train)
asyncio.run(main())
当初の目的だったサイネージ側でも、この出力を試しに京橋駅などのLCD発車標風のデザインで描画するところまで進められた。

公開目的で再現すると、著作権違反にあたる可能性があるので注意しよう。
注意点と免責
最後に、この手の非公式解析にはつきものの注意点を整理しておく。
- 公式APIではない。 ここで使ったエンドポイントは公開ページの内部実装であり、いつ構造が変わっても文句は言えない。実際、座標→駅の変換は路線図の描画都合に強く依存しているので、路線図がリニューアルされれば書き直しになる。
- 取得頻度に配慮する。 サーバーに負荷をかけないよう、ライブラリ側にはデフォルトで15秒のレート制限を内蔵した。京阪側の更新も1分間隔なので、それより高頻度で叩く意味はない。
- 二次利用の可否は別問題。 個人的・実験的に解析するのと、それを公開サービスとして稼働させるのは話が違う。特に商用や公共設置を考える場合は、データ提供元への確認が必要になる。京阪バス(BUS NAVI)やYahoo!路線情報の運行データは利用規約で商業的二次利用が禁止されており、私的利用の範囲にとどめている。
- あくまで自己責任で。
ライブラリはMITライセンスで公開している。詳しい仕様はリポジトリのREADMEにまとめたので、興味があれば覗いてみてほしい。
参考
- 大阪電気通信大学プログラミング研究会の運行情報掲示板(着想を得た事例。コードはゼロから独自に構築)
