beway 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.
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