#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
A one-file almost-sniper for eBay.

Notes: 

* Of course, no warranties whatsoever.  If this script has a bug
  and bids 4000 Euro for a wonderful souvenir plate, tough luck.
* No time zone evaulation.  We assume eBay uses your time zone
* No currency evaluation.  You give your bid in the currency eBay assumes.

We want this in one file, so instead of modules you have chapters in
here, separated by lines with ##################.  

Without having paid attention to it, I believe you'll want at least
python 2.4 to run this (python3 support... shouldn't be a big deal).

You want BeautifulSoup installed.  These days, it's in packages called
something like python-bs4 (or use PyPI).

I'd expect this to work on non-German-language eBays, since I'm only
looking at the document structure.  If eBay changes their document
structure, you probably only want to look in the eBay interface chapter.

Try spitmonkey.py --help-config for info on configuration.  You'll at
least need to put

[general]
user=<your user id>
password=<your password>

in a file ~/.spitmonkey
"""

from bs4 import BeautifulSoup
import cgi
import cookielib
import ConfigParser
import datetime
import new
import os
import re
import sys
import time
import urllib, urllib2
import weakref
import re

settingsFile = os.path.join(os.environ.get("HOME", "/"), ".spitmonkey")


class Error(Exception):
	pass

monthNames = ["Jan", "Feb", u"Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep",
	"Okt", "Nov", "Dez"]
monthDict = dict(zip(monthNames, range(1, 13)))


#def extractLogStatus(soup):
def extractLogStatus(doc):
	"""returns True if soup (of an article page) says there's a user logged in.
	"""
# Hm -- it's not very obvious to tell if you're logged in an more.  For
# now, just assume everything worked.
	return config.get("user").encode("utf-8") in doc
	return True # FIXME
	for link in soup.findAll("a"):
		try:
			if link["href"].endswith("eBayISAPI.dll?SignIn"):
				return link.string=="Ausloggen"
		except KeyError: # no href, irrelevant for us
			pass
	raise Error("No signin link found")


############################### Configuration
defaultSection = "general"  # must be all lowercase


class ConfigError(Exception):
	"""is the base class of the user visible exceptions from this module.
	"""

class ParseError(ConfigError):
	"""is raised by ConfigItem's parse methods if there is a problem with
	the input.

	These should only escape to users of this module unless they call
	ConfigItem.set themselves (which they shouldn't).
	"""

class NoConfigItem(ConfigError):
	"""is raised by Configuration if a non-existing configuration
	item is set or requested.
	"""

class BadConfigValue(ConfigError):
	"""is raised by getConfiguration when there is a syntax error or the
	like in a value.

	The error message gives a hint at the reason of the error and is intended
	for human consumption.
	"""

class SyntaxError(ConfigError):
	"""is raised when the input file syntax is bad (i.e., on
	ConfigParser.ParsingErrors)
	"""

class ConfigItem(object):
	"""is a description of a configuration item including methods
	to parse and unparse them.

	This class is an abstract base class for options with real syntax
	(_parse and _unparse methods).

	ConfigItems have a section and a name (as in ConfigParser), a
	value (that defaults to default), an origin (which is "default",
	if the value has not been changed and otherwise can be freely
	used  by clients), and a description.  The origin is important
	for distinguishing what to save.

	You need to define the _parse and _unparse methods in deriving
	classes.  The _parse methods must take a byte string (the encoding
	has to be utf-8) and return anything or raise ParseErrors (with
	a sensible description of the problem) if there is a problem
	with the input; the must not raise other exceptions when passed a
	string (but may do anything when passed something else.  _unparse
	methods must not raise exceptions, take a value as returned
	by parse (nothing else must be passed in) and return a
	string that _parse would parse into this value.

	Thus, the set method *only* takes strings as values.  To set
	parsed values, assign to value directly.  However, _unparse
	methods are not required to cope with any crazy stuff you enter
	in this way, and thus you suddenly may receive all kinds of
	funny exceptions when serializing a Configuration.

	Inheriting classes need to specify a class attribute default that
	kicks in when no default has been specified during construction.
	These must be strings parseable by _parse.

	Finally, you should provide a typedesc class attribute, a description
	of the type intended for human consumption.  See the documentation
	functions below to get an idea how the would be shown.
	"""

	typedesc = "unspecified value"

	def __init__(self, name, default=None, description="Undocumented"):
		self.name = name
		if default is None:
			default = self.default
		self.default = default
		self.set(default, "default")
		self.description = description
		self.parent = None # will be set on adoption by a Configuration
	
	def set(self, value, origin="user"):
		self.value, self.origin = self._parse(value), origin

	def getAsString(self):
		return self._unparse(self.value)

	def _parse(self, value):
		raise ParseError("Internal error: Base config item used.")
	
	def _unparse(self, value):
		return value


class StringConfigItem(ConfigItem):
	"""is a config item containing unicode strings.

	The serialization of the config file is supposed to be utf-8.

	The special value None is used as a Null value literal.

	Tests are below.
	"""

	typedesc = "string"
	default = ""

	def _parse(self, value):
		if value=="None":
			return None
		if isinstance(value, unicode):
			return value
		try:
			return value.decode("utf-8")
		except UnicodeError:
			raise ParseError("Not a valid utf-8 string: %s"%repr(value))
		except AttributeError:
			raise ParseError("Only strings are allowed in %s, not %s"%(
				self.__class__.__name__, repr(value)))

	def _unparse(self, value):
		if value is None:
			return "None"
		return value.encode("utf-8")


class BooleanConfigItem(ConfigItem):
	"""is a config item that contains a boolean and can be parsed from
	many fancy representations.
	"""

	typedesc = "boolean"
	default = "False"

	trueLiterals = set(["true", "yes", "t", "on", "enabled", "1"])
	falseLiterals = set(["false", "no", "f", "off", "disabled", "0"])
	def _parse(self, value):
		value = value.lower()
		if value in self.trueLiterals:
			return True
		elif value in self.falseLiterals:
			return False
		else:
			raise ParseError("'%s' is no recognized boolean literal."%value)
	
	def _unparse(self, value):
		return {True: "True", False: "False"}[value]


class _Undefined(object):
	"""is a sentinel for section.get.
	"""

class Section(object):
	"""is a section within the configuration.

	It is constructed with a name, a documentation, and the configuration
	items.

	They double as proxies between the configuration and their items
	via the setParent method.
	"""
	def __init__(self, name, documentation, *items):
		self.name, self.documentation = name, documentation
		self.items = {}
		for item in items:
			self.items[item.name.lower()] = item

	def __iter__(self):
		for name in sorted(self.items):
			yield self.items[name]

	def getitem(self, name):
		if name.lower() in self.items:
			return self.items[name.lower()]
		else:
			raise NoConfigItem("No such configuration item: [%s] %s"%(
				self.name, name))

	def get(self, name):
		"""returns the value of the configuration item name.

		If it does not exist, a NoConfigItem exception will be raised.
		"""
		return self.getitem(name).value
		
	def set(self, name, value, origin="user"):
		"""set the value of the configuration item name.

		value must always be a string, regardless of the item's actual type.
		"""
		self.getitem(name).set(value, origin)

	def setParent(self, parent):
		for item in self.items.values():
			item.parent = parent


class DefaultSection(Section):
	"""is the default section, named by defaultSection above.

	The only difference to Section is that you leave out the name.
	"""
	def __init__(self, documentation, *items):
		Section.__init__(self, defaultSection, documentation, *items)


class Configuration(object):
	"""is a collection of config Sections and provides an interface to access 
	them and their items.

	You construct it with the Sections you want and then use the get
	method to access their content.  You can either use get(section, name)
	or just get(name), which implies the defaultSection section defined
	at the top (right now, "general").

	To read configuration items, use addFromFp.  addFromFp should only
	raise subclasses of ConfigError.

	You can also set individual items using set.

	The class follows the default behaviour of ConfigParser in that section
	and item names are lowercased.

	Note that direct access to sections is not forbidden, but you have to
	keep case mangling of keys into account when doing so.
	"""
	def __init__(self, *sections):
		self.sections = {}
		for section in sections:
			self.sections[section.name.lower()] = section
			section.setParent(weakref.proxy(self))

	def __iter__(self):
		sectHeads = self.sections.keys()
		if defaultSection in sectHeads:
			sectHeads.remove(defaultSection)
			yield self.sections[defaultSection]
		for h in sorted(sectHeads):
			yield self.sections[h]

	def getitem(self, arg1, arg2=None):
		"""returns the *item* described by section, name or just name.
		"""
		if arg2 is None:
			section, name = defaultSection, arg1
		else:
			section, name = arg1, arg2
		if section.lower() in self.sections:
			return self.sections[section.lower()].getitem(name)
		raise NoConfigItem("No such configuration item: [%s] %s"%(
			section, name))

	def get(self, arg1, arg2=None, default=_Undefined):
		try:
			return self.getitem(arg1, arg2).value
		except NoConfigItem:
			if default is _Undefined:
				raise
			return default

	def set(self, arg1, arg2, arg3=None, origin="user"):
		"""sets a configuration item to a value.

		arg1 can be a section, in which case arg2 is a key and arg3 is a
		value; alternatively, if arg3 is not given, arg1 is a key in
		the defaultSection, and arg2 is the value.

		All arguments are strings that must be parseable by the referenced
		item's _parse method.
		
		Origin is a tag you can use to, e.g., determine what to save.
		"""
		if arg3 is None:
			section, name, value = defaultSection, arg1, arg2
		else:
			section, name, value = arg1, arg2, arg3
		if section.lower() in self.sections:
			return self.sections[section.lower()].set(name, value, origin)
		else:
			raise NoConfigItem("No such configuration item: [%s] %s"%(
				section, name))

	def addFromFp(self, fp, origin="user", fName="<internal>"):
		"""adds the config items in the file fp to self.
		"""
		p = ConfigParser.SafeConfigParser()
		try:
			p.readfp(fp, fName)
		except ConfigParser.ParsingError, msg:
			raise SyntaxError("Config syntax error in %s: %s"%(fName, 
				unicode(msg)))
		sections = p.sections()
		for section in sections:
			for name, value in p.items(section):
				try:
					self.set(section, name, value, origin)
				except ParseError, msg:
					raise BadConfigValue("While parsing value of %s in section %s,"
						" file %s:\n%s"%
						(name, section, fName, unicode(msg)))


def _addToConfig(config, fName, origin):
	"""adds the config items in the file named in fName to the Configuration, 
	tagging them with origin.

	fName can be None or point to a non-exisiting file.  In both cases,
	the function does nothing.
	"""
	if not fName or not os.path.exists(fName):
		return
	f = open(fName)
	config.addFromFp(f, origin=origin, fName=fName)
	f.close()
	

def readConfiguration(config, systemFName, userFName):
	"""fills the Configuration config with values from the the two locations.

	File names that are none or point to non-existing locations are
	ignored.
	"""
	_addToConfig(config, systemFName, "system")
	_addToConfig(config, userFName, "user")


def makeTxtDocs(config, underlineChar="."):
	import textwrap
	docs = []
	for section in config:
		hdr = "Section [%s]"%(section.name)
		body = section.documentation
		docs.append("\n%s\n%s\n\n%s\n"%(hdr, underlineChar*len(hdr),
			textwrap.fill(body, width=72)))
		for ci in section:
			docs.append("* %s: %s; "%(ci.name, ci.typedesc)) 
			if ci.default is not None:
				docs.append("  defaults to '%s' --"%ci.default)
			docs.append(textwrap.fill(ci.description, width=72, initial_indent="  ",
				subsequent_indent="  "))
	return "\n".join(docs)


config = Configuration(
	DefaultSection("Configuration for all things spitmonkey",
		StringConfigItem("user", "None", "eBay user name"),
		StringConfigItem("password", "None", "Password for user"),
		StringConfigItem("eBayCC", "de", "TLD for your eBay"),
		StringConfigItem("userAgent", 
			"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV2)",
			"User agent to present eBay with")))

readConfiguration(config, None, settingsFile)


############################### Helper code

class Dumper(object):
	"""is a helper class to dump HTML responses.
	"""
	namePat = "resp%02d.html"

	def __init__(self, doDump):
		self.doDump = doDump
		self.respCount = 1
	
	def dump(self, content):
		if self.doDump:
			f = open(self.namePat%self.respCount, "w")
			f.write(content)
			f.close()
			self.respCount += 1


def makeUrllibOpener(opts):
	"""build an urllib2 opener with cookie support and install it.

	opts is stuff coming from parseCommandLine.
	"""
	global cookieJar
	cookieJar = cookielib.LWPCookieJar("cookies.txt", 
		cookielib.DefaultCookiePolicy())
	if opts.persistCookies:
		try:
			cookieJar.load()
		except IOError:
			pass

	opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookieJar))
	opener.addheaders = [
		('user-agent', config.get("userAgent"))]

	def decorateForLogging(opener):
		realOpen = opener.open
		def logOpen(self, url, data=None):
			reqId = "%x"%id(url)
			result = realOpen(url, data)
			return result
		opener.open = new.instancemethod(logOpen, opener, opener.__class__)

		realError = opener.error
		def logError(self, proto, *args):
			if proto=="http":
				request, _, status, _, msg = args
				reqId = "%x"%id(request)
			return realError(proto, *args)
		opener.error = new.instancemethod(logError, opener, opener.__class__)

	if opts.verbose:
		decorateForLogging(opener)
	urllib2.install_opener(opener)


cacheResults = False 

def getWithCache(url):
	cacheName = re.sub("[^\w]+", "", url)+".cache"
	if cacheResults and os.path.exists(cacheName):
		doc = open(cacheName).read()
	else:
		doc = urllib2.urlopen(url).read()
		_dumper.dump(doc)
		if cacheResults:
			f = open(cacheName, "w")
			f.write(doc)
			f.close()
	return doc


def formToDict(formElement):
	"""returns a dictionary of input defaults/values from a BeautifulSoup
	form element.

	Form elements lacking a default value are returned with an empty
	string.  I believe that's the browser's behaviour.
	"""
	inputItems = {}
	for f in formElement.findAll("input"):
		if f.has_key("name"):
			inputItems[f["name"]] = f.get("value", "")
	return inputItems


############################ eBay interface

loginURL = "https://signin.ebay.%s/ws/eBayISAPI.dll?SignIn"%config.get(
	"eBayCC")
articleURL = "https://cgi.ebay.%s/ws/eBayIAAPI.dll?ViewItem"%config.get(
	"eBayCC")

class Article(object):
	"""is an article on eBay.

	It is constructed with an item number that can be scraped off the eBay
	pages.  The class retrieves eBay's info page in its constructor and parses
	it, yielding a set of strucured information.
	"""
	def __init__(self, itemNo):
		self.itemNo = itemNo
		self._retrieveInfo()

	def _retrieveInfo(self):
		startTime = time.mktime(time.localtime())
		doc = getWithCache(articleURL+"&item="+self.itemNo)
		endTime = time.mktime(time.localtime())
		_dumper.dump(doc)
		self.latency = endTime-startTime
		soup = BeautifulSoup(doc, "html.parser")
		self._extractBidAttrs(soup)
		self._extractArtAttrs(soup)
		#self.userIsLogged = extractLogStatus(soup)
		self.userIsLogged = extractLogStatus(doc)

	def __str__(self):
		return "Article %s, last bid %s, ends %s"%(self.itemNo,
			self.curBid, self.auctionEnds.strftime("%H:%M %Y-%m-%d"))

	def _locateForm(self, soup):
		for form in soup.findAll("form"):
			if "MakeBid" in form["action"]:
				return form
		raise Error("No bid form found")

	def _extractBidAttrs(self, soup):
		bidForm = self._locateForm(soup)
		self.bidURL = bidForm["action"]
		self.bidArguments = formToDict(bidForm)

	def _extractArtAttrs(self, soup):
		curBidLiteral = soup.find("span", {"itemprop": "price"})
		try:
			self.curBid = float(re.search("\d+[.,]\d+", str(curBidLiteral)).group().
				replace(",", "."))
		except AttributeError:  # no match, enter a null value for now
			self.curBid = -99.99
		self.auctionEnds = None
		#timeEndsLit = str(soup.find("title"))
		try:
			aeDateTime = soup.find("span", {"class": "vi-tm-left"})
			aeDate, aeTime = [i.string for i in aeDateTime if i.string.strip()]
			aeDateLitElements = [el.strip() for el in aeDate[1:].split(".")]
			aeDateLitElements[1] = str(monthNames.index(aeDateLitElements[1])+1).rjust(2, "0")
			aeDateLit = ".".join(aeDateLitElements)
			aeTimeLit = aeTime[:8]
			"""
			aeTimeLit = str(soup.find("span", {"class": "vi-is1-t"}).contents[0])[:8]
			aeDateLit = str(soup.find("span", {"class":
				"vi-is1-dt-eu"}).find("span").contents[0])[1:]
			aeDateLit = aeDateLit[:-10] + str(monthDict[aeDateLit[-9:-6]]) + "." + aeDateLit[-4:]
			"""
			hrs, mins, secs = time.strptime(aeTimeLit, "%H:%M:%S")[3:6]
			self.auctionEnds = datetime.datetime(
				*time.strptime(aeDateLit, "%d.%m.%Y")[:3]
				)+datetime.timedelta(hours=hrs, minutes=mins, seconds=secs)
		except (ValueError, TypeError): # We're in the hours/minutes regime
			self.auctionEnds = datetime.datetime.now()


def postWithForm(action, parameters):
	for key in parameters:
		if isinstance(parameters[key], unicode):
			parameters[key] = parameters[key].encode("utf-8")
	postContent = urllib.urlencode(parameters)
	resp = urllib2.urlopen(action, postContent)
	content = resp.read()
	_dumper.dump(content)
	resp.close()
	return content


def logIn(form, user, password):
	destURL = form.attrMap["action"]
	parameters = formToDict(form)
	parameters["userid"], parameters["pass"] = user, password
	return postWithForm(destURL, parameters)


def bidOn(article, price, triedOnce=False):
	# get reconfirmation page
	action, parameters = article.bidURL, article.bidArguments
	parameters["maxbid"] = price
	doc = postWithForm(action, parameters)

	# if we're not logged in, there's now a form with the name
	# SignInForm
	soup = BeautifulSoup(doc, "html.parser")
	loginForm = soup.find("form", attrs={"name":"SignInForm"})
	if loginForm is not None:
		if triedOnce:
			raise Error("Sorry, cannot log you in")
		loggedIn = logIn(loginForm, config.get("user"), config.get("password"))
		return bidOn(article, price, triedOnce=True)

	# submit reconfirmation page
	# gross insanity in eBay's current HTML, so I'm passing the whole thing
	# to look for inputs
	parameters = formToDict(soup)
	action = soup.find("form")["action"]
	return postWithForm(action, parameters)


def postBidToEbay(opts, artId, amount):
	if config.get("user") is None or config.get("password") is None:
		raise Error("User and/or password missing in %s.  Read docs at"
			" head of %s"%(settingsfile, sys.argv[0]))
	article = Article(artId)
	print article
	if not opts.force:
		sys.stderr.write("Bid %s on %s (^C to quit, Enter to go on)?"%(
			amount, artId))
		raw_input()
	if opts.restTime:
		sleepTime = (time.mktime(article.auctionEnds.timetuple()) -
		             time.mktime(time.localtime()) -
		             opts.restTime - 
		             2*article.latency -
		             3)
		if sleepTime > 0:
			sys.stderr.write("going to sleep now for %i seconds.\n"%sleepTime)
			time.sleep(sleepTime)
			sys.stderr.write("waking up to place the bid. Current time: %s\n"%(
				time.strftime("%H:%M:%S", time.localtime())))
	doc = bidOn(article, amount)
	open("resp.html", "w").write(doc)
	article = Article(artId)
	sys.stderr.write("Current bid: %s\n"%article.curBid)


############################# "user interface"

def parseCommandLine():
	global _dumper

	from optparse import OptionParser
	parser = OptionParser(usage="%prog [options] <ebay-id> <amount>"
		"\nBids <amount> on <ebay-id>")
	parser.add_option("-v", "--verbose", help="Spit out http connection"
		" information to stdout", dest="verbose", action="store_true",
		default=False)
	parser.add_option("-f", "--force", help="Don't ask before placing the bid",
		dest="force", action="store_true", default=False)
	parser.add_option("-a", "--amnesic-cookies", help="Do not keep cookies"
		" between invocations", dest="persistCookies", action="store_false", 
		default=True)
	parser.add_option("-H", "--help-config", help="Show configuration"
		" items for %s"%settingsFile, action="store_true", dest="helpConfig",
		default=False)
	parser.add_option("-D", "--dump-responses", help="Dump eBay responses"
		" to respN.html (for debugging)", action="store_true",
		dest="dumpResponses", default=False)
	parser.add_option("-t", "--time", help="Place the bid TIME seconds before"
		" end. This option implies -f.", dest="restTime", action="store",
		type="int", default=0, metavar="TIME")

	opts, args = parser.parse_args()

	if opts.helpConfig:
		print makeTxtDocs(config)
		sys.exit(0)
	if len(args)!=2:
		parser.print_help()
		sys.exit(1)
	if opts.restTime:
		opts.force = True
	_dumper = Dumper(opts.dumpResponses)
	return opts, args


def main():
	try:
		opts, args = parseCommandLine()
		makeUrllibOpener(opts)
		postBidToEbay(opts, *args)
	except Error, msg:
		sys.stderr.write("%s: %s\n"%(sys.argv[0], str(msg)))
		sys.exit(1)
	if opts.persistCookies:
		cookieJar.save()

if __name__=="__main__":
	main()
