stravaweb 0.0.0 → 0.0.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: 3b7216c73408e14904ad9588f61ac80a0782a251557e58131ef73c50c51ac1fc
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 14cb7266c37ca3ff99cf6d0fe56fbda38ddc78dc9000e054f893292d305a7806
4
+ data.tar.gz: a45823ef0f68109c8ee591b4eb07fdd00215e0e7acfa49bbffcb8eef744eefed
5
5
  SHA512:
6
- metadata.gz: 223552130e1ab4db291fd2bd0719c8837f1186c3581f6e57078429243cdefad2be7ed5b55a05429993f4fc078eec0f9f00fc929ba0fbe571c80aba2eb8955d3d
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: fc5fbed31cbee20560af59302e60213a0a03d6e769865e3b32657320cb5f0bbaa835fbb75a1d19615b23787cdedfbda2675efe042194f391b6aeb1698cfd7057
7
+ data.tar.gz: 0baac02ed5abea6f947fd29faa776131c6f69d0d8b1d1182978b8cf93b8bd9ae7e58a0910dbf38b92ebd73ce7cc3838df25f86b1c41b5129488c41b3c8e47c17
@@ -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,187 @@
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
+ def initialize(jwt: nil, auth_seed: nil, cookie_path: nil)
33
+ @cookie_path = cookie_path
34
+ @jar = HTTP::CookieJar.new
35
+
36
+ load_cookies
37
+
38
+ jwt ||= decode_auth_seed(auth_seed) if auth_seed
39
+
40
+ explicit = jwt || (auth_seed && !auth_seed.to_s.strip.empty?)
41
+
42
+ if explicit
43
+ login_with_jwt(jwt)
44
+ elsif authenticated?
45
+ return
46
+ else
47
+ raise AuthError, "no web session — set jwt or auth_seed\n#{AUTH_HELP}"
48
+ end
49
+
50
+ save_cookies
51
+ end
52
+
53
+ def authenticated?
54
+ return false if @jar.cookies(URI(BASE_URL)).empty?
55
+
56
+ resp = build_session.get("/me")
57
+ resp.status == 302
58
+ rescue
59
+ false
60
+ end
61
+
62
+ def export(activity_id, format: :original)
63
+ raise ArgumentError, "unknown format: #{format.inspect}" unless VALID_FORMATS.include?(format)
64
+
65
+ path = EXPORT_URLS[format.to_sym] % activity_id
66
+ resp = build_session.get(path)
67
+
68
+ unless resp.success?
69
+ klass = (resp.status == 404) ? NotFoundError : ExportError
70
+ raise klass, "HTTP #{resp.status} for activity #{activity_id}"
71
+ end
72
+
73
+ filename = extract_filename(resp, activity_id, format)
74
+ fmt = infer_format(filename, format)
75
+
76
+ ExportResult.new(filename: filename, content: resp.body, format: fmt)
77
+ end
78
+
79
+ def persist_cookies!
80
+ save_cookies
81
+ end
82
+
83
+ private
84
+
85
+ def build_session
86
+ Faraday.new(url: BASE_URL) do |f|
87
+ f.use :cookie_jar, jar: @jar
88
+ f.headers["User-Agent"] = "stravaweb/#{VERSION}"
89
+ f.headers["Accept"] = "*/*"
90
+ f.options.timeout = 30
91
+ f.options.open_timeout = 10
92
+ f.adapter Faraday.default_adapter
93
+ end
94
+ end
95
+
96
+ def load_cookies
97
+ return unless @cookie_path && File.exist?(@cookie_path)
98
+ @jar.load(@cookie_path, :yaml, session: true)
99
+ rescue
100
+ begin
101
+ FileUtils.rm_f(@cookie_path)
102
+ rescue
103
+ nil
104
+ end
105
+ end
106
+
107
+ def save_cookies
108
+ return unless @cookie_path
109
+ FileUtils.mkdir_p(File.dirname(@cookie_path))
110
+ @jar.save(@cookie_path, :yaml, session: true)
111
+ end
112
+
113
+ def decode_auth_seed(seed)
114
+ return nil if seed.to_s.strip.empty?
115
+
116
+ Base64.strict_decode64(seed)
117
+ rescue ArgumentError
118
+ seed.to_s.strip
119
+ end
120
+
121
+ def login_with_jwt(jwt)
122
+ jwt = jwt.to_s.strip
123
+ raise AuthError, "jwt is empty" if jwt.empty?
124
+
125
+ parts = jwt.split(".")
126
+ raise AuthError, "invalid JWT (expected 3 parts, got #{parts.length})" unless parts.length == 3
127
+
128
+ payload = parts[1]
129
+ payload += "=" * (4 - payload.length % 4) if payload.length % 4 != 0
130
+
131
+ begin
132
+ data = JSON.parse(Base64.decode64(payload))
133
+ rescue => e
134
+ raise AuthError, "JWT payload decode failed: #{e.message}"
135
+ end
136
+
137
+ athlete_id = data["sub"].to_s
138
+ raise AuthError, "JWT missing 'sub' claim" if athlete_id.empty?
139
+
140
+ exp = data["exp"]
141
+ if exp&.is_a?(Numeric) && Time.at(exp) < Time.now
142
+ raise AuthError, "JWT expired at #{Time.at(exp)} — re-run local OTP login\n#{AUTH_HELP}"
143
+ end
144
+
145
+ URI(BASE_URL)
146
+ @jar << HTTP::Cookie.new(
147
+ "strava_remember_id", athlete_id,
148
+ domain: ".strava.com", path: "/", secure: true,
149
+ max_age: 365 * 86400
150
+ )
151
+ @jar << HTTP::Cookie.new(
152
+ "strava_remember_token", jwt,
153
+ domain: ".strava.com", path: "/", secure: true,
154
+ max_age: 365 * 86400
155
+ )
156
+
157
+ begin
158
+ resp = build_session.get("/me")
159
+ rescue Faraday::Error => e
160
+ raise AuthError, "network error verifying session: #{e.message}\n#{AUTH_HELP}"
161
+ end
162
+ unless resp.status == 302
163
+ raise AuthError, "JWT session verification failed (HTTP #{resp.status})\n#{AUTH_HELP}"
164
+ end
165
+ end
166
+
167
+ def extract_filename(resp, activity_id, format)
168
+ cd = resp.headers["Content-Disposition"].to_s
169
+
170
+ if (m = cd.match(/filename\*=UTF-8''([^;"]+)/))
171
+ URI.decode_www_form_component(m[1])
172
+ elsif (m = cd.match(/filename="([^"]+)"/))
173
+ m[1]
174
+ elsif (m = cd.match(/filename=([^;\s]+)/))
175
+ m[1]
176
+ else
177
+ ext = (format == :original) ? "dat" : format.to_s
178
+ "#{activity_id}.#{ext}"
179
+ end
180
+ end
181
+
182
+ def infer_format(filename, source_format)
183
+ ext = File.extname(filename).delete(".").downcase
184
+ FORMAT_EXTS[ext] || source_format
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,3 @@
1
+ module StravaWeb
2
+ VERSION = "0.0.1"
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.1
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: []