howlongtobeat 0.2.1 → 0.2.2
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 +10 -0
- data/lib/howlongtobeat/html_requests.rb +65 -18
- data/lib/howlongtobeat/json_result_parser.rb +62 -20
- 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: 9c202be25ee5614c52d8334494939e9dbfb220a74e6f1fb1791b927bbf4ad1ba
|
|
4
|
+
data.tar.gz: '00268aa34aa51cd06ce4d0484f7bc380218e52ae881f5b967c03204379c48080'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83c98f7ed7ef66cc500fd3f0005330371711a1b3111df321933fa7bc7b99909e720be9e95a2be354705fe674d8115065513fbe9f6cfa7644cbab01a6698c1065
|
|
7
|
+
data.tar.gz: dc27b8455d7c0d99015e9f57a3e5fe72e7efc837e6b3f7d7e528df8e15a7098d18406505064d5228b3e52784553a9ed75f6d2a68cee5ef2c8dd9c8392771076e
|
data/CHANGELOG.md
CHANGED
|
@@ -25,6 +25,16 @@ 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.2] - 2026-03-24
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- Restore search against HowLongToBeat's current API by discovering the POST search endpoint from site scripts (e.g. `/api/finder`) instead of hardcoding `/api/search`
|
|
32
|
+
- Obtain the auth token from the same API base as search (`{endpoint}/init`), with fallback candidates when parsing is incomplete
|
|
33
|
+
- Tolerate search response shape changes when parsing JSON (alternate root keys, camelCase field names)
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- Default search endpoint fallback is now `/api/finder`
|
|
37
|
+
|
|
28
38
|
## [0.2.1] - 2025-12-13
|
|
29
39
|
|
|
30
40
|
### Fixed
|
|
@@ -8,7 +8,7 @@ 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/
|
|
11
|
+
SEARCH_URL = "#{BASE_URL}/api/finder"
|
|
12
12
|
|
|
13
13
|
class SearchModifiers
|
|
14
14
|
NONE = ""
|
|
@@ -47,15 +47,25 @@ module HowLongToBeat
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def extract_search_url_script(script_content)
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
# 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}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Legacy fallback for previous script shape using .concat(...)
|
|
60
|
+
legacy_pattern = /fetch\(\s*["'](\/api\/[^"']*)['"]((?:\s*\.concat\(\s*["']([^"']*)['"]\s*\))+)\s*,/
|
|
61
|
+
if (matches = script_content.match(legacy_pattern))
|
|
52
62
|
endpoint = matches[1]
|
|
53
63
|
concat_calls = matches[2]
|
|
54
64
|
concat_strings = concat_calls.scan(/\.concat\(\s*["']([^"']*)['"]\s*\)/).flatten
|
|
55
|
-
concatenated_str = concat_strings.join
|
|
56
|
-
concatenated_str = concatenated_str.gsub(/["\(\)\[\]'\\]/, '')
|
|
65
|
+
concatenated_str = concat_strings.join.gsub(/["\(\)\[\]'\\]/, '')
|
|
57
66
|
return endpoint if concatenated_str == @api_key
|
|
58
67
|
end
|
|
68
|
+
|
|
59
69
|
nil
|
|
60
70
|
end
|
|
61
71
|
end
|
|
@@ -117,12 +127,25 @@ module HowLongToBeat
|
|
|
117
127
|
end
|
|
118
128
|
|
|
119
129
|
def send_web_request(game_name, search_modifiers = SearchModifiers::NONE, page = 1)
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
search_info = send_website_request_getcode(false)
|
|
131
|
+
if search_info.nil? || search_info.search_url.nil?
|
|
132
|
+
search_info = send_website_request_getcode(true)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
endpoint_candidates = build_endpoint_candidates(search_info&.search_url)
|
|
122
136
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
endpoint_candidates.each do |endpoint|
|
|
138
|
+
token = fetch_search_token(endpoint)
|
|
139
|
+
next unless token
|
|
140
|
+
|
|
141
|
+
headers = get_search_request_headers.merge('x-auth-token' => token)
|
|
142
|
+
payload = get_search_request_data(game_name, search_modifiers, page, search_info)
|
|
143
|
+
search_url = "#{BASE_URL}#{endpoint}"
|
|
144
|
+
response = make_request(search_url, headers, payload)
|
|
145
|
+
return response if response
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
nil
|
|
126
149
|
end
|
|
127
150
|
|
|
128
151
|
def get_game_title(game_id)
|
|
@@ -142,18 +165,42 @@ module HowLongToBeat
|
|
|
142
165
|
|
|
143
166
|
private
|
|
144
167
|
|
|
145
|
-
def fetch_search_token
|
|
146
|
-
|
|
168
|
+
def fetch_search_token(parsed_search_url = nil)
|
|
169
|
+
base_endpoint = parsed_search_url.to_s.strip
|
|
170
|
+
base_endpoint = '/api/finder' if base_endpoint.empty?
|
|
171
|
+
base_endpoint = "/#{base_endpoint}" unless base_endpoint.start_with?('/')
|
|
172
|
+
base_endpoint = base_endpoint.sub(%r{/+\z}, '')
|
|
173
|
+
base_endpoint = base_endpoint.sub(%r{/init\z}, '')
|
|
174
|
+
|
|
175
|
+
url = "#{BASE_URL}#{base_endpoint}/init?t=#{Time.now.to_i}"
|
|
147
176
|
headers = get_title_request_headers
|
|
148
177
|
response = make_get_request(url, headers)
|
|
149
178
|
return nil unless response
|
|
150
179
|
|
|
151
180
|
json = JSON.parse(response) rescue nil
|
|
152
|
-
json.is_a?(Hash)
|
|
181
|
+
return nil unless json.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
json['token'] || json.dig('data', 'token') || json['auth_token'] || json['authToken']
|
|
153
184
|
rescue StandardError
|
|
154
185
|
nil
|
|
155
186
|
end
|
|
156
187
|
|
|
188
|
+
def build_endpoint_candidates(parsed_endpoint = nil)
|
|
189
|
+
preferred = parsed_endpoint.to_s.strip
|
|
190
|
+
candidates = []
|
|
191
|
+
candidates << preferred unless preferred.empty?
|
|
192
|
+
candidates.concat(['/api/finder', '/api/search', '/api/s'])
|
|
193
|
+
|
|
194
|
+
normalized = candidates.map do |endpoint|
|
|
195
|
+
next nil if endpoint.nil? || endpoint.strip.empty?
|
|
196
|
+
value = endpoint.strip
|
|
197
|
+
value = "/#{value}" unless value.start_with?('/')
|
|
198
|
+
value.sub(%r{/+\z}, '')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
normalized.compact.uniq
|
|
202
|
+
end
|
|
203
|
+
|
|
157
204
|
def send_website_request_getcode(parse_all_scripts)
|
|
158
205
|
headers = get_title_request_headers
|
|
159
206
|
response = make_get_request(BASE_URL, headers)
|
|
@@ -170,7 +217,7 @@ module HowLongToBeat
|
|
|
170
217
|
next unless script_content
|
|
171
218
|
|
|
172
219
|
search_info = SearchInfo.new(script_content)
|
|
173
|
-
return search_info if search_info.api_key && !search_info.api_key.empty?
|
|
220
|
+
return search_info if (search_info.search_url && !search_info.search_url.empty?) || (search_info.api_key && !search_info.api_key.empty?)
|
|
174
221
|
end
|
|
175
222
|
|
|
176
223
|
nil
|
|
@@ -195,12 +242,12 @@ module HowLongToBeat
|
|
|
195
242
|
|
|
196
243
|
response = http.request(request)
|
|
197
244
|
response.body if response.is_a?(Net::HTTPSuccess)
|
|
198
|
-
rescue OpenSSL::SSL::SSLError
|
|
245
|
+
rescue OpenSSL::SSL::SSLError
|
|
199
246
|
# SSL certificate verification failed - disable verification as fallback
|
|
200
247
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
201
248
|
response = http.request(request)
|
|
202
249
|
response.is_a?(Net::HTTPSuccess) ? response.body : nil
|
|
203
|
-
rescue StandardError
|
|
250
|
+
rescue StandardError
|
|
204
251
|
nil
|
|
205
252
|
end
|
|
206
253
|
|
|
@@ -217,12 +264,12 @@ module HowLongToBeat
|
|
|
217
264
|
else
|
|
218
265
|
nil
|
|
219
266
|
end
|
|
220
|
-
rescue OpenSSL::SSL::SSLError
|
|
267
|
+
rescue OpenSSL::SSL::SSLError
|
|
221
268
|
# SSL certificate verification failed - disable verification as fallback
|
|
222
269
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
223
270
|
response = http.request(request)
|
|
224
271
|
response.is_a?(Net::HTTPSuccess) ? response.body : nil
|
|
225
|
-
rescue StandardError
|
|
272
|
+
rescue StandardError
|
|
226
273
|
nil
|
|
227
274
|
end
|
|
228
275
|
|
|
@@ -27,7 +27,10 @@ module HowLongToBeat
|
|
|
27
27
|
|
|
28
28
|
def parse_json_result(input_json_result)
|
|
29
29
|
response_result = JSON.parse(input_json_result)
|
|
30
|
-
|
|
30
|
+
games = extract_games(response_result)
|
|
31
|
+
return if games.nil? || games.empty?
|
|
32
|
+
|
|
33
|
+
games.each do |game|
|
|
31
34
|
new_game_entry = parse_json_element(game)
|
|
32
35
|
|
|
33
36
|
if @game_id && new_game_entry.game_id.to_s != @game_id.to_s
|
|
@@ -42,35 +45,74 @@ module HowLongToBeat
|
|
|
42
45
|
|
|
43
46
|
private
|
|
44
47
|
|
|
48
|
+
def extract_games(response_result)
|
|
49
|
+
return response_result if response_result.is_a?(Array)
|
|
50
|
+
return [] unless response_result.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
%w[data results result items games].each do |key|
|
|
53
|
+
value = response_result[key]
|
|
54
|
+
return value if value.is_a?(Array)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def field(input, *keys)
|
|
61
|
+
keys.each do |key|
|
|
62
|
+
return input[key] if input.key?(key)
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalize_platforms(value)
|
|
68
|
+
return value if value.is_a?(Array)
|
|
69
|
+
return nil if value.nil?
|
|
70
|
+
value.to_s.split(", ")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_time(value)
|
|
74
|
+
return nil if value.nil?
|
|
75
|
+
time_value = value.to_f
|
|
76
|
+
return nil if time_value <= 0
|
|
77
|
+
|
|
78
|
+
# Older payloads expose seconds; some variants may already use hours.
|
|
79
|
+
if time_value > 500
|
|
80
|
+
round_time(time_value)
|
|
81
|
+
else
|
|
82
|
+
time_value.round(2)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
45
86
|
def parse_json_element(input_game_element)
|
|
46
87
|
current_entry = HowLongToBeatEntry.new
|
|
47
88
|
|
|
48
89
|
# Base fields
|
|
49
|
-
current_entry.game_id = input_game_element
|
|
50
|
-
current_entry.game_name = input_game_element
|
|
51
|
-
current_entry.game_alias = input_game_element
|
|
52
|
-
current_entry.game_type = input_game_element
|
|
53
|
-
|
|
90
|
+
current_entry.game_id = field(input_game_element, "game_id", "gameId", "id")
|
|
91
|
+
current_entry.game_name = field(input_game_element, "game_name", "gameName", "name")
|
|
92
|
+
current_entry.game_alias = field(input_game_element, "game_alias", "gameAlias", "alias")
|
|
93
|
+
current_entry.game_type = field(input_game_element, "game_type", "gameType", "type")
|
|
94
|
+
game_image = field(input_game_element, "game_image", "gameImage", "image")
|
|
95
|
+
current_entry.game_image_url = "#{IMAGE_URL_PREFIX}#{game_image}" if game_image
|
|
54
96
|
current_entry.game_web_link = "#{GAME_URL_PREFIX}#{current_entry.game_id}"
|
|
55
|
-
current_entry.review_score = input_game_element
|
|
56
|
-
current_entry.profile_dev = input_game_element
|
|
57
|
-
current_entry.profile_platforms = input_game_element
|
|
58
|
-
current_entry.release_world = input_game_element
|
|
97
|
+
current_entry.review_score = field(input_game_element, "review_score", "reviewScore", "score")
|
|
98
|
+
current_entry.profile_dev = field(input_game_element, "profile_dev", "profileDev", "developer")
|
|
99
|
+
current_entry.profile_platforms = normalize_platforms(field(input_game_element, "profile_platform", "profilePlatform", "platforms"))
|
|
100
|
+
current_entry.release_world = field(input_game_element, "release_world", "releaseWorld", "releaseYear")
|
|
59
101
|
current_entry.json_content = input_game_element
|
|
60
102
|
|
|
61
103
|
# Completion times
|
|
62
|
-
current_entry.main_story =
|
|
63
|
-
current_entry.main_extra =
|
|
64
|
-
current_entry.completionist =
|
|
65
|
-
current_entry.all_styles =
|
|
66
|
-
current_entry.coop_time =
|
|
67
|
-
current_entry.mp_time =
|
|
104
|
+
current_entry.main_story = normalize_time(field(input_game_element, "comp_main", "compMain", "main_story", "mainStory"))
|
|
105
|
+
current_entry.main_extra = normalize_time(field(input_game_element, "comp_plus", "compPlus", "main_extra", "mainExtra"))
|
|
106
|
+
current_entry.completionist = normalize_time(field(input_game_element, "comp_100", "comp100", "completionist"))
|
|
107
|
+
current_entry.all_styles = normalize_time(field(input_game_element, "comp_all", "compAll", "all_styles", "allStyles"))
|
|
108
|
+
current_entry.coop_time = normalize_time(field(input_game_element, "invested_co", "investedCo", "coop_time", "coopTime"))
|
|
109
|
+
current_entry.mp_time = normalize_time(field(input_game_element, "invested_mp", "investedMp", "mp_time", "mpTime"))
|
|
68
110
|
|
|
69
111
|
# Complexity flags
|
|
70
|
-
current_entry.complexity_lvl_combine = input_game_element
|
|
71
|
-
current_entry.complexity_lvl_sp = input_game_element
|
|
72
|
-
current_entry.complexity_lvl_co = input_game_element
|
|
73
|
-
current_entry.complexity_lvl_mp = input_game_element
|
|
112
|
+
current_entry.complexity_lvl_combine = field(input_game_element, "comp_lvl_combine", "compLvlCombine", "complexity_lvl_combine", "complexityLvlCombine").to_i == 1
|
|
113
|
+
current_entry.complexity_lvl_sp = field(input_game_element, "comp_lvl_sp", "compLvlSp", "complexity_lvl_sp", "complexityLvlSp").to_i == 1
|
|
114
|
+
current_entry.complexity_lvl_co = field(input_game_element, "comp_lvl_co", "compLvlCo", "complexity_lvl_co", "complexityLvlCo").to_i == 1
|
|
115
|
+
current_entry.complexity_lvl_mp = field(input_game_element, "comp_lvl_mp", "compLvlMp", "complexity_lvl_mp", "complexityLvlMp").to_i == 1
|
|
74
116
|
|
|
75
117
|
# Auto-filter times based on complexity
|
|
76
118
|
if @auto_filter_times
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitrii Pashutskii
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-03-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|