bnet_scraper 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. data/CHANGELOG.md +9 -0
  2. data/Gemfile +1 -1
  3. data/LICENSE +1 -1
  4. data/README.md +51 -31
  5. data/bnet_scraper.gemspec +3 -1
  6. data/fixtures/vcr_cassettes/demon_achievements.yml +246 -0
  7. data/fixtures/vcr_cassettes/demon_leagues.yml +359 -0
  8. data/fixtures/vcr_cassettes/demon_match_history.yml +241 -0
  9. data/fixtures/vcr_cassettes/demon_matches.yml +240 -0
  10. data/fixtures/vcr_cassettes/demon_profile.yml +1428 -0
  11. data/fixtures/vcr_cassettes/demon_profile_leagues.yml +1291 -0
  12. data/fixtures/vcr_cassettes/full_demon_scrape.yml +5449 -0
  13. data/fixtures/vcr_cassettes/invalid_achievement.yml +199 -0
  14. data/fixtures/vcr_cassettes/invalid_leagues.yml +199 -0
  15. data/fixtures/vcr_cassettes/invalid_matches.yml +199 -0
  16. data/fixtures/vcr_cassettes/invalid_profile.yml +199 -0
  17. data/fixtures/vcr_cassettes/new_league.yml +595 -0
  18. data/fixtures/vcr_cassettes/profile_invalid.yml +199 -0
  19. data/fixtures/vcr_cassettes/profile_not_laddered.yml +443 -0
  20. data/fixtures/vcr_cassettes/realm_status.yml +578 -0
  21. data/lib/bnet_scraper/starcraft2.rb +7 -8
  22. data/lib/bnet_scraper/starcraft2/achievement_scraper.rb +5 -5
  23. data/lib/bnet_scraper/starcraft2/league.rb +54 -0
  24. data/lib/bnet_scraper/starcraft2/league_scraper.rb +3 -1
  25. data/lib/bnet_scraper/starcraft2/match.rb +15 -0
  26. data/lib/bnet_scraper/starcraft2/match_history_scraper.rb +9 -18
  27. data/lib/bnet_scraper/starcraft2/profile.rb +45 -0
  28. data/lib/bnet_scraper/starcraft2/profile_scraper.rb +68 -44
  29. data/spec/spec_helper.rb +14 -0
  30. data/spec/starcraft2/achievement_scraper_spec.rb +67 -83
  31. data/spec/starcraft2/league_scraper_spec.rb +25 -59
  32. data/spec/starcraft2/league_spec.rb +43 -0
  33. data/spec/starcraft2/match_history_scraper_spec.rb +19 -39
  34. data/spec/starcraft2/profile_scraper_spec.rb +36 -141
  35. data/spec/starcraft2/profile_spec.rb +46 -0
  36. data/spec/starcraft2/status_scraper_spec.rb +12 -5
  37. data/spec/starcraft2_spec.rb +19 -36
  38. data/spec/support/shared/sc2_scraper.rb +28 -13
  39. metadata +57 -42
  40. data/spec/support/achievements.html +0 -1156
  41. data/spec/support/failure.html +0 -565
  42. data/spec/support/initial_league.html +0 -1082
  43. data/spec/support/initial_leagues.html +0 -3598
  44. data/spec/support/league.html +0 -8310
  45. data/spec/support/leagues.html +0 -3810
  46. data/spec/support/load_fakeweb.rb +0 -42
  47. data/spec/support/matches.html +0 -1228
  48. data/spec/support/no_ladder.html +0 -967
  49. data/spec/support/no_ladder_leagues.html +0 -664
  50. data/spec/support/profile.html +0 -1074
  51. data/spec/support/status.html +0 -1
@@ -94,15 +94,14 @@ module BnetScraper
94
94
  # scraped from the website
95
95
  def self.full_profile_scrape bnet_id, account, region = 'na'
96
96
  profile_scraper = ProfileScraper.new bnet_id: bnet_id, account: account, region: region
97
- profile_output = profile_scraper.scrape
98
-
99
- parsed_leagues = []
100
- profile_output[:leagues].each do |league|
101
- league_scraper = LeagueScraper.new url: league[:href]
102
- parsed_leagues << league_scraper.scrape
97
+ profile = profile_scraper.scrape
98
+ profile.leagues.each do |league|
99
+ league.scrape_league
103
100
  end
104
- profile_output[:leagues] = parsed_leagues
105
- return profile_output
101
+ profile.achievements
102
+ profile.match_history
103
+
104
+ return profile
106
105
  end
107
106
 
108
107
  # Determine if Supplied profile is valid. Useful for validating now before an
@@ -72,11 +72,11 @@ module BnetScraper
72
72
  def scrape_progress
73
73
  progress_ach = response.css("#progress-module .achievements-progress:nth(2) span")
74
74
  @progress = {
75
- liberty_campaign: progress_ach[0].inner_text,
76
- exploration: progress_ach[1].inner_text,
77
- custom_game: progress_ach[2].inner_text,
78
- cooperative: progress_ach[3].inner_text,
79
- quick_match: progress_ach[4].inner_text,
75
+ liberty_campaign: response.css(".progress-tile:nth-child(1) .profile-progress span").inner_text,
76
+ exploration: response.css(".progress-tile:nth-child(2) .profile-progress span").inner_text,
77
+ custom_game: response.css(".progress-tile:nth-child(3) .profile-progress span").inner_text,
78
+ cooperative: response.css(".progress-tile:nth-child(4) .profile-progress span").inner_text,
79
+ quick_match: response.css(".progress-tile:nth-child(5) .profile-progress span").inner_text,
80
80
  }
81
81
  end
82
82
 
@@ -0,0 +1,54 @@
1
+ module BnetScraper
2
+ module Starcraft2
3
+ class League
4
+ attr_accessor :id, :name, :href, :season, :name, :division, :size, :random, :bnet_id,
5
+ :account
6
+
7
+ def initialize options = {}
8
+ options.each_key do |key|
9
+ self.send "#{key}=", options[key]
10
+ end
11
+ end
12
+
13
+ def name
14
+ scrape_or_return :@name
15
+ end
16
+
17
+ def season
18
+ scrape_or_return :@season
19
+ end
20
+
21
+ def division
22
+ scrape_or_return :@division
23
+ end
24
+
25
+ def size
26
+ scrape_or_return :@size
27
+ end
28
+
29
+ def bnet_id
30
+ scrape_or_return :@bnet_id
31
+ end
32
+
33
+ def account
34
+ scrape_or_return :@account
35
+ end
36
+
37
+ def scrape_or_return attribute
38
+ if self.instance_variable_get(attribute)
39
+ return self.instance_variable_get(attribute)
40
+ else
41
+ scrape_league
42
+ self.instance_variable_get(attribute)
43
+ end
44
+ end
45
+
46
+ def scrape_league
47
+ scraped_data = LeagueScraper.new(url: href).scrape
48
+ scraped_data.each_key do |key|
49
+ self.send "#{key}=", scraped_data[key]
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,5 @@
1
+ require 'bnet_scraper/starcraft2/league'
2
+
1
3
  module BnetScraper
2
4
  module Starcraft2
3
5
  # This pulls information on a specific league for a specific account. It is best used either in conjunction with a
@@ -34,7 +36,7 @@ module BnetScraper
34
36
  if @response.success?
35
37
  @response = Nokogiri::HTML(@response.body)
36
38
  value = @response.css(".data-title .data-label h3").inner_text().strip
37
- header_regex = /Season (\d{1}) - \s+(\dv\d)( Random)? (\w+)\s+Division (.+)/
39
+ header_regex = /(.+) -\s+(\dv\d)( Random)? (\w+)\s+Division (.+)/
38
40
  header_values = value.match(header_regex).to_a
39
41
  header_values.shift()
40
42
  @season, @size, @random, @division, @name = header_values
@@ -0,0 +1,15 @@
1
+ module BnetScraper
2
+ module Starcraft2
3
+ class Match
4
+ attr_accessor :outcome, :map_name, :type, :date
5
+
6
+ def won?
7
+ @outcome == :won
8
+ end
9
+
10
+ def lost?
11
+ @outcome == :loss
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,3 +1,5 @@
1
+ require 'bnet_scraper/starcraft2/match'
2
+
1
3
  module BnetScraper
2
4
  module Starcraft2
3
5
  # This pulls the 25 most recent matches played for an account. Note that this is only as up-to-date as battle.net is, and
@@ -42,29 +44,18 @@ module BnetScraper
42
44
 
43
45
  response.css('.match-row').each do |m|
44
46
  match = {}
47
+ match = Match.new
45
48
 
46
49
  cells = m.css('td')
47
- match[:map_name] = cells[1].inner_text
48
- match[:type] = cells[2].inner_text
49
- match[:outcome] = (cells[3].inner_text.strip == 'Win' ? :win : :loss)
50
- match[:date] = cells[4].inner_text.strip
50
+ match.map_name = cells[1].inner_text
51
+ match.type = cells[2].inner_text
52
+ match.outcome = (cells.css('.match-loss') ? :win : :loss)
53
+ match.date = cells[4].inner_text.strip
54
+
51
55
  @matches << match
52
- if match[:outcome] == :win
53
- @wins += 1
54
- else
55
- @losses += 1
56
- end
57
- output
58
56
  end
59
57
 
60
- end
61
-
62
- def output
63
- {
64
- matches: @matches,
65
- wins: @wins,
66
- losses: @losses
67
- }
58
+ @matches
68
59
  end
69
60
  end
70
61
  end
@@ -0,0 +1,45 @@
1
+
2
+ module BnetScraper
3
+ module Starcraft2
4
+ class Profile
5
+ attr_accessor :portrait, :url, :achievement_points, :current_solo_league,
6
+ :highest_solo_league, :current_team_league, :highest_team_league,
7
+ :career_games, :games_this_season, :terran_swarm_level, :protoss_swarm_level,
8
+ :zerg_swarm_level, :leagues, :swarm_levels
9
+
10
+ def initialize options = {}
11
+ options.each_key do |key|
12
+ self.send "#{key}=", options[key]
13
+ end
14
+ end
15
+
16
+ def achievements
17
+ @achievements ||= AchievementScraper.new(url: url).scrape
18
+ end
19
+
20
+ def recent_achievements
21
+ achievements[:recent]
22
+ end
23
+
24
+ def progress_achievements
25
+ achievements[:progress]
26
+ end
27
+
28
+ def showcase_achievements
29
+ achievements[:showcase]
30
+ end
31
+
32
+ def match_history
33
+ @match_history ||= MatchHistoryScraper.new(url: url).scrape
34
+ end
35
+
36
+ def swarm_levels
37
+ {
38
+ zerg: @zerg_swarm_level,
39
+ protoss: @protoss_swarm_level,
40
+ terran: @terran_swarm_level
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,3 +1,5 @@
1
+ require 'bnet_scraper/starcraft2/profile'
2
+
1
3
  module BnetScraper
2
4
  module Starcraft2
3
5
  # This pulls basic profile information for an account, as well as an array of league URLs. This is a good starting
@@ -28,19 +30,22 @@ module BnetScraper
28
30
  # ]
29
31
  # }
30
32
  class ProfileScraper < BaseScraper
31
- attr_reader :achievement_points, :career_games, :race, :leagues, :most_played,
32
- :games_this_season, :highest_solo_league, :current_solo_league, :highest_team_league,
33
- :current_team_league, :portrait
33
+ attr_reader :achievement_points, :career_games, :leagues, :games_this_season,
34
+ :highest_solo_league, :current_solo_league, :highest_team_league,
35
+ :current_team_league, :portrait, :terran_swarm_level, :protoss_swarm_level,
36
+ :zerg_swarm_level, :profile
34
37
 
35
38
  def initialize options = {}
36
39
  super
37
40
  @leagues = []
41
+ @profile ||= Profile.new url: profile_url
38
42
  end
39
43
 
40
44
  def scrape
41
45
  get_profile_data
42
46
  get_league_list
43
- output
47
+
48
+ @profile
44
49
  end
45
50
 
46
51
  # scrapes the profile page for basic account information
@@ -50,64 +55,83 @@ module BnetScraper
50
55
  if response.success?
51
56
  html = Nokogiri::HTML(response.body)
52
57
 
53
- # Portraits use spritemaps, so we extract positions and map to
54
- # PORTRAITS.
55
- @portrait = begin
56
- portrait = html.css("#profile-header #portrait span").attr('style').to_s.scan(/url\('(.*?)'\) ([\-\d]+)px ([\-\d]+)px/).flatten
57
- portrait_map, portrait_size = portrait[0].scan(/(\d)\-(\d+)\.jpg/)[0]
58
- portrait_position = (((0-portrait[2].to_i) / portrait_size.to_i) * 6) + ((0-portrait[1].to_i) / portrait_size.to_i + 1)
59
- PORTRAITS[portrait_map.to_i][portrait_position-1]
60
- rescue
61
- nil
62
- end
58
+ @profile.achievement_points = html.css("#profile-header h3").inner_html()
59
+ @profile.career_games = html.css(".career-stat-block:nth-child(4) .stat-value").inner_html()
60
+ @profile.games_this_season = html.css(".career-stat-block:nth-child(5) .stat-value").inner_html()
61
+
62
+ get_portrait html
63
+ get_solo_league_info html
64
+ get_team_league_info html
65
+ get_swarm_levels html
66
+ else
67
+ raise BnetScraper::InvalidProfileError
68
+ end
69
+ end
70
+
71
+ def get_portrait html
72
+ # Portraits use spritemaps, so we extract positions and map to
73
+ # PORTRAITS.
74
+ @profile.portrait = begin
75
+ portrait = html.css("#profile-header #portrait span").attr('style').to_s.scan(/url\('(.*?)'\) ([\-\d]+)px ([\-\d]+)px/).flatten
76
+ portrait_map, portrait_size = portrait[0].scan(/(\d)\-(\d+)\.jpg/)[0]
77
+ portrait_position = (((0-portrait[2].to_i) / portrait_size.to_i) * 6) + ((0-portrait[1].to_i) / portrait_size.to_i + 1)
78
+ PORTRAITS[portrait_map.to_i][portrait_position-1]
79
+ rescue
80
+ nil
81
+ end
82
+ end
63
83
 
64
- @race = html.css(".stat-block:nth-child(4) h2").inner_html()
65
- @achievement_points = html.css("#profile-header h3").inner_html()
66
- @career_games = html.css(".stat-block:nth-child(3) h2").inner_html()
67
- @most_played = html.css(".stat-block:nth-child(2) h2").inner_html()
68
- @games_this_season = html.css(".stat-block:nth-child(1) h2").inner_html()
69
-
70
- if html.css("#best-finish-SOLO div")[0]
71
- @highest_solo_league = html.css("#best-finish-SOLO div")[0].children[2].inner_text.strip
72
- if html.css("#best-finish-SOLO div")[0].children[8]
73
- @current_solo_league = html.css("#best-finish-SOLO div")[0].children[8].inner_text.strip
74
- else
75
- @current_solo_league = html.css("#best-finish-SOLO div")[0].children[5].inner_text.strip
76
- end
84
+ def get_solo_league_info html
85
+ if html.css("#best-finish-SOLO div")[0]
86
+ @profile.highest_solo_league = html.css("#best-finish-SOLO div")[0].children[2].inner_text.strip
87
+ if html.css("#best-finish-SOLO div")[0].children[8]
88
+ @profile.current_solo_league = html.css("#best-finish-SOLO div")[0].children[8].inner_text.strip
77
89
  else
78
- @highest_solo_league = "Not Yet Ranked"
79
- @current_solo_league = "Not Yet Ranked"
90
+ @profile.current_solo_league = "Not Yet Ranked"
80
91
  end
92
+ else
93
+ @profile.highest_solo_league = "Not Yet Ranked"
94
+ @profile.current_solo_league = "Not Yet Ranked"
95
+ end
96
+ end
81
97
 
82
- if html.css("#best-finish-TEAM div")[0]
83
- @highest_team_league = html.css("#best-finish-TEAM div")[0].children[2].inner_text.strip
84
- if html.css("#best-finish-TEAM div")[0].children[8]
85
- @current_team_league = html.css("#best-finish-TEAM div")[0].children[8].inner_text.strip
86
- else
87
- @current_team_league = html.css("#best-finish-TEAM div")[0].children[5].inner_text.strip
88
- end
98
+ def get_team_league_info html
99
+ if html.css("#best-finish-TEAM div")[0]
100
+ @profile.highest_team_league = html.css("#best-finish-TEAM div")[0].children[2].inner_text.strip
101
+ if html.css("#best-finish-TEAM div")[0].children[8]
102
+ @profile.current_team_league = html.css("#best-finish-TEAM div")[0].children[8].inner_text.strip
89
103
  else
90
- @highest_team_league = "Not Yet Ranked"
91
- @current_team_league = "Not Yet Ranked"
104
+ @profile.current_team_league = "Not Yet Ranked"
92
105
  end
93
-
94
106
  else
95
- raise BnetScraper::InvalidProfileError
107
+ @profile.highest_team_league = "Not Yet Ranked"
108
+ @profile.current_team_league = "Not Yet Ranked"
96
109
  end
97
110
  end
98
111
 
112
+ def get_swarm_levels html
113
+ @profile.protoss_swarm_level = get_swarm_level :protoss, html
114
+ @profile.terran_swarm_level = get_swarm_level :terran, html
115
+ @profile.zerg_swarm_level = get_swarm_level :zerg, html
116
+ end
117
+
118
+ def get_swarm_level race, html
119
+ level = html.css(".race-level-block.#{race} .level-value").inner_html
120
+ level.match(/Level (\d+)/)[1].to_i
121
+ end
122
+
99
123
  # scrapes the league list from account's league page and sets an array of URLs
100
124
  def get_league_list
101
125
  response = Faraday.get profile_url + "ladder/leagues"
102
126
  if response.success?
103
127
  html = Nokogiri::HTML(response.body)
104
128
 
105
- @leagues = html.css("a[href*='#current-rank']").map do |league|
106
- {
107
- name: league.inner_html().strip,
129
+ @profile.leagues = html.css("a[href*='#current-rank']").map do |league|
130
+ League.new({
131
+ name: league.inner_text().strip,
108
132
  id: league.attr('href').sub('#current-rank',''),
109
133
  href: "#{profile_url}ladder/#{league.attr('href')}"
110
- }
134
+ })
111
135
  end
112
136
  else
113
137
  raise BnetScraper::InvalidProfileError
@@ -1,5 +1,19 @@
1
1
  $:.push File.join(File.dirname(__FILE__), '..', 'lib')
2
2
 
3
3
  require 'bnet_scraper'
4
+ require 'pry'
5
+ require 'vcr'
4
6
  Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f }
5
7
 
8
+ VCR.configure do |config|
9
+ config.cassette_library_dir = 'fixtures/vcr_cassettes'
10
+ config.hook_into :fakeweb
11
+ end
12
+
13
+ RSpec.configure do |config|
14
+ config.treat_symbols_as_metadata_keys_with_true_values = true
15
+ config.around(:each, :vcr) do |example|
16
+ name = example.metadata[:full_description].split(/\s+/, 2).join("/").underscore.gsub(/[^\w\/]+/, "_")
17
+ VCR.use_cassette(name) { example.call }
18
+ end
19
+ end
@@ -9,97 +9,81 @@ describe BnetScraper::Starcraft2::AchievementScraper do
9
9
  let(:subject) { scraper_class.new(url: url) }
10
10
  end
11
11
 
12
- describe '#get_response' do
13
- it 'should get the HTML response to be scraped' do
14
- subject.response.should be_nil
15
- subject.get_response
16
- subject.response.should_not be_nil
17
- end
18
- end
19
-
20
- describe '#scrape' do
21
- it 'should call get_response and trigger scraper methods' do
22
- subject.should_receive(:get_response)
23
- subject.should_receive(:scrape_progress)
24
- subject.should_receive(:scrape_recent)
25
- subject.should_receive(:scrape_showcase)
26
- subject.scrape
27
- end
28
-
12
+ describe 'scrape' do
29
13
  it 'should return InvalidProfileError if response is 404' do
30
- url = 'http://us.battle.net/sc2/en/profile/2377239/1/SomeDude/achievements/'
31
- scraper = BnetScraper::Starcraft2::AchievementScraper.new(url: url)
32
- expect { scraper.scrape }.to raise_error(BnetScraper::InvalidProfileError)
33
- end
34
- end
35
-
36
- describe '#scrape_showcase' do
37
- before :each do
38
- subject.get_response
39
- subject.scrape_showcase
40
- end
41
-
42
- it 'should set the showcase' do
43
- subject.showcase.should have(5).achievements
44
- end
45
- end
46
-
47
- describe '#scrape_recent' do
48
- before :each do
49
- subject.get_response
50
- subject.scrape_recent
51
- end
52
-
53
- it 'should have the title of the achievement' do
54
- subject.recent[0][:title].should == 'Blink of an Eye'
55
- end
56
-
57
- it 'should have the description of the achievement' do
58
- # this is a cop-out because the string contains UTF-8. Please fix this. - Cad
59
- subject.recent[0][:description].should be_a String
60
- end
61
-
62
- it 'should have the date the achievement was earned' do
63
- subject.recent[0][:earned].should == '3/5/2012'
64
- end
65
- end
66
-
67
- describe '#scrape_progress' do
68
- before :each do
69
- subject.get_response
70
- subject.scrape_progress
71
- end
72
-
73
- it 'should set the liberty campaign progress' do
74
- subject.progress[:liberty_campaign].should == '1580'
75
- end
76
-
77
- it 'should set the exploration progress' do
78
- subject.progress[:exploration].should == '480'
79
- end
80
-
81
- it 'should set the custom game progress' do
82
- subject.progress[:custom_game].should == '330'
83
- end
84
-
85
- it 'should set the cooperative progress' do
86
- subject.progress[:cooperative].should == '660'
14
+ VCR.use_cassette('invalid_achievement') do
15
+ url = 'http://us.battle.net/sc2/en/profile/2377239/1/SomeDude/achievements/'
16
+ scraper = BnetScraper::Starcraft2::AchievementScraper.new(url: url)
17
+ expect { scraper.scrape }.to raise_error(BnetScraper::InvalidProfileError)
18
+ end
87
19
  end
88
20
 
89
- it 'should set the quick match progress' do
90
- subject.progress[:quick_match].should == '170'
21
+ context 'valid' do
22
+ before do
23
+ VCR.use_cassette('demon_achievements') do
24
+ subject.get_response
25
+ end
26
+ end
27
+
28
+ describe 'showcase' do
29
+ before { subject.scrape_showcase }
30
+ its(:showcase) { should have(5).achievements }
31
+ end
32
+
33
+ describe 'recent' do
34
+ before { subject.scrape_recent }
35
+
36
+ it 'should have the title of the achievement' do
37
+ subject.recent[0][:title].should == 'Three-way Dominant'
38
+ end
39
+
40
+ it 'should have the description of the achievement' do
41
+ # this is a cop-out because the string contains UTF-8. Please fix this. - Cad
42
+ subject.recent[0][:description].should be_a String
43
+ end
44
+
45
+ it 'should have the date the achievement was earned' do
46
+ subject.recent[0][:earned].should == '2/7/2013'
47
+ end
48
+ end
49
+
50
+ describe 'progress' do
51
+ before { subject.scrape_progress }
52
+
53
+ it 'should set the liberty campaign progress' do
54
+ subject.progress[:liberty_campaign].should == '1580'
55
+ end
56
+
57
+ it 'should set the exploration progress' do
58
+ subject.progress[:exploration].should == '0'
59
+ end
60
+
61
+ it 'should set the custom game progress' do
62
+ subject.progress[:custom_game].should == '1280'
63
+ end
64
+
65
+ it 'should set the cooperative progress' do
66
+ subject.progress[:cooperative].should == '120'
67
+ end
68
+
69
+ it 'should set the quick match progress' do
70
+ subject.progress[:quick_match].should == '220'
71
+ end
72
+ end
91
73
  end
92
74
  end
93
75
 
94
76
  describe '#output' do
95
77
  it 'should return the scraped data when scrape has been called' do
96
- subject.scrape
97
- expected = {
98
- recent: subject.recent,
99
- showcase: subject.showcase,
100
- progress: subject.progress
101
- }
102
- subject.output.should == expected
78
+ VCR.use_cassette('demon_achievements') do
79
+ subject.scrape
80
+ expected = {
81
+ recent: subject.recent,
82
+ showcase: subject.showcase,
83
+ progress: subject.progress
84
+ }
85
+ subject.output.should == expected
86
+ end
103
87
  end
104
88
  end
105
89
  end