{"id":2113,"date":"2026-07-05T22:50:37","date_gmt":"2026-07-05T13:50:37","guid":{"rendered":"https:\/\/butsuri-kori.net\/?p=2113"},"modified":"2026-07-06T20:15:50","modified_gmt":"2026-07-06T11:15:50","slug":"keihan_tracker","status":"publish","type":"post","link":"https:\/\/butsuri-kori.net\/en\/tech\/keihan_tracker\/","title":{"rendered":"I Reverse-Engineered Keihan Railway's Live Train Position Page and Built an Unofficial Train-Tracking Library"},"content":{"rendered":"\n<figure class=\"wp-block-image aligncenter size-full is-style-vk-image-rounded\"><img decoding=\"async\" width=\"1398\" height=\"735\" src=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8-2026-07-05-230539-1.png\" alt=\"\" class=\"wp-image-2150\" style=\"aspect-ratio:1.9065519992907172\" srcset=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8-2026-07-05-230539-1.png 1398w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8-2026-07-05-230539-1-300x158.png 300w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8-2026-07-05-230539-1-1024x538.png 1024w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8-2026-07-05-230539-1-768x404.png 768w\" sizes=\"(max-width: 1398px) 100vw, 1398px\" \/><\/figure>\n\n\n\n<div class=\"wp-block-vk-blocks-alert vk_alert alert alert-info has-alert-icon\"><div class=\"vk_alert_icon\"><div class=\"vk_alert_icon_icon\"><i class=\"fa-solid fa-circle-info\" aria-hidden=\"true\"><\/i><\/div><div class=\"vk_alert_icon_text\"><span>Information<\/span><\/div><\/div><div class=\"vk_alert_content\">\n<p class=\"wp-block-paragraph\">This article was written by Claude (AI).<\/p>\n<\/div><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">This article is a rewrite, for public release, of an independent research report I originally wrote for a social studies inquiry course in my third year of junior high school.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Introduction<\/h2>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">\u26a0\ufe0f The API discussed in this article is not officially provided or published by Keihan Electric Railway. It's the result of analyzing JSON that is loaded internally by a public web page. The specification can change without notice, and there are restrictions on secondary use (see \"Notes and Disclaimer\" at the end). Please read this purely as a technical investigation record.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">The starting point was wanting to build digital signage that shows which train is coming when leaving school, since the walk between the nearest station and the school building is about 1.5 km. To do that, I first need to obtain, in a machine-readable form, where trains currently are.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keihan Railway has a page called <a href=\"https:\/\/www.keihan.co.jp\/zaisen\/\">Train Position<\/a> that shows train locations on a route map in real time. Of course, there isn't a documented, publicly available API that tells you \"which train is coming next to station XX.\" So I analyzed, from the browser, how this page fetches its data, and compiled the results into a Python library called  <a href=\"https:\/\/github.com\/dk-butsuri\/keihan_tracker\"><code>keihan_tracker<\/code><\/a> .<\/p>\n\n\n\n<figure class=\"wp-block-image aligncenter size-large is-resized is-style-vk-image-photoFrame-tilt-left\"><a href=\"https:\/\/github.com\/dk-butsuri\/keihan_tracker\"><img decoding=\"async\" width=\"1024\" height=\"512\" src=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-1024x512.png\" alt=\"\" class=\"wp-image-2057\" style=\"width:588px;height:auto\" srcset=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-1024x512.png 1024w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-300x150.png 300w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-768x384.png 768w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image.png 1200w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\"><a href=\"https:\/\/github.com\/dk-butsuri\/keihan_tracker\">https:\/\/github.com\/dk-butsuri\/keihan_tracker<\/a><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This article covers that investigation process, and especially the \"calculating the next stop\" problem that gave me the most trouble. I hope it's useful to others who want to analyze the internal APIs of public web pages in a similar way.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Finding the Endpoints<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The approach is simple: just use Chrome DevTools to find the endpoints the page is hitting behind the scenes.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Open the \"Train Position\" page<\/li>\n\n\n\n<li><code>F12<\/code>  to open DevTools, then go to the <strong>Network<\/strong>  tab<\/li>\n\n\n\n<li>Filter to  <strong>Fetch\/XHR<\/strong>  only<\/li>\n\n\n\n<li>Reload the page<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">This shows only the files being fetched via JavaScript's  <code>fetch<\/code> . The following JSON files were loaded from Keihan's page.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Endpoint<\/th><th>Role<\/th><\/tr><\/thead><tbody><tr><td><code>select_station.json<\/code><\/td><td>Station list per line (station numbers, names, multiple languages)<\/td><\/tr><tr><td><code>startTimeList.json<\/code><\/td><td>Stop stations and times per train (schedule)<\/td><\/tr><tr><td><code>transferGuideInfo.json<\/code><\/td><td>Transfer line info per station<\/td><\/tr><tr><td><code>trainPositionList.json<\/code><\/td><td>Position, type, and delay per train in service<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Of these, <strong>only  <code><a href=\"https:\/\/www.keihan.co.jp\/zaisen-up\/trainPositionList.json\">trainPositionList.json<\/a><\/code>  has real-time characteristics<\/strong>; the station list and schedule stay close to static data. The library also fetches the station list and schedule only once, and updates only the position data via polling. Here's roughly what it looks like inside:<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-json\" data-file=\"trainPositionList.json\" data-lang=\"JSON\" data-show-lang=\"1\"><code>{\n  &quot;delay&quot;: &quot;&quot;,\n  &quot;delayEn&quot;: &quot;&quot;,\n  &quot;delayKo&quot;: &quot;&quot;,\n  &quot;delayZhCn&quot;: &quot;&quot;,\n  &quot;delayZhTw&quot;: &quot;&quot;,\n  &quot;locationCol&quot;: &quot;4&quot;,\n  &quot;locationRow&quot;: &quot;64&quot;,\n  &quot;trainDirection&quot;: &quot;1&quot;,\n  &quot;trainIconTypeImageJp&quot;: &quot;JP21_0_0_Pre_D.png&quot;,\n  &quot;trainInfoObjects&quot;: [\n    {\n      &quot;carsOfTrain&quot;: &quot;8&quot;,\n      &quot;delayMinutes&quot;: &quot;&quot;,\n      &quot;delayMinutesEn&quot;: &quot;&quot;,\n      &quot;delayMinutesKo&quot;: &quot;&quot;,\n      &quot;delayMinutesZhCn&quot;: &quot;&quot;,\n      &quot;delayMinutesZhTw&quot;: &quot;&quot;,\n      &quot;destStationCode&quot;: &quot;1&quot;,\n      &quot;destStationNameEn&quot;: &quot;Yodoyabashi&quot;,\n      &quot;destStationNameJp&quot;: &quot;\u6dc0\u5c4b\u6a4b&quot;,\n      &quot;destStationNameKo&quot;: &quot;\uc694\ub3c4\uc57c\ubc14\uc2dc&quot;,\n      &quot;destStationNameZhCn&quot;: &quot;\u6dc0\u5c4b\u6865&quot;,\n      &quot;destStationNameZhTw&quot;: &quot;\u6dc0\u5c4b\u6a4b&quot;,\n      &quot;destStationNumber&quot;: &quot;01&quot;,\n      &quot;lastPassStation&quot;: &quot;21&quot;,\n      &quot;trainNumber&quot;: &quot;0807&quot;,\n      &quot;trainTypeEn&quot;: &quot;Limited Exp.&quot;,\n      &quot;trainTypeIcon&quot;: &quot;JP21_0_0_Pre_D.png&quot;,\n      &quot;trainTypeJp&quot;: &quot;\u7279\u6025&quot;,\n      &quot;trainTypeKo&quot;: &quot;\ud2b9\uae09&quot;,\n      &quot;trainTypeZhCn&quot;: &quot;\u7279\u6025&quot;,\n      &quot;trainTypeZhTw&quot;: &quot;\u7279\u6025&quot;,\n      &quot;wdfBlockNo&quot;: &quot;246&quot;\n    }\n  ],\n  &quot;trainTypeVisIconVis&quot;: &quot;EN21_0_0_Pre_D.png&quot;\n}, ...<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Here's roughly what the schedule data, <code>startTimeList.json<\/code>, looks like:<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-json\" data-file=\"startTimeList.json\" data-lang=\"JSON\"><code>{\n  &quot;fileCreatedTime&quot;: &quot;20260706190215&quot;,\n  &quot;fileVersion&quot;: &quot;1.0.0&quot;,\n  &quot;TrainInfo&quot;: [\n    {\n      &quot;wdfBlockNo&quot;: &quot;1&quot;,\n      &quot;extTrain&quot;: &quot;0&quot;,\n      &quot;premiumCar&quot;: &quot;0&quot;,\n      &quot;trainCar&quot;: &quot;13016&quot;,\n      &quot;diaStationInfoObjects&quot;: [\n        {\n          &quot;stationNumber&quot;: &quot;401&quot;,\n          &quot;stationDepTime&quot;: &quot;-&quot;,\n          &quot;stationNameJp&quot;: &quot;\u4e09\u6761&quot;,\n          &quot;stationNameEn&quot;: &quot;Sanjo&quot;,\n          &quot;stationNameZhTw&quot;: &quot;\u4e09\u689d&quot;,\n          &quot;stationNameZhCn&quot;: &quot;\u4e09\u6761&quot;,\n          &quot;stationNameKo&quot;: &quot;\uc0b0\uc870&quot;\n        },\n        {\n          &quot;stationNumber&quot;: &quot;392&quot;,\n          &quot;stationDepTime&quot;: &quot;05:42&quot;,\n          &quot;stationNameJp&quot;: &quot;\u7947\u5712\u56db\u6761&quot;,\n          &quot;stationNameEn&quot;: &quot;Gion-shijo&quot;,\n          &quot;stationNameZhTw&quot;: &quot;\u7947\u5712\u56db\u689d&quot;,\n          &quot;stationNameZhCn&quot;: &quot;\u7947\u56ed\u56db\u6761&quot;,\n          &quot;stationNameKo&quot;: &quot;\uae30\uc628\uc2dc\uc870&quot;\n        },\n        {\n          &quot;stationNumber&quot;: &quot;372&quot;,\n          &quot;stationDepTime&quot;: &quot;05:44&quot;,\n          &quot;stationNameJp&quot;: &quot;\u4e03\u6761&quot;,\n          &quot;stationNameEn&quot;: &quot;Shichijo&quot;,\n          &quot;stationNameZhTw&quot;: &quot;\u4e03\u689d&quot;,\n          &quot;stationNameZhCn&quot;: &quot;\u4e03\u6761&quot;,\n          &quot;stationNameKo&quot;: &quot;\uc2dc\uce58\uc870&quot;\n        },\n        {\n          &quot;stationNumber&quot;: &quot;330&quot;,\n          &quot;stationDepTime&quot;: &quot;99:99&quot;,\n          &quot;stationNameJp&quot;: &quot;\u9f8d\u8c37\u5927\u524d\u6df1\u8349&quot;,\n          &quot;stationNameEn&quot;: &quot;Ryukokudai-mae-fukakusa&quot;,\n          &quot;stationNameZhTw&quot;: &quot;\u9f8d\u8c37\u5927\u524d\u6df1\u8349&quot;,\n          &quot;stationNameZhCn&quot;: &quot;\u9f99\u8c37\u5927\u524d\u6df1\u8349&quot;,\n          &quot;stationNameKo&quot;: &quot;\ub958\ucf54\ucfe0\ub2e4\uc774\ub9c8\uc5d0 \ud6c4\uce74\ucfe0\uc0ac&quot;\n        },\n        {\n          &quot;stationNumber&quot;: &quot;301&quot;,\n          &quot;stationDepTime&quot;: &quot;05:50&quot;,\n          &quot;stationNameJp&quot;: &quot;\u4e39\u6ce2\u6a4b&quot;,\n          &quot;stationNameEn&quot;: &quot;Tambabashi&quot;,\n          &quot;stationNameZhTw&quot;: &quot;\u4e39\u6ce2\u6a4b&quot;,\n          &quot;stationNameZhCn&quot;: &quot;\u4e39\u6ce2\u6865&quot;,\n          &quot;stationNameKo&quot;: &quot;\ub2e8\ubc14\ubc14\uc2dc&quot;\n        },...<\/code><\/pre><\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Locking Down the JSON with Types<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Treating raw JSON as a plain dict leads to more and more accesses like <code>data[\"locationObjects\"][0][\"trainInfoObjects\"][0][\"delayMinutes\"]<\/code>, and you lose any way to notice a typo'd key name or a structural change on the API's side. So I defined models with <a href=\"https:\/\/docs.pydantic.dev\/\">Pydantic<\/a> and made sure fetched JSON is always validated before use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For example, here's roughly how a single train's worth of <code>trainPositionList.json<\/code> gets mapped into a model (partial excerpt).<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-python\" data-lang=\"Python\" data-show-lang=\"1\"><code>from pydantic import BaseModel, model_validator\nclass trainInfoObject(BaseModel):\n    wdfBlockNo:       int   # internal train management number\n    carsOfTrain:      int   # number of cars\n    delayMinutes:     str   # delay (e.g. &quot;About 5 min&quot;)\n    destStationCode:  int   # destination station code\n    destStationNameJp: str  # destination (Japanese)\n    lastPassStation:  int   # <- the troublemaker discussed below\n    trainNumber:      str   # train number\n    trainTypeJp:      TrainType  # type (converted to Enum)\n    is_special:       bool  # whether it's a special\/extra train\n<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Adding types means editor autocomplete works, and if the API returns an unexpected value, a validation error catches it immediately. When handling external data in a dynamically-typed language like Python, I think this \"validate at the entry point\" pattern is close to mandatory.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The type field (<code>trainTypeJp<\/code>) is converted into an <code>Enum<\/code> rather than kept as a raw string. Extra\/special trains have \"\u81e8\u6642\" (extra) prepended to the type name per the spec, so inside the validator I set a flag and strip out that extra text.<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-python\" data-lang=\"Python\" data-show-lang=\"1\"><code>    @model_validator(mode=&quot;before&quot;)\n    @classmethod\n    def check_type_special(cls, d: dict):\n        if &quot;\u81e8\u6642&quot; in d[&quot;trainTypeJp&quot;]:\n            d[&quot;is_special&quot;] = True\n            d[&quot;trainTypeJp&quot;] = d[&quot;trainTypeJp&quot;].replace(&quot;\u81e8\u6642\u3000\u3000\u3000\u3000&quot;, &quot;&quot;)\n        else:\n            d[&quot;is_special&quot;] = False\n        return d\n<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">For networking, I use <code>httpx<\/code>'s async client. Anticipating that this would eventually be called from the signage backend (FastAPI), I designed it around <code>async<\/code>\/<code>await<\/code> from the start.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Main Challenge: How to Determine the \"Next Stop\"<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the part that gave me the most trouble.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What the signage needs to show when leaving school is \"which train is arriving at the nearest station, and in how many minutes.\" To do that, I need to extract, from all the trains currently running, the ones \"<strong>whose next stop is the nearest station<\/strong>.\" In other words, nothing works unless the \"next stop\" is determined for every train.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Trap 1: <code>lastPassStation<\/code> Can't Be Trusted<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The first thing I focused on was a parameter called <code>lastPassStation<\/code>. Going by the name, it looks like \"the last station passed,\" and if you know that, it seems like you should be able to derive the next station too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Testing it at Korien Station, this value did indeed seem to point to \"the station currently stopped at, or the next station to stop at.\" But when I tried to get \"the next train to stop at this station\" for a different station, trains that should have appeared didn't show up at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reading through the JavaScript on the \"Train Position\" page, also with help from Google AI Studio, revealed the cause. This parameter is <strong>an internal value used as the basis for deciding which station a train's detail view starts displaying stop times from<\/strong>, and it isn't strictly managed as \"the last station passed.\" It just happened to behave as expected at Korien; at least on the local-stop segment between Kyobashi and Kayashima, it returns the value of whatever major station precedes it \u2014 Kayashima, Kyobashi, Moriguchi-shi, and so on.<\/p>\n\n\n\n<figure class=\"wp-block-image alignright size-full is-resized\"><img decoding=\"async\" width=\"933\" height=\"990\" src=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-4.png\" alt=\"\" class=\"wp-image-2182\" style=\"aspect-ratio:0.942434534896461;width:408px;height:auto\" srcset=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-4.png 933w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-4-283x300.png 283w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-4-768x815.png 768w\" sizes=\"(max-width: 933px) 100vw, 933px\" \/><figcaption class=\"wp-element-caption\">The \"train detail\" screen for a local train running between \u014cwada (KH15) and Furukawabashi (KH14)<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This screenshot is of a local train running between \u014cwada (KH15) and Furukawabashi (KH14); because <code>lastPassStation<\/code> is 16 (Kayashima Station), the display starts from the estimated arrival time at \u014cwada \u2014 a station the train should have already passed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the project's purposes, it would have been fine to leave this alone as long as it worked at Korien or Neyagawa-shi, but considering that a future spec change could break it at Korien too, I decided to calculate it with separate logic instead. The library still keeps the <code>lastpass_station<\/code> property, but explicitly marks it as deprecated.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Trap 2: Deriving the Station from Coordinates<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The next thing I used was the <code>locationCol<\/code> \/ <code>locationRow<\/code> parameters. These are coordinates for rendering the route map as a grid, and they're really meant for determining display position. Still, they were effectively the only thing usable as position data.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keihan's route map is a vertical grid, and the range of the <code>row<\/code> value roughly determines the line and station. For example, on the regular sections of the Main Line and \u014ct\u014d Line, three rows correspond to one station block, and the position within the block (top\/middle\/bottom) determines whether the train is \"stopped\" or \"moving toward the next station.\" I built the coordinate-to-station-segment conversion logic through what's often called \"vibe coding.\" Roughly, it's structured like this.<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-python\" data-lang=\"Python\" data-show-lang=\"1\"><code>def calc_position(col: int, row: int):\n    &quot;&quot;&quot;Given coordinates, return the stopped station number, or the two stations for a train in transit&quot;&quot;&quot;\n    # 1. Determine the line from the row range\n    if 1 &lt;= row &lt;= 131:\n        line = &quot;\u4eac\u962a\u672c\u7dda\u30fb\u9d28\u6771\u7dda&quot;  # (the Nakanoshima Line branch is handled separately)\n    elif 132 &lt;= row &lt;= 153:\n        line = &quot;\u5b87\u6cbb\u7dda&quot;\n    elif 154 &lt;= row &lt;= 175:\n        line = &quot;\u4ea4\u91ce\u7dda&quot;\n\n    # 2. On regular Main Line sections, 3 rows = 1 station block\n    if row &lt;= 117:\n        block_index = (row - 1) \/\/ 3\n        current_station = 42 - block_index      # counting backwards from KH42 Demachiyanagi\n        pos_in_block = (row - 1) % 3            # 0: stopped, otherwise: moving\n        if pos_in_block == 0:\n            return (line, current_station, None)        # stopped\n        else:\n            return (line, current_station, current_station - 1)  # moving\n    ...\n<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">However, the underground section (Tenmabashi\u2013Yodoyabashi, and the Nakanoshima Line) is packed tight with no gaps on the route map, so the normal block calculation doesn't work there; I handled it by hardcoding individual stations to specific <code>row<\/code> ranges. This part depends entirely on how Keihan happens to draw its route map, so there was no way around doing it the ugly way.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Trap 3: You Can't Reach the Next Station by +1\/-1 on the Station Number<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Once you know the segment a train is in, you can get the next stop by searching, in the direction of travel, for \"the first station that appears in the stop-station list.\" At first I simply tried searching by incrementing\/decrementing the station number by +1 \/ -1.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This works fine on the Main Line's continuous stretches, but it breaks down at <strong>junctions where the station number jumps<\/strong>. Keihan's station numbering is assigned per line, so:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The Nakanoshima Line (KH51\u2013KH54) connects to Tenmabashi (KH03) on the Main Line<\/li>\n\n\n\n<li>The Katano Line (KH61\u2013KH67) connects to Hirakatashi (KH21)<\/li>\n\n\n\n<li>The Uji Line (KH71\u2013KH77) connects to Chushojima (KH28)<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">In other words, the numbering jumps discontinuously at connecting stations. With a naive increment-based approach, you can't correctly follow a transition like going from Tenmabashi (No. 3) to Naniwabashi (No. 51) on the Nakanoshima Line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So I dropped the number-based search and switched to <strong>defining the actual station order per line as an array<\/strong>. That's <code>stations_map.py<\/code>.<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-python\" data-lang=\"Python\" data-show-lang=\"1\"><code>KYOBASHI_TO_DEMACHIYANAGI = [i for i in range(4, 42 + 1)]\n\nUJI_UP        = [77, 76, 75, 74, 73, 72, 71, 28]\nKATANO_UP     = [67, 66, 65, 64, 63, 62, 61, 21]\nHONNSEN_UP    = [1, 2, 3]              + KYOBASHI_TO_DEMACHIYANAGI\nNAKANOSHIMA_UP = [54, 53, 52, 51, 3]  + KYOBASHI_TO_DEMACHIYANAGI\n<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Looking at <code>NAKANOSHIMA_UP<\/code>'s <code>[54, 53, 52, 51, 3, 4, 5, ...]<\/code>, you can see that even the discontinuous connection where Naniwabashi (No. 51) is followed by Tenmabashi (No. 3) is represented simply as \"the next element\" in the array. By keeping order instead of numbers, you can walk to \"the next element\" without worrying about jumps at connection points.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here's the final logic for calculating the \"next stop.\"<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-python\" data-lang=\"Python\" data-show-lang=\"1\"><code>def next_stop_station(self):\n    next_station = self.next_station.station_number\n    stop_stations = [s.station.station_number for s in self.stop_stations]\n\n    # 1. Get the station-order list for the line\n    line = {\n        &quot;\u4e2d\u4e4b\u5cf6\u7dda&quot;: NAKANOSHIMA_UP,\n        &quot;\u4ea4\u91ce\u7dda&quot;:   KATANO_UP,\n        &quot;\u4eac\u962a\u672c\u7dda\u30fb\u9d28\u6771\u7dda&quot;: HONNSEN_UP,\n        &quot;\u5b87\u6cbb\u7dda&quot;:   UJI_UP,\n    }[self.line]\n\n    # 2. Reverse the list if heading down (outbound)\n    if self.direction == &quot;down&quot;:\n        line = list(reversed(line))\n\n    # 3. If the current location is already a stop station, return it\n    if next_station in stop_stations:\n        return self.master.stations[next_station]\n\n    # 4. Search forward through the list and return the first station that&#39;s in the stop list\n    index = line.index(next_station)\n    for i in range(index, len(line)):\n        if line[i] in stop_stations:\n            return self.master.stations[line[i]]\n    return None\n<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">To summarize the flow:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Determine the line and direction (inbound\/outbound) currently being traveled, from the coordinates<\/li>\n\n\n\n<li>Get the station-order list for that line (reversed for outbound)<\/li>\n\n\n\n<li>Find the current location's index in the list<\/li>\n\n\n\n<li>From there, take the first station that appears in that train's stop-station list as the \"next stop\"<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">With this, even trains crossing a junction between lines now get the correct next stop.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Merging the Schedule with Position Data<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>trainPositionList.json<\/code> (running position) and <code>startTimeList.json<\/code> (schedule) are separate JSON files, but both carry a train's internal management number called <code>wdfBlockNo<\/code>. By matching the two on that key, I made it possible to treat \"where the train is running now\" and \"which stations it will stop at, and when, from here on\" as a single train object.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the library, running trains are represented by <code>ActiveTrainData<\/code>, and scheduled or finished-service trains by <code>TrainData<\/code>, with the former inheriting from the latter. Only <code>ActiveTrainData<\/code> carries position data, which you can check with <code>isinstance()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What's interesting is that <strong>the schedule data doesn't include the \"train type\" or \"direction of travel\"<\/strong>. These can only be inferred from the pattern of stop stations. For example, I judge the type by stacking up rules like \"if it stops at Noe (KH05), it's a local\" or \"if it stops at Toba-kaido without passing through Kyobashi, it's a local.\" Direction is inferred from whether the origin or destination station number is larger (e.g., if the destination number is smaller, it's heading toward Osaka = outbound). For running trains, <code>ActiveTrainData<\/code> gets the exact type and direction back from the API, so that takes priority.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What I Ended Up With<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In the end, I was able to implement it to the point where, given a station, you can get a list of \"trains that will stop at this station going forward, and their arrival times.\"<\/p>\n\n\n\n<div class=\"hcb_wrap\"><pre class=\"prism line-numbers lang-python\" data-lang=\"Python\" data-show-lang=\"1\"><code>import asyncio\nfrom keihan_tracker import KHTracker\n\nasync def main():\n    tracker = KHTracker()\n    await tracker.fetch_pos()   # data is empty until you call this\n\n    station = tracker.stations[17]  # KH17 Neyagawa-shi\n    next_train, arrival = station.upcoming_trains[0]\n    print(next_train)\n\nasyncio.run(main())\n<\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">On the signage side too \u2014 the original goal \u2014 I was able to get as far as rendering this output, just as an experiment, in a design styled after the LCD departure boards you see at stations like Kyobashi.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" width=\"1024\" height=\"769\" src=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-1024x769.jpeg\" alt=\"A screen reproducing the LCD departure board at Kyobashi Station\" class=\"wp-image-2185\" srcset=\"https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-1024x769.jpeg 1024w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-300x225.jpeg 300w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-768x577.jpeg 768w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image-1536x1154.jpeg 1536w, https:\/\/butsuri-kori.net\/wp-content\/uploads\/2026\/07\/image.jpeg 1920w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">This is purely for <s>the romance of it<\/s> debugging purposes \u2014 I have no intention of actually displaying this anywhere.<br>Be careful: reproducing it for public display could amount to copyright infringement.<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Notes and Disclaimer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, let me lay out the caveats that come with this kind of unofficial analysis.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>This isn't an official API.<\/strong> The endpoints used here are internal implementation details of a public page, and there's no guarantee the structure won't change without notice. In fact, the coordinate-to-station conversion depends heavily on how the route map happens to be drawn, so it will need to be rewritten if the route map is ever redesigned.<\/li>\n\n\n\n<li><strong>Be mindful of request frequency.<\/strong> To avoid putting load on the server, the library has a default built-in rate limit of 15 seconds. Keihan's own data also only updates once a minute, so there's no point polling more frequently than that.<\/li>\n\n\n\n<li><strong>Whether secondary use is permitted is a separate question.<\/strong> Analyzing this personally or experimentally is a different matter from running it as a public-facing service. If you're considering commercial or public deployment in particular, you'll need to check with the data provider. The operational data from Keihan Bus (BUS NAVI) and Yahoo! Transit is prohibited from commercial secondary use under their terms of service, so I've kept my own use of it within personal-use bounds.<\/li>\n\n\n\n<li>Use entirely <strong>at your own risk<\/strong>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The library is released under the MIT License. The detailed specification is written up in the repository's README, so take a look if you're interested.<\/p>\n\n\n\n<figure class=\"wp-block-embed\"><div class=\"wp-block-embed__wrapper\">\n\t\t\t<div class=\"blog-card\">\n\t\t\t\t<div class=\"blog-card-body-outer\">\n\t\t\t\t\t<div class=\"blog-card-body\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t<h5 class=\"blog-card-title\">\n\t\t\t\t\t\t\t\t<a href=\"https:\/\/github.com\/dk-butsuri\/keihan_tracker\">GitHub - dk-butsuri\/keihan_tracker: \u4eac\u962a\u96fb\u8eca\u306e\u975e\u516c\u5f0f\u30ea\u30a2\u30eb\u30bf\u30a4\u30e0\u5217\u8eca\u4f4d\u7f6eAPI\u30e9\u30c3\u30d1\u30fc \u00b7 GitHub<\/a>\n\t\t\t\t\t\t\t<\/h5>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<p class=\"blog-card-text\">\n\t\t\t\t\t\t\t\t\u4eac\u962a\u96fb\u8eca\u306e\u975e\u516c\u5f0f\u30ea\u30a2\u30eb\u30bf\u30a4\u30e0\u5217\u8eca\u4f4d\u7f6eAPI\u30e9\u30c3\u30d1\u30fc. Contribute to dk-butsuri\/keihan_tracker development by creating an account on GitHub.\t\t\t\t\t\t\t<\/p>\n\t\t\t\t\t\t\t\t\t\t\t\t<div class=\"blog-card-site-title\">\n\t\t\t\t\t\t\t<a href=\"http:\/\/github.com\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tGitHub\t\t\t\t\t\t\t<\/a>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t\t<div class=\"blog-card-image-outer\">\n\t\t\t\t\t\t<a href=\"https:\/\/github.com\/dk-butsuri\/keihan_tracker\" class=\"blog-card-image-frame\">\n\t\t\t\t\t\t\t<img decoding=\"async\" class=\"blog-card-image-src\" src=\"https:\/\/opengraph.githubassets.com\/9d644162b9ba3a73a8e0dec928aac16e3aa733ce4b024d9a52292e1e439571b6\/dk-butsuri\/keihan_tracker\" alt=\"\">\n\t\t\t\t\t\t<\/a>\n\t\t\t\t\t<\/div>\n\t\t\t\t\t\t\t<\/div>\n\n\t\t\t\n<\/div><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">References<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/prtimes.jp\/main\/html\/rd\/p\/000000021.000127223.html\">Osaka Electro-Communication University Programming Club's train operation info board<\/a> (the example that inspired this; the code here was built independently from scratch)<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>This article is a rewrite, for public release, of an independent research report I originally wrote for a social studies inquiry course in my third year of junior high school. Introduction \u26a0\ufe0f The API discussed in this article is not officially provided or published by Keihan Electric Railway. It's the result of analyzing JSON that [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2150,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_locale":"en_US","_original_post":"https:\/\/butsuri-kori.net\/?p=2044","sns_share_botton_hide":"","vkExUnit_sns_title":"","_vk_print_noindex":"","sitemap_hide":"","_veu_custom_css":"","veu_display_promotion_alert":"","vkexunit_cta_each_option":"","_lightning_design_setting":[],"footnotes":""},"categories":[12],"tags":[],"class_list":["post-2113","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tech","en-US"],"veu_head_title_object":{"title":"","add_site_title":""},"_links":{"self":[{"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/posts\/2113","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/comments?post=2113"}],"version-history":[{"count":50,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/posts\/2113\/revisions"}],"predecessor-version":[{"id":2190,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/posts\/2113\/revisions\/2190"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/media\/2150"}],"wp:attachment":[{"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/media?parent=2113"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/categories?post=2113"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/butsuri-kori.net\/wp-json\/wp\/v2\/tags?post=2113"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}