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.
- data/Gemfile +7 -0
- data/Gemfile.lock +34 -0
- data/README +43 -0
- data/README.rdoc +43 -0
- data/Rakefile +19 -0
- data/bin/beway +5 -0
- data/doc/Beway.html +185 -0
- data/doc/Beway/Auction.html +802 -0
- data/doc/Beway/AuctionParseError.html +160 -0
- data/doc/Beway/Bidder.html +531 -0
- data/doc/Beway/BidderError.html +160 -0
- data/doc/Beway/CliRunner.html +817 -0
- data/doc/Beway/EbayData.html +453 -0
- data/doc/Beway/EbayDataParseError.html +160 -0
- data/doc/Beway/InvalidUrlError.html +160 -0
- data/doc/README.html +175 -0
- data/doc/created.rid +6 -0
- data/doc/index.html +225 -0
- data/doc/lib/beway/auction_rb.html +56 -0
- data/doc/lib/beway/bidder_rb.html +54 -0
- data/doc/lib/beway/cli_runner_rb.html +52 -0
- data/doc/lib/beway/ebay_data_rb.html +58 -0
- data/doc/rdoc.css +706 -0
- data/lib/beway.rb +4 -0
- data/lib/beway/auction.rb +150 -0
- data/lib/beway/bidder.rb +100 -0
- data/lib/beway/cli_runner.rb +184 -0
- data/lib/beway/ebay_data.rb +66 -0
- data/spec/auction_spec.rb +163 -0
- data/spec/bidder_spec.rb +30 -0
- data/spec/config.rb +4 -0
- data/spec/config.rb-dist +4 -0
- data/spec/ebay_data_spec.rb +23 -0
- data/spec/html/alfani-sweater-complete.html +39 -0
- data/spec/html/cashmere-sweater-complete.html +39 -0
- data/spec/html/cashmere-sweater.html +84 -0
- data/spec/html/mens-cardigans-dutch-bin.html +192 -0
- data/spec/html/pink-sweater-bid-bin.html +68 -0
- data/spec/html/polo-lambs-wool.html +150 -0
- data/spec/html/spring-mercer-bin-mo.html +533 -0
- data/spec/html/xmas-sweater.html +260 -0
- metadata +192 -0
data/lib/beway.rb
ADDED
@@ -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
|
data/lib/beway/bidder.rb
ADDED
@@ -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
|