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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c202be25ee5614c52d8334494939e9dbfb220a74e6f1fb1791b927bbf4ad1ba
4
- data.tar.gz: '00268aa34aa51cd06ce4d0484f7bc380218e52ae881f5b967c03204379c48080'
3
+ metadata.gz: 5e8314a8423ed470f6b0f5f80097b903ece915d2a6eabc356cdb34104dacb929
4
+ data.tar.gz: 7e6c9c045ee96345593fa961007988a96b78ffd4dd1f44b0f4529f79c14f2ecd
5
5
  SHA512:
6
- metadata.gz: 83c98f7ed7ef66cc500fd3f0005330371711a1b3111df321933fa7bc7b99909e720be9e95a2be354705fe674d8115065513fbe9f6cfa7644cbab01a6698c1065
7
- data.tar.gz: dc27b8455d7c0d99015e9f57a3e5fe72e7efc837e6b3f7d7e528df8e15a7098d18406505064d5228b3e52784553a9ed75f6d2a68cee5ef2c8dd9c8392771076e
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
- SEARCH_URL = "#{BASE_URL}/api/finder"
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
- 'referer' => REFERER_HEADER,
80
- 'origin' => BASE_URL
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(false)
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
- token = fetch_search_token(endpoint)
139
- next unless token
153
+ auth_struct = fetch_search_token(endpoint)
154
+ next unless auth_struct
140
155
 
141
- headers = get_search_request_headers.merge('x-auth-token' => token)
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/finder' if base_endpoint.empty?
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
- candidates.concat(['/api/finder', '/api/search', '/api/s'])
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
- def send_website_request_getcode(parse_all_scripts)
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
- scripts = parse_all_scripts ? script_urls : script_urls.select { |url| url.include?('_app-') }
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 search_info if (search_info.search_url && !search_info.search_url.empty?) || (search_info.api_key && !search_info.api_key.empty?)
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
@@ -1,3 +1,3 @@
1
1
  module HowLongToBeat
2
- VERSION = "0.2.2"
2
+ VERSION = "0.2.4"
3
3
  end
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.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-03-24 00:00:00.000000000 Z
11
+ date: 2026-05-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri