git-fit 0.23.2 → 0.24.0

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: 2be1dd5148fb72d95b76d20b31193422edbb0e8be620062a6d2c34d04f577aa0
4
+ data.tar.gz: 2192297d4bce4c23cdd8f9420f9f21e37caa1c562fec5eb2f6abd1235fa7977e
5
5
  SHA512:
6
- metadata.gz: 57640bd7dc13d63da72f616a50cd48f9bd9a903f4cadf3edf1321f2344410859641f44d50701ac26da1622c3bdc460a6d639a94564c1da7a7862dccd6b9c8c65
7
- data.tar.gz: 80ee63c140618207ab471327f3673daed9d2ed211f104dd9442484758f085a2420290756187dadb6d521575abf50b5832edfcb24911db633e620fbf5c207c4e0
6
+ metadata.gz: f9156265923a164e1dd88e46d924c7d607a13027dfdcbb5c8206ae768d58f8c27c3ac09e462840c3968eb1cd152f473f0eb4462d484319ab349ec1d9e1573ead
7
+ data.tar.gz: 998b8597afbea703c34761105394e64c3d8c76f75f4109e55a3a43c6b73073a75711d95a2876ab03a1cbff1c2606cc2cb6067d39b775ab049a71087bea9ed830
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,423 @@
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: ')
58
+ @sms_code ||= prompt('Joyrun SMS code: ')
59
+ login_with_sms(@phone, @sms_code)
60
+ end
61
+
62
+ def login_with_sms(phone, code)
63
+ resp = http_request(:get, LOGIN_PATH, params: {
64
+ 'phoneNumber' => phone,
65
+ 'identifyingCode' => code,
66
+ })
67
+ data = parse_json(resp)
68
+ return login_failed(resp, data) unless resp.code.to_i == 200 && data['ret'].to_s == '0'
69
+
70
+ @sid = data.dig('data', 'sid').to_s
71
+ @uid = data.dig('data', 'user', 'uid').to_s
72
+ return login_failed(resp, data) if @sid.empty? || @uid.empty?
73
+
74
+ @generated_secrets = { 'UID' => @uid, 'SID' => @sid }
75
+ @auth_method = 'sms'
76
+ true
77
+ rescue StandardError => e
78
+ warn "Joyrun: SMS login failed: #{e.message}"
79
+ false
80
+ end
81
+
82
+ def pending_ids
83
+ existing = existing_run_ids
84
+ resp = http_request(:post, LIST_PATH, body: { 'year' => 0 })
85
+ data = parse_json(resp)
86
+ raise SyncError, sid_error_message unless sid_valid?(resp, data)
87
+ return [] unless data.is_a?(Hash) && data['datas'].is_a?(Array)
88
+
89
+ data['datas'].filter_map do |item|
90
+ id = item['fid'].to_s
91
+ next if id.empty? || (existing.include?(id) && raw_file_exists?(id))
92
+
93
+ type = map_sport(item['type'])
94
+ next if skip_type?(type)
95
+
96
+ { id: id, type: type, has_raw: raw_file_exists?(id) }
97
+ end
98
+ end
99
+
100
+ def process_one(pending)
101
+ return load_raw_activity(pending[:id]) if pending[:has_raw]
102
+
103
+ resp = http_request(:post, DETAIL_PATH, body: { 'fid' => pending[:id], 'wgs' => 1 })
104
+ data = parse_json(resp)
105
+ raise SyncError, sid_error_message unless sid_valid?(resp, data)
106
+ return false unless data.is_a?(Hash) && data['runrecord'].is_a?(Hash)
107
+
108
+ write_source_archive(data, pending[:id])
109
+ upsert_from_detail(data['runrecord'])
110
+ end
111
+
112
+ def self.signature(params, uid, sid, salt, timestamp: nil)
113
+ timestamp ||= params['timestamp'] || Time.now.to_i
114
+ signed = params.merge('timestamp' => timestamp)
115
+ pre_string = signed.sort.map { |key, value| "#{key}#{value}" }.join
116
+ pre_string = "#{pre_string}#{salt}#{uid}#{sid}"
117
+ Digest::MD5.hexdigest(pre_string).upcase
118
+ end
119
+
120
+ private
121
+
122
+ def http_request(method, path, params: nil, body: nil)
123
+ timestamp = Time.now.to_i
124
+ sign_v1 = self.class.signature(params || body || {}, @uid, @sid, SALT_V1, timestamp: timestamp)
125
+ sign_v2 = self.class.signature(params || body || {}, @uid, @sid, SALT_V2, timestamp: timestamp)
126
+ uri = URI.join(BASE_URL, path)
127
+
128
+ if method == :get
129
+ query = (params || {}).merge('timestamp' => timestamp, 'signature' => sign_v1)
130
+ uri.query = URI.encode_www_form(query)
131
+ request = Net::HTTP::Get.new(uri)
132
+ else
133
+ form = (body || {}).merge('timestamp' => timestamp, 'signature' => sign_v1)
134
+ uri.query = URI.encode_www_form(params) if params
135
+ request = Net::HTTP::Post.new(uri)
136
+ request.body = URI.encode_www_form(form)
137
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
138
+ end
139
+ headers_for_request(request)
140
+ request['_sign'] = sign_v2
141
+
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = true
144
+ http.open_timeout = 30
145
+ http.read_timeout = 60
146
+ http.start { |client| client.request(request) }
147
+ end
148
+
149
+ def headers_for_request(request)
150
+ BASE_HEADERS.each { |key, value| request[key] = value }
151
+ DEVICE_HEADERS.each { |key, value| request[key] = value }
152
+ return unless credentials_present?
153
+
154
+ cookie = "sid=#{@sid}&uid=#{@uid}"
155
+ request['ypcookie'] = cookie
156
+ request['Cookie'] = "ypcookie=#{URI.encode_www_form_component(cookie).downcase}"
157
+ end
158
+
159
+ def load_raw_activity(id)
160
+ data = JSON.parse(File.read(raw_path(id)))
161
+ record = data['runrecord']
162
+ return false unless record.is_a?(Hash)
163
+
164
+ upsert_from_detail(record)
165
+ end
166
+
167
+ def upsert_from_detail(record)
168
+ attrs = build_activity(record)
169
+ return false unless attrs
170
+
171
+ points = attrs.delete(:standard_points)
172
+ write_std(points, record['fid'])
173
+ upsert_activity(attrs)
174
+ attrs
175
+ end
176
+
177
+ def build_activity(record)
178
+ started_at = numeric(record['starttime'])
179
+ ended_at = numeric(record['endtime'])
180
+ return nil if started_at.nil? || started_at <= 0 || ended_at.nil? || ended_at <= 0
181
+
182
+ start_time = Time.at(numeric(record['starttime']) || 0).utc
183
+ end_time = Time.at(ended_at).utc
184
+ points = build_points(record, start_time, end_time)
185
+ distance = numeric(record['meter'])
186
+ moving_time = numeric(record['second'])&.round
187
+ sport = map_sport(record['type'])
188
+ hr_values = Array(parse_literal(record['heartrate'])).filter_map do |value|
189
+ value = numeric(value)
190
+ value&.positive? ? value : nil
191
+ end
192
+ location = [record['city'], record['province']].filter_map(&:to_s).reject(&:empty?).join(':')
193
+
194
+ attrs = {
195
+ run_id: "joyrun_#{record['fid']}",
196
+ name: "#{sport.capitalize} from Joyrun",
197
+ distance: distance&.positive? ? distance : nil,
198
+ moving_time: moving_time&.positive? ? moving_time : nil,
199
+ elapsed_time: end_time > start_time ? (end_time - start_time).round : nil,
200
+ sport_category: sport,
201
+ sport_type: record['type'].to_s,
202
+ start_date: start_time.utc.iso8601,
203
+ start_date_local: start_time.utc.getlocal('+08:00').iso8601,
204
+ location_country: location.empty? ? nil : location,
205
+ summary_polyline: if points.any?
206
+ Geo::Polyline.encode(points.map do |point|
207
+ point.values_at(:latitude, :longitude)
208
+ end)
209
+ end,
210
+ average_heartrate: average(hr_values),
211
+ max_heartrate: hr_values.max,
212
+ average_speed: distance&.positive? && moving_time&.positive? ? (distance / moving_time).round(2) : nil,
213
+ source: 'joyrun',
214
+ }
215
+ attrs.merge!(point_attributes(points))
216
+ attrs[:standard_points] = points
217
+ attrs
218
+ end
219
+
220
+ def build_points(record, start_time, end_time)
221
+ coordinates = parse_literal(record['content'].to_s.gsub(']-[', '],['))
222
+ return [] unless coordinates.is_a?(Array)
223
+
224
+ coordinates = coordinates.filter_map do |pair|
225
+ next unless pair.is_a?(Array) && pair.size >= 2
226
+
227
+ lat = numeric(pair[0])
228
+ lng = numeric(pair[1])
229
+ next unless lat && lng
230
+
231
+ [lat / 1_000_000.0, lng / 1_000_000.0]
232
+ end
233
+ return [] if coordinates.empty?
234
+
235
+ heart_rates = array_or_empty(parse_literal(record['heartrate']))
236
+ altitudes = array_or_empty(parse_literal(record['altitude']))
237
+ pauses = Array(parse_literal(record['pause'])).filter_map do |entry|
238
+ next unless entry.is_a?(Array) && entry.size >= 2
239
+
240
+ [numeric(entry[0])&.to_i, numeric(entry[1])&.to_i]
241
+ end
242
+
243
+ current_time = start_time
244
+ segment = 0
245
+ pause_index = 0
246
+ coordinates.each_with_index.map do |coordinate, index|
247
+ timestamp = index == coordinates.size - 1 ? end_time : current_time
248
+ point = {
249
+ latitude: coordinate[0],
250
+ longitude: coordinate[1],
251
+ altitude: numeric(at_altitude(altitudes, index)),
252
+ timestamp: timestamp.utc.iso8601,
253
+ heartrate: hr_value(at_value(heart_rates, index)),
254
+ segment: segment,
255
+ }
256
+ unless index == coordinates.size - 1
257
+ current_time += 5
258
+ pause = pauses[pause_index]
259
+ if pause && pause[0] && pause[1] && pause[0] - 1 == index
260
+ current_time += pause[1]
261
+ pause_index += 1
262
+ segment += 1
263
+ end
264
+ end
265
+ point
266
+ end
267
+ end
268
+
269
+ def point_attributes(points)
270
+ return { summary_polyline: nil } if points.empty?
271
+
272
+ alts = points.filter_map { |point| point[:altitude] }
273
+ {
274
+ elevation_gain: elevation_delta(alts, :gain),
275
+ elevation_loss: elevation_delta(alts, :loss),
276
+ elevation_min: alts.min&.round(1),
277
+ elevation_max: alts.max&.round(1),
278
+ }
279
+ end
280
+
281
+ def at_value(values, index)
282
+ value = values[index]
283
+ value.is_a?(Array) ? value.first : value
284
+ end
285
+
286
+ def at_altitude(values, index)
287
+ at_value(values, index)
288
+ end
289
+
290
+ def hr_value(value)
291
+ value = numeric(value)
292
+ value&.positive? ? value : nil
293
+ end
294
+
295
+ def sid_valid?(resp, data)
296
+ resp.code.to_i == 200 && !(data.is_a?(Hash) && data.key?('ret') && data['ret'].to_s != '0')
297
+ end
298
+
299
+ def sid_error_message
300
+ "Joyrun: sid expired or invalid\n Re-auth: git fit auth joyrun"
301
+ end
302
+
303
+ def map_sport(value)
304
+ SPORT_MAP[numeric(value)&.to_i] || 'other'
305
+ end
306
+
307
+ def credentials_present?
308
+ !@uid.empty? && !@sid.empty?
309
+ end
310
+
311
+ def login_failed(resp, data)
312
+ reason = data.is_a?(Hash) ? data['msg'] : nil
313
+ warn "Joyrun: SMS login failed (HTTP #{resp.code}#{reason ? ": #{reason}" : ''})"
314
+ false
315
+ end
316
+
317
+ def prompt(label)
318
+ warn label
319
+ $stdin.gets.to_s.strip
320
+ end
321
+
322
+ def present_string(value)
323
+ value.to_s
324
+ end
325
+
326
+ def parse_json(resp)
327
+ JSON.parse(resp.body)
328
+ rescue JSON::ParserError, TypeError
329
+ {}
330
+ end
331
+
332
+ def parse_literal(value)
333
+ return nil if value.nil? || value.to_s.strip.empty?
334
+
335
+ JSON.parse(value)
336
+ rescue JSON::ParserError
337
+ parse_python_literal(value)
338
+ end
339
+
340
+ def parse_python_literal(value)
341
+ text = value.to_s.gsub(']-[', '],[')
342
+ return nil unless text.start_with?('[') && text.end_with?(']')
343
+
344
+ elements = split_top_level(text[1..-2])
345
+ elements.map { |element| parse_literal_element(element) }
346
+ end
347
+
348
+ def split_top_level(text)
349
+ groups = []
350
+ depth = 0
351
+ current = +''
352
+ text.each_char do |char|
353
+ case char
354
+ when '[' then depth += 1
355
+ when ']' then depth -= 1
356
+ when ','
357
+ if depth.zero?
358
+ groups << current
359
+ current = +''
360
+ next
361
+ end
362
+ end
363
+ current << char
364
+ end
365
+ groups << current unless current.empty?
366
+ groups
367
+ end
368
+
369
+ def parse_literal_element(element)
370
+ text = element.strip
371
+ return parse_python_literal(text) if text.start_with?('[')
372
+
373
+ value = Float(text)
374
+ value == value.to_i ? value.to_i : value
375
+ rescue ArgumentError, TypeError
376
+ nil
377
+ end
378
+
379
+ def array_or_empty(value)
380
+ value.is_a?(Array) ? value : []
381
+ end
382
+
383
+ def numeric(value)
384
+ return nil if value.nil? || value.to_s.strip.empty?
385
+
386
+ Float(value)
387
+ rescue ArgumentError, TypeError
388
+ nil
389
+ end
390
+
391
+ def average(values)
392
+ return nil if values.empty?
393
+
394
+ (values.sum.to_f / values.size).round(1)
395
+ end
396
+
397
+ def elevation_delta(altitudes, direction)
398
+ return nil if altitudes.size < 2
399
+
400
+ total = 0.0
401
+ (1...altitudes.size).each do |index|
402
+ delta = altitudes[index] - altitudes[index - 1]
403
+ total += delta if direction == :gain && delta.positive?
404
+ total -= delta if direction == :loss && delta.negative?
405
+ end
406
+ total.round(1)
407
+ end
408
+
409
+ def raw_path(platform_id)
410
+ File.join(raw_dir, "#{platform_id}.json")
411
+ end
412
+
413
+ def write_std(points, platform_id)
414
+ dir = std_dir
415
+ FileUtils.mkdir_p(dir)
416
+ tmp = File.join(dir, ".#{platform_id}.json.tmp")
417
+ final = File.join(dir, "#{platform_id}.json")
418
+ File.write(tmp, JSON.generate(points))
419
+ File.rename(tmp, final)
420
+ end
421
+ end
422
+ end
423
+ 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.0'
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.0
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