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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +1 -0
- data/lib/howlongtobeat/html_requests.rb +47 -18
- 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,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
|

|
|
4
|
+
[](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
|
|
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/
|
|
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/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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/
|
|
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
|
|
238
|
-
#
|
|
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
|
|
254
|
-
# leaves us with no idea where to POST
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
288
|
+
fallback
|
|
260
289
|
end
|
|
261
290
|
|
|
262
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-
|
|
11
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|