Files
maptool/lib/maptool/livetrack_client.rb
T
randy cd4eef342b
CI / check (push) Successful in 1m56s
CI / deploy (push) Successful in 2m56s
Add a persisted test suite
`bundle exec rake test` (minitest, a default gem but pinned in
Gemfile for reproducibility) now covers what ad-hoc scripts kept
re-verifying by hand during development: Gpx/Fit/TrackStats parsing,
GpxStore/LiveTrackStore file/TTL logic, LiveTrackClient's payload
parsing, and app.rb's routes end to end via Rack::MockRequest.

MAPTOOL_DATA_DIR (new, app.rb) lets the suite point GPX_STORE/
LIVETRACK_STORE at a throwaway directory instead of real data/ —
without it every run would leave uploaded test files behind. Each
lib file's old `if __FILE__ == $0` self-check is now redundant with
real tests covering the same ground, so removed rather than kept as
a second copy of the same assertions. Wired into ci.yml so it isn't
just a local convenience.
2026-09-07 12:46:35 +02:00

129 lines
4.9 KiB
Ruby

require "net/http"
require "json"
module Maptool
# Fetches a Garmin LiveTrack session's track and metadata.
#
# 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 both the full track and the session's own details
# (as React Server Component data, via `self.__next_f.push(...)` calls in
# the HTML) rather than fetching them client-side. So: fetch the page, and
# pull both 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
Result = Struct.new(:points, :session, keyword_init: true)
def self.fetch(session_id, token)
new.fetch(session_id, token)
end
# `points`: array of track point hashes (position/dateTime/speed/...).
# `session`: hash of session details (sessionName/start/end/...), or nil
# if that part wasn't found even though points were (seen in practice:
# session details are consistently present alongside points, but nothing
# here depends on that holding forever).
def fetch(session_id, token)
payloads = parse_payloads(fetch_html(session_id, token))
points = search(payloads) { |node| node["trackPoints"] if node["trackPoints"].is_a?(Array) }
raise FetchError, "no track points found (session invalid or expired?)" unless points
session = search(payloads) { |node| node if node["sessionName"] }
Result.new(points: clean(points), session: clean(session))
end
# Distance/duration/activity type come from the last track point (its
# totals are cumulative for the whole session), name/type from the
# session details — nil-safe throughout since either can be missing.
#
# "active": Garmin doesn't set a session's `end` until the activity is
# actually stopped, so its absence means the session is still recording
# — inferred from the one (already-ended) session this was built
# against, not verified against a session actually caught mid-activity.
def self.summarize(result)
last = result.points.last || {}
session = result.session || {}
{
"session_name" => session["sessionName"],
"activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"],
"active" => session["end"].nil?,
"start" => session["start"],
"end" => session["end"],
}
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, each an
# independently-parseable `[lineId, "<json>"]` pair once its line-id
# prefix is stripped off.
def parse_payloads(html)
html.scan(PUSH_PATTERN).filter_map do |(raw)|
push = safe_parse(raw)
next unless push.is_a?(Array) && push[1].is_a?(String)
_line_id, json = push[1].split(":", 2)
json && safe_parse(json)
end
end
# Walks the payloads looking for a hash the block matches — the payload
# nests under some data-fetching library's own structure (React Query,
# at the time this was written) that isn't worth depending on the exact
# shape of.
def search(node, &block)
case node
when Hash
found = block.call(node)
return found if found
node.each_value { |value| (found = search(value, &block)) and return found }
nil
when Array
node.each { |value| (found = search(value, &block)) and return found }
nil
end
end
def safe_parse(json)
JSON.parse(json)
rescue JSON::ParserError
nil
end
end
end