bdoap 0.0.1

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: a2c463012b75ab5e6a2ad6d2558aaf0d13d4ca815b0092d7384efa44903c124d
4
+ data.tar.gz: ffd6889945d22837ba488133f5d364d086352dd945adf95cb4adb6512036963e
5
+ SHA512:
6
+ metadata.gz: cf5c5d4011b0febc18953536d8bd94d90fa1df257386685dbce8e6d1dbc745e8ed4afbab2f079be71e1f2d13767802cf8d054ffeb90f2c2342f2dc9093e39c29
7
+ data.tar.gz: 2c6626ef2c4222dc002db072403d55bec2c06c7b87a860795b739d1b312419283ab6fdc3b6ace8a31f1da9374131a6045d3fd8ce3b2dbd581c40add167cf1ba5
data/bin/bdoap ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/bdo_alchemy_profits'
5
+
6
+ profit_calculator = BDOAlchemyProfits.new
7
+
8
+ profit_calculator.start_cli
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # MIT License
5
+ #
6
+ # Copyright (c) 2025 jpegzilla
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ # of this software and associated documentation files (the "Software"), to deal
10
+ # in the Software without restriction, including without limitation the rights
11
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the Software is
13
+ # furnished to do so, subject to the following conditions:
14
+ #
15
+ # The above copyright notice and this permission notice shall be included in all
16
+ # copies or substantial portions of the Software.
17
+ #
18
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ # SOFTWARE.
25
+
26
+ require 'optparse'
27
+
28
+ require_relative './utils/user_cli'
29
+ require_relative './central_market/market_searcher'
30
+ require_relative './bdo_codex/bdo_codex_searcher'
31
+
32
+ class BDOAlchemyProfits
33
+ include Utils
34
+
35
+ def start_cli
36
+ options = {}
37
+
38
+ OptionParser.new do |opt|
39
+ opt.on('--silent', '-s') { options[:silent] = true }
40
+ end.parse!
41
+
42
+ cli = UserCLI.new options
43
+
44
+ # option setup
45
+
46
+ category = cli.choose_category
47
+
48
+ cli.end_cli if category == 'exit'
49
+
50
+ region = cli.choose_region
51
+
52
+ cli.end_cli if region == 'exit'
53
+
54
+ lang = cli.choose_lang
55
+
56
+ cli.end_cli if lang == 'exit'
57
+
58
+ aggression = cli.choose_aggression
59
+
60
+ cli.end_cli if aggression == 'exit'
61
+
62
+ if aggression == 'hyperaggressive'
63
+ puts cli.orange("\nWARN: hyperagressive mode is RISKY AND SLOW. this will evaluate every substitution for every recipe. hammers apis violently. you will get rate limited. you will get IP blocked. her royal holiness imperva incapsula WILL get you. select if you know what all that stuff means and you are ok with waiting 20 minutes.")
64
+ end
65
+
66
+ # start searching
67
+
68
+ cli.vipiko("\n♫ let's see if #{cli.yellow category} alchemy items are profitable in #{cli.yellow region}!")
69
+
70
+ market_searcher = MarketSearcher.new region, cli
71
+
72
+ market_item_list = market_searcher.get_alchemy_market_data category
73
+
74
+ cli.vipiko("I'll look for #{cli.yellow(market_item_list.length)} item#{
75
+ market_item_list.empty? || market_item_list.length > 1 ? 's' : ''
76
+ } in #{category == 'all' ? cli.yellow('all categories'): "the #{cli.yellow category} category"}!")
77
+
78
+ bdo_codex_searcher = BDOCodexSearcher.new(region, lang, cli, aggression == 'hyperaggressive')
79
+
80
+ item_codex_data = bdo_codex_searcher.get_item_codex_data market_item_list
81
+
82
+ recipe_prices = market_searcher.get_all_recipe_prices item_codex_data, category
83
+
84
+ mapped_prices = recipe_prices.sort_by { |recipe| recipe[:silver_per_hour].to_i }.map { |recipe| recipe[:information] }
85
+
86
+ if mapped_prices.length > 0
87
+ cli.vipiko_overwrite "done!"
88
+ puts "\n\n"
89
+ puts mapped_prices
90
+ else
91
+ cli.vipiko_overwrite "none of those recipes look profitable right now...let's go gathering!"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'httparty'
5
+ require 'nokogiri'
6
+ require 'awesome_print'
7
+
8
+ require_relative '../utils/user_cli'
9
+ require_relative '../utils/array_utils'
10
+ require_relative '../utils/hash_cache'
11
+
12
+ # some variables that are necessary for parsing bdocodex data
13
+ class BDO_CODEX_UTILS
14
+ BDOCODEX_QUERY_DATA_KEY = 'aaData'
15
+ RECIPE_COLUMNS = [
16
+ 'id',
17
+ 'icon',
18
+ 'title',
19
+ 'type',
20
+ 'skill level',
21
+ 'exp',
22
+ 'materials',
23
+ 'total weight of materials',
24
+ 'products',
25
+ 'all ingredients',
26
+ ]
27
+ end
28
+
29
+ # used to retrieve items from BDOCodex
30
+ class BDOCodexSearcher
31
+ include Utils
32
+
33
+ RECIPE_INGREDIENTS_INDEX = 2
34
+
35
+ def initialize(region, lang, cli, hyper_aggressive = false)
36
+ @region = region
37
+ @root_url = ENVData.get_root_url region
38
+ @cli = cli
39
+ @region_lang = lang
40
+ @cache = HashCache.new ENVData::BDO_CODEX_CACHE
41
+ @hyper_aggressive = hyper_aggressive
42
+ end
43
+
44
+ def get_recipe_url(url)
45
+ begin
46
+ data = HTTParty.get(
47
+ URI(url),
48
+ headers: ENVData::REQUEST_OPTS[:bdo_codex_headers],
49
+ content_type: 'application/x-www-form-urlencoded'
50
+ )
51
+
52
+ JSON.parse data unless data.body.nil? or data.body.empty?
53
+ rescue
54
+ {}
55
+ end
56
+ end
57
+
58
+ # TODO: is there a way to get rid of all these nested loops in this class?
59
+ def get_recipe_substitutions(recipe_with_substitute_ids, all_potion_recipes, name)
60
+ all_recipe_substitutions = []
61
+ all_potion_recipes.each do |recipe|
62
+ original_recipe = recipe[RECIPE_INGREDIENTS_INDEX]
63
+ original_ingredient_indices = original_recipe.map do |item|
64
+ recipe_with_substitute_ids.find_index { |id| id == item[:id] }
65
+ end
66
+
67
+ chunked_by_substitution_groups = []
68
+
69
+ original_ingredient_indices.each.with_index do |_arr_index, idx|
70
+ slice_from = [0, original_ingredient_indices[idx].to_i].max
71
+ slice_to = slice_from + 1
72
+ slice_to = original_ingredient_indices[idx + 1] if @hyper_aggressive
73
+ # set hyper_aggressive to true if you want to check every
74
+ # permutation of this recipe, with all substitutions considered
75
+ # this will be exceedingly slow and generate hundreds and hundreds
76
+ # of post requests, hammering the black desert market api and
77
+ # potentially causing incapsula to GET YOU (block your IP)
78
+
79
+ chunked_by_substitution_groups.push(
80
+ recipe_with_substitute_ids[slice_from..(slice_to ? slice_to - 1 : -1)]
81
+ )
82
+ end
83
+
84
+ original_recipe_length = original_recipe.length
85
+ permutated_chunks = ArrayUtils.deep_permute chunked_by_substitution_groups, original_recipe_length
86
+
87
+ permutated_chunks.each do |id_list|
88
+ recipe_with_new_items = [*recipe]
89
+ recipe_with_new_items[RECIPE_INGREDIENTS_INDEX] = [*recipe][RECIPE_INGREDIENTS_INDEX].map.with_index do |recipe_list, idx|
90
+ { **recipe_list, id: id_list[idx] }
91
+ end
92
+
93
+ all_recipe_substitutions.push recipe_with_new_items
94
+ end
95
+ end
96
+
97
+ # 1 in elem[1] is the index of the recipe name
98
+ all_recipe_substitutions.filter { |elem| elem[1].downcase == name.downcase }
99
+ end
100
+
101
+ def parse_raw_recipe(recipe_data, item_name)
102
+ item_with_ingredients = recipe_data.dig(BDO_CODEX_UTILS::BDOCODEX_QUERY_DATA_KEY)
103
+
104
+ if item_with_ingredients
105
+ recipe_with_substitute_ids = item_with_ingredients.map do |arr|
106
+ arr.filter.with_index { |_, idx| idx == 9 }
107
+ .map { |item| JSON.parse(item) }
108
+ .first
109
+ end.first
110
+
111
+ all_potion_recipes = item_with_ingredients.map do |arr|
112
+ mapped_item_data = arr
113
+ .filter.with_index { |_, idx | !BDO_CODEX_UTILS::RECIPE_COLUMNS[idx].nil? }
114
+ .map.with_index do |raw_element, idx|
115
+ category = BDO_CODEX_UTILS::RECIPE_COLUMNS[idx]
116
+
117
+ next if ['skill level', 'exp', 'type', 'icon', 'total weight of materials'].include? category
118
+
119
+ element = Nokogiri::HTML5 raw_element.to_s
120
+
121
+ result = {
122
+ :category => category,
123
+ :element => element
124
+ }
125
+
126
+ result[:element] = element.text.downcase if category == 'title'
127
+
128
+ result[:element] = element.text.downcase if category == 'id'
129
+
130
+ if %w[materials products].include? category
131
+ quants = element.text.scan(/\](\d+)/im).map { |e| e[0].to_i }
132
+
133
+ ids = element.to_s.scan(/#{@region_lang}\/item\/(\d+)/).map { |e| e[0].to_i }
134
+ result[:element] = ids.map.with_index { |id, i| { id: id, quant: quants[i]} }.flatten
135
+ end
136
+
137
+ result
138
+ end
139
+
140
+ filtered_item_data = mapped_item_data
141
+ .compact
142
+ .filter { |e| %w[id title materials products].include? e[:category] }
143
+ .map { |e| e[:element] }
144
+
145
+ filtered_item_data
146
+ end
147
+
148
+ get_recipe_substitutions recipe_with_substitute_ids, all_potion_recipes, item_name
149
+ end
150
+ end
151
+
152
+ def get_item_recipes(item_id, item_name)
153
+ recipe_direct_url = "https://bdocodex.com/query.php?a=recipes&type=product&item_id=#{item_id}&l=#{@region_lang}"
154
+ mrecipe_direct_url = "https://bdocodex.com/query.php?a=mrecipes&type=product&item_id=#{item_id}&l=#{@region_lang}"
155
+ # houserecipe_direct_url = "https://bdocodex.com/query.php?a=designs&type=product&item_id=#{item_id}&l=#{@region_lang}"
156
+
157
+ # TODO: there MUST be a better way to determine which recipe to use, rather than just trying them both.
158
+ begin
159
+ direct_data = get_recipe_url recipe_direct_url
160
+
161
+ if !direct_data || direct_data.empty?
162
+ mrecipe_data = get_recipe_url mrecipe_direct_url
163
+
164
+ return parse_raw_recipe mrecipe_data, item_name
165
+ else
166
+ parsed = parse_raw_recipe direct_data, item_name
167
+
168
+ if !parsed || parsed.empty?
169
+ mrecipe_data = get_recipe_url mrecipe_direct_url
170
+
171
+ return parse_raw_recipe mrecipe_data, item_name
172
+ end
173
+
174
+ parsed
175
+ end
176
+ rescue StandardError => error
177
+ puts @cli.red("if you're not messing with the code, you should never see this. get_item_recipes broke.")
178
+
179
+ File.open(ENVData::ERROR_LOG, 'a+') do |file|
180
+ file.write(error.full_message)
181
+ file.write("\n\r")
182
+ end
183
+
184
+ []
185
+ end
186
+ end
187
+
188
+ # m_recipes_first may be useful if a lot of recipes aren't working
189
+ # refer to the original javascript implementation of
190
+ # searchCodexForRecipes
191
+ # m_recipes_first should be used to hit the /mrecipe version of a recipe
192
+ # for manufacturing-type recipes
193
+ def search_codex_for_recipes(item, m_recipes_first)
194
+ item_id = item[:main_key]
195
+ item_name = item[:name]
196
+ item_index = "#{item_id} #{item[:name]}"
197
+ potential_cached_recipes = @cache.read item_index
198
+ cache_data = {}
199
+
200
+ # TODO: remove this check
201
+ # return unless item_name.downcase == 'harmony draught'
202
+
203
+ unless potential_cached_recipes.to_a.empty?
204
+ recipe_to_maybe_select = potential_cached_recipes.filter { |elem| elem[1].downcase == item_name.downcase }
205
+ return recipe_to_maybe_select unless recipe_to_maybe_select.empty?
206
+ end
207
+
208
+ recipes = get_item_recipes item_id, item_name
209
+ cache_data[item_index] = recipes
210
+
211
+ @cache.write cache_data
212
+
213
+ recipes
214
+ end
215
+
216
+ # pass in a list of items as retrieved from the central market API
217
+ def get_item_codex_data(item_list)
218
+ recipes = []
219
+
220
+ # newline because vipiko is about to start carriage returning
221
+ puts "\n"
222
+ item_list.each.with_index do |item_hash, index|
223
+ item = item_hash.transform_keys { |key|
224
+ key.gsub(/(.)([A-Z])/,'\1_\2').downcase.to_sym
225
+ }
226
+
227
+ next unless item[:main_key]
228
+
229
+ begin
230
+ search_results = search_codex_for_recipes item, false
231
+
232
+ if search_results
233
+ # res[0] is the recipe ID
234
+ all_recipes_for_item = search_results.map { |res|
235
+ recipe_array = [res[0], res[RECIPE_INGREDIENTS_INDEX]]
236
+ recipe_array
237
+ }
238
+
239
+ @cli.vipiko_overwrite "(#{index + 1} / #{item_list.length}) let's read the recipe for #{@cli.yellow "[#{item[:name].downcase}]"}. hmm..."
240
+
241
+ stock_count = item[:total_in_stock].to_i.zero? ? item[:count].to_i : item[:total_in_stock].to_i
242
+ recipe_hash = {
243
+ name: item[:name],
244
+ recipe_list: all_recipes_for_item,
245
+ price: item[:price_per_one],
246
+ id: item[:main_key],
247
+ total_trade_count: item[:total_trade_count],
248
+ total_in_stock: stock_count,
249
+ main_category: item[:main_category],
250
+ sub_category: item[:sub_category]
251
+ }
252
+
253
+ recipes.push recipe_hash
254
+ end
255
+ rescue StandardError => error
256
+ puts @cli.red("if you're not messing with the code, you should never see this. get_item_codex_data broke.")
257
+
258
+ File.open(ENVData::ERROR_LOG, 'a+') do |file|
259
+ file.write(error.full_message)
260
+ file.write("\n\r")
261
+ end
262
+
263
+ next
264
+ end
265
+ end
266
+
267
+ recipes
268
+ end
269
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils/constants'
4
+
5
+ module MarketSearchTools
6
+ include Utils
7
+
8
+ CONSUMABLE_CATEGORY = 35
9
+ CONSUMABLE_SUBCATEGORIES = {
10
+ offensive: 1,
11
+ defensive: 2,
12
+ functional: 3,
13
+ potion: 5,
14
+ other: 8,
15
+ all: [1, 2, 3, 5, 8]
16
+ }.freeze
17
+
18
+ # get information used for searching specific categories
19
+ def category_search_options(url, search_url) # rubocop:disable Metrics/AbcSize
20
+ # TODO: there's probably a smart / concise way to construct this array
21
+ [
22
+ {
23
+ name: 'black stone',
24
+ url: url,
25
+ query_string: "#{ENVData::RVT}&mainCategory=30&subCategory=1",
26
+ # update: ->(data) { { blackStoneResponse: data['list'] } },
27
+ update: ->(data) { data['list'] }
28
+ },
29
+ {
30
+ name: 'misc',
31
+ url: url,
32
+ query_string: "#{ENVData::RVT}&mainCategory=25&subCategory=8",
33
+ # update: ->(data) { { blackStoneResponse: data['list'] } },
34
+ update: ->(data) { data['list'] }
35
+ },
36
+ {
37
+ name: 'other tools',
38
+ url: url,
39
+ query_string: "#{ENVData::RVT}&mainCategory=40&subCategory=10",
40
+ # update: ->(data) { { blackStoneResponse: data['list'] } },
41
+ update: ->(data) { data['list'] }
42
+ },
43
+ {
44
+ name: 'blood',
45
+ url: search_url,
46
+ query_string: "#{ENVData::RVT}&searchText='s+blood",
47
+ # update: ->(data) { { bloodResponse: data['list'] } },
48
+ update: ->(data) { data['list'] }
49
+ },
50
+ {
51
+ name: 'reagent',
52
+ url: search_url,
53
+ query_string: "#{ENVData::RVT}&searchText=reagent",
54
+ # update: ->(data) { { reagentResponse: data['list'] } },
55
+ update: ->(data) { data['list'] }
56
+ },
57
+ {
58
+ name: 'oil',
59
+ url: search_url,
60
+ query_string: "#{ENVData::RVT}&searchText=oil+of",
61
+ # update: ->(data) { { oilResponse: data['list'] } },
62
+ update: ->(data) { data['list'] }
63
+ },
64
+ {
65
+ name: 'alchemy stone',
66
+ url: search_url,
67
+ query_string: "#{ENVData::RVT}&searchText=stone+of",
68
+ # update: ->(data) { { alchemyStoneResponse: data['list'] } },
69
+ update: ->(data) { data['list'].filter { |i| i['grade'] == 0 } }
70
+ },
71
+ # {
72
+ # name: 'magic crystal',
73
+ # url: search_url,
74
+ # query_string: "#{ENVData::RVT}&searchText=magic+crystal",
75
+ # # update: ->(data) { { magicCrystalResponse: data['list'] } },
76
+ # update: ->(data) { data['list'] }
77
+ # },
78
+ {
79
+ name: 'offensive',
80
+ url: url,
81
+ query_string:
82
+ "#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:offensive]}",
83
+ # update: ->(data) { { offensiveResponse: data['marketList'] } },
84
+ update: ->(data) { data['marketList'] }
85
+ },
86
+ {
87
+ name: 'defensive',
88
+ url: url,
89
+ query_string:
90
+ "#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:defensive]}",
91
+ # update: ->(data) { { defensiveResponse: data['marketList'] } },
92
+ update: ->(data) { data['marketList'] }
93
+ },
94
+ {
95
+ name: 'functional',
96
+ url: url,
97
+ query_string:
98
+ "#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:functional]}",
99
+ # update: ->(data) { { functionalResponse: data['marketList'] } },
100
+ update: ->(data) { data['marketList'] }
101
+ },
102
+ {
103
+ name: 'potion',
104
+ url: url,
105
+ query_string:
106
+ "#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:potion]}",
107
+ # update: ->(data) { { potionResponse: data['marketList'] } },
108
+ update: ->(data) { data['marketList'] }
109
+ },
110
+ {
111
+ name: 'other',
112
+ url: url,
113
+ query_string:
114
+ "#{ENVData::RVT}&mainCategory=#{CONSUMABLE_CATEGORY}&subCategory=#{CONSUMABLE_SUBCATEGORIES[:other]}",
115
+ # update: ->(data) { { otherResponse: data['marketList'] } },
116
+ update: ->(data) { data['marketList'] }
117
+ }
118
+ ]
119
+ end
120
+ end