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,499 @@
1
+ require_relative "base"
2
+ require "base64"
3
+ require "zlib"
4
+ require "openssl"
5
+ require "time"
6
+ require "tzinfo"
7
+
8
+ module GitFit
9
+ module Sync
10
+ class Keep < Base
11
+ LOGIN_URL = "https://api.gotokeep.com/v1.1/users/login"
12
+ STATS_URL = "https://api.gotokeep.com/pd/v3/stats/detail?dateUnit=all&type=%{sport}&lastDate=%{last_date}"
13
+ LOG_URL = "https://api.gotokeep.com/pd/v3/%{sport}log/%{run_id}"
14
+
15
+ AES_KEY = Base64.decode64("NTZmZTU5OzgyZzpkODczYw==")
16
+ AES_IV = Base64.decode64("MjM0Njg5MjQzMjkyMDMwMA==")
17
+
18
+ PLACEHOLDER_POLYLINE = "gqqrFurkeU??".freeze
19
+
20
+ KEEP_TYPES = %w[running hiking cycling].freeze
21
+ KEEP_RAW_EXT = "json".freeze
22
+
23
+ TYPE_MAP = {
24
+ "outdoorWalking" => "walk",
25
+ "outdoorRunning" => "run",
26
+ "outdoorCycling" => "ride",
27
+ "indoorRunning" => "run",
28
+ "mountaineering" => "hike"
29
+ }.freeze
30
+
31
+ STRAVA_TYPE_MAP = {
32
+ "outdoorWalking" => "Walk",
33
+ "outdoorRunning" => "Run",
34
+ "outdoorCycling" => "Ride",
35
+ "indoorRunning" => "VirtualRun",
36
+ "mountaineering" => "Hiking"
37
+ }.freeze
38
+
39
+ TIMESTAMP_THRESHOLD = 3_600_000
40
+ HR_THRESHOLD = 100
41
+
42
+ register_adapter
43
+ register_config :phone, :password
44
+
45
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
46
+ super
47
+ @phone = @config["phone"] || @config["mobile"]
48
+ @password = @config["password"]
49
+ @headers = {
50
+ "User-Agent" => "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0",
51
+ "Content-Type" => "application/x-www-form-urlencoded;charset=utf-8"
52
+ }
53
+ @session = nil
54
+ end
55
+
56
+ def before_call
57
+ return false if @phone.nil? || @password.nil?
58
+ super
59
+ end
60
+
61
+ def authenticate
62
+ @session = login
63
+ @session ? true : false
64
+ end
65
+
66
+ def pending_ids
67
+ existing = existing_run_ids
68
+ ids = []
69
+ KEEP_TYPES.each do |sport|
70
+ compound_ids = fetch_run_ids(sport)
71
+ compound_ids.each do |cid|
72
+ keep_id = extract_id(cid) || cid
73
+ run_id = "keep_#{keep_id}"
74
+ has_existing = existing.include?(run_id)
75
+ has_raw = raw_file_exists?(keep_id)
76
+ next if has_existing && has_raw
77
+ ids << { id: cid, keep_id: keep_id, sport: sport, has_raw: has_raw }
78
+ end
79
+ end
80
+ ids
81
+ end
82
+
83
+ def process_one(pending)
84
+ if pending[:has_raw]
85
+ upsert_from_raw(pending[:id])
86
+ else
87
+ sync_single_run_with_raw(pending[:id], pending[:sport])
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def http_post(url, body, headers)
94
+ uri = URI(url)
95
+ req = Net::HTTP::Post.new(uri)
96
+ headers.each { |k, v| req[k] = v }
97
+ req.body = body.is_a?(Hash) ? URI.encode_www_form(body) : body
98
+ http = Net::HTTP.new(uri.host, uri.port)
99
+ http.use_ssl = uri.scheme == "https"
100
+ http.open_timeout = 30
101
+ http.read_timeout = 60
102
+ http.start { |h| h.request(req) }
103
+ end
104
+
105
+ def http_get(url, headers)
106
+ uri = URI(url)
107
+ req = Net::HTTP::Get.new(uri)
108
+ headers.each { |k, v| req[k] = v }
109
+ http = Net::HTTP.new(uri.host, uri.port)
110
+ http.use_ssl = uri.scheme == "https"
111
+ http.open_timeout = 30
112
+ http.read_timeout = 60
113
+ http.start { |h| h.request(req) }
114
+ end
115
+
116
+ def login
117
+ resp = http_post(LOGIN_URL, { mobile: @phone, password: @password }, @headers)
118
+ return nil unless resp.code.to_i == 200
119
+
120
+ data = JSON.parse(resp.body)
121
+ token = data.dig("data", "token")
122
+ return nil unless token
123
+
124
+ @headers["Authorization"] = "Bearer #{token}"
125
+ @session = true
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+
130
+ def sync_sport_type(sport)
131
+ pending_ids.select { |p| p[:sport] == sport }
132
+ .filter_map { |p| process_one(p) }
133
+ .size
134
+ end
135
+
136
+ def existing_run_ids
137
+ @db[:activities].where(source: "keep").select_map(:run_id).map(&:to_s)
138
+ end
139
+
140
+ def fetch_run_ids(sport)
141
+ ids = []
142
+ last_date = 0
143
+
144
+ loop do
145
+ url = STATS_URL % { sport: sport, last_date: last_date }
146
+ resp = @session ? http_get(url, @headers) : nil
147
+ break unless resp&.code&.to_i == 200
148
+
149
+ data = JSON.parse(resp.body)["data"]
150
+ break unless data
151
+
152
+ records = data["records"] || []
153
+ records.each do |r|
154
+ logs = r["logs"] || []
155
+ logs.each do |log|
156
+ stats = log["stats"] || log
157
+ ids << stats["id"] unless stats["isDoubtful"]
158
+ end
159
+ end
160
+
161
+ last_date = data["lastTimestamp"]
162
+ break if last_date.nil? || last_date.zero?
163
+
164
+ spider_sleep
165
+ end
166
+
167
+ ids
168
+ rescue JSON::ParserError
169
+ ids
170
+ end
171
+
172
+ def sync_single_run_with_raw(compound_id, sport)
173
+ url = LOG_URL % { sport: sport, run_id: compound_id }
174
+ resp = http_get(url, @headers)
175
+ return false unless resp.code.to_i == 200
176
+
177
+ data = JSON.parse(resp.body)
178
+ run_data = data["data"]
179
+ return false unless run_data
180
+
181
+ payload = decrypt_raw_payload(run_data)
182
+ keep_id = extract_id(run_data["id"])
183
+ return false unless keep_id
184
+
185
+ write_source_archive({
186
+ source: "keep",
187
+ decodedAt: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
188
+ meta: {
189
+ startTime: run_data["startTime"],
190
+ endTime: run_data["endTime"],
191
+ duration: run_data["duration"],
192
+ dataType: run_data["dataType"],
193
+ distance: run_data["distance"],
194
+ region: run_data["region"]
195
+ },
196
+ geoPoints: payload[:geo_points],
197
+ heartRates: payload[:hr_data]
198
+ }, keep_id)
199
+
200
+ wgs_points = compute_wgs_points(payload, run_data)
201
+ write_standardized_json(wgs_points, payload, run_data, keep_id)
202
+
203
+ attrs = build_activity_from_wgs(run_data, wgs_points)
204
+ return false unless attrs
205
+
206
+ upsert_activity(attrs)
207
+ attrs
208
+ rescue JSON::ParserError
209
+ nil
210
+ end
211
+
212
+ def upsert_from_raw(compound_id)
213
+ keep_id = extract_id(compound_id) || compound_id
214
+ raw = JSON.parse(File.read(raw_path(keep_id)))
215
+ run_data = raw["meta"].merge("id" => compound_id)
216
+
217
+ wgs = load_wgs_points(keep_id, raw)
218
+ return false unless wgs&.any?
219
+
220
+ if raw["heartRates"]&.any?
221
+ vals = raw["heartRates"].map { |h| h["value"] }.compact
222
+ run_data["heartRate"] = { "averageHeartRate" => vals.sum / vals.size } if vals.any?
223
+ end
224
+
225
+ attrs = build_activity_from_wgs(run_data, wgs)
226
+ return false unless attrs
227
+
228
+ upsert_activity(attrs)
229
+ attrs
230
+ end
231
+
232
+ def load_wgs_points(keep_id, raw)
233
+ std_path = File.join(std_dir, "#{keep_id}.json")
234
+ if File.exist?(std_path)
235
+ pts = JSON.parse(File.read(std_path))
236
+ pts.map { |pt| [pt["latitude"], pt["longitude"], pt["altitude"],
237
+ pt["timestamp"] ? Time.parse(pt["timestamp"]) : nil, pt["heartrate"]] }
238
+ else
239
+ payload = { geo_points: raw["geoPoints"], hr_data: raw["heartRates"] }
240
+ run_data = raw["meta"]
241
+ compute_wgs_points(payload, run_data)
242
+ end
243
+ end
244
+
245
+ def decrypt_raw_payload(run_data)
246
+ {
247
+ geo_points: decode_geo_points(run_data["geoPoints"]),
248
+ hr_data: decode_hr_data(run_data.dig("heartRate", "heartRates"))
249
+ }
250
+ end
251
+
252
+ def compute_wgs_points(payload, run_data)
253
+ geo_points = payload[:geo_points]
254
+ hr_data = payload[:hr_data]
255
+ return [] unless geo_points&.any?
256
+
257
+ start_time = run_data["startTime"]
258
+ use_absolute_ts = geo_points.first["timestamp"].to_i > TIMESTAMP_THRESHOLD
259
+ effective_start = use_absolute_ts ? 0 : start_time
260
+
261
+ geo_points.map do |p|
262
+ lat, lng = Geo::CoordTransform.gcj02_to_wgs84_exact(p["latitude"], p["longitude"])
263
+ ts = p["timestamp"] || p["unixTimestamp"]
264
+ pt_time = ts ? Time.at(effective_start / 1000.0 + ts.to_i / 10.0).utc : nil
265
+ hr = find_nearest_hr(hr_data, ts, start_time)
266
+ [lat, lng, p["altitude"], pt_time, hr]
267
+ end
268
+ end
269
+
270
+ def tz_resolver
271
+ @tz_resolver ||= GitFit::Timezone::Resolver.new({})
272
+ end
273
+
274
+ def local_time_from_gps(start_utc, wgs_points)
275
+ return start_utc.iso8601 unless wgs_points&.any?
276
+
277
+ lat, lng = wgs_points.first[0..1]
278
+ tz_string = tz_resolver.resolve(lat, lng)
279
+ tz = TZInfo::Timezone.get(tz_string)
280
+ tz.utc_to_local(start_utc).iso8601
281
+ rescue => e
282
+ warn "Keep: timezone resolution failed: #{e.message}"
283
+ start_utc.iso8601
284
+ end
285
+
286
+ def build_activity_from_wgs(run_data, wgs_points)
287
+ keep_id = extract_id(run_data["id"])
288
+ return nil unless keep_id
289
+
290
+ start_time = run_data["startTime"]
291
+ end_time = run_data["endTime"]
292
+ duration = run_data["duration"]
293
+ return nil unless duration
294
+
295
+ start_date = Time.at(start_time / 1000.0).utc
296
+ data_type = run_data["dataType"]
297
+
298
+ avg_hr = run_data.dig("heartRate", "averageHeartRate")
299
+ avg_hr = nil if avg_hr && avg_hr < 0
300
+
301
+ polyline_str = nil
302
+ total_elevation = run_data["elevation"]
303
+ computed_distance = run_data["distance"]
304
+
305
+ if wgs_points.any?
306
+ if wgs_points.any? { |pt| pt[3] }
307
+ computed_distance ||= total_distance_haversine(wgs_points)
308
+ total_elevation ||= elevation_gain_from_points(wgs_points)
309
+ end
310
+
311
+ polyline_str = Geo::Polyline.encode(wgs_points.map { |pt| [pt[0], pt[1]] })
312
+ avg_hr ||= compute_avg_hr(wgs_points)
313
+ end
314
+
315
+ moving_time = duration
316
+ elapsed_time = ((end_time - start_time) / 1000.0).round
317
+
318
+ {
319
+ run_id: "keep_#{keep_id}",
320
+ name: "#{STRAVA_TYPE_MAP[data_type] || data_type} from keep",
321
+ distance: computed_distance&.round(2),
322
+ moving_time: moving_time,
323
+ elapsed_time: elapsed_time,
324
+ sport_category: TYPE_MAP[data_type] || "other",
325
+ sport_type: data_type,
326
+ start_date: start_date.iso8601,
327
+ start_date_local: local_time_from_gps(start_date, wgs_points),
328
+ location_country: run_data["region"],
329
+ summary_polyline: polyline_str,
330
+ average_heartrate: avg_hr&.round(1),
331
+ average_speed: computed_distance && moving_time&.positive? ? (computed_distance.to_f / moving_time).round(2) : nil,
332
+ elevation_gain: total_elevation&.round(1),
333
+ source: "keep"
334
+ }
335
+ end
336
+
337
+ def write_standardized_json(wgs_points, payload, run_data, keep_id)
338
+ geo_points = payload[:geo_points] || []
339
+ hr_data = payload[:hr_data]
340
+ start_time = run_data["startTime"]
341
+
342
+ extended = wgs_points.each_with_index.map do |pt, i|
343
+ orig = geo_points[i] || {}
344
+ ts = pt[3]
345
+ {
346
+ latitude: pt[0], longitude: pt[1], altitude: pt[2],
347
+ timestamp: ts&.strftime("%Y-%m-%dT%H:%M:%SZ"),
348
+ heartrate: pt[4],
349
+ speed: orig["speed"],
350
+ currentTotalDistance: orig["currentTotalDistance"],
351
+ currentTotalDuration: orig["currentTotalDuration"],
352
+ currentPace: orig["currentPace"],
353
+ pressure: orig["pressure"],
354
+ accuracyRadius: orig["accuracyRadius"],
355
+ verticalAccuracy: orig["verticalAccuracy"],
356
+ locationType: orig["locationType"],
357
+ isPause: orig["isPause"],
358
+ flags: orig["flags"],
359
+ processLabel: orig["processLabel"],
360
+ crossKmMark: orig["crossKmMark"],
361
+ currentTotalSteps: orig["currentTotalSteps"]
362
+ }
363
+ end
364
+
365
+ dir = std_dir
366
+ FileUtils.mkdir_p(dir)
367
+ tmp = File.join(dir, ".#{keep_id}.json.tmp")
368
+ final = File.join(dir, "#{keep_id}.json")
369
+ File.write(tmp, JSON.generate(extended))
370
+ File.rename(tmp, final)
371
+ end
372
+
373
+ def raw_path(platform_id)
374
+ File.join(raw_dir, "#{platform_id}.#{KEEP_RAW_EXT}")
375
+ end
376
+
377
+ def extract_id(id_str)
378
+ return nil unless id_str
379
+ id_str.split("_")[1]
380
+ end
381
+
382
+ def decode_geo_points(geo_data)
383
+ return nil unless geo_data
384
+ raw = Base64.decode64(geo_data)
385
+ decrypted = aes_decrypt(raw)
386
+ decompressed = zlib_inflate(decrypted)
387
+ JSON.parse(decompressed)
388
+ rescue StandardError
389
+ nil
390
+ end
391
+
392
+ def decode_hr_data(hr_data)
393
+ return nil unless hr_data
394
+ raw = Base64.decode64(hr_data)
395
+ decompressed = zlib_inflate(raw)
396
+ JSON.parse(decompressed)
397
+ rescue StandardError
398
+ nil
399
+ end
400
+
401
+ def aes_decrypt(data)
402
+ cipher = OpenSSL::Cipher.new("AES-128-CBC")
403
+ cipher.decrypt
404
+ cipher.key = AES_KEY
405
+ cipher.iv = AES_IV
406
+ cipher.update(data) + cipher.final
407
+ end
408
+
409
+ def zlib_inflate(data)
410
+ [-Zlib::MAX_WBITS, Zlib::MAX_WBITS, Zlib::MAX_WBITS + 16].each do |wbits|
411
+ begin
412
+ inflater = Zlib::Inflate.new(wbits)
413
+ result = inflater.inflate(data)
414
+ inflater.finish rescue nil
415
+ inflater.close
416
+ return result
417
+ rescue Zlib::DataError
418
+ next
419
+ end
420
+ end
421
+ raise Zlib::DataError, "cannot inflate data"
422
+ end
423
+
424
+ def find_nearest_hr(hr_data, geo_ts, start_time, threshold: HR_THRESHOLD)
425
+ return nil unless hr_data&.any? && geo_ts
426
+
427
+ normalize = ->(ts) {
428
+ if ts > TIMESTAMP_THRESHOLD
429
+ ts - start_time / 100
430
+ else
431
+ ts
432
+ end
433
+ }
434
+
435
+ target = normalize.call(geo_ts)
436
+
437
+ nearest = nil
438
+ min_diff = Float::INFINITY
439
+
440
+ hr_data.each do |item|
441
+ ts = item["timestamp"]
442
+ next unless ts
443
+
444
+ diff = (normalize.call(ts) - target).abs
445
+ if diff <= threshold && diff < min_diff
446
+ nearest = item
447
+ min_diff = diff
448
+ end
449
+ end
450
+
451
+ hr = nearest&.dig("beatsPerMinute")
452
+ hr&.positive? ? hr : nil
453
+ end
454
+
455
+ def compute_avg_hr(points)
456
+ hrs = points.map { |p| p[4] }.compact
457
+ return nil if hrs.empty?
458
+ hrs.sum / hrs.size
459
+ end
460
+
461
+ def total_distance_haversine(points)
462
+ return 0.0 if points.length < 2
463
+ total = 0.0
464
+ (1...points.length).each do |i|
465
+ total += haversine_dist(points[i - 1], points[i])
466
+ end
467
+ total
468
+ end
469
+
470
+ def haversine_dist(p1, p2)
471
+ lat1 = p1[0] * Math::PI / 180.0
472
+ lat2 = p2[0] * Math::PI / 180.0
473
+ dlat = (p2[0] - p1[0]) * Math::PI / 180.0
474
+ dlon = (p2[1] - p1[1]) * Math::PI / 180.0
475
+
476
+ a = Math.sin(dlat / 2) ** 2 +
477
+ Math.cos(lat1) * Math.cos(lat2) *
478
+ Math.sin(dlon / 2) ** 2
479
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
480
+ 6371000.0 * c
481
+ end
482
+
483
+ def elevation_gain_from_points(points)
484
+ total = 0.0
485
+ (1...points.length).each do |i|
486
+ elev1 = points[i - 1][2] || 0.0
487
+ elev2 = points[i][2] || 0.0
488
+ gain = elev2 - elev1
489
+ total += gain if gain > 0
490
+ end
491
+ total
492
+ end
493
+
494
+ def spider_sleep
495
+ sleep 0.1
496
+ end
497
+ end
498
+ end
499
+ end
@@ -6,6 +6,7 @@ module GitFit
6
6
  module Sync
7
7
  class Lorem < Base
8
8
  register_config :count
9
+ register_test_adapter
9
10
 
10
11
  DEMO_ACTIVITIES = [
11
12
  { id: "alpha_001", name: "Lorem Morning Run", sport_category: "run",