howlongtobeat 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ef5a11f2d4eb82426ed1c0e6312a824381798af62a77425fa3bd39671a9d7080
4
+ data.tar.gz: b16931671e18bfd16a02a4072dd124a089549fa9dbd0af7bcddb76e693bbe388
5
+ SHA512:
6
+ metadata.gz: b39fcf8b55b15bac316188010468528576b2c0abbec0ca69c9a29eed9fa3be32cfc38fdfb5c03425705522a9807f87da76170821cb0adbb7eb2c0bca0835e8cc
7
+ data.tar.gz: 77bfcbc196915c9c253db36313ed97ed404d627d14232fd2ca1439307d46a367b7dc8a29865d6deff8a68bf2b4662bc720487dfd41e72958a7f317231f82475d
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - Initial release
12
+ - Basic search functionality
13
+ - Search by ID functionality
14
+ - Search modifiers support
15
+ - Similarity filtering
16
+
17
+ ### Changed
18
+ - None
19
+
20
+ ### Deprecated
21
+ - None
22
+
23
+ ### Removed
24
+ - None
25
+
26
+ ### Fixed
27
+ - None
28
+
29
+ ### Security
30
+ - None
31
+
32
+ ## [0.1.0] - 2025-03-06
33
+
34
+ - Initial release
35
+
36
+ ## [0.1.2] - 2025-03-06
37
+
38
+ - Update Ruby requirement version
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # HowLongToBeat Ruby API
2
+
3
+ A simple Ruby API to read data from howlongtobeat.com.
4
+
5
+ It is inspired by [ScrappyCocco's HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI).
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'howlongtobeat'
13
+ ```
14
+
15
+ And then execute:
16
+ ```bash
17
+ $ bundle install
18
+ ```
19
+
20
+ Or install it yourself as:
21
+ ```bash
22
+ $ gem install howlongtobeat
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Basic Search
28
+
29
+ ```ruby
30
+ require 'howlongtobeat'
31
+
32
+ hltb = HowLongToBeat::HowLongToBeat.new
33
+ results = hltb.search("The Witcher 3")
34
+ ```
35
+
36
+ The `search` method returns an array of possible games, or `nil` if no results were found or there was an error in the request.
37
+
38
+ Each result is a `HowLongToBeat::Entry` object containing:
39
+ - `id`: The game's ID on HowLongToBeat
40
+ - `name`: The game's name
41
+ - `description`: Game description
42
+ - `main_story`: Main story completion time
43
+ - `main_plus_sides`: Main story + side quests completion time
44
+ - `completionist`: 100% completion time
45
+ - `all_styles`: Average time across all playstyles
46
+ - `similarity`: How closely the game name matches the search query (0.0 to 1.0)
47
+
48
+ ### Search by ID
49
+
50
+ You can also search for a game using its HowLongToBeat ID:
51
+
52
+ ```ruby
53
+ result = hltb.search_from_id(10270) # The Witcher 3: Wild Hunt
54
+ ```
55
+
56
+ This returns a single `Entry` object or `nil` if not found.
57
+
58
+ ### Search Modifiers
59
+
60
+ You can filter your search results using modifiers:
61
+
62
+ ```ruby
63
+ hltb = HowLongToBeat::HowLongToBeat.new
64
+ results = hltb.search("The Witcher 3", HowLongToBeat::HTMLRequests::SearchModifiers::HIDE_DLC)
65
+ ```
66
+
67
+ Available modifiers:
68
+ - `NONE`: Default search (includes DLCs)
69
+ - `ISOLATE_DLC`: Show only DLCs
70
+ - `ISOLATE_MODS`: Show only mods
71
+ - `ISOLATE_HACKS`: Show only hacks
72
+ - `HIDE_DLC`: Hide DLCs and show only games
73
+
74
+ ### Similarity Filtering
75
+
76
+ By default, the search filters results with a similarity score greater than 0.4. You can adjust this threshold:
77
+
78
+ ```ruby
79
+ # Return all results without filtering
80
+ hltb = HowLongToBeat::HowLongToBeat.new(0.0)
81
+ results = hltb.search("The Witcher 3")
82
+
83
+ # Use a higher threshold for stricter matching
84
+ hltb = HowLongToBeat::HowLongToBeat.new(0.7)
85
+ results = hltb.search("The Witcher 3")
86
+ ```
87
+
88
+ ## Development
89
+
90
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests.
91
+
92
+ ## Contributing
93
+
94
+ Bug reports and pull requests are welcome on GitHub.
95
+
96
+ ## License
97
+
98
+ The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,49 @@
1
+ module HowLongToBeat
2
+ class HowLongToBeat
3
+ def initialize(input_minimum_similarity = 0.4, input_auto_filter_times = false)
4
+ @minimum_similarity = input_minimum_similarity
5
+ @auto_filter_times = input_auto_filter_times
6
+ end
7
+
8
+ def search(game_name, search_modifiers = HTMLRequests::SearchModifiers::NONE,
9
+ similarity_case_sensitive = true)
10
+ return nil if game_name.nil? || game_name.empty?
11
+
12
+ html_result = HTMLRequests.send_web_request(game_name, search_modifiers)
13
+ return nil unless html_result
14
+
15
+ parse_web_result(game_name, html_result, nil, similarity_case_sensitive)
16
+ end
17
+
18
+ def search_from_id(game_id)
19
+ return nil if game_id.nil? || game_id == 0
20
+
21
+ game_title = HTMLRequests.get_game_title(game_id)
22
+ return nil unless game_title
23
+
24
+ html_result = HTMLRequests.send_web_request(game_title)
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
31
+ end
32
+
33
+ private
34
+
35
+ def parse_web_result(game_name, html_result, game_id = nil, similarity_case_sensitive = true)
36
+ parser = JSONResultParser.new(
37
+ game_name,
38
+ HTMLRequests::GAME_URL,
39
+ @minimum_similarity,
40
+ game_id,
41
+ similarity_case_sensitive,
42
+ @auto_filter_times
43
+ )
44
+
45
+ parser.parse_json_result(html_result)
46
+ parser.results
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,58 @@
1
+ module HowLongToBeat
2
+ class HowLongToBeatEntry
3
+ # Base Game Details
4
+ attr_accessor :game_id, :game_name, :game_alias, :game_type, :game_image_url,
5
+ :game_web_link, :review_score, :profile_dev, :profile_platforms,
6
+ :release_world, :similarity, :json_content
7
+
8
+ # Completion times
9
+ attr_accessor :main_story, :main_extra, :completionist, :all_styles,
10
+ :coop_time, :mp_time
11
+
12
+ # Complexity flags
13
+ attr_accessor :complexity_lvl_combine, :complexity_lvl_sp,
14
+ :complexity_lvl_co, :complexity_lvl_mp
15
+
16
+ def initialize
17
+ # Initialize with default values
18
+ @game_id = -1
19
+ @game_name = nil
20
+ @game_alias = nil
21
+ @game_type = nil
22
+ @game_image_url = nil
23
+ @game_web_link = nil
24
+ @review_score = nil
25
+ @profile_dev = nil
26
+ @profile_platforms = nil
27
+ @release_world = nil
28
+ @similarity = -1
29
+ @json_content = nil
30
+
31
+ # Completion times
32
+ @main_story = nil
33
+ @main_extra = nil
34
+ @completionist = nil
35
+ @all_styles = nil
36
+ @coop_time = nil
37
+ @mp_time = nil
38
+
39
+ # Complexity flags
40
+ @complexity_lvl_combine = false
41
+ @complexity_lvl_sp = false
42
+ @complexity_lvl_co = false
43
+ @complexity_lvl_mp = false
44
+ end
45
+
46
+ def to_s
47
+ times = []
48
+ times << "Main Story: #{main_story}h" if main_story
49
+ times << "Main + Extra: #{main_extra}h" if main_extra
50
+ times << "Completionist: #{completionist}h" if completionist
51
+ times << "All Styles: #{all_styles}h" if all_styles
52
+ times << "Co-op: #{coop_time}h" if coop_time
53
+ times << "Multiplayer: #{mp_time}h" if mp_time
54
+
55
+ "#{game_name} (ID: #{game_id}) - #{times.join(', ')}"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,224 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'nokogiri'
5
+
6
+ module HowLongToBeat
7
+ class HTMLRequests
8
+ BASE_URL = 'https://howlongtobeat.com'
9
+ REFERER_HEADER = BASE_URL
10
+ GAME_URL = "#{BASE_URL}/game"
11
+ SEARCH_URL = "#{BASE_URL}/api/search"
12
+
13
+ class SearchModifiers
14
+ NONE = ""
15
+ ISOLATE_DLC = "only_dlc"
16
+ ISOLATE_MODS = "only_mods"
17
+ ISOLATE_HACKS = "only_hacks"
18
+ HIDE_DLC = "hide_dlc"
19
+ end
20
+
21
+ class SearchInfo
22
+ attr_accessor :search_url, :api_key
23
+
24
+ def initialize(script_content)
25
+ @api_key = extract_api_from_script(script_content)
26
+ @search_url = extract_search_url_script(script_content)
27
+ @search_url = @search_url&.strip&.gsub(/^\/+|\/+$/, '')
28
+ end
29
+
30
+ private
31
+
32
+ def extract_api_from_script(script_content)
33
+ if (matches = script_content.scan(/users\s*:\s*{\s*id\s*:\s*"([^"]+)"/))
34
+ key = matches.flatten.first
35
+ return key if key && !key.empty?
36
+ end
37
+
38
+ if (matches = script_content.scan(/\/api\/\w+\/"(?:\.concat\("[^"]*"\))*/))
39
+ matches_str = matches.to_s
40
+ concat_parts = matches_str.split('.concat')[1..]
41
+ concat_parts = concat_parts.map { |part| part.gsub(/["\(\)\[\]'\\]/, '') }
42
+ key = concat_parts.join
43
+ return key if key && !key.empty?
44
+ end
45
+
46
+ nil
47
+ end
48
+
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))
52
+ endpoint = matches[1]
53
+ concat_calls = matches[2]
54
+ concat_strings = concat_calls.scan(/\.concat\(\s*["']([^"']*)['"]\s*\)/).flatten
55
+ concatenated_str = concat_strings.join
56
+ concatenated_str = concatenated_str.gsub(/["\(\)\[\]'\\]/, '')
57
+ return endpoint if concatenated_str == @api_key
58
+ end
59
+ nil
60
+ end
61
+ end
62
+
63
+ class << self
64
+ def get_search_request_headers
65
+ {
66
+ 'content-type' => 'application/json',
67
+ 'accept' => '*/*',
68
+ 'User-Agent' => random_user_agent,
69
+ 'referer' => REFERER_HEADER
70
+ }
71
+ end
72
+
73
+ def get_search_request_data(game_name, search_modifiers = SearchModifiers::NONE, page = 1, search_info = nil)
74
+ payload = {
75
+ searchType: 'games',
76
+ searchTerms: game_name.split,
77
+ searchPage: page,
78
+ size: 20,
79
+ searchOptions: {
80
+ games: {
81
+ userId: 0,
82
+ platform: '',
83
+ sortCategory: 'popular',
84
+ rangeCategory: 'main',
85
+ rangeTime: { min: 0, max: 0 },
86
+ gameplay: {
87
+ perspective: '',
88
+ flow: '',
89
+ genre: '',
90
+ difficulty: ''
91
+ },
92
+ rangeYear: {
93
+ max: '',
94
+ min: ''
95
+ },
96
+ modifier: search_modifiers
97
+ },
98
+ users: {
99
+ sortCategory: 'postcount'
100
+ },
101
+ lists: {
102
+ sortCategory: 'follows'
103
+ },
104
+ filter: '',
105
+ sort: 0,
106
+ randomizer: 0
107
+ },
108
+ useCache: true
109
+ }
110
+
111
+ if search_info&.api_key
112
+ payload[:searchOptions][:users][:id] = search_info.api_key
113
+ end
114
+
115
+ payload.to_json
116
+ end
117
+
118
+ def send_web_request(game_name, search_modifiers = SearchModifiers::NONE, page = 1)
119
+ headers = get_search_request_headers
120
+ search_info = send_website_request_getcode(false)
121
+ search_info ||= send_website_request_getcode(true)
122
+
123
+ return nil unless search_info&.api_key
124
+
125
+ if search_info.search_url
126
+ search_url = "#{BASE_URL}/#{search_info.search_url}"
127
+ else
128
+ search_url = SEARCH_URL
129
+ end
130
+
131
+ # Try with API key in URL
132
+ search_url_with_key = "#{search_url}/#{search_info.api_key}"
133
+ payload = get_search_request_data(game_name, search_modifiers, page)
134
+ response = make_request(search_url_with_key, headers, payload)
135
+ return response if response
136
+
137
+ # Fallback to standard search with API key in payload
138
+ payload = get_search_request_data(game_name, search_modifiers, page, search_info)
139
+ make_request(search_url, headers, payload)
140
+ end
141
+
142
+ def get_game_title(game_id)
143
+ url = "#{GAME_URL}/#{game_id}"
144
+ headers = get_title_request_headers
145
+
146
+ contents = make_get_request(url, headers)
147
+ return nil unless contents
148
+
149
+ doc = Nokogiri::HTML(contents)
150
+ title_tag = doc.title
151
+ return nil unless title_tag
152
+
153
+ title_text = title_tag
154
+ title_text[12...-17]&.strip
155
+ end
156
+
157
+ private
158
+
159
+ def send_website_request_getcode(parse_all_scripts)
160
+ headers = get_title_request_headers
161
+ response = make_get_request(BASE_URL, headers)
162
+ return nil unless response
163
+
164
+ doc = Nokogiri::HTML(response)
165
+ script_urls = doc.css('script[src]').map { |script| script['src'] }
166
+
167
+ scripts = parse_all_scripts ? script_urls : script_urls.select { |url| url.include?('_app-') }
168
+
169
+ scripts.each do |script_url|
170
+ url = script_url.start_with?('http') ? script_url : "#{BASE_URL}#{script_url}"
171
+ script_content = make_get_request(url, headers)
172
+ next unless script_content
173
+
174
+ search_info = SearchInfo.new(script_content)
175
+ return search_info if search_info.api_key && !search_info.api_key.empty?
176
+ end
177
+
178
+ nil
179
+ end
180
+
181
+ def get_title_request_headers
182
+ {
183
+ 'User-Agent' => random_user_agent,
184
+ 'referer' => REFERER_HEADER
185
+ }
186
+ end
187
+
188
+ def make_request(url, headers, payload)
189
+ uri = URI(url)
190
+ http = Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = true
192
+
193
+ request = Net::HTTP::Post.new(uri.path, headers)
194
+ request.body = payload
195
+
196
+ response = http.request(request)
197
+ response.body if response.is_a?(Net::HTTPSuccess)
198
+ rescue StandardError => e
199
+ nil
200
+ end
201
+
202
+ def make_get_request(url, headers)
203
+ uri = URI(url)
204
+ http = Net::HTTP.new(uri.host, uri.port)
205
+ http.use_ssl = true
206
+
207
+ request = Net::HTTP::Get.new(uri.request_uri, headers)
208
+ response = http.request(request)
209
+
210
+ if response.is_a?(Net::HTTPSuccess)
211
+ response.body
212
+ else
213
+ nil
214
+ end
215
+ rescue StandardError => e
216
+ nil
217
+ end
218
+
219
+ def random_user_agent
220
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,151 @@
1
+ require 'json'
2
+ require 'set'
3
+
4
+ module HowLongToBeat
5
+ class JSONResultParser
6
+ IMAGE_URL_PREFIX = "https://howlongtobeat.com/games/"
7
+ GAME_URL_PREFIX = "https://howlongtobeat.com/game/"
8
+
9
+ attr_reader :results
10
+
11
+ def initialize(input_game_name, input_game_url, input_minimum_similarity, input_game_id = nil,
12
+ input_similarity_case_sensitive = true, input_auto_filter_times = false)
13
+ @results = []
14
+ @minimum_similarity = input_minimum_similarity
15
+ @similarity_case_sensitive = input_similarity_case_sensitive
16
+ @auto_filter_times = input_auto_filter_times
17
+ @game_id = input_game_id
18
+ @base_game_url = input_game_url
19
+ @game_name = input_game_name
20
+ @game_name_numbers = @game_name.split(" ").select { |word| word.match?(/^\d+$/) }
21
+
22
+ if @game_id
23
+ @minimum_similarity = 0
24
+ @similarity_case_sensitive = false
25
+ end
26
+ end
27
+
28
+ def parse_json_result(input_json_result)
29
+ response_result = JSON.parse(input_json_result)
30
+ response_result["data"].each do |game|
31
+ new_game_entry = parse_json_element(game)
32
+
33
+ if @game_id && new_game_entry.game_id.to_s != @game_id.to_s
34
+ next
35
+ elsif @minimum_similarity == 0.0
36
+ @results << new_game_entry
37
+ elsif new_game_entry.similarity >= @minimum_similarity
38
+ @results << new_game_entry
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def parse_json_element(input_game_element)
46
+ current_entry = HowLongToBeatEntry.new
47
+
48
+ # 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"]
54
+ 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"]
59
+ current_entry.json_content = input_game_element
60
+
61
+ # 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"])
68
+
69
+ # 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
74
+
75
+ # Auto-filter times based on complexity
76
+ if @auto_filter_times
77
+ if !current_entry.complexity_lvl_sp
78
+ current_entry.main_story = nil
79
+ current_entry.main_extra = nil
80
+ current_entry.completionist = nil
81
+ current_entry.all_styles = nil
82
+ end
83
+ current_entry.coop_time = nil unless current_entry.complexity_lvl_co
84
+ current_entry.mp_time = nil unless current_entry.complexity_lvl_mp
85
+ end
86
+
87
+ # Calculate similarity
88
+ game_name_similarity = similar(@game_name, current_entry.game_name)
89
+ game_alias_similarity = similar(@game_name, current_entry.game_alias)
90
+ current_entry.similarity = [game_name_similarity, game_alias_similarity].max
91
+
92
+ current_entry
93
+ end
94
+
95
+ def similar(a, b)
96
+ return 0 if a.nil? || b.nil?
97
+
98
+ a = a.downcase unless @similarity_case_sensitive
99
+ b = b.downcase unless @similarity_case_sensitive
100
+
101
+ # Simple Levenshtein distance for similarity
102
+ distance = levenshtein_distance(a, b)
103
+ max_length = [a.length, b.length].max
104
+ similarity = 1 - (distance.to_f / max_length)
105
+
106
+ # Additional check for numbers
107
+ if @game_name_numbers.any?
108
+ cleaned = b.gsub(/[^\w\s]/, '')
109
+ number_found = cleaned.split.any? do |word|
110
+ word.match?(/^\d+$/) && @game_name_numbers.include?(word)
111
+ end
112
+ similarity -= 0.1 unless number_found
113
+ end
114
+
115
+ similarity
116
+ end
117
+
118
+ def levenshtein_distance(str1, str2)
119
+ m = str1.length
120
+ n = str2.length
121
+ return m if n == 0
122
+ return n if m == 0
123
+
124
+ matrix = Array.new(m + 1) { Array.new(n + 1) }
125
+
126
+ (0..m).each { |i| matrix[i][0] = i }
127
+ (0..n).each { |j| matrix[0][j] = j }
128
+
129
+ (1..n).each do |j|
130
+ (1..m).each do |i|
131
+ if str1[i-1] == str2[j-1]
132
+ matrix[i][j] = matrix[i-1][j-1]
133
+ else
134
+ matrix[i][j] = [
135
+ matrix[i-1][j] + 1,
136
+ matrix[i][j-1] + 1,
137
+ matrix[i-1][j-1] + 1
138
+ ].min
139
+ end
140
+ end
141
+ end
142
+
143
+ matrix[m][n]
144
+ end
145
+
146
+ def round_time(seconds)
147
+ return nil if seconds.nil?
148
+ (seconds / 3600.0).round(2)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,3 @@
1
+ module HowLongToBeat
2
+ VERSION = "0.1.2"
3
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'howlongtobeat/version'
2
+ require_relative 'howlongtobeat/how_long_to_beat'
3
+ require_relative 'howlongtobeat/how_long_to_beat_entry'
4
+ require_relative 'howlongtobeat/html_requests'
5
+ require_relative 'howlongtobeat/json_result_parser'
6
+
7
+ module HowLongToBeat
8
+ class Error < StandardError; end
9
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: howlongtobeat
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Dmitrii Pashutskii
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-03-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.15.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.15.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: ostruct
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.6.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.6.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ description: A simple Ruby gem to fetch game completion times from HowLongToBeat.com
84
+ email:
85
+ - dpashutskii@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - CODE_OF_CONDUCT.md
92
+ - README.md
93
+ - lib/howlongtobeat.rb
94
+ - lib/howlongtobeat/how_long_to_beat.rb
95
+ - lib/howlongtobeat/how_long_to_beat_entry.rb
96
+ - lib/howlongtobeat/html_requests.rb
97
+ - lib/howlongtobeat/json_result_parser.rb
98
+ - lib/howlongtobeat/version.rb
99
+ homepage: https://github.com/dpashutskii/howlongtobeat
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ homepage_uri: https://github.com/dpashutskii/howlongtobeat
104
+ source_code_uri: https://github.com/dpashutskii/howlongtobeat
105
+ changelog_uri: https://github.com/dpashutskii/howlongtobeat/blob/main/CHANGELOG.md
106
+ bug_tracker_uri: https://github.com/dpashutskii/howlongtobeat/issues
107
+ rubygems_mfa_required: 'true'
108
+ post_install_message:
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 3.1.0
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.5.16
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: Ruby client for HowLongToBeat.com
127
+ test_files: []