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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30968d273638c5687a8f004d3af466ba21fe5c1ffb9a901b8d46bb7e0bfe9c57
4
- data.tar.gz: 70cfdf898cc6adc10f3916822e4dc9b6f64db824fb45be348dd2185825c0d292
3
+ metadata.gz: 9c202be25ee5614c52d8334494939e9dbfb220a74e6f1fb1791b927bbf4ad1ba
4
+ data.tar.gz: '00268aa34aa51cd06ce4d0484f7bc380218e52ae881f5b967c03204379c48080'
5
5
  SHA512:
6
- metadata.gz: 26add34fc6bc60313c3c85cd37de8f3f3e3fb116b1fd841104311080d7d2ccdbab4afbd528b85cbfc17a4707830741ba532a7d735c3708258eb710c670b35a21
7
- data.tar.gz: d29ee7ade48aa6984af6eaecdad80242d464aa9d5c1ce659e1508ad3810cccbf9608db188ea16f1c721c8ed8093f884037b93ab6ef2b105912bc15109cfc3dcb
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/search"
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
- pattern = /fetch\(\s*["'](\/api\/[^"']*)['"]((?:\s*\.concat\(\s*["']([^"']*)['"]\s*\))+)\s*,/
51
- if (matches = script_content.match(pattern))
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
- token = fetch_search_token
121
- return nil unless token
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
- headers = get_search_request_headers.merge('x-auth-token' => token)
124
- payload = get_search_request_data(game_name, search_modifiers, page)
125
- make_request(SEARCH_URL, headers, payload)
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
- url = "#{BASE_URL}/api/search/init?t=#{Time.now.to_i}"
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) ? json['token'] : nil
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 => e
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 => e
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 => e
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 => e
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
- response_result["data"].each do |game|
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["game_id"]
50
- current_entry.game_name = input_game_element["game_name"]
51
- current_entry.game_alias = input_game_element["game_alias"]
52
- current_entry.game_type = input_game_element["game_type"]
53
- current_entry.game_image_url = "#{IMAGE_URL_PREFIX}#{input_game_element['game_image']}" if input_game_element["game_image"]
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["review_score"]
56
- current_entry.profile_dev = input_game_element["profile_dev"]
57
- current_entry.profile_platforms = input_game_element["profile_platform"]&.split(", ")
58
- current_entry.release_world = input_game_element["release_world"]
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 = round_time(input_game_element["comp_main"])
63
- current_entry.main_extra = round_time(input_game_element["comp_plus"])
64
- current_entry.completionist = round_time(input_game_element["comp_100"])
65
- current_entry.all_styles = round_time(input_game_element["comp_all"])
66
- current_entry.coop_time = round_time(input_game_element["invested_co"])
67
- current_entry.mp_time = round_time(input_game_element["invested_mp"])
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["comp_lvl_combine"].to_i == 1
71
- current_entry.complexity_lvl_sp = input_game_element["comp_lvl_sp"].to_i == 1
72
- current_entry.complexity_lvl_co = input_game_element["comp_lvl_co"].to_i == 1
73
- current_entry.complexity_lvl_mp = input_game_element["comp_lvl_mp"].to_i == 1
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
@@ -1,3 +1,3 @@
1
1
  module HowLongToBeat
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.2"
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.1
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: 2025-12-13 00:00:00.000000000 Z
11
+ date: 2026-03-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri