`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.
129 lines
4.9 KiB
Ruby
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
|