git-fit 0.17.3 → 0.17.5
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/elevation/backfill.rb +21 -1
- data/lib/git_fit/sync/xingzhe.rb +193 -7
- data/lib/git_fit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e56c52b84377fa13e79c341ee252e84ce3664a448de70ceb690609c55f6d659
|
|
4
|
+
data.tar.gz: 5eee032b2293d18b5775a1250b15c723f794c02aedf27b0198d975c5fd1f27d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba9969a0f3a82a4b255e41e41739cafd75554a1a36e499322610d50cd0fba75ebec6e46a8936418ca58dcd4763e167f72467036b4ff778d6f92a142d53ba6e7b
|
|
7
|
+
data.tar.gz: 470017ec4828dcae793778c26732234b9c27b6aeac561d774a152734adc2a2ab27cc362feb586b40862cc7ddb45c16924026ae3ac99c181ed20d7b3b5b3f2fa3
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module GitFit
|
|
4
4
|
module Elevation
|
|
5
5
|
class Backfill
|
|
6
|
-
SOURCES = %w[igpsport xoss strava garmin garmin_cn].freeze
|
|
6
|
+
SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe].freeze
|
|
7
7
|
BATCH_SIZE = 100
|
|
8
8
|
|
|
9
9
|
def initialize(db, force: false)
|
|
@@ -61,6 +61,7 @@ module GitFit
|
|
|
61
61
|
when 'garmin' then compute_from_fit(source, natural_id)
|
|
62
62
|
when 'garmin_cn' then compute_from_fit(source, natural_id)
|
|
63
63
|
when 'strava' then compute_from_l2(source, natural_id)
|
|
64
|
+
when 'xingzhe' then compute_from_xingzhe(natural_id)
|
|
64
65
|
else return
|
|
65
66
|
end
|
|
66
67
|
|
|
@@ -118,6 +119,25 @@ module GitFit
|
|
|
118
119
|
nil
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
def compute_from_xingzhe(natural_id)
|
|
123
|
+
raw_path = File.join('data', 'raw', 'xingzhe', "#{natural_id}.json")
|
|
124
|
+
return nil unless File.exist?(raw_path)
|
|
125
|
+
|
|
126
|
+
raw = JSON.parse(File.read(raw_path))
|
|
127
|
+
alts = raw.dig('stream', 'altitude') || []
|
|
128
|
+
return nil if alts.size < 2
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
elevation_gain: elevation_gain_from_alts(alts)&.round(1),
|
|
132
|
+
elevation_loss: elevation_loss_from_alts(alts)&.round(1),
|
|
133
|
+
elevation_min: alts.min&.round(1),
|
|
134
|
+
elevation_max: alts.max&.round(1),
|
|
135
|
+
}
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
warn "Elevation backfill [xingzhe][#{natural_id}]: #{e.message}"
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
121
141
|
def fit_path_for(source, natural_id)
|
|
122
142
|
case source
|
|
123
143
|
when 'garmin', 'garmin_cn'
|
data/lib/git_fit/sync/xingzhe.rb
CHANGED
|
@@ -14,6 +14,7 @@ module GitFit
|
|
|
14
14
|
WORKOUTS_PATH = '/api/v1/pgworkout/'
|
|
15
15
|
STREAM_PATH = '/api/v1/pgworkout/'
|
|
16
16
|
DETAIL_PATH = '/api/v1/pgworkout/'
|
|
17
|
+
EXPORT_BASE = '/api/v1/workout/'
|
|
17
18
|
RSA_PUBKEY = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDmuQkBbijudDAJgfffDeeIButqWHZvUwcRuvWdg89393FSdz3IJUHc0rgI/S3WuU8N0VePJLmVAZtCOK4qe4FY/eKmWpJmn7JfXB4HTMWjPVoyRZmSYjW4L8GrWmh51Qj7DwpTADadF3aq04o+s1b8LXJa8r6+TIqqL5WUHtRqmQIDAQAB'
|
|
18
19
|
|
|
19
20
|
SPORT_MAP = {
|
|
@@ -21,8 +22,6 @@ module GitFit
|
|
|
21
22
|
5 => 'swim', 6 => 'walk', 11 => 'ski', 13 => 'workout'
|
|
22
23
|
}.freeze
|
|
23
24
|
|
|
24
|
-
RAW_EXT = 'json'
|
|
25
|
-
|
|
26
25
|
SESSION_TTL = 30 * 24 * 60 * 60
|
|
27
26
|
|
|
28
27
|
register_adapter
|
|
@@ -85,6 +84,49 @@ module GitFit
|
|
|
85
84
|
@last_http_code
|
|
86
85
|
end
|
|
87
86
|
|
|
87
|
+
# ── Raw directory layout (Phase 1, mirrors garmin Phase 1) ──────────
|
|
88
|
+
# data/raw/xingzhe/{id}/stream.json — stream envelope
|
|
89
|
+
# data/raw/xingzhe/{id}/detail.json — full detail response (workout+user+...)
|
|
90
|
+
# data/raw/xingzhe/{id}/{id}.gpx — GPX export (when available)
|
|
91
|
+
# data/raw/xingzhe/{id}/{id}.fit — FIT export (when is_fit)
|
|
92
|
+
def raw_path(platform_id)
|
|
93
|
+
File.join(raw_dir, platform_id, 'stream.json')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def raw_detail_path(platform_id)
|
|
97
|
+
File.join(raw_dir, platform_id, 'detail.json')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def raw_gpx_path(platform_id)
|
|
101
|
+
File.join(raw_dir, platform_id, "#{platform_id}.gpx")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def raw_fit_path(platform_id)
|
|
105
|
+
File.join(raw_dir, platform_id, "#{platform_id}.fit")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def raw_file_exists?(platform_id)
|
|
109
|
+
dir = File.join(raw_dir, platform_id)
|
|
110
|
+
%w[stream.json detail.json].all? { |f| File.exist?(File.join(dir, f)) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def write_source_archive(data, platform_id, _ext = 'json')
|
|
114
|
+
# Back-compat entry point — now writes stream.json into per-id dir
|
|
115
|
+
write_raw_file(data, 'stream.json', platform_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_raw_file(data, filename, platform_id)
|
|
119
|
+
dir = File.join(raw_dir, platform_id)
|
|
120
|
+
FileUtils.mkdir_p(dir)
|
|
121
|
+
tmp = File.join(dir, ".#{filename}.tmp")
|
|
122
|
+
dest = File.join(dir, filename)
|
|
123
|
+
case data
|
|
124
|
+
when String then File.write(tmp, data)
|
|
125
|
+
when Hash, Array then File.write(tmp, JSON.generate(data))
|
|
126
|
+
end
|
|
127
|
+
File.rename(tmp, dest)
|
|
128
|
+
end
|
|
129
|
+
|
|
88
130
|
private
|
|
89
131
|
|
|
90
132
|
def http_request(method, path, body: nil, params: nil, headers: {})
|
|
@@ -252,9 +294,28 @@ module GitFit
|
|
|
252
294
|
average_speed: (act['avg_speed'].to_f / 3.6).round(2).nonzero?,
|
|
253
295
|
elevation_gain: act['elevation_gain'].to_f.nonzero?,
|
|
254
296
|
elevation_loss: act['elevation_loss'].to_f.nonzero?,
|
|
297
|
+
elevation_max: act['max_altitude']&.to_f&.nonzero?,
|
|
255
298
|
source: 'xingzhe',
|
|
256
299
|
}
|
|
257
300
|
|
|
301
|
+
# Phase 1: fetch and persist detail + stream; Phase 2: exports
|
|
302
|
+
detail_workout = nil
|
|
303
|
+
full_detail = nil
|
|
304
|
+
|
|
305
|
+
# Fetch full detail (public, with segments/slopes/pois/laps) for raw archive
|
|
306
|
+
fetched_full, fetched_workout = fetch_full_detail(aid)
|
|
307
|
+
if fetched_full
|
|
308
|
+
full_detail = fetched_full
|
|
309
|
+
detail_workout = fetched_workout
|
|
310
|
+
# Persist full detail response (code/data/msg) as detail.json
|
|
311
|
+
write_raw_file(full_detail, 'detail.json', aid)
|
|
312
|
+
# Prefer workout from full_detail for attrs that benefit from richer data
|
|
313
|
+
if detail_workout
|
|
314
|
+
attrs[:elevation_max] ||= detail_workout['max_altitude']&.to_f&.nonzero?
|
|
315
|
+
# equipment_info / power / grade etc. are archived in detail.json for downstream use
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
258
319
|
pts = nil
|
|
259
320
|
stream = fetch_stream(aid)
|
|
260
321
|
if stream
|
|
@@ -270,19 +331,36 @@ module GitFit
|
|
|
270
331
|
end
|
|
271
332
|
end
|
|
272
333
|
|
|
273
|
-
if attrs[:summary_polyline].nil?
|
|
274
|
-
|
|
275
|
-
|
|
334
|
+
if attrs[:summary_polyline].nil?
|
|
335
|
+
fallback_workout = detail_workout || fetch_activity_detail(aid)
|
|
336
|
+
if fallback_workout && (segments = fallback_workout['segments_km'])
|
|
337
|
+
pts = segments.map { |s| [s['latitude'], s['longitude']] }
|
|
338
|
+
attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
|
|
339
|
+
end
|
|
276
340
|
end
|
|
277
341
|
|
|
342
|
+
# Elevation min: prefer stream altitude min, fallback to segments_km altitude min (see wiki)
|
|
343
|
+
attrs[:elevation_min] = derive_elevation_min(stream, detail_workout || act)
|
|
344
|
+
|
|
345
|
+
# Exports (best-effort, per-record format probing — see issue #86)
|
|
346
|
+
try_download_exports(aid, detail_workout || act) if stream || full_detail
|
|
347
|
+
|
|
278
348
|
attrs[:start_date_local] = resolve_local_time(start_t, pts)
|
|
279
349
|
|
|
280
350
|
upsert_activity(attrs)
|
|
281
351
|
attrs
|
|
282
352
|
end
|
|
283
353
|
|
|
284
|
-
def
|
|
285
|
-
|
|
354
|
+
def derive_elevation_min(stream, detail_workout)
|
|
355
|
+
if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.any?
|
|
356
|
+
v = alt.compact.min
|
|
357
|
+
return v.to_f if v
|
|
358
|
+
end
|
|
359
|
+
if detail_workout && (segs = detail_workout['segments_km']) && segs.is_a?(Array) && segs.any?
|
|
360
|
+
v = segs.filter_map { |s| s['altitude'] }.min
|
|
361
|
+
return v.to_f if v
|
|
362
|
+
end
|
|
363
|
+
nil
|
|
286
364
|
end
|
|
287
365
|
|
|
288
366
|
def fetch_stream(id)
|
|
@@ -338,6 +416,114 @@ module GitFit
|
|
|
338
416
|
data.dig('data', 'workout')
|
|
339
417
|
end
|
|
340
418
|
|
|
419
|
+
# Full detail with segments/slopes/pois/laps — returns [full_json, workout_hash]
|
|
420
|
+
def fetch_full_detail(id)
|
|
421
|
+
resp = http_request(:get, "#{DETAIL_PATH}#{id}/",
|
|
422
|
+
params: { segments: 'true', slopes: 'true', pois: 'true', laps: 'true' })
|
|
423
|
+
return [nil, nil] unless resp.code.to_i == 200
|
|
424
|
+
|
|
425
|
+
data = JSON.parse(resp.body)
|
|
426
|
+
return [nil, nil] unless data['code'] == 200 || data.dig('data', 'workout')
|
|
427
|
+
|
|
428
|
+
workout = data.dig('data', 'workout')
|
|
429
|
+
[data, workout]
|
|
430
|
+
rescue JSON::ParserError
|
|
431
|
+
[nil, nil]
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def try_download_exports(activity_id, detail_workout)
|
|
435
|
+
# GPX: generally available — probe via authed GET
|
|
436
|
+
download_and_save_gpx(activity_id)
|
|
437
|
+
# FIT: only when is_fit truthy (per detail), else probe still but expect 404/302 empty
|
|
438
|
+
is_fit = detail_workout && detail_workout['is_fit']
|
|
439
|
+
# Always attempt FIT when summary suggests ride with device; but gate on is_fit to avoid noise
|
|
440
|
+
download_and_save_fit(activity_id) if is_fit
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
warn "XingZhe: export download failed for #{activity_id}: #{e.message}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def download_and_save_gpx(activity_id)
|
|
446
|
+
resp = authed_get("#{EXPORT_BASE}#{activity_id}/gpx/")
|
|
447
|
+
return false unless resp && resp.code.to_i == 200
|
|
448
|
+
body = resp.body
|
|
449
|
+
return false if body.nil? || body.empty?
|
|
450
|
+
# GPX endpoint returns XML but Content-Type may be application/json — validate by prefix
|
|
451
|
+
stripped = body.lstrip
|
|
452
|
+
unless stripped.start_with?('<?xml', '<gpx')
|
|
453
|
+
# Some error responses are JSON with code/msg
|
|
454
|
+
begin
|
|
455
|
+
j = JSON.parse(body)
|
|
456
|
+
return false if j['code'] && j['code'] != 200
|
|
457
|
+
rescue JSON::ParserError
|
|
458
|
+
return false
|
|
459
|
+
end
|
|
460
|
+
return false unless stripped.include?('<gpx') || stripped.include?('<trk')
|
|
461
|
+
end
|
|
462
|
+
write_raw_file(body, "#{activity_id}.gpx", activity_id)
|
|
463
|
+
true
|
|
464
|
+
rescue StandardError => e
|
|
465
|
+
warn "XingZhe: failed to download gpx for #{activity_id}: #{e.message}"
|
|
466
|
+
false
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def download_and_save_fit(activity_id)
|
|
470
|
+
# FIT export: 302 → presigned OSS URL (see issue #86 verification)
|
|
471
|
+
resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers || {})
|
|
472
|
+
code = resp.code.to_i
|
|
473
|
+
# 302/301 redirect to OSS is the expected success path — handle before auth check
|
|
474
|
+
if [301, 302, 303, 307, 308].include?(code)
|
|
475
|
+
location = resp['location'] || resp['Location']
|
|
476
|
+
if location && !location.empty?
|
|
477
|
+
uri = URI(location)
|
|
478
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
479
|
+
http.use_ssl = uri.scheme == 'https'
|
|
480
|
+
http.open_timeout = 30
|
|
481
|
+
http.read_timeout = 60
|
|
482
|
+
req = Net::HTTP::Get.new(uri)
|
|
483
|
+
oss_resp = http.start { |h| h.request(req) }
|
|
484
|
+
return false unless oss_resp.code.to_i == 200
|
|
485
|
+
|
|
486
|
+
# Binary FIT — write as String (Ruby handles binary)
|
|
487
|
+
write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
|
|
488
|
+
return true
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
if auth_failure?(resp)
|
|
492
|
+
remove_stale_session
|
|
493
|
+
return false unless login
|
|
494
|
+
|
|
495
|
+
resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers)
|
|
496
|
+
code = resp.code.to_i
|
|
497
|
+
if [301, 302, 303, 307, 308].include?(code)
|
|
498
|
+
location = resp['location'] || resp['Location']
|
|
499
|
+
return false unless location && !location.empty?
|
|
500
|
+
|
|
501
|
+
uri = URI(location)
|
|
502
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
503
|
+
http.use_ssl = uri.scheme == 'https'
|
|
504
|
+
http.open_timeout = 30
|
|
505
|
+
http.read_timeout = 60
|
|
506
|
+
req = Net::HTTP::Get.new(uri)
|
|
507
|
+
oss_resp = http.start { |h| h.request(req) }
|
|
508
|
+
return false unless oss_resp.code.to_i == 200
|
|
509
|
+
|
|
510
|
+
write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
|
|
511
|
+
return true
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
if code == 200
|
|
515
|
+
# Direct FIT (rare)
|
|
516
|
+
return false if resp.body.nil? || resp.body.empty?
|
|
517
|
+
write_raw_file(resp.body, "#{activity_id}.fit", activity_id)
|
|
518
|
+
true
|
|
519
|
+
else
|
|
520
|
+
false
|
|
521
|
+
end
|
|
522
|
+
rescue StandardError => e
|
|
523
|
+
warn "XingZhe: failed to download fit for #{activity_id}: #{e.message}"
|
|
524
|
+
false
|
|
525
|
+
end
|
|
526
|
+
|
|
341
527
|
def map_sport(sport)
|
|
342
528
|
SPORT_MAP[sport] || 'other'
|
|
343
529
|
end
|
data/lib/git_fit/version.rb
CHANGED