howlongtobeat 0.2.3 → 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: b94e6610de0b221a6a9913bcddc28648dc7fb7bbb79991e6e512dee9a7359cbc
4
- data.tar.gz: 5daf1faf915be4b7f33ccb1542d801b079cf6386bcb1d8ae56dbb988a81bc4a9
3
+ metadata.gz: 33771117767e72dbc78a8b1559be38a30e27e680973a4301b67f9cde7b0ffe6e
4
+ data.tar.gz: 1b8540d8616c52e7b984fbff528c96afbf3b6e5d8e167b66d31171b7b80b31e4
5
5
  SHA512:
6
- metadata.gz: b7e115db1b5c3ba6444d1fd5e2bf76082f830a6bb55c0c9a55da132b05dc33d364ded99036ef448872221aa99aaea84b2ac17e3d7ee37dfc97ee529f813898f6
7
- data.tar.gz: dfaf4acae0ed822a1c59900a8c6a78131e4548aff26b0d59f2baaf91505fa533ff23f708d5627de6a84c7b04c2d27679bcfc3164ce31fab0e300e0f03b38f82b
6
+ metadata.gz: f717cf2bdc98707945faef90795d7e972b9f94f3c0f4b218fd3fae8ed7502f7f930d47d01d11ed1015220f1fbb72b6d7fc90fa532bd4354a1c2ea5f3dca19679
7
+ data.tar.gz: 928586677fb5c27e8582611f3611bd95b0fc0ac1b911fa50c6ddcab79d94f883067e1c50e73bcbcefd6ede5c0d42e481fb57b746905e68fc6c9483962c05c443
data/CHANGELOG.md CHANGED
@@ -25,6 +25,24 @@ 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
+
37
+ ## [0.2.4] - 2026-05-07
38
+
39
+ ### Fixed
40
+ - 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.
41
+
42
+ ### Changed
43
+ - 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.
44
+ - `send_website_request_getcode` only returns when a `search_url` is found; finding only an `api_key` no longer short-circuits the loop.
45
+
28
46
  ## [0.2.3] - 2026-04-15
29
47
 
30
48
  ### 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,7 +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
- SEARCH_URL = "#{BASE_URL}/api/finder"
11
+ # HLTB renames this endpoint periodically
12
+ # (most recent: /api/find -> /api/finder -> /api/bleed -> /api/search/site).
13
+ # The runtime discovery in `send_website_request_getcode` is the source of truth;
14
+ # this constant is the fallback when discovery fails.
15
+ SEARCH_URL = "#{BASE_URL}/api/search/site"
12
16
 
13
17
  class SearchModifiers
14
18
  NONE = ""
@@ -21,12 +25,25 @@ module HowLongToBeat
21
25
  class SearchInfo
22
26
  attr_accessor :search_url, :api_key
23
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
+
24
33
  def initialize(script_content)
25
34
  @api_key = extract_api_from_script(script_content)
35
+ @authenticated = false
26
36
  @search_url = extract_search_url_script(script_content)
27
37
  @search_url = @search_url&.strip&.gsub(/^\/+|\/+$/, '')
28
38
  end
29
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
+
30
47
  private
31
48
 
32
49
  def extract_api_from_script(script_content)
@@ -48,12 +65,21 @@ module HowLongToBeat
48
65
 
49
66
  def extract_search_url_script(script_content)
50
67
  # 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".
52
- post_fetch_pattern = /fetch\s*\(\s*["']\/api\/([a-zA-Z0-9_\/-]+)[^"']*["']\s*,\s*{[^}]*method:\s*["']POST["'][^}]*}/mi
53
- if (match = script_content.match(post_fetch_pattern))
54
- path_suffix = match[1]
55
- base_path = path_suffix.include?('/') ? path_suffix.split('/').first : path_suffix
56
- 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}"
57
83
  end
58
84
 
59
85
  # Legacy fallback for previous script shape using .concat(...)
@@ -141,10 +167,7 @@ module HowLongToBeat
141
167
  end
142
168
 
143
169
  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
170
+ search_info = send_website_request_getcode
148
171
 
149
172
  endpoint_candidates = build_endpoint_candidates(search_info&.search_url)
150
173
 
@@ -181,7 +204,7 @@ module HowLongToBeat
181
204
 
182
205
  def fetch_search_token(parsed_search_url = nil)
183
206
  base_endpoint = parsed_search_url.to_s.strip
184
- base_endpoint = '/api/finder' if base_endpoint.empty?
207
+ base_endpoint = '/api/search/site' if base_endpoint.empty?
185
208
  base_endpoint = "/#{base_endpoint}" unless base_endpoint.start_with?('/')
186
209
  base_endpoint = base_endpoint.sub(%r{/+\z}, '')
187
210
  base_endpoint = base_endpoint.sub(%r{/init\z}, '')
@@ -216,7 +239,8 @@ module HowLongToBeat
216
239
  preferred = parsed_endpoint.to_s.strip
217
240
  candidates = []
218
241
  candidates << preferred unless preferred.empty?
219
- candidates.concat(['/api/finder', '/api/search', '/api/s'])
242
+ # Known historical endpoints, newest first. HLTB rotates this name periodically.
243
+ candidates.concat(['/api/search/site', '/api/bleed', '/api/finder', '/api/search', '/api/s'])
220
244
 
221
245
  normalized = candidates.map do |endpoint|
222
246
  next nil if endpoint.nil? || endpoint.strip.empty?
@@ -228,7 +252,17 @@ module HowLongToBeat
228
252
  normalized.compact.uniq
229
253
  end
230
254
 
231
- def send_website_request_getcode(parse_all_scripts)
255
+ # Walks every <script src> tag on the homepage looking for one that
256
+ # contains a `fetch("/api/<name>", { method: "POST" })` call. HLTB used to
257
+ # bundle the relevant code under `_app-*.js`, but the modern (Turbopack)
258
+ # build emits opaque chunk names like `0-~-0up.q3_p0.js`, so a name-based
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.
265
+ def send_website_request_getcode
232
266
  headers = get_title_request_headers
233
267
  response = make_get_request(BASE_URL, headers)
234
268
  return nil unless response
@@ -236,18 +270,22 @@ module HowLongToBeat
236
270
  doc = Nokogiri::HTML(response)
237
271
  script_urls = doc.css('script[src]').map { |script| script['src'] }
238
272
 
239
- scripts = parse_all_scripts ? script_urls : script_urls.select { |url| url.include?('_app-') }
240
-
241
- scripts.each do |script_url|
273
+ fallback = nil
274
+ script_urls.each do |script_url|
242
275
  url = script_url.start_with?('http') ? script_url : "#{BASE_URL}#{script_url}"
243
276
  script_content = make_get_request(url, headers)
244
277
  next unless script_content
245
278
 
246
279
  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?)
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
248
286
  end
249
287
 
250
- nil
288
+ fallback
251
289
  end
252
290
 
253
291
  def get_title_request_headers
@@ -1,3 +1,3 @@
1
1
  module HowLongToBeat
2
- VERSION = "0.2.3"
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.3
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-04-15 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