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 +7 -0
- data/bin/bdoap +8 -0
- data/lib/bdo_alchemy_profits.rb +94 -0
- data/lib/bdo_codex/bdo_codex_searcher.rb +269 -0
- data/lib/central_market/category_search_options.rb +120 -0
- data/lib/central_market/market_searcher.rb +414 -0
- data/lib/utils/array_utils.rb +21 -0
- data/lib/utils/constants.rb +135 -0
- data/lib/utils/hash_cache.rb +34 -0
- data/lib/utils/npc_item_index.rb +526 -0
- data/lib/utils/price_calculator.rb +26 -0
- data/lib/utils/recipe_logger.rb +71 -0
- data/lib/utils/user_cli.rb +110 -0
- metadata +54 -0
@@ -0,0 +1,414 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'httparty'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
require_relative '../utils/constants'
|
7
|
+
require_relative '../utils/price_calculator'
|
8
|
+
require_relative '../utils/recipe_logger'
|
9
|
+
require_relative '../utils/npc_item_index'
|
10
|
+
require_relative './category_search_options'
|
11
|
+
|
12
|
+
# search for information on recipes in given categories
|
13
|
+
class MarketSearcher
|
14
|
+
include Utils
|
15
|
+
include MarketSearchTools
|
16
|
+
|
17
|
+
def initialize(region, cli)
|
18
|
+
@root_url = ENVData.get_root_url region
|
19
|
+
@region_subdomain = CLIConstants::REGION_DOMAINS[region.to_sym].split('.')[1..].join('.')
|
20
|
+
@market_list_url = "#{@root_url}#{ENVData::WORLD_MARKET_LIST}"
|
21
|
+
@market_search_url = "#{@root_url}#{ENVData::MARKET_SEARCH_LIST}"
|
22
|
+
@market_sub_url = "#{@root_url}#{ENVData::MARKET_SUB_LIST}"
|
23
|
+
@market_sell_buy_url = "#{@root_url}#{ENVData::MARKET_SELL_BUY_INFO}"
|
24
|
+
@cli = cli
|
25
|
+
@ingredient_cache = {}
|
26
|
+
end
|
27
|
+
|
28
|
+
def get_alchemy_market_data(category)
|
29
|
+
construct_item_data(category, category == 'all')
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_price_data(elem)
|
33
|
+
data = HTTParty.post(
|
34
|
+
URI(@market_sub_url),
|
35
|
+
headers: ENVData.get_central_market_headers(ENVData.get_incap_cookie(@region_subdomain)),
|
36
|
+
body: "#{ENVData::RVT}&mainKey=#{elem['mainKey']}&usingCleint=0",
|
37
|
+
content_type: 'application/x-www-form-urlencoded'
|
38
|
+
)
|
39
|
+
|
40
|
+
sleep 1
|
41
|
+
|
42
|
+
if data&.dig('detailList')
|
43
|
+
# data_to_use = { **elem, **data['detailList'][0] }
|
44
|
+
# cache_data = {}
|
45
|
+
# cache_data[elem['mainKey']] = data_to_use
|
46
|
+
# @market_cache.write cache_data
|
47
|
+
{ **elem, **data['detailList'][0] }
|
48
|
+
else
|
49
|
+
puts 'incapsula might have your number, ip swap'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def construct_item_data(subcategory, all_subcategories)
|
54
|
+
aggregate = aggregate_category_data(@market_list_url, @market_search_url, subcategory, all_subcategories)
|
55
|
+
|
56
|
+
filtered_aggregate = aggregate.filter do |elem|
|
57
|
+
elem != nil
|
58
|
+
end
|
59
|
+
|
60
|
+
# TODO: this is probably not a smart way to do this type of retry logic
|
61
|
+
puts
|
62
|
+
mapped_aggregate = filtered_aggregate.map.with_index do |elem, index|
|
63
|
+
|
64
|
+
@cli.vipiko_overwrite "(#{index + 1} / #{filtered_aggregate.length}) researching #{@cli.yellow elem['name'].downcase}... (category: #{subcategory})"
|
65
|
+
|
66
|
+
begin
|
67
|
+
get_price_data elem
|
68
|
+
rescue
|
69
|
+
sleep 2
|
70
|
+
begin
|
71
|
+
get_price_data elem
|
72
|
+
rescue StandardError => error
|
73
|
+
puts @cli.red("this could be a network failure. construct_item_data broke.")
|
74
|
+
|
75
|
+
File.open(ENVData::ERROR_LOG, 'a+') do |file|
|
76
|
+
file.write(error.full_message)
|
77
|
+
file.write("\n\r")
|
78
|
+
end
|
79
|
+
|
80
|
+
[]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end.filter { |item| !item&.nil? && !item&.dig('pricePerOne').nil? }
|
84
|
+
|
85
|
+
puts "\n\n" unless mapped_aggregate.empty?
|
86
|
+
|
87
|
+
mapped_aggregate.sort do |a, b|
|
88
|
+
b['pricePerOne'] - a['pricePerOne']
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def aggregate_category_data(url, search_url, subcategory, all_subcategories)
|
93
|
+
make_match_options = proc do |subcat_to_match|
|
94
|
+
{
|
95
|
+
subcat_to_match: subcat_to_match,
|
96
|
+
subcategory: subcategory,
|
97
|
+
all_subcategories: all_subcategories
|
98
|
+
}
|
99
|
+
end
|
100
|
+
|
101
|
+
aggregate_response = []
|
102
|
+
|
103
|
+
category_search_options(url, search_url).each do |category_opts|
|
104
|
+
do_if_category_matches(make_match_options.call(category_opts[:name])) do
|
105
|
+
begin
|
106
|
+
data = HTTParty.post(
|
107
|
+
URI(category_opts[:url]),
|
108
|
+
headers: ENVData.get_central_market_headers(ENVData.get_incap_cookie(@region_subdomain)),
|
109
|
+
body: category_opts[:query_string],
|
110
|
+
content_type: 'application/x-www-form-urlencoded'
|
111
|
+
)
|
112
|
+
|
113
|
+
sleep 1
|
114
|
+
|
115
|
+
aggregate_response.push category_opts[:update].call(data) if data
|
116
|
+
rescue StandardError => error
|
117
|
+
puts @cli.red("this could be a network failure. aggregate_category_data broke.")
|
118
|
+
|
119
|
+
File.open(ENVData::ERROR_LOG, 'a+') do |file|
|
120
|
+
file.write(error.full_message)
|
121
|
+
file.write("\n\r")
|
122
|
+
end
|
123
|
+
|
124
|
+
[]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
aggregate_response.flatten
|
130
|
+
end
|
131
|
+
|
132
|
+
def get_item_price_info(ingredient_id, is_recipe_ingredient = true)
|
133
|
+
npc_item = NPCItemIndex.get_item(ingredient_id)
|
134
|
+
|
135
|
+
return npc_item if npc_item
|
136
|
+
|
137
|
+
if is_recipe_ingredient
|
138
|
+
ingredient_data = {}
|
139
|
+
|
140
|
+
begin
|
141
|
+
ingredient_data = HTTParty.post(
|
142
|
+
URI(@market_sub_url),
|
143
|
+
headers: ENVData.get_central_market_headers(ENVData.get_incap_cookie(@region_subdomain)),
|
144
|
+
body: "#{ENVData::RVT}&mainKey=#{ingredient_id}&usingCleint=0",
|
145
|
+
content_type: 'application/x-www-form-urlencoded'
|
146
|
+
)
|
147
|
+
|
148
|
+
sleep rand(1..3)
|
149
|
+
rescue StandardError => error
|
150
|
+
puts @cli.red("this could be a network failure. get_item_price_info broke.")
|
151
|
+
|
152
|
+
File.open(ENVData::ERROR_LOG, 'a+') do |file|
|
153
|
+
file.write(error.full_message)
|
154
|
+
file.write("\n\r")
|
155
|
+
ingredient_data = {}
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
ingredient_data = {} if ingredient_data.include? 'use a different browser'
|
160
|
+
|
161
|
+
if ingredient_data.dig('detailList')
|
162
|
+
resolved_data = ingredient_data['detailList'][0]
|
163
|
+
unless resolved_data.nil?
|
164
|
+
body_string = "#{ENVData::RVT}&mainKey=#{resolved_data['mainKey']}&subKey=0&chooseKey=0&isUp=true&keyType=0&name=#{URI.encode_www_form_component(resolved_data['name'])}"
|
165
|
+
|
166
|
+
detailed_price_list = {}
|
167
|
+
|
168
|
+
begin
|
169
|
+
detailed_price_list = HTTParty.post(
|
170
|
+
URI(@market_sell_buy_url),
|
171
|
+
headers: ENVData.get_central_market_headers(ENVData.get_incap_cookie(@region_subdomain)),
|
172
|
+
body: body_string,
|
173
|
+
content_type: 'application/x-www-form-urlencoded'
|
174
|
+
)
|
175
|
+
sleep rand
|
176
|
+
rescue StandardError => error
|
177
|
+
puts @cli.red("this could be a network failure. get_item_price_info broke.")
|
178
|
+
|
179
|
+
File.open(ENVData::ERROR_LOG, 'a+') do |file|
|
180
|
+
file.write(error&.full_message || error)
|
181
|
+
file.write("\n\r")
|
182
|
+
detailed_price_list = {}
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
optimal_prices = detailed_price_list&.dig('marketConditionList')&.sort do |a, b|
|
187
|
+
b['sellCount'] - a['sellCount']
|
188
|
+
end
|
189
|
+
|
190
|
+
total_stock = optimal_prices.to_a.map { |price| price["sellCount"] }.sum
|
191
|
+
|
192
|
+
optimal_price = optimal_prices.to_a.first
|
193
|
+
|
194
|
+
if optimal_price
|
195
|
+
if optimal_price['pricePerOne'] && optimal_price['sellCount']
|
196
|
+
{ **resolved_data, count: total_stock, pricePerOne: optimal_price['pricePerOne'] }
|
197
|
+
else
|
198
|
+
{ **resolved_data, count: total_stock, pricePerOne: resolved_data['pricePerOne'] }
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def get_all_recipe_prices(item_codex_data, subcategory)
|
208
|
+
mapped_recipe_prices = []
|
209
|
+
out_of_stock_items = []
|
210
|
+
# vipiko is about to start writing carriage returns,
|
211
|
+
# so printing newline here
|
212
|
+
|
213
|
+
item_codex_data.each.with_index do |item_with_recipe, index|
|
214
|
+
potential_recipes = []
|
215
|
+
name = item_with_recipe[:name].downcase
|
216
|
+
recipe_list = item_with_recipe[:recipe_list]
|
217
|
+
|
218
|
+
@cli.vipiko_overwrite "(#{index + 1} / #{item_codex_data.length}) I'll ask a merchant about the price of ingredients for #{@cli.yellow name}!"
|
219
|
+
|
220
|
+
recipe_list.each do |(recipe_id, recipe)|
|
221
|
+
potential_recipe = []
|
222
|
+
|
223
|
+
recipe.each do |ingredient|
|
224
|
+
ingredient_id = ingredient['id'] ? ingredient['id'] : ingredient[:id]
|
225
|
+
quant = ingredient['quant'] ? ingredient['quant'] : ingredient[:quant]
|
226
|
+
|
227
|
+
if @ingredient_cache[ingredient_id]
|
228
|
+
cached_ingredient = { **@ingredient_cache[ingredient_id], quant: quant }
|
229
|
+
potential_recipe.push cached_ingredient
|
230
|
+
next
|
231
|
+
end
|
232
|
+
|
233
|
+
item_price_info_hash = get_item_price_info ingredient_id, true
|
234
|
+
|
235
|
+
next if item_price_info_hash.nil?
|
236
|
+
|
237
|
+
item_price_info = item_price_info_hash.transform_keys { |key|
|
238
|
+
key.to_s.gsub(/(.)([A-Z])/,'\1_\2').downcase.to_sym
|
239
|
+
}
|
240
|
+
|
241
|
+
if item_price_info[:count].zero? && rand > 0.5
|
242
|
+
out_of_stock_items.push(item_price_info[:name].downcase)
|
243
|
+
end
|
244
|
+
|
245
|
+
stock_count = get_stock_count item_price_info
|
246
|
+
|
247
|
+
# attach the npc item data (such as infinite stock, etc) to the item
|
248
|
+
npc_data = {}
|
249
|
+
npc_data = item_price_info if item_price_info[:is_npc_item]
|
250
|
+
price_per_one = npc_data[:price].to_i.zero? ? item_price_info[:price_per_one].to_i : npc_data[:price].to_i
|
251
|
+
|
252
|
+
# TODO: this is ridiculous, dedupe the hash values
|
253
|
+
potential_ingredient_hash = {
|
254
|
+
name: item_price_info[:name],
|
255
|
+
price: price_per_one,
|
256
|
+
id: item_price_info[:main_key],
|
257
|
+
total_trade_count: item_price_info[:total_trade_count].to_i,
|
258
|
+
total_in_stock: stock_count,
|
259
|
+
main_category: item_price_info[:main_category],
|
260
|
+
sub_category: item_price_info[:sub_category],
|
261
|
+
quant: quant,
|
262
|
+
price_per_one: price_per_one,
|
263
|
+
count: stock_count,
|
264
|
+
for_recipe_id: recipe_id,
|
265
|
+
**npc_data
|
266
|
+
}
|
267
|
+
|
268
|
+
@ingredient_cache[ingredient_id] = potential_ingredient_hash
|
269
|
+
|
270
|
+
potential_recipe.push potential_ingredient_hash
|
271
|
+
end
|
272
|
+
|
273
|
+
next unless potential_recipe.length == recipe.length
|
274
|
+
|
275
|
+
potential_recipes.push potential_recipe
|
276
|
+
end
|
277
|
+
|
278
|
+
mapped_recipe_prices.push map_recipe_prices(potential_recipes, item_with_recipe, subcategory)
|
279
|
+
end
|
280
|
+
|
281
|
+
mapped_recipe_prices.filter { |e| !e.nil? && !!e }
|
282
|
+
end
|
283
|
+
|
284
|
+
def map_recipe_prices(potential_recipes, item, category)
|
285
|
+
average_procs = 1
|
286
|
+
|
287
|
+
if [25, 35].include? item[:main_category]
|
288
|
+
unless /oil of|draught|\[mix\]|\[party\]|immortal:|perfume|indignation/im.match item[:name].downcase
|
289
|
+
average_procs = 2.5
|
290
|
+
end
|
291
|
+
|
292
|
+
if category == 'reagent' || /reagent/.match(item[:name].downcase)
|
293
|
+
average_procs = 3
|
294
|
+
end
|
295
|
+
|
296
|
+
# harmony draught recipe produces 10
|
297
|
+
if item[:id].to_s == '1399'
|
298
|
+
average_procs = 10
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
filtered_recipes = potential_recipes.filter do |recipe|
|
303
|
+
recipe.all? do |ingredient|
|
304
|
+
if ingredient[:total_in_stock] == Float::INFINITY
|
305
|
+
true
|
306
|
+
else
|
307
|
+
stock = ingredient[:total_in_stock].to_i.zero? ? ingredient[:count] : ingredient[:total_in_stock]
|
308
|
+
|
309
|
+
stock == Float::INFINITY ? true : stock.to_i > 0
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
selected_recipe = filtered_recipes.sort_by do |recipe|
|
315
|
+
recipe.map { |ingredient| mapper ingredient }.sum
|
316
|
+
end[0]
|
317
|
+
|
318
|
+
return nil if selected_recipe.nil?
|
319
|
+
|
320
|
+
average_procs = 1 if selected_recipe.find { |a| a[:name].downcase == 'blue reagent' } != nil
|
321
|
+
|
322
|
+
# remove recipes where one ingredient is used twice
|
323
|
+
# this usually happens because of incorrect substitution being
|
324
|
+
# found from bdocodex. or maybe they're not incorrect, and there
|
325
|
+
# are some seriously mysterious alchemy recipes out there...
|
326
|
+
ingredients_already_appeared = []
|
327
|
+
filtered_selected_recipe = selected_recipe.filter do |ingredient|
|
328
|
+
# set procs to 1 if blue reagent required
|
329
|
+
average_procs = 1 if ingredient[:name].downcase == 'blue reagent'
|
330
|
+
return false if ingredients_already_appeared.include? ingredient[:name]
|
331
|
+
ingredients_already_appeared.push(ingredient[:name])
|
332
|
+
true
|
333
|
+
end
|
334
|
+
|
335
|
+
return nil if filtered_selected_recipe.length != selected_recipe.length
|
336
|
+
|
337
|
+
total_ingredient_cost = (selected_recipe.map { |ing| ing[:price] * ing[:quant] }.sum / average_procs).floor
|
338
|
+
total_ingredient_stock = selected_recipe.filter { |ing| !ing[:is_npc_item] }.map { |ing| ing[:total_in_stock] }.sum
|
339
|
+
any_ingredient_out = selected_recipe.any? { |ing| ing[:total_in_stock].zero? || ing[:total_in_stock] < ing[:quant] }
|
340
|
+
|
341
|
+
body_string = "#{ENVData::RVT}&mainKey=#{item[:id]}&subKey=0&chooseKey=0&isUp=true&keyType=0&name=#{URI.encode_www_form_component(item[:name])}"
|
342
|
+
|
343
|
+
item_price_data = HTTParty.post(
|
344
|
+
URI(@market_sell_buy_url),
|
345
|
+
headers: ENVData.get_central_market_headers(ENVData.get_incap_cookie(@region_subdomain)),
|
346
|
+
body: body_string,
|
347
|
+
content_type: 'application/x-www-form-urlencoded'
|
348
|
+
)
|
349
|
+
|
350
|
+
# assuming we were able to find the item price list
|
351
|
+
if item_price_data&.dig('marketConditionList')
|
352
|
+
item_market_sell_price = item_price_data['marketConditionList']&.last&.dig('pricePerOne').to_i
|
353
|
+
raw_profit_with_procs = (item_market_sell_price - total_ingredient_cost) * average_procs
|
354
|
+
raw_profit_before_procs = item_market_sell_price - total_ingredient_cost
|
355
|
+
|
356
|
+
# TODO: allow the user to configure if the tool should show them. this would require some alteration
|
357
|
+
# to the output logger formatting
|
358
|
+
|
359
|
+
# skip out of stock / unprofitable recipes
|
360
|
+
return nil if raw_profit_before_procs.zero?
|
361
|
+
return nil if any_ingredient_out
|
362
|
+
return nil if total_ingredient_stock < 10
|
363
|
+
|
364
|
+
max_potion_count = selected_recipe.map { |ing| ing[:total_in_stock] == Float::INFINITY ? Float::INFINITY : (ing[:total_in_stock] / ing[:quant]).floor }.min
|
365
|
+
|
366
|
+
# important - the total cost of making the maximum possible
|
367
|
+
# amount of this recipe
|
368
|
+
# @type [Integer]
|
369
|
+
total_max_ingredient_cost = total_ingredient_cost * max_potion_count
|
370
|
+
|
371
|
+
# important - the untaxed sell price of the maximum amount of this
|
372
|
+
# recipe
|
373
|
+
# @type [Integer]
|
374
|
+
raw_max_market_sell_price = (max_potion_count * item_market_sell_price) * average_procs
|
375
|
+
|
376
|
+
# taxed profit on selling one of this this recipe, with
|
377
|
+
# average procs accounted for
|
378
|
+
# @type [Integer]
|
379
|
+
taxed_sell_profit_after_procs = (((PriceCalculator.calculate_taxed_price(item_market_sell_price) - total_ingredient_cost)) * average_procs).floor
|
380
|
+
|
381
|
+
# taxed profit on selling max amount of this this recipe, with
|
382
|
+
# average procs accounted for
|
383
|
+
# @type [Integer]
|
384
|
+
#
|
385
|
+
max_taxed_sell_profit_after_procs = (PriceCalculator.calculate_taxed_price(item_market_sell_price * max_potion_count) - total_max_ingredient_cost) * average_procs
|
386
|
+
|
387
|
+
return nil if max_taxed_sell_profit_after_procs.to_s.downcase == 'nan'
|
388
|
+
return nil if max_taxed_sell_profit_after_procs < 0
|
389
|
+
|
390
|
+
recipe_logger = RecipeLogger.new @cli
|
391
|
+
results = recipe_logger.log_recipe_data(item, selected_recipe, max_potion_count, item_market_sell_price, total_ingredient_cost, average_procs, total_max_ingredient_cost, raw_max_market_sell_price, max_taxed_sell_profit_after_procs, raw_profit_with_procs, taxed_sell_profit_after_procs)
|
392
|
+
{ information: results[:recipe_info], max_profit: max_taxed_sell_profit_after_procs, silver_per_hour: results[:silver_per_hour] }
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
def mapper(item)
|
397
|
+
item[:price].to_i * item[:quant].to_i
|
398
|
+
end
|
399
|
+
|
400
|
+
def get_stock_count(item_info)
|
401
|
+
return Float::INFINITY if item_info.dig(:is_npc_item)
|
402
|
+
|
403
|
+
item_info[:total_in_stock].to_i.zero? ? item_info[:count].to_i : item_info[:total_in_stock].to_i
|
404
|
+
end
|
405
|
+
|
406
|
+
# async def do_if_category_matches(options, &procedure)
|
407
|
+
# procedure.call.wait if options[:subcategory] == options[:subcat_to_match] || options[:all_subcategories]
|
408
|
+
# end
|
409
|
+
|
410
|
+
def do_if_category_matches(options, &procedure)
|
411
|
+
procedure.call if options[:all_subcategories]
|
412
|
+
procedure.call if options[:subcategory] == options[:subcat_to_match]
|
413
|
+
end
|
414
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Utils
|
4
|
+
class ArrayUtils
|
5
|
+
def self.make_permutations(list, n = 0, result = [], current = [], limit)
|
6
|
+
if n == list.length && n == limit
|
7
|
+
result.push current
|
8
|
+
else
|
9
|
+
list[n].each do |item|
|
10
|
+
make_permutations list, n + 1, result, [*current, item], limit
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
result
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.deep_permute(input, limit)
|
18
|
+
make_permutations input, limit
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
$request_verification_token = '__RequestVerificationToken=aVYGQPovG8EI6bRIagh8tbHJUhZlM-nH3UKVQaV9R9N0vODzmWcB747BHEsHaphwANvzsaNi5TCPlB-72-e1LadqAlL-bdkDkTqVh4gMnu81' # rubocop:disable Layout/LineLength
|
6
|
+
|
7
|
+
module Utils
|
8
|
+
# constants to use when user is configuring the tool
|
9
|
+
class CLIConstants
|
10
|
+
CATEGORY_OPTIONS = {
|
11
|
+
all: 'collates everything',
|
12
|
+
offensive: 'category 35, subcategory 1',
|
13
|
+
defensive: 'category 35, subcategory 2',
|
14
|
+
functional: 'category 35, subcategory 3',
|
15
|
+
potion: 'category 35, subcategory 5',
|
16
|
+
other: 'category 35, subcategory 8',
|
17
|
+
blood: 'searches "\'s blood"',
|
18
|
+
oil: "searches 'oil of'",
|
19
|
+
'alchemy stone': "searches 'stone of'",
|
20
|
+
reagent: "searches 'reagent'",
|
21
|
+
'black stone': 'category 30, subcategory 1',
|
22
|
+
'misc': 'category 25, subcategory 8',
|
23
|
+
'other tools': 'category 40, subcategory 10',
|
24
|
+
# 'magic crystal': "searches 'magic crystal'",
|
25
|
+
exit: 'stops the search'
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
REGION_DOMAINS = {
|
29
|
+
na: 'na-trade.naeu.playblackdesert.com',
|
30
|
+
eu: 'eu-trade.naeu.playblackdesert.com',
|
31
|
+
eu_console: 'eu-trade.console.playblackdesert.com',
|
32
|
+
na_console: 'na-trade.console.playblackdesert.com',
|
33
|
+
asia_console: 'asia-trade.console.playblackdesert.com',
|
34
|
+
sea: 'trade.sea.playblackdesert.com',
|
35
|
+
mena: 'trade.tr.playblackdesert.com',
|
36
|
+
kr: 'trade.kr.playblackdesert.com',
|
37
|
+
ru: 'trade.ru.playblackdesert.com',
|
38
|
+
jp: 'trade.jp.playblackdesert.com',
|
39
|
+
th: 'trade.th.playblackdesert.com',
|
40
|
+
tw: 'trade.tw.playblackdesert.com',
|
41
|
+
sa: 'blackdesert-tradeweb.playredfox.com',
|
42
|
+
exit: 'stops the search'
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
REGION_LANGUAGES = {
|
46
|
+
us: 'us english',
|
47
|
+
de: 'deutsch',
|
48
|
+
fr: 'français',
|
49
|
+
ru: 'русский',
|
50
|
+
es: 'español (na/eu)',
|
51
|
+
sp: 'español (sa)',
|
52
|
+
pt: 'português',
|
53
|
+
jp: '日本語',
|
54
|
+
kr: '한국어',
|
55
|
+
cn: '中文',
|
56
|
+
tw: '繁体中文',
|
57
|
+
th: 'ภาษาไทย',
|
58
|
+
tr: 'türkçe',
|
59
|
+
id: 'basa indonesia',
|
60
|
+
se: 'sea english',
|
61
|
+
gl: 'global lab',
|
62
|
+
exit: 'stops the search'
|
63
|
+
}.freeze
|
64
|
+
|
65
|
+
AGGRESSION_LEVELS = {
|
66
|
+
normal: 'evaluate one permutation of each recipe',
|
67
|
+
hyperaggressive: 'evaluate every substitution for every recipe',
|
68
|
+
exit: 'stops the search'
|
69
|
+
}.freeze
|
70
|
+
|
71
|
+
def self.set_rvt(rvt)
|
72
|
+
$request_verification_token = rvt
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# constants that will be used in by search / scraping scripts
|
77
|
+
class ENVData
|
78
|
+
MARKET_CACHE = './market_cache'
|
79
|
+
BDO_CODEX_CACHE = './bdo_codex_cache'
|
80
|
+
ERROR_LOG = './error.log'
|
81
|
+
# note that the following two tokens are different!! the first one is from a request cookie
|
82
|
+
# the second is from the dom of the actual BDO central market interface
|
83
|
+
COOKIE = '__RequestVerificationToken=aVYGQPovG8EI6bRIagh8tbHJUhZlM-nH3UKVQaV9R9N0vODzmWcB747BHEsHaphwANvzsaNi5TCPlB-72-e1LadqAlL-bdkDkTqVh4gMnu81' # rubocop:disable Layout/LineLength
|
84
|
+
RVT = '__RequestVerificationToken=yWbqJmiU4wcp2IRQGkbDfqMFs2fjCYx4UqVxg4umK8CvdbLhweMLZ1es-4SFWD8J1UfoqwbaQyo_YuzAkSzHWY8Wjyx4ttwZBvtu_pd--9U1' # rubocop:disable Layout/LineLength
|
85
|
+
WORLD_MARKET_LIST = '/GetWorldMarketList'
|
86
|
+
MARKET_SUB_LIST = '/GetWorldMarketSubList'
|
87
|
+
MARKET_SEARCH_LIST = '/GetWorldMarketSearchList'
|
88
|
+
MARKET_SELL_BUY_INFO = '/GetItemSellBuyInfo'
|
89
|
+
MARKET_HOT_LIST = '/GetWorldMarketHotList'
|
90
|
+
MARKET_WAIT_LIST = '/GetWorldMarketWaitList'
|
91
|
+
REQUEST_OPTS = {
|
92
|
+
method: 'post',
|
93
|
+
central_market_headers: {
|
94
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
95
|
+
'User-Agent':
|
96
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
97
|
+
Cookie: COOKIE,
|
98
|
+
Dnt: '1',
|
99
|
+
'x-cdn': 'Imperva'
|
100
|
+
},
|
101
|
+
bdo_codex_headers: {
|
102
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
103
|
+
'User-Agent':
|
104
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36',
|
105
|
+
Dnt: '1'
|
106
|
+
}
|
107
|
+
}.freeze
|
108
|
+
|
109
|
+
def self.get_central_market_headers(incap_cookie = '')
|
110
|
+
original_cookie = REQUEST_OPTS[:central_market_headers][:Cookie]
|
111
|
+
if rand > 0.5
|
112
|
+
REQUEST_OPTS[:central_market_headers]
|
113
|
+
{ **REQUEST_OPTS[:central_market_headers], 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', Cookie: "#{original_cookie};#{incap_cookie}" }
|
114
|
+
else
|
115
|
+
{ **REQUEST_OPTS[:central_market_headers], 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', Cookie: "#{original_cookie};#{incap_cookie}" }
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.get_incap_cookie(region_domain)
|
120
|
+
new_expiry = Date.today + 365
|
121
|
+
day = Time.now.strftime("%a")
|
122
|
+
month = Time.now.strftime("%b")
|
123
|
+
hour = rand(1..12)
|
124
|
+
minute = rand(1..60)
|
125
|
+
second = rand(1..60)
|
126
|
+
|
127
|
+
# TODO: EXPERIMENTAL - figure out how to simulate incapsula data
|
128
|
+
"visid_incap_2504212=xoYUIj+XR/acq/q6uc0RZyLI/2cAAAAAQUIPAAAAAAAMYr/xXVQYe6Eo4uVK+L6V; expires=#{day}, #{new_expiry.day} #{month} #{new_expiry.year} #{hour}:#{minute}:#{second} GMT; HttpOnly; path=/; Domain=.#{region_domain}; Secure; SameSite=None"
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.get_root_url(region)
|
132
|
+
"https://#{CLIConstants::REGION_DOMAINS[region.to_sym]}/Home"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Utils
|
4
|
+
class HashCache
|
5
|
+
def initialize(cache_file)
|
6
|
+
@cache_file = cache_file
|
7
|
+
@all_content = read_all
|
8
|
+
end
|
9
|
+
|
10
|
+
def read_all
|
11
|
+
begin
|
12
|
+
file_content = File.read(@cache_file)
|
13
|
+
return JSON.parse file_content unless file_content.empty?
|
14
|
+
|
15
|
+
{}
|
16
|
+
rescue
|
17
|
+
{}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def read(key)
|
22
|
+
@all_content&.dig(key)
|
23
|
+
end
|
24
|
+
|
25
|
+
def write(data)
|
26
|
+
item_map = read_all
|
27
|
+
File.open(@cache_file, 'w') do |file|
|
28
|
+
new_data = { **item_map, **data }
|
29
|
+
|
30
|
+
file.write new_data.to_json
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|