beway 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. data/Gemfile +7 -0
  2. data/Gemfile.lock +34 -0
  3. data/README +43 -0
  4. data/README.rdoc +43 -0
  5. data/Rakefile +19 -0
  6. data/bin/beway +5 -0
  7. data/doc/Beway.html +185 -0
  8. data/doc/Beway/Auction.html +802 -0
  9. data/doc/Beway/AuctionParseError.html +160 -0
  10. data/doc/Beway/Bidder.html +531 -0
  11. data/doc/Beway/BidderError.html +160 -0
  12. data/doc/Beway/CliRunner.html +817 -0
  13. data/doc/Beway/EbayData.html +453 -0
  14. data/doc/Beway/EbayDataParseError.html +160 -0
  15. data/doc/Beway/InvalidUrlError.html +160 -0
  16. data/doc/README.html +175 -0
  17. data/doc/created.rid +6 -0
  18. data/doc/index.html +225 -0
  19. data/doc/lib/beway/auction_rb.html +56 -0
  20. data/doc/lib/beway/bidder_rb.html +54 -0
  21. data/doc/lib/beway/cli_runner_rb.html +52 -0
  22. data/doc/lib/beway/ebay_data_rb.html +58 -0
  23. data/doc/rdoc.css +706 -0
  24. data/lib/beway.rb +4 -0
  25. data/lib/beway/auction.rb +150 -0
  26. data/lib/beway/bidder.rb +100 -0
  27. data/lib/beway/cli_runner.rb +184 -0
  28. data/lib/beway/ebay_data.rb +66 -0
  29. data/spec/auction_spec.rb +163 -0
  30. data/spec/bidder_spec.rb +30 -0
  31. data/spec/config.rb +4 -0
  32. data/spec/config.rb-dist +4 -0
  33. data/spec/ebay_data_spec.rb +23 -0
  34. data/spec/html/alfani-sweater-complete.html +39 -0
  35. data/spec/html/cashmere-sweater-complete.html +39 -0
  36. data/spec/html/cashmere-sweater.html +84 -0
  37. data/spec/html/mens-cardigans-dutch-bin.html +192 -0
  38. data/spec/html/pink-sweater-bid-bin.html +68 -0
  39. data/spec/html/polo-lambs-wool.html +150 -0
  40. data/spec/html/spring-mercer-bin-mo.html +533 -0
  41. data/spec/html/xmas-sweater.html +260 -0
  42. metadata +192 -0
data/lib/beway.rb ADDED
@@ -0,0 +1,4 @@
1
+ require_relative './beway/auction.rb'
2
+ require_relative './beway/bidder.rb'
3
+ require_relative './beway/cli_runner.rb'
4
+ require_relative './beway/ebay_data.rb'
@@ -0,0 +1,150 @@
1
+ require 'nokogiri'
2
+ require 'open-uri'
3
+
4
+ module Beway
5
+ class AuctionParseError < StandardError; end;
6
+ class InvalidUrlError < StandardError; end;
7
+
8
+ # Auction
9
+ #
10
+ # Represents an ebay auction. Can only be instantiated for true auctions
11
+ # (no buy-it-now-only sales) and completed auctions.
12
+ class Auction
13
+
14
+ attr_reader :url, :doc, :last_updated
15
+
16
+ def initialize url
17
+ @url = url
18
+ refresh_doc
19
+ raise InvalidUrlError unless valid_auction?
20
+ end
21
+
22
+ # can we represent this auction?
23
+ def valid_auction?
24
+ return true if complete? or has_bid_button?
25
+ return false
26
+ end
27
+
28
+ # has bidding ended yet?
29
+ def complete?
30
+ complete_span = @doc.at_xpath('//span[contains(text(), "Bidding has ended on this item")]')
31
+ return (complete_span.nil?) ? false : true
32
+ end
33
+
34
+ # fetch the url again
35
+ def refresh_doc
36
+ @doc = Nokogiri::HTML(open(@url))
37
+ @last_updated = Time.now
38
+ end
39
+
40
+ # parsing method, returns a string
41
+ def current_bid
42
+ # list of ways to get the bid.
43
+ xpaths = [
44
+ "//th[contains(text(),'Current bid:')]",
45
+ "//th[contains(text(),'Starting bid:')]",
46
+ "//th[contains(text(),'Price:')]",
47
+ "//td[contains(text(),'Starting bid:')]",
48
+ "//td[contains(text(),'Winning bid:')]",
49
+ ]
50
+
51
+ bid_node = xpaths.reduce(nil) do |node, xpath|
52
+ if node.nil?
53
+ node = @doc.at_xpath(xpath)
54
+ node = node.next_sibling unless node.nil?
55
+ end
56
+ node
57
+ end
58
+
59
+ raise AuctionParseError, "Couldn't find current/starting bid header in document" if bid_node.nil?
60
+ bid_text = node_text(bid_node)
61
+ bid_text = bid_text[/^[^\[]+/].strip if complete?
62
+ return bid_text
63
+ end
64
+
65
+ # parsing method, returns a string
66
+ def description
67
+ desc = @doc.at_css('b#mainContent h1')
68
+ raise AuctionParseError, "Couldn't find description in document" if desc.nil?
69
+ desc.inner_text.strip
70
+ end
71
+
72
+ # parsing method, returns a string
73
+ def time_left
74
+ return nil if complete?
75
+
76
+ time_str = node_text(time_node)
77
+ time_str = time_str[/^[^(]*/].strip
78
+ time_ar = time_str.split
79
+ raise AuctionParseError, "Didn't find hour marker where expected" unless time_ar[1] == 'h'
80
+ raise AuctionParseError, "Didn't find minute marker where expected" unless time_ar[3] == 'm'
81
+ raise AuctionParseError, "Didn't find second marker where expected" unless time_ar[5] == 's'
82
+ time_ar[0] + 'h ' + time_ar[2] + 'm ' + time_ar[4] + 's'
83
+ end
84
+
85
+ # parsing method, returns a float
86
+ def min_bid
87
+ return nil if complete?
88
+
89
+ max_label = @doc.at_xpath("//th/label[contains(text(),'Your max bid:')]")
90
+ raise AuctionParseError, "Couldn't find max bid label in document" unless max_label
91
+ min_bid_node = max_label.parent.parent.next_sibling
92
+ raise AuctionParseError, "Couldn't find minimum bid in document" unless min_bid_node
93
+ md = /\(Enter ([^)]*) or more\)/.match min_bid_node.inner_text
94
+ raise AuctionParseError, "Min Bid data not in expected format" if md.nil?
95
+ md[1][/\d*\.\d*/].to_f
96
+ end
97
+
98
+ # parsing method, returns a Time object
99
+ def end_time
100
+ text = node_text(time_node)
101
+ md = text.match(/\(([^)]*)\)/)
102
+ if md
103
+ time_str = md[1]
104
+ else
105
+ time_str = text
106
+ end
107
+ raise AuctionParseError unless time_str
108
+ Time.parse(time_str)
109
+ end
110
+
111
+ # parsing method, returns a string
112
+ def auction_number
113
+ canonical_url_node = @doc.at_css('link[@rel = "canonical"]')
114
+ raise AuctionParseError, "Couldn't find canonical URL" unless canonical_url_node
115
+ canonical_url_node.attr('href')[/\d+$/]
116
+ end
117
+
118
+ # parsming method, returns boolean
119
+ def has_bid_button?
120
+ place_bid_button = @doc.at_xpath('//form//input[@value="Place bid"]')
121
+ return (place_bid_button.nil?) ? false : true
122
+ end
123
+
124
+ private
125
+
126
+ # fetch the node containing the end time
127
+ def time_node
128
+ if complete?
129
+ td = @doc.at_xpath("//td[contains(text(),'Ended:')]")
130
+ raise AuctionParseError, "Couldn't find ended header" unless td
131
+ node = td.next_sibling
132
+ else
133
+ th = @doc.at_xpath("//th[contains(text(),'Time left:')]")
134
+ raise AuctionParseError, "Couldn't find Time Left header" unless th
135
+ node = th.parent.at_css('td')
136
+ end
137
+
138
+ raise AuctionParseError, "Couldn't find Time node" unless node
139
+ node
140
+ end
141
+
142
+ # a string of all text nodes below n, concatenated
143
+ def node_text(n)
144
+ t = ''
145
+ n.traverse { |e| t << ' ' + e.to_s if e.text? }
146
+ t.gsub(/ +/, ' ').strip
147
+ end
148
+
149
+ end
150
+ end
@@ -0,0 +1,100 @@
1
+ require 'mechanize'
2
+
3
+ module Beway
4
+
5
+ class BidderError < StandardError; end;
6
+
7
+ # Bidder
8
+ #
9
+ # Wrapper for Mechanize to perform actions on ebay
10
+ class Bidder
11
+
12
+ EBAY_HOME = 'http://www.ebay.com'
13
+
14
+ attr_accessor :username
15
+ attr_writer :password
16
+ attr_reader :agent, :logged_in, :last_login_time
17
+
18
+ # create a bidder with login credentials
19
+ def initialize(username, password)
20
+ @username = username
21
+ @password = password
22
+ @agent = Mechanize.new
23
+ @logged_in = false
24
+ @last_login_time = nil
25
+ end
26
+
27
+ # log user in with credentials.
28
+ # returns boolean representing success
29
+ def login
30
+ ebay_home_page = @agent.get(EBAY_HOME)
31
+
32
+ sign_in_link = ebay_home_page.link_with( :text => 'Sign in' )
33
+ raise BidderError, "Couldn't find sign in link" unless sign_in_link
34
+ login_page = sign_in_link.click
35
+
36
+ handle_login_page(login_page)
37
+
38
+ return @logged_in
39
+ end
40
+
41
+ # bid amount on given auction
42
+ def bid(auction_url, amount)
43
+
44
+ # get auction
45
+ auction_page = @agent.get(auction_url)
46
+
47
+ # get the bid form
48
+ forms = auction_page.forms_with( :action => /http:\/\/offer\.ebay\.com\// )
49
+ raise BidderError, "Couldn't find auction bid form" if forms.length != 1
50
+ bid_form = forms[0]
51
+
52
+ # fill in, submit bid form
53
+ bid_form.maxbid = amount
54
+ bid_response = bid_form.submit
55
+
56
+ # if given a login page, do it and get redirected to confirm bid
57
+ if is_login_page?(bid_response)
58
+ bid_response = handle_login_page(bid_response)
59
+ end
60
+
61
+ # get confirm bid form
62
+ forms = bid_response.forms_with( :action => 'http://offer.ebay.com/ws/eBayISAPI.dll' )
63
+ raise BidderError, "Couldn't find confirm bid form" if forms.length != 1
64
+ confirm_form = forms[0]
65
+
66
+ # click confirm button
67
+ confirm_button = confirm_form.button_with( :value => 'Confirm Bid' )
68
+ raise BidderError, "Couldn't find confirm bid button" unless confirm_button
69
+ confirm_response = confirm_form.submit( confirm_button )
70
+
71
+ confirm_response
72
+ end
73
+
74
+ private
75
+
76
+ # is page a login page?
77
+ def is_login_page?(page)
78
+ if page.form_with( :name => 'SignInForm')
79
+ true
80
+ else
81
+ false
82
+ end
83
+ end
84
+
85
+ # log into ebay as prompted by login_page
86
+ def handle_login_page(login_page)
87
+ login_form = login_page.form_with( :name => 'SignInForm')
88
+ raise BidderError, "Couldn't find login form" unless login_form
89
+ login_form.userid = @username
90
+ login_form.pass = @password
91
+ login_response = login_form.submit
92
+
93
+ @logged_in = login_response.search('form#SignInForm').empty?
94
+ @last_login_time = Time.now if @logged_in
95
+
96
+ return login_response
97
+ end
98
+
99
+ end
100
+ end
@@ -0,0 +1,184 @@
1
+ require_relative '../beway.rb'
2
+
3
+ module Beway
4
+
5
+ # CliRunner
6
+ #
7
+ # A UI for the Beway module to snipe an ebay auction
8
+ class CliRunner
9
+
10
+ BID_THRESHOLD = 10
11
+
12
+ def self.start
13
+ runner = self.new
14
+ runner.run
15
+ end
16
+
17
+ def initialize
18
+ end
19
+
20
+ def run
21
+
22
+ display_intro
23
+
24
+ # login prep
25
+ user = prompt_username
26
+ pass = prompt_password
27
+ bidder = Bidder.new(user, pass)
28
+ puts "Logging in..."
29
+ bidder.login
30
+ if bidder.logged_in
31
+ puts " ...success"
32
+ else
33
+ puts "Bad ebay username/password combo! Please try again."
34
+ exit
35
+ end
36
+
37
+ # bid prep
38
+ auction = auction_from_user
39
+ bid_amount = bid_for_auction_from_user(auction)
40
+
41
+ # our tools
42
+ ebay = EbayData.instance
43
+
44
+ loop do
45
+ display_auction auction
46
+
47
+ seconds_to_end = ebay.seconds_to(auction.end_time)
48
+
49
+ if seconds_to_end <= BID_THRESHOLD
50
+ puts "Placing bid..."
51
+ begin
52
+ bidder.bid(auction.url, bid_amount)
53
+ puts " ...placed."
54
+ puts "Sleeping til end of auction..."
55
+ sleep ebay.seconds_to(auction.end_time).ceil
56
+ auction.refresh_doc
57
+ display_auction auction
58
+ exit
59
+ rescue BidderError => e
60
+ puts " ...oh no!"
61
+ puts "There was an error placing your bid:"
62
+ puts
63
+ puts " " + e.message
64
+ puts
65
+ puts "So sorry!"
66
+ exit
67
+ end
68
+ end
69
+
70
+ seconds = seconds_to_end.floor / 2
71
+ seconds = 5 if seconds_to_end < (2 * BID_THRESHOLD)
72
+ puts "Sleeping #{seconds} seconds..."
73
+ sleep seconds
74
+
75
+ puts "Updating auction..."
76
+ puts
77
+ auction.refresh_doc
78
+ end
79
+ end
80
+
81
+ def bid_for_auction_from_user(a)
82
+ loop do
83
+ bid_amount = prompt_bid()#a.min_bid)
84
+ return bid_amount if prompt_confirm_bid(bid_amount)
85
+ end
86
+ end
87
+
88
+ def auction_from_user
89
+ loop do
90
+ begin
91
+ auction = Auction.new(prompt_url)
92
+ if auction.complete?
93
+ display_auction auction
94
+ puts
95
+ puts "That auction is done already! Try another, or 'exit' to quit."
96
+ next
97
+ end
98
+ rescue AuctionParseError
99
+ puts "Sorry, we can't parse that url as an auction"
100
+ next
101
+ end
102
+
103
+ return auction if prompt_confirm_auction(auction)
104
+ end
105
+ end
106
+
107
+ def display_intro
108
+ puts "Welcome to Beway's CLI interface"
109
+ end
110
+
111
+ def display_auction(a)
112
+ puts
113
+ puts "URL: #{a.url}"
114
+ puts "Description: #{a.description}"
115
+ puts "Auction Number: #{a.auction_number}"
116
+ puts "Current Bid: #{a.current_bid}"
117
+ puts "Min Bid: #{a.min_bid || '-- bidding closed --'}"
118
+ puts "Time Left: #{a.time_left || '-- bidding closed --'}"
119
+ puts "End Time: #{a.end_time}"
120
+ end
121
+
122
+ def prompt_username
123
+ print "Enter eBay username >> "
124
+ return get_user_input.chomp
125
+ end
126
+
127
+ def prompt_password
128
+ print "Enter eBay password >> "
129
+ system "stty -echo"
130
+ pass = get_user_input
131
+ system "stty echo"
132
+ puts
133
+
134
+ pass.chomp
135
+ end
136
+
137
+ def prompt_bid(min=nil)
138
+ print "Enter your bid for the item >> "
139
+ bid = get_user_input.to_f
140
+ puts
141
+ return bid if min.nil? or bid >= min
142
+ puts "The minimum bid for this auction is #{min}. Try again, or type 'exit' to quit."
143
+ prompt_bid(min)
144
+ end
145
+
146
+ def prompt_confirm_bid(amount)
147
+ printf "Are you sure you want to bid %.2f? (y\\n) >> ", amount
148
+ confirm = get_user_input
149
+ puts
150
+ if confirm.downcase.chr == 'y'
151
+ true
152
+ else
153
+ false
154
+ end
155
+ end
156
+
157
+ def prompt_confirm_auction(a)
158
+ display_auction(a)
159
+ print 'Does this look like your auction? (y\n) >> '
160
+ confirm = get_user_input
161
+ if confirm.downcase.chr == 'y'
162
+ true
163
+ else
164
+ false
165
+ end
166
+ end
167
+
168
+ def prompt_url
169
+ puts
170
+ puts "Enter an ebay auction url (or 'exit' to exit): "
171
+ print "\n>> "
172
+ url = get_user_input
173
+
174
+ url.chomp
175
+ end
176
+
177
+ def get_user_input
178
+ s = gets
179
+ exit if 'exit' == s.chomp.downcase
180
+ s
181
+ end
182
+ end
183
+
184
+ end
@@ -0,0 +1,66 @@
1
+ require 'singleton'
2
+ require 'nokogiri'
3
+ require 'open-uri'
4
+
5
+ module Beway
6
+
7
+ class EbayDataParseError < StandardError; end;
8
+
9
+ # EbayData
10
+ #
11
+ # Singleton class to handle ebay queries that are not auction-related.
12
+ class EbayData
13
+
14
+ include Singleton
15
+
16
+ EBAY_OFFICIAL_TIME_URL = 'http://viv.ebay.com/ws/eBayISAPI.dll?EbayTime'
17
+
18
+ def initialize
19
+ @time_offset = nil
20
+ @last_time_offset = nil
21
+ end
22
+
23
+ # The current ebay time as calculated by an offset from localtime.
24
+ def time
25
+ Time.now.localtime + self.time_offset
26
+ end
27
+
28
+ # The localtime offset from ebay time.
29
+ #
30
+ # add this offset to localtime to get an estimated ebay time
31
+ def time_offset
32
+ calc_time_offset unless @time_offset
33
+ @time_offset
34
+ end
35
+
36
+ # Calculate the ebay time offset
37
+ def calc_time_offset
38
+ @last_time_offset = Time.now
39
+ @time_offset = official_time - Time.now.localtime
40
+ end
41
+
42
+ # Retrieve the official ebay time
43
+ def official_time
44
+ doc = Nokogiri::HTML(open(EBAY_OFFICIAL_TIME_URL))
45
+
46
+ time_label = doc.at_xpath('//p[contains(text(), "The official eBay Time is now:")]')
47
+
48
+ raise EbayDataParseError, "Couldn't find time label" unless time_label
49
+
50
+ time_node = time_label.next_sibling.next_sibling
51
+ raise EbayDataParseError, "Couldn't find time node" unless time_node
52
+
53
+ time_str = time_node.inner_text
54
+ time_re = /(Sun|Mon|Tues|Wednes|Thurs|Fri|Satur)day, (January|February|March|April|May|June|July|August|September|October|December) \d\d, 20\d\d \d\d:\d\d:\d\d PST/
55
+ raise EbayDataParseError, "Time in unexpected format" unless time_re.match(time_str)
56
+
57
+ Time.parse(time_str).localtime
58
+ end
59
+
60
+ # Returns the number of seconds to some_ebay_time
61
+ def seconds_to(some_ebay_time)
62
+ some_ebay_time - time
63
+ end
64
+
65
+ end
66
+ end