Fake-Formular per POST an Bank verschicken

Sockets, TCP/IP, (XML-)RPC und ähnliche Themen gehören in dieses Forum
Antworten
droptix
User
Beiträge: 521
Registriert: Donnerstag 13. Oktober 2005, 21:27

Meine Bank bietet mir den Download der Kontoumsätze als CSV-Datei an. Mich nervt allerdings immer das Einloggen und die ganze Prozedur, bis ich endlich bei den Umsätzen bin. Ich möchte daher den Download weitestgehend automatisieren. Leider bietet die Bank auch keine API dafür an.

Die Bank bietet zwar eine Software dafür an, allerdings läuft diese nur unter Windows und auch sonst ist die nicht so grandios. Also dachte ich dran, das ganze mit PHP oder besser noch mit Python zu machen.

Der Login erfolgt mit Kontonummer und PIN. Beides geht über eine SSL-Verbindung zum Webserver. Die anschließenden Formulare sind immer gleich. Diese lassen sich also faken. Ich erhalte aber bei jedem Login eine andere Session-ID, die immer als hidden-Field in den HTML-Formularen steckt.

Es gilt also zunächst, den Login automatisiert per HTTP-POST-Request durchzuführen. Anschließend kann ich aus dem Seitenquelltext via RegEx die Session-ID extrahieren.

Wie gestaltet man in Python einen HTTP-POST-Request?
Leonidas
Python-Forum Veteran
Beiträge: 16025
Registriert: Freitag 20. Juni 2003, 16:30
Kontaktdaten:

droptix hat geschrieben:Wie gestaltet man in Python einen HTTP-POST-Request?
Das geht über den Data-Parameter von urllib2.urlopen(). Hier mal ein Beispiel, wie ich so etwas ähnliches gemacht habe (ohne Cookielib). Dabei habe ich aber BeautifulSoup statt regulären Ausdrücken verwendet um mit mein Ticket, in deinem Fall eine Session-ID, rauszuholen.
My god, it's full of CARs! | Leonidasvoice vs (former) Modvoice
Benutzeravatar
Luzandro
User
Beiträge: 87
Registriert: Freitag 21. April 2006, 17:03

Ich habe mir für solche und ähnliche Sachen einen "FakeBrowser" geschrieben:

Code: Alles auswählen

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

import cookielib, urllib2, socket, urlparse, libxml2dom
from urllib import urlencode

class FakeBrowser:
    HEADERS = {'User-agent' : 'Opera/9.10 (X11; Linux i686; U; en)'}
    
    def __init__(self):
        socket.setdefaulttimeout(30) # seconds
        self.cj = cookielib.LWPCookieJar()
        opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cj))
        urllib2.install_opener(opener)
        self.standardParser = None
        self.url = None
        
    def _getAbsoluteURL(self, url):
        if url.startswith('http://') or url.startswith('https://'):
            return url
        else:
            if self.url is None:
                raise Exception, "History is empty - cannot open relative url"
            else:
                return urlparse.urljoin(self.url, url)
        
    def GET(self, url, getDomNode=False, parse=True):
        return self.POST(url, None, getDomNode, parse)
            
    def POST(self, url, params={}, getDomNode=False, parse=True):
        if params != None:
            params = urlencode(params)
        url = self._getAbsoluteURL(url)
        req = urllib2.Request(url, params, self.HEADERS)
        retries = 0
        while retries < 5:
            try:
                handle = urllib2.urlopen(req)
                data = handle.read()
                parse and self.standardParser != None and self.standardParser(data)
                self.url = handle.geturl()
                if getDomNode:
                    return libxml2dom.parseString(data, html=True)
                else:
                    return data
            except (socket.timeout, urllib2.URLError):
                retries += 1
        raise Exception, "5 retries - aborting..."
        
    def getHiddenFields(self, formNode, params=None):
        if params is None:
            params = {}
        for h in formNode.xpath("//input[@type='hidden']"):
            params[h.getAttribute('name')] = h.getAttribute('value')
        return params
    
    def printCookies(self):
        for index, cookie in enumerate(self.cj):
            print index, '  :  ', cookie
libxml2dom musst du vermutlich erst installieren, aber es vereinfacht die Verarbeitung doch ziemlich (wie z.B. das Übernehmen der hidden Fields, was du ja auch brauchst)
Noch ein konkretes Bsp:

Code: Alles auswählen

#! /usr/bin/python
# -*- coding: UTF-8 -*-

import os.path
from fakeBrowser import FakeBrowser

LOGIN_URL = 'https://rechnung.bob.at/login.do'
DL_DIR = '/mnt/guardian/shared/bob.rechnung/'

def login(browser, user, pwd):
    doc = browser.GET(LOGIN_URL, getDomNode=True)
    form = doc.xpath("//form")[0]
    params = browser.getHiddenFields(form)
    params['username'] = user
    params['password'] = pwd
    response = browser.POST(form.getAttribute('action'), params, getDomNode=True)
    if browser.url == LOGIN_URL:
        raise Exception, "Login failed..."
    return response
        
def downloadBills(browser, docNode):
    links = docNode.xpath("//a[@title='Rechnung als PDF anzeigen']")
    for link in links:
        href = link.getAttribute('href')
        filename = href[href.rfind('/')+1 : href.find('?')]
        if not os.path.exists(os.path.join(DL_DIR, filename)):
            bill = browser.GET(href)
            f = open(os.path.join(DL_DIR, filename), 'w')
            f.write(bill)
            f.close()
    
if __name__ == '__main__':
    fb = FakeBrowser()
    response = login(fb, 'XXX', 'YYY')
    downloadBills(fb, response)
    
[url=http://www.leckse.net/artikel/meta/profilieren]Profilieren im Netz leicht gemacht[/url]
droptix
User
Beiträge: 521
Registriert: Donnerstag 13. Oktober 2005, 21:27

OK, das klappt schonmal ganz gut. Ich besuche zuerst die Startseite, um eine Pre-Session-ID zu erhalten. Außerdem ist das Feld für das Passwort jedesmal generiert, damit der Login nur im Zusammenspiel aus Pre-Session-ID und Passwortfeld funktioniert.

Ich habe also beides. Nun erstelle ich ein Dictionary, dessen Keys den Feldnamen des Login-Formulars entsprechen:

Code: Alles auswählen

fields = {}
fields['KtoNr'] = "123456789"
fields['d682ca'] = "passwort" # feldname wird immer neu generiert
fields['pre_sess'] = "3dbf2c29f472daad4e232fd387943c20p000_00005078"
fields['Login'] = "Login" # login button
Nun schicke ich das alles ab:

Code: Alles auswählen

url = "http://www.bank.de/login.php"
request = urllib2.Request(url)
data = urllib.urlencode(fields)
try:
	handle = urllib2.urlopen(request, data)
	content = handle.read()
	handle.close()
	return content
except urllib2.HTTPError:
	print "error 404: %s" % url
Es geht! Aber: Wo stelle ich ein, ob ich die Daten in data per GET oder POST abschicken möchte? Müsste man die bei GET gleich direkt an die URL hängen?
Benutzeravatar
Luzandro
User
Beiträge: 87
Registriert: Freitag 21. April 2006, 17:03

droptix hat geschrieben:Es geht! Aber: Wo stelle ich ein, ob ich die Daten in data per GET oder POST abschicken möchte? Müsste man die bei GET gleich direkt an die URL hängen?
Wenn data angegeben ist, wird es laut Doku per POST abgeschickt, d.h. zu der zweiten Frage würde ich mal ja sagen.
[url=http://www.leckse.net/artikel/meta/profilieren]Profilieren im Netz leicht gemacht[/url]
droptix
User
Beiträge: 521
Registriert: Donnerstag 13. Oktober 2005, 21:27

Jippieh, alles klappt! Nun kriege ich meine Kontoauszüge automatisiert per Python-Skript :D

Jetzt muss ich die Transaktionen aus der CSV-Datei nur noch in eine Datenbank überführen...

EDIT: Ich stelle fest, dass die CSV-Datei nach dem Runterladen immer eine Leerzeile nach jeder Zeile hat. Wenn ich sie manuell über die Website runterlade, dann ist das nicht der Fall. Muss ich da noch irgend einen Header mitsenden, um den Zeilenumbruch wegzukriegen oder passiert das während des Speicherns? Ich suche mir nach dem Login die URL der Datei raus und fordere die URL an. Als Ergebnis erhalte ich den Inhalt der Datei als String, den ich anschließend speichere. Muss ich vorher vielleicht noch Zeilenumbrüche umwandeln?
Leonidas
Python-Forum Veteran
Beiträge: 16025
Registriert: Freitag 20. Juni 2003, 16:30
Kontaktdaten:

Dazu musst du uns wohl den Code zeigen, der die CSV-Datei speichert. Ich tippe mal drauf, dass writeline() aufgerufen wird, die Datei aber an den einzlenen Zeilen schon ein \n hat. Irgendwie so wird es sein, dass die Zeilenumbrüche dann doppelt sind. Lässt sich sicher leicht beheben.
My god, it's full of CARs! | Leonidasvoice vs (former) Modvoice
BlackJack

Wie hast Du das mit der Leerzeile festgestellt? Ist da irgendwo ``print`` involviert? Das gibt ja selbst nochmal einen Zeilenumbruch aus, auch wenn die Zeichenkette schon einen am Ende hatte.
droptix
User
Beiträge: 521
Registriert: Donnerstag 13. Oktober 2005, 21:27

BlackJack hat geschrieben:Wie hast Du das mit der Leerzeile festgestellt?
Na indem ich die runtergeladene Datei in einem Editor oder in OpenOffice.org Calc öffne.

Hier der Code...

Prinzipiell bastel ich mir die Download-URL analog zu Leonidas' Code-Snippet mittels urllib2 und BeautifulSoup zusammen. Die URL wird dann in getPage(url) angefordert. Als Ergebnis erhalte ich den Inhalt der Datei als String, den ich mit write() in eine Datei schreibe.

Code: Alles auswählen

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import urllib
import urllib2
import urlparse
import os.path
from BeautifulSoup import BeautifulSoup

class Object:
	pass

class AccountTurnover:
	def __init__(self):
		# configuration
		self.config = Object()
		# front page
		self.config.frontpage = Object()
		self.config.frontpage.url = None
		# login page
		self.config.login = Object()
		self.config.login.url = None
		self.config.login.user = None
		self.config.login.passwd = None
		# download page
		self.config.download = Object()
		self.config.download.url = None
		# logout action
		self.config.logout = Object()
		self.config.logout.url = None
		# session
		self.session = None
		# file content and filename
		self.fileContent = ""
	
	def getPage(self, url, data=None, headers=None):
		request = urllib2.Request(url)
		if data is not None:
			data = urllib.urlencode(data)
		try:
			handle = urllib2.urlopen(request, data)
			content = handle.read()
			handle.close()
			return content
		except urllib2.HTTPError:
			print "error 404: %s" % url
	
	def getElement(self, html, name, attrs):
		soup = BeautifulSoup(html)
		tag = soup.find(name, attrs)
		return tag
	
	def login(self):
		# get pre-session
		html = self.getPage(self.config.frontpage.url)
		frameSrc = self.getElement(html, "frame", {'scrolling' : "yes"})['src']
		# get password field
		html = self.getPage(urlparse.urljoin(self.config.login.url, frameSrc))
		fields = {}
		fields['KtoNr'] = self.config.login.user
		fields['pre_sess'] = self.getElement(html, "input", {'name' : "pre_sess"})['value']
		fields['Direkt'] = "120401"
		fields['screenres'] = ""
		fields['Login'] = "Login"
		passwdField = self.getElement(html, "input", {'type' : "password"})['name']
		fields[passwdField] = self.config.login.passwd
		# get session
		html = self.getPage(self.config.login.url, fields)
		self.session = self.getElement(html, "input", {'name' : "sess"})['value']
	
	def logout(self):
		fields = {}
		fields['sess'] = self.session
		fields['Logout'] = "Logout"
		self.getPage(self.config.logout.url, fields)
		print "logged out"
	
	def download(self):
		fields = {}
		fields['Konto'] = self.config.login.user
		fields['Art'] = "99"
		fields['von'] = "01.12.2006"
		fields['bis'] = "31.12.2006"
		fields['ungebucht'] = "1"
		fields['sess'] = self.session
		fields['Weiter'] = "Weiter"
		html = self.getPage(self.config.download.url, fields)
		# get downloads
		downloads = self.getElement(html, "ul", {'class' : "downloads"})
		csv = self.getElement(html, "a", {'target' : "_blank"})['href']
		url = urlparse.urljoin(self.config.download.url, csv)
		url = url.split("&")
		url = "&".join(url)
		self.fileContent = self.getPage(url)
	
	def saveToDisk(self, filename):
		print "saving to %s..." % filename
		try:
			handle = file(filename, "w")
			handle.write(self.fileContent)
			handle.close()
			print "saved successfully"
		except:
			print "error while saving"

if __name__ == "__main__":
	at = AccountTurnover()
	at.config.frontpage.url = "https://meine.bank.de/index.php"
	at.config.login.url = "https://meine.bank.de/login.php"
	at.config.logout.url = "https://meine.bank.de/logout.php"
	at.config.download.url = "https://meine.bank.de/umsaetze.php"
	at.config.login.user = "1234567890"
	at.config.login.passwd = raw_input("%s: " % at.config.login.user)
	at.login()
	at.download()
	filename = None
	while not filename:
		filename = raw_input("save to:")
		if os.path.exists(filename):
			print "%s already exists... choose again" % filename
			filename = None
	at.saveToDisk(filename)
	at.logout()
EDIT: Ach ja nochwas: Ich suche sowas wie str_replace() aus PHP. Aus irgend einem Grund krieg ich als Download-URL sowas wie

http://meine.bank.de/download.php?file= ... c78ff8daaa...

Also anstatt dem normalen & als Variablen-Trenner bekomme ich die HTML-Entity & Wenn ich die nicht umwandle (s. download()), ist die URL ungültig und die Datei kann nicht runtergeladen werden. Ich behelfe mir momentan mit einem split("&") und einem anschließenden "&".join().
BlackJack

droptix hat geschrieben:
BlackJack hat geschrieben:Wie hast Du das mit der Leerzeile festgestellt?
Na indem ich die runtergeladene Datei in einem Editor oder in OpenOffice.org Calc öffne.
Damit siehst Du normalerweise nicht wirklich was drinsteht. Also Byte für Byte. Wenn man genau wissen will, was da drin steht, braucht man entweder ``print repr(data)`` oder einen Hexeditor.

Du öffnest die Datei als Textdatei. Wenn die Daten die Du runterlädtst schon Zeilenenden im Windowsformat haben ('\r\n'), wird beim schreiben in eine Textdatei unter Windows aus dem '\n' nochmal ein '\r\n', also steht in der Datei letztendlich '\r\r\n' am Ende jeder Zeile. Oder '0D 0D 0A' für den Hexeditor.

Verschwindet das Problem wenn Du die Datei im Binärmodus ('wb') öffnest?
Ach ja nochwas: Ich suche sowas wie str_replace() aus PHP. Aus irgend einem Grund krieg ich als Download-URL sowas wie

http://meine.bank.de/download.php?file= ... c78ff8daaa...
Der Grund wird wohl sein, dass das gültiges HTML ist. Du kannst natürlich die `replace()`-Methode von Zeichenketten benutzen, aber sauberer wäre es wohl, `BeautifulSoup` zu sagen das HTML-Entities ersetzt werden sollen:

Code: Alles auswählen

    soup = BeautifulSoup(source,
                         convertEntities=BeautifulSoup.HTML_ENTITIES)
droptix
User
Beiträge: 521
Registriert: Donnerstag 13. Oktober 2005, 21:27

BlackJack hat geschrieben:Verschwindet das Problem wenn Du die Datei im Binärmodus ('wb') öffnest?
Jepp, danke!
BlackJack hat geschrieben:aber sauberer wäre es wohl, `BeautifulSoup` zu sagen das HTML-Entities ersetzt werden sollen:

Code: Alles auswählen

    soup = BeautifulSoup(source,
                         convertEntities=BeautifulSoup.HTML_ENTITIES)
Klappt nicht, also url.replace("&", "&"). Funzt!
Antworten