howlongtobeat 0.2.4 → 0.3.0
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 +38 -0
- data/README.md +29 -2
- data/lib/howlongtobeat/client.rb +178 -0
- data/lib/howlongtobeat/errors.rb +21 -0
- data/lib/howlongtobeat/fields.rb +46 -0
- data/lib/howlongtobeat/game_page_parser.rb +37 -0
- data/lib/howlongtobeat/how_long_to_beat.rb +45 -16
- data/lib/howlongtobeat/html_requests.rb +48 -19
- data/lib/howlongtobeat/http.rb +87 -0
- data/lib/howlongtobeat/records.rb +17 -0
- data/lib/howlongtobeat/search_result_parser.rb +33 -0
- data/lib/howlongtobeat/version.rb +1 -1
- data/lib/howlongtobeat.rb +7 -4
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 460063aabeca602d857275839c6454dd8c0fb8d4eaa17f8e0a986c08d72cb3f3
|
|
4
|
+
data.tar.gz: a45bd306c0fb36cc2006b1bdde32d5b361cce1eb405e8f71ce31ef4aabd32dfa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf147ef1c998ee37ce46a1d676614fa233617555a9095bc89f12f24f6ef2619a2f9340781fc866a500b45c1500c4efcc8a638cd26f3bc02b0a12a3be49c8fb57
|
|
7
|
+
data.tar.gz: 18ff0a91a30dfa9b5d0380935c5d2053d586dfdb253da52a72f40e571f793ee8d7cb9e8075a290ec6e03b24a27c58fce040c8cde0f27391b5bb21559ad3d1c7b
|
data/CHANGELOG.md
CHANGED
|
@@ -25,6 +25,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
25
25
|
### Security
|
|
26
26
|
- None
|
|
27
27
|
|
|
28
|
+
## [0.3.0] - 2026-10-05
|
|
29
|
+
|
|
30
|
+
HowLongToBeat started answering 429 to bursts of requests in late September 2026, which is why this release paces them.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `HowLongToBeat::Client`: a stateful client meant to be kept for the life of a process.
|
|
34
|
+
- `#game(id)` reads a game with one request to its HLTB page. It returns a `GameDetail`: times in hours, release date, aliases and Steam app id.
|
|
35
|
+
- `#search(title)` returns raw `SearchResult` rows; `#search_json(title)` returns the raw body.
|
|
36
|
+
- `HowLongToBeat::Client.default`: a lazily built, process-wide client. The legacy `HowLongToBeat::HowLongToBeat` API uses it unless you pass `client:`, so instances no longer each pay for a full endpoint discovery.
|
|
37
|
+
- Distinct errors: `RateLimitedError` (429), `RequestError` (network, timeout, 5xx, 403, a 404 that is not HLTB's own; `#status` carries the HTTP status) and `ParseError` (unexpected page or response shape, a `/init` response without a token, or a search endpoint that cannot be found).
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- Requests are paced: at least 2 seconds apart by default.
|
|
41
|
+
- The search endpoint is cached for an hour and the `/init` token for 60 seconds, with a 1.2 second pause before the first search with a new token.
|
|
42
|
+
- `HowLongToBeat#search` and `#search_from_id` now run on `Client`. `search_from_id` takes one request instead of about eight.
|
|
43
|
+
- One current, fixed browser User-Agent.
|
|
44
|
+
- Explicit 5-second connect and 10-second read timeouts.
|
|
45
|
+
- `HowLongToBeat#search_from_id` now builds its entry from the game page: it sets id, name, alias, type, web link, release year, the six times and similarity (1.0); image URL, review score, developer, platforms, JSON content and complexity flags stay unset, and `auto_filter_times` no longer applies to it.
|
|
46
|
+
|
|
47
|
+
### Deprecated
|
|
48
|
+
- The public `HTMLRequests.send_web_request` and `HTMLRequests.get_game_title` are unpaced legacy helpers. Use `Client` instead.
|
|
49
|
+
|
|
50
|
+
### Removed
|
|
51
|
+
- After a 429 the client no longer cycles through retired endpoints.
|
|
52
|
+
- The fallback that disabled SSL certificate verification is gone (applies to the new client).
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
- `HowLongToBeat#search` and `#search_from_id` return nil for malformed responses and non-numeric ids instead of raising.
|
|
56
|
+
|
|
57
|
+
## [0.2.5] - 2026-09-04
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- 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).
|
|
61
|
+
- Refresh the hardcoded fallbacks (`SEARCH_URL`, the `fetch_search_token` default, and the candidate list) to put `/api/search/site` first.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
- 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.
|
|
65
|
+
|
|
28
66
|
## [0.2.4] - 2026-05-07
|
|
29
67
|
|
|
30
68
|
### 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
|
|
|
@@ -26,6 +27,30 @@ $ gem install howlongtobeat
|
|
|
26
27
|
|
|
27
28
|
## Usage
|
|
28
29
|
|
|
30
|
+
### Client (recommended)
|
|
31
|
+
|
|
32
|
+
Keep one `Client` per process: use `HowLongToBeat::Client.default`, or build a single instance of your own. It paces requests (2 seconds apart by default) and caches the search endpoint and auth token, so every extra instance pays for its own endpoint discovery. Errors are distinct, so "rate-limited" never looks like "not found". The legacy `HowLongToBeat::HowLongToBeat` API uses `Client.default` unless you pass `client:`.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
client = HowLongToBeat::Client.default
|
|
36
|
+
|
|
37
|
+
detail = client.game(10270) # one request; nil if HLTB's 404 page says there is no such game
|
|
38
|
+
detail.main_story # => 51.68 (hours)
|
|
39
|
+
detail.steam_app_id # => 292030
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
client.search("The Witcher 3") # => [HowLongToBeat::SearchResult, ...]; [] when nothing matches
|
|
43
|
+
rescue HowLongToBeat::RateLimitedError
|
|
44
|
+
# HLTB answered 429: back off before trying again
|
|
45
|
+
rescue HowLongToBeat::RequestError
|
|
46
|
+
# network failure, timeout or an HTTP error: retry later
|
|
47
|
+
rescue HowLongToBeat::ParseError
|
|
48
|
+
# HLTB changed its site, so the gem needs an update
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
HowLongToBeat has no official API and rate-limits bursts. Please keep the default pacing.
|
|
53
|
+
|
|
29
54
|
### Basic Search
|
|
30
55
|
|
|
31
56
|
```ruby
|
|
@@ -35,7 +60,7 @@ hltb = HowLongToBeat::HowLongToBeat.new
|
|
|
35
60
|
results = hltb.search("The Witcher 3")
|
|
36
61
|
```
|
|
37
62
|
|
|
38
|
-
The `search` method returns an array of possible games, or `nil` if
|
|
63
|
+
The `search` method returns an array of possible games (`[]` when nothing matches), or `nil` if the game name is empty or the request failed.
|
|
39
64
|
|
|
40
65
|
Each result is an [`HowLongToBeatEntry`](https://github.com/dpashutskii/howlongtobeat/blob/main/lib/howlongtobeat/how_long_to_beat_entry.rb) object containing:
|
|
41
66
|
|
|
@@ -79,6 +104,8 @@ result = hltb.search_from_id(10270) # The Witcher 3: Wild Hunt
|
|
|
79
104
|
|
|
80
105
|
This returns a single [`HowLongToBeatEntry`](https://github.com/dpashutskii/howlongtobeat/blob/main/lib/howlongtobeat/how_long_to_beat_entry.rb) object or `nil` if not found.
|
|
81
106
|
|
|
107
|
+
Since 0.3.0 `search_from_id` reads the game page in one request, so it fills fewer fields than before (no image URL, review score, developer, platforms, JSON content or complexity flags). See the [CHANGELOG](CHANGELOG.md) for the exact list.
|
|
108
|
+
|
|
82
109
|
### Search Modifiers
|
|
83
110
|
|
|
84
111
|
You can filter your search results using modifiers:
|
|
@@ -115,7 +142,7 @@ This gem was originally created for and is being used by [SearchToPlay](https://
|
|
|
115
142
|
|
|
116
143
|
## Development
|
|
117
144
|
|
|
118
|
-
After checking out the repo, run `bundle install` to install dependencies. Then
|
|
145
|
+
After checking out the repo, run `bundle install` to install dependencies. Then run `bundle exec rspec --tag '~live'` to run the offline tests. `rake spec` runs everything, including the `live` specs that call howlongtobeat.com (paced, about 3 minutes); run those with `bundle exec rspec --tag live` before a release.
|
|
119
146
|
|
|
120
147
|
## Contributing
|
|
121
148
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'nokogiri'
|
|
3
|
+
|
|
4
|
+
module HowLongToBeat
|
|
5
|
+
# Polite, stateful HLTB client. Keep one instance per process (Client.default
|
|
6
|
+
# is one): it remembers the discovered search endpoint and the auth token,
|
|
7
|
+
# and its Http enforces a minimum gap between requests. Safe to share
|
|
8
|
+
# between threads.
|
|
9
|
+
class Client
|
|
10
|
+
BASE_URL = Http::BASE_URL
|
|
11
|
+
|
|
12
|
+
# Tried in order only when no script chunk reveals the search endpoint.
|
|
13
|
+
KNOWN_SEARCH_PATHS = %w[/api/search/site /api/bleed /api/finder /api/search /api/s].freeze
|
|
14
|
+
|
|
15
|
+
# HLTB's own Next.js 404 page embeds this in its __NEXT_DATA__.
|
|
16
|
+
NOT_FOUND_MARKER = '"page":"/404"'.freeze
|
|
17
|
+
|
|
18
|
+
Endpoint = Struct.new(:path, :search_info, :found_at)
|
|
19
|
+
Token = Struct.new(:auth, :issued_at)
|
|
20
|
+
EndpointGone = Class.new(StandardError)
|
|
21
|
+
DEFAULT_LOCK = Mutex.new
|
|
22
|
+
private_constant :Endpoint, :Token, :EndpointGone, :DEFAULT_LOCK
|
|
23
|
+
|
|
24
|
+
# The process-wide client, built on first use. Share it so every caller
|
|
25
|
+
# pays for one endpoint discovery and one paced stream of requests.
|
|
26
|
+
def self.default
|
|
27
|
+
DEFAULT_LOCK.synchronize { @default ||= new }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(http: Http.new, endpoint_ttl: 3600, token_ttl: 60, token_warmup: 1.2,
|
|
31
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
32
|
+
sleeper: ->(seconds) { sleep(seconds) })
|
|
33
|
+
@http = http
|
|
34
|
+
@endpoint_ttl = endpoint_ttl
|
|
35
|
+
@token_ttl = token_ttl
|
|
36
|
+
@token_warmup = token_warmup
|
|
37
|
+
@clock = clock
|
|
38
|
+
@sleeper = sleeper
|
|
39
|
+
@endpoint = nil
|
|
40
|
+
@token = nil
|
|
41
|
+
@lock = Mutex.new # guards @endpoint and @token; never held during the search POST
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One GET of the game page. nil only when HLTB's own 404 page says there
|
|
45
|
+
# is no such game; any other 404 (a CDN or proxy page) is a RequestError.
|
|
46
|
+
def game(id)
|
|
47
|
+
response = @http.get("#{BASE_URL}/game/#{Integer(id.to_s, 10)}")
|
|
48
|
+
if response.code == 404
|
|
49
|
+
return nil if response.body.include?(NOT_FOUND_MARKER)
|
|
50
|
+
|
|
51
|
+
raise RequestError.new("HLTB returned an unrecognised 404 for /game/#{id}", status: 404)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
GamePageParser.parse(response.body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raw candidates for a title. [] is a genuine "not on HLTB".
|
|
58
|
+
def search(title, modifier: HTMLRequests::SearchModifiers::NONE)
|
|
59
|
+
SearchResultParser.parse(search_json(title, modifier: modifier))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The raw search response body (the legacy API parses it itself).
|
|
63
|
+
def search_json(title, modifier: HTMLRequests::SearchModifiers::NONE)
|
|
64
|
+
post_search(title, modifier)
|
|
65
|
+
rescue EndpointGone
|
|
66
|
+
forget_endpoint!
|
|
67
|
+
begin
|
|
68
|
+
post_search(title, modifier)
|
|
69
|
+
rescue EndpointGone
|
|
70
|
+
raise ParseError, 'HLTB search endpoint returns 404 even after rediscovery'
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def post_search(title, modifier)
|
|
77
|
+
endpoint, auth = @lock.synchronize do
|
|
78
|
+
current = current_endpoint
|
|
79
|
+
[current, current_token(current)]
|
|
80
|
+
end
|
|
81
|
+
headers = HTMLRequests.get_search_request_headers(auth)
|
|
82
|
+
body = HTMLRequests.get_search_request_data(title, modifier, 1, endpoint.search_info, auth)
|
|
83
|
+
response = @http.post("#{BASE_URL}#{endpoint.path}", headers, body)
|
|
84
|
+
raise EndpointGone if response.code == 404
|
|
85
|
+
|
|
86
|
+
response.body
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Callers hold @lock, so the ivars are read once and updated without a race.
|
|
90
|
+
def current_endpoint
|
|
91
|
+
endpoint = @endpoint
|
|
92
|
+
return endpoint if endpoint && @clock.call - endpoint.found_at < @endpoint_ttl
|
|
93
|
+
|
|
94
|
+
@token = nil
|
|
95
|
+
@endpoint = discover
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def forget_endpoint!
|
|
99
|
+
@lock.synchronize do
|
|
100
|
+
@endpoint = nil
|
|
101
|
+
@token = nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def current_token(endpoint)
|
|
106
|
+
cached = @token
|
|
107
|
+
return cached.auth if cached && @clock.call - cached.issued_at < @token_ttl
|
|
108
|
+
|
|
109
|
+
response = @http.get(init_url(endpoint.path))
|
|
110
|
+
raise EndpointGone if response.code == 404
|
|
111
|
+
|
|
112
|
+
store_token(response.body)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Parses an /init body, caches the token, and gives HLTB a moment to
|
|
116
|
+
# accept it before the first search.
|
|
117
|
+
def store_token(body)
|
|
118
|
+
@token = fresh = Token.new(parse_auth(body), @clock.call)
|
|
119
|
+
@sleeper.call(@token_warmup)
|
|
120
|
+
fresh.auth
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def init_url(path)
|
|
124
|
+
"#{BASE_URL}#{path}/init?t=#{Time.now.to_i}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_auth(body)
|
|
128
|
+
json = JSON.parse(body.to_s)
|
|
129
|
+
raise ParseError, 'HLTB /init response is not an object' unless json.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
token = json['token'] || json.dig('data', 'token') || json['auth_token'] || json['authToken']
|
|
132
|
+
raise ParseError, 'HLTB /init response has no token' if token.to_s.empty?
|
|
133
|
+
|
|
134
|
+
key = json.find { |name, _| name.downcase.include?('key') }&.last
|
|
135
|
+
value = json.find { |name, _| name.downcase.include?('val') }&.last
|
|
136
|
+
HTMLRequests::AuthStruct.new(token, key, value)
|
|
137
|
+
rescue JSON::ParserError => e
|
|
138
|
+
raise ParseError, "HLTB /init response is not JSON: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The search call is the POST fetch in HLTB's JS bundle that sends
|
|
142
|
+
# x-auth-token. Chunk names are opaque, so walk them in page order.
|
|
143
|
+
def discover
|
|
144
|
+
homepage = @http.get(BASE_URL)
|
|
145
|
+
raise RequestError, 'HLTB homepage returned 404' if homepage.code == 404
|
|
146
|
+
|
|
147
|
+
Nokogiri::HTML(homepage.body).css('script[src]').each do |script|
|
|
148
|
+
info = search_info_from(script['src'])
|
|
149
|
+
return Endpoint.new("/#{info.search_url}", info, @clock.call) if info
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
fallback_endpoint
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def search_info_from(src)
|
|
156
|
+
url = src.start_with?('http') ? src : "#{BASE_URL}#{src}"
|
|
157
|
+
response = @http.get(url)
|
|
158
|
+
return nil unless response.code == 200
|
|
159
|
+
|
|
160
|
+
info = HTMLRequests::SearchInfo.new(response.body)
|
|
161
|
+
info if info.authenticated? && !info.search_url.to_s.empty?
|
|
162
|
+
rescue RequestError
|
|
163
|
+
nil # a redirected or broken chunk (e.g. the consent script) is not fatal
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fallback_endpoint
|
|
167
|
+
KNOWN_SEARCH_PATHS.each do |path|
|
|
168
|
+
response = @http.get(init_url(path))
|
|
169
|
+
next unless response.code == 200
|
|
170
|
+
|
|
171
|
+
store_token(response.body) # the probe already fetched /init; don't fetch it again
|
|
172
|
+
return Endpoint.new(path, nil, @clock.call)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
raise ParseError, 'Could not discover the HLTB search endpoint'
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module HowLongToBeat
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# HLTB answered 429. Back off; don't retry right away.
|
|
5
|
+
class RateLimitedError < Error; end
|
|
6
|
+
|
|
7
|
+
# Network failure, timeout, or an HTTP status that means "try later"
|
|
8
|
+
# (5xx, 403, unexpected redirects). Never means "not found".
|
|
9
|
+
# `status` is the HTTP status when there was one, nil for network failures.
|
|
10
|
+
class RequestError < Error
|
|
11
|
+
attr_reader :status
|
|
12
|
+
|
|
13
|
+
def initialize(message = nil, status: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# HLTB answered, but not in the shape we know. Usually a site change.
|
|
20
|
+
class ParseError < Error; end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
|
|
3
|
+
module HowLongToBeat
|
|
4
|
+
# Conversions shared by the game-page and search parsers.
|
|
5
|
+
module Fields
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# HLTB reports times in seconds; 0 means "no data".
|
|
9
|
+
def hours(seconds)
|
|
10
|
+
value = seconds.to_f
|
|
11
|
+
value.positive? ? (value / 3600.0).round(2) : nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def aliases(value)
|
|
15
|
+
value.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def positive_int(value)
|
|
19
|
+
number = value.to_i
|
|
20
|
+
number.positive? ? number : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Game pages send "2015-05-19"; search rows send 2015.
|
|
24
|
+
def release_date(value)
|
|
25
|
+
Date.iso8601(value.to_s) if value.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
26
|
+
rescue Date::Error
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def release_year(value)
|
|
31
|
+
year = value.to_s[/\A\d{4}/].to_i
|
|
32
|
+
year.positive? ? year : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def times(game)
|
|
36
|
+
{
|
|
37
|
+
main_story: hours(game['comp_main']),
|
|
38
|
+
main_extra: hours(game['comp_plus']),
|
|
39
|
+
completionist: hours(game['comp_100']),
|
|
40
|
+
all_styles: hours(game['comp_all']),
|
|
41
|
+
coop: hours(game['invested_co']),
|
|
42
|
+
multiplayer: hours(game['invested_mp'])
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module HowLongToBeat
|
|
4
|
+
# Reads the record Next.js embeds in every /game/<id> page, so refreshing
|
|
5
|
+
# a known game takes one GET and no search or auth token.
|
|
6
|
+
module GamePageParser
|
|
7
|
+
NEXT_DATA = %r{<script[^>]*\bid="__NEXT_DATA__"[^>]*>(.*?)</script>}m
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def parse(html)
|
|
12
|
+
encoded_html = html.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
13
|
+
json = encoded_html[NEXT_DATA, 1]
|
|
14
|
+
raise ParseError, 'HLTB game page has no __NEXT_DATA__ script' unless json
|
|
15
|
+
|
|
16
|
+
data = JSON.parse(json)
|
|
17
|
+
game = data.dig('props', 'pageProps', 'game', 'data', 'game')
|
|
18
|
+
game = game.first if game.is_a?(Array)
|
|
19
|
+
raise ParseError, 'HLTB game page has no game record' unless game.is_a?(Hash) && game['game_id']
|
|
20
|
+
|
|
21
|
+
GameDetail.new(
|
|
22
|
+
id: game['game_id'].to_i,
|
|
23
|
+
name: game['game_name'],
|
|
24
|
+
aliases: Fields.aliases(game['game_alias']),
|
|
25
|
+
release_date: Fields.release_date(game['release_world']),
|
|
26
|
+
release_year: Fields.release_year(game['release_world']),
|
|
27
|
+
steam_app_id: Fields.positive_int(game['profile_steam']),
|
|
28
|
+
game_type: game['game_type'],
|
|
29
|
+
**Fields.times(game)
|
|
30
|
+
)
|
|
31
|
+
rescue JSON::ParserError => e
|
|
32
|
+
raise ParseError, "HLTB game page JSON is invalid: #{e.message}"
|
|
33
|
+
rescue TypeError, NoMethodError => e
|
|
34
|
+
raise ParseError, "HLTB game page has unexpected structure: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -1,37 +1,66 @@
|
|
|
1
1
|
module HowLongToBeat
|
|
2
|
+
# The original API, kept for existing callers. It now runs on Client, so
|
|
3
|
+
# it shares the pacing, caching and error handling; any failure still
|
|
4
|
+
# returns nil, as before.
|
|
5
|
+
#
|
|
6
|
+
# Note: search_from_id now builds entries from the game page only. It sets
|
|
7
|
+
# id, name, alias, type, web link, release year, and the six time fields
|
|
8
|
+
# (main_story, main_extra, completionist, all_styles, coop_time, mp_time)
|
|
9
|
+
# plus similarity (1.0). Image URL, review score, developer, platforms,
|
|
10
|
+
# JSON content, and complexity flags stay unset. auto_filter_times does
|
|
11
|
+
# not apply to search_from_id results.
|
|
2
12
|
class HowLongToBeat
|
|
3
|
-
def initialize(input_minimum_similarity = 0.4, input_auto_filter_times = false)
|
|
13
|
+
def initialize(input_minimum_similarity = 0.4, input_auto_filter_times = false, client: Client.default)
|
|
4
14
|
@minimum_similarity = input_minimum_similarity
|
|
5
15
|
@auto_filter_times = input_auto_filter_times
|
|
16
|
+
@client = client
|
|
6
17
|
end
|
|
7
18
|
|
|
8
19
|
def search(game_name, search_modifiers = HTMLRequests::SearchModifiers::NONE,
|
|
9
|
-
|
|
20
|
+
similarity_case_sensitive = true)
|
|
10
21
|
return nil if game_name.nil? || game_name.empty?
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
json = @client.search_json(game_name, modifier: search_modifiers)
|
|
24
|
+
# Validate the shape first; SearchResultParser raises ParseError for any
|
|
25
|
+
# malformed response, which rescue Error below will turn into nil.
|
|
26
|
+
SearchResultParser.parse(json)
|
|
27
|
+
parse_web_result(game_name, json, nil, similarity_case_sensitive)
|
|
28
|
+
rescue Error
|
|
29
|
+
nil
|
|
16
30
|
end
|
|
17
31
|
|
|
18
32
|
def search_from_id(game_id)
|
|
19
33
|
return nil if game_id.nil? || game_id == 0
|
|
34
|
+
# Validate the ID is numeric before calling the client; game_id is expected to be
|
|
35
|
+
# an integer, but Integer() will coerce strings like "123" and raise ArgumentError on "abc"
|
|
36
|
+
return nil unless game_id.is_a?(Integer) || (game_id.is_a?(String) && game_id.match?(/\A\d+\z/))
|
|
20
37
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return nil unless html_result
|
|
26
|
-
|
|
27
|
-
result_list = parse_web_result(game_title, html_result, game_id)
|
|
28
|
-
return nil unless result_list && result_list.size == 1
|
|
29
|
-
|
|
30
|
-
result_list.first
|
|
38
|
+
detail = @client.game(game_id)
|
|
39
|
+
detail && entry_from(detail)
|
|
40
|
+
rescue Error, ArgumentError
|
|
41
|
+
nil
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
private
|
|
34
45
|
|
|
46
|
+
def entry_from(detail)
|
|
47
|
+
HowLongToBeatEntry.new.tap do |entry|
|
|
48
|
+
entry.game_id = detail.id
|
|
49
|
+
entry.game_name = detail.name
|
|
50
|
+
entry.game_alias = detail.aliases.join(', ')
|
|
51
|
+
entry.game_type = detail.game_type
|
|
52
|
+
entry.game_web_link = "#{JSONResultParser::GAME_URL_PREFIX}#{detail.id}"
|
|
53
|
+
entry.release_world = detail.release_year
|
|
54
|
+
entry.main_story = detail.main_story
|
|
55
|
+
entry.main_extra = detail.main_extra
|
|
56
|
+
entry.completionist = detail.completionist
|
|
57
|
+
entry.all_styles = detail.all_styles
|
|
58
|
+
entry.coop_time = detail.coop
|
|
59
|
+
entry.mp_time = detail.multiplayer
|
|
60
|
+
entry.similarity = 1.0
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
35
64
|
def parse_web_result(game_name, html_result, game_id = nil, similarity_case_sensitive = true)
|
|
36
65
|
parser = JSONResultParser.new(
|
|
37
66
|
game_name,
|
|
@@ -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
|
|
@@ -310,7 +339,7 @@ module HowLongToBeat
|
|
|
310
339
|
end
|
|
311
340
|
|
|
312
341
|
def random_user_agent
|
|
313
|
-
|
|
342
|
+
Http::USER_AGENT
|
|
314
343
|
end
|
|
315
344
|
end
|
|
316
345
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'openssl'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
6
|
+
module HowLongToBeat
|
|
7
|
+
# Paced HTTP transport for HLTB. Every request waits until at least
|
|
8
|
+
# `min_interval` seconds have passed since the previous one, however the
|
|
9
|
+
# caller loops. 2xx and 404 come back as a Response; 429 raises
|
|
10
|
+
# RateLimitedError; anything else raises RequestError, so a failure can
|
|
11
|
+
# never look like "not found".
|
|
12
|
+
class Http
|
|
13
|
+
Response = Struct.new(:code, :body)
|
|
14
|
+
|
|
15
|
+
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' \
|
|
16
|
+
'(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
|
|
17
|
+
BASE_URL = 'https://howlongtobeat.com'
|
|
18
|
+
|
|
19
|
+
NETWORK_ERRORS = [
|
|
20
|
+
Timeout::Error, IOError, EOFError, SocketError, SystemCallError,
|
|
21
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(min_interval: 2.0, open_timeout: 5, read_timeout: 10,
|
|
25
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
26
|
+
sleeper: ->(seconds) { sleep(seconds) })
|
|
27
|
+
@min_interval = min_interval
|
|
28
|
+
@open_timeout = open_timeout
|
|
29
|
+
@read_timeout = read_timeout
|
|
30
|
+
@clock = clock
|
|
31
|
+
@sleeper = sleeper
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@last_request_at = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get(url, headers = {})
|
|
37
|
+
uri = URI(url)
|
|
38
|
+
perform(uri, Net::HTTP::Get.new(uri.request_uri, default_headers.merge(headers)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def post(url, headers, body)
|
|
42
|
+
uri = URI(url)
|
|
43
|
+
request = Net::HTTP::Post.new(uri.request_uri, default_headers.merge(headers))
|
|
44
|
+
request.body = body
|
|
45
|
+
perform(uri, request)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def perform(uri, request)
|
|
51
|
+
pace!
|
|
52
|
+
# Disable Net::HTTP's silent retry (max_retries: 1 by default) so every request,
|
|
53
|
+
# including automatic retries after transient errors, goes through pace!.
|
|
54
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
55
|
+
max_retries: 0,
|
|
56
|
+
open_timeout: @open_timeout,
|
|
57
|
+
read_timeout: @read_timeout) do |connection|
|
|
58
|
+
connection.request(request)
|
|
59
|
+
end
|
|
60
|
+
to_response(uri, response)
|
|
61
|
+
rescue *NETWORK_ERRORS => e
|
|
62
|
+
raise RequestError, "#{request.method} #{uri.path} failed: #{e.class}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_response(uri, response)
|
|
66
|
+
code = response.code.to_i
|
|
67
|
+
raise RateLimitedError, "HLTB rate limited #{uri.path} (429)" if code == 429
|
|
68
|
+
return Response.new(code, response.body.to_s) if (200..299).cover?(code) || code == 404
|
|
69
|
+
|
|
70
|
+
raise RequestError.new("HLTB returned #{code} for #{uri.path}", status: code)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pace!
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
if @last_request_at
|
|
76
|
+
wait = @min_interval - (@clock.call - @last_request_at)
|
|
77
|
+
@sleeper.call(wait) if wait.positive?
|
|
78
|
+
end
|
|
79
|
+
@last_request_at = @clock.call
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def default_headers
|
|
84
|
+
{ 'User-Agent' => USER_AGENT, 'Referer' => BASE_URL, 'Origin' => BASE_URL, 'Accept' => '*/*' }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module HowLongToBeat
|
|
2
|
+
# A game as its /game/<id> page describes it. Times are hours, nil when
|
|
3
|
+
# HLTB has no data.
|
|
4
|
+
GameDetail = Struct.new(
|
|
5
|
+
:id, :name, :aliases, :release_date, :release_year, :steam_app_id, :game_type,
|
|
6
|
+
:main_story, :main_extra, :completionist, :all_styles, :coop, :multiplayer,
|
|
7
|
+
keyword_init: true
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# One row of a title search. Search rows carry a release year, not a date,
|
|
11
|
+
# and no Steam id; fetch the game page for those.
|
|
12
|
+
SearchResult = Struct.new(
|
|
13
|
+
:id, :name, :aliases, :release_year, :game_type,
|
|
14
|
+
:main_story, :main_extra, :completionist, :all_styles, :coop, :multiplayer,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
)
|
|
17
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module HowLongToBeat
|
|
4
|
+
# Turns the search API response into SearchResult rows. An empty `data`
|
|
5
|
+
# array is a genuine "not on HLTB"; any other shape is a ParseError.
|
|
6
|
+
module SearchResultParser
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def parse(json)
|
|
10
|
+
payload = JSON.parse(json.to_s.dup.force_encoding(Encoding::UTF_8).scrub)
|
|
11
|
+
games = payload['data'] if payload.is_a?(Hash)
|
|
12
|
+
raise ParseError, 'HLTB search response has no data array' unless games.is_a?(Array)
|
|
13
|
+
|
|
14
|
+
games.map do |game|
|
|
15
|
+
raise ParseError, 'HLTB search response game row is not a dict' unless game.is_a?(Hash)
|
|
16
|
+
raise ParseError, 'HLTB search response game row has no game_id' unless game['game_id']
|
|
17
|
+
|
|
18
|
+
SearchResult.new(
|
|
19
|
+
id: game['game_id'].to_i,
|
|
20
|
+
name: game['game_name'],
|
|
21
|
+
aliases: Fields.aliases(game['game_alias']),
|
|
22
|
+
release_year: Fields.release_year(game['release_world']),
|
|
23
|
+
game_type: game['game_type'],
|
|
24
|
+
**Fields.times(game)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
rescue JSON::ParserError => e
|
|
28
|
+
raise ParseError, "HLTB search response is not JSON: #{e.message}"
|
|
29
|
+
rescue NoMethodError, TypeError => e
|
|
30
|
+
raise ParseError, "HLTB search response has unexpected structure: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/howlongtobeat.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
require_relative 'howlongtobeat/version'
|
|
2
|
+
require_relative 'howlongtobeat/errors'
|
|
3
|
+
require_relative 'howlongtobeat/http'
|
|
4
|
+
require_relative 'howlongtobeat/records'
|
|
5
|
+
require_relative 'howlongtobeat/fields'
|
|
6
|
+
require_relative 'howlongtobeat/game_page_parser'
|
|
7
|
+
require_relative 'howlongtobeat/search_result_parser'
|
|
2
8
|
require_relative 'howlongtobeat/how_long_to_beat'
|
|
3
9
|
require_relative 'howlongtobeat/how_long_to_beat_entry'
|
|
4
10
|
require_relative 'howlongtobeat/html_requests'
|
|
5
11
|
require_relative 'howlongtobeat/json_result_parser'
|
|
6
|
-
|
|
7
|
-
module HowLongToBeat
|
|
8
|
-
class Error < StandardError; end
|
|
9
|
-
end
|
|
12
|
+
require_relative 'howlongtobeat/client'
|
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.
|
|
4
|
+
version: 0.3.0
|
|
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-10-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -80,6 +80,20 @@ dependencies:
|
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '3.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: webmock
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.23'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.23'
|
|
83
97
|
description: A simple Ruby gem to fetch game completion times from HowLongToBeat.com
|
|
84
98
|
email:
|
|
85
99
|
- dpashutskii@gmail.com
|
|
@@ -91,10 +105,17 @@ files:
|
|
|
91
105
|
- CODE_OF_CONDUCT.md
|
|
92
106
|
- README.md
|
|
93
107
|
- lib/howlongtobeat.rb
|
|
108
|
+
- lib/howlongtobeat/client.rb
|
|
109
|
+
- lib/howlongtobeat/errors.rb
|
|
110
|
+
- lib/howlongtobeat/fields.rb
|
|
111
|
+
- lib/howlongtobeat/game_page_parser.rb
|
|
94
112
|
- lib/howlongtobeat/how_long_to_beat.rb
|
|
95
113
|
- lib/howlongtobeat/how_long_to_beat_entry.rb
|
|
96
114
|
- lib/howlongtobeat/html_requests.rb
|
|
115
|
+
- lib/howlongtobeat/http.rb
|
|
97
116
|
- lib/howlongtobeat/json_result_parser.rb
|
|
117
|
+
- lib/howlongtobeat/records.rb
|
|
118
|
+
- lib/howlongtobeat/search_result_parser.rb
|
|
98
119
|
- lib/howlongtobeat/version.rb
|
|
99
120
|
homepage: https://github.com/dpashutskii/howlongtobeat
|
|
100
121
|
licenses:
|