howlongtobeat 0.2.3 → 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: b94e6610de0b221a6a9913bcddc28648dc7fb7bbb79991e6e512dee9a7359cbc
4
- data.tar.gz: 5daf1faf915be4b7f33ccb1542d801b079cf6386bcb1d8ae56dbb988a81bc4a9
3
+ metadata.gz: 5e8314a8423ed470f6b0f5f80097b903ece915d2a6eabc356cdb34104dacb929
4
+ data.tar.gz: 7e6c9c045ee96345593fa961007988a96b78ffd4dd1f44b0f4529f79c14f2ecd
5
5
  SHA512:
6
- metadata.gz: b7e115db1b5c3ba6444d1fd5e2bf76082f830a6bb55c0c9a55da132b05dc33d364ded99036ef448872221aa99aaea84b2ac17e3d7ee37dfc97ee529f813898f6
7
- data.tar.gz: dfaf4acae0ed822a1c59900a8c6a78131e4548aff26b0d59f2baaf91505fa533ff23f708d5627de6a84c7b04c2d27679bcfc3164ce31fab0e300e0f03b38f82b
6
+ metadata.gz: e6cb621ce224ad454077852c33e4295f8bab8394c81865703c397a93f6d83d710142cd426b43f14e9d94bca24e2f9e9aafb1ab800db66709864f7a7531d29b27
7
+ data.tar.gz: e59d86c29009a8e2759874ef021c0ff49285d797778454ef60cb639d8d8899412ce445d5cf6180c3d6cfc756b6590b60324abffda6f305f6ca5648b61c6eb630
data/CHANGELOG.md CHANGED
@@ -25,6 +25,15 @@ 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
+
28
37
  ## [0.2.3] - 2026-04-15
29
38
 
30
39
  ### 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]
@@ -141,10 +145,7 @@ module HowLongToBeat
141
145
  end
142
146
 
143
147
  def send_web_request(game_name, search_modifiers = SearchModifiers::NONE, page = 1)
144
- search_info = send_website_request_getcode(false)
145
- if search_info.nil? || search_info.search_url.nil?
146
- search_info = send_website_request_getcode(true)
147
- end
148
+ search_info = send_website_request_getcode
148
149
 
149
150
  endpoint_candidates = build_endpoint_candidates(search_info&.search_url)
150
151
 
@@ -181,7 +182,7 @@ module HowLongToBeat
181
182
 
182
183
  def fetch_search_token(parsed_search_url = nil)
183
184
  base_endpoint = parsed_search_url.to_s.strip
184
- base_endpoint = '/api/finder' if base_endpoint.empty?
185
+ base_endpoint = '/api/bleed' if base_endpoint.empty?
185
186
  base_endpoint = "/#{base_endpoint}" unless base_endpoint.start_with?('/')
186
187
  base_endpoint = base_endpoint.sub(%r{/+\z}, '')
187
188
  base_endpoint = base_endpoint.sub(%r{/init\z}, '')
@@ -216,7 +217,8 @@ module HowLongToBeat
216
217
  preferred = parsed_endpoint.to_s.strip
217
218
  candidates = []
218
219
  candidates << preferred unless preferred.empty?
219
- 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'])
220
222
 
221
223
  normalized = candidates.map do |endpoint|
222
224
  next nil if endpoint.nil? || endpoint.strip.empty?
@@ -228,7 +230,13 @@ module HowLongToBeat
228
230
  normalized.compact.uniq
229
231
  end
230
232
 
231
- 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
232
240
  headers = get_title_request_headers
233
241
  response = make_get_request(BASE_URL, headers)
234
242
  return nil unless response
@@ -236,15 +244,16 @@ module HowLongToBeat
236
244
  doc = Nokogiri::HTML(response)
237
245
  script_urls = doc.css('script[src]').map { |script| script['src'] }
238
246
 
239
- scripts = parse_all_scripts ? script_urls : script_urls.select { |url| url.include?('_app-') }
240
-
241
- scripts.each do |script_url|
247
+ script_urls.each do |script_url|
242
248
  url = script_url.start_with?('http') ? script_url : "#{BASE_URL}#{script_url}"
243
249
  script_content = make_get_request(url, headers)
244
250
  next unless script_content
245
251
 
246
252
  search_info = SearchInfo.new(script_content)
247
- 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?
248
257
  end
249
258
 
250
259
  nil
@@ -1,3 +1,3 @@
1
1
  module HowLongToBeat
2
- VERSION = "0.2.3"
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.3
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-04-15 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