Render a session's track on the map
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 19s

Clicking a LiveTrack session now fetches and draws its track (line +
latest position). Garmin's own API 403s any plain server-side request
(Cloudflare bot protection), so LiveTrackClient fetches the session
page instead and pulls the track out of the server-rendered data
embedded in it. The webhook now captures the session's token too,
since the id alone isn't enough to fetch anything.
This commit is contained in:
randy committed 2026-09-06 22:07:13 +02:00
1 parent 7083cec36c
commit f4b5237f01
7 files changed
+326 -29

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
require "net/http"
require "json"
module Maptool
# Fetches a Garmin LiveTrack session's track points.
#
# There's no usable REST call for this: livetrack.garmin.com/api/v2/... is
# behind Cloudflare bot protection that 403s a plain HTTP client regardless
# of headers (checked — not just a missing User-Agent). The session page
# itself isn't blocked, though, and Garmin's Next.js frontend renders it
# server-side, embedding the full track (as React Server Component data,
# via `self.__next_f.push(...)` calls in the HTML) rather than fetching it
# client-side. So: fetch the page, and pull the track out of that instead
# of the API it would otherwise call.
class LiveTrackClient
class FetchError < StandardError; end
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
def self.track_points(session_id, token)
new.track_points(session_id, token)
end
def track_points(session_id, token)
points = find_track_points(fetch_html(session_id, token))
raise FetchError, "no track points found (session invalid or expired?)" unless points
points.map { |point| clean(point) }
end
private
# The RSC payload serializes an absent field as the literal string
# "$undefined" (Next.js's flight protocol, not JSON's own undefined) —
# normalize that to nil so nothing downstream has to know about it.
def clean(node)
case node
when "$undefined" then nil
when Hash then node.transform_values { |value| clean(value) }
when Array then node.map { |value| clean(value) }
else node
end
end
def fetch_html(session_id, token)
uri = URI("https://livetrack.garmin.com/session/#{session_id}/token/#{token}")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 15) do |http|
http.get(uri.request_uri, "User-Agent" => USER_AGENT)
end
raise FetchError, "unexpected response #{response.code}" unless response.is_a?(Net::HTTPSuccess)
response.body
rescue SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
raise FetchError, e.message
end
# Next.js streams several of these RSC payloads per page; the track
# points live inside one of them, under some data-fetching library's own
# nesting (React Query, at the time this was written) that isn't worth
# depending on the exact shape of — so this just walks every payload
# looking for a "trackPoints" array, wherever it ends up.
def find_track_points(html)
html.scan(PUSH_PATTERN).each do |(raw)|
push = safe_parse(raw)
next unless push.is_a?(Array) && push[1].is_a?(String)
_line_id, json = push[1].split(":", 2)
next unless json
found = search_track_points(safe_parse(json))
return found if found
end
nil
end
def search_track_points(node)
case node
when Hash
return node["trackPoints"] if node["trackPoints"].is_a?(Array)
node.each_value { |value| (found = search_track_points(value)) and return found }
nil
when Array
node.each { |value| (found = search_track_points(value)) and return found }
nil
end
end
def safe_parse(json)
JSON.parse(json)
rescue JSON::ParserError
nil
end
end
end
if __FILE__ == $0
client = Maptool::LiveTrackClient.new
fake_page = <<~HTML
<html><body>
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\"}]}]}}}"])</script>
</body></html>
HTML
points = client.send(:find_track_points, fake_page)
raise "expected 2 points" unless points&.size == 2
raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1
raise "expected nil when no trackPoints present" unless client.send(:find_track_points, "<html></html>").nil?
puts "ok"
end
+18 -9
View File
@@ -2,10 +2,12 @@ require "json"
require "fileutils"
module Maptool
# JSON-file-backed store of Garmin LiveTrack session IDs, appended to by a
# JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
# webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc).
# Validity/expiry of a session isn't tracked here — checking whether a
# given ID still has live data is up to whoever polls Garmin with it.
# The token is what actually lets us fetch the session's track later (see
# LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
# and the id alone isn't enough. Validity/expiry isn't tracked here —
# whoever fetches the track finds out then.
class LiveTrackStore
def initialize(path)
@path = path
@@ -14,12 +16,12 @@ module Maptool
File.write(@path, "[]") unless File.exist?(@path)
end
# Records `session_id`, moving it to the front if already present.
def add(session_id)
# Records `session_id`/`token`, moving it to the front if already present.
def add(session_id, token)
@mutex.synchronize do
sessions = read
sessions.reject! { |s| s["id"] == session_id }
sessions.unshift({ "id" => session_id, "received_at" => Time.now.utc.iso8601 })
sessions.unshift({ "id" => session_id, "token" => token, "received_at" => Time.now.utc.iso8601 })
File.write(@path, JSON.pretty_generate(sessions))
end
end
@@ -29,6 +31,10 @@ module Maptool
read
end
def find(session_id)
read.find { |s| s["id"] == session_id }
end
private
def read
@@ -46,14 +52,17 @@ if __FILE__ == $0
store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
raise "expected empty list" unless store.list == []
store.add("abc123")
store.add("def456")
store.add("abc123", "tok-abc")
store.add("def456", "tok-def")
raise "expected 2 sessions" unless store.list.size == 2
raise "expected newest first" unless store.list.first["id"] == "def456"
raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc"
raise "expected unknown id to be nil" unless store.find("nope").nil?
store.add("abc123")
store.add("abc123", "tok-abc-2")
raise "expected re-added session moved to front, not duplicated" \
unless store.list.map { |s| s["id"] } == %w[abc123 def456]
raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2"
reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
raise "expected data to survive reload" unless reloaded.list.size == 2