Jeg fik Saxo til at lukke for adgang til ebøger for gratister

Jeg er både interesseret i at udforske API’er og i at læse bøger. Jeg har fx tidligere vist, hvordan jeg ved hjælp af en Android-emulator og programmet Charles fandt en løsning til at få videresendt E-boksbeskeder til min e-mail.

Og så skete der det, at jeg fik et prøveabonnement til Saxo Premium.

Derfor gav jeg mig i kast med at udforske boghandelen Saxos app til streaming af bøger og opdagede nogle sikkerhedsproblemer.

Jeg fandt ud af, at enhver med lidt teknisk snilde kunne skaffe sig adgang til Saxos ebøger, uden først at købe et streaming-abonnement.

Efter først at have kontaktet Saxo anonymt for at blive forsikret om, at de ikke ville melde mig til politiet, så længe jeg ikke havde udnyttet sikkerhedshullerne, fik jeg indrapporteret problemerne.

Saxo var både flinke og professionelle i dialogen, og nu har de lanceret en mere sikker app. Derfor vil og kan jeg nu fortælle om, hvordan jeg undersøgte Saxos app, og hvad jeg opdagede.

Om streaming og at sikre sig mod kopiering af data

Men først en sidebemærkning: Det er meget svært at give midlertidigt adgang til data (bøger, film, musik), som Saxo, Netflix, Spotify osv., gør, og være helt sikker på, at adgangen altid og i alle tilfælde kun er midlertidig.

Hvis noget kan ses på en skærm eller lyttes til på højttalere, skal der meget til at forhindre ihærdige brugere, (der måske endda er villige til at bryde ophavsretsloven – gisp!), i at få gemt en kopi af materialet.

Spørgsmålet er mere, hvor svært og tidskrævende man gør det.

Problemet hos Saxo var altså ikke, efter min mening, at en abonnement kunne vælge at misbruge deres streamingabonnement til at tage kopier af ebøger. Problemet var, at selv en ikke-abonnement kunne gøre det.

Om at undersøge hvordan apps virker og opdage sikkerhedshuller

Hvis du tænker på selv at give dig i kast med at undersøge apps, API’er og eventuelle sikkerhedshuller, er det vigtigt, du har hjertet på det rette sted.

Brancheorganisationen IT-Branchen har udarbejdet et kodeks, som handler om hvordan privatpersoner og virksomheder bør opføre sig, når der opdages og indrapporteres sikkerhedshuller.

Som privatperson er det vigtigt, at:

  • Du ikke udnytter en utilsigtet adgang til data. Straffeloven siger, du ikke uberettiget må skaffe dig adgang til andres data. Ophavsretsloven siger, du ikke må dele ophavsretsbeskyttede værker med andre. Du kan også (måske uden selv at have opdaget det) have accepteret nogle vilkår for brug af en app, som ejeren af appen måske kan bruge til at anlægge en sag mod dig, som de måske/måske ikke kan vinde.
  • Du hurtigst muligt giver virksomheden besked om sikkerhedshullerne, så den kan rette dem.
  • Du ikke deler din viden om sikkerhedshuller med andre, fx dine venner eller medierne. Det ville medføre, at nogle kunne udnytte hullerne og begå ulovligheder.

Læs IT-Branchens glimrende vejledning til anmeldere af sikkerhedsbrister.

Til gengæld for din ædelhed bør virksomheden:

  • Behandle henvendelsen fra dig fortroligt
  • Handle på henvendelsen og løse sikkerhedsproblemerne inden for rimelig tid
  • Undlade at melde dig til politiet, hvis du (anmelderen) har handlet i overensstemmelse med kodekset

Du kan ikke være sikker på, at virksomheden handler ædelt. Derfor valgte jeg selv at kontakte Saxo anonymt, for at høre om deres ædelhed, inden jeg fortalte om det sikkerhedshul, jeg havde opdaget.

Forberedelsesfasen

Android-emulator

Det første man skal bruge for at kunne undersøge mobilapps til Android er en emulator, så apps kan køre på ens PC og man har mulighed for at overvåge trafikken.

Jeg bruger en emulator, der hedder Nox. Den er mest lavet til at kunne spille spil og er fyldt med reklamer og sikkert også overvågning. Men: Nox gør det let at hente, installere og eksportere apps, og at begynde at overvåge trafikken til og fra dem.

For at overvåge trafikken på moderne Android-versioner er det nødvendigt først at pille lidt ved den app, hvis trafik man vil overvåge.

(Nogle apps med højere sikkerhedsniveau, bruger noget der hedder “SSL pinning” til at forhindre trafikovervågning. Det gør det væsentligt sværere at overvåge trafikken, end hvad jeg beskriver her.)

Rette i appen for at tillade traffikovervågning

I Nox installerede jeg Saxos app og eksporterede appen som apk-fil.

Det er nemt at eksportere en apk-fil fra en app ud af emulatoren Nox

Derefter brugte jeg værktøjet Apktool til at dekompilere appen. Værktøjet er gratis, kræver Java og er ret nemt at bruge.

Sådan ser Saxos app ud, når Apktool har haft fat i den.

For at kunne overvåge trafikken skal appen tillade, at jeg bruger andre SSL-certifikater end dem, der kommer med Android.

I mappen res/xml oprettede jeg filen network_security_config.xml (nogle apps har den allerede) og satte det her indhold ind. Det fortæller, at jeg både stoler på systemcertifikater og brugerinstallerede certifikater:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />
        </trust-anchors>
    </base-config>
</network-security-config>

Derefter åbnede jeg AndroidManifest.xml og indsatte stien på den nye fil i tagget “application”.

Før:

<application android:allowBackup="false" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" android:name="com.saxo.read.saxoread.application.SaxoReadApplication" android:resizeableActivity="false" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/SaxoTheme">

Efter:

<application android:networkSecurityConfig="@xml/network_security_config" android:allowBackup="false" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" android:name="com.saxo.read.saxoread.application.SaxoReadApplication" android:resizeableActivity="false" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/SaxoTheme">

Med disse små rettelser på plads, brugte jeg Apktool til at pakke en ny apk-fil og signerede filen med et værktøj, der hedder Uber Apk Signer.

Min modificerede app var klar, og jeg trak-og-slap den over i Nox for at installere den.

Overvågning af app-trafik

Jeg brugte programmet Charles til at overvåge trafikken fra Saxos app. Kort fortalt installerer man Charles’ SSL-certifikat og sætter Nox op til at bruge Charles som proxy for internettrafik.

Hul igennem

Hvis alt er gået godt, kan det se sådan her ud, når Charles trafikovervåger. Her er fx oprettelsen af en bruger og de første par handlinger i Saxos app:

Hul igennem til traffikovervågning med Charles

Undersøgelsesfasen

Når først trafikken til og fra en app kan overvåges, er det bare at begynde at bruge app’en for at finde ud af, hvordan dens API virker.

Det jeg fandt ud af, var et problem ved Saxos app, var:

  1. Når man søger med Saxos app, returneres en række id-numre på bøger
  2. Søgning i appen kræver ikke en Premium-konto, alle kan oprette en konto og søge for at se udvalget af bøger
  3. Download-adressen på en bog til offline-læsning kunne regnes ud alene ud fra en bogs id-nummer
  4. Downloadede bøger var krypterede, men:
  5. Appens nøgle til at dekryptere downloadede bøger, så de kunne læses offline, var meget nem at finde

Problemet betød, at brugere uden abonnement kunne få adgang til bøger uden abonnement.

Brugere med abonnement kunne få adgang til bøger uden at deres download blev registreret gennem Saxos API og dermed, formoder jeg, uden at Saxo kunne honorere bogens forlag og i sidste ende bogens forfatter.

Muligvis – det har jeg ikke testet – gjorde hullet også, at eventuelle check, som Saxo har af, hvor mange bøger en bruger kan hente, blev sat ud af spillet.

I de næste afsnit prøver jeg at tage dig med gennem undersøgelsesfasen.

Download af bøger til offline-læsning

Når jeg downloade en bog hos Saxo, kunne jeg se, at der blev spurgt efter en fil herfra:

Når jeg forsøgte at tilgå adressen i en browser, kunne jeg downloade filen. Men, som adressen afslører, er filerne krypteret (“encrypted-base-files”).

Nøglesammenfald mellem søgeresultater og download-adresse

Så opdagede jeg, at en bogs id gik igen i download-adressen. Her har jeg klikket mig ind på den novellesamling af Jens Blendstrup, som jeg downloadede til offlinebrug. Læg mærke til id:

Id:

a3519df0-182a-4f77-90f7-52a82c5bacf9/user/ee3b4be0-fcab-48b8-944f-e2bffe372f45

Bliver til download-url:

https://readcontentprdcryptbase.blob.core.windows.net/encrypted-base-files/a3/51/9d/f0/a3519df0-182a-4f77-90f7-52a82c5bacf9/a3519df0-182a-4f77-90f7-52a82c5bacf9

Mapperne “a3“, “51“, “9d” og “F0” i stien kommer fra de første otte tegn i id’et: a3519df0.

Altså: Kender du id på en bog, kender du også downloadadressen på den krypterede bog!

Søgeresultater tilgængelige for alle

Jeg fandt også ud af, at søgning i appen, som er åben for både premium-medlemmer og ikke-betalende brugere på appen, udstiller bøgers id. Her er et eksempel, hvor jeg leder efter Puk Damsgårds Arabica:

Næsten alle kan downloade alt!

Når søgningen udstiller bøgers id, og download-adressen til bøger kan findes alene ud fra en bogs id, kunne en ondsindet bruger med god tid, eller med evnen til at programmere en robot, have:

  • Hentet alle ISBN-numre på alle bøger i Saxos streaming-katalog
  • Søgt på alle ISBN-numrene ved hjælp af Saxos app-api og fundet bøgernes id
  • Downloadet alle bøgerne i kataloget

Det kan være, at Saxo havde implementeret noget, der fx blokerede en bruger, der foretog rigtig mange søgninger, eller downloadede rigtigt meget fra downloadserveren. Hvis de havde det, havde det gjort øvelsen med at kopiere hele kataloget lidt sværere. Men kun en lille smule.

Sidste brik: Dekryptering af downloadede bøger

Efter at have fundet muligheden for at downloade krypterede bøger hos Saxo uden at være abonnent, ledte jeg efter en krypteringsnøgle.

Når Saxos app kan læse de downloadede, krypterede bøger, må krypteringsnøglen jo befinde sig i – eller blive leveret til – Saxos app på en eller anden måde.

Jeg fandt nøglen i mine dekompilerede filer fra appen på min harddisk efter at have lavet forskellige søgninger i filerne, som jeg dårligt nok kan huske og heller ikke vil afsløre. Nøglen viste sig at ligge meget dårligt skjult, og med en lille bid Python-kode og modulet Cryptography skrev jeg et lille program, der kunne dekryptere bøgerne:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

filename = ""	# Filnavn på krypteret bog
key = ""		# Fra Saxos app
iv = ""			# Fra Saxos app
encoded_key = key.encode('utf-8')
encoded_iv = iv.encode('utf-8')

backend = default_backend()
cipher = Cipher(algorithms.AES(encoded_key), modes.CBC(encoded_iv), backend=backend)

with open(filename, "rb") as encrypted_book:
	encrypted = encrypted_book.read()
	decryptor = cipher.decryptor()
	decrypted = decryptor.update(encrypted) + decryptor.finalize()
	with open(filename + '.epub', "wb") as fout:
		fout.write(decrypted)

Saxos rettelser

Efter at Saxo har opdateret deres app, har jeg genbesøgt appen. Jeg kan se, at:

  • Krypteringsnøglen til Saxos ebøger er blevet skjult en hel del bedre end tidligere.
  • Det er ikke længere nok at have internetadressen på en bog for at downloade den. For at brugeren kan downloade en bog, genererer appen nu en unik nøgle per bog, som downloadserveren vil have for at sende en bog tilbage.

Den gamle version af appen tillod en bruger at springe det trin over, hvor Saxo registererer, at en bog er blevet hentet. I den nye er det kun muligt at downloade en bog, hvis man inden da har spurgt API’et om downloadadressen:

Downloadadresserne på bøger har fået nogle ekstra parametre på, bl.a. en unik “signatur” (“sig”), som API’et returnerer for hver bog, brugeren downloader. Jeg har censureret nogle få bytes her:

De nye downloadadresser er mere komplicerede end tidligere. Jeg ved ikke, hvad alle de nye parametre gør, men mon ikke datoparametrene har noget at gøre med en udløbsdato, hvorefter det ikke længere er muligt at bruge linket?

Derudover har Saxo, har de fortalt mig, lavet andre sikkerhedsopstramninger bag kulisserne.

Skønt!

Hvad betyder ændringerne i Saxos app?

Ændringerne betyder, at ikke-betalende brugere af Saxos app, så vidt jeg kan se, nu er effektivt afskåret fra at kunne tilgå bøger.

Betalende brugere har ikke længere mulighed for at downloade bøger, uden at Saxo kan registrere det og sørge for passende honorering af forfatter og forlag.

Tidsforløb og Saxos kommentarer

Jeg gjorde opmærksom på sikkerhedsproblemet den 22. marts 2021. Jeg kontaktede først Saxos kundeservice den 12. marts og opdagede, så vidt jeg husker, problemet den 11. marts. Saxos opdaterede app blev rullet ud den 17. maj 2021.

Jeg har slettet de bøger, jeg selv har downloadet og dekrypteret i forbindelse med undersøgelsen af Saxos app.

Saxo har haft mulighed for at foreslå rettelser og kommentere dette blogindlæg.

Saxo foreslog at gøre ekstra opmærksom på lovgivningen omkring at få uberettiget adgang til og dele data. Det synes jeg var en god idé, så det har jeg gjort i afsnittet om at undersøge hvordan apps virker og at opdage sikkerhedshuller.

De foreslog også at gøre opmærksom på, at det at dekompilere apps, ændre dem og bygge dem igen, efter deres mening er en juridisk gråzone. Her har din hensigt med at undersøge en app betydning: Hvis du blot er ude på at undersøge, hvordan ting virker, kender jeg ikke noget lovgivning, der siger, at du gør noget forkert (hvis du gør, vil jeg gerne høre om det). Hvis du er ude på at få uberettiget adgang til data eller ophavsretsbeskyttet materiale er det en anden sag – så er du ude på at bryde loven.

ETF’er og fonde med aktiebeskatning 2021

For et par år siden blev det muligt at købe og tjene/tabe penge på aktiebaserede ETF’er og udenlandske investeringsfonde som aktieindkomst og ikke længere som kapitalindkomst.

Det eneste problem er/var, er at det velmenende regneark, der viser aktiebaserede investeringsselskaber, som er godkendt til den lavere beskatningskat.dk, er en lille smule svært at bruge, når man gerne vil sammenligne værdipapirerne og finde ud af, hvor de kan købes.

Derfor har jeg lavet https://wallnot.dk/stocks.

Her kan du læse om, hvordan jeg gjorde.

  1. Jeg downloaded excelarket fra skat.dk
  2. Jeg tilføjede nogle kolonner og gemte som CSV-fil
  3. Jeg brugte Python til at hente data og links til værdipapirer hos Saxo Bank, Nordnet og Morningstar
  4. Jeg oprettede en app i Django og definerede en datamodel tilsvarende excelarket
  5. Jeg importerede data til Django
  6. Jeg byggede visningen

Nogle timers arbejde for mig. Forhåbentlig nogle sparede timer for dig.

Download af excelark

https://skat.dk/getfile.aspx?id=145013&type=xlsx

Tilføje nogle kolonner og gemme som CSV-fil

Lidt upædagogisk, men hvad:

Registreringsland/Skattemæssigt hjemsted;ISIN-kode;Navn;LEI kode;ASIDENT;CVR/SE/TIN;Venligt navn;Første registreringsår;Morningstar_id;Saxo_id;Nordnet_url;Nordnet_id;Nordnet_ÅOP;Nordnet_udbyttepolitik;Nordnet_prospekt;Saxo_url;Morningstar_prospekt;Morningstar_url;Morningstar_ÅOP

Hente data og links til værdipapirer

Ret sjusket Python-program. Men fungerer OK:

import csv
import requests
import re
import json
from bs4 import BeautifulSoup

def nordnet_cookies():
	# Nordnet user account credentials
	user = ''
	password = ''

	# A cookie dictionary for storing cookies
	cookies = {}
	
	# First part of cookie setting prior to login
	url = 'https://classic.nordnet.dk/mux/login/start.html?cmpi=start-loggain&state=signin'
	request = requests.get(url)
	cookies['LOL'] = request.cookies['LOL']
	cookies['TUX-COOKIE'] = request.cookies['TUX-COOKIE']

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

	# Actual login that gets us cookies required for later use
	url = "https://classic.nordnet.dk/api/2/authentication/basic/login"
	request = requests.post(url,cookies=cookies, data = {'username': user, 'password': password})
	cookies['NOW'] = request.cookies['NOW']
	cookies['xsrf'] = request.cookies['xsrf']

	# Getting a NEXT cookie
	url = "https://classic.nordnet.dk/oauth2/authorize?client_id=NEXT&response_type=code&redirect_uri=https://www.nordnet.dk/oauth2/"
	request = requests.get(url, cookies=cookies)
	cookies['NEXT'] = request.history[1].cookies['NEXT']

	return cookies

def saxo_headers():
	# Saxo user account credentials
	user = ''
	password = ''

	# Visit login page and get AuthnRequest token value from input form
	url = 'https://www.saxoinvestor.dk/Login/da/'
	request = requests.get(url)
	soup = BeautifulSoup(request.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/'
	request = requests.post(url, data = {'field_userid': user, 'field_password': password, 'AuthnRequest': authnrequest})
	soup = BeautifulSoup(request.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("&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}

	return headers
	

nordnet_cookies = nordnet_cookies()
saxo_headers = saxo_headers()

filename = 'Copy of ABIS liste 2021 - opdateret den 11-01-2021.csv'
output_file = 'stocks.csv'

get_nordnet = True
get_saxo = True
get_morningstar = True


with open(output_file, 'w', newline='') as output_csv:
	paperwriter = csv.writer(output_csv, delimiter=';', quotechar ='"', quoting = csv.QUOTE_MINIMAL)

	with open(filename) as csvfile:
		paperreader = csv.reader(csvfile, delimiter=';')
		for row in paperreader:
			if row[1] != '0' and row[1] != 'ISIN-kode' and row[1] != '':
				isin = row[1]
				if get_morningstar == True:
					morningstar = requests.get('https://www.morningstar.dk/dk/util/SecuritySearch.ashx?q=' + isin)
					morningstar_text = morningstar.text
					if morningstar_text:
						first_hit = morningstar_text[morningstar_text.index("{"):morningstar_text.index("}")+1]
						first_hit_json = json.loads(first_hit)
						morningstar_id = first_hit_json['i']
						morningstar_url = 'https://www.morningstar.dk/dk/funds/snapshot/snapshot.aspx?id=' + morningstar_id
						morningstar_info = requests.get(morningstar_url)
						
						soup = BeautifulSoup(morningstar_info.text, "lxml")
						try:
							aop = soup.find(text=re.compile('Løbende omkostning'))
							aop_value = aop.parent.next.next.next.next.next.next.next.string
							if aop_value:
								cleaned_aop = aop_value.replace(",",".").replace("%","")
							else:
								cleaned_aop = ''
						except:
							cleaned_aop = ''
						
						morningstar_documents = requests.get('https://www.morningstar.dk/dk/funds/snapshot/snapshot.aspx?id=' + morningstar_id + '&tab=12')
						document_soup = BeautifulSoup(morningstar_documents.text, "lxml")
						try:
							prospect = document_soup.find(text=re.compile('CI'))
							prospect_link = prospect.parent.next.next.next.next.next.next.next.next.a['href']
							document_id = prospect_link[prospect_link.index("Id=")+3:prospect_link.rfind("&")]
							document_url = 'https://doc.morningstar.com/document/' + document_id + '.msdoc'
						except:
							try:
								prospect = document_soup.find(text=re.compile('Prospekt'))
								prospect_link = prospect.parent.next.next.next.next.next.next.next.next.a['href']
								document_id = prospect_link[prospect_link.index("Id=")+3:prospect_link.rfind("&")]
								document_url = 'https://doc.morningstar.com/document/' + document_id + '.msdoc'
							except:
								document_url = ''
						
						row[8] = morningstar_id
						row[16] = document_url
						row[17] = morningstar_url
						row[18] = cleaned_aop

				if get_saxo == True:
					saxo = requests.get('https://www.saxotrader.com/openapi/ref/v1/instruments/?$top=201&$skip=0&includeNonTradable=true&AssetTypes=Stock,Bond,MutualFund,Etf,Etc,Etn,Fund,Rights,CompanyWarrant,StockIndex&keywords=' + isin + '&OrderBy=', headers=saxo_headers)
					try:
						saxo_json = saxo.json()
						if saxo_json and saxo.status_code == 200:
							try:
								data = saxo_json['Data']
								if data:
									identifier = data[0]['Identifier']
									assettype = data[0]['AssetType']
									saxo_url = 'https://www.saxotrader.com/d/trading/product-overview?assetType=' + assettype + '&uic=' + str(identifier)
									row[9] = identifier
									row[15] = saxo_url
							except Exception as e:
								print(e)
								breakpoint()
					except:
						pass
				if get_nordnet == True:
					nordnet = requests.get('https://www.nordnet.dk/api/2/main_search?query=' + isin + '&search_space=ALL&limit=60', cookies=nordnet_cookies)
					nordnet_json = nordnet.json()
					if nordnet_json and nordnet.status_code == 200:
						try:
							display_types = [hit['display_group_type'] for hit in nordnet_json]
						except:
							breakpoint()
						good_hit = "wait"
						try:
							good_hit = display_types.index('ETF')
							base_url = 'https://www.nordnet.dk/markedet/etf-lister/'
						except:
							try:
								good_hit = display_types.index('PINV')
								base_url = 'https://www.nordnet.dk/markedet/investeringsforeninger-liste/'
							except:
								try:
									good_hit = display_types.index('FUND')
									base_url = 'https://www.nordnet.dk/markedet/fondslister/'
								except:
									try:
										bad_hit = display_types.index('NEWS')
									except:
										try:
											good_hit = display_types.index('EQUITY')
											base_url = 'https://www.nordnet.dk/markedet/aktiekurser/'
										except:
											breakpoint()
						if good_hit != 'wait':
							results = nordnet_json[good_hit]['results']
							instrument_id = results[0]['instrument_id']
							display_name = results[0]['display_name']

							space_counter = 0
							paper_url = ''
							for letter in display_name:
								if letter == " ":
									space_counter += 1
									if space_counter > 2:
										break
									letter = '-'
									paper_url += letter
								else:
									letter = letter.lower()
									paper_url += letter
							full_url = base_url + str(instrument_id) + '-' + paper_url
							if "&" in full_url:
								full_url = full_url.replace("&","")
							
							check_full_url = requests.get(full_url)
							
							soup = BeautifulSoup(check_full_url.text, "lxml")
							try:
								policy = soup.find('span', text=re.compile('Udbyttepolitik'))
								policy_value = policy.next.next.string
							except:
								policy_value = "Ukendt"
							try:
								prospectus = soup.find('span', text=re.compile('Faktaark'))
								prospectus_value = prospectus.next.next.a['href']
								cleaned_prospectus = prospectus_value[:prospectus_value.rfind("?")].replace('http','https')
							except:
								cleaned_prospectus = "Ukendt"
							try:
								aop = soup.find('span', text=re.compile('Årlig omkostning'))
								aop_value = aop.next.next.get_text()
								cleaned_aop = aop_value.replace(",",".").replace("%","")
							except:
								cleaned_aop = "Ukendt"							
							
							row[10] = check_full_url.url
							row[11] = instrument_id
							row[12] = cleaned_aop
							row[13] = policy_value
							row[14] = cleaned_prospectus
			print(row)
			paperwriter.writerow(row)

Datamodel i Django

Her er models.py:

from django.db import models

class Stock(models.Model):
	country = models.CharField('Registreringsland', max_length=2)
	isin = models.CharField('ISIN-kode', max_length=20, blank=True)
	name = models.CharField('Navn', max_length=200, blank=True)
	lei = models.CharField('LEI-kode', max_length=20, blank=True)
	asident = models.CharField('ASIDENT', max_length=20, blank=True)
	cvr = models.CharField('CVR/SE/TIN', max_length=20, blank=True)
	friendly_name = models.CharField('Venligt navn', max_length=200, blank=True)
	first_registration_year = models.CharField('Første registreringsår', max_length=4, blank=True)
	morningstar_id = models.CharField('Morningstar: Id', max_length=20, blank=True)
	saxo_id = models.CharField('Saxo Bank: Id', max_length=20, blank=True)
	nordnet_id = models.CharField('Nordnet: Id', max_length=20, blank=True)
	morningstar_url = models.URLField('Morningstar: Url', max_length=200, blank=True)
	saxo_url = models.URLField('Saxo Bank: Url', max_length=200, blank=True)
	nordnet_url = models.URLField('Nordnet: Url', max_length=200, blank=True)
	morningstar_aop = models.FloatField('Morningstar: Løbende omkostninger', null=True, blank=True)
	nordnet_aop = models.FloatField('Nordnet: Løbende omkostninger', null=True, blank=True)
	nordnet_dividend = models.CharField('Nordnet: Udbyttepolitik', max_length=20, blank=True)
	nordnet_prospect = models.URLField('Nordnet: Investorinformation', max_length=200, blank=True)
	morningstar_prospect = models.URLField('Morningstar: Investorinformation', max_length=200, blank=True)

Importere data til Django

Her brugte jeg Django’s databasehåndtering i stedet for selv at skrive SQL-sætninger:

import csv
with open('stocks.csv', newline='\n') as csvfile:
	reader = csv.DictReader(csvfile, delimiter=";")
	count = 0
	for row in reader:
		stock = Stock(country = row['Registreringsland/Skattemæssigt hjemsted'])
		if row['ISIN-kode']:
			stock.isin = row['ISIN-kode']
		if row['Navn']:
			stock.name = row['Navn']
		if row['LEI kode']:	
			stock.lei = row['LEI kode']
		if row['ASIDENT']:	
			stock.asident = row['ASIDENT']
		if row['CVR/SE/TIN']:	
			stock.cvr = row['CVR/SE/TIN']
		if row['Venligt navn']:	
			stock.friendly_name = row['Venligt navn']
		if row['Første registreringsår']:	
			stock.first_registration_year = row['Første registreringsår']
		if row['Morningstar_id']:	
			stock.morningstar_id = row['Morningstar_id']
		if row['Saxo_id']:	
			stock.saxo_id = row['Saxo_id']
		if row['Nordnet_id']:	
			stock.nordnet_id = row['Nordnet_id']
		if row['Morningstar_url']:	
			stock.morningstar_url = row['Morningstar_url']
		if row['Saxo_url']:	
			stock.saxo_url = row['Saxo_url']
		if row['Nordnet_url']:	
			stock.nordnet_url = row['Nordnet_url']
		if row['Morningstar_ÅOP']:	
			stock.morningstar_aop = row['Morningstar_ÅOP']
		if row['Nordnet_ÅOP'] and row['Nordnet_ÅOP'] != '-' and row['Nordnet_ÅOP'] != 'Ukendt':	
			stock.nordnet_aop = row['Nordnet_ÅOP']
		if row['Nordnet_udbyttepolitik']:	
			stock.nordnet_dividend = row['Nordnet_udbyttepolitik']
		if row['Nordnet_prospekt']:	
			stock.nordnet_prospect = row['Nordnet_prospekt']
		if row['Morningstar_prospekt']:	
			stock.morningstar_prospect = row['Morningstar_prospekt']

		stock.save()
		count += 1
		print(count)

Bygge visningen

Her er views.py:

from django.shortcuts import render
from .models import Stock

def index(request):
	#FILTER LOGIC
	if request.GET.get('filter'):
		filter = request.GET.get('filter')
		if filter == 'nordnetsaxo':
			stocks = Stock.objects.exclude(nordnet_url='') | Stock.objects.exclude(saxo_url='')
		elif filter == 'nordnet':
			stocks = Stock.objects.exclude(nordnet_url='')
		elif filter == 'saxo':
			stocks = Stock.objects.exclude(saxo_url='')
		elif filter == 'ikkenordnetsaxo':
			stocks = Stock.objects.filter(nordnet_url='').filter(saxo_url='')
		elif filter == 'alle':
			stocks = Stock.objects.all()
	else:
		stocks = Stock.objects.exclude(nordnet_url='') | Stock.objects.exclude(saxo_url='')
	
	#SORT LOGIC
	sort = request.GET.get('sort')
	print(sort)
	if sort == "name" or not sort:
		stocks = stocks.order_by('name')
	elif sort == "-name":
		stocks = stocks.order_by('-name')
	elif sort == "isin":
		stocks = stocks.order_by('isin')
	elif sort == "-isin":
		stocks = stocks.order_by('-isin')
	elif sort == "morningstar_aop":
		stocks = stocks.order_by('morningstar_aop')
	elif sort == "-morningstar_aop":
		stocks = stocks.order_by('-morningstar_aop')
	elif sort == "nordnet_aop":
		stocks = stocks.order_by('nordnet_aop')
	elif sort == "-nordnet_aop":
		stocks = stocks.order_by('-nordnet_aop')
		
	context = {'stocks': stocks}
	return render(request, 'stocks/index.html', context)

Og her er så skabelonen index.html:

{% extends "stocks/base.html" %}
{% load static %}
{% block title %}ETF'er og fonde med aktiebeskatning 2021{% endblock %}
{% block content %}{% spaceless %}

<h1>ETF'er og fonde med aktiebeskatning 2021</h1>

<p>Du har læst om, <a href="https://www.nordnet.dk/blog/nye-regler-for-beskatning-af-investeringsfonde/">at aktiebaserede ETF'er og udenlandske investeringsfonde fra 2020 beskattes som aktieindkomst og ikke længere som kapitalindkomst</a>.</p>

<p>Du har endda fundet <a href="https://skat.dk/getfile.aspx?id=145013&type=xlsx">det fine regneark, der viser aktiebaserede investeringsselskaber</a> på <a href="https://skat.dk/skat.aspx?oid=2244641">skat.dk</a>.</p>

<p>Men det er godt nok svært for dig at få overblik over, hvilke af papirerne du overhovedet kan købe som almindelig hobby-/cryptoinvestor, og at sammenligne omkostninger, ÅOP og hvad det ellers hedder, for at finde det rigtige køb.</p>

<p>Her er et forsøg på at løse dit (og mit) problem. Data kommer fra <a href="https://skat.dk/getfile.aspx?id=145013&type=xlsx">det fine regneark</a> og har samme fejl og mangler, men er suppleret med nyttige informationer og links.</p>

<p><a href="#forbehold">Du kan læse om forbehold nederst på siden</a> og du kan <a href="https://helmstedt.dk/2021/03/etfer-og-fonde-med-aktiebeskatning-2021/">læse om hvordan siden er lavet på min blog</a>.</p>

<p><strong>Vis til salg hos:</strong>
<form id="prefs">

	<input type="radio" id="nordnetsaxo" name="filter" value="nordnetsaxo"{% if request.GET.filter == "nordnetsaxo" or not request.GET.filter %} checked{% endif %}>
	<label title="Værdipapirer til salg hos Nordnet, Saxo Bank eller begge steder" for="nordnetsaxo">Nordnet og/eller Saxo Bank</label>
	<input type="radio" id="nordnet" name="filter" value="nordnet"{% if request.GET.filter == "nordnet" %} checked{% endif %}>
	<label title="Værdipapirer til salg hos Nordnet" for="nordnet">Nordnet</label>		
	<input type="radio" id="saxo" name="filter" value="saxo"{% if request.GET.filter == "saxo" %} checked{% endif %}>
	<label title="Værdipapirer til salg hos Saxo Bank" for="saxo">Saxo Bank</label>
	<input type="radio" id="ikkenordnetsaxo" name="filter" value="ikkenordnetsaxo"{% if request.GET.filter == "ikkenordnetsaxo" %} checked{% endif %}>
	<label title="Værdipapirer, der hverken er til salg hos Nordnet eller Saxo Bank" for="ikkenordnetsaxo">Ikke Nordnet og/eller Saxo</label>
	<input type="radio" id="alle" name="filter" value="alle"{% if request.GET.filter == "alle" %} checked{% endif %}>
	<label title="Alle værdipapirer, både dem der kan købes hos Nordnet/Saxo Bank og de, der ikke kan" for="alle">Hele pivtøjet</label>
</form>
</p>

<table>
	<tr>
		<th><a href="{% url 'stocks_index' %}?sort={% if request.GET.sort == "-name" %}name{% else %}-name{% endif %}">Navn</a></th>
		<th><a href="{% url 'stocks_index' %}?sort={% if request.GET.sort == "isin" %}-isin{% else %}isin{% endif %}">Isin</a></th>
		<th><a href="{% url 'stocks_index' %}?sort={% if request.GET.sort == "morningstar_aop" %}-morningstar_aop{% else %}morningstar_aop{% endif %}">Løbende omkostninger</a></th>
		<th><a href="{% url 'stocks_index' %}?sort={% if request.GET.sort == "nordnet_aop" %}-nordnet_aop{% else %}nordnet_aop{% endif %}">ÅOP</a></th>
		<th>Investorinformation</th>
		<th>Morningstar</th>
		<th>Nordnet</th>
		<th>Saxo</th>
	</tr>
	{% for stock in stocks %}
	<tr>
		<td>{{ stock.name }}</td>
		<td>{{ stock.isin }}</td>
		<td>{% if stock.morningstar_aop %}{{ stock.morningstar_aop }}%{% endif %}</td>
		<td>{% if stock.nordnet_aop %}{{ stock.nordnet_aop }}%{% endif %}</td>
		<td>{% if stock.nordnet_prospect %}<a href="{{ stock.nordnet_prospect }}">Info</a>{% elif stock.morningstar_prospect %}<a href="{{ stock.morningstar_prospect }}">Info</a>{% endif %}</td>
		<td>{% if stock.morningstar_url %}<a href="{{ stock.morningstar_url }}">Link</a>{% endif %}</td>
		<td>{% if stock.nordnet_url %}<a href="{{ stock.nordnet_url }}">Link</a>{% endif %}</td>
		<td>{% if stock.saxo_url %}<a href="{{ stock.saxo_url }}">Link</a>{% endif %}</td>
	</tr>
	{% endfor %}
</table>

<a name="forbehold"></a>
<h2>Forbehold</h2>
<p>Alt hvad du læser på denne side er løgn og fiktion fra ende til anden og har ingen relation til virkeligheden. Hvis du kunne finde på at læse indholdet, som om det omhandlede værdipapirer, eller at købe, sælge eller tage dig af din personlige hygiejne med værdipapirer på grund af indholdet på denne side, er det fuldstændig et hundrede procent på eget ansvar. Alt hvad der findes på siden er fejlbehæftet, forældet og lavet af en uduelig amatør uden forstand på noget som helst. Du skal regne med, at alle links fører til nogle andre værdipapirer, end man skulle tro, og at de værdipapirer som står til salg et sted sikkert ikke sælges der - og omvendt. Alle oplysninger om løbende omkostninger og ÅOP er fundet ved hjælp af hønebingo og dermed så godt som tilfældige.</p>
{% endspaceless %}{% endblock %}