Category Archives: journalismus

Looking at the number of “concurrent” readers on the Süddeutsche Zeitung articles

I scraped the numbers of live readers per article published by Süddeutsche Zeitung on their website for more than 3 years, never did anything too interesting with it and just decided to stop. Basically they publish a list of stories and their estimated current concurrent number of readers. Meaning you get a timestamp -> story/URL -> number of current readers. Easy enough and interesting for sure.

Here is how it worked, some results and data for you to build upon. Loads of it is stupid and silly, this is just me dumping it publicly so I can purge it.


For data storage I chose the dumbest and easiest approach because I did not care about efficiency. This was a bit troublesome later when the VPS ran out of space but … shrug … I cleaned up and resumed without changes. Usually it’s ok to be lazy. :)

So yeah, data storage: A SQLite database with two tables:

CREATE TABLE visitors_per_url (
    timestamp TEXT,    -- 2022-01-13 10:58:00
    visitors INTEGER,  -- 13
    url TEXT           -- /wissen/zufriedenheit-stadt-land-1.5504425

CREATE TABLE visitors_total (
    timestamp TEXT,    -- 2022-01-13 10:58:00
    visitors INTEGER   -- 13

Can you spot the horrible bloating issue? Yeah, when the (same) URLs are stored again and again for each row, that gets big very quickly. Well, I was too lazy to write something smarter and more “relational”. Like this it is only marginally better than a CSV file (I used indexes on all the fields as I was playing around…). Hope you can relate. :o)


#!/usr/bin/env python3

from datetime import datetime
from lxml import html
import requests
import sqlite3
import os

# # TODO
# - store URLs in a separate table and reference them by id, this will significantly reduce size of the db :o)
#     - more complicated insertion queries though so ¯\\\_(ツ)\_/¯

# The site updates every 2 minutes, so a job should run every 2 minutes.

# # Create database if not exists
sql_initialise = """
CREATE TABLE visitors_per_url (timestamp TEXT, visitors INTEGER, url TEXT);
CREATE TABLE visitors_total (timestamp TEXT, visitors INTEGER);
CREATE INDEX idx_visitors_per_url_timestamp ON visitors_per_url(timestamp);
CREATE INDEX idx_visitors_per_url_url ON visitors_per_url(url);
CREATE INDEX idx_visitors_per_url_timestamp_url ON visitors_per_url(timestamp, url);
CREATE INDEX idx_visitors_total_timestamp ON visitors_total(timestamp);
CREATE INDEX idx_visitors_per_url_timestamp_date ON visitors_per_url(date(timestamp));

if not os.path.isfile("sz.db"):
    conn = sqlite3.connect('sz.db')
    with conn:
        c = conn.cursor()

# # Current time
# we don't know how long fetching the page will take nor do we
# need any kind of super accurate timestamps in the first place
# so let's truncate to full minutes
# WARNING: this *floors*, you might get visitor counts for stories
# that were released almost a minute later! timetravel wooooo!
now =
now = now.replace(second=0, microsecond=0)

# # Get the webpage with the numbers
page = requests.get('')
tree = html.fromstring(page.content)
entries = tree.xpath('//div[@class="entrylist__entry"]')

# # Extract visitor counts and insert them to the database
# Nothing smart, fixed paths and indexes. If it fails, we will know the code needs updating to a new structure.

total_count = entries[0].xpath('span[@class="entrylist__count"]')[0].text

visitors_per_url = []
for entry in entries[1:]:
    count = entry.xpath('span[@class="entrylist__socialcount"]')[0].text
    url = entry.xpath('div[@class="entrylist__content"]/a[@class="entrylist__link"]')[0].attrib['href']
    url = url.replace("", "")  # save some bytes...
    visitors_per_url.append((now, count, url))

conn = sqlite3.connect('sz.db')
with conn:
    c = conn.cursor()
    c.execute('INSERT INTO visitors_total VALUES (?,?)', (now, total_count))
    c.executemany('INSERT INTO visitors_per_url VALUES (?,?,?)', visitors_per_url)

This ran every 2 minutes with a cronjob.


I plotted the data with bokeh, I think because it was easiest to get a color category per URL (… looking at my plotting script, ugh, I am not sure that was the reason).

#!/usr/bin/env python3

import os
import sqlite3
from shutil import copyfile
from datetime import datetime, date

from bokeh.plotting import figure, save, output_file
from bokeh.models import ColumnDataSource

def dict_factory(cursor, row):
    d = {}
    for idx, col in enumerate(cursor.description):
        d[col[0]] = row[idx]
    return d

today = date.isoformat(

conn = sqlite3.connect('sz.db')
conn.row_factory = dict_factory
with conn:
    c = conn.cursor()
        SELECT * FROM visitors_per_url
        WHERE visitors > 100
        AND date(timestamp) = date('now');

    ## i am lazy so i group in sql, then parse from strings in python :o)
    #c.execute('SELECT url, group_concat(timestamp) AS timestamps, group_concat(visitors) AS visitors FROM visitors_per_url GROUP BY url;')

    visitors_per_url = c.fetchall()

# so that the data is available for hover

data = {
    "timestamps": [datetime.strptime(e["timestamp"], '%Y-%m-%d %H:%M:%S') for e in visitors_per_url],
    "visitors": [e["visitors"] for e in visitors_per_url],
    "urls": [e["url"] for e in visitors_per_url],
    "colors": [f"#{str(hash(e['url']))[1:7]}" for e in visitors_per_url]  # lol!

source = ColumnDataSource(data=data)

# for hover

p = figure(
    title=f"Leser pro Artikel auf {today}"

# radius must be huge because of unixtime values maybe?!
    x="timestamps", y="visitors", source=source,
    size=5, fill_color="colors", fill_alpha=1, line_color=None,

# click_policy does not work, hides everything
#p.legend.location = "top_left"
#p.legend.click_policy="hide"  # mute is broken too, nothing happens

p.hover.tooltips = [
    ("timestamp", "@timestamps"),
    ("visitors", "@visitors"),
    ("url", "@urls"),

output_file(f"public/plot_{today}.html", title=f"SZ-Leser {today}", mode='inline')

os.remove("public/plot.html")  # will fail once :o)
copyfile(f"public/plot_{today}.html", "public/plot.html")

Results and findings

Nothing particularly noteworthy comes to mind. You can see perfectly normal days, you can see a pandemic wrecking havoc, you can see fascists being fascists. I found it interesting to see how you can clearly see when articles were pushed on social media or put on the frontpage (or off).


If there is anything broken or missing, that’s how it is. I did not do any double checks just now. :}

Das eigene kleine Deutschlandradio Archiv

Mediatheken des Öffentlich-rechtlichen Rundfunks müssen wegen asozialen Arschlöchern ihre Inhalte depublizieren. Wegen anderer Arschlöcher sind die Inhalte nicht konsequent unter freien Lizenzen, aber das ist ein anderes Thema.

Ich hatte mir irgendwann mal angesehen, was es eigentlich für ein Aufwand wäre, die Inhalte verschiedener Mediatheken in ein privates Archiv zu spiegeln. Mit dem Deutschlandradio hatte ich angefangen und mit den üblichen Tools täglich die neuen Audiobeiträge in ein Google Drive geschoben. Dieses Setup läuft jetzt seit mehr als 2 Jahren ohne Probleme und vielleicht hat ja auch wer anders Spaß dran:


  • rclone einrichten oder mit eigener Infrastruktur arbeiten (dann die rclone-Zeile mit z.B. rsync ersetzen)
  • <20 GB Platz haben
  • Untenstehendes Skript als täglichen Cronjob einrichten (und sich den Output zu mailen lassen)

# exit if anything fails
# not a good idea as downloads might 404 :D
set -e

cd /home/dradio/deutschlandradio

# get all available files
wget -nv -nc -x ""{0..100}"&drau:limit=1000"
grep -hEo 'http.*mp3'* | sort | uniq > urls

# check which ones are new according to the list of done files
comm -13 urls_done urls > todo

numberofnewfiles=$(wc -l todo | awk '{print $1}')
echo "${numberofnewfiles} new files"

if (( numberofnewfiles < 1 )); then
        echo "exiting"

# get the new ones
echo "getting new ones"
wget -i todo -nv -x -nc || echo "true so that set -e does not exit here :)"
echo "new ones downloaded"

# copy them to remote storage
rclone copy /home/dradio_scraper/deutschlandradio remote:deutschlandradio && echo "rclone done"

## clean up
# remove files
echo "cleaning up"
rm -r
rm -rv
rm urls

# update list of done files
cat urls_done todo | sort | uniq > /tmp/urls_done
mv /tmp/urls_done urls_done

# save todo of today
mv todo urls_$(date +%Y%m%d)

echo "done"

Pro Tag sind es so 2-3 Gigabyte neuer Beiträge.

In zwei Jahren sind rund 2,5 Terabyte zusammengekommen und ~300.000 Dateien, aber da sind eventuell auch die Seiten des Feeds mitgezählt worden und Beiträge, die schon älter waren.

Wer mehr will nimmt am besten direkt die Mediathekview-Datenbank als Grundlage.

Nächster Schritt wäre das eigentlich auch täglich nach zu schieben.


Fergus Falls liegt im Westen Minnesotas, zwischen den Bundesstaaten Wisconsin und North Dakota, am nördlichen Rand der USA. Die Jahresdurchschnittstemperatur nahe der Grenze zu Kanada liegt bei plus 3 Grad Celsius, im Winter bei minus 20 Grad. Von den Hochhäusern New Yorks und den Stränden San Franciscos sind es mehr als 2200 Kilometer bis nach Fergus Falls. Von El Paso, der mexikanischen Grenze, braucht ein Auto 22 Stunden. Wie viele Mexikaner zieht es in diese Gegend? Wer stellt hier so ein Schild auf?

Der SPIEGEL hat in einem ersten Schritt seine Dokumentationsabteilung das Manuskript der Relotius-Geschichte noch einmal in Stichproben prüfen lassen – und musste feststellen, dass bei der Verifikation tatsächlich nicht sauber gearbeitet wurde. (Mehr zu den Sicherheitsmechanismen des Hauses lesen Sie hier.)

Ein paar Detail-Beispiele, die auffielen:

So sind es von Fergus Falls nicht 2200 Kilometer nach New York, wie es im Text steht, sondern nur 1888.

Ich *hasse* diesen Zahlenfetischismus (passt genau in diese gefühlsmanipulative Art von Reportagen), daher war mein Interesse geweckt (während im Hintergrund gerade “for king and country” aus den Lautsprechern schallte.

Warum sollten es exakt 1888 Kilometer sein? Was hatte die Spiegel-Dokumentation denn da gemessen? Rathaus zu Rathaus? Zentroid zu Zentroid der administrativen Ortsgrenzen? Die kürzeste Distanz?

Da im nächsten Satz des Artikels eine Reisedauer mit dem Auto angegeben wurde, scheint es naheliegend, dass der Autor auch für die Distanzen eine solche Reise angenommen hatte.

Und siehe da, Google Maps gibt für die Reise mit dem Auto von Fergus Falls (Minnesota 56537) nach New York 2218 Kilometer, alternativ 2335 Kilometer an.

Graphhopper bestätigt die 2218 Kilometer (exakt!!!11).

Bing bietet Routen von 2208 bis 2372 Kilometer an.

Yandex rundet sinnvoll auf 2300 bzw. 2400 Kilometer

Vier verschiedene Routenplaner bestätigen also eine Entfernung von grob 2200 Kilometern (oder mehr) und damit die Angabe im Artikel des Autors.

Was hat die Spiegel-Dokumentation also dann wohl gemacht?

Auf Wikipedia gibt es repräsentative Koordinaten für beide Orte, 46°16′59″N 96°04′39″W für Fergus Falls (irgendwo), 40°42′46″N 74°00′21″W für New York City (Rathaus).

Betrachtet man die Erde als Spheroid läge die direkte Distanz zwischen diesen bei etwa 1878 Kilometern.

Mit einem besser passenden Ellipsoid (WGS84) wären es 1882 Kilometer.

Die Spiegel-Dokumentation hat also wohl irgendwelche Koordinaten, relativ zentral in den beiden Orten, gewählt und die direkte Distanz zwischen ihnen berechnet. Immerhin geodätisch!

Fazit: Ach, watt weiß ich, dieses Detail im Faktencheck der Spiegel-Dokumentation ist definitiv verständnisloser Aktionismus. Distanzen sind ein kompliziertes Konzept. Ist eine Reisedistanz gemeint? Diese kann durch unterschiedliche Routen oder Umleitungen stark abweichen. Ist eine direkte Distanz gemeint? Falls ja, wie genau kann das Ergebnis jetzt sein? Was sind die exakten Punkte, zwischen denen gemessen wurde und welche Art von Genauigkeit bzw. Unsicherheit haben diese? Wann ist es sinnvoll eine Stadt mit einer Koordinate zu beschreiben?

1888 Kilometer ist in jedem Fall nichts als ein blindes Vortäuschen von Exaktheit.

PS: Zahlen vergöttern die, die sie nicht verstehen. Mir schwindelte.

(Not) Solving #quiztime with a spatial relationship query in Overpass-Turbo

I saw a #quiztime on Twitter and it looked like a perfect geospatial puzzle so I had to give it a try:

So we have something called “Lidl” which I know is a supermarket. And we have something else, with a potential name ending in “nden”. And that’s it.

If you have a global OSM database, this would be a simple query away. If you don’t have a global OSM database, the Overpass API let’s you query one. And overpass turbo can make it fun (not in this case though, I had a lot of trial and error until I found these nice examples in the Wiki).

I ended up looking for things within 50 meters to a Lidl named “-nden”





(._;>;);  // whatever? 

Too far away from the Lidl.

But driving around the place in Google StreetView I could not find the spot and it does not look very much like the photo.

So I guess my query is fine but either the "thing" or the Lidl are not in OSM yet.

Oh well, I did learn something new (about Overpass) and it was fun. :)

Wie man eine bivariate Farbskala nicht erstellen sollte

Ich hatte diese Kritik im Rahmen des (wahnsinnig tollen) Daten-Labors 2015 nebenbei geäußert und dann aufgrund des Interesses versprochen meine Gedanken aufzuschreiben. Hier sind sie nun endlich.

Geld zieht Ärzte an, so titelte die Zeit Online vor einigen Monaten über einer Recherche zum Verhältnis der räumlichen Verteilung von Ärzten im Vergleich mit verschiedenen demographischen Faktoren. Integraler Bestandteil des Artikels sind komplexe Karten und Diagramme. Die Redakteure versuchten sich an der Verwendung einer bivariaten Klassen-/Farb-Skala, doch leider ging die Wahl der Farben daneben, so dass das Endprodukt ineffektiv und irreführend ist. Es geht mir hier ausschließlich um die kartografische Darstellung. Zum Inhalt und der Datenanalyse kann ich nichts sagen!

zeit karte
So funktionieren die Karten: Grau steht für Privatpatienten, Grün für Ärzte. Zu den drei Helligkeitsstufen (je dunkler, desto höher der Anteil der Privatversicherten) kommt die Farbe dazu (je intensiver, desto mehr Ärzte pro Einwohner) So ergeben sich neun verschiedene Werte für die Einfärbung der Karten.

In einer bivariaten Skala wird das Verhältnis zweier Variablen zueinander/miteinander in vollem Detail dargestellt. Anstelle einer einzelnen Verhältniszahl sind hier mehrere Achsen im Gebrauch und damit die einzelnen Werte der Variablen nachvollziehbar. Solche Skalen sind in der Kartographie an sich nichts neues, werden allerdings (aufgrund der Komplexität meiner Meinung nach zu Recht) eher selten verwendet. Im Frühjahr 2015 veröffentlichte Joshua Stevens einen fantastischen Artikel, dessen Lektüre ich vor dem Weiterlesen sehr empfehle.

Joshua zeigt dort, wie aus den jeweiligen Farbskalen der beiden Attribute eine gemischte “Matrix” entsteht. Die Diagonale wird hierbei zu einem neuen sequenziellen Farbverlauf, der das neutrale Verhältnis der Variablen anzeigt. Die Farbskalen müssen dementsprechend mit Bedacht gewählt werden, so dass sich bei ihrer Vermischung eine sinnvolle, geordnete und “eigenständige” Skala entsteht.


In Joshuas Beispiel sind (relativ) klar differenzierbar und identifizierbare Achsen entstanden, die dem Kartenbetrachter (mit etwas Anstrengung) ermöglichen, die Karte korrekt zu interpretieren. Man kann anhand der Farbe das jeweilige Verhältnis und die absoluten Werte lesen. Die Farbachsen sind intuitiv korrekt sortierbar.

Wie sieht es mit dem Farbschema der Zeit aus? Leider nicht gut.

Die Redakteure wählten für die eine Variable einen Farbverlauf von Grau nach Grün, für die andere einen von Grau nach Dunkelgrau (siehe oben). Die diagonale Farbskala entsteht also aus der Vermischung von Grün und Grau. Was passiert, wenn man Grün und Grau mischt? Man bekommt Farbtönen zwischen Grün und Grau… Die Farben auf der Diagonalen werden also sehr ähnlich zu zumindest einer der Hauptachsen. Damit zeigen sich Farben im Kartenbild, deren Ordnung der Betrachter unmöglich intuitiv und auch mithilfe der Legende kaum mental durchführen kann. Und genau das können wir hier sehen:

zeit karte exploded

Als kleine Demonstration wieviele Details und Strukturen tatsächlich in den Daten stecken, habe ich einfach mal eine bivariate Farbskala von Cynthia Brewer auf die Daten geworfen. Achtung: Ich habe die Klassen nicht genau so legen können (Faulheit), wie sie in der Ursprungskarte vorliegen! Grundsätzlich dürfte die Aussage der Karte aber stimmen. Die Ästhetik steht erstmal an zweiter Stelle. ;)


zeit karte vs

Tracking deutscher Onlinemedien

Tracking deutscher Onlinemedien - 1

Weil ich jedes Mal das Würgen kriege, wenn Medienvertreter über die pösen pösen Ad-Blocker meckern und wie das Internet ohne Werbung keine Inhalte hätte. Weil es niemanden etwas angeht, wer, was, wann und wo liest. Weil ich eine tolle Initiative finde. Weil Gephi Spaß bringt. :)

Tracking deutscher Onlinemedien - lightbeamLightbeam for Firefox ist ein nettes Tool, um Verbindungen zwischen Webseiten zu tracken und visualisieren. Ich habe es installiert und rund 90 Internetauftritte zufälliger Zeitungen und Magazine aufgerufen (Liste am Ende dieses Beitrags). Ich habe dabei “klassische” Medien bevorzugt


Yumm yumm! Leider ist der eingebaute Graph ziemlich hässlich und unübersichtlich. Daher habe ich die Daten exportiert, etwas umformatiert und in Gephi visualisiert.

In Lightbeam: “Save Data”. Es kommt eine lightbeamData.json-Datei raus. Diese hab ich dann (per Holzhammer) mit GNU/Linux-Bordmitteln bekämpft. Erst mit sed Zeilenumbrüche eingefügt, dann mit awk die interessanten Felder extrahiert, mit sed bereinigt und dann Duplikate entfernt:

sed 's/\],/\],\n/g' lightbeamData.json | awk -F "," '{print $1"\t"$2}' | sed 's/[\["]//g' | sort | uniq > lightbeamData.tsv

Um sie anschließend direkt in Gephi laden zu können, müssen die Spalten Titel haben. Also zum Beispiel einfach “source[TAB]target” in die erste Zeile einfügen.

In Gephi ist es dann eigentlich ganz einfach. Neues Projekt, Data Laboratory -> Import Spreadsheet. Die lightbeamData.tsv-Datei auswählen. Tab als Trennzeichen und als Edges-Tabelle laden. Next -> Finish, Fertig!

Im Overview unten auf das Label Attributes Icon klicken und “Id” auswählen, jetzt werden die Nodes beschriftet. Links bei Ranking (Degree) -> Label Size als maximale Größe zum Beispiel 2 auswählen. Mit einem der Slider unten kann man die gesamte Textgröße dynamisch ändern. Und dann mit den Layouts herumspielen. Um ein schönes Endprodukt zu bekommen ist Label Adjust ganz furchtbar toll (verfälscht aber natürlich vorherige Layout-Algorithmen). Per Mouseover kann man sich benachbarte Nodes anzeigen lassen. Da kommt dann zum Beispiel sowas bei raus:

Tracking deutscher Onlinemedien - 2

Tracking deutscher Onlinemedien - 3

Tracking deutscher Onlinemedien - bild

Tracking deutscher Onlinemedien - fb

Tracking deutscher Onlinemedien - kugel

Tracking deutscher Onlinemedien - nuggad

Tracking deutscher Onlinemedien - süddeutsche

Tracking deutscher Onlinemedien - yieldlab

Fazit: HTTP Switchboard oder Request Policy lohnen sich.

Die folgenden Domains wurden besucht:

Heimlich versteckte Randnotizen:
Ja, ich habe die “guten” Seiten ignoriert, die sich ausserhalb der Spaghettibälle befinden. In Lightbeam waren dies:,,,,,

Da ich einige Domains direkt im Router blocke könnte die Wirklichkeit noch schlimmer aussehen.

Ich habe das Gefühl, dass Lightbeam mehr Verbindungen suggeriert/erzeugt, als tatsächlich anfallen…