howlongtobeat 0.2.2 → 0.2.4
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/CHANGELOG.md +15 -0
- data/lib/howlongtobeat/html_requests.rb +59 -23
- data/lib/howlongtobeat/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e8314a8423ed470f6b0f5f80097b903ece915d2a6eabc356cdb34104dacb929
|
|
4
|
+
data.tar.gz: 7e6c9c045ee96345593fa961007988a96b78ffd4dd1f44b0f4529f79c14f2ecd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6cb621ce224ad454077852c33e4295f8bab8394c81865703c397a93f6d83d710142cd426b43f14e9d94bca24e2f9e9aafb1ab800db66709864f7a7531d29b27
|
|
7
|
+
data.tar.gz: e59d86c29009a8e2759874ef021c0ff49285d797778454ef60cb639d8d8899412ce445d5cf6180c3d6cfc756b6590b60324abffda6f305f6ca5648b61c6eb630
|
data/CHANGELOG.md
CHANGED
|
@@ -25,6 +25,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
25
25
|
### Security
|
|
26
26
|
- None
|
|
27
27
|
|
|
28
|
+
## [0.2.4] - 2026-05-07
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- HLTB renamed the search endpoint from `/api/finder` to `/api/bleed`. Runtime discovery already adapted, but the hardcoded fallbacks didn't — refresh `SEARCH_URL`, the default in `fetch_search_token`, and the candidate list to put `/api/bleed` first.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Drop the `_app-*.js` script-name filter in `send_website_request_getcode`. The modern HLTB build (Turbopack) ships chunks with opaque names like `0-~-0up.q3_p0.js`, so the filter never matched and forced every search through a redundant retry. Single-pass discovery now iterates all `<script src>` tags directly.
|
|
35
|
+
- `send_website_request_getcode` only returns when a `search_url` is found; finding only an `api_key` no longer short-circuits the loop.
|
|
36
|
+
|
|
37
|
+
## [0.2.3] - 2026-04-15
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- Send `x-hp-key` and `x-hp-val` headers extracted from the `/init` response, matching HLTB's updated API authentication (mirrors Python package v1.0.21)
|
|
41
|
+
- Inject the dynamic key/value pair from `/init` into the search request payload
|
|
42
|
+
|
|
28
43
|
## [0.2.2] - 2026-03-24
|
|
29
44
|
|
|
30
45
|
### Fixed
|
|
@@ -8,7 +8,10 @@ module HowLongToBeat
|
|
|
8
8
|
BASE_URL = 'https://howlongtobeat.com'
|
|
9
9
|
REFERER_HEADER = BASE_URL
|
|
10
10
|
GAME_URL = "#{BASE_URL}/game"
|
|
11
|
-
|
|
11
|
+
# HLTB renames this endpoint periodically (most recent: /api/find -> /api/finder -> /api/bleed).
|
|
12
|
+
# The runtime discovery in `send_website_request_getcode` is the source of truth;
|
|
13
|
+
# this constant is the fallback when discovery fails.
|
|
14
|
+
SEARCH_URL = "#{BASE_URL}/api/bleed"
|
|
12
15
|
|
|
13
16
|
class SearchModifiers
|
|
14
17
|
NONE = ""
|
|
@@ -48,7 +51,8 @@ module HowLongToBeat
|
|
|
48
51
|
|
|
49
52
|
def extract_search_url_script(script_content)
|
|
50
53
|
# Prefer the search endpoint used in POST fetch calls, which is more stable
|
|
51
|
-
# than hardcoding "/api/search" and works with variants like "/api/finder"
|
|
54
|
+
# than hardcoding "/api/search" and works with variants like "/api/finder"
|
|
55
|
+
# and "/api/bleed" (current as of 2026-05).
|
|
52
56
|
post_fetch_pattern = /fetch\s*\(\s*["']\/api\/([a-zA-Z0-9_\/-]+)[^"']*["']\s*,\s*{[^}]*method:\s*["']POST["'][^}]*}/mi
|
|
53
57
|
if (match = script_content.match(post_fetch_pattern))
|
|
54
58
|
path_suffix = match[1]
|
|
@@ -70,18 +74,28 @@ module HowLongToBeat
|
|
|
70
74
|
end
|
|
71
75
|
end
|
|
72
76
|
|
|
77
|
+
AuthStruct = Struct.new(:auth_token, :auth_key, :auth_value)
|
|
78
|
+
|
|
73
79
|
class << self
|
|
74
|
-
def get_search_request_headers
|
|
75
|
-
{
|
|
80
|
+
def get_search_request_headers(auth_struct = nil)
|
|
81
|
+
headers = {
|
|
76
82
|
'content-type' => 'application/json',
|
|
77
83
|
'accept' => '*/*',
|
|
78
84
|
'User-Agent' => random_user_agent,
|
|
79
|
-
'
|
|
80
|
-
'
|
|
85
|
+
'Referer' => REFERER_HEADER,
|
|
86
|
+
'Origin' => BASE_URL
|
|
81
87
|
}
|
|
88
|
+
|
|
89
|
+
if auth_struct
|
|
90
|
+
headers['x-auth-token'] = auth_struct.auth_token.to_s if auth_struct.auth_token
|
|
91
|
+
headers['x-hp-key'] = auth_struct.auth_key.to_s if auth_struct.auth_key
|
|
92
|
+
headers['x-hp-val'] = auth_struct.auth_value.to_s if auth_struct.auth_value
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
headers
|
|
82
96
|
end
|
|
83
97
|
|
|
84
|
-
def get_search_request_data(game_name, search_modifiers = SearchModifiers::NONE, page = 1, search_info = nil)
|
|
98
|
+
def get_search_request_data(game_name, search_modifiers = SearchModifiers::NONE, page = 1, search_info = nil, auth_struct = nil)
|
|
85
99
|
payload = {
|
|
86
100
|
searchType: 'games',
|
|
87
101
|
searchTerms: game_name.split,
|
|
@@ -123,23 +137,24 @@ module HowLongToBeat
|
|
|
123
137
|
payload[:searchOptions][:users][:id] = search_info.api_key
|
|
124
138
|
end
|
|
125
139
|
|
|
140
|
+
if auth_struct&.auth_key && auth_struct&.auth_value
|
|
141
|
+
payload[auth_struct.auth_key] = auth_struct.auth_value
|
|
142
|
+
end
|
|
143
|
+
|
|
126
144
|
payload.to_json
|
|
127
145
|
end
|
|
128
146
|
|
|
129
147
|
def send_web_request(game_name, search_modifiers = SearchModifiers::NONE, page = 1)
|
|
130
|
-
search_info = send_website_request_getcode
|
|
131
|
-
if search_info.nil? || search_info.search_url.nil?
|
|
132
|
-
search_info = send_website_request_getcode(true)
|
|
133
|
-
end
|
|
148
|
+
search_info = send_website_request_getcode
|
|
134
149
|
|
|
135
150
|
endpoint_candidates = build_endpoint_candidates(search_info&.search_url)
|
|
136
151
|
|
|
137
152
|
endpoint_candidates.each do |endpoint|
|
|
138
|
-
|
|
139
|
-
next unless
|
|
153
|
+
auth_struct = fetch_search_token(endpoint)
|
|
154
|
+
next unless auth_struct
|
|
140
155
|
|
|
141
|
-
headers = get_search_request_headers
|
|
142
|
-
payload = get_search_request_data(game_name, search_modifiers, page, search_info)
|
|
156
|
+
headers = get_search_request_headers(auth_struct)
|
|
157
|
+
payload = get_search_request_data(game_name, search_modifiers, page, search_info, auth_struct)
|
|
143
158
|
search_url = "#{BASE_URL}#{endpoint}"
|
|
144
159
|
response = make_request(search_url, headers, payload)
|
|
145
160
|
return response if response
|
|
@@ -167,7 +182,7 @@ module HowLongToBeat
|
|
|
167
182
|
|
|
168
183
|
def fetch_search_token(parsed_search_url = nil)
|
|
169
184
|
base_endpoint = parsed_search_url.to_s.strip
|
|
170
|
-
base_endpoint = '/api/
|
|
185
|
+
base_endpoint = '/api/bleed' if base_endpoint.empty?
|
|
171
186
|
base_endpoint = "/#{base_endpoint}" unless base_endpoint.start_with?('/')
|
|
172
187
|
base_endpoint = base_endpoint.sub(%r{/+\z}, '')
|
|
173
188
|
base_endpoint = base_endpoint.sub(%r{/init\z}, '')
|
|
@@ -180,7 +195,20 @@ module HowLongToBeat
|
|
|
180
195
|
json = JSON.parse(response) rescue nil
|
|
181
196
|
return nil unless json.is_a?(Hash)
|
|
182
197
|
|
|
183
|
-
json['token'] || json.dig('data', 'token') || json['auth_token'] || json['authToken']
|
|
198
|
+
token = json['token'] || json.dig('data', 'token') || json['auth_token'] || json['authToken']
|
|
199
|
+
|
|
200
|
+
auth_key = nil
|
|
201
|
+
auth_value = nil
|
|
202
|
+
json.each do |field_name, field_value|
|
|
203
|
+
lower = field_name.downcase
|
|
204
|
+
if lower.match?(/key/)
|
|
205
|
+
auth_key = field_value
|
|
206
|
+
elsif lower.match?(/val/)
|
|
207
|
+
auth_value = field_value
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
AuthStruct.new(token, auth_key, auth_value)
|
|
184
212
|
rescue StandardError
|
|
185
213
|
nil
|
|
186
214
|
end
|
|
@@ -189,7 +217,8 @@ module HowLongToBeat
|
|
|
189
217
|
preferred = parsed_endpoint.to_s.strip
|
|
190
218
|
candidates = []
|
|
191
219
|
candidates << preferred unless preferred.empty?
|
|
192
|
-
|
|
220
|
+
# Known historical endpoints, newest first. HLTB rotates this name periodically.
|
|
221
|
+
candidates.concat(['/api/bleed', '/api/finder', '/api/search', '/api/s'])
|
|
193
222
|
|
|
194
223
|
normalized = candidates.map do |endpoint|
|
|
195
224
|
next nil if endpoint.nil? || endpoint.strip.empty?
|
|
@@ -201,7 +230,13 @@ module HowLongToBeat
|
|
|
201
230
|
normalized.compact.uniq
|
|
202
231
|
end
|
|
203
232
|
|
|
204
|
-
|
|
233
|
+
# Walks every <script src> tag on the homepage looking for one that
|
|
234
|
+
# contains a `fetch("/api/<name>", { method: "POST" })` call. HLTB used to
|
|
235
|
+
# bundle the relevant code under `_app-*.js`, but the modern (Turbopack)
|
|
236
|
+
# build emits opaque chunk names like `0-~-0up.q3_p0.js`, so a name-based
|
|
237
|
+
# filter is no longer reliable — we just iterate and stop on the first
|
|
238
|
+
# script that yields a `search_url`.
|
|
239
|
+
def send_website_request_getcode
|
|
205
240
|
headers = get_title_request_headers
|
|
206
241
|
response = make_get_request(BASE_URL, headers)
|
|
207
242
|
return nil unless response
|
|
@@ -209,15 +244,16 @@ module HowLongToBeat
|
|
|
209
244
|
doc = Nokogiri::HTML(response)
|
|
210
245
|
script_urls = doc.css('script[src]').map { |script| script['src'] }
|
|
211
246
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
scripts.each do |script_url|
|
|
247
|
+
script_urls.each do |script_url|
|
|
215
248
|
url = script_url.start_with?('http') ? script_url : "#{BASE_URL}#{script_url}"
|
|
216
249
|
script_content = make_get_request(url, headers)
|
|
217
250
|
next unless script_content
|
|
218
251
|
|
|
219
252
|
search_info = SearchInfo.new(script_content)
|
|
220
|
-
return
|
|
253
|
+
# Only return on a search_url match — an api_key without a search_url
|
|
254
|
+
# leaves us with no idea where to POST, and the loop should keep
|
|
255
|
+
# looking for a chunk that gives us the endpoint.
|
|
256
|
+
return search_info if search_info.search_url && !search_info.search_url.empty?
|
|
221
257
|
end
|
|
222
258
|
|
|
223
259
|
nil
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: howlongtobeat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitrii Pashutskii
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|