Categories
blandet

Opdateret Python-program til at få E-boksbeskeder sendt på mail

Jeg har tidligere skrevet om mit Python-program til at få beskeder fra E-boks sendt på mail.

Nu har E-boks opgraderet sikkerheden, sådan man skal bruge Nemid til at aktivere E-boks på sin mobiltelefon, inden man får lov til at læse E-boks uden brug af Nemid.

Derfor var jeg nødt til at fikse mit program.

Løsningen blev at
bruge Android-emulatoren NOX, installerede app’en, aktivere med nemid og bruge Charles til at overvåge internettrafikken. Derefter genbrugte jeg app’ens kommunikation med E-boks’ server i mit program.

App’en sender en header med “deviceid” og en lang “challenge”-streng og xml-indhold med CPR-nummer og password for at logge på E-boks med “slevel 25”. Mon ikke “slevel” betyder security level?

I mit program kopierede jeg headeren og indholdet fra Charles ind min første kommunikation med E-boks (har redigeret noget ud):

content = '<?xml version="1.0" encoding="utf-8"?><Logon xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="urn:eboks:mobile:1.0.0"><App version="3.6.1-MOBILEACCESS2.210" os="Android" osVersion="4.4.2" device="SM-G925F" /><User identity="[mit cpr-nummer]" identityType="P" nationality="DK" pincode="[mit password]" /></Logon>'

authstr = 'logon deviceid="[en id-streng]", datetime="2019-05-29 06:25:51Z", challenge="[en meget lang streng]"'

Det færdige program

Hvis du ønsker at bruge det færdige program, skal du:

  1. Installere Nox (eller en anden Android-emulator)
  2. Installere Charles (eller et andet program til at overvåge internettrafik)
  3. Opsætte Nox til at bruge Charles’ IP som proxy-server
  4. Åbne op for at kunne se SSL-traffik i Charles
  5. Installere E-boks-app’en i Nox og aktivere app’en med dit nemid
  6. Starte E-boks-app’en igen og logge på med dit CPR-nummer og password og logge trafikken i Charles
  7. Kopiere “content” og “authstr” fra Charles (se skærmbillede)og sætte ind i programmet hvor der står hhv. content = ” og authstr = ”

Held og lykke!

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com.
# Based on https://github.com/dk/Net-Eboks perl API for eboks.dk by Dmitry Karasik. Thanks!
""" This program logs on to e-boks.dk and takes new messages and sends them
to an e-mail. It requires prior Nemid activation using the E-boks app, e.g.
running the Nox emulator and a traffic monitor such as Charles. It also
requires access to a secure (SSL) SMTP server and mail account for sending e-mails. """

# Necessary modules
from datetime import datetime						# Current date and time
import requests										# Communicating with E-boks
import xml.etree.ElementTree as ET					# Parse E-boks XML responses
import smtplib										# Sending e-mails
from email.mime.multipart import MIMEMultipart		# Creating multipart e-mails
from email.mime.text import MIMEText				# Attaching text to e-mails
from email.mime.application import MIMEApplication	# Attaching pdf to e-mails
from email.mime.image import MIMEImage				# Attaching images to e-mails
from email.utils import formataddr					# Used for correct encoding of senders with special characters in name (e.g. Københavns Kommune)
import chardet										# Text message character set detection
import time											# Pause between e-mails sent

# Configuration data
data = {
'emailserver': '', 	# Your mail server hostname: host.server.dk
'emailserverport': ,					# Mail server port, e.g. 465
'emailusername': '',	# Sender mail account username
'emailpassword': '',		# Sender mail account password
'emailfrom': '',		# Sender e-mail, e.g. trump@usa.gov
'emailto': '',		# Recipient e-mail, e.g. hillary@clinton.net
'numberofmessagesperfolder': '10',		# Number of messages to request (10 is usually enough)
'unreadstatusvalue': "true",			# Normally "true". If "false" also read messages are sent
'unreadmorethan': 0,					# Normally 0, only unread messages are sent. If -1 all messages are sent
'sendemails': True,						# If True, e-mails are sent, if False, they are not
'country': 'DK',
'type': 'P',
'deviceid': '2',    # Use id from http traffic monitor
'datetime': '',
'root': 'rest.e-boks.dk',
'nonce': '',
'sessionid': '',
'response': '523b931af795698785df1eb85e8c10ea0687a46edb3a48468943f2d368fe725a',
'uid': '',
'uname': '',
'challenge': ''
}

# Gets current date and time for E-boks challenge
now = datetime.now()
data['datetime'] = datetime.strftime(now, '%Y-%m-%d %H:%M:%SZ')

# These functions are used to create sessionid, nonce and authstring values for communicating
# with E-boks throughout the program
def sessionid(authenticate):
	sessionstart = authenticate.find('sessionid="')+len('sessionid="')
	sessionend = authenticate.find('"', sessionstart)
	data['sessionid'] = authenticate[sessionstart:sessionend]

def nonce(authenticate):
	noncestart = authenticate.find('nonce="')+len('nonce="')
	nonceend = authenticate.find('"', noncestart)
	data['nonce'] = authenticate[noncestart:nonceend]

def createauthstring():
	authstr = 'deviceid="' + data['deviceid'] + '",nonce="' + data['nonce'] + ',sessionid="' + data['sessionid'] + '",response="' + data['response'] + '"'
	return authstr

# Logon to mail server
server = smtplib.SMTP_SSL(data['emailserver'], data['emailserverport'])
server.login(data['emailusername'], data['emailpassword'])
	
# First logon to e-boks
url = "https://" + data['root'] + "/mobile/1/xml.svc/en-gb/session"

# Use XML string from http traffic monitor
content = ''

# Use string from http traffic monitor
authstr = ''

headers = {
'Content-Type': 'application/xml',
'Content-Length': str(len(content)),
'X-EBOKS-AUTHENTICATE': authstr,
'Accept': '*/*',
'Accept-Language': 'en-US',
'Accept-Encoding': 'gzip,deflate',
'Host': data['root'],
}

r = requests.put(url, headers=headers, data=content)

authenticate = r.headers['X-EBOKS-AUTHENTICATE']
nonce(authenticate)
sessionid(authenticate)

xml = ET.fromstring(r.text)
# Saves username and user id
data['uname'] = xml[0].attrib['name']
data['uid'] = xml[0].attrib['userId']

# Get folder data from e-boks
url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folders'
authstr = createauthstring()

headers = {
'X-EBOKS-AUTHENTICATE': authstr,
'Accept': '*/*',
'Accept-Language': 'en-US',
'Host': data['root'],
}

r = requests.get(url, headers=headers)
authenticate = r.headers['X-EBOKS-AUTHENTICATE']
nonce(authenticate)

xml = ET.fromstring(r.text)

eboks_folders = xml

# Get folder id's and numbers of unread messages
for folder in eboks_folders:
	folderid = folder.attrib['id']
	unread = folder.attrib['unread']
	
	# Get messages ONLY if any unread messages in folder
	if int(unread) > data['unreadmorethan']:		# Usually > 0. Can be changed to == 0 for debugging purposes
		
		# Get list of messages
		url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid
		
		authstr = createauthstring()

		headers = {
		'X-EBOKS-AUTHENTICATE': authstr,
		'Accept': '*/*',
		'Accept-Language': 'en-US',
		'Host': data['root'],
		}

		params = {
		'skip': '0',
		'take': data['numberofmessagesperfolder']
		}

		r = requests.get(url, headers=headers, params=params)
		authenticate = r.headers['X-EBOKS-AUTHENTICATE']
		nonce(authenticate)		
		
		xml = ET.fromstring(r.text)
		eboks_messages = xml

		i = 0
		max = int(params['take']) - 1
		while i <= max:
			for child in eboks_messages:
				messageid = child[i].attrib['id']
				subject  = child[i].attrib['name']
				sender = child[i][0].text
				unreadstatus = child[i].attrib['unread']
				attachmentcount = child[i].attrib['attachmentsCount']
				format = child[i].attrib['format'].lower()
				received = child[i].attrib['receivedDateTime']
				
				i += 1
				
				# Get only messages that are unread
				if unreadstatus == data['unreadstatusvalue']:	# Usually true. Can be changed to false for debugging purposes
					
					# Start e-mail
					msg = MIMEMultipart()
					msg['From'] = formataddr((sender, data['emailfrom']))
					msg['To'] = data['emailto']
					msg['Subject'] = "E-boks: " + subject
					body = ""
					
										
					# Get message (marks it as read)
					url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + messageid

					authstr = createauthstring()

					headers = {
					'X-EBOKS-AUTHENTICATE': authstr,
					'Accept': '*/*',
					'Accept-Language': 'en-US',
					'Host': data['root'],
					}

					r = requests.get(url, headers=headers)
					authenticate = r.headers['X-EBOKS-AUTHENTICATE']
					nonce(authenticate)
					
					# Get primary message content
					url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + messageid + '/content'

					authstr = createauthstring()

					headers = {
					'X-EBOKS-AUTHENTICATE': authstr,
					'Accept': '*/*',
					'Accept-Language': 'en-US',
					'Host': data['root'],
					}

					r = requests.get(url, headers=headers)
					authenticate = r.headers['X-EBOKS-AUTHENTICATE']
					nonce(authenticate)
					
					# Attach primary message content to e-mail
					if format in ("txt","text","plain"):
						characterset = chardet.detect(r.content)
						r.encoding = characterset['encoding']
						body = r.text
						msg.attach(MIMEText(body, 'plain'))
					elif format in ("html","htm"):
						characterset = chardet.detect(r.content)
						r.encoding = characterset['encoding']
						body = r.text
						msg.attach(MIMEText(body, 'html'))						
					elif format == "pdf":
						filename = "".join().rstrip() + "." + format
						part = MIMEApplication(r.content)
						part.add_header('Content-Disposition', 'attachment', filename = filename)
						msg.attach(part)
					elif format in ("gif","jpg","jpeg","tiff","tif","webp"):
						filename = "".join().rstrip() + "." + format
						part = MIMEImage(r.content)
						part.add_header('Content-Disposition', 'attachment', filename = filename)
						msg.attach(part)
										
					# Get attachment data if message has attachments
					if int(attachmentcount) > 0:

						url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + messageid

						authstr = createauthstring()

						headers = {
						'X-EBOKS-AUTHENTICATE': authstr,
						'Accept': '*/*',
						'Accept-Language': 'en-US',
						'Host': data['root'],
						}

						r = requests.get(url, headers=headers)
						authenticate = r.headers['X-EBOKS-AUTHENTICATE']
						nonce(authenticate)

						xml = ET.fromstring(r.text)
						
						eboks_attachment = xml
						
						# Gets if, name and format of attachment
						for child in eboks_attachment:
							for subtree in child:
								attachmentid = subtree.attrib['id']
								attachmenttitle = subtree.attrib['name']
								attachmentformat = subtree.attrib['format']
				
								# Gets the actual attachment
								url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + attachmentid + '/content'
									
								authstr = createauthstring()

								headers = {
								'X-EBOKS-AUTHENTICATE': authstr,
								'Accept': '*/*',
								'Accept-Language': 'en-US',
								'Host': data['root'],
								}

								r = requests.get(url, headers=headers)
								authenticate = r.headers['X-EBOKS-AUTHENTICATE']
								nonce(authenticate)
								
								# Attach attachment to e-mail
								if attachmentformat in ("txt","text","html","htm","plain"):
									filename = "".join().rstrip() + "." + attachmentformat
									r.encoding = "utf-8"
									part = MIMEText(r.text)
									part.add_header('Content-Disposition', 'attachment', filename = filename)
									msg.attach(part)
								elif attachmentformat == "pdf":
									filename = "".join().rstrip() + "." + attachmentformat
									part = MIMEApplication(r.content)
									part.add_header('Content-Disposition', 'attachment', filename = filename)
									msg.attach(part)
								elif attachmentformat in ("gif","jpg","jpeg","tiff","tif","webp"):
									filename = "".join().rstrip() + "." + attachmentformat
									part = MIMEImage(r.content)
									part.add_header('Content-Disposition', 'attachment', filename = filename)
									msg.attach(part)

					# Send e-mail
					if data['sendemails'] == True:
						print("sending")
						msg.attach(MIMEText(body, 'plain'))
						server.sendmail(data['emailfrom'], data['emailto'], msg.as_string())
						time.sleep(2)

Categories
blandet

Sådan låner jeg bøger på eReolen – med Python

eReolen er fint til at læse gratis e-bøger på, men både hjemmesiden og app’en synes jeg er kluntet at bruge. Derfor har jeg undersøgt, om jeg kunne bruge Python til at snakke med eReolen – og så udelukkende bruge app’en til at læse på.

Først havde jeg brug for at finde ud af, hvordan eReolens app virker.

Jeg installerede Android-emulatoren NOX og installerede app’en. Derefter hentede jeg Charles, som er et værktøj til at overvåge internettraffik. Jeg satte NOX op til at bruge Charles’ IP som proxyserver, åbnede op i Charles for at kunne se krypteret trafik og vupti – nu kunne jeg se trafikken fra NOX i Charles.

Det ser nogenlunde sådan ud, når man logger på eReolen:

eReolens app snakker altså med ereolen-be.redia.dk og en side på serveren, der hedder rpc.php. I spalten “Info” kan jeg se forskellige funktioner, som rpc.php understøtter. Den allerførste (authenticate) er login. Den kiggede jeg nærmere på:

Browseren sender data af sted i JSON-format
JSON-strengen har et id, som ser ud til at være tilfældigt genereret, fortæller hvilken protokol, der bruges, en metode (her “authenticate”) og nogle parametre (bibliotekskode, CPR-nummer og PIN-kode)

Når det lykkes at logge på, får man nogenlunde dette data tilbage:

{
	"jsonrpc": "2.0",
	"id": "[slettet]",
	"result": {
		"result": true,
		"data": {
			"retailerid": "810",
			"cardnumber": "[slettet]",
			"ereolen": {
				"result": true,
				"data": null,
				"message": "",
				"language": "da",
				"code": 0
			},
			"netlydbog": {
				"result": true,
				"data": null,
				"message": "",
				"language": "da",
				"code": 0
			}
		},
		"message": "",
		"language": "da",
		"code": 0
	}
}

Code 0 betyder, at login er lykkedes.

Tilbage får man også en cookie med et ID for den “session”, man har startet. Den hedder PHPSESSID.

Sådan her ser login-proceduren ud i Python:

import requests		# Used for http requests
import uuid			# Used to generate random strings for json request ids

# Credentials
# Library ID as string (See end of program for list), CPR-number as string (1234561234), PIN as string
logininfo = ['810','1234567890','1234']

# Login and get session cookie
headers = {
	'Accept-Charset': 'UTF-8',
	'Content-Type':	'application/json',
	'User-Agent': 'Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-G925F Build/JLS36C)',
	'Accept-Encoding': 'gzip'
}	

json = {
	"id": str(uuid.uuid4()),
	"jsonrpc": "2.0",
	"method": "authenticate",
	"params": logininfo
}

url = 'https://ereolen-be.redia.dk/v5/rpc.php'
r = requests.post(url, headers=headers, json=json)
loginresponse = r.json()

# Save session cookie to cookie dictionary
cookies = {}
cookies['PHPSESSID'] = r.cookies['PHPSESSID']
cookies['ROUTEID'] = r.cookies['ROUTEID']

Det er let at undersøge de forskellige funktioner/metoder i Charles. I dette tilfælde var jeg interesseret i at låne en bog. Den funktion hedder, logisk nok, createLoan.

Her er hvad eReolens app sender af sted, når man låner en ebog:

{
	"id": "[slettet]",
	"jsonrpc": "2.0",
	"method": "createLoan",
	"params": ["9788792922021", "ereolen"]
}

En søgning på tallet i “params” viser, at vi her har at gøre med Håbløse slægter af Herman Bang. Tallet er simpelthen bogens ISBN-nummer.

Det færdige program

Det færdige program, der lader låneren indtaste et ISBN-nummer på en bog, som så bliver lånt, ser sådan her ud.

Du er velkommen til at bruge programmet, videreudvikle, og hvad du ellers har lyst til.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" Loan a book at eReolen """

import requests		# Used for http requests
import uuid			# Used to generate random strings for json request ids

# Credentials
# Library ID as string (See end of program for list), CPR-number as string (1234561234), PIN as string
logininfo = ['810','1234567890','1234']

# Login and get session cookie
headers = {
	'Accept-Charset': 'UTF-8',
	'Content-Type':	'application/json',
	'User-Agent': 'Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-G925F Build/JLS36C)',
	'Accept-Encoding': 'gzip'
}	

json = {
	"id": str(uuid.uuid4()),
	"jsonrpc": "2.0",
	"method": "authenticate",
	"params": logininfo
}

url = 'https://ereolen-be.redia.dk/v5/rpc.php'
r = requests.post(url, headers=headers, json=json)
loginresponse = r.json()

# Save session cookie to cookie dictionary
cookies = {}
cookies['PHPSESSID'] = r.cookies['PHPSESSID']
cookies['ROUTEID'] = r.cookies['ROUTEID']

# Check for successful login
if loginresponse["result"]["result"] == True and loginresponse["result"]["data"]["ereolen"]["code"] == 0:
	print("Login lykkedes")
	print("")
	validinput = False
	# Loop to ensure the user enters valid ISBN
	while validinput ==  False:
		isbn = input("Indtast ISBN (13 cifre) på den bog, du vil låne: ")
		# ISBN must be 13 characters and digits only
		if len(isbn) == 13 and isbn.isdigit():
			validinput = True
			isbn = str(isbn)
			# Create loan
			json = {
				"id": str(uuid.uuid4()),
				"jsonrpc": "2.0",
				"method": "createLoan",
				"params": [isbn, "ereolen"]
			}
			r = requests.post(url, headers=headers, cookies=cookies, json=json)
			loanresponse = r.json()
			# Check for loan success
			if loanresponse["result"]["code"] == 0:
				print("Tillykke! Det lykkes at låne bogen. God fornøjelse.")
			else:
				print("Noget gik galt. Måske kan dette gøre dig klogere:")
				print("")
				print(loanresponse)
				print("")
		else:
			print("")
			print("Det ligner, du har tastet forkert ISBN. Prøv igen.")
			print("")
else:
	print("Noget gik galt ved login. Prøv at tjekke dine logininformationer.")

#LIBRARY IDS
'''
Haderslev:851
Frederiksberg:823
Herlev:866
Hedensted:912
Brøndby:860
Lolland:874
Esbjerg:826
Guldborgsund:873
Brønderslev:886
Vesthimmerland:890
Ikast-Brande:835
Glostrup:856
Assens:953
Roskilde:824
Hjørring:867
Køge:872
Greve:830
Samsø:936
Rebild:862
Frederikshavn:880
Dragør:905
Høje-Taastrup:875
Thisted:885
Nordfyn:950
Svendborg:897
Aarhus:816
Langeland:852
Herning:828
Vallensbæk:877
Favrskov:888
Kerteminde:917
Skive:907
Holbæk:899
Hillerød:861
Halsnæs:887
Jammerbugt:902
Ballerup:843
Kalundborg:910
Helsingør:896
Varde:834
Odsherred:879
Viborg:854
Stevns:882
Fredensborg:855
Fredericia:891
Sorø:848
Frederikssund:870
Faxe:895
Norddjurs:906
Rødovre:838
Randers:916
København:810
Hvidovre:853
Sydslesvig:889
Slagelse:894
Tønder:900
Ringsted:884
Albertslund:881
Silkeborg:829
Lejre:935
Gentofte:832
Odense:825
Struer:869
Faaborg-Midtfyn:840
Vejen:893
Ærø:954
Rudersdal:841
Hørsholm:871
Middelfart:864
Billund:850
Allerød:952
Solrød:844
Egedal:898
Lemvig:911
Býarbókasavnið:955
Horsens:847
Kolding:878
Gladsaxe:863
Aabenraa:839
Odder:876
Gribskov:903
Nyborg:858
Ishøj:868
Vejle:849
Sønderborg:836
Mariagerfjord:842
Skanderborg:883
Vordingborg:901
Morsø:904
Lyngby:857
Syddjurs:892
Aalborg:822
Bornholm:865
Furesø:908
Holstebro:859
Næstved:831
Tårnby:837
Ringkøbing-Skjern:845
'''

Categories
blandet

Sådan trækker du dine data ud fra Saxo Bank med Python

Opdateret d. 20/10/2019: Nogle gange har Saxo Bank en “disclaimer” (i dette tilfælde omkring Brexit), som de vil vise, inden man får lov at logge på. Jeg har tilpasset koden, sådan programmet kan håndtere dette tilfælde.

Jeg har tidligere skrevet om, hvordan jeg trækker transaktionsdata ud fra mine konti hos Nordnet.

Nu er jeg også blevet kunde hos Saxo Bank. (Hvorfor? Mulighed for at oprette en aktiesparekonto og ingen minimumskurtage.)

Selvbetjeningsløsningen hos Saxo Bank er rigtig dårlig (sammenlignet med Nordnets).

Derfor var jeg interesseret i, om jeg kunne finde en måde at trække transaktionsdata ud fra min konto hos Saxo Bank – uden at have brug for at se på hjemmesiden.

Det kunne jeg. Måske ikke på den smarteste måde i verden, for Saxo Bank har faktisk en API-løsning, man kan bruge, hvis man har mod på at udfylde en aftale i hånden og scanne den ind (det gad jeg ikke).

Her kan du læse, hvordan jeg fik fat i mine data.

Snakke http med Saxo

Ligesom da jeg hentede mine transaktioner hos Nordnet, undersøgte jeg, hvordan min browser snakker med – og viser data fra – min konto hos Saxo Bank.

Allerførst kiggede jeg på, hvad hjemmesiden gør, når den skal vise mine data. Jeg valgte gennemførte handler i menuen:

Og så kiggede jeg på, hvad browseren gjorde. Det viser sig at hjemmesiden – fornuftigt nok – bruger Saxo Banks API, når den skal vise data til brugeren:

Jeg kunne se, at API’et modtog en lang streng (“Authorization” med ordet BEARER foran). Den gik jeg ud fra, var nødvendig, for at få data tilbage.

Så spørgsmålet var egentlig bare: Hvordan bliver sådan en BEARER-streng genereret?

Tilbage til start

For at komme frem til, hvordan BEARER-strengen genereres, gik jeg tilbage til start: Jeg gik til loginsiden og trykkede F12 i min browser (Chrome) for at følge med i netværksforespørgslerne.

Loginsidens formular sender mit brugernavn og password af sted, sammen med en streng – “AuthnRequest” – der genereres på ny hver gang, loginsiden hentes:

I mit Python-program prøver jeg at sende sådan en formular af sted, og undersøger hvad jeg får tilbage.

# Visit login page and get AuthnRequest token value from input form
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.get(url)

soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"id":"AuthnRequest"})
authnrequest = input[0]["value"]

# Login step 1: Submit username, password and token and get another token back
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.post(url, data = {'field_userid': user, 'field_password': password, 'AuthnRequest': authnrequest})

Lidt forkortet ser det sådan her ud:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="Application-State" content="service=IDP;federated=False;env=Live;state=Ok;authenticated=True;"><meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<noscript><p><strong>Note:</strong> Since your browser does not support Javascript, you must press the Continue button to proceed.</p></noscript>
<form id="form" action="https://www.saxoinvestor.dk/investor/login.sso.ashx" method="post"><div>
<input type="hidden" name="SAMLResponse" value="PHNhbWxwOlJlc3BvbnNlIElEPSJfNjQzZGI4ODQtMDMzZi00MWVhLWE4ZjEtYzVjOWVlMWIxM2IwIiBJblJlc3BvbnNlVG89Il9mN2E3ODBlZi0yZjdmLTQyYmItODk1[...]G9uc2U+"/>
<input type="hidden" name="RelayState" value=""/>
<input type="hidden" name="PageLoadInfo" id="PageLoadInfo" value=""/></div>
<noscript><div>
<input type="submit" value="Continue"/></div></noscript></form><script type="text/javascript">function doSubmit(){var t=-1;if(window.location.hash){var m=window.location.hash.match(/\/lst\/(\d+)/);if(m) t=parseInt(m[1]);}if(t>=0 &amp;&amp; document.getElementById("PageLoadInfo").value=='')document.getElementById("PageLoadInfo").value=t;document.forms.form.submit();}</script><script  type="text/javascript">doSubmit();</script></body></html>

Og hvad er det så? En formular (“<input>”) til browsere uden Javascript, med et felt der hedder “SAMLResponse” med en lang (her forkortet) streng som værdi.

I Chrome kan jeg se, at allersidste trin. inden jeg når ind på forsiden af selvbetjeningen, faktisk er, at min browser sender “SAMLResponse” af sted til en side, der hedder “login.sso.ashx”:

Så jeg sender trygt formularen af sted med SAMLResponse-.værdien og ser, hvad jeg får tilbage:

soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})
samlresponse = input[0]["value"]

# Login step 2: Get bearer token necessary for API requests
url = 'https://www.saxoinvestor.dk/investor/login.sso.ashx'
r = requests.post(url, data = {'SAMLResponse': samlresponse})

Og vupti: Siden videresender mig til API’et med et BEARER-token, jeg kan benytte mig af. Det får jeg fat i (og skærer lidt til) sådan her::

bearer = r.history[0].headers['Location']
bearer = bearer[bearer.find("BEARER"):bearer.find("/exp/")]
bearer = bearer.replace("%20"," ")

Og så er jeg ellers klar til at hente data fra API’et. Det starter sådan her:

# START API CALLS
# Documentation at https://www.developer.saxo/openapi/learn

# Set bearer token as header
headers = {'Authorization': bearer}

# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
r = requests.get(url, headers=headers)

clientdata = r.json()
clientkey = clientdata['ClientKey']

Hele programmet

Hele programmet – inklusive den måde, jeg omdanner Saxo Bank-data til samme format som Nordnets transaktionsdata – er her.

Du må gøre præcis som du har lyst til med det (på eget ansvar).

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
"""This program logs into a Saxo Bank account and lets you make API requests."""

import requests 
from datetime import datetime
from datetime import date
from bs4 import BeautifulSoup
import json

# USER ACCOUNT AND PERIOD DATA. SHOULD BE EDITED FOR YOUR NEEDS #

# Saxo user account credentials
user = '' # your user id
password = '' # your password

# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '2019-01-01'
today = date.today()
enddate = datetime.strftime(today, '%Y-%m-%d')

# LOGIN TO SAXO BANK
	
# Visit login page and get AuthnRequest token value from input form
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.get(url)

soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"id":"AuthnRequest"})
authnrequest = input[0]["value"]

# Login step 1: Submit username, password and token and get another token back
url = 'https://www.saxoinvestor.dk/Login/da/'
r = requests.post(url, data = {'field_userid': user, 'field_password': password, 'AuthnRequest': authnrequest})

soup = BeautifulSoup(r.text, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})
# Most of the time this works
if input:
	samlresponse = input[0]["value"]
# But sometimes there's a disclaimer that Saxo Bank would like you to accept
else:
	input = soup.find_all('input')
	inputs = {}
	try:
		for i in input:
			inputs[i['name']] = i['value']
	except:
		pass
	url = 'https://www.saxotrader.com/disclaimer'
	request = requests.post(url, data=inputs)
	cook = request.cookies['DisclaimerApp']
	returnurl = cook[cook.find("ReturnUrl")+10:cook.find("&amp;IsClientStation")]
	url = 'https://live.logonvalidation.net/complete-app-consent/' + returnurl[returnurl.find("complete-app-consent/")+21:]
	request = requests.get(url)
	soup = BeautifulSoup(request.text, "html.parser")
	input = soup.find_all('input', {"name":"SAMLResponse"})
	samlresponse = input[0]["value"]

# Login step 2: Get bearer token necessary for API requests
url = 'https://www.saxoinvestor.dk/investor/login.sso.ashx'
r = requests.post(url, data = {'SAMLResponse': samlresponse})

bearer = r.history[0].headers['Location']
bearer = bearer[bearer.find("BEARER"):bearer.find("/exp/")]
bearer = bearer.replace("%20"," ")

# START API CALLS
# Documentation at https://www.developer.saxo/openapi/learn

# Set bearer token as header
headers = {'Authorization': bearer}

# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
r = requests.get(url, headers=headers)

clientdata = r.json()
clientkey = clientdata['ClientKey']

# Example API call #1
url = 'https://www.saxoinvestor.dk/openapi/cs/v1/reports/aggregatedAmounts/' + clientkey + '/' + startdate + '/' + enddate + '/'
r = requests.get(url, headers=headers)
data = r.json()

# Working on that data to add some transaction types to personal system
saxoaccountname = "Aktiesparekonto: Saxo Bank"
currency = "DKK"
saxotransactions = ""

for item in data['Data']:
	if item['AffectsBalance'] == True:
		date = item['Date']
		amount = item['Amount']
		amount_str = str(amount).replace(".",",")
		if item['UnderlyingInstrumentDescription'] == 'Cash deposit or withdrawal' or item['UnderlyingInstrumentDescription'] == 'Cash inter-account transfer':
			if amount > 0:
				transactiontype = 'INDBETALING'
			elif amount < 0:
				transactiontype = 'HÆVNING'
			saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";;;;;;;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\r\n"
		if item['AmountTypeName'] == 'Corporate Actions - Cash Dividends':
			transactiontype = "UDB."
			if item['InstrumentDescription'] == "Novo Nordisk B A/S":
				paper = "Novo B"
				papertype = "Aktie"
			if item['InstrumentDescription'] == "Tryg A/S":
				paper = "TRYG"
				papertype = "Aktie"
			saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";" + paper + ";" + papertype + ";;;;;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\n"

# Example API call #2		
url = "https://www.saxoinvestor.dk/openapi/cs/v1/reports/trades/" + clientkey + "?fromDate=" + startdate + "&amp;" + "toDate=" + enddate
r = requests.get(url, headers=headers)
data = r.json()

# Working on that data to add trades to personal system
for item in data['Data']:
	date = item['AdjustedTradeDate']
	numberofpapers = str(int(item['Amount']))
	amount_str = str(item['BookedAmountAccountCurrency']).replace(".",",")
	priceperpaper = str(item['BookedAmountAccountCurrency'] / item['Amount']).replace(".",",")
	if item['TradeEventType'] == 'Bought':
		transactiontype = "KØBT"
	if item['AssetType'] == 'Stock':
		papertype = "Aktie"
	if item['InstrumentDescription'] == "Novo Nordisk B A/S":
		paper = "Novo B"
		isin = "DK0060534915"
	if item['InstrumentDescription'] == "Tryg A/S":
		paper = "TRYG"
		isin = "DK0060636678"
	saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";" + paper + ";" + papertype + ";" + isin + ";" + numberofpapers + ";" + priceperpaper + ";;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\n"
Categories
blandet

Python-program til at sende dine beskeder i E-boks til din mailadresse

OPDATERING 29. maj 2019: Dette program virker ikke længere. Prøv dette i stedet.

Der er mange, der har fået den idé, automatisk at videresende beskeder i E-boks til en anden mailadresse eller gemme dem på computeren. Videresendelse kan også gøres i E-boks selv, men kun manuelt og en besked ad gangen. Her er nogle projekter:

Jeg kan kun finde ud af Python, og ved hvordan jeg sætter et job op på min webserver, der kan gøre Python-programmer regelmæssigt (det skulle jeg nemlig bruge til https://wallnot.dk).

Så – med kæmpe hjælp fra koden til Net-Eboks af Dmitry Karasik – har jeg skrevet et Python-program, der videresender nye beskeder i E-boks til min mail.

Programmet fungerer, men det er ikke gennemtestet, og tager ikke højde for fejl, fx. at brugeren indtaster forkerte oplysninger i programmet. Så det er nok en god idé også at logge ind på E-boks en gang imellem og lige se at alt bliver hentet og videresendt.

Du er velkommen til at bruge programmet, videreudvikle, og hvad du ellers kan finde på.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com.
# Based on https://github.com/dk/Net-Eboks perl API for eboks.dk by Dmitry Karasik. Thanks!
""" This program logs on to e-boks.dk and takes new messages and sends them
to an e-mail. It requires mobile app login for e-boks (see http://www.e-boks.dk/help.aspx?pageid=db5a89a1-8530-418a-90e9-ff7f0713784a for
how to create). It also requires access to a secure (SSL) SMTP server and mail 
account for sending e-mails. """

# Necessary modules
from datetime import datetime						# Current date and time
import requests										# Communicating with E-boks
import hashlib										# Hash configuration for challenge/logon
import xml.etree.ElementTree as ET					# Parse E-boks XML responses
import smtplib										# Sending e-mails
from email.mime.multipart import MIMEMultipart		# Creating multipart e-mails
from email.mime.text import MIMEText				# Attaching text to e-mails
from email.mime.application import MIMEApplication	# Attaching pdf to e-mails
from email.mime.image import MIMEImage				# Attaching images to e-mails
from email.utils import formataddr					# Used for correct encoding of senders with special characters in name (e.g. Københavns Kommune)
import chardet										# Text message character set detection
import time											# Pause between e-mails sent

# Configuration data
data = {
'emailserver': '', 	# Your mail server hostname: host.server.dk
'emailserverport': ,					# Mail server port, e.g. 465
'emailusername': '',	# Sender mail account username
'emailpassword': '',		# Sender mail account password
'emailfrom': '',		# Sender e-mail, e.g. trump@usa.gov
'emailto': '',		# Recipient e-mail, e.g. hillary@clinton.net
'cpr': '',					# CPR number (no hyphens), e.g. 1234567890
'password': '',		# E-boks mobile account password
'activation': '',				# E-boks mobile account activation code
'numberofmessagesperfolder': '10',		# Number of messages to request (10 is usually enough)
'unreadstatusvalue': "true",			# Normally "true". If "false" also read messages are sent
'unreadmorethan': 0,					# Normally 0, only unread messages are sent. If -1 all messages are sent
'sendemails': True,						# If True, e-mails are sent, if False, they are not
'country': 'DK',
'type': 'P',
'deviceid': 'python-e-boks-000000000000',
'datetime': '',
'root': 'rest.e-boks.dk',
'nonce': '',
'sessionid': '',
'response': '3a1a51f235a8bd6bbc29b2caef986a1aeb77018d60ffdad9c5e31117e7b6ead3',
'uid': '',
'uname': '',
'challenge': ''
}

# Gets current date and time for E-boks challenge
now = datetime.now()
data['datetime'] = datetime.strftime(now, '%Y-%m-%d %H:%M:%SZ')

# Hashes parts of configuration data and sets challenge value to authenticate with E-boks
hashstring = data['activation']+":"+data['deviceid']+":"+data['type']+":"+data['cpr']+":"+data['country']+":"+data['password']+":"+data['datetime']
hashstringcoded = hashstring.encode('utf-8')
data['challenge'] = hashlib.sha256(hashstringcoded).hexdigest().encode('utf-8')
data['challenge'] = hashlib.sha256(data['challenge']).hexdigest()

# These functions are used to create sessionid, nonce and authstring values for communicating
# with E-boks throughout the program
def sessionid(authenticate):
	sessionstart = authenticate.find('sessionid="')+len('sessionid="')
	sessionend = authenticate.find('"', sessionstart)
	data['sessionid'] = authenticate[sessionstart:sessionend]

def nonce(authenticate):
	noncestart = authenticate.find('nonce="')+len('nonce="')
	nonceend = authenticate.find('"', noncestart)
	data['nonce'] = authenticate[noncestart:nonceend]

def createauthstring():
	authstr = 'deviceid="' + data['deviceid'] + '",nonce="' + data['nonce'] + ',sessionid="' + data['sessionid'] + '",response="' + data['response'] + '"'
	return authstr

# Logon to mail server
server = smtplib.SMTP_SSL(data['emailserver'], data['emailserverport'])
server.login(data['emailusername'], data['emailpassword'])
	
# First logon to e-boks
url = "https://" + data['root'] + "/mobile/1/xml.svc/en-gb/session"

content = '<Logon xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="urn:eboks:mobile:1.0.0"><User identity="' + data['cpr'] + '" identityType="' + data['type'] + '" nationality="' + data['country'] + '" pincode="' + data['password'] + '"/></Logon>'

authstr = 'logon ' + 'deviceid="' + data['deviceid']+ '",' + 'datetime="' + data['datetime'] + '",' + 'challenge="' + data['challenge'] + '"'

headers = {
'Content-Type': 'application/xml',
'Content-Length': str(len(content)),
'X-EBOKS-AUTHENTICATE': authstr,
'Accept': '*/*',
'Accept-Language': 'en-US',
'Accept-Encoding': 'gzip,deflate',
'Host': data['root'],
}

r = requests.put(url, headers=headers, data=content)

authenticate = r.headers['X-EBOKS-AUTHENTICATE']
nonce(authenticate)
sessionid(authenticate)

xml = ET.fromstring(r.text)
# Saves username and user id
data['uname'] = xml[0].attrib['name']
data['uid'] = xml[0].attrib['userId']

# Get folder data from e-boks
url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folders'
authstr = createauthstring()

headers = {
'X-EBOKS-AUTHENTICATE': authstr,
'Accept': '*/*',
'Accept-Language': 'en-US',
'Host': data['root'],
}

r = requests.get(url, headers=headers)
authenticate = r.headers['X-EBOKS-AUTHENTICATE']
nonce(authenticate)

xml = ET.fromstring(r.text)

eboks_folders = xml

# Get folder id's and numbers of unread messages
for folder in eboks_folders:
	folderid = folder.attrib['id']
	unread = folder.attrib['unread']
	
	# Get messages ONLY if any unread messages in folder
	if int(unread) > data['unreadmorethan']:		# Usually > 0. Can be changed to == 0 for debugging purposes
		
		# Get list of messages
		url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid

		authstr = createauthstring()

		headers = {
		'X-EBOKS-AUTHENTICATE': authstr,
		'Accept': '*/*',
		'Accept-Language': 'en-US',
		'Host': data['root'],
		}

		params = {
		'skip': '0',
		'take': data['numberofmessagesperfolder']
		}

		r = requests.get(url, headers=headers, params=params)
		authenticate = r.headers['X-EBOKS-AUTHENTICATE']
		nonce(authenticate)		
		
		xml = ET.fromstring(r.text)
		
		eboks_messages = xml
		
		i = 0
		max = int(params['take']) - 1
		
		while i  <= max:
			for child in eboks_messages:
				messageid = child[i].attrib['id']
				subject  = child[i].attrib['name']
				sender = child[i][0].text
				unreadstatus = child[i].attrib['unread']
				attachmentcount = child[i].attrib['attachmentsCount']
				format = child[i].attrib['format'].lower()
				received = child[i].attrib['receivedDateTime']
				
				i += 1
				
				# Get only messages that are unread
				if unreadstatus == data['unreadstatusvalue']:	# Usually true. Can be changed to false for debugging purposes
					
					# Start e-mail
					msg = MIMEMultipart()
					msg['From'] = formataddr((sender, data['emailfrom']))
					msg['To'] = data['emailto']
					msg['Subject'] = "E-boks: " + subject
					body = ""
					
										
					# Get message (marks it as read)
					url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + messageid

					authstr = createauthstring()

					headers = {
					'X-EBOKS-AUTHENTICATE': authstr,
					'Accept': '*/*',
					'Accept-Language': 'en-US',
					'Host': data['root'],
					}

					r = requests.get(url, headers=headers)
					authenticate = r.headers['X-EBOKS-AUTHENTICATE']
					nonce(authenticate)
					
					# Get primary message content
					url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + messageid + '/content'

					authstr = createauthstring()

					headers = {
					'X-EBOKS-AUTHENTICATE': authstr,
					'Accept': '*/*',
					'Accept-Language': 'en-US',
					'Host': data['root'],
					}

					r = requests.get(url, headers=headers)
					authenticate = r.headers['X-EBOKS-AUTHENTICATE']
					nonce(authenticate)
					
					# Attach primary message content to e-mail
					if format in ("txt","text","plain"):
						characterset = chardet.detect(r.content)
						r.encoding = characterset['encoding']
						body = r.text
						msg.attach(MIMEText(body, 'plain'))
					elif format in ("html","htm"):
						characterset = chardet.detect(r.content)
						r.encoding = characterset['encoding']
						body = r.text
						msg.attach(MIMEText(body, 'html'))						
					elif format == "pdf":
						filename = "".join().rstrip() + "." + format
						part = MIMEApplication(r.content)
						part.add_header('Content-Disposition', 'attachment', filename = filename)
						msg.attach(part)
					elif format in ("gif","jpg","jpeg","tiff","tif","webp"):
						filename = "".join().rstrip() + "." + format
						part = MIMEImage(r.content)
						part.add_header('Content-Disposition', 'attachment', filename = filename)
						msg.attach(part)
										
					# Get attachment data if message has attachments
					if int(attachmentcount) > 0:

						url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + messageid

						authstr = createauthstring()

						headers = {
						'X-EBOKS-AUTHENTICATE': authstr,
						'Accept': '*/*',
						'Accept-Language': 'en-US',
						'Host': data['root'],
						}

						r = requests.get(url, headers=headers)
						authenticate = r.headers['X-EBOKS-AUTHENTICATE']
						nonce(authenticate)

						xml = ET.fromstring(r.text)
						
						eboks_attachment = xml
						
						# Gets if, name and format of attachment
						for child in eboks_attachment:
							for subtree in child:
								attachmentid = subtree.attrib['id']
								attachmenttitle = subtree.attrib['name']
								attachmentformat = subtree.attrib['format']
				
								# Gets the actual attachment
								url = 'https://' + data['root'] + '/mobile/1/xml.svc/en-gb/' + data['uid'] + '/0/mail/folder/' + folderid + '/message/' + attachmentid + '/content'
									
								authstr = createauthstring()

								headers = {
								'X-EBOKS-AUTHENTICATE': authstr,
								'Accept': '*/*',
								'Accept-Language': 'en-US',
								'Host': data['root'],
								}

								r = requests.get(url, headers=headers)
								authenticate = r.headers['X-EBOKS-AUTHENTICATE']
								nonce(authenticate)
								
								# Attach attachment to e-mail
								if attachmentformat in ("txt","text","html","htm","plain"):
									filename = "".join().rstrip() + "." + attachmentformat
									r.encoding = "utf-8"
									part = MIMEText(r.text)
									part.add_header('Content-Disposition', 'attachment', filename = filename)
									msg.attach(part)
								elif attachmentformat == "pdf":
									filename = "".join().rstrip() + "." + attachmentformat
									part = MIMEApplication(r.content)
									part.add_header('Content-Disposition', 'attachment', filename = filename)
									msg.attach(part)
								elif attachmentformat in ("gif","jpg","jpeg","tiff","tif","webp"):
									filename = "".join().rstrip() + "." + attachmentformat
									part = MIMEImage(r.content)
									part.add_header('Content-Disposition', 'attachment', filename = filename)
									msg.attach(part)

					# Send e-mail
					if data['sendemails'] == True:
						print("sending")
						msg.attach(MIMEText(body, 'plain'))
						server.sendmail(data['emailfrom'], data['emailto'], msg.as_string())
						time.sleep(2)
Categories
blandet

Sådan trækker jeg links til gratis Zetland-artikler ud fra Zetlands Twitter-konto til wallnot.dk

wallnot.dk udgiver jeg en liste over gratisartikler fra en lang række medier, der benytter sig af betalingsmure/paywall. Siden er ment som en service til brugere, der ved, at de gerne vil læse nyhedsartikler, og at de ikke vil betale for dem.

Zetland er ikke som de andre aviser. Der er ikke en forside med links til alle nypublicerede artikler. I stedet bruger Zetland Twitter til at lægge appetitvækkere ud.

Jeg syntes det var ærgerligt ikke at have Zetland med på Wallnot, så i stedet for at kigge efter links på forsiden, som Wallnot gør hos de andre medier, brugte jeg Twitters API til at trække artikellinks ud.

Her kan du se, hvordan jeg gjorde. Hvis du gerne vil prøve programmet af, skal du registrere dig som udvikler på Twitter.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" This program uses the Twitter API to get a list of free articles from Zetland """

import requests
from bs4 import BeautifulSoup
from datetime import datetime
from datetime import date
import json
from nested_lookup import nested_lookup
import base64


# GETS TWITTER DATA #

# Key and secret from Twitter developer account: https://developer.twitter.com/en/apply/user
client_key = ''
client_secret = ''

# Key and secret encoding, preparing for Twitter request
key_secret = '{}:{}'.format(client_key, client_secret).encode('ascii')
b64_encoded_key = base64.b64encode(key_secret)
b64_encoded_key = b64_encoded_key.decode('ascii')

base_url = 'https://api.twitter.com/'
auth_url = '{}oauth2/token'.format(base_url)

auth_headers = {
	'Authorization': 'Basic {}'.format(b64_encoded_key),
	'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}

auth_data = {
	'grant_type': 'client_credentials'
}

auth_resp = requests.post(auth_url, headers=auth_headers, data=auth_data)
auth_resp.json().keys()
access_token = auth_resp.json()['access_token']

search_headers = {
	'Authorization': 'Bearer {}'.format(access_token)
}

# Search parameters for Zetland tweets
search_params = {
	'user_id': 452898921,
	'count': 35,
	'tweet_mode': 'extended',
	'exclude_replies': 'true',
	'trim_user': 'true'
}

# Request url for searching user timelines
search_url = '{}1.1/statuses/user_timeline.json'.format(base_url)

# Request to Twitter
search_resp = requests.get(search_url, headers=search_headers, params=search_params)

# Response from Twitter in json format
tweet_data = search_resp.json()
#prettyjson = json.dumps(tweet_data, ensure_ascii=False, indent=4) # Only needed for debugging to pretify json

# Looks for all instances of expanded_url (that is, links) in json	
linklist = list(set(nested_lookup('expanded_url', tweet_data)))

# Populates a list of links to Zetland articles
urllist = []
for link in linklist:
	if "zetland.dk/historie" in link:
		urllist.append(link)

		
# GETS ARTICLE DATA FROM ZETLAND #
		
# Requests articles and get titles and dates and sort by dates directly from Zetland site
articlelist = []
titlecheck = []

for url in urllist:
	try:
		data = requests.get(url)
		result = data.text

		# Soup site and create a dictionary of links and their titles and dates
		articledict = {}
		soup = BeautifulSoup(result, "lxml")

		title = soup.find('meta', attrs={'property':'og:title'})
		title = title['content']
		
		timestamp = soup.find('meta', attrs={'property':'article:published_time'})
		timestamp = timestamp['content']
		timestamp = timestamp[:timestamp.find("+")]
		dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.%f')
		
		if title not in titlecheck:
			articlelist.append({"title": title, "url": url, "date": dateofarticle})
			titlecheck.append(title)
	except:
		print(url)


# PREPARES LIST OF ARTICLES FOR WALLNOT.DK #

# Sort articles by date (newest first)		
articlelist_sorted = sorted(articlelist, key=lambda k: k['date'], reverse=True) 

# Removes articles older than approximately three months
articlelist_recent = []
now = datetime.now()
for article in articlelist_sorted:
	timesincelast = now - article["date"]
	if timesincelast.days < 92:
		articlelist_recent.append(article)

# Converts dates to friendly format for display and outputs articles as html paragraphs
zet_linkstr = ""
for article in articlelist_recent:
	friendlydate = article["date"].strftime("%d/%m")
	zet_linkstr += '<p>' + friendlydate + ': ' + '<a href="' + article["url"] + '">' + article["title"] + '</a></p>\n' 

# Prints list of articles	
print(zet_linkstr)
Categories
blandet

Sådan trækker du historiske og aktuelle kurser på aktier og andre værdipapirer fra Nordnet til Excel

OPDATERING: Nordnet er ude i en ny version, som gør at man foreløbig er nødt til at ændre URLs i programmet til “classic.nordnet.dk” for at bruge den gamle version. På et tidspunkt virker det nok heller ikke længere. Jeg har opdateret koden neden for med den korrekte url.

OPDATERING: Hvis du har login til Nordnet, kan du læse her, hvordan du henter kurser i det nye API.

I mit indlæg fra i går, fortalte jeg om at bruge Python til at hente min transaktionshistorik automatisk fra Nordnet.

I dag handler det om at få fat i kurser på værdipapirer, sådan man kan lave flotte grafer i Excel over den historiske udvikling.

Sådan kan en oversigt se ud i Excel. Der er små “huller” i kurserne for enkelte papirer. Jeg har ikke undersøgt nærmere hvorfor der ikke er kurser for de datoer.

Du behøver ikke at være kunde hos Nordnet og du kan – så vidt jeg kan se – udtrække helt aktuelle realtidskurser uden forsinkelse.

Sådan gjorde jeg

Jeg startede med at besøge et værdipapir på Nordnet. Her lagde jeg mærke til den fine graf over kursudviklingen. Sådan en graf må få data et sted fra. Jeg trykkede F12 for at åbne Developer Tools og kiggede på fanen Network. Når jeg ændrede periode for grafen, kunne jeg se, at der blev sendt en ny forespørgsel af sted:

En fin kursgraf fra Nordnet. Når jeg ændrer periode, kan jeg se, at Nordnet spørger om data. På billedet kan man se, at data bliver returneret i JSON-format med forskellige kursoplysninger og et tidsstempel.

Under fanen Headers kan jeg se oplysninger om, hvad for en URL, min browser sender forespørgsler til, og hvad den spørger om:

Forespørgslen
Parametre

I Python bygger jeg et program, der kan løbe en liste værdipapirer igennem. Jeg opdager et enkelt værdipapir, som ikke findes hos Nordnet. Derfor bygger jeg også et slags fallback, der kan hente kursdata fra Morningstar (med 15 minutters forsinkelse).

Det færdige program i Python

Her er så det færdige program i Python. Du er velkommen til at bruge det, videreudvikle, og hvad du ellers har lyst til.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" This program extracts historical stock prices from Nordnet (and Morningstar as a fallback) """

import requests
from datetime import datetime
from datetime import date
import os
import json


# DATE AND STOCK DATA. SHOULD BE EDITED FOR YOUR NEEDS #

# Start date (start of historical price period) and date today used as standard enddate for price period
startdate = '2013-01-01'
today = date.today()
enddate = str(today)

# List of shares to look up prices for.
# Format is: Name, Morningstar id, Nordnet stock identifier, Nordnet market number
# See e.g. https://www.nordnet.dk/mux/web/marknaden/aktiehemsidan/index.html?identifier=4804&amp;marketid=14
# (identifier is 4804, market is 14)
# All shares must have a name (whatever you like). To get prices they must either have a Nordnet identifier
# and market number or a Morningstar id
sharelist = [
["Maj Invest Globale Obligationer","F0GBR064US",36432,14],
["Novo Nordisk B A/S","0P0000A5BQ",1158,14],
["BlackRock iShares Core S&amp;P 500 UCITS ETF","0P0000OO21]22]1]","SXR8",4],
["Nordnet Superfonden Danmark","F00000TH8X","",""],
["Danske Invest Global Indeks Akk KL DKK h","F0GBR04EPX",38898,14]
]


# CREATE OUTPUT FOLDER AND VARIABLES FOR LATER USE. #

# Checking that we have an output folder to save our csv file
if not os.path.exists("./output"):
	os.makedirs("./output")

# A variable to store historical prices before saving to csv	
finalresult = ""
finalresult += '"date";"price";"instrument"' + '\n'


# LOOPS TO REQUEST HISTORICAL PRICES AT NORDNET AND MORNINGSTAR #

# Nordnet loop to get historical prices
for share in sharelist:
	# Nordnet stock identifier and market number must both exist
	if share[2] and share[3]:
		url = "https://classic.nordnet.dk/graph/instrument/" + str(share[3]) + "/" + str(share[2])
		payload = {"from": startdate, "to": enddate, "fields": "last"}
		data = requests.get(url, params=payload)
		result = data.text
		jsondecode = json.loads(result)
		
		# Sometimes the final date is returned twice. A list is created to check for duplicates.
		datelist = []
		
		for value in jsondecode:
			price = str(value['last'])
			price = price.replace(".",",")
			date = datetime.fromtimestamp(value['time'] / 1000)
			date = datetime.strftime(date, '%Y-%m-%d')
			# Only adds a date if it has not been added before
			if date not in datelist:
				datelist.append(date)
				finalresult += '"' + date + '"' + ";" + '"' + price + '"' + ";" + '"' + share[0] + '"' + "\n"

# Morningstar loop to get historical prices			
for share in sharelist:
	# Only runs for one specific fund in this instance
	if share[0] == "Nordnet Superfonden Danmark":
		payload = {"id": share[1], "currencyId": "DKK", "idtype": "Morningstar", "frequency": "daily", "startDate": startdate, "outputType": "COMPACTJSON"}
		data = requests.get("http://tools.morningstar.dk/api/rest.svc/timeseries_price/nen6ere626", params=payload)
		result = data.text
		jsondecode = json.loads(result)
		
		for lists in jsondecode:
			price = str(lists[1])
			price = price.replace(".",",")
			date = datetime.fromtimestamp(lists[0] / 1000)
			date = datetime.strftime(date, '%Y-%m-%d')
			finalresult += '"' + date + '"' + ";" + '"' + price + '"' + ";" + '"' + share[0] + '"' + "\n"


# WRITE CSV OUTPUT TO FILE #			

with open("./output/kurser1.csv", "w", newline='', encoding='utf8') as fout:
	fout.write(finalresult)
Categories
blandet

Sådan trækker du dine transaktioner ud fra Nordnet med Python

OPDATERING: Nordnet er ude i en ny version, som gør at man foreløbig er nødt til at ændre URLs i programmet til “classic.nordnet.dk” for at bruge den gamle version. På et tidspunkt virker det nok heller ikke længere. Jeg har opdateret koden neden for med den korrekte url.

OPDATERING: Find en opdateret udgave af programmet, der virker med det nye Nordnet, her.

Jeg kan godt lide at bruge Excel til at holde øje med min økonomi, så jeg har et ark med en pivottabel, som jeg bruger til at få overblik over min portefølje hos Nordnet. Nordnet har en funktion til at trække en CSV-fil med en oversigt over mine transaktioner, men det er lidt besværligt at skulle a) logge ind på Nordnet for derefter b) at gå ind på hver enkelt depot og c) trække en ny oversigt og copy/paste hver gang, der fx udbetales udbytte.

Her klikker du hos Nordnet for at generere en CSV-fil, du kan bruge til Excel

Derfor tænkte jeg: Kan jeg automatisere dette udtræk, sådan jeg altid har opdateret data i mit Excelark? Ja, det kan jeg. Med Python. Her fortæller jeg om hvordan og deler min kode.

Hvordan snakker man http med Nordnet?

Det første jeg gjorde, var at undersøge hvad der egentlig sker, når jeg beder Nordnet om en transaktionsfil. Det gør jeg i Chrome ved at trykke F12, vælge Network og undersøge hvad min browser sender af sted for at få en CSV-fil tilbage. Jeg kan se, at der ryger en cookie af sted og nogle parametre, der handler om bl.a. sortering og periode for de transaktioner, jeg vil have ud:

I Python bruger jeg modulet Requests til at snakke med Nordnet og forsøger at konstruere noget der ligner det, min browser smider af sted. Efter at have prøvet mig frem, finder jeg ud af, at det er den cookie, der hedder NOW, der er afgørende for, at modtage noget fra Nordnet. Jeg laver en cookie-ordbog, der foreløbig indeholder min NOW-værdi fra Chrome:

cookies = {'NOW': '63261fc324153bd1632006105c5b4444d97fc72a'}

Min forespørgsel, der giver mig data tilbage, ser sådan ud:

payload = {
'year': 'all',
'month': 'all',
'trtyp': 'all',
'vp': 'all',
'curr': 'all',
'sorteringsordning': 'fallande',
'sortera': 'datum',
'startperiod': startdate,
'endperiod': enddate
}
url = "https://www.nordnet.dk/mux/laddaner/transaktionsfil.html"
data = requests.get(url, params=payload, cookies=cookies)
result = data.text

Så langt, så godt.

Men da jeg logger ud af Nordnet, virker min forespørgsel ikke længere. For at få en gyldig cookie, er jeg altså nødt til at logge ind på Nordnet.

Tilbage i Chrome kigger jeg på, hvad der sendes af sted, og modtages, ved indlogningsproceduren. Indlogningsproceduren foregår i 3 trin:

  1. https://www.nordnet.dk/mux/login/start.html?cmpi=start-loggain&state=signin sættes to cookies: LOL og TUX-COOKIE.
  2. Når brugeren har indtastet brugernavn og password, forespørges https://www.nordnet.dk/api/2/login/anonymous og returnerer en cookie: NOW.
  3. Til sidst sendes de 3 cookie-værdier sammen med brugernavn og password til https://www.nordnet.dk/api/2/authentication/basic/login. Der returneres en ny NOW-værdi og en anden cookie, der hedder xrsf.

Login på siden ser sådan her ud:

# LOGIN TO NORDNET #
	
# First part of cookie setting prior to login
url = 'https://www.nordnet.dk/mux/login/start.html?cmpi=start-loggain&amp;state=signin'
r = requests.get(url)

cookies['LOL'] = r.cookies['LOL']
cookies['TUX-COOKIE'] = r.cookies['TUX-COOKIE']

# Second part of cookie setting prior to login
url = 'https://www.nordnet.dk/api/2/login/anonymous'
r = requests.post(url, cookies=cookies)
cookies['NOW'] = r.cookies['NOW']

# Actual login that gets us cookies required for primary account extraction
url = "https://www.nordnet.dk/api/2/authentication/basic/login"

r = requests.post(url,cookies=cookies, data = {'username': user, 'password': password})

cookies['NOW'] = r.cookies['NOW']
cookies['xsrf'] = r.cookies['xsrf']

Når denne procedure er gennemført, kan jeg med den genererede NOW-værdi trække transaktioner ud af mit primære depot (det jeg oprettede først, da jeg fik en Nordnet-konto).

For at trække transaktioner ud fra andre depoter, undersøger jeg hvad der sker, når jeg vælger et andet depot i Nordnet. Det sker ved at forespørge https://www.nordnet.dk/mux/ajax/session/bytdepa.html med mine gemte cookies og værdien fra den cookie, der hedder xrsf i headeren. Tilbage får jeg en ny NOW-værdi, som jeg kan bruge til at hente transaktioner på det andet depot, og en ny xrsf-værdi, som jeg kan bruge, hvis jeg har endnu flere depoter, jeg får brug for at skifte til:

		# Switch to secondary account and set new cookies
		url = 'https://www.nordnet.dk/mux/ajax/session/bytdepa.html'
		headers = {'X-XSRF-TOKEN': cookies['xsrf']}	
		
		r = requests.post(url,cookies=cookies, headers=headers, data = {'portfolio': item['id']})

		cookies['NOW'] = r.cookies['NOW']
		cookies['xsrf'] = r.cookies['xsrf']

Til sidst skal jeg finde en fornuftig struktur for mit program, finde ud af hvordan jeg får lavet en god struktur i min CSV-fil (nogle gange returnerer Nordnet en CSV-fil med en kolonne for meget). Og så har jeg brug for en mulighed for at tilføje manuelle linjer til min CSV-fil (fordi jeg gerne vil have historisk data med fra et gammelt depot hos en anden bank).

Det færdige program i Python

Her er det færdige program. Du er velkommen til at bruge det, videreudvikle, osv.

<pre class="wp-block-syntaxhighlighter-code"><p># -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
&quot;&quot;&quot; This program logs into a Nordnet account and extracts transactions as a csv file.
Handy for exporting to Excel with as few manual steps as possible &quot;&quot;&quot;

import requests 
from datetime import datetime
from datetime import date
import os

# USER ACCOUNT, PORTFOLIO AND PERIOD DATA. SHOULD BE EDITED FOR YOUR NEEDS #

# Nordnet user account credentials and name of primary portfolio (first one listed in Nordnet)
user = ''
password = ''
primaryportfolioname = &quot;Frie midler&quot;

# Names and portfolio ids for all any all secondary portfolios. The id is listed in 
# Nordnet when selecting a portfolio. If no secondary portfolios the variable
# secondaryportfolioexists should be set to False.
secondaryportfolioexists = True
secondaryportfolios = [
{'name': 'Ratepension', 'id': ''},
]

# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '01.01.2013'
today = date.today()
enddate = datetime.strftime(today, '%d.%m.%Y')


# Manual date lines. These can be used if you have portfolios elsewhere that you would
# like to add manually to the data set. If no manual data the variable manualdataexists
# should be set to False
manualdataexists = True
manualdata = &quot;&quot;&quot;
Id;Bogføringsdag;Handelsdag;Valørdag;Transaktionstype;Værdipapirer;Instrumenttyp;ISIN;Antal;Kurs;Rente;Afgifter;Beløb;Valuta;Indkøbsværdi;Resultat;Totalt antal;Saldo;Vekslingskurs;Transaktionstekst;Makuleringsdato;Verifikations-/Notanummer;Depot
;30-09-2013;30-09-2013;30-09-2013;KØBT;Obligationer 3,5%;Obligationer;;72000;;;;-69.891,54;DKK;;;;;;;;;Frie midler
&quot;&quot;&quot;


# CREATE OUTPUT FOLDER AND VARIABLES FOR LATER USE. #

# Checking that we have an output folder to save our csv file
if not os.path.exists(&quot;./output&quot;):
	os.makedirs(&quot;./output&quot;)

# Creates a dictionary to use with cookies	
cookies = {}

# A variable to store transactions before saving to csv
transactions = &quot;&quot;

# Payload for transaction requests
payload = {
'year': 'all',
'month': 'all',
'trtyp': 'all',
'vp': 'all',
'curr': 'all',
'sorteringsordning': 'fallande',
'sortera': 'datum',
'startperiod': startdate,
'endperiod': enddate
}

# LOGIN TO NORDNET #
	
# First part of cookie setting prior to login
url = 'https://classic.nordnet.dk/mux/login/start.html?cmpi=start-loggain&amp;amp;state=signin'
r = requests.get(url)

cookies['LOL'] = r.cookies['LOL']
cookies['TUX-COOKIE'] = r.cookies['TUX-COOKIE']

# Second part of cookie setting prior to login
url = 'https://classic.nordnet.dk/api/2/login/anonymous'
r = requests.post(url, cookies=cookies)
cookies['NOW'] = r.cookies['NOW']

# Actual login that gets us cookies required for primary account extraction
url = &quot;https://classic.nordnet.dk/api/2/authentication/basic/login&quot;

r = requests.post(url,cookies=cookies, data = {'username': user, 'password': password})

cookies['NOW'] = r.cookies['NOW']
cookies['xsrf'] = r.cookies['xsrf']


# GET PRIMARY ACCOUNT TRANSACTION DATA #

# Get CSV for primary account
url = &quot;https://classic.nordnet.dk/mux/laddaner/transaktionsfil.html&quot;
data = requests.get(url, params=payload, cookies=cookies)
result = data.text
result = result.splitlines()
firstline = 0

for line in result:
	if line and firstline == 0:
		transactions += line + ';' + &quot;Depot&quot; + &quot;\n&quot;
		firstline = 1
	elif line:
		# Sometimes Nordnet inserts one semicolon too many in the file. This removes the additional semicolon
		if line.count(';') == 22:
			position = line.rfind(';')
			line = line [:position] + line[position+1:]
		transactions += line + ';' + primaryportfolioname + &quot;\n&quot;

		
# GET TRANSACTION DATA FOR ALL/ANY SECONDARY ACCOUNTS #

if secondaryportfolioexists == True:
	for item in secondaryportfolios:
		# Switch to secondary account and set new cookies
		url = 'https://classic.nordnet.dk/mux/ajax/session/bytdepa.html'
		headers = {'X-XSRF-TOKEN': cookies['xsrf']}	
		
		r = requests.post(url,cookies=cookies, headers=headers, data = {'portfolio': item['id']})

		cookies['NOW'] = r.cookies['NOW']
		cookies['xsrf'] = r.cookies['xsrf']

		# Get CSV for secondary account
		url = &quot;https://classic.nordnet.dk/mux/laddaner/transaktionsfil.html&quot;
		data = requests.get(url, params=payload, cookies=cookies)
		result = data.text
		result = result.split(&quot;\n&quot;,1)[1]
		result = result.splitlines()

		for line in result:
			if line:
				# Sometimes Nordnet inserts one semicolon too many in the file. This removes the additional semicolon
				if line.count(';') == 22:
					position = line.rfind(';')
					line = line [:position] + line[position+1:]
				transactions += line + ';' + item['name'] + &quot;\n&quot;


if manualdataexists == True:
	manualdata = manualdata.split(&quot;\n&quot;,2)[2]
	transactions += manualdata				


# WRITE CSV OUTPUT TO FILE #
		
with open(&quot;./output/trans.csv&quot;, &quot;w&quot;, encoding='utf8') as fout:
	fout.write(transactions)</p></pre>
Categories
blandet

Beskyttet: 2018

Dette indhold er adgangskodebeskyttet. For at se det, indtast venligst din adgangskode nedenfor:

Categories
fotos

Beskyttet: Nye børn

Dette indhold er adgangskodebeskyttet. For at se det, indtast venligst din adgangskode nedenfor:

Categories
blandet

Beskyttet: Karen og Christians fest

Dette indhold er adgangskodebeskyttet. For at se det, indtast venligst din adgangskode nedenfor: