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 +4 -4
- data/lib/stravaweb/errors.rb +6 -0
- data/lib/stravaweb/fetch.rb +210 -0
- data/lib/stravaweb/version.rb +3 -0
- data/lib/stravaweb.rb +3 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01af368370c6506d43d0b866a22f8e8a2d9ef87f658739040dc5651891db842d
|
|
4
|
+
data.tar.gz: f98532d5f405dd33ad1a2f261e864d6df74cba19b5820dbc93b2ba4bfe471f40
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c709dc01fef28e7287161b48313b2838fc2898de795a37bb5a3e5b72f964634d545cebc049b2f736d81d3f804fab646d6a80ff8f55795ba64339d1ce604ba97a
|
|
7
|
+
data.tar.gz: d73f627e987704e621a26b6effcd6a537e8e9a1f979b7705ce2a90cec32ee77fa8481cb1771c8e68bf285238d30614bc12b6043a390db94cd420e00823affad0
|
|
@@ -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
|
data/lib/stravaweb.rb
ADDED
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.
|
|
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:
|
|
82
|
+
rubygems_version: 3.6.9
|
|
79
83
|
specification_version: 4
|
|
80
84
|
summary: Strava website client — original file fetch
|
|
81
85
|
test_files: []
|