git-fit 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/git-fit.rb +10 -3
- data/lib/git_fit/cli/sync.rb +23 -34
- data/lib/git_fit/config.rb +9 -2
- data/lib/git_fit/config_template.rb +12 -0
- data/lib/git_fit/sync/base.rb +64 -1
- data/lib/git_fit/sync/garmin.rb +16 -0
- data/lib/git_fit/sync/garmin_base.rb +648 -0
- data/lib/git_fit/sync/garmin_base_di.rb +186 -0
- data/lib/git_fit/sync/garmin_cn.rb +29 -0
- data/lib/git_fit/sync/igpsport.rb +225 -0
- data/lib/git_fit/sync/keep.rb +499 -0
- data/lib/git_fit/sync/lorem.rb +1 -0
- data/lib/git_fit/sync/runner.rb +322 -0
- data/lib/git_fit/sync/strava.rb +187 -0
- data/lib/git_fit/sync/xingzhe.rb +262 -0
- data/lib/git_fit/sync/xoss.rb +369 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +66 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require_relative "garmin_base"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module GitFit
|
|
5
|
+
module Sync
|
|
6
|
+
class GarminBaseDI < GarminBase
|
|
7
|
+
DI_CLIENT_IDS = %w[
|
|
8
|
+
GARMIN_CONNECT_MOBILE_ANDROID_DI_2025Q2
|
|
9
|
+
GARMIN_CONNECT_MOBILE_ANDROID_DI_2024Q4
|
|
10
|
+
GARMIN_CONNECT_MOBILE_ANDROID_DI
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def authenticate
|
|
14
|
+
@oauth_consumer = fetch_oauth_consumer
|
|
15
|
+
|
|
16
|
+
if load_tokens
|
|
17
|
+
return true if access_token_valid?
|
|
18
|
+
begin
|
|
19
|
+
return true if refresh_token
|
|
20
|
+
rescue AuthError
|
|
21
|
+
remove_stale_tokens
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if load_auth_seed
|
|
26
|
+
return true if access_token_valid?
|
|
27
|
+
begin
|
|
28
|
+
return true if refresh_token
|
|
29
|
+
rescue AuthError
|
|
30
|
+
@access_token = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts "Garmin: no valid DI token found"
|
|
35
|
+
puts " First time: ./scripts/garmin_auth_local.sh"
|
|
36
|
+
puts " Refresh: ./scripts/garmin_auth_local.sh --sync"
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def before_call
|
|
41
|
+
if @config["auth_seed"].to_s.empty? && !File.exist?(token_path)
|
|
42
|
+
puts "Garmin: auth_seed not configured and no cached token"
|
|
43
|
+
puts " Run: ./scripts/garmin_auth_local.sh"
|
|
44
|
+
return false
|
|
45
|
+
end
|
|
46
|
+
@start_time = Time.now
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reauth_hint
|
|
51
|
+
"Re-auth: ./scripts/garmin_auth_local.sh --sync"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def auth_base
|
|
57
|
+
"https://diauth.#{@domain}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def load_tokens
|
|
61
|
+
path = token_path
|
|
62
|
+
return false unless File.exist?(path)
|
|
63
|
+
|
|
64
|
+
data = JSON.parse(File.read(path))
|
|
65
|
+
@access_token = data
|
|
66
|
+
true
|
|
67
|
+
rescue => e
|
|
68
|
+
puts "Failed to load Garmin tokens: #{e.message}"
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def save_tokens
|
|
73
|
+
return unless @access_token&.dig("access_token")
|
|
74
|
+
|
|
75
|
+
path = token_path
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
77
|
+
File.write(path, JSON.pretty_generate(@access_token))
|
|
78
|
+
rescue => e
|
|
79
|
+
puts "Failed to save Garmin tokens: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remove_stale_tokens
|
|
83
|
+
path = token_path
|
|
84
|
+
File.delete(path) if File.exist?(path)
|
|
85
|
+
rescue => e
|
|
86
|
+
puts "Warning: failed to remove stale tokens: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_auth_seed
|
|
90
|
+
seed = @config["auth_seed"]
|
|
91
|
+
return false unless seed && !seed.empty?
|
|
92
|
+
|
|
93
|
+
decoded = JSON.parse(Base64.strict_decode64(seed))
|
|
94
|
+
@access_token = decoded
|
|
95
|
+
save_tokens
|
|
96
|
+
true
|
|
97
|
+
rescue JSON::ParserError
|
|
98
|
+
puts "Garmin: auth_seed decode failed — invalid Base64 or JSON"
|
|
99
|
+
puts " Fix: ./scripts/garmin_auth_local.sh --sync"
|
|
100
|
+
false
|
|
101
|
+
rescue => e
|
|
102
|
+
puts "Garmin: auth_seed error: #{e.message}"
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def refresh_token
|
|
107
|
+
return false unless @access_token&.dig("refresh_token")
|
|
108
|
+
|
|
109
|
+
client_id = @access_token["di_client_id"] || DI_CLIENT_IDS.first
|
|
110
|
+
basic = Base64.strict_encode64("#{client_id}:")
|
|
111
|
+
|
|
112
|
+
uri = URI("#{auth_base}/di-oauth2-service/oauth/token")
|
|
113
|
+
req = Net::HTTP::Post.new(uri)
|
|
114
|
+
req["Authorization"] = "Basic #{basic}"
|
|
115
|
+
req["Content-Type"] = "application/x-www-form-urlencoded"
|
|
116
|
+
req["Cache-Control"] = "no-cache"
|
|
117
|
+
req["User-Agent"] = "GCM-Android-5.23"
|
|
118
|
+
req.body = URI.encode_www_form(
|
|
119
|
+
grant_type: "refresh_token",
|
|
120
|
+
client_id: client_id,
|
|
121
|
+
refresh_token: @access_token["refresh_token"]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
resp = retry_on_429 { send_http(uri, req) }
|
|
125
|
+
unless resp.code.to_i == 200
|
|
126
|
+
msg = "DI refresh error: #{resp.code}"
|
|
127
|
+
if resp.code.to_i == 400
|
|
128
|
+
msg += " — refresh_token expired across rotation"
|
|
129
|
+
msg += ", re-run: scripts/garmin_auth_local.sh"
|
|
130
|
+
elsif resp.code.to_i == 401
|
|
131
|
+
msg += " — DI client_id rotated by Garmin"
|
|
132
|
+
msg += ", re-run: scripts/garmin_auth_local.sh"
|
|
133
|
+
end
|
|
134
|
+
raise AuthError, msg
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
result = JSON.parse(resp.body)
|
|
138
|
+
result["expires_at"] = Time.now.to_i + (result["expires_in"] || 64800).to_i
|
|
139
|
+
if result["refresh_token_expires_in"]
|
|
140
|
+
result["refresh_token_expires_at"] = Time.now.to_i + result["refresh_token_expires_in"].to_i
|
|
141
|
+
end
|
|
142
|
+
@access_token = result
|
|
143
|
+
save_tokens
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @deprecated Kept for local script reference only.
|
|
148
|
+
# email+password SSO strategies are not called by `authenticate`
|
|
149
|
+
# because garmin.com DI auth requires MFA, handled by
|
|
150
|
+
# scripts/garmin_auth_local.sh outside Ruby.
|
|
151
|
+
#
|
|
152
|
+
# Note: method naming (strategy_a = curl_cffi, strategy_b = Playwright) is
|
|
153
|
+
# historical. In scripts/garmin_auth_local.sh, Playwright runs first (cookies
|
|
154
|
+
# persist 365d → no repeat MFA), curl_cffi is the fallback.
|
|
155
|
+
def strategy_a_login
|
|
156
|
+
script = File.expand_path("../../../scripts/garmin_auth.py", __dir__)
|
|
157
|
+
return nil unless File.exist?(script)
|
|
158
|
+
|
|
159
|
+
stdout, stderr, status = Open3.capture3(
|
|
160
|
+
{"SYNC__GARMIN__EMAIL" => @email, "SYNC__GARMIN__PASSWORD" => @password, "SYNC__GARMIN__DOMAIN" => @domain},
|
|
161
|
+
"python3", script
|
|
162
|
+
)
|
|
163
|
+
warn stderr unless stderr.empty?
|
|
164
|
+
JSON.parse(stdout) if status.success?
|
|
165
|
+
rescue => e
|
|
166
|
+
puts "Strategy A (curl_cffi) failed: #{e.message}"
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def strategy_b_login
|
|
171
|
+
script = File.expand_path("../../../scripts/garmin_auth_playwright.py", __dir__)
|
|
172
|
+
return nil unless File.exist?(script)
|
|
173
|
+
|
|
174
|
+
stdout, stderr, status = Open3.capture3(
|
|
175
|
+
{"SYNC__GARMIN__EMAIL" => @email, "SYNC__GARMIN__PASSWORD" => @password, "SYNC__GARMIN__DOMAIN" => @domain},
|
|
176
|
+
"python3", script
|
|
177
|
+
)
|
|
178
|
+
warn stderr unless stderr.empty?
|
|
179
|
+
JSON.parse(stdout) if status.success?
|
|
180
|
+
rescue => e
|
|
181
|
+
puts "Strategy B (Playwright) failed: #{e.message}"
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require_relative "garmin_base"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module Sync
|
|
5
|
+
class GarminCN < GarminBase
|
|
6
|
+
register_adapter
|
|
7
|
+
register_config :email, :password, :secret
|
|
8
|
+
config_key "garmin_cn"
|
|
9
|
+
|
|
10
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
11
|
+
super
|
|
12
|
+
@domain = "garmin.cn"
|
|
13
|
+
@ssl_verify = config.key?("ssl_verify") ? config["ssl_verify"] : false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def raw_dir
|
|
17
|
+
File.join("data", "raw", "garmin_cn")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def std_dir
|
|
21
|
+
File.join("data", "std", "garmin_cn")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def source_prefix
|
|
25
|
+
"garmin_cn"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require "faraday"
|
|
3
|
+
|
|
4
|
+
module GitFit
|
|
5
|
+
module Sync
|
|
6
|
+
class IGPSPORT < Base
|
|
7
|
+
BASE_URL = "https://prod.zh.igpsport.com/service"
|
|
8
|
+
LOGIN_URL = "#{BASE_URL}/auth/account/login"
|
|
9
|
+
QUERY_URL = "#{BASE_URL}/web-gateway/web-analyze/activity/queryMyActivity"
|
|
10
|
+
DOWNLOAD_URL = "#{BASE_URL}/web-gateway/web-analyze/activity/getDownloadUrl"
|
|
11
|
+
|
|
12
|
+
EXERCISE_MAP = {
|
|
13
|
+
0 => "ride", 1 => "run", 2 => "hike", 3 => "walk",
|
|
14
|
+
4 => "swim", 5 => "ski", 6 => "workout"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
RAW_EXT = "fit"
|
|
18
|
+
|
|
19
|
+
register_adapter
|
|
20
|
+
register_config :phone, :password
|
|
21
|
+
|
|
22
|
+
def initialize(...)
|
|
23
|
+
super
|
|
24
|
+
@phone = @config["phone"]
|
|
25
|
+
@password = @config["password"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def before_call
|
|
29
|
+
return false if @phone.nil? || @password.nil?
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def authenticate
|
|
34
|
+
login
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pending_ids
|
|
38
|
+
existing = existing_run_ids
|
|
39
|
+
ids = []
|
|
40
|
+
page = 1
|
|
41
|
+
loop do
|
|
42
|
+
acts = fetch_page(page)
|
|
43
|
+
break if acts.nil? || acts.empty?
|
|
44
|
+
acts.each do |act|
|
|
45
|
+
ride_id = act["rideId"].to_s
|
|
46
|
+
next if existing.include?(ride_id) && raw_file_exists?(ride_id)
|
|
47
|
+
type = map_sport(act["exerciseType"])
|
|
48
|
+
next if skip_type?(type)
|
|
49
|
+
ids << { id: ride_id, type: type, summary: act }
|
|
50
|
+
end
|
|
51
|
+
page += 1
|
|
52
|
+
end
|
|
53
|
+
ids
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def process_one(pending)
|
|
57
|
+
sync_activity(pending[:summary], pending[:id])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def login
|
|
63
|
+
resp = Faraday.post(LOGIN_URL,
|
|
64
|
+
JSON.generate({ appId: "igpsport-web", username: @phone, password: @password }),
|
|
65
|
+
{ "Content-Type" => "application/json" }
|
|
66
|
+
)
|
|
67
|
+
return false unless resp.status == 200
|
|
68
|
+
data = JSON.parse(resp.body)
|
|
69
|
+
@token = data.dig("data", "access_token")
|
|
70
|
+
return false unless @token
|
|
71
|
+
@auth_headers = { "Authorization" => "Bearer #{@token}" }
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sync_activities
|
|
76
|
+
pending_ids.filter_map { |p| process_one(p) }.size
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sync_activity(act, ride_id)
|
|
80
|
+
url = get_download_url(ride_id)
|
|
81
|
+
return false unless url
|
|
82
|
+
|
|
83
|
+
download_fit(url, ride_id)
|
|
84
|
+
|
|
85
|
+
fit_path = raw_path(ride_id)
|
|
86
|
+
return false unless File.exist?(fit_path)
|
|
87
|
+
|
|
88
|
+
wgs_points = FIT::Decoder.decode(fit_path)
|
|
89
|
+
return false if wgs_points.empty?
|
|
90
|
+
|
|
91
|
+
first_ts = wgs_points.first["timestamp"]
|
|
92
|
+
if first_ts
|
|
93
|
+
fit_epoch = Time.new(1989, 12, 31, 0, 0, 0, "+00:00").to_i
|
|
94
|
+
t = Time.at(fit_epoch + first_ts)
|
|
95
|
+
@fit_start_utc = t.utc
|
|
96
|
+
@fit_start_local = t.getlocal("+08:00")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
write_standardized_json(wgs_points, ride_id)
|
|
100
|
+
polyline = Geo::Polyline.encode(wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] })
|
|
101
|
+
sensor_summary = compute_sensor_summary(wgs_points)
|
|
102
|
+
|
|
103
|
+
attrs = build_attrs(act, ride_id, polyline, sensor_summary)
|
|
104
|
+
return false unless attrs
|
|
105
|
+
|
|
106
|
+
upsert_activity(attrs)
|
|
107
|
+
attrs
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def get_download_url(ride_id)
|
|
111
|
+
resp = Faraday.get("#{DOWNLOAD_URL}/#{ride_id}", {}, @auth_headers)
|
|
112
|
+
return nil unless resp.status == 200
|
|
113
|
+
JSON.parse(resp.body)["data"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def download_fit(url, ride_id)
|
|
117
|
+
resp = Faraday.get(url)
|
|
118
|
+
return false unless resp.status == 200
|
|
119
|
+
|
|
120
|
+
write_source_archive(resp.body, ride_id, RAW_EXT)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fit_to_polyline(ride_id)
|
|
125
|
+
fit_path = raw_path(ride_id)
|
|
126
|
+
return nil unless File.exist?(fit_path)
|
|
127
|
+
|
|
128
|
+
wgs_points = FIT::Decoder.decode(fit_path)
|
|
129
|
+
return nil if wgs_points.empty?
|
|
130
|
+
|
|
131
|
+
Geo::Polyline.encode(wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def write_standardized_json(wgs_points, ride_id)
|
|
135
|
+
dir = std_dir
|
|
136
|
+
FileUtils.mkdir_p(dir)
|
|
137
|
+
tmp = File.join(dir, ".#{ride_id}.json.tmp")
|
|
138
|
+
final = File.join(dir, "#{ride_id}.json")
|
|
139
|
+
File.write(tmp, JSON.generate(wgs_points))
|
|
140
|
+
File.rename(tmp, final)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def fetch_page(page)
|
|
144
|
+
resp = Faraday.get(QUERY_URL,
|
|
145
|
+
{ pageNo: page, pageSize: 20, sort: 1, reqType: 0 },
|
|
146
|
+
@auth_headers
|
|
147
|
+
)
|
|
148
|
+
return nil unless resp.status == 200
|
|
149
|
+
JSON.parse(resp.body).dig("data", "rows")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def build_attrs(act, ride_id, polyline = nil, sensor = {})
|
|
153
|
+
type = map_sport(act["exerciseType"])
|
|
154
|
+
start_t = @fit_start_utc || parse_date(act["startTime"])
|
|
155
|
+
start_t_local = @fit_start_local || start_t
|
|
156
|
+
distance = act["rideDistance"].to_f
|
|
157
|
+
moving_time = (act["totalMovingTime"] || act["recordTime"]).to_i
|
|
158
|
+
avg_speed = act["avgSpeed"].to_f
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
run_id: "igpsport_#{ride_id}",
|
|
162
|
+
name: act["title"] || "iGPSPORT Activity",
|
|
163
|
+
distance: distance,
|
|
164
|
+
moving_time: moving_time > 0 ? moving_time : nil,
|
|
165
|
+
elapsed_time: moving_time > 0 ? moving_time : nil,
|
|
166
|
+
sport_category: type,
|
|
167
|
+
sport_type: EXERCISE_MAP[act["exerciseType"]] || "ride",
|
|
168
|
+
start_date: start_t.iso8601,
|
|
169
|
+
start_date_local: start_t_local.iso8601,
|
|
170
|
+
location_country: nil,
|
|
171
|
+
average_heartrate: act["avgHrm"]&.to_f&.nonzero?,
|
|
172
|
+
max_heartrate: act["maxHrm"]&.to_f&.nonzero?,
|
|
173
|
+
average_cadence: sensor[:average_cadence],
|
|
174
|
+
max_cadence: sensor[:max_cadence],
|
|
175
|
+
average_power: sensor[:average_power],
|
|
176
|
+
max_power: sensor[:max_power],
|
|
177
|
+
calories: sensor[:calories],
|
|
178
|
+
average_temperature: sensor[:average_temperature],
|
|
179
|
+
average_speed: avg_speed > 0 ? avg_speed : nil,
|
|
180
|
+
elevation_gain: act["totalAscent"].to_f.nonzero?,
|
|
181
|
+
summary_polyline: polyline,
|
|
182
|
+
source: "igpsport"
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def compute_sensor_summary(records)
|
|
187
|
+
return {} if records.empty?
|
|
188
|
+
|
|
189
|
+
cad = records.map { |r| r["cadence"] }.compact
|
|
190
|
+
pow = records.map { |r| r["power"] }.compact
|
|
191
|
+
tmp = records.map { |r| r["temperature"] }.compact
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
average_cadence: cad.any? ? (cad.sum.to_f / cad.size).round(1) : nil,
|
|
195
|
+
max_cadence: cad.max,
|
|
196
|
+
average_power: pow.any? ? (pow.sum.to_f / pow.size).round(1) : nil,
|
|
197
|
+
max_power: pow.max,
|
|
198
|
+
average_temperature: tmp.any? ? (tmp.sum.to_f / tmp.size).round(1) : nil,
|
|
199
|
+
calories: nil
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def map_sport(exercise_type)
|
|
204
|
+
EXERCISE_MAP[exercise_type] || "other"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parse_date(str)
|
|
208
|
+
return nil unless str
|
|
209
|
+
normalized = str.to_s.tr(".", "-")
|
|
210
|
+
Time.parse(normalized)
|
|
211
|
+
rescue
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def existing_run_ids
|
|
216
|
+
@db[:activities].where(source: "igpsport").select_map(:run_id)
|
|
217
|
+
.map { |id| id.sub("igpsport_", "") }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def raw_path(platform_id)
|
|
221
|
+
File.join(raw_dir, "#{platform_id}.#{RAW_EXT}")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|