git-fit 0.17.3 → 0.17.4
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 +192 -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: 4c9c026f3d44b75a14eae1f36cc7dc6a56434cd0039996bfb38163fc3bb617a5
|
|
4
|
+
data.tar.gz: b11245bfa2b0fef405e5f0f369fd02582d1a2ba509b7fb91972f38249ae8ec01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1baed90c0c86bbb46edefd951646ba0f2fffd8cd08b07eb900479a74f372de43d50c7a0f39c56f728f8fb476cfe264075eaafcc3721b2bcd5dddc692b9469592
|
|
7
|
+
data.tar.gz: e98542328ef0210b5934a1c85dfb388b35563df9a4998bb52de7dd6847c05f7ada8a57402d9c359ed08bb6f9420d3a5205d91454c61b40801faa6bfd6fad769f
|
|
@@ -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,48 @@ 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
|
+
File.exist?(raw_path(platform_id))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def write_source_archive(data, platform_id, _ext = 'json')
|
|
113
|
+
# Back-compat entry point — now writes stream.json into per-id dir
|
|
114
|
+
write_raw_file(data, 'stream.json', platform_id)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def write_raw_file(data, filename, platform_id)
|
|
118
|
+
dir = File.join(raw_dir, platform_id)
|
|
119
|
+
FileUtils.mkdir_p(dir)
|
|
120
|
+
tmp = File.join(dir, ".#{filename}.tmp")
|
|
121
|
+
dest = File.join(dir, filename)
|
|
122
|
+
case data
|
|
123
|
+
when String then File.write(tmp, data)
|
|
124
|
+
when Hash, Array then File.write(tmp, JSON.generate(data))
|
|
125
|
+
end
|
|
126
|
+
File.rename(tmp, dest)
|
|
127
|
+
end
|
|
128
|
+
|
|
88
129
|
private
|
|
89
130
|
|
|
90
131
|
def http_request(method, path, body: nil, params: nil, headers: {})
|
|
@@ -252,9 +293,28 @@ module GitFit
|
|
|
252
293
|
average_speed: (act['avg_speed'].to_f / 3.6).round(2).nonzero?,
|
|
253
294
|
elevation_gain: act['elevation_gain'].to_f.nonzero?,
|
|
254
295
|
elevation_loss: act['elevation_loss'].to_f.nonzero?,
|
|
296
|
+
elevation_max: act['max_altitude']&.to_f&.nonzero?,
|
|
255
297
|
source: 'xingzhe',
|
|
256
298
|
}
|
|
257
299
|
|
|
300
|
+
# Phase 1: fetch and persist detail + stream; Phase 2: exports
|
|
301
|
+
detail_workout = nil
|
|
302
|
+
full_detail = nil
|
|
303
|
+
|
|
304
|
+
# Fetch full detail (public, with segments/slopes/pois/laps) for raw archive
|
|
305
|
+
fetched_full, fetched_workout = fetch_full_detail(aid)
|
|
306
|
+
if fetched_full
|
|
307
|
+
full_detail = fetched_full
|
|
308
|
+
detail_workout = fetched_workout
|
|
309
|
+
# Persist full detail response (code/data/msg) as detail.json
|
|
310
|
+
write_raw_file(full_detail, 'detail.json', aid)
|
|
311
|
+
# Prefer workout from full_detail for attrs that benefit from richer data
|
|
312
|
+
if detail_workout
|
|
313
|
+
attrs[:elevation_max] ||= detail_workout['max_altitude']&.to_f&.nonzero?
|
|
314
|
+
# equipment_info / power / grade etc. are archived in detail.json for downstream use
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
258
318
|
pts = nil
|
|
259
319
|
stream = fetch_stream(aid)
|
|
260
320
|
if stream
|
|
@@ -270,19 +330,36 @@ module GitFit
|
|
|
270
330
|
end
|
|
271
331
|
end
|
|
272
332
|
|
|
273
|
-
if attrs[:summary_polyline].nil?
|
|
274
|
-
|
|
275
|
-
|
|
333
|
+
if attrs[:summary_polyline].nil?
|
|
334
|
+
fallback_workout = detail_workout || fetch_activity_detail(aid)
|
|
335
|
+
if fallback_workout && (segments = fallback_workout['segments_km'])
|
|
336
|
+
pts = segments.map { |s| [s['latitude'], s['longitude']] }
|
|
337
|
+
attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
|
|
338
|
+
end
|
|
276
339
|
end
|
|
277
340
|
|
|
341
|
+
# Elevation min: prefer stream altitude min, fallback to segments_km altitude min (see wiki)
|
|
342
|
+
attrs[:elevation_min] = derive_elevation_min(stream, detail_workout || act)
|
|
343
|
+
|
|
344
|
+
# Exports (best-effort, per-record format probing — see issue #86)
|
|
345
|
+
try_download_exports(aid, detail_workout || act) if stream || full_detail
|
|
346
|
+
|
|
278
347
|
attrs[:start_date_local] = resolve_local_time(start_t, pts)
|
|
279
348
|
|
|
280
349
|
upsert_activity(attrs)
|
|
281
350
|
attrs
|
|
282
351
|
end
|
|
283
352
|
|
|
284
|
-
def
|
|
285
|
-
|
|
353
|
+
def derive_elevation_min(stream, detail_workout)
|
|
354
|
+
if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.any?
|
|
355
|
+
v = alt.compact.min
|
|
356
|
+
return v.to_f if v
|
|
357
|
+
end
|
|
358
|
+
if detail_workout && (segs = detail_workout['segments_km']) && segs.is_a?(Array) && segs.any?
|
|
359
|
+
v = segs.filter_map { |s| s['altitude'] }.min
|
|
360
|
+
return v.to_f if v
|
|
361
|
+
end
|
|
362
|
+
nil
|
|
286
363
|
end
|
|
287
364
|
|
|
288
365
|
def fetch_stream(id)
|
|
@@ -338,6 +415,114 @@ module GitFit
|
|
|
338
415
|
data.dig('data', 'workout')
|
|
339
416
|
end
|
|
340
417
|
|
|
418
|
+
# Full detail with segments/slopes/pois/laps — returns [full_json, workout_hash]
|
|
419
|
+
def fetch_full_detail(id)
|
|
420
|
+
resp = http_request(:get, "#{DETAIL_PATH}#{id}/",
|
|
421
|
+
params: { segments: 'true', slopes: 'true', pois: 'true', laps: 'true' })
|
|
422
|
+
return [nil, nil] unless resp.code.to_i == 200
|
|
423
|
+
|
|
424
|
+
data = JSON.parse(resp.body)
|
|
425
|
+
return [nil, nil] unless data['code'] == 200 || data.dig('data', 'workout')
|
|
426
|
+
|
|
427
|
+
workout = data.dig('data', 'workout')
|
|
428
|
+
[data, workout]
|
|
429
|
+
rescue JSON::ParserError
|
|
430
|
+
[nil, nil]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def try_download_exports(activity_id, detail_workout)
|
|
434
|
+
# GPX: generally available — probe via authed GET
|
|
435
|
+
download_and_save_gpx(activity_id)
|
|
436
|
+
# FIT: only when is_fit truthy (per detail), else probe still but expect 404/302 empty
|
|
437
|
+
is_fit = detail_workout && detail_workout['is_fit']
|
|
438
|
+
# Always attempt FIT when summary suggests ride with device; but gate on is_fit to avoid noise
|
|
439
|
+
download_and_save_fit(activity_id) if is_fit
|
|
440
|
+
rescue StandardError => e
|
|
441
|
+
warn "XingZhe: export download failed for #{activity_id}: #{e.message}"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def download_and_save_gpx(activity_id)
|
|
445
|
+
resp = authed_get("#{EXPORT_BASE}#{activity_id}/gpx/")
|
|
446
|
+
return false unless resp && resp.code.to_i == 200
|
|
447
|
+
body = resp.body
|
|
448
|
+
return false if body.nil? || body.empty?
|
|
449
|
+
# GPX endpoint returns XML but Content-Type may be application/json — validate by prefix
|
|
450
|
+
stripped = body.lstrip
|
|
451
|
+
unless stripped.start_with?('<?xml', '<gpx')
|
|
452
|
+
# Some error responses are JSON with code/msg
|
|
453
|
+
begin
|
|
454
|
+
j = JSON.parse(body)
|
|
455
|
+
return false if j['code'] && j['code'] != 200
|
|
456
|
+
rescue JSON::ParserError
|
|
457
|
+
return false
|
|
458
|
+
end
|
|
459
|
+
return false unless stripped.include?('<gpx') || stripped.include?('<trk')
|
|
460
|
+
end
|
|
461
|
+
write_raw_file(body, "#{activity_id}.gpx", activity_id)
|
|
462
|
+
true
|
|
463
|
+
rescue StandardError => e
|
|
464
|
+
warn "XingZhe: failed to download gpx for #{activity_id}: #{e.message}"
|
|
465
|
+
false
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def download_and_save_fit(activity_id)
|
|
469
|
+
# FIT export: 302 → presigned OSS URL (see issue #86 verification)
|
|
470
|
+
resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers || {})
|
|
471
|
+
code = resp.code.to_i
|
|
472
|
+
# 302/301 redirect to OSS is the expected success path — handle before auth check
|
|
473
|
+
if [301, 302, 303, 307, 308].include?(code)
|
|
474
|
+
location = resp['location'] || resp['Location']
|
|
475
|
+
if location && !location.empty?
|
|
476
|
+
uri = URI(location)
|
|
477
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
478
|
+
http.use_ssl = uri.scheme == 'https'
|
|
479
|
+
http.open_timeout = 30
|
|
480
|
+
http.read_timeout = 60
|
|
481
|
+
req = Net::HTTP::Get.new(uri)
|
|
482
|
+
oss_resp = http.start { |h| h.request(req) }
|
|
483
|
+
return false unless oss_resp.code.to_i == 200
|
|
484
|
+
|
|
485
|
+
# Binary FIT — write as String (Ruby handles binary)
|
|
486
|
+
write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
|
|
487
|
+
return true
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
if auth_failure?(resp)
|
|
491
|
+
remove_stale_session
|
|
492
|
+
return false unless login
|
|
493
|
+
|
|
494
|
+
resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers)
|
|
495
|
+
code = resp.code.to_i
|
|
496
|
+
if [301, 302, 303, 307, 308].include?(code)
|
|
497
|
+
location = resp['location'] || resp['Location']
|
|
498
|
+
return false unless location && !location.empty?
|
|
499
|
+
|
|
500
|
+
uri = URI(location)
|
|
501
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
502
|
+
http.use_ssl = uri.scheme == 'https'
|
|
503
|
+
http.open_timeout = 30
|
|
504
|
+
http.read_timeout = 60
|
|
505
|
+
req = Net::HTTP::Get.new(uri)
|
|
506
|
+
oss_resp = http.start { |h| h.request(req) }
|
|
507
|
+
return false unless oss_resp.code.to_i == 200
|
|
508
|
+
|
|
509
|
+
write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
|
|
510
|
+
return true
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
if code == 200
|
|
514
|
+
# Direct FIT (rare)
|
|
515
|
+
return false if resp.body.nil? || resp.body.empty?
|
|
516
|
+
write_raw_file(resp.body, "#{activity_id}.fit", activity_id)
|
|
517
|
+
true
|
|
518
|
+
else
|
|
519
|
+
false
|
|
520
|
+
end
|
|
521
|
+
rescue StandardError => e
|
|
522
|
+
warn "XingZhe: failed to download fit for #{activity_id}: #{e.message}"
|
|
523
|
+
false
|
|
524
|
+
end
|
|
525
|
+
|
|
341
526
|
def map_sport(sport)
|
|
342
527
|
SPORT_MAP[sport] || 'other'
|
|
343
528
|
end
|
data/lib/git_fit/version.rb
CHANGED