maimai_net 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.
@@ -0,0 +1,606 @@
1
+ require 'maimai_net/model'
2
+
3
+ require 'nokogiri'
4
+
5
+ module MaimaiNet
6
+ module Page
7
+ class Base
8
+ include ModuleExt
9
+ include ModuleExt::MethodCache
10
+
11
+ cache_method :data
12
+
13
+ # @param document [Nokogiri::HTML::Document]
14
+ # @raise [ArgumentError] invalid document structure
15
+ # @see #validate!
16
+ def initialize(document)
17
+ @document = document
18
+ @root = document.at_css('.main_wrapper')
19
+
20
+ initialize_extension
21
+ validate!
22
+ end
23
+
24
+ # @abstract extends variable initialization of the class.
25
+ # @return [void]
26
+ def initialize_extension
27
+ end
28
+
29
+ # validates document structure
30
+ # @raise [ArgumentError] invalid document structure
31
+ # @return [void]
32
+ def validate!
33
+ fail ArgumentError, 'provided document is not a valid maimai-net format' if @root.nil?
34
+ end
35
+
36
+ class << self
37
+ # @param content [String] provided page content
38
+ # @return [Page::Base]
39
+ def parse(content)
40
+ doc_class = Nokogiri::HTML
41
+ doc_class = Nokogiri::HTML5 if defined?(Nokogiri::HTML5) && %r{^\s*<!DOCTYPE\s+html>\s?}i.match?(content)
42
+ new(doc_class.parse(content))
43
+ end
44
+ end
45
+
46
+ inspect_permit_expression do |value|
47
+ !(Nokogiri::XML::Node === value)
48
+ end
49
+
50
+ protected :initialize_extension
51
+ end
52
+
53
+ require 'maimai_net/page-html_helper'
54
+ require 'maimai_net/page-player_data_helper'
55
+ require 'maimai_net/page-track_result_helper'
56
+
57
+ class PlayerData < Base
58
+ STAT_KEYS = %i(
59
+ count_sssp count_sss
60
+ count_ssp count_ss
61
+ count_sp count_s
62
+ count_clear
63
+ count_dx5
64
+ count_dx4 count_dx3
65
+ count_dx2 count_dx1
66
+ count_max count_ap
67
+ count_gfc count_fc
68
+ count_fdx2 count_fdx1
69
+ count_fs2 count_fs1 count_sync_play
70
+ ).freeze
71
+
72
+ STAT_FIELDS = {
73
+ ranks: [
74
+ %i(count_s count_sp count_ss count_ssp count_sss count_sssp),
75
+ %i(s s+ ss ss+ sss sss+),
76
+ ],
77
+ dx_ranks: [
78
+ Array.new(5) do |i| :"count_dx#{i.succ}" end,
79
+ Array.new(5) do |i| i.succ end,
80
+ ],
81
+ flags: [
82
+ %i(count_fc count_gfc count_ap count_max),
83
+ %i(fc gfc ap max),
84
+ ],
85
+ sync_flags: [
86
+ %i(count_sync_play count_fs1 count_fs2 count_fdx1 count_fdx2),
87
+ %i(play full_sync_miss full_sync_match full_deluxe_miss full_deluxe_match),
88
+ ],
89
+ }.freeze
90
+
91
+ def initialize_extension
92
+ super
93
+
94
+ @gameplay_block = @root.at_css('.see_through_block:nth-of-type(1)')
95
+ @player_block = @gameplay_block.at_css('.basic_block')
96
+ end
97
+
98
+ helper_method :data do
99
+ user_count_version_plays, user_count_series_plays = scan_int(strip(@gameplay_block.at_css('> .basic_block + .clearfix + div')))
100
+ deluxe_web_id = int(@gameplay_block.at_css('form[action$="/playerData/"] button[name=diff][value]:has(.diffbtn_selected)')['value'])
101
+ diff = Difficulty({deluxe_web_id: deluxe_web_id})
102
+
103
+ raw_stat = STAT_KEYS.zip(PlayerDataHelper.process(@gameplay_block)).to_h
104
+ diff_stat = {}
105
+ diff_stat[:clears] = raw_stat[:count_clear]
106
+ STAT_FIELDS.each do |k, (source, target)|
107
+ diff_stat[k] = target.zip(raw_stat.values_at(*source)).to_h
108
+ end
109
+ user_diff_stat = {diff.abbrev => Model::PlayerData::DifficultyStatistic.new(**diff_stat)}
110
+
111
+ Model::PlayerData::Data.new(
112
+ plate: Model::PlayerData::InfoPlate.new(
113
+ info: Model::PlayerCommon::Info.new(
114
+ name: strip(@player_block.at_css('.name_block')),
115
+ title: strip(@player_block.at_css('.trophy_block')),
116
+ grade: src(@player_block.at_css('> div > .clearfix ~ img:nth-of-type(1)')),
117
+ ),
118
+ decoration: Model::PlayerData::Decoration.new(
119
+ icon: URI(src(@player_block.at_css('> img:nth-of-type(1)'))),
120
+ ),
121
+ extended: Model::PlayerData::ExtendedInfo.new(
122
+ rating: get_int(strip(@player_block.at_css('.rating_block'))),
123
+ class_grade: src(@player_block.at_css('> div > .clearfix ~ img:nth-of-type(2)')),
124
+ partner_star_total: get_int(strip(@player_block.at_css('> div > .clearfix ~ div:nth-of-type(1)'))),
125
+ ),
126
+ ),
127
+ statistics: user_diff_stat,
128
+ )
129
+ end
130
+ end
131
+
132
+ class PhotoUpload < Base
133
+ helper_method :data do
134
+ images = @root.css('.container ~ div')
135
+ images.map do |elm|
136
+ elm = elm.at_css('> div')
137
+
138
+ chart_type = Pathname(src(elm.at_css('> .music_kind_icon'))).sub_ext('').basename
139
+ difficulty = Difficulty(Pathname(src(elm.at_css('> img:nth-of-type(2)'))).sub_ext('').sub('diff_', '').basename)
140
+
141
+ Model::PhotoUpload.new(
142
+ info: Model::Chart::InfoLite.new(
143
+ title: strip(elm.at_css('> div:not(.clearfix):nth-of-type(2)')),
144
+ type: chart_type.to_s,
145
+ difficulty: difficulty.id,
146
+ ),
147
+ url: URI(src(elm.at_css('> img:nth-of-type(3)'))),
148
+ location: strip(elm.at_css('> div:not(.clearfix):nth-of-type(4)')),
149
+ time: Time.strptime(
150
+ strip(elm.at_css('> div:not(.clearfix):nth-of-type(1)')) + ' +09:00',
151
+ '%Y/%m/%d %H:%M %z',
152
+ Time.now.localtime(32400),
153
+ ),
154
+ )
155
+ end
156
+ end
157
+ end
158
+
159
+ class ChartsetRecord < Base
160
+ def initialize_extension
161
+ super
162
+
163
+ @summary_block = @root.at_css('.basic_block')
164
+ end
165
+
166
+ helper_method :data do
167
+ song_info_elm = @summary_block.at_css('> div:nth-of-type(1)')
168
+
169
+ song_jacket = URI(src(@summary_block.at_css('> img:nth-of-type(1)')))
170
+ set_type = Pathname(src(song_info_elm.at_css('> div:nth-of-type(1) > img'))).sub_ext('').sub(/.+_/, '').basename.to_s
171
+ song_genre = strip(song_info_elm.at_css('> div:nth-of-type(1)'))
172
+ song_name = strip(song_info_elm.at_css('> div:nth-of-type(2)'))
173
+ song_artist = strip(song_info_elm.at_css('> div:nth-of-type(3)'))
174
+
175
+ song_info = Model::Chart::Song.new(
176
+ title: song_name,
177
+ artist: song_artist,
178
+ genre: song_genre,
179
+ jacket: song_jacket,
180
+ )
181
+
182
+ chart_records = {}
183
+ difficulty_data = {}
184
+ info_blocks = @summary_block.css('table > tbody > tr:has(.music_lv_back):has(form input[type=hidden][name=diff])')
185
+ chart_score_blocks = @summary_block.css('~ div:has(~ img)')
186
+
187
+ info_blocks.each do |info_block|
188
+ level_text = strip(info_block.at_css('.music_lv_back'))
189
+ form_block = info_block.at_css('form')
190
+ form_inputs = form_block.css('input[name][type=hidden]').map do |elm|
191
+ [elm['name'].to_sym, elm['value']]
192
+ end.to_h
193
+ difficulty = Difficulty(deluxe_web_id: form_inputs[:diff].to_i)
194
+ difficulty_data[difficulty.abbrev] ||= {}
195
+ difficulty_data[difficulty.abbrev].store(:info, Model::Chart::Info.new(
196
+ web_id: Model::WebID.parse(form_inputs[:idx]),
197
+ title: song_name,
198
+ type: set_type,
199
+ difficulty: difficulty.id,
200
+ level_text: level_text,
201
+ ))
202
+ end
203
+
204
+ chart_score_blocks.each do |chart_score_block|
205
+ difficulty = Difficulty(Pathname(src(chart_score_block.at_css('> img:nth-of-type(1)'))).sub_ext('').sub(/.+_/, '').basename)
206
+ chart_type = Pathname(src(chart_score_block.at_css('> img:nth-of-type(2)')))&.sub_ext('')&.sub(/.+_/, '')&.basename&.to_s or set_type
207
+ clearfixes = chart_score_block.css('.clearfix')
208
+
209
+ chart_record_block = clearfixes[0].at_css('~ div:nth-of-type(2)')
210
+ record_grade, record_flag, record_sync_flag = chart_record_block.css('> img').map do |elm|
211
+ value = Pathname(URI(src(elm)).path).sub_ext('')&.sub(/.+_/, '')&.basename.to_s
212
+ case value
213
+ when 'back'; nil
214
+ when *MaimaiNet::AchievementFlag::RECORD.values; MaimaiNet::AchievementFlag.new(record_key: value)
215
+ else value.to_sym
216
+ end
217
+ end
218
+ last_played_date, total_play_count = chart_record_block.css('table tr td:nth-of-type(2)').zip([
219
+ ->(content){Time.strptime(content + ' +09:00', '%Y/%m/%d %H:%M %z')},
220
+ method(:int),
221
+ ]).map do |elm, block|
222
+ block.call(strip(elm))
223
+ end
224
+
225
+ chart_best_block = clearfixes[1].at_css('~ div')
226
+ chart_score = strip(chart_best_block.at_css('.music_score_block:nth-of-type(1)')).to_f
227
+ chart_deluxe_scores = scan_int(strip(chart_best_block.at_css('.music_score_block:nth-of-type(2)')))
228
+ chart_deluxe_grade_elm = chart_best_block.at_css('.music_score_block:nth-of-type(2) img:nth-child(2)')
229
+ record_deluxe_grade = chart_deluxe_grade_elm ?
230
+ Pathname(src(chart_deluxe_grade_elm))&.sub_ext('')&.sub(/.+_/, '')&.basename&.to_s.to_i :
231
+ 0
232
+
233
+ difficulty_data[difficulty.abbrev].tap do |d|
234
+ d[:record] = Model::Record::Score.new(
235
+ web_id: d[:info].web_id,
236
+ score: chart_score,
237
+ deluxe_score: Model::Result::Progress.new(%i(value max).zip(chart_deluxe_scores).to_h),
238
+ grade: record_grade,
239
+ deluxe_grade: record_deluxe_grade,
240
+ flags: [record_flag, record_sync_flag].compact.map(&:to_sym),
241
+ )
242
+ d[:history] = Model::Record::History.new(
243
+ play_count: total_play_count,
244
+ last_played: last_played_date,
245
+ )
246
+ end
247
+ end
248
+
249
+ difficulty_data.each do |abbrev, data|
250
+ difficulty = Difficulty(abbrev: abbrev)
251
+
252
+ chart_records[abbrev] = Model::Record::ChartRecord.new(**data)
253
+ end
254
+
255
+ Model::Record::Data.new(
256
+ info: song_info,
257
+ charts: chart_records,
258
+ )
259
+ end
260
+ end
261
+
262
+ class TrackResult < Base
263
+ def initialize_extension
264
+ super
265
+
266
+ start_anchor = @root.at_css('img.title')
267
+ @otomodachi_block = start_anchor.at_css('~ div#vsUser > :first-child')
268
+ @score_block = start_anchor.at_css('~ div:not(#vsUser):nth-of-type(1)')
269
+ @breakdown_block = start_anchor.at_css('~ div:not(#vsUser):nth-of-type(2)')
270
+ end
271
+
272
+ helper_method :data do
273
+ result_breakdown = @breakdown_block.css('table.playlog_notes_detail tr:not(:first-child)').map do |row|
274
+ key = Pathname(URI(src(row.at_css('th img'))).path).sub_ext('').basename.to_s.to_sym
275
+ values = Model::Result::Judgment.new(**Model::Result::Judgment.members.zip(
276
+ row.css('td').map(&method(:strip)).map(&method(:get_int))
277
+ ).to_h)
278
+
279
+ [key, values]
280
+ end.to_h
281
+ result_offset_breakdown = @breakdown_block.css('.playlog_fl_block > div').map do |elm|
282
+ get_int(strip(elm))
283
+ end
284
+ result_rating_after = int(strip(@breakdown_block.at_css('.playlog_rating_detail_block > div:nth-of-type(1) .rating_block')))
285
+ result_rating_delta = int(strip(@breakdown_block.at_css('.playlog_rating_detail_block > div:nth-of-type(2)')))
286
+ result_combos, result_sync_scores = @breakdown_block.css('.playlog_score_block').map do |elm|
287
+ scan_int(strip(elm)).tap do |ary| ary.fill(0, ary.size...2) end
288
+ end
289
+
290
+ result_tour_members = @breakdown_block.css('.playlog_chara_container').map do |chara_block|
291
+ Model::Result::TourMember.new(
292
+ icon: URI(src(chara_block.at_css('.chara_cycle_img'))),
293
+ grade: get_int(strip(chara_block.at_css('.collection_chara_awakening_block_txt'))),
294
+ level: get_int(strip(chara_block.at_css('.playlog_chara_lv_block'))),
295
+ )
296
+ end
297
+
298
+ result_otomodachi_rival = nil
299
+ unless @otomodachi_block.nil? then
300
+ rival_name = strip(@otomodachi_block.at_css('> span > div').children[0])
301
+ rival_score = strip(@otomodachi_block.at_css('> span > div > span')).to_f
302
+ rival_rating = int(strip(@otomodachi_block.at_css('> div .rating_block')))
303
+
304
+ result_otomodachi_rival = Model::Result::RivalInfo.new(
305
+ player: Model::PlayerData::Lite.new(
306
+ name: rival_name,
307
+ rating: rival_rating,
308
+ ),
309
+ score: rival_score,
310
+ )
311
+ end
312
+
313
+ chart_web_id = Model::WebID.parse(@root.at_css('form[action$="/record/musicDetail/"] input[name=idx]')['value'])
314
+
315
+ Model::Result::Data.new(
316
+ track: TrackResultHelper.process(
317
+ @score_block,
318
+ web_id: chart_web_id,
319
+ result_combo: result_combos,
320
+ result_sync_score: result_sync_scores,
321
+ ),
322
+ breakdown: result_breakdown,
323
+ timing: Model::Result::Offset.new(**Model::Result::Offset.members.zip(result_offset_breakdown).to_h),
324
+ members: result_tour_members,
325
+ rival: result_otomodachi_rival,
326
+ )
327
+ end
328
+ end
329
+
330
+ class RecentTrack < Base
331
+ helper_method :data do
332
+ track_blocks = @root.css('img.title ~ div')
333
+
334
+ track_blocks.map do |elm|
335
+ ref_web_id = Model::Result::ReferenceWebID.parse(elm.at_css('form input[type=hidden][name=idx]')['value'])
336
+ Model::Result::TrackReference.new(
337
+ track: TrackResultHelper.process(elm),
338
+ ref_web_id: ref_web_id,
339
+ )
340
+ end.sort_by do |track_ref| track_ref.ref_web_id.time end
341
+ end
342
+ end
343
+
344
+ class MusicList < Base
345
+ helper_method :data do
346
+ track_group_blocks = @root.css('.screw_block')
347
+ track_segmented_blocks = {}
348
+ current_group = nil
349
+
350
+ if track_group_blocks.empty? then
351
+ track_segmented_blocks[current_group] = @root.css('div:has(> form[action$="/musicDetail/"] input[name=idx])')
352
+ else
353
+ @root.css('.see_through_block ~ div').each do |elm|
354
+ if elm.classes.include? 'screw_block' then
355
+ current_group = elm.content
356
+ next
357
+ end
358
+
359
+ track_segmented_blocks[current_group] ||= Nokogiri::XML::NodeSet.new(@document)
360
+ track_segmented_blocks[current_group] << elm
361
+ end
362
+ end
363
+
364
+ result = track_segmented_blocks.transform_values do |elm_group|
365
+ elm_group.map do |elm|
366
+ chart_info = Model::Chart::Info.new(
367
+ web_id: Model::WebID.parse(elm.at_css('input[name=idx][type=hidden]')['value']),
368
+ title: elm.at_css('.music_name_block').content,
369
+ type: Pathname(URI(src(elm.at_css('.music_kind_icon'))).path).sub_ext('').sub(/.+_/, '').basename.to_s,
370
+ difficulty: Difficulty(Pathname(URI(src(elm.at_css('form > img:nth-of-type(1)'))).path).sub_ext('').sub(/.+_/, '').basename.to_s).id,
371
+ level_text: elm.at_css('.music_lv_block').content,
372
+ )
373
+
374
+ score_info = nil
375
+
376
+ if !elm.at_css('.music_score_block:nth-of-type(1) > div').nil? then
377
+ if elm.at_css('.music_score_block > div > img').nil? then
378
+ # musicMybest page
379
+ Model::Record::InfoBest.new(
380
+ info: chart_info,
381
+ play_count: get_int(strip(elm.at_css('.music_score_block'))),
382
+ )
383
+ else
384
+ # ratingTargetMusic page
385
+ best_grade = Pathname(URI(src(elm.at_css('.music_score_block:nth-of-type(1) > div > img'))).path).sub_ext('').sub(/.+_/, '').basename.to_s.to_sym
386
+ score_info = Model::Result::ScoreOnly.new(
387
+ score: strip(elm.at_css('.music_score_block:nth-of-type(1)')).to_f,
388
+ grade: best_grade,
389
+ )
390
+
391
+ Model::Record::InfoRating.new(
392
+ info: chart_info,
393
+ score: score_info,
394
+ )
395
+ end
396
+ else
397
+ # music<Category> page
398
+ if !elm.at_css('.music_score_block').nil? then
399
+ best_deluxe_score = scan_int(strip(elm.at_css('.music_score_block:nth-of-type(2)')))
400
+ flairs = elm.css('.music_score_block ~ img:has(~ .clearfix)').map do |img|
401
+ Pathname(URI(src(img)).path).sub_ext('').sub(/.+_/, '').basename.to_s.to_sym.yield_self do |value|
402
+ value == :back ? nil : value
403
+ end
404
+ end
405
+ *record_flags, best_grade = flairs
406
+ record_flags.compact!
407
+ record_flags.map! &:to_s
408
+ record_flags.map! do |key| MaimaiNet::AchievementFlag.new(record_key: key) end
409
+ score_info = Model::Result::ScoreLite.new(
410
+ score: strip(elm.at_css('.music_score_block:nth-of-type(1)')).to_f,
411
+ deluxe_score: Model::Result::Progress.new(**%i(value max).zip(best_deluxe_score).to_h),
412
+ grade: best_grade,
413
+ flags: record_flags.map(&:to_sym),
414
+ )
415
+ end
416
+
417
+ Model::Record::InfoCategory.new(
418
+ info: chart_info,
419
+ score: score_info,
420
+ )
421
+ end
422
+ end
423
+ end
424
+
425
+ if result.size == 1 && result.key?(nil) then
426
+ result[nil]
427
+ else
428
+ result
429
+ end
430
+ end
431
+ end
432
+
433
+ class UserOption < Base
434
+ helper_method :data do
435
+ settings = {}
436
+
437
+ @root.css('select').each do |group_elm|
438
+ name = group_elm['name'].dup.freeze
439
+ selected = nil
440
+ option = group_elm.css('option').map do |choice_elm|
441
+ selected = choice_elm['value'].to_i if choice_elm['selected']
442
+ choice = MaimaiNet::UserOption::Choice.new(
443
+ name,
444
+ choice_elm['value'].to_i,
445
+ choice_elm.content.dup,
446
+ )
447
+
448
+ [choice.value, choice]
449
+ end.to_h
450
+
451
+ option = option.values if option.keys.each_cons(2).all? do |x, y| (y - x).pred.zero? end
452
+ option = MaimaiNet::UserOption::Option.new(
453
+ name,
454
+ option,
455
+ option[selected],
456
+ )
457
+
458
+ settings[name.to_sym] = option
459
+ end
460
+
461
+ settings
462
+ end
463
+ end
464
+
465
+ class UserFavorite < Base
466
+ helper_method :data do
467
+ [].tap do |music_list|
468
+ @root.css('form[action][method=post] .screw_block').each do |genre_elm|
469
+ genre_elm.css('+ .scroll_point + div[name] input[type=checkbox]').map do |song_elm|
470
+ Model::SongFavoriteInfo.new(
471
+ song: Model::SongEntry.new(
472
+ web_id: Model::WebID.parse(song_elm['value']),
473
+ title: strip(song_elm.at_css('+ .favorite_music_name')),
474
+ genre: genre_elm.content,
475
+ ),
476
+ flag: !song_elm['checked'].nil?,
477
+ )
478
+ end.tap &music_list.method(:concat)
479
+ end
480
+ end
481
+ end
482
+ end
483
+
484
+ class FinaleArchive < Base
485
+ STAT_KEYS = %i(
486
+ count_clear
487
+ count_s count_sp
488
+ count_ss count_ssp
489
+ count_sss count_max
490
+ count_fc count_gfc count_ap
491
+ count_sync_play
492
+ count_mf count_tmf count_sync_max
493
+ ).freeze
494
+
495
+ STAT_FIELDS = {
496
+ ranks: [
497
+ %i(count_s count_sp count_ss count_ssp count_sss count_max),
498
+ %i(s s+ ss ss+ sss max),
499
+ ],
500
+ flags: [
501
+ %i(count_fc count_gfc count_ap),
502
+ %i(fc gfc ap),
503
+ ],
504
+ sync_flags: [
505
+ %i(count_sync_play count_sync_max),
506
+ %i(play max),
507
+ ],
508
+ multi_flags: [
509
+ %i(count_mf count_tmf),
510
+ %i(max_fever strong_max_fever),
511
+ ]
512
+ }.freeze
513
+
514
+
515
+ # @return [void]
516
+ def initialize_extension
517
+ super
518
+
519
+ @root = @root.at_css('.finale_area')
520
+ @gameplay_block = @root.at_css('.see_through_block:nth-of-type(1)')
521
+ @collection_block = @root.at_css('.see_through_block:nth-of-type(2)')
522
+ @player_block = @root.at_css('.basic_block')
523
+ end
524
+
525
+ helper_method :data do
526
+ user_block_styles = Page::parse_style(@player_block.at_css('.finale_user_block'))
527
+ user_block_image = user_block_styles['background-image'][4...-1] rescue nil
528
+
529
+ user_rating_str = strip(@player_block.at_css('.finale_rating'))
530
+ user_rating_current, user_rating_max = user_rating_str.scan(/[1-9]*[0-9]\.[0-9]+/).map &:to_f
531
+ user_currency = strip(@player_block.at_css('.finale_point_block')).scan(/\d+/).map(&method(:int))
532
+ user_currency.fill(0, user_currency.size...3)
533
+
534
+ user_count_version_plays, user_count_sync_plays,
535
+ user_count_versus_wins, user_count_sync_amount = @gameplay_block.css('table td')
536
+ .map(&method(:strip)).map(&method(:get_int))
537
+
538
+ user_diff_stat = {}
539
+ @gameplay_block.css('div.finale_musiccount_block').each do |difficulty_block|
540
+ key = difficulty_block.attribute('id').value.slice(0...-4).to_sym
541
+ diff = Difficulty(key)
542
+
543
+ diff_stat = {}
544
+ raw_stat = {}
545
+ raw_stat[:total_score] = get_int(strip(difficulty_block.at_css('div:nth-child(1)')))
546
+ raw_stat.update STAT_KEYS.zip(PlayerDataHelper.process(difficulty_block)).to_h
547
+
548
+ diff_stat[:total_score] = raw_stat[:total_score]
549
+ diff_stat[:clears] = raw_stat[:count_clear]
550
+ STAT_FIELDS.each do |k, (source, target)|
551
+ diff_stat[k] = target.zip(raw_stat.values_at(*source)).to_h
552
+ end
553
+
554
+ user_diff_stat[diff.abbrev] = Model::FinaleArchive::DifficultyStatistic.new(**diff_stat)
555
+ end
556
+
557
+ user_collection_count = int(strip(@collection_block.at_css('div:nth-child(1)')))
558
+
559
+ Model::FinaleArchive::Data.new(
560
+ info: Model::PlayerCommon::Info.new(
561
+ name: strip(@player_block.at_css('.finale_username')),
562
+ title: strip(@player_block.at_css('.finale_trophy_inner_block')),
563
+ grade: src(@player_block.at_css('.finale_grade')),
564
+ ),
565
+ decoration: Model::FinaleArchive::Decoration.new(
566
+ icon: URI(src(@player_block.at_css('.finale_icon'))),
567
+ player_frame: URI(user_block_image),
568
+ nameplate: URI(src(@player_block.at_css('.finale_nameplate'))),
569
+ ),
570
+ extended: Model::FinaleArchive::ExtendedInfo.new(
571
+ rating: user_rating_current,
572
+ rating_highest: user_rating_max,
573
+
574
+ region_count: int(strip(@player_block.at_css('.finale_region_block'))),
575
+
576
+ currency: Model::FinaleArchive::Currency.new(
577
+ amount: user_currency[0],
578
+ piece: user_currency[1],
579
+ parts: user_currency[2],
580
+ ),
581
+ partner_level_total: int(strip(@player_block.at_css('.finale_totallv')).scan(/\d+/).first),
582
+ ),
583
+ statistics: user_diff_stat,
584
+ )
585
+ end
586
+ end
587
+
588
+ class << self
589
+ def parse_style(element)
590
+ case element
591
+ when NilClass
592
+ return {}
593
+ when Nokogiri::XML::Element
594
+ else
595
+ fail TypeError, "expected HTML Node, given #{element.class}"
596
+ end
597
+
598
+ element['style']&.split(/\s*;\s*/)&.map do |line|
599
+ line.split(/\s*:\s*/, 2)
600
+ end.to_h
601
+ end
602
+ end
603
+
604
+ require 'maimai_net/page-debug'
605
+ end
606
+ end
@@ -0,0 +1,28 @@
1
+ module MaimaiNet
2
+ # includes AutoConstants into invokable class
3
+ module IncludeAutoConstant
4
+ refine Kernel do
5
+ include CoreExt::KernelAutoConstantInclusion
6
+ end
7
+ end
8
+
9
+ # converts any object into a single-element array unless it's an array
10
+ module ObjectAsArray
11
+ refine Object do
12
+ def as_array
13
+ [self]
14
+ end
15
+
16
+ alias as_unique_array as_array
17
+ end
18
+ refine Array do
19
+ def as_array
20
+ self
21
+ end
22
+
23
+ def as_unique_array
24
+ uniq
25
+ end
26
+ end
27
+ end
28
+ end