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.
@@ -0,0 +1,262 @@
1
+ require_relative "base"
2
+ require "fileutils"
3
+ require "openssl"
4
+ require "tzinfo"
5
+
6
+ module GitFit
7
+ module Sync
8
+ class XingZhe < Base
9
+ BASE_HOST = "www.imxingzhe.com"
10
+ LOGIN_PATH = "/api/v1/user/login/"
11
+ WORKOUTS_PATH = "/api/v1/pgworkout/"
12
+ STREAM_PATH = "/api/v1/pgworkout/"
13
+ DETAIL_PATH = "/api/v1/pgworkout/"
14
+ RSA_PUBKEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDmuQkBbijudDAJgfffDeeIButqWHZvUwcRuvWdg89393FSdz3IJUHc0rgI/S3WuU8N0VePJLmVAZtCOK4qe4FY/eKmWpJmn7JfXB4HTMWjPVoyRZmSYjW4L8GrWmh51Qj7DwpTADadF3aq04o+s1b8LXJa8r6+TIqqL5WUHtRqmQIDAQAB"
15
+
16
+ SPORT_MAP = {
17
+ 3 => "ride", 1 => "run", 2 => "hike",
18
+ 5 => "swim", 6 => "walk", 11 => "ski", 13 => "workout"
19
+ }.freeze
20
+
21
+ RAW_EXT = "json"
22
+
23
+ register_adapter
24
+ register_config :email, :password
25
+
26
+ def initialize(...)
27
+ super
28
+ @email = @config["email"]
29
+ @password = @config["password"]
30
+ end
31
+
32
+ def before_call
33
+ return false if @email.nil? || @password.nil?
34
+ super
35
+ end
36
+
37
+ def authenticate
38
+ login
39
+ end
40
+
41
+ def pending_ids
42
+ existing = existing_run_ids
43
+ ids = []
44
+ offset = 0
45
+ limit = 50
46
+ loop do
47
+ acts = fetch_page(offset, limit)
48
+ break if acts.nil? || acts.empty?
49
+ acts.each do |act|
50
+ aid = act["id"].to_s
51
+ next if existing.include?(aid) && raw_file_exists?(aid)
52
+ type = map_sport(act["sport"])
53
+ next if skip_type?(type)
54
+ ids << { id: aid, type: type, summary: act }
55
+ end
56
+ offset += limit
57
+ end
58
+ ids
59
+ end
60
+
61
+ def process_one(pending)
62
+ process_activity(pending[:summary], pending[:type])
63
+ end
64
+
65
+ private
66
+
67
+ def http_request(method, path, body: nil, params: nil, headers: {})
68
+ uri = URI("https://#{BASE_HOST}#{path}")
69
+ uri.query = URI.encode_www_form(params) if params
70
+
71
+ req = case method
72
+ when :get then Net::HTTP::Get.new(uri)
73
+ when :post then Net::HTTP::Post.new(uri)
74
+ end
75
+
76
+ headers.each { |k, v| req[k] = v }
77
+ req.body = body if body
78
+
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = true
81
+ http.open_timeout = 30
82
+ http.read_timeout = 60
83
+ http.start { |h| h.request(req) }
84
+ end
85
+
86
+ def login
87
+ encrypted = rsa_encrypt(@password)
88
+ resp = http_request(:post, LOGIN_PATH,
89
+ body: JSON.generate({ account: @email, password: encrypted }),
90
+ headers: { "Content-Type" => "application/json" }
91
+ )
92
+ return false unless resp.code.to_i == 200
93
+
94
+ data = JSON.parse(resp.body)
95
+ return false unless data["code"] == 0
96
+
97
+ cookie = extract_cookie(resp)
98
+ return false unless cookie
99
+
100
+ @headers = { "Cookie" => cookie, "Accept" => "application/json" }
101
+ true
102
+ end
103
+
104
+ def rsa_encrypt(password)
105
+ pem = "-----BEGIN PUBLIC KEY-----\n#{RSA_PUBKEY}\n-----END PUBLIC KEY-----"
106
+ key = OpenSSL::PKey::RSA.new(pem)
107
+ Base64.strict_encode64(key.public_encrypt(password, OpenSSL::PKey::RSA::PKCS1_PADDING))
108
+ end
109
+
110
+ def extract_cookie(resp)
111
+ set_cookie = resp["set-cookie"]
112
+ return nil unless set_cookie
113
+ m = set_cookie.match(/sessionid=([^;]+)/)
114
+ m ? "sessionid=#{m[1]}" : nil
115
+ end
116
+
117
+ def sync_activities
118
+ pending_ids.filter_map { |p| process_one(p) }.size
119
+ end
120
+
121
+ def fetch_page(offset, limit)
122
+ resp = http_request(:get, WORKOUTS_PATH,
123
+ params: { offset: offset, limit: limit },
124
+ headers: @headers
125
+ )
126
+ return nil unless resp.code.to_i == 200
127
+
128
+ JSON.parse(resp.body).dig("data", "data")
129
+ end
130
+
131
+ def tz_resolver
132
+ @tz_resolver ||= GitFit::Timezone::Resolver.new({})
133
+ end
134
+
135
+ def resolve_local_time(start_utc, pts)
136
+ return start_utc.iso8601 unless pts&.any?
137
+
138
+ lat, lng = pts.first
139
+ return start_utc.iso8601 unless lat && lng
140
+
141
+ tz_string = tz_resolver.resolve(lat, lng)
142
+ tz = TZInfo::Timezone.get(tz_string)
143
+ tz.utc_to_local(start_utc).iso8601
144
+ rescue => e
145
+ warn "XingZhe: timezone resolution failed: #{e.message}"
146
+ start_utc.iso8601
147
+ end
148
+
149
+ def process_activity(act, type)
150
+ aid = act["id"].to_s
151
+ start_t = Time.at(act["start_time"] / 1000.0).utc rescue nil
152
+ return false unless start_t
153
+
154
+ attrs = {
155
+ run_id: "xingzhe_#{aid}",
156
+ name: act["title"] || "XingZhe Activity",
157
+ distance: act["distance"].to_f,
158
+ moving_time: (t = act["duration"].to_i) > 0 ? t : nil,
159
+ elapsed_time: t > 0 ? t : nil,
160
+ sport_category: type,
161
+ sport_type: act["sport"].to_s,
162
+ start_date: start_t.iso8601,
163
+ start_date_local: start_t.iso8601,
164
+ average_speed: (act["avg_speed"].to_f / 3.6).round(2).nonzero?,
165
+ elevation_gain: act["elevation_gain"].to_f.nonzero?,
166
+ source: "xingzhe"
167
+ }
168
+
169
+ pts = nil
170
+ stream = fetch_stream(aid)
171
+ if stream
172
+ write_source_archive({
173
+ source: "xingzhe",
174
+ fetchedAt: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
175
+ stream: stream
176
+ }, aid)
177
+ if (locs = stream["location"]) && locs.size >= 2
178
+ pts = locs.map { |lng, lat| [lat, lng] }
179
+ write_std(pts, stream, aid)
180
+ attrs[:summary_polyline] = stream_to_polyline(aid)
181
+ end
182
+ end
183
+
184
+ if attrs[:summary_polyline].nil? && (detail = fetch_activity_detail(aid)) && (segments = detail["segments_km"])
185
+ pts = segments.map { |s| [s["latitude"], s["longitude"]] }
186
+ attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
187
+ end
188
+
189
+ attrs[:start_date_local] = resolve_local_time(start_t, pts)
190
+
191
+ upsert_activity(attrs)
192
+ attrs
193
+ end
194
+
195
+ def raw_path(platform_id)
196
+ File.join(raw_dir, "#{platform_id}.#{RAW_EXT}")
197
+ end
198
+
199
+ def fetch_stream(id)
200
+ resp = http_request(:get, "#{STREAM_PATH}#{id}/stream/", headers: @headers || {})
201
+ return nil unless resp.code.to_i == 200
202
+
203
+ JSON.parse(resp.body).dig("data")
204
+ rescue JSON::ParserError
205
+ nil
206
+ end
207
+
208
+ def write_std(pts, stream, id)
209
+ dir = std_dir
210
+ FileUtils.mkdir_p(dir)
211
+ ts_arr = stream["timestamp"] || []
212
+ alt_arr = stream["altitude"] || []
213
+ spd_arr = stream["speed"] || []
214
+ dst_arr = stream["distance"] || []
215
+
216
+ extended = pts.each_with_index.map do |(lat, lng), i|
217
+ ts_val = ts_arr[i]
218
+ {
219
+ latitude: lat, longitude: lng,
220
+ altitude: alt_arr[i],
221
+ timestamp: ts_val ? Time.at(ts_val).utc.strftime("%Y-%m-%dT%H:%M:%SZ") : nil,
222
+ speed: spd_arr[i],
223
+ distance: dst_arr[i]
224
+ }
225
+ end
226
+
227
+ tmp = File.join(dir, ".#{id}.json.tmp")
228
+ final = File.join(dir, "#{id}.json")
229
+ File.write(tmp, JSON.generate(extended))
230
+ File.rename(tmp, final)
231
+ end
232
+
233
+ def stream_to_polyline(id)
234
+ raw = JSON.parse(File.read(raw_path(id)))
235
+ locs = raw.dig("stream", "location") || []
236
+ return nil if locs.size < 2
237
+
238
+ pts = locs.map { |lng, lat| [lat, lng] }
239
+ Geo::Polyline.encode(pts)
240
+ rescue Errno::ENOENT, JSON::ParserError
241
+ nil
242
+ end
243
+
244
+ def fetch_activity_detail(id)
245
+ resp = http_request(:get, "#{DETAIL_PATH}#{id}/")
246
+ return nil unless resp.code.to_i == 200
247
+
248
+ data = JSON.parse(resp.body)
249
+ data.dig("data", "workout")
250
+ end
251
+
252
+ def map_sport(sport)
253
+ SPORT_MAP[sport] || "other"
254
+ end
255
+
256
+ def existing_run_ids
257
+ @db[:activities].where(source: "xingzhe").select_map(:run_id)
258
+ .map { |id| id.sub("xingzhe_", "") }
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,369 @@
1
+ require_relative "base"
2
+ require "fileutils"
3
+ require "tzinfo"
4
+
5
+ module GitFit
6
+ module Sync
7
+ class SyncError < RuntimeError; end unless const_defined?(:SyncError)
8
+
9
+ class XOSS < Base
10
+ FIT_FILE_PATH = "/api/v3/activity/%<id>s/fit_file/"
11
+ BASE_URL = "https://backend.xoss.co"
12
+ LOGIN_PATH = "/api/v1/jwt-token/"
13
+ REFRESH_PATH = "/api/v1/jwt-token/refresh/"
14
+ ACTIVITIES_PATH = "/api/v3/activity/"
15
+ ACCESS_TOKEN_TTL = 86400
16
+
17
+ SPORT_MAP = {
18
+ 1 => "run",
19
+ 2 => "ride",
20
+ 3 => "other",
21
+ 4 => "workout",
22
+ 5 => "swim",
23
+ 11 => "walk"
24
+ }.freeze
25
+
26
+ register_adapter
27
+ register_config :phone, :password
28
+
29
+ def initialize(...)
30
+ super
31
+ @email = @config["email"]
32
+ @password = @config["password"]
33
+ @token_path = @config["token_path"] || File.join("data", "cache", "xoss_tokens.json")
34
+ end
35
+
36
+ def before_call
37
+ return false if @email.nil? || @password.nil?
38
+ super
39
+ end
40
+
41
+ def authenticate
42
+ return true if load_tokens && access_token_valid?
43
+ return true if refresh_access_token
44
+ return true if login
45
+ false
46
+ end
47
+
48
+ def pending_ids
49
+ existing = existing_run_ids
50
+ ids = []
51
+ offset = 1
52
+ limit = 50
53
+ loop do
54
+ acts = fetch_page(offset, limit)
55
+ break if acts.nil? || acts.empty?
56
+ acts.each do |act|
57
+ act_id = act["id"].to_s
58
+ next if existing.include?(act_id) && raw_file_exists?(act_id)
59
+ type = map_sport(act["sport"])
60
+ next if skip_type?(type)
61
+ ids << { id: act_id, type: type, summary: act }
62
+ end
63
+ offset += limit
64
+ end
65
+ ids
66
+ end
67
+
68
+ def process_one(pending)
69
+ process_activity(pending[:summary])
70
+ end
71
+
72
+ private
73
+
74
+ def http_request(method, path, body: nil, params: nil, headers: {})
75
+ uri = URI("#{BASE_URL}#{path}")
76
+ uri.query = URI.encode_www_form(params) if params
77
+
78
+ req = case method
79
+ when :get then Net::HTTP::Get.new(uri)
80
+ when :post then Net::HTTP::Post.new(uri)
81
+ end
82
+
83
+ headers.each { |k, v| req[k] = v }
84
+ req.body = body if body
85
+
86
+ proxy = proxy_uri
87
+ http = if proxy
88
+ Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
89
+ else
90
+ Net::HTTP.new(uri.host, uri.port)
91
+ end
92
+ http.use_ssl = true
93
+ http.open_timeout = 30
94
+ http.read_timeout = 60
95
+ http.start { |h| h.request(req) }
96
+ end
97
+
98
+ def proxy_uri
99
+ uri = URI(ENV["http_proxy"] || ENV["HTTP_PROXY"] || ENV["https_proxy"] || ENV["HTTPS_PROXY"] || "")
100
+ uri.host ? uri : nil
101
+ end
102
+
103
+ def login
104
+ retries ||= 0
105
+ resp = http_request(:post, LOGIN_PATH,
106
+ body: JSON.generate({ email: @email, password: @password }),
107
+ headers: { "Content-Type" => "application/json", "Origin" => "xoss" }
108
+ )
109
+ return false unless resp.code.to_i == 200
110
+
111
+ data = JSON.parse(resp.body)
112
+ return false unless data["code"] == 0
113
+
114
+ @access_token = data.dig("data", "access")
115
+ @refresh_token = data.dig("data", "refresh")
116
+ unless @access_token
117
+ remove_stale_tokens
118
+ return false
119
+ end
120
+
121
+ @auth_headers = { "Authorization" => "Bearer #{@access_token}", "Origin" => "xoss" }
122
+ save_tokens
123
+ true
124
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
125
+ if (retries += 1) <= 2
126
+ sleep 2
127
+ retry
128
+ end
129
+ raise SyncError, "XOSS login timeout: #{e.message}"
130
+ end
131
+
132
+ def token_path
133
+ @token_path
134
+ end
135
+
136
+ def load_tokens
137
+ return false unless File.exist?(token_path)
138
+
139
+ data = JSON.parse(File.read(token_path))
140
+ @access_token = data["access"]
141
+ @refresh_token = data["refresh"]
142
+ @expires_at = data["expires_at"]
143
+ @auth_headers = { "Authorization" => "Bearer #{@access_token}", "Origin" => "xoss" } if @access_token
144
+ true
145
+ rescue => e
146
+ warn "XOSS: failed to load cached tokens: #{e.message}"
147
+ false
148
+ end
149
+
150
+ def save_tokens
151
+ return unless @access_token
152
+
153
+ FileUtils.mkdir_p(File.dirname(token_path))
154
+ File.write(token_path, JSON.generate({
155
+ access: @access_token,
156
+ refresh: @refresh_token,
157
+ expires_at: @expires_at || Time.now.to_i + ACCESS_TOKEN_TTL
158
+ }))
159
+ rescue => e
160
+ warn "XOSS: failed to save tokens: #{e.message}"
161
+ end
162
+
163
+ def remove_stale_tokens
164
+ File.delete(token_path) if File.exist?(token_path)
165
+ rescue => e
166
+ warn "XOSS: failed to remove stale tokens: #{e.message}"
167
+ end
168
+
169
+ def access_token_valid?
170
+ return false unless @access_token
171
+ return false unless @expires_at
172
+ Time.now.to_i < @expires_at - 60
173
+ end
174
+
175
+ def refresh_access_token
176
+ return false unless @refresh_token
177
+
178
+ retries ||= 0
179
+ resp = http_request(:post, REFRESH_PATH,
180
+ body: JSON.generate({ refresh: @refresh_token }),
181
+ headers: { "Content-Type" => "application/json", "Origin" => "xoss" }
182
+ )
183
+ unless resp.code.to_i == 200
184
+ remove_stale_tokens
185
+ return false
186
+ end
187
+
188
+ data = JSON.parse(resp.body)
189
+ unless data["code"] == 0
190
+ remove_stale_tokens
191
+ return false
192
+ end
193
+
194
+ @access_token = data.dig("data", "access")
195
+ @refresh_token = data.dig("data", "refresh")
196
+ return false unless @access_token
197
+
198
+ @expires_at = Time.now.to_i + ACCESS_TOKEN_TTL
199
+ @auth_headers = { "Authorization" => "Bearer #{@access_token}", "Origin" => "xoss" }
200
+ save_tokens
201
+ true
202
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
203
+ if (retries += 1) <= 2
204
+ sleep 2
205
+ retry
206
+ end
207
+ raise SyncError, "XOSS token refresh timeout: #{e.message}"
208
+ end
209
+
210
+ def raw_path(platform_id)
211
+ File.join(raw_dir, "#{platform_id}.fit")
212
+ end
213
+
214
+ def sync_activities
215
+ pending_ids.filter_map { |p| process_one(p) }.size
216
+ end
217
+
218
+ def fetch_page(offset, limit)
219
+ retries ||= 0
220
+ resp = http_request(:get, ACTIVITIES_PATH,
221
+ params: { offset: offset, limit: limit },
222
+ headers: @auth_headers
223
+ )
224
+ return nil unless resp.code.to_i == 200
225
+
226
+ JSON.parse(resp.body).dig("data", "results")
227
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
228
+ if (retries += 1) <= 2
229
+ sleep 2
230
+ retry
231
+ end
232
+ raise SyncError, "XOSS fetch timeout: #{e.message}"
233
+ rescue JSON::ParserError
234
+ if (retries ||= 0; (retries += 1) <= 2)
235
+ sleep 2
236
+ retry
237
+ end
238
+ nil
239
+ end
240
+
241
+ def tz_resolver
242
+ @tz_resolver ||= GitFit::Timezone::Resolver.new({})
243
+ end
244
+
245
+ def resolve_local_time(start_utc, wgs_points)
246
+ return start_utc.iso8601 unless wgs_points&.any?
247
+
248
+ lat = wgs_points.first["positionLat"]
249
+ lng = wgs_points.first["positionLong"]
250
+ return start_utc.iso8601 unless lat && lng
251
+
252
+ tz_string = tz_resolver.resolve(lat, lng)
253
+ tz = TZInfo::Timezone.get(tz_string)
254
+ tz.utc_to_local(start_utc).iso8601
255
+ rescue => e
256
+ warn "XOSS: timezone resolution failed: #{e.message}"
257
+ start_utc.iso8601
258
+ end
259
+
260
+ def process_activity(act)
261
+ act_id = act["id"].to_s
262
+ type = map_sport(act["sport"])
263
+
264
+ start_t = Time.at(act["start_timestamp"]).utc rescue nil
265
+ return false unless start_t
266
+
267
+ distance = act["distance"].to_f
268
+ moving_time = act["duration"].to_i
269
+ avg_speed = distance > 0 && moving_time > 0 ? (distance / moving_time).round(2) : nil
270
+
271
+ attrs = {
272
+ run_id: "xoss_#{act_id}",
273
+ name: act["title"] || "XOSS Activity",
274
+ distance: distance,
275
+ moving_time: moving_time > 0 ? moving_time : nil,
276
+ elapsed_time: moving_time > 0 ? moving_time : nil,
277
+ sport_category: type,
278
+ sport_type: act["sub_sport"].to_s,
279
+ start_date: start_t.iso8601,
280
+ start_date_local: start_t.iso8601,
281
+ location_country: nil,
282
+ average_speed: avg_speed,
283
+ elevation_gain: act["elevation_gain"].to_f.nonzero?,
284
+ calories: act["total_calories"]&.to_i&.nonzero?,
285
+ source: "xoss"
286
+ }
287
+
288
+ wgs_points = []
289
+ if act["fit_file_hash"].to_s != ""
290
+ fit_data = download_fit(act_id)
291
+ if fit_data
292
+ write_source_archive(fit_data, act_id, "fit")
293
+ begin
294
+ wgs_points = FIT::Decoder.decode(raw_path(act_id))
295
+ rescue => e
296
+ warn "XOSS: FIT decode failed for #{act_id}: #{e.message}"
297
+ wgs_points = []
298
+ end
299
+ if wgs_points.any?
300
+ write_std(wgs_points, act_id)
301
+ pts = wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] }
302
+ attrs[:summary_polyline] = Geo::Polyline.encode(pts)
303
+ attrs.merge!(compute_sensor_summary(wgs_points))
304
+ end
305
+ end
306
+ end
307
+
308
+ attrs[:start_date_local] = resolve_local_time(start_t, wgs_points)
309
+
310
+ upsert_activity(attrs)
311
+ attrs
312
+ end
313
+
314
+ def download_fit(act_id)
315
+ resp = http_request(:get, format(FIT_FILE_PATH, id: act_id), headers: @auth_headers)
316
+ return nil unless resp.code.to_i == 302
317
+
318
+ fit_uri = URI(resp["location"])
319
+ fit_http = Net::HTTP.new(fit_uri.host, fit_uri.port)
320
+ fit_http.use_ssl = fit_uri.scheme == "https"
321
+ fit_http.open_timeout = 60
322
+ fit_http.read_timeout = 180
323
+ fit_resp = fit_http.start { |h| h.request(Net::HTTP::Get.new(fit_uri)) }
324
+ return nil unless fit_resp.code.to_i == 200
325
+
326
+ fit_resp.body
327
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
328
+ warn "XOSS: FIT download timeout for #{act_id}: #{e.message}"
329
+ nil
330
+ end
331
+
332
+ def write_std(points, act_id)
333
+ dir = std_dir
334
+ FileUtils.mkdir_p(dir)
335
+ tmp = File.join(dir, ".#{act_id}.json.tmp")
336
+ final = File.join(dir, "#{act_id}.json")
337
+ File.write(tmp, JSON.generate(points))
338
+ File.rename(tmp, final)
339
+ end
340
+
341
+ def compute_sensor_summary(records)
342
+ return {} if records.empty?
343
+
344
+ cad = records.map { |r| r["cadence"] }.compact
345
+ pow = records.map { |r| r["power"] }.compact
346
+ tmp = records.map { |r| r["temperature"] }.compact
347
+ hr = records.map { |r| r["heart_rate"] }.compact
348
+
349
+ {
350
+ average_cadence: cad.any? ? (cad.sum.to_f / cad.size).round(1) : nil,
351
+ max_cadence: cad.max,
352
+ average_power: pow.any? ? (pow.sum.to_f / pow.size).round(1) : nil,
353
+ max_power: pow.max,
354
+ average_temperature: tmp.any? ? (tmp.sum.to_f / tmp.size).round(1) : nil,
355
+ average_heartrate: hr.any? ? (hr.sum.to_f / hr.size).round(1) : nil
356
+ }
357
+ end
358
+
359
+ def map_sport(sport)
360
+ SPORT_MAP[sport] || "other"
361
+ end
362
+
363
+ def existing_run_ids
364
+ @db[:activities].where(source: "xoss").select_map(:run_id)
365
+ .map { |id| id.sub("xoss_", "") }
366
+ end
367
+ end
368
+ end
369
+ end
@@ -1,3 +1,3 @@
1
1
  module GitFit
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.1"
3
3
  end