stravaweb 0.0.0 → 0.0.2

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: 3b7216c73408e14904ad9588f61ac80a0782a251557e58131ef73c50c51ac1fc
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 01af368370c6506d43d0b866a22f8e8a2d9ef87f658739040dc5651891db842d
4
+ data.tar.gz: f98532d5f405dd33ad1a2f261e864d6df74cba19b5820dbc93b2ba4bfe471f40
5
5
  SHA512:
6
- metadata.gz: 223552130e1ab4db291fd2bd0719c8837f1186c3581f6e57078429243cdefad2be7ed5b55a05429993f4fc078eec0f9f00fc929ba0fbe571c80aba2eb8955d3d
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: c709dc01fef28e7287161b48313b2838fc2898de795a37bb5a3e5b72f964634d545cebc049b2f736d81d3f804fab646d6a80ff8f55795ba64339d1ce604ba97a
7
+ data.tar.gz: d73f627e987704e621a26b6effcd6a537e8e9a1f979b7705ce2a90cec32ee77fa8481cb1771c8e68bf285238d30614bc12b6043a390db94cd420e00823affad0
@@ -0,0 +1,6 @@
1
+ module StravaWeb
2
+ class Error < StandardError; end
3
+ class AuthError < Error; end
4
+ class ExportError < Error; end
5
+ class NotFoundError < ExportError; end
6
+ end
@@ -0,0 +1,210 @@
1
+ require "faraday"
2
+ require "faraday-cookie_jar"
3
+ require "http/cookie_jar"
4
+ require "base64"
5
+ require "json"
6
+ require "time"
7
+ require "fileutils"
8
+ require "uri"
9
+
10
+ module StravaWeb
11
+ BASE_URL = "https://www.strava.com".freeze
12
+
13
+ VALID_FORMATS = %i[original tcx gpx].freeze
14
+ EXPORT_URLS = {
15
+ original: "/activities/%s/export_original",
16
+ tcx: "/activities/%s/export_tcx",
17
+ gpx: "/activities/%s/export_gpx"
18
+ }.freeze
19
+
20
+ FORMAT_EXTS = {
21
+ "fit" => :fit,
22
+ "tcx" => :tcx,
23
+ "gpx" => :gpx,
24
+ "json" => :json
25
+ }.freeze
26
+
27
+ ExportResult = Data.define(:filename, :content, :format)
28
+
29
+ class Fetch
30
+ AUTH_HELP = "Set sync.strava.web_auth.jwt in config.yml — see docs/strava-web-auth.md".freeze
31
+
32
+ attr_reader :expires_at
33
+
34
+ def initialize(jwt: nil, auth_seed: nil, cookie_path: nil)
35
+ @cookie_path = cookie_path
36
+ @jar = HTTP::CookieJar.new
37
+
38
+ load_cookies
39
+
40
+ jwt ||= decode_auth_seed(auth_seed) if auth_seed
41
+
42
+ explicit = jwt || (auth_seed && !auth_seed.to_s.strip.empty?)
43
+
44
+ if explicit
45
+ @expires_at = decode_exp(jwt)
46
+ login_with_jwt(jwt)
47
+ elsif authenticated?
48
+ @expires_at = decode_exp(cookie_token)
49
+ return
50
+ else
51
+ raise AuthError, "no web session — set jwt or auth_seed\n#{AUTH_HELP}"
52
+ end
53
+
54
+ save_cookies
55
+ end
56
+
57
+ def authenticated?
58
+ return false if @jar.cookies(URI(BASE_URL)).empty?
59
+
60
+ resp = build_session.get("/me")
61
+ resp.status == 302
62
+ rescue
63
+ false
64
+ end
65
+
66
+ def export(activity_id, format: :original)
67
+ raise ArgumentError, "unknown format: #{format.inspect}" unless VALID_FORMATS.include?(format)
68
+
69
+ path = EXPORT_URLS[format.to_sym] % activity_id
70
+ resp = build_session.get(path)
71
+
72
+ unless resp.success?
73
+ klass = (resp.status == 404) ? NotFoundError : ExportError
74
+ raise klass, "HTTP #{resp.status} for activity #{activity_id}"
75
+ end
76
+
77
+ filename = extract_filename(resp, activity_id, format)
78
+ fmt = infer_format(filename, format)
79
+
80
+ ExportResult.new(filename: filename, content: resp.body, format: fmt)
81
+ end
82
+
83
+ def persist_cookies!
84
+ save_cookies
85
+ end
86
+
87
+ private
88
+
89
+ def build_session
90
+ Faraday.new(url: BASE_URL) do |f|
91
+ f.use :cookie_jar, jar: @jar
92
+ f.headers["User-Agent"] = "stravaweb/#{VERSION}"
93
+ f.headers["Accept"] = "*/*"
94
+ f.options.timeout = 30
95
+ f.options.open_timeout = 10
96
+ f.adapter Faraday.default_adapter
97
+ end
98
+ end
99
+
100
+ def load_cookies
101
+ return unless @cookie_path && File.exist?(@cookie_path)
102
+ @jar.load(@cookie_path, :yaml, session: true)
103
+ rescue
104
+ begin
105
+ FileUtils.rm_f(@cookie_path)
106
+ rescue
107
+ nil
108
+ end
109
+ end
110
+
111
+ def save_cookies
112
+ return unless @cookie_path
113
+ FileUtils.mkdir_p(File.dirname(@cookie_path))
114
+ @jar.save(@cookie_path, :yaml, session: true)
115
+ end
116
+
117
+ def decode_auth_seed(seed)
118
+ return nil if seed.to_s.strip.empty?
119
+
120
+ Base64.strict_decode64(seed)
121
+ rescue ArgumentError
122
+ seed.to_s.strip
123
+ end
124
+
125
+ def decode_exp(jwt)
126
+ parts = jwt.split(".")
127
+ return nil unless parts.length == 3
128
+
129
+ payload = parts[1]
130
+ payload += "=" * (4 - payload.length % 4) if payload.length % 4 != 0
131
+
132
+ data = JSON.parse(Base64.decode64(payload))
133
+ exp = data["exp"]
134
+ Time.at(exp) if exp.is_a?(Numeric)
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def cookie_token
140
+ cookie = @jar.cookies(URI(BASE_URL)).find { |c| c.name == "strava_remember_token" }
141
+ cookie && cookie.value
142
+ end
143
+
144
+ def login_with_jwt(jwt)
145
+ jwt = jwt.to_s.strip
146
+ raise AuthError, "jwt is empty" if jwt.empty?
147
+
148
+ parts = jwt.split(".")
149
+ raise AuthError, "invalid JWT (expected 3 parts, got #{parts.length})" unless parts.length == 3
150
+
151
+ payload = parts[1]
152
+ payload += "=" * (4 - payload.length % 4) if payload.length % 4 != 0
153
+
154
+ begin
155
+ data = JSON.parse(Base64.decode64(payload))
156
+ rescue => e
157
+ raise AuthError, "JWT payload decode failed: #{e.message}"
158
+ end
159
+
160
+ athlete_id = data["sub"].to_s
161
+ raise AuthError, "JWT missing 'sub' claim" if athlete_id.empty?
162
+
163
+ exp = data["exp"]
164
+ if exp&.is_a?(Numeric) && Time.at(exp) < Time.now
165
+ raise AuthError, "JWT expired at #{Time.at(exp)} — re-run local OTP login\n#{AUTH_HELP}"
166
+ end
167
+
168
+ URI(BASE_URL)
169
+ @jar << HTTP::Cookie.new(
170
+ "strava_remember_id", athlete_id,
171
+ domain: ".strava.com", path: "/", secure: true,
172
+ max_age: 365 * 86400
173
+ )
174
+ @jar << HTTP::Cookie.new(
175
+ "strava_remember_token", jwt,
176
+ domain: ".strava.com", path: "/", secure: true,
177
+ max_age: 365 * 86400
178
+ )
179
+
180
+ begin
181
+ resp = build_session.get("/me")
182
+ rescue Faraday::Error => e
183
+ raise AuthError, "network error verifying session: #{e.message}\n#{AUTH_HELP}"
184
+ end
185
+ unless resp.status == 302
186
+ raise AuthError, "JWT session verification failed (HTTP #{resp.status})\n#{AUTH_HELP}"
187
+ end
188
+ end
189
+
190
+ def extract_filename(resp, activity_id, format)
191
+ cd = resp.headers["Content-Disposition"].to_s
192
+
193
+ if (m = cd.match(/filename\*=UTF-8''([^;"]+)/))
194
+ URI.decode_www_form_component(m[1])
195
+ elsif (m = cd.match(/filename="([^"]+)"/))
196
+ m[1]
197
+ elsif (m = cd.match(/filename=([^;\s]+)/))
198
+ m[1]
199
+ else
200
+ ext = (format == :original) ? "dat" : format.to_s
201
+ "#{activity_id}.#{ext}"
202
+ end
203
+ end
204
+
205
+ def infer_format(filename, source_format)
206
+ ext = File.extname(filename).delete(".").downcase
207
+ FORMAT_EXTS[ext] || source_format
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,3 @@
1
+ module StravaWeb
2
+ VERSION = "0.0.2"
3
+ end
data/lib/stravaweb.rb ADDED
@@ -0,0 +1,3 @@
1
+ require_relative "stravaweb/version"
2
+ require_relative "stravaweb/errors"
3
+ require_relative "stravaweb/fetch"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stravaweb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -56,7 +56,11 @@ description: Web-scraping client for the Strava website. Fetches original activi
56
56
  executables: []
57
57
  extensions: []
58
58
  extra_rdoc_files: []
59
- files: []
59
+ files:
60
+ - lib/stravaweb.rb
61
+ - lib/stravaweb/errors.rb
62
+ - lib/stravaweb/fetch.rb
63
+ - lib/stravaweb/version.rb
60
64
  homepage: https://github.com/Lax/stravaweb
61
65
  licenses:
62
66
  - MIT
@@ -75,7 +79,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
79
  - !ruby/object:Gem::Version
76
80
  version: '0'
77
81
  requirements: []
78
- rubygems_version: 4.0.17
82
+ rubygems_version: 3.6.9
79
83
  specification_version: 4
80
84
  summary: Strava website client — original file fetch
81
85
  test_files: []