Render a session's track on the map
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:
1 parent
7083cec36c
commit
f4b5237f01
7 files changed
+326
-29
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user