howlongtobeat 0.2.4 → 0.2.5

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: 5e8314a8423ed470f6b0f5f80097b903ece915d2a6eabc356cdb34104dacb929
4
- data.tar.gz: 7e6c9c045ee96345593fa961007988a96b78ffd4dd1f44b0f4529f79c14f2ecd
3
+ metadata.gz: 33771117767e72dbc78a8b1559be38a30e27e680973a4301b67f9cde7b0ffe6e
4
+ data.tar.gz: 1b8540d8616c52e7b984fbff528c96afbf3b6e5d8e167b66d31171b7b80b31e4
5
5
  SHA512:
6
- metadata.gz: e6cb621ce224ad454077852c33e4295f8bab8394c81865703c397a93f6d83d710142cd426b43f14e9d94bca24e2f9e9aafb1ab800db66709864f7a7531d29b27
7
- data.tar.gz: e59d86c29009a8e2759874ef021c0ff49285d797778454ef60cb639d8d8899412ce445d5cf6180c3d6cfc756b6590b60324abffda6f305f6ca5648b61c6eb630
6
+ metadata.gz: f717cf2bdc98707945faef90795d7e972b9f94f3c0f4b218fd3fae8ed7502f7f930d47d01d11ed1015220f1fbb72b6d7fc90fa532bd4354a1c2ea5f3dca19679
7
+ data.tar.gz: 928586677fb5c27e8582611f3611bd95b0fc0ac1b911fa50c6ddcab79d94f883067e1c50e73bcbcefd6ede5c0d42e481fb57b746905e68fc6c9483962c05c443
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.5] - 2026-09-04
29
+
30
+ ### Fixed
31
+ - HLTB moved the search endpoint from `/api/bleed` to the nested path `/api/search/site` (around 2026-08-26). Runtime discovery matched the new `fetch("/api/search/site", { method: "POST" })` call but truncated it to the first path segment (`/api/search`), whose `/init` returns 404. Every search and `search_from_id` returned `nil`. The full path is now kept verbatim (mirrors Python package fix for ScrappyCocco/HowLongToBeat-PythonAPI#59).
32
+ - Refresh the hardcoded fallbacks (`SEARCH_URL`, the `fetch_search_token` default, and the candidate list) to put `/api/search/site` first.
33
+
34
+ ### Changed
35
+ - Discovery now prefers the POST fetch that sends `x-auth-token` (the search call's signature). The HLTB bundle also contains an unrelated `fetch("/api/error", { method: "POST" })` chunk and chunk order is not stable, so stopping on the first POST fetch could pick the wrong endpoint. `SearchInfo#authenticated?` exposes the distinction; a plain POST fetch is still used as a last resort.
36
+
28
37
  ## [0.2.4] - 2026-05-07
29
38
 
30
39
  ### Fixed
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # HowLongToBeat Ruby API
2
2
 
3
3
  ![CI](https://github.com/dpashutskii/howlongtobeat/actions/workflows/ci.yml/badge.svg)
4
+ [![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/dpashutskii)
4
5
 
5
6
  A simple Ruby API to read data from howlongtobeat.com.
6
7
 
@@ -8,10 +8,11 @@ module HowLongToBeat
8
8
  BASE_URL = 'https://howlongtobeat.com'
9
9
  REFERER_HEADER = BASE_URL
10
10
  GAME_URL = "#{BASE_URL}/game"
11
- # HLTB renames this endpoint periodically (most recent: /api/find -> /api/finder -> /api/bleed).
11
+ # HLTB renames this endpoint periodically
12
+ # (most recent: /api/find -> /api/finder -> /api/bleed -> /api/search/site).
12
13
  # The runtime discovery in `send_website_request_getcode` is the source of truth;
13
14
  # this constant is the fallback when discovery fails.
14
- SEARCH_URL = "#{BASE_URL}/api/bleed"
15
+ SEARCH_URL = "#{BASE_URL}/api/search/site"
15
16
 
16
17
  class SearchModifiers
17
18
  NONE = ""
@@ -24,12 +25,25 @@ module HowLongToBeat
24
25
  class SearchInfo
25
26
  attr_accessor :search_url, :api_key
26
27
 
28
+ # A POST fetch to /api/... whose request options mention x-auth-token.
29
+ # This is the signature of the search call and distinguishes it from
30
+ # other POST fetches in the bundle (e.g. /api/error).
31
+ AUTHENTICATED_FETCH_MARKER = /x-auth-token/i
32
+
27
33
  def initialize(script_content)
28
34
  @api_key = extract_api_from_script(script_content)
35
+ @authenticated = false
29
36
  @search_url = extract_search_url_script(script_content)
30
37
  @search_url = @search_url&.strip&.gsub(/^\/+|\/+$/, '')
31
38
  end
32
39
 
40
+ # True when the discovered search_url came from a fetch that sends
41
+ # x-auth-token. Callers use this to prefer the real search endpoint over
42
+ # any other POST fetch found in an earlier script.
43
+ def authenticated?
44
+ @authenticated
45
+ end
46
+
33
47
  private
34
48
 
35
49
  def extract_api_from_script(script_content)
@@ -51,13 +65,21 @@ module HowLongToBeat
51
65
 
52
66
  def extract_search_url_script(script_content)
53
67
  # Prefer the search endpoint used in POST fetch calls, which is more stable
54
- # than hardcoding "/api/search" and works with variants like "/api/finder"
55
- # and "/api/bleed" (current as of 2026-05).
56
- post_fetch_pattern = /fetch\s*\(\s*["']\/api\/([a-zA-Z0-9_\/-]+)[^"']*["']\s*,\s*{[^}]*method:\s*["']POST["'][^}]*}/mi
57
- if (match = script_content.match(post_fetch_pattern))
58
- path_suffix = match[1]
59
- base_path = path_suffix.include?('/') ? path_suffix.split('/').first : path_suffix
60
- return "/api/#{base_path}"
68
+ # than hardcoding "/api/search" and works with variants like "/api/finder",
69
+ # "/api/bleed" and nested paths like "/api/search/site" (current as of
70
+ # 2026-09). The full path is kept verbatim: truncating to the first
71
+ # segment turned "/api/search/site" into "/api/search", which 404s.
72
+ post_fetch_pattern = /fetch\s*\(\s*["']\/api\/([a-zA-Z0-9_\/-]+)[^"']*["']\s*,\s*({[^}]*method:\s*["']POST["'][^}]*})/mi
73
+ matches = script_content.to_enum(:scan, post_fetch_pattern).map { Regexp.last_match }
74
+ unless matches.empty?
75
+ # A single chunk can contain several POST fetches; the search one is
76
+ # the one that sends x-auth-token. Fall back to the first POST fetch
77
+ # so a header rename doesn't leave us with nothing.
78
+ match = matches.find { |m| m[2].match?(AUTHENTICATED_FETCH_MARKER) }
79
+ @authenticated = !match.nil?
80
+ match ||= matches.first
81
+ path_suffix = match[1].sub(%r{/+\z}, '')
82
+ return "/api/#{path_suffix}"
61
83
  end
62
84
 
63
85
  # Legacy fallback for previous script shape using .concat(...)
@@ -182,7 +204,7 @@ module HowLongToBeat
182
204
 
183
205
  def fetch_search_token(parsed_search_url = nil)
184
206
  base_endpoint = parsed_search_url.to_s.strip
185
- base_endpoint = '/api/bleed' if base_endpoint.empty?
207
+ base_endpoint = '/api/search/site' if base_endpoint.empty?
186
208
  base_endpoint = "/#{base_endpoint}" unless base_endpoint.start_with?('/')
187
209
  base_endpoint = base_endpoint.sub(%r{/+\z}, '')
188
210
  base_endpoint = base_endpoint.sub(%r{/init\z}, '')
@@ -218,7 +240,7 @@ module HowLongToBeat
218
240
  candidates = []
219
241
  candidates << preferred unless preferred.empty?
220
242
  # Known historical endpoints, newest first. HLTB rotates this name periodically.
221
- candidates.concat(['/api/bleed', '/api/finder', '/api/search', '/api/s'])
243
+ candidates.concat(['/api/search/site', '/api/bleed', '/api/finder', '/api/search', '/api/s'])
222
244
 
223
245
  normalized = candidates.map do |endpoint|
224
246
  next nil if endpoint.nil? || endpoint.strip.empty?
@@ -234,8 +256,12 @@ module HowLongToBeat
234
256
  # contains a `fetch("/api/<name>", { method: "POST" })` call. HLTB used to
235
257
  # bundle the relevant code under `_app-*.js`, but the modern (Turbopack)
236
258
  # 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`.
259
+ # filter is no longer reliable — we iterate every script.
260
+ #
261
+ # The bundle also contains unrelated POST fetches (e.g. `/api/error`), and
262
+ # chunk order is not stable, so we stop on the first chunk whose POST fetch
263
+ # sends `x-auth-token` (the search call's signature) and only fall back to
264
+ # the first plain POST fetch if no chunk is authenticated.
239
265
  def send_website_request_getcode
240
266
  headers = get_title_request_headers
241
267
  response = make_get_request(BASE_URL, headers)
@@ -244,19 +270,22 @@ module HowLongToBeat
244
270
  doc = Nokogiri::HTML(response)
245
271
  script_urls = doc.css('script[src]').map { |script| script['src'] }
246
272
 
273
+ fallback = nil
247
274
  script_urls.each do |script_url|
248
275
  url = script_url.start_with?('http') ? script_url : "#{BASE_URL}#{script_url}"
249
276
  script_content = make_get_request(url, headers)
250
277
  next unless script_content
251
278
 
252
279
  search_info = SearchInfo.new(script_content)
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?
280
+ # Only consider a search_url match — an api_key without a search_url
281
+ # leaves us with no idea where to POST.
282
+ next unless search_info.search_url && !search_info.search_url.empty?
283
+
284
+ return search_info if search_info.authenticated?
285
+ fallback ||= search_info
257
286
  end
258
287
 
259
- nil
288
+ fallback
260
289
  end
261
290
 
262
291
  def get_title_request_headers
@@ -1,3 +1,3 @@
1
1
  module HowLongToBeat
2
- VERSION = "0.2.4"
2
+ VERSION = "0.2.5"
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.4
4
+ version: 0.2.5
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-05-07 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri