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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +1 -0
- data/lib/howlongtobeat/html_requests.rb +57 -19
- 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: 33771117767e72dbc78a8b1559be38a30e27e680973a4301b67f9cde7b0ffe6e
|
|
4
|
+
data.tar.gz: 1b8540d8616c52e7b984fbff528c96afbf3b6e5d8e167b66d31171b7b80b31e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
4
|
+
[](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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
fallback
|
|
251
289
|
end
|
|
252
290
|
|
|
253
291
|
def get_title_request_headers
|
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.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
|
|
11
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|