git-fit 0.23.2 → 0.24.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f0c03193a95a9f5f4b02aaf515cad3b547e57c82201359d1a9cbd2194261ccf
4
- data.tar.gz: ba4acbff8c51f343c0641a265c3e75356830864e48e93abd8c24054602762745
3
+ metadata.gz: 91c3d0ac4cecce476fb6ca55ac9d42b2f0cf5159d52f51df9551aa511ce20108
4
+ data.tar.gz: b8ea24629e8e92424084edfe441588ad478fdc3388f8763d299c88ddc9a73e2d
5
5
  SHA512:
6
- metadata.gz: 57640bd7dc13d63da72f616a50cd48f9bd9a903f4cadf3edf1321f2344410859641f44d50701ac26da1622c3bdc460a6d639a94564c1da7a7862dccd6b9c8c65
7
- data.tar.gz: 80ee63c140618207ab471327f3673daed9d2ed211f104dd9442484758f085a2420290756187dadb6d521575abf50b5832edfcb24911db633e620fbf5c207c4e0
6
+ metadata.gz: a86c69332d9dc3474a2f8984e900f8f84be6cce1b6a9f3262a103d9e153407935575e4f32a697906f3947d0e86f947e075abd3961014bab525b0e267bdc2b466
7
+ data.tar.gz: c84de3d8dc16989789e2b69c477b5f9500f51e048ac51f4cd4cbe19aefc5944669ed96c3ef192f6ff4bfbd80a31168b33da043472aabd71449150812252bcd24
data/lib/git-fit.rb CHANGED
@@ -69,6 +69,8 @@ require_relative 'git_fit/sync/garmin_com'
69
69
  require_relative 'git_fit/sync/garmin_cn'
70
70
  require_relative 'git_fit/sync/strava'
71
71
  require_relative 'git_fit/sync/keep'
72
+ require_relative 'git_fit/sync/codoon'
73
+ require_relative 'git_fit/sync/joyrun'
72
74
  require_relative 'git_fit/sync/igpsport'
73
75
  require_relative 'git_fit/sync/xoss'
74
76
  require_relative 'git_fit/sync/xingzhe'
@@ -32,19 +32,24 @@ module GitFit
32
32
  end
33
33
 
34
34
  def auth(source)
35
- sources = %w[garmin garmin_cn strava]
35
+ sources = %w[garmin garmin_cn strava joyrun]
36
36
  unless sources.include?(source)
37
37
  say_status :error, "Supported: #{sources.join(', ')}", :red
38
38
  return
39
39
  end
40
40
 
41
41
  config = git_fit_config.sync_config(source)
42
- if config.empty?
42
+ if source == 'joyrun'
43
+ config = config.merge(
44
+ 'phone' => options[:phone],
45
+ 'sms_code' => options[:sms_code],
46
+ )
47
+ elsif config.empty?
43
48
  say_status :warn, "No config for #{source}. Set env vars or config.yml", :yellow
44
49
  return
45
50
  end
46
51
 
47
- adapter = build_auth_adapter(source)
52
+ adapter = build_auth_adapter(source, config: config, interactive_auth: source == 'joyrun')
48
53
  unless adapter.respond_to?(:authenticate)
49
54
  say_status :error, "#{source} does not support interactive auth", :red
50
55
  return
@@ -63,6 +68,10 @@ module GitFit
63
68
  puts 'Local config:'
64
69
  puts " #{source}:"
65
70
  puts " secret: (已自动写入 #{GitFit::Config.default_path})"
71
+ elsif adapter.respond_to?(:generated_secrets) && (secrets = adapter.generated_secrets)
72
+ puts ''
73
+ puts 'GitHub Actions Secrets:'
74
+ secrets.each { |name, value| puts " SYNC_JOYRUN_#{name} = #{value}" }
66
75
  end
67
76
  else
68
77
  say_status :error, 'Authentication failed', :red
@@ -73,13 +82,13 @@ module GitFit
73
82
 
74
83
  private
75
84
 
76
- def build_auth_adapter(source)
85
+ def build_auth_adapter(source, config: nil, interactive_auth: false)
77
86
  klass = GitFit::Sync::Base.adapters.find { |a| a.config_key == source }
78
87
  return nil unless klass
79
88
 
80
- cfg = git_fit_config.sync_config(source).dup
89
+ cfg = (config || git_fit_config.sync_config(source)).dup
81
90
  cfg['config_path'] = options[:config] if options[:config]
82
- klass.new(config: cfg, db: nil)
91
+ klass.new(config: cfg, db: nil, interactive_auth: interactive_auth)
83
92
  end
84
93
  end
85
94
  end
data/lib/git_fit/cli.rb CHANGED
@@ -76,12 +76,14 @@ module GitFit
76
76
  super
77
77
  end
78
78
 
79
- desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
79
+ desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn, joyrun)'
80
80
  option :A, type: :boolean, desc: 'Garmin Strategy A: curl-impersonate shellout'
81
81
  option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright'
82
82
  option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A (default)'
83
83
  option :sync, type: :boolean, desc: 'Garmin: promote cached token to auth_seed'
84
84
  option :mfa_code, type: :string, desc: 'Garmin MFA code (from email), re-run to continue'
85
+ option :phone, type: :string, desc: 'Joyrun phone number'
86
+ option :sms_code, type: :string, desc: 'Joyrun SMS code'
85
87
  def auth(source)
86
88
  if source == 'garmin'
87
89
  GitFit::Auth::Garmin.new(options, git_fit_config).call
@@ -35,6 +35,14 @@ module GitFit
35
35
  # phone: "" # env: GIT_FIT_KEEP_PASSWORD
36
36
  # password: ""
37
37
 
38
+ # codoon: # env: GIT_FIT_CODOON_PHONE
39
+ # phone: "" # env: GIT_FIT_CODOON_PASSWORD
40
+ # password: ""
41
+
42
+ # joyrun: # env: GIT_FIT_JOYRUN_UID
43
+ # uid: ""
44
+ # sid: "" # env: GIT_FIT_JOYRUN_SID / git fit auth joyrun
45
+
38
46
  # igpsport: # env: GIT_FIT_IGPSPORT_PHONE
39
47
  # phone: "" # env: GIT_FIT_IGPSPORT_PASSWORD
40
48
  # password: ""
@@ -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 xingzhe keep].freeze
6
+ SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe keep codoon joyrun].freeze
7
7
  BATCH_SIZE = 100
8
8
 
9
9
  def initialize(db, force: false)
@@ -63,6 +63,7 @@ module GitFit
63
63
  when 'strava' then compute_from_l2(source, natural_id)
64
64
  when 'xingzhe' then compute_from_xingzhe(natural_id)
65
65
  when 'keep' then compute_from_keep(natural_id)
66
+ when 'codoon', 'joyrun' then compute_from_std(source, natural_id)
66
67
  else return
67
68
  end
68
69
 
@@ -105,6 +106,27 @@ module GitFit
105
106
  nil
106
107
  end
107
108
 
109
+ def compute_from_std(source, natural_id)
110
+ l2_path = File.join('data', 'std', source, "#{natural_id}.json")
111
+ return nil unless File.exist?(l2_path)
112
+
113
+ l2 = JSON.parse(File.read(l2_path))
114
+ return nil unless l2.is_a?(Array)
115
+
116
+ alts = l2.filter_map { |point| point['altitude']&.to_f if point.is_a?(Hash) }
117
+ return nil if alts.size < 2
118
+
119
+ {
120
+ elevation_gain: elevation_gain_from_alts(alts, threshold: 3.0).round(1),
121
+ elevation_loss: elevation_loss_from_alts(alts, threshold: 3.0).round(1),
122
+ elevation_min: alts.min&.round(1),
123
+ elevation_max: alts.max&.round(1),
124
+ }
125
+ rescue StandardError => e
126
+ warn "Elevation backfill [#{source}][#{natural_id}]: #{e.message}"
127
+ nil
128
+ end
129
+
108
130
  def compute_from_keep(natural_id)
109
131
  l2_path = File.join('data', 'std', 'keep', "#{natural_id}.json")
110
132
  return nil unless File.exist?(l2_path)
@@ -6,7 +6,7 @@ module GitFit
6
6
  # threshold → activities.elevation_*_terrain. Raw device/API values stay in
7
7
  # elevation_* (written by Backfill); this class only fills the _terrain columns.
8
8
  class Correct
9
- SOURCES = %w[garmin garmin_cn strava keep igpsport xoss xingzhe 2bulu apple_health].freeze
9
+ SOURCES = %w[garmin garmin_cn strava keep igpsport xoss xingzhe 2bulu apple_health codoon joyrun].freeze
10
10
  BARO_SOURCES = %w[xingzhe 2bulu apple_health].freeze
11
11
  BATCH_SIZE = 100
12
12
  STATIC_HYST_BARO = 2.0
@@ -5,7 +5,7 @@ module GitFit
5
5
  module Resolver
6
6
  # garmin_cn 必须排在 garmin 前面:"garmin_cn_7".start_with?("garmin_") == true
7
7
  SOURCE_PREFIXES = %w[
8
- garmin_cn garmin strava keep igpsport xoss xingzhe
8
+ garmin_cn garmin strava keep igpsport xoss xingzhe codoon joyrun
9
9
  apple_health gpx fit tcx
10
10
  ].freeze
11
11
 
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'cgi'
5
+ require 'openssl'
6
+ require 'time'
7
+
8
+ module GitFit
9
+ module Sync
10
+ class Codoon < Base
11
+ BASE_URL = 'https://api.codoon.com'
12
+ TOKEN_PATH = '/token'
13
+ LIST_PATH = '/api/get_old_route_log'
14
+ DETAIL_PATH = '/api/get_single_log'
15
+ BASIC_AUTH = 'MDk5Y2NlMjhjMDVmNmMzOWFkNWUwNGU1MWVkNjA3MDQ6YzM5ZDNmYmVhMWU4NWJlY2VlNDFjMTk5N2FjZjBlMzY='
16
+ CLIENT_ID = '099cce28c05f6c39ad5e04e51ed60704'
17
+ SIGNATURE_KEY = 'ecc140ad6e1e12f7d972af04add2c7ee'
18
+ DID = '24-ffffffff-faac-3052-0033-c5870033c587'
19
+ USER_AGENT = 'CodoonSport(8.9.0 1170;Android 7;Sony XZ1)'
20
+ PAGE_SIZE = 500
21
+ CHINA_OFFSET = '+08:00'
22
+
23
+ SPORT_MAP = { 0 => 'hike', 1 => 'run', 2 => 'ride' }.freeze
24
+
25
+ register_adapter
26
+ register_config :phone, :password
27
+
28
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
29
+ super
30
+ @phone = @config['phone']
31
+ @password = @config['password']
32
+ @access_token = nil
33
+ @user_id = nil
34
+ @last_http_code = nil
35
+ end
36
+
37
+ def before_call
38
+ return credentials_missing unless credentials_present?
39
+
40
+ super
41
+ end
42
+
43
+ def authenticate
44
+ params = {
45
+ 'client_id' => CLIENT_ID,
46
+ 'email' => @phone,
47
+ 'grant_type' => 'password',
48
+ 'password' => @password,
49
+ 'scope' => 'user',
50
+ }
51
+ resp = http_request(:get, TOKEN_PATH, params: params)
52
+ data = parse_json(resp)
53
+ valid_login = resp.code.to_i == 200 && data.is_a?(Hash) &&
54
+ data['access_token'].is_a?(String) && !data['access_token'].empty? &&
55
+ !data['user_id'].nil?
56
+ return login_failed(resp, data) unless valid_login
57
+
58
+ @access_token = data['access_token']
59
+ @user_id = data['user_id'].to_s
60
+ @auth_method = 'login'
61
+ true
62
+ rescue StandardError => e
63
+ warn "Codoon: login failed: #{e.message}\n Fix: 检查 GIT_FIT_CODOON_PHONE/PASSWORD"
64
+ false
65
+ end
66
+
67
+ def pending_ids
68
+ existing = existing_run_ids
69
+ ids = []
70
+ page = 1
71
+ loop do
72
+ items, has_more = fetch_page(page)
73
+ break if items.nil? || items.empty?
74
+
75
+ items.each do |item|
76
+ id = item['log_id'].to_s
77
+ next if id.empty?
78
+
79
+ type = map_sport(item['sports_type'])
80
+ next if skip_type?(type)
81
+
82
+ has_raw = raw_file_exists?(id)
83
+ next if existing.include?(id) && has_raw
84
+
85
+ ids << { id: id, route_id: item['route_id'].to_s, type: type, has_raw: has_raw }
86
+ end
87
+ page += 1
88
+ break unless has_more
89
+ end
90
+ ids
91
+ end
92
+
93
+ def process_one(pending)
94
+ return load_raw_activity(pending[:id]) if pending[:has_raw]
95
+
96
+ resp = http_request(:post, DETAIL_PATH, body: { route_id: pending[:route_id] },
97
+ token: @access_token)
98
+ data = parse_json(resp)
99
+ return false unless resp.code.to_i == 200 && data.is_a?(Hash) && data['data'].is_a?(Hash)
100
+
101
+ write_source_archive(data, pending[:id])
102
+ upsert_from_detail(pending[:id], data['data'])
103
+ end
104
+
105
+ def self.signature(auth, path_with_query, body: '', timestamp: 0, key: nil)
106
+ path, query = path_with_query.split('?', 2)
107
+ query = URI.decode_www_form_component(query.to_s)
108
+ message = "Authorization=#{auth}&Davinci=0&Did=#{DID}&Timestamp=#{timestamp}" \
109
+ "|path=#{path}|body=#{body}|#{query}"
110
+ key ||= SIGNATURE_KEY
111
+ Base64.strict_encode64(OpenSSL::HMAC.digest('sha1', key, message))
112
+ end
113
+
114
+ private
115
+
116
+ def fetch_page(page)
117
+ resp = http_request(:post, LIST_PATH,
118
+ body: { limit: PAGE_SIZE, page: page, user_id: @user_id },
119
+ token: @access_token)
120
+ data = parse_json(resp)
121
+ return [[], false] unless resp.code.to_i == 200 && data.is_a?(Hash) && data['data'].is_a?(Hash)
122
+
123
+ [Array(data.dig('data', 'log_list')), data.dig('data', 'has_more') ? true : false]
124
+ end
125
+
126
+ def http_request(method, path, params: nil, body: nil, token: nil)
127
+ uri = URI.join(BASE_URL, path)
128
+ uri.query = URI.encode_www_form(params) if params
129
+ json_body = body ? JSON.generate(body) : ''
130
+ timestamp = method == :get ? 0 : Time.now.to_i
131
+ auth = token ? "Bearer #{token}" : "Basic #{BASIC_AUTH}"
132
+
133
+ request = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
134
+ request['User-Agent'] = USER_AGENT
135
+ request['did'] = DID
136
+ request['davinci'] = '0'
137
+ request['authorization'] = auth
138
+ request['timestamp'] = timestamp.to_s
139
+ request['signature'] = self.class.signature(auth, uri.request_uri, body: json_body, timestamp: timestamp)
140
+ unless method == :get
141
+ request['Content-Type'] =
142
+ token ? 'application/json; charset=utf-8' : 'application/x-www-form-urlencode; charset=utf-8'
143
+ request.body = json_body
144
+ end
145
+
146
+ http = Net::HTTP.new(uri.host, uri.port)
147
+ http.use_ssl = true
148
+ http.open_timeout = 30
149
+ http.read_timeout = 60
150
+ response = http.start { |client| client.request(request) }
151
+ @last_http_code = response.code.to_i
152
+ response
153
+ end
154
+
155
+ def load_raw_activity(id)
156
+ data = JSON.parse(File.read(raw_path(id)))
157
+ detail = data['data']
158
+ return false unless detail.is_a?(Hash)
159
+
160
+ upsert_from_detail(id, detail)
161
+ end
162
+
163
+ def upsert_from_detail(log_id, detail)
164
+ attrs = build_activity(log_id, detail)
165
+ return false unless attrs
166
+
167
+ points = attrs.delete(:standard_points)
168
+ write_std(points, log_id)
169
+ upsert_activity(attrs)
170
+ attrs
171
+ end
172
+
173
+ def build_activity(log_id, detail)
174
+ start_time = parse_beijing_time(detail['start_time'])
175
+ end_time = parse_beijing_time(detail['end_time'])
176
+ moving_time = detail['total_time'].to_i
177
+ return nil unless start_time && moving_time.positive?
178
+
179
+ points = standard_points(detail['points'])
180
+ return nil if points.empty?
181
+
182
+ hr_values = heart_rate_values(detail['heart_rate'])
183
+ cadence_values = cadence_values(detail)
184
+ distance = detail['total_length'].to_f
185
+ elapsed = end_time ? [(end_time - start_time).round, 0].max : moving_time
186
+ sport = map_sport(detail['sports_type'])
187
+
188
+ official_cadence = numeric(detail['average_step_cadence'])
189
+ attrs = {
190
+ run_id: "codoon_#{log_id}",
191
+ name: "#{sport.capitalize} from Codoon",
192
+ distance: distance.positive? ? distance : nil,
193
+ moving_time: moving_time,
194
+ elapsed_time: elapsed,
195
+ sport_category: sport,
196
+ sport_type: detail['sports_type'].to_s,
197
+ start_date: Time.at(start_time.to_i).utc.iso8601,
198
+ start_date_local: start_time.iso8601,
199
+ average_heartrate: average(hr_values),
200
+ max_heartrate: hr_values.max,
201
+ average_cadence: official_cadence&.positive? ? official_cadence : average(cadence_values),
202
+ max_cadence: cadence_values.max,
203
+ calories: numeric(detail['total_calories'])&.round,
204
+ average_speed: distance.positive? && moving_time.positive? ? (distance / moving_time).round(2) : nil,
205
+ source: 'codoon',
206
+ }
207
+ attrs.merge!(point_attributes(points))
208
+ attrs[:standard_points] = points
209
+ attrs
210
+ end
211
+
212
+ def point_attributes(points)
213
+ return { summary_polyline: nil } if points.empty?
214
+
215
+ alts = points.filter_map { |point| numeric(point[:altitude]) }
216
+ {
217
+ summary_polyline: Geo::Polyline.encode(points.map { |point| [point[:latitude], point[:longitude]] }),
218
+ elevation_gain: elevation_delta(alts, :gain),
219
+ elevation_loss: elevation_delta(alts, :loss),
220
+ elevation_min: alts.min&.round(1),
221
+ elevation_max: alts.max&.round(1),
222
+ }
223
+ end
224
+
225
+ def standard_points(points)
226
+ Array(points).filter_map do |point|
227
+ lat = numeric(point['latitude'])
228
+ lng = numeric(point['longitude'])
229
+ point_time = parse_beijing_time(point['time_stamp'])
230
+ next unless lat && lng
231
+ next unless point_time
232
+
233
+ lat, lng = Geo::CoordTransform.gcj02_to_wgs84_exact(lat, lng)
234
+ {
235
+ latitude: lat.round(7),
236
+ longitude: lng.round(7),
237
+ altitude: numeric(point['elevation']),
238
+ timestamp: Time.at(point_time.to_i).utc.iso8601,
239
+ }
240
+ end
241
+ end
242
+
243
+ def heart_rate_values(values)
244
+ return [] unless values.is_a?(Hash)
245
+
246
+ values.values.filter_map { |value| hr_value(value) }
247
+ end
248
+
249
+ def cadence_values(detail)
250
+ Array(detail['user_steps_list_perm']).filter_map do |entry|
251
+ value = entry.is_a?(Array) ? entry[1] : entry['value'] || entry['step']
252
+ value = numeric(value)
253
+ value if value&.positive?
254
+ end
255
+ end
256
+
257
+ def hr_value(value)
258
+ value = numeric(value)
259
+ value if value&.positive?
260
+ end
261
+
262
+ def parse_beijing_time(value)
263
+ return nil if value.nil? || value.to_s.strip.empty?
264
+
265
+ Time.parse("#{value.to_s.sub(/\.\d+\z/, '')}#{CHINA_OFFSET}")
266
+ rescue ArgumentError, TypeError
267
+ nil
268
+ end
269
+
270
+ def map_sport(value)
271
+ SPORT_MAP[numeric(value)&.to_i] || 'other'
272
+ end
273
+
274
+ def credentials_present?
275
+ [@phone, @password].all? { |value| value.is_a?(String) && !value.empty? }
276
+ end
277
+
278
+ def credentials_missing
279
+ warn "Codoon: credentials not configured\n Fix: 检查 GIT_FIT_CODOON_PHONE/PASSWORD"
280
+ false
281
+ end
282
+
283
+ def login_failed(resp, data)
284
+ reason = data.is_a?(Hash) ? data['description'] || data['error'] : nil
285
+ detail = reason ? ": #{reason}" : ''
286
+ warn "Codoon: login failed (HTTP #{resp.code}#{detail})\n Fix: 检查 GIT_FIT_CODOON_PHONE/PASSWORD"
287
+ false
288
+ end
289
+
290
+ def parse_json(resp)
291
+ JSON.parse(resp.body)
292
+ rescue JSON::ParserError, TypeError
293
+ nil
294
+ end
295
+
296
+ def numeric(value)
297
+ return nil if value.nil? || value.to_s.strip.empty?
298
+
299
+ Float(value)
300
+ rescue ArgumentError, TypeError
301
+ nil
302
+ end
303
+
304
+ def average(values)
305
+ return nil if values.empty?
306
+
307
+ (values.sum.to_f / values.size).round(1)
308
+ end
309
+
310
+ def elevation_delta(altitudes, direction)
311
+ return nil if altitudes.size < 2
312
+
313
+ total = 0.0
314
+ (1...altitudes.size).each do |index|
315
+ delta = altitudes[index] - altitudes[index - 1]
316
+ total += delta if direction == :gain && delta.positive?
317
+ total -= delta if direction == :loss && delta.negative?
318
+ end
319
+ total.round(1)
320
+ end
321
+
322
+ def raw_path(platform_id)
323
+ File.join(raw_dir, "#{platform_id}.json")
324
+ end
325
+
326
+ def write_std(points, platform_id)
327
+ dir = std_dir
328
+ FileUtils.mkdir_p(dir)
329
+ tmp = File.join(dir, ".#{platform_id}.json.tmp")
330
+ final = File.join(dir, "#{platform_id}.json")
331
+ File.write(tmp, JSON.generate(points))
332
+ File.rename(tmp, final)
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'digest'
5
+ require 'time'
6
+
7
+ module GitFit
8
+ module Sync
9
+ class Joyrun < Base
10
+ BASE_URL = 'https://api.thejoyrun.com'
11
+ LOGIN_PATH = '/user/login/phonecode'
12
+ LIST_PATH = '/userRunList.aspx'
13
+ DETAIL_PATH = '/Run/GetInfo.aspx'
14
+ SALT_V1 = '1fd6e28fd158406995f77727b35bf20a'
15
+ SALT_V2 = '0C077B1E70F5FDDE6F497C1315687F9C'
16
+ BASE_HEADERS = {
17
+ 'Accept-Language' => 'en_US',
18
+ 'User-Agent' => 'okhttp/3.10.0',
19
+ 'Host' => 'api.thejoyrun.com',
20
+ 'Connection' => 'Keep-Alive',
21
+ }.freeze
22
+ DEVICE_HEADERS = {
23
+ 'MODELTYPE' => 'Xiaomi MI 5',
24
+ 'SYSVERSION' => '8.0.0',
25
+ 'APPVERSION' => '4.2.0',
26
+ }.freeze
27
+ SPORT_MAP = { 0 => 'hike', 1 => 'run', 2 => 'ride' }.freeze
28
+
29
+ register_adapter
30
+ register_config :uid, :sid
31
+
32
+ attr_reader :generated_secrets
33
+
34
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil,
35
+ interactive_auth: false)
36
+ super(config: config, db: db, activity_filter: activity_filter, privacy: privacy,
37
+ time_budget: time_budget)
38
+ @uid = present_string(@config['uid'])
39
+ @sid = present_string(@config['sid'])
40
+ @phone = present_string(@config['phone'])
41
+ @sms_code = present_string(@config['sms_code'])
42
+ @interactive_auth = interactive_auth
43
+ @generated_secrets = nil
44
+ end
45
+
46
+ def before_call
47
+ return super if credentials_present?
48
+ return false unless @interactive_auth
49
+
50
+ super
51
+ end
52
+
53
+ def authenticate
54
+ return true if credentials_present?
55
+ return false unless @interactive_auth
56
+
57
+ @phone = prompt('Joyrun phone number: ') if @phone.empty?
58
+ @sms_code = prompt('Joyrun SMS code: ') if @sms_code.empty?
59
+ raise AuthError, 'Joyrun phone and SMS code are required' if @phone.empty? || @sms_code.empty?
60
+
61
+ login_with_sms(@phone, @sms_code)
62
+ end
63
+
64
+ def login_with_sms(phone, code)
65
+ resp = http_request(:get, LOGIN_PATH, params: {
66
+ 'phoneNumber' => phone,
67
+ 'identifyingCode' => code,
68
+ })
69
+ data = parse_json(resp)
70
+ return login_failed(resp, data) unless resp.code.to_i == 200 && data['ret'].to_s == '0'
71
+
72
+ @sid = data.dig('data', 'sid').to_s
73
+ @uid = data.dig('data', 'user', 'uid').to_s
74
+ return login_failed(resp, data) if @sid.empty? || @uid.empty?
75
+
76
+ @generated_secrets = { 'UID' => @uid, 'SID' => @sid }
77
+ @auth_method = 'sms'
78
+ true
79
+ rescue StandardError => e
80
+ warn "Joyrun: SMS login failed: #{e.message}"
81
+ false
82
+ end
83
+
84
+ def pending_ids
85
+ existing = existing_run_ids
86
+ resp = http_request(:post, LIST_PATH, body: { 'year' => 0 })
87
+ data = parse_json(resp)
88
+ raise SyncError, sid_error_message unless sid_valid?(resp, data)
89
+ return [] unless data.is_a?(Hash) && data['datas'].is_a?(Array)
90
+
91
+ data['datas'].filter_map do |item|
92
+ id = item['fid'].to_s
93
+ next if id.empty? || (existing.include?(id) && raw_file_exists?(id))
94
+
95
+ type = map_sport(item['type'])
96
+ next if skip_type?(type)
97
+
98
+ { id: id, type: type, has_raw: raw_file_exists?(id) }
99
+ end
100
+ end
101
+
102
+ def process_one(pending)
103
+ return load_raw_activity(pending[:id]) if pending[:has_raw]
104
+
105
+ resp = http_request(:post, DETAIL_PATH, body: { 'fid' => pending[:id], 'wgs' => 1 })
106
+ data = parse_json(resp)
107
+ raise SyncError, sid_error_message unless sid_valid?(resp, data)
108
+ return false unless data.is_a?(Hash) && data['runrecord'].is_a?(Hash)
109
+
110
+ write_source_archive(data, pending[:id])
111
+ upsert_from_detail(data['runrecord'])
112
+ end
113
+
114
+ def self.signature(params, uid, sid, salt, timestamp: nil)
115
+ timestamp ||= params['timestamp'] || Time.now.to_i
116
+ signed = params.merge('timestamp' => timestamp)
117
+ pre_string = signed.sort.map { |key, value| "#{key}#{value}" }.join
118
+ pre_string = "#{pre_string}#{salt}#{uid}#{sid}"
119
+ Digest::MD5.hexdigest(pre_string).upcase
120
+ end
121
+
122
+ private
123
+
124
+ def http_request(method, path, params: nil, body: nil)
125
+ timestamp = Time.now.to_i
126
+ sign_v1 = self.class.signature(params || body || {}, @uid, @sid, SALT_V1, timestamp: timestamp)
127
+ sign_v2 = self.class.signature(params || body || {}, @uid, @sid, SALT_V2, timestamp: timestamp)
128
+ uri = URI.join(BASE_URL, path)
129
+
130
+ if method == :get
131
+ query = (params || {}).merge('timestamp' => timestamp, 'signature' => sign_v1)
132
+ uri.query = URI.encode_www_form(query)
133
+ request = Net::HTTP::Get.new(uri)
134
+ else
135
+ form = (body || {}).merge('timestamp' => timestamp, 'signature' => sign_v1)
136
+ uri.query = URI.encode_www_form(params) if params
137
+ request = Net::HTTP::Post.new(uri)
138
+ request.body = URI.encode_www_form(form)
139
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
140
+ end
141
+ headers_for_request(request)
142
+ request['_sign'] = sign_v2
143
+
144
+ http = Net::HTTP.new(uri.host, uri.port)
145
+ http.use_ssl = true
146
+ http.open_timeout = 30
147
+ http.read_timeout = 60
148
+ http.start { |client| client.request(request) }
149
+ end
150
+
151
+ def headers_for_request(request)
152
+ BASE_HEADERS.each { |key, value| request[key] = value }
153
+ DEVICE_HEADERS.each { |key, value| request[key] = value }
154
+ return unless credentials_present?
155
+
156
+ cookie = "sid=#{@sid}&uid=#{@uid}"
157
+ request['ypcookie'] = cookie
158
+ request['Cookie'] = "ypcookie=#{URI.encode_www_form_component(cookie).downcase}"
159
+ end
160
+
161
+ def load_raw_activity(id)
162
+ data = JSON.parse(File.read(raw_path(id)))
163
+ record = data['runrecord']
164
+ return false unless record.is_a?(Hash)
165
+
166
+ upsert_from_detail(record)
167
+ end
168
+
169
+ def upsert_from_detail(record)
170
+ attrs = build_activity(record)
171
+ return false unless attrs
172
+
173
+ points = attrs.delete(:standard_points)
174
+ write_std(points, record['fid'])
175
+ upsert_activity(attrs)
176
+ attrs
177
+ end
178
+
179
+ def build_activity(record)
180
+ started_at = numeric(record['starttime'])
181
+ ended_at = numeric(record['endtime'])
182
+ return nil if started_at.nil? || started_at <= 0 || ended_at.nil? || ended_at <= 0
183
+
184
+ start_time = Time.at(numeric(record['starttime']) || 0).utc
185
+ end_time = Time.at(ended_at).utc
186
+ points = build_points(record, start_time, end_time)
187
+ distance = numeric(record['meter'])
188
+ moving_time = numeric(record['second'])&.round
189
+ sport = map_sport(record['type'])
190
+ hr_values = Array(parse_literal(record['heartrate'])).filter_map do |value|
191
+ value = numeric(value)
192
+ value&.positive? ? value : nil
193
+ end
194
+ location = [record['city'], record['province']].filter_map(&:to_s).reject(&:empty?).join(':')
195
+
196
+ attrs = {
197
+ run_id: "joyrun_#{record['fid']}",
198
+ name: "#{sport.capitalize} from Joyrun",
199
+ distance: distance&.positive? ? distance : nil,
200
+ moving_time: moving_time&.positive? ? moving_time : nil,
201
+ elapsed_time: end_time > start_time ? (end_time - start_time).round : nil,
202
+ sport_category: sport,
203
+ sport_type: record['type'].to_s,
204
+ start_date: start_time.utc.iso8601,
205
+ start_date_local: start_time.utc.getlocal('+08:00').iso8601,
206
+ location_country: location.empty? ? nil : location,
207
+ summary_polyline: if points.any?
208
+ Geo::Polyline.encode(points.map do |point|
209
+ point.values_at(:latitude, :longitude)
210
+ end)
211
+ end,
212
+ average_heartrate: average(hr_values),
213
+ max_heartrate: hr_values.max,
214
+ average_speed: distance&.positive? && moving_time&.positive? ? (distance / moving_time).round(2) : nil,
215
+ source: 'joyrun',
216
+ }
217
+ attrs.merge!(point_attributes(points))
218
+ attrs[:standard_points] = points
219
+ attrs
220
+ end
221
+
222
+ def build_points(record, start_time, end_time)
223
+ coordinates = parse_literal(record['content'].to_s.gsub(']-[', '],['))
224
+ return [] unless coordinates.is_a?(Array)
225
+
226
+ coordinates = coordinates.filter_map do |pair|
227
+ next unless pair.is_a?(Array) && pair.size >= 2
228
+
229
+ lat = numeric(pair[0])
230
+ lng = numeric(pair[1])
231
+ next unless lat && lng
232
+
233
+ [lat / 1_000_000.0, lng / 1_000_000.0]
234
+ end
235
+ return [] if coordinates.empty?
236
+
237
+ heart_rates = array_or_empty(parse_literal(record['heartrate']))
238
+ altitudes = array_or_empty(parse_literal(record['altitude']))
239
+ pauses = Array(parse_literal(record['pause'])).filter_map do |entry|
240
+ next unless entry.is_a?(Array) && entry.size >= 2
241
+
242
+ [numeric(entry[0])&.to_i, numeric(entry[1])&.to_i]
243
+ end
244
+
245
+ current_time = start_time
246
+ segment = 0
247
+ pause_index = 0
248
+ coordinates.each_with_index.map do |coordinate, index|
249
+ timestamp = index == coordinates.size - 1 ? end_time : current_time
250
+ point = {
251
+ latitude: coordinate[0],
252
+ longitude: coordinate[1],
253
+ altitude: numeric(at_altitude(altitudes, index)),
254
+ timestamp: timestamp.utc.iso8601,
255
+ heartrate: hr_value(at_value(heart_rates, index)),
256
+ segment: segment,
257
+ }
258
+ unless index == coordinates.size - 1
259
+ current_time += 5
260
+ pause = pauses[pause_index]
261
+ if pause && pause[0] && pause[1] && pause[0] - 1 == index
262
+ current_time += pause[1]
263
+ pause_index += 1
264
+ segment += 1
265
+ end
266
+ end
267
+ point
268
+ end
269
+ end
270
+
271
+ def point_attributes(points)
272
+ return { summary_polyline: nil } if points.empty?
273
+
274
+ alts = points.filter_map { |point| point[:altitude] }
275
+ {
276
+ elevation_gain: elevation_delta(alts, :gain),
277
+ elevation_loss: elevation_delta(alts, :loss),
278
+ elevation_min: alts.min&.round(1),
279
+ elevation_max: alts.max&.round(1),
280
+ }
281
+ end
282
+
283
+ def at_value(values, index)
284
+ value = values[index]
285
+ value.is_a?(Array) ? value.first : value
286
+ end
287
+
288
+ def at_altitude(values, index)
289
+ at_value(values, index)
290
+ end
291
+
292
+ def hr_value(value)
293
+ value = numeric(value)
294
+ value&.positive? ? value : nil
295
+ end
296
+
297
+ def sid_valid?(resp, data)
298
+ resp.code.to_i == 200 && !(data.is_a?(Hash) && data.key?('ret') && data['ret'].to_s != '0')
299
+ end
300
+
301
+ def sid_error_message
302
+ "Joyrun: sid expired or invalid\n Re-auth: git fit auth joyrun"
303
+ end
304
+
305
+ def map_sport(value)
306
+ SPORT_MAP[numeric(value)&.to_i] || 'other'
307
+ end
308
+
309
+ def credentials_present?
310
+ !@uid.empty? && !@sid.empty?
311
+ end
312
+
313
+ def login_failed(resp, data)
314
+ reason = data.is_a?(Hash) ? data['msg'] : nil
315
+ warn "Joyrun: SMS login failed (HTTP #{resp.code}#{reason ? ": #{reason}" : ''})"
316
+ false
317
+ end
318
+
319
+ def prompt(label)
320
+ $stderr.print(label)
321
+ $stderr.flush
322
+ input = $stdin.gets
323
+ raise AuthError, 'Joyrun auth requires phone and SMS code on stdin' if input.nil?
324
+
325
+ input.strip
326
+ end
327
+
328
+ def present_string(value)
329
+ value.to_s
330
+ end
331
+
332
+ def parse_json(resp)
333
+ JSON.parse(resp.body)
334
+ rescue JSON::ParserError, TypeError
335
+ {}
336
+ end
337
+
338
+ def parse_literal(value)
339
+ return nil if value.nil? || value.to_s.strip.empty?
340
+
341
+ JSON.parse(value)
342
+ rescue JSON::ParserError
343
+ parse_python_literal(value)
344
+ end
345
+
346
+ def parse_python_literal(value)
347
+ text = value.to_s.gsub(']-[', '],[')
348
+ return nil unless text.start_with?('[') && text.end_with?(']')
349
+
350
+ elements = split_top_level(text[1..-2])
351
+ elements.map { |element| parse_literal_element(element) }
352
+ end
353
+
354
+ def split_top_level(text)
355
+ groups = []
356
+ depth = 0
357
+ current = +''
358
+ text.each_char do |char|
359
+ case char
360
+ when '[' then depth += 1
361
+ when ']' then depth -= 1
362
+ when ','
363
+ if depth.zero?
364
+ groups << current
365
+ current = +''
366
+ next
367
+ end
368
+ end
369
+ current << char
370
+ end
371
+ groups << current unless current.empty?
372
+ groups
373
+ end
374
+
375
+ def parse_literal_element(element)
376
+ text = element.strip
377
+ return parse_python_literal(text) if text.start_with?('[')
378
+
379
+ value = Float(text)
380
+ value == value.to_i ? value.to_i : value
381
+ rescue ArgumentError, TypeError
382
+ nil
383
+ end
384
+
385
+ def array_or_empty(value)
386
+ value.is_a?(Array) ? value : []
387
+ end
388
+
389
+ def numeric(value)
390
+ return nil if value.nil? || value.to_s.strip.empty?
391
+
392
+ Float(value)
393
+ rescue ArgumentError, TypeError
394
+ nil
395
+ end
396
+
397
+ def average(values)
398
+ return nil if values.empty?
399
+
400
+ (values.sum.to_f / values.size).round(1)
401
+ end
402
+
403
+ def elevation_delta(altitudes, direction)
404
+ return nil if altitudes.size < 2
405
+
406
+ total = 0.0
407
+ (1...altitudes.size).each do |index|
408
+ delta = altitudes[index] - altitudes[index - 1]
409
+ total += delta if direction == :gain && delta.positive?
410
+ total -= delta if direction == :loss && delta.negative?
411
+ end
412
+ total.round(1)
413
+ end
414
+
415
+ def raw_path(platform_id)
416
+ File.join(raw_dir, "#{platform_id}.json")
417
+ end
418
+
419
+ def write_std(points, platform_id)
420
+ dir = std_dir
421
+ FileUtils.mkdir_p(dir)
422
+ tmp = File.join(dir, ".#{platform_id}.json.tmp")
423
+ final = File.join(dir, "#{platform_id}.json")
424
+ File.write(tmp, JSON.generate(points))
425
+ File.rename(tmp, final)
426
+ end
427
+ end
428
+ end
429
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.23.2'
4
+ VERSION = '0.24.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.2
4
+ version: 0.24.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -363,11 +363,13 @@ files:
363
363
  - lib/git_fit/std/resolver.rb
364
364
  - lib/git_fit/strava_web/file_check.rb
365
365
  - lib/git_fit/sync/base.rb
366
+ - lib/git_fit/sync/codoon.rb
366
367
  - lib/git_fit/sync/garmin_base.rb
367
368
  - lib/git_fit/sync/garmin_base_di.rb
368
369
  - lib/git_fit/sync/garmin_cn.rb
369
370
  - lib/git_fit/sync/garmin_com.rb
370
371
  - lib/git_fit/sync/igpsport.rb
372
+ - lib/git_fit/sync/joyrun.rb
371
373
  - lib/git_fit/sync/keep.rb
372
374
  - lib/git_fit/sync/lorem.rb
373
375
  - lib/git_fit/sync/runner.rb