Abgang von Kaderärzten am Kantonsspital Baselland

Im Vorfeld der umstrittenen Spitalfusion gab es Gerüchte, dass viele Kaderärzte am Kantonsspital Baselland (KSBL) unzufrieden sind und vor dem Absprung stehen. Der bz wurde eine Liste zugespielt mit fünf Kaderärzten, die das Spital verlassen, der Artikel dazu ist hier.

Um dies zu überprüfen, habe ich am 31. Oktober die Mitarbeiter-Liste auf ksbl.ch kopiert und in ein Excel-File eingefügt. Dafür hätte man auch einen Scraper schreiben können, doch das Kopieren und Einfügen von A bis Z dauert wenige Minuten. Dann, wichtig, habe ich mir einen Eintrag in den Kalender geschrieben für +3 Monate.

Drei Monate später dasselbe, 26 Mal copy/paste der aktuellen Mitarbeiterliste in ein zweites Tabellenblatt. Danach habe ich etwas rumgepröbelt, um eine Liste zu erhalten mit a) all jenen Kaderärzten (=Oberärzte + Leitende Ärzte + Chefärzte), die im Oktober noch aufgeführt waren, im Januar nicht mehr; b) all jenen Ärzten, die neu aufgeführt sind im Januar und c) all jenen Ärzten, die befördert worden sind. Das Vorgehen dabei war: Bei der Januar-Liste eine weitere Spalte „schon da“ mit =1 eingefügt, dann beide Listen untereinander in ein neues Arbeitsblatt kopiert und daraus eine Pivot-Tabelle mit:

Entstanden daraus ist eine Liste (Namen eingeschwärzt) mit allen Abgängen, die das KSBL seit November 2018 zu verzeichnen hatte.

Diese Liste habe ich den Verantwortlichen vorgelegt und zu folgendem Artikel (print/online) verarbeitet.

JSON-Schnittstelle zu Infogr.am

Seit wir regelmässig das Basler Kantonsparlament mit einem Live-Ticker begleiten, stellte ich mir die Frage: Wie lassen sich Abstimmungsresultate schnell, detailliert, schön und kompatibel für alle bz-Plattformen darstellen. Die Lösung: Die in Echtzeit als PDF (!) publizierten Resultate werden durch meinen bereits geschriebenen Scraper eingelesen, ausgewertet und als speziell formatiertes JSON ausgegeben, exakt so, wie es vom Drittanbieter infogr.am vorgegeben wird. Die infogram-Grafik wiederum kann dann mittels embed in alle bz-Angebote eingebunden werden.

Was einfach klingt (und nun auch funtioniert), war ein Weg mit vielen Stolpersteinen. Das Einlesen und Auswerten der PDFs habe ich ja bereits an anderer Stelle beschrieben. Die Vorgabe für den JSON-Import bei infogr.am entspricht a) eigentlich diesem Beispiel:

[[["Index of Happiness","09:00","10:00","11:00","12:00","13:00"],["Eden",762,240,929,963,596],["Shambhala",550,118,976,762,738],["Avalon",952,886,234,794,227],["Camelot",197,452,687,876,952],["El Dorado",156,267,294,354,560],["Atlantis",500,924,954,138,585]]]

und b) eben nicht, zumindest dann nicht, wenn eine Tabelle über mehrere Blätter verfügen soll. Dann muss das ganze folgendermassen formatiert werden (die Logik erschliesst sich mir nicht, aber es funktioniert):

[[["Fraktionen","SP","LDP","SVP","GB","FDP","CVP/EVP","fraktionslos"],["Ja","15","6","11","4","4","3","2"],["Nein","13","1","1","7","2","",""],["Total","35","15","15","13","11","8","3"]],[["Politiker","Fraktion","Stimme"],["Semseddin Yilmaz","SP","J"],["Franziska Roth","SP","N"],["Sasha Mazzotti","SP","N"],["Andreas Zappalà","FDP","J"],["Annemarie Pfeifer","CVP/EVP","A"],["Thomas Grossenbacher","GB","N"],["Christian Griss","CVP/EVP","A"],["Katja Christ","fraktionslos","J"],["Olivier Battaglia","LDP","E"]]]

Entscheidend sind die zweifachen eckigen Klammern (rot), um die beiden Tabellen-Blätter voneinander zu trennen.

Weil es sich nicht um echtes JSON handelt, musste ich die Ausgabe manuell erstellen. Der Quellcode (gekürzt) dazu:

$geschaeft = auswerten($eintrag);
 if($_GET["ausgabe"] == "json"){
 //$geschaeftdetails["parteien"][$partei][$resultat]++;
 echo "[[[\"Fraktionen\"";
 foreach($geschaeft["parteien"] as $partei=>$resultat){
 echo ",\"".$partei."\"";
 }
 echo "],";
 
 echo "[\"Ja\"";
 foreach($geschaeft["parteien"] as $partei=>$resultat){
 echo ",\"".$resultat["J"]."\"";
 }
 echo "],";
 
 echo "[\"Total\"";
 foreach($geschaeft["parteien"] as $partei=>$resultat){
 echo ",\"";echo $resultat["N"]+$resultat["J"]+$resultat["E"]+$resultat["P"]+$resultat["A"];
 echo "\"";
 }
 echo "]],";
 //$geschaeftdetails["parteien"][$partei][$resultat]++;
 echo "[[\"Politiker\",\"Fraktion\",\"Stimme\"]";
 foreach($geschaeft["politiker"] as $name=>$detail){
 echo ",[\"".$name."\",\"".$detail["partei"]."\",\"".$detail["resultat"]."\"]";
 }
 echo "]]";
}

Aufgerufen wird der gewünschte JSON-Feed via URL-Parameter:

http://samuelhufschmid.ch/grossratssitzung/?action=einzeln&source=web&ausgabe=json&url=http://abstimmungen.grosserrat-basel.ch/aktuell/Abst_0614_20180919_223358_0025_0000_ab.pdf

Der zuständige Online-Redaktor am Grossrats-Ticker muss also künftig „nur“ noch die PDF-URL der gewünschten Abstimmung von der Grossrats-Seite (hier) kopieren und damit den roten Teil der obenstehenden URL ersetzen. Diese neue URL ergibt dann die von Infogram verlangte JSON-Formatierung und kann in ein vorgefertigtes Diagramm importiert werden. Das Ergebnis lässt sich, so finde ich, sehen und verfügt über alle interessanten Details, etwa das (sortierbare) Stimmverhalten aller Ratsmitglieder oder die Abwesenden.

Parkhaus-Auslastung

In Basel laufen derzeit mehrere Grossprojekte für unterirdische Parkings. Dabei stellt sich die Frage: Wer braucht all diese Plätze? Und: Sind die bestehenden Parkings denn überhaupt ausgelastet?

Einen guten Überblick bietet www.parkleitsystem-basel.ch/status.php; dort werden die aktuellen Belegungszahlen minütlich aktualisiert dargestellt. Und noch besser: Es wird ein RSS-Feed mit strukturierten XML-Daten zur Verfügung gestellt: http://www.parkleitsystem-basel.ch/rss_feed.php.

Dieser Feed ist folgendermassen aufgebaut:

<rss version="2.0">
<channel>
<title>Permanentes Parkleitsystem Basel</title>
<description>Ein Parkleitsystem ist ein Informationssystem welches die Aufgabe hat, Parkmoeglichkeiten in einer Stadt anzuzeigen und die Autofahrer zielgerecht dorthin zu fuehren.</description>
<link>http://www.parkleitsystem-basel.ch/status.php</link><lastBuildDate>Thu, 26 Apr 2018 14:07:55 +0100</lastBuildDate><generator>FeedCreator 1.7.2-ppt (info@mypapit.net)</generator>
<item><title>Parkhaus Bad. Bahnhof</title><link>http://www.parkleitsystem-basel.ch/parkhaus/badbahnhof.php</link><description>Anzahl freie Parkpl&auml;tze: 68</description><comments>Stand: 26.04.2018 14:07:00</comments><pubDate>Thu, 26 Apr 2018 13:07:00 +0100</pubDate></item>
...
Entscheidend sind die drei Elemente title, description und comments. Diese werden mit der PHP-Funktion simplexml_load_file in ein Array gepackt, danach wird das Array in einer foreach-Schleife abgearbeitet (die einzelnen item-elemente, dies mit dem Kommando
foreach($xml->channel->item as $test){
Kleine Bearbeitungen und das Entfernen eines Umlauts später steht das Array bereit, um in eine Mysql-Datenbank mit Spalten für die einzelnen Parkhäuser eingefügt zu werden.
$xml=simplexml_load_file($url) or die("Error: Cannot create object");

$arrayfuerdb = array();

foreach($xml->channel->item as $test){
 if(trim($test->title) == "Parkhaus Bahnhof S&uuml;d"){
 $test->title = "Parkhaus Bahnhof Sued";
 }
 $arrayfuerdb[trim($test->title)] = intval(substr($test->description,strpos($test->description,":")+2));
}

$date_for_database = date ("Y-m-d H:i:s", strtotime(substr($xml->channel->item[0]->comments,strpos($xml->channel->item[0]->comments,"Stand: ")+7)));

$arrayfuerdb["datetime"] = $date_for_database;
var_dump($arrayfuerdb);

$wpdb->insert('parkleitsystem',$arrayfuerdb); }

Ein CURL-Befehl führt das entsprechende Script seither alle fünf Minuten aus und füttert die Datenbank.

Erstmals elegant gelöst habe ich die automatisierte Ausgabe via JSON und der JSON-Importfunktion von infogr.am. Die Vorgaben für den Infogram-Import sind hier aufgefürt: https://infogram.com/api/examples/live.json. Ich habe diese Vorgabe mit zwei zusätzlichen echo“]“;-Kommandos erfüllt, den Rest des Arrays habe ich mittels json_encode ausgegeben. Wichtig dabei war, bei der Datenabfage an die MySql-Datenbank den Parameter Array_N zu übergeben, damit es als numerisches Array zurückgegeben wird.

Ein kleines Kunststück ist mir bei der SQL-Abfage des Datums geglückt, und zwar mit dem Statement

"Select DATE_FORMAT(datetime, '%a,%H:%i') as Zeit, [...]"

in Kombination mit einem vorher abgesetzten

$wpdb->query("SET lc_time_names = 'de_DE';");

Das führt dazu, dass die Ausgabe als abgekürzter Wochentag auf Deutsch + Uhrzeit ausgegeben wird. Der ganze Code:

if($_GET["output"]=="infogram"){
 $wpdb->query("SET lc_time_names = 'de_DE';");
 $alleeintraege = $wpdb->get_results( "Select DATE_FORMAT(datetime, '%a,%H:%i') as Zeit,`Parkhaus Bad. Bahnhof`, `Parkhaus Messe`, `Parkhaus Europe`, `Parkhaus Rebgasse`, `Parkhaus Claramatte`, `Parkhaus Clarahuus`, `Parkhaus Elisabethen`, `Parkhaus Steinen`, `Parkhaus City-USB`, `Parkhaus Storchen`, `Parkhaus Post Basel`, `Parkhaus Centralbahnparking`, `Parkhaus Aeschen`, `Parkhaus Anfos`, `Parkhaus Bahnhof Sued` FROM parkleitsystem",ARRAY_N );

 $arrayhinzu = array("Zeit","Parkhaus Bad. Bahnhof", "Parkhaus Messe", "Parkhaus Europe", "Parkhaus Rebgasse", "Parkhaus Claramatte", "Parkhaus Clarahuus", "Parkhaus Elisabethen", "Parkhaus Steinen", "Parkhaus City-USB", "Parkhaus Storchen", "Parkhaus Post Basel", "Parkhaus Centralbahnparking", "Parkhaus Aeschen", "Parkhaus Anfos", "Parkhaus Bahnhof Sued");

array_unshift($alleeintraege,$arrayhinzu);
 echo "[";
 echo json_encode($alleeintraege);
//Print array in JSON format
 echo "]";die();

Hier die fertige, sich selbst aktualisierende Grafik:

Die fertige Geschichte gibt’s online und in der Print-Version als pdf.

Blackbox Euroairport

Der Streit rund um die Nachtflüge am Euroairport in Basel schwelt seit Jahren, die Fronten (Flughafen/Anwohner) sind klar, die Argumente teilweise zweifelhaft. So lässt sich der Flughafen nur ungern in die Karten blicken, etwa wenn es darum geht, welche Flüge / Fluggesellschaften besonders häufig spät (nach 23 Uhr) starten. Auch um die genauen Abflug-Routen herrscht Verwirrung – «die Flugzeuge könnten früher abbiegen und über unbesiedeltes Gebiet starten» behaupten Fluglärm-Gegner, das werde bereits gemacht, sagt der Flughafen. Zwei Streitpunkte, die ich mit datenjournalistischem Ansatz angehen konnte.

Streitpunkt 1: Welche Flüge und Fluglinien starten am häufigsten nach 23 Uhr?

Der Flughafen veröffentlicht auf dieser Seite die täglichen Abflugzeiten – und zwar die tatsächlichen, nicht, wann die Flugzeuge nach Flugplan starten oder das Dock verlassen. Die Daten werden jedoch nach sieben Tagen wieder gelöscht. Nicht jedoch für einen Scraper, der täglich darauf zugreift und die Daten in eine Datenbank schreibt. Diese liegen als Javascript-Variabel vor, und zwar folgendermassen:

var flights = {"00:00":[{"datetime":"2018-02-03 23:03:00","runway":15,"flight_pattern":"N","direction":"D","airline":"REGA","flight":"FR2","destination":"DIVERS CH","aircraft":"HELICO","mtow":3000,"noise_idx":"11.0","destination_fr":"DIVERS CH","destination_de":"DIVERS CH","destination_en":"DIVERS CH","hour_runway":"23_15","procedure":null,"time":"00:03"}],"06:00":[{"datetime":"2018-02-04 05:25:00","runway":33,"flight_pattern":"S","direction":"D","airline":"IBERIA","flight":"IB9048","destination":"MADRID","aircraft":"MODEL757\/200 WITH RB211C","mtow":99790,"noise_i [...]

Das Format entspricht JSON und kann mittels JSON-Decode in ein PHP-Array umgewandelt, analysiert und bereits mit perfekten Labels versehen in eine entsprechende Datenbank eingefügt werden. Und zwar so:

$starts = json_decode(substr($html,strpos($html,"var flights")+14,strpos($html,"var ul_width")-strpos($html,"var flights")-18), true);
 foreach($starts as $zeit=>$content){
 if($zeit == "22:00" || $zeit == "22:30" || $zeit == "23:00" || $zeit == "23:30" || $zeit == "00:00" || $zeit == "00:30" || $zeit == "01:00" || $zeit == "01:30" || $zeit == "02:00" || $zeit == "02:30" || $zeit == "03:00" || $zeit == "03:30" || $zeit == "04:00" || $zeit == "04:30" || $zeit == "05:00" || $zeit == "05:30"){
 foreach($content as $einzelnerflug){
 echo $einzelnerflug["time"]."|geplant ".$einzelnerflug["datetime"]." Flugdetails:".$einzelnerflug["airline"]."|".$einzelnerflug["destination"]."<br/>";
 $wpdb->insert('EAPstarts',$einzelnerflug);
 }
 }
 }

Um Dubletten zu vermeiden, habe ich die Spalte Datetime als Unique Key definiert. Und um das Script täglich aufzurufen, habe ich einen CRON-Job definiert:

Für die Auswertung habe ich auf Excel vertraut, indem ich folgende MYSQL-Abfrage gemacht und die Resultate als CSV-Datei exportiert habe:

SELECT * 
FROM `EAPstarts` 
WHERE TIME LIKE "%23:%"

Die Auswertung mittels PIVOT-Funktion geht schnell und zuverlässig und bietet genügend Funktionen, um alle interessanten Fragen zu beantworten (die Spalten 15 und 33 bezeichnen die Startrichtung).

Aufbereitet als Infografik
Aufbereitet als Infografik

Veröffentlicht als Text im Print und online.

Fragestellung zwei: Welche Route fliegen die Flugzeuge bei Süd-Starts?

Fluglärmgegner behaupten seit Jahren, dass die Flugzeuge bei Südstarts früher abdrehen könnten und dadurch unbesiedeltes statt besiedeltes Gebiet überfliegen. Die Materie ist sehr kompliziert, aber mittels Flightradar24.com ist es möglich, die Südstarts zu exportieren und in Google Maps darzustellen. Hierzu ist ein kostenpflichtiger Flightradar-Account nötig (oder zumindest eine 7-tätige Test-Version) und ziemlich viel Handarbeit nötig (wahrscheinlich lässt sich vieles automatisieren und ich weiss nicht, wie). So habe ich die manuelle Variante gemacht:

Zunächst habe ich auf der Abflug-Seite des Euroairports nach Südstarts gesucht, die entsprechende Flugnummer auf Flightradar24.com eingegeben und ein KML-File heruntergeladen. Wie dies geht, ist hier beschrieben. Das File habe ich danach in eine Google Map als Layer importiert; es werden gleichzeitig zwei Layer erstellt, eine mit Punkten (wieder gelöscht) und eine mit dem Linienverlauf. Leider ist die Anzahl Layer in Google Maps auf zehn limitiert, aber eine Idee der ungefähren Startrouten ist dennoch möglich. Zur schöneren Darstellung habe ich die uninteressanten Punkte (einzeln) gelöscht, Tipp: Delete-Taste gedrückt halten und linke Maustaste klicken. Danach habe ich den Layer in die Flugnummer inklusive Datum umbenannt.

Das Resultat:

Der zweite Streitpunkt betrifft die Flughöhe. Auch diese Daten sind in den KML-Files enthalten, zur Darstellung wird jedoch Google Earth verwendet. Entstanden ist folgendes Bild, wobei die grünen Linien normale Flüge und die roten Linien für Flüge stehen, die vom hinteren Pistenrand aus gestartet sind.

Starts vom Nordende der Piste (rote Linien) bringen Höhengewinne.

Entstanden daraus ist ein Faktencheck, auf den ich verweise, sobald er veröffentlicht wurde.

Abstimmungsverhalten im Landrat

Nach mehreren Artikeln über das Abstimmungsverhalten im Grossen Rat von Basel-Stadt versuchte ich, dasselbe für den Baselbieter Landrat zu tun. Rasch zeigte sich, dass die Arbeit nur in geringem Masse reproduzierbar ist. Das Hauptproblem beim Landrat war, dass kein generelles Verzeichnis der Abstimmungsvorgänge vorhanden ist (wie etwa dieses in Basel: http://abstimmungen.grosserrat-basel.ch/index_archiv.php).

Der einzige Verweis auf die eigentlich praktisch unauffindbaren Dateien (gespeichert unter kryptischen Adressen wie https://www.baselland.ch/politik-und-behorden/landrat-parlament/sitzungen/traktanden-2010/landratssitzung-vom-5-12-19-november-2015/protokoll-der-landratssitzung-vom-5-12-u-143/protokoll-der-landratssitzung-vom-4-juni-2015/downloads/04-06-2015_11-36-06.pdf – man beachte die Sekundenzahl der Abstimmung!) findet sich in den Landratsprotokollen – zum Glück zumindest jeweils mit einem Link, der als „Namenliste“ (teilweise auch “ Namenliste“ oder „Namenliste “ oder “ Namenliste „) aufgeführt ist. Diese lassen sich mit meinen gängigen Methoden scraperwiki und simple_html_dom einfach und zuverlässig einlesen. Weil das Archiv (seit 2010) sehr umfangreich ist, habe ich eine Hilfsdatenbank verwendet und dort jeweils per reload ein Landratsprotokoll nach dem anderen eingelesen und die URL der einzelnen Protokoll-Abschnitte (insgesamt 3659) abgespeichert. In einem zweiten Durchlauf hat das Script sämtliche URLS geladen und nach [Namenliste]-Links durchsucht und diese in eine zweite DB abgespeichert. In einem dritten Schritt habe ich mit smalot/pdfparser den Inhalt der pdf-Dateien in die zweite DB geschrieben. Die Daten sind teilweise fehlerhaft – keine Ahnung, wie das geschehen kann – wird hier von Hand nachgebessert? So wurden „;“ vergessen und Namen als „Schweizer Hannes SP JSchweizer Kathrin“ eingegeben… Der Horror für jede Datenanalyse. Mit ziemlich viel Aufwand (>2 Stunden) konnte ich mit manuellen Korrekturen direkt in der DB die Fehler eliminieren. Auch waren einige PDF-Dateien grundsätzlich fehlerhaft abgespeichert und konnten auch im Browser nicht angezeigt werden. Bsp: https://www.baselland.ch/politik-und-behorden/landrat-parlament/sitzungen/traktanden-2010/landratssitzung-vom-22-oktober-2015/protokoll-der-landratssitzung-vom-22-okt-12/downloads/22-10-2015_14-44-58.pdf. Oder Dateien wie diese mit nur einem Namen: https://www.baselland.ch/politik-und-behorden/landrat-parlament/sitzungen/traktanden-2010/landratssitzungen-vom-22-29-september-2016/pdf-traktandenliste-22-und-29-september-2016/22-09-2016-15-32-02.pdf

In einer ersten Auswertung habe ich den Fokus auf die BüZa (Bürgerliche Zusammenarbeit) gelegt, insbesondere auf die umstrittene Rolle der CVP. Das eigentliche Ziel, die Wiederverwertbarkeit der Auswertungen auch für den Grossen Rat, musste ich vorerst auf Eis legen, werde es aber bei einer Überarbeitung oder Anpassung auf ein weiteres Kantonsparlament (mehr dazu bald) vorantreiben, denn die neu erstellte Auswertung ist kompakter und eleganter als die erste Version für den Basler Grossen Rat.

Zunächst habe ich die Auswahl der relevanten Geschäfte erstmals per SQL vorgenommen:

$alleeintraege = $wpdb->get_results( "SELECT * FROM lr_import where datetime > '2015-07-01 00:00:00'" );

Danach folgt eine erste Schlaufe zur Auswahl der relevanten Geschäfte und die komplizierte Aufspaltung in Geschäfte, bei denen die büza funktioniert und solchen, bei denen sie nicht funktionert:

foreach($resultatearrayganz as $zeile){
 if($_GET["action"] == "cvp"){
 //Array vorbereiten für die Rolle der CVP in der büza
 if($resultatearray["Partei"] == "Gruene" || $resultatearray["Partei"] == "SP"){
 $geschaeft["rotgruen"][$resultatearray["Stimme"]]++;
 }
 if($resultatearray["Partei"] == "FDP" || $resultatearray["Partei"] == "SVP"){
 $geschaeft["buergerliche"][$resultatearray["Stimme"]]++;
 }
 if($resultatearray["Partei"] == "CVP"){
 $geschaeft["cvp"][$resultatearray["Stimme"]]++;
 
 array_push($cvp_politiker[$resultatearray["Stimme"]], $resultatearray["Name"]);
 $cvp_abweichler[$resultatearray["Name"]]["total"]++;
 }
 
 } 
 //Auswertung, ob es sich um ein relevantes Geschäft handelt
 if($_GET["action"] == "cvp"){
 //Die Rolle der CVP in der Büza untersuchen
 //1. war es eine Abstimmung mit geschlossenen Lagern (wenig Gegenstimmen/Enthaltungen sowohl bei rotgrün als auch bei bürgerlichen)
 if(abs($geschaeft["buergerliche"]["J"] - $geschaeft["buergerliche"]["N"]) > 20 && abs($geschaeft["rotgruen"]["J"] - $geschaeft["rotgruen"]["N"]) > 15){
 //2. war die Abstimmung umstritten?
 if(abs($geschaeft["buergerliche"]["J"] - $geschaeft["rotgruen"]["J"]) > 20){
 $ausgabetext = "<tr><td><a href=\"".$eintrag->url."\">".$eintrag->datetime."</a></td><td><a href=\"https://baselland.talus.ch/de/politik/cdws/geschaefte_data.php?volltext=&number=".$geschaeftnummer."&title=&typ=&year=&author=&partei=&state=&board=&committee=&pendent=&due=\" title=\"".$geschaeftstitel."\">".substr($geschaeftstitel,0,70)."</a></td><td>".(0+$geschaeft["buergerliche"]["J"]).":".(0+$geschaeft["buergerliche"]["N"])."</td><td>".(0+$geschaeft["rotgruen"]["J"]).":".(0+$geschaeft["rotgruen"]["N"])."</td><td>".(0+$geschaeft["cvp"]["J"]).":".(0+$geschaeft["cvp"]["N"])."</td></tr>";
 //3. Doppelte Auswertung zur Rolle der CVP - funktioniert die Büza oder nicht?
 //3a: Sagen die Bürgerlichen Ja?
 if($geschaeft["buergerliche"]["J"] - $geschaeft["rotgruen"]["J"] > 20){
 //3a1. die CVP sagt auch Ja = büza funktioniert
 if($geschaeft["cvp"]["J"] > $geschaeft["cvp"]["N"]){
 $buezafunktioniert[substr($geschaeftstitel,0,70)]=$ausgabetext;
 foreach($cvp_politiker["N"] as $politiker){
 $cvp_abweichler[$politiker]["abweichend"]++;
 }
 } 
 //3a2. die CVP sagt nein = büza funktionier nicht
 else{
 $buezafunktioniertnicht[substr($geschaeftstitel,0,70)]=$ausgabetext;
 foreach($cvp_politiker["N"] as $politiker){
 $cvp_abweichler[$politiker]["abweichend"]++;
 }
 
 }
 }
 //3b: Die Bürgerlichen sagen nein
 else{
 //3b1. die CVP sagt auch Nein = büza funktioniert
 if($geschaeft["cvp"]["J"] < $geschaeft["cvp"]["N"]){
 $buezafunktioniert[substr($geschaeftstitel,0,70)]=$ausgabetext;
 foreach($cvp_politiker["J"] as $politiker){
 $cvp_abweichler[$politiker]["abweichend"]++;
 }
 } 
 //3a2. die CVP sagt Ja = büza funktionier nicht
 else{
 $buezafunktioniertnicht[substr($geschaeftstitel,0,70)]=$ausgabetext;
 foreach($cvp_politiker["J"] as $politiker){
 $cvp_abweichler[$politiker]["abweichend"]++;
 }
 }
 }
 }

Zur Ausgabe habe ich erstmals Javascript zum nachträglichen Sortieren der Tabelle eingefügt. Es heisst sorttable.js und wird folgendermassen eingebunden:

?>
<script src="http://www.samuelhufschmid.ch/wp-content/themes/twentysixteen/js/sorttable.js"></script>
<style type="text/css">
<!--
a:link { color: #000000; text-decoration: none}
-->
</style>
<?php

 

In einer ersten Auswertung habe ich den Fokus auf die BüZa (Bürgerliche Zusammenarbeit) gelegt, insbesondere auf die umstrittene Rolle der CVP. Meine Kollegen aus der Landregion haben daraus einen spannenden Text gemacht, der online hier und im Print hier zu finden ist.

Abwesenheitsanalyse Grosser Rat

Mit den Daten aus der PDF-Analyse des Abstimmungsverhaltens im Grossen Rat (mehr dazu hier: Abstimmungsverhalten im Grossen Rat anhand von 1502 PDF-Dokumenten) habe ich eine Analyse der Abwesenheiten gemacht, wobei für diese Auswertung nur die Abstimmungen seit Legislaturbeginn  verwendet wurden. Drei aktuelle Fragen standen im Raum: Einerseits forderte eine Politikerin der Grünen ein Stellvertretermodell für Abwesenheiten, andererseits waren die Abwesenheiten der Bürgerlichen Thema eines selbstkritischen Kommentars von Eric Sarasin in der baz. Drittens kamen in letzter Zeit immer wieder Geschäfte durch, die eigentlich in einem Parlament mit bürgerlicher Mehrheit keine Chance haben sollten, etwa die Wiedereingliederung von ehemaligem Staatspersonal.

Der Hauptfokus lag daher bei der Frage: Gibt es Abstimmungen, bei denen die rot-grüne Ratsminderheit (48 von 100 Stimmen) ein Absolutes Mehr hat, weil bei den politischen Gegnern zu viele Politiker abwesend sind?

Die bereits in einer mysql-Datenbank abgespeicherten, aus PDFs eingelesenen Daten musste ich dafür zunächst auf Abwesenheiten durchforsten und diese den jeweiligen Gruppen $rotgruen / $rest zuordnen. Der dazugehörige Code:

//neu hinzugefügt für detaillierte Abwesenheits-Analyse 16.11.2017

if(strlen($name)> 3 && strlen($name) < 30 && $partei != ""){
 //Gesamt-Array füllen pro Geschäft ["FDP"]["A"] = 5
 $Abwesenheitsanalyse_Parteiverhalten[$partei][$resultatpartei]++;
 if($resultatpartei == "A") {
  $Abwesenheitsanalyse_partei[$partei]["abwesend"]++;
 }
 $Abwesenheitsanalyse_partei[$partei]["total"]++;
}

Im aktuellen Grossen Rat hat rot-grün das Absolute Mehr, wenn vier oder mehr Bürgerliche mehr als rot-grüne Politiker abwesend sind; bei drei Abwesenden mehr auf bürgerlicher Seite ist das Stimmverhältnis unentschieden -> der Grossratspräsident entscheidet (und der ist von der SVP). Die Abfrage, etwas kompliziert, dazu lautet:

$buergerliche_total = $Abwesenheitsanalyse_Parteiverhalten["FDP"]["A"]+$Abwesenheitsanalyse_Parteiverhalten["LDP"]["A"]+$Abwesenheitsanalyse_Parteiverhalten["CVP/EVP"]["A"]+$Abwesenheitsanalyse_Parteiverhalten["fraktionslos"]["A"]+$Abwesenheitsanalyse_Parteiverhalten["SVP"]["A"];

$rotgruen_total = $Abwesenheitsanalyse_Parteiverhalten["SP"]["A"]+$Abwesenheitsanalyse_Parteiverhalten["GB"]["A"];

if($buergerliche_total >= ($rotgruen_total+4)){

Die Ausgabe ist eine Liste aller Abstimmungen, bei denen rot-grün das Absolute Mehr hatte. Dabei ergab sich ein Wert von 26,4 Prozent – also knapp mehr als ein Viertel der Abstimmungen, bei der die Rats-Minderheit das Absolute Mehr hatte. Der Artikel dazu online und im Print: 8_0305_1033_v0_MLZBZFMAN021.

 

Basler Wohnquartiere als D3.js-Cartogram

Wie kann die unterschiedliche Dichte von Quartieren dargestellt werden? Mit einer verzerrten Karte, analog dieses Beispiels:

(c) http://www.esri.com/news/arcuser/0110/cartograms.html
(c) http://www.esri.com/news/arcuser/0110/cartograms.html

Doch wie heissen solche Karten und wie werden sie hergestellt? Twitter hilft, genauer @phwampfler.

Nach einiger Recherche bin ich auf diese verzerrte Berner Kantonskarte gestossen und das Ziel war klar: Dieselbe Karte mit den Basler Wohnquartieren nachbauen. Das Vorgehen (theoretisch):

Wohnquartiere im Geodatenshop von bs.ch als Shapefile beziehen. GeodatenBS_20170403_1151 Das Shapefile zunächst als .geojson und dann als .topojson umwandeln. Das Resultat hier: basel.topojson

Nur hat das leider nicht funktioniert und ich brauchte die Hilfe von Lukas Vonlanthen, der die Berner Kantonskarte programmiert hat. Er hat mir das benötigte, fehlerfrei funktionierende .topojson-File erstellt und schreibt dazu:

Ich habe das .shp in QGIS geöffnet, den Layer als Geojson abgespeichert und dann in Topojson konvertiert:
topojson -o basel.topojson -p –bbox — basel.geojson

Die fehlerfrei funktionierende Datei sieht so aus: baseltopojson.zip.

Das Einarbeiten der neuen Werte war problemlos, die .csv-Datei sieht folgendermassen aus:

ID,Name,PLZ,Location,Population 
01,Altstadt Grossbasel,,,50

Die angezeigte Grafik lässt sich als .svg-Datei abspeichern und weiterbearbeiten, beispielsweise für eine Illustration in der Zeitung. Dazu in Google Chrome die dev-tools öffnen und den svg-tag „als HTML“ bearbeiten. Den entsprechenden Code in einem Texteditor mit folgender Deklaration ergänzen (<svg… ersetzen) und als .svg-Datei abspeichern.

<?xml version="1.0" encoding="UTF-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1">

Die interaktive Version findet ihr hier, die grafische Umsetzung in der bz Basel als pdf hier. Ein animiertes gif (erstellt mit licecap 30 Frames / Sekunde) hier:

cartogram der Basler Quartiere anhand der Bruttogeschossfläche

(Das animierte Gif habe ich mit licecap hergestellt mi5 30 Frames / Sekunde).

Der erste Ajax-Scraper oder: Wer ist der Medienstar im Zolli?

Der Zoo Basel hat eine Website, die intensiv auf asynchrones Javascript (Ajax) setzt – sich die anzuzeigenden Daten also erst nachträglich holt. Gleichzeitig verfügt der Zoo über einen umfangreichen Katalog der Tierarten mit jeweiligen News-Meldungen dazu. Man könnte also die Frage beantworten, welche Tierart der Medienstar ist unter den Zolli-Bewohnern und welche armen Geschöpfe noch nie mit einer News-Mitteilung beehrt worden sind.

Mit normalen Scrapern können Ajax-Inhalte nicht eingelesen werden. Ein stackoverflow-Nutzer beschreibt das so, dass die Seite gerendert werden muss und die meisten Server-Side-Scripts dies nicht tun. Er verweist auf Alternativen und auf einen manuellen Weg, der gar nicht so kompliziert ist und beim Zolli-Scraper zielführend.

Mittels Chrome Dev Tools habe ich (wie im verlinkten Beitrag beschrieben) die relevante Datei gefunden, deren URL schaut so aus:

http://www.zoobasel.ch/dmxDatabaseSources/view_news_tiere_url.php?TiereID=290

Dort liegen die Daten als JSON-Datei vor, was eine Weiterverarbeitung mit PHP sehr einfach macht. Eine zweite JSON-Datei führt die ID-Nummern der Tiere auf, das Script hat diese einzeln abgearbeitet (mittels Javascript-Reload und Übergabe einer Variabel*) und alle Infos in eine Datenbank geschrieben. Die Auswertung sah folgendermassen aus:

$alleeintraege = $wpdb->get_results( "SELECT * FROM zolli");
foreach ( $alleeintraege as $eintrag ) {
 $anzahlnachname[$eintrag->tierartname]++;
 $anzahlnachtiergruppe[$eintrag->tiergruppe]++;
 $tierartenmitnews[] = $eintrag->tierartname;
}

arsort($anzahlnachname);
foreach ($anzahlnachname as $key => $val) {
 echo $key." (".$val.")<br/>";
}

$armeschlucker = array_diff($alletierarten, $tierartenmitnews);

var_dump($anzahlnachtiergruppe);

var_dump($armeschlucker );

Das Resultat ist in unstrukturierter Form auf http://samuelhufschmid.ch/zolli/ zu finden. Und noch ein Wort zu den Ergebnissen: Mit 31 Mitteilungen sind die Gorillas die Medien-Stars des Basler Zollis – mehr dazu hier:

*diese Vorgehensweise habe ich bereits beschrieben, jedoch noch nicht veröffentlicht, weil die dazugehörige Recherche noch nicht abgeschlossen ist.

 

Abstimmungsverhalten im Grossen Rat anhand von 1502 PDF-Dokumenten

Der Basler Wahlkampf 2016 ist geprägt vom Bürgerlichen Schulterschluss mit der SVP. Die Bürgerlichen betonen, dass die Basler SVP nichts mit den nationalen Entgleisungen ihrer Mutterpartei zu tun habe – die Linken behaupten das Gegenteil. Unter dem Titel „Fakten statt Wahlversprechen“ habe ich am 19. September eine datenjournalistische Analyse des Wahlverhaltens der Parteien im Grossen Rat seit 2013 publiziert. Dafür habe ich die 1502 Abstimmungen der laufenden Legislatur analysiert. Als Gedankenstütze für mich, als Inspiration für Kollegen sowie für Transparenz gegenüber Lesern und Politikern veröffentliche ich hier die Anleitung:

Wie alle meine datenjournalistischen Projekte in letzter Zeit habe ich den Website-Scraper mit PHP als WordPress-Template erstellt. Das erleichtert den Umgang mit Datenbanken enorm, zudem können kleinere Anpassungen via Web-Backend von WordPress gemacht werden, was praktisch ist, wenn am Büro-Computer keine Programme installiert werden können.

Die Rohdaten liegen auf dem Server von bs.ch, geordnet nach Amtsjahr. Mit einem Loop (Jahr+1) und den beiden PHP-Libraries scraperwiki.php und simple_html_dom.php habe ich die URLS der einzelnen PDF-Dateien (hier ein Beispiel) erzeugt. Eine grosse Hilfe beim Umwandeln der PDF-Dateien in Text war der Smalot PDF-Parser. Den ganzen Text-Inhalt der PDFs habe ich danach in eine MySQL-Datei abgespeichert; das brachte viel Zeitersparnis, denn während das Herunterladen und Umwandeln der PDFs rund fünf Sekunden pro Dokument und entsprechend für alle Dokumente mehr als eine Stunde dauert, geschieht die Analyse aus der MySQL-Datenbank in Sekundenbruchteilen, auch wenn darin der ganze Text-Inhalt gespeichert ist.

Der Quellcode fürs Einlesen der PDF-Inhalte in die Datenbank lautet:

/*Links zu pdf-Dateien in Datenbank

require_once($_SERVER['DOCUMENT_ROOT'].'/wp-content/themes/twentysixteen/inc/scraperwiki.php');
    require_once($_SERVER['DOCUMENT_ROOT'].'/wp-content/themes/twentysixteen/inc/simple_html_dom.php');

set_time_limit(30000);

//dieser teil geht die website grosserrat.bs.ch durch und genertiert einträge in die datenbank gr_import
$jahr = 2013;
while($jahr<2017){//muss 2017 sein!
    $jahrplus1 = $jahr+1;
    $url = "http://abstimmungen.grosserrat-basel.ch/index_archiv2_v2.php?path=archiv/Amtsjahr_".$jahr."-".$jahrplus1;
    $jahr++;

    $html_content = scraperwiki::scrape($url);
    $html = str_get_html($html_content);

    $i = 0;

    foreach ($html->find("a") as $el) {
        if($i > 1){
            $suburl = "http://abstimmungen.grosserrat-basel.ch/".$el->href;
            $subhtml_content = scraperwiki::scrape($suburl);
            $subhtml = str_get_html($subhtml_content);

            $subi = 0;
            foreach($subhtml->find("a") as $subel){
                if($subi > 3    ){

                    //pdf holen und als text in die db
                    include 'autoload.php';

                    // Parse pdf file and build necessary objects.
                    $parser = new \Smalot\PdfParser\Parser();
                    $pdf    = $parser->parseFile("http://abstimmungen.grosserrat-basel.ch/".$subel->href);

                    $text = $pdf->getText();

                    $wpdb->insert(
                        'gr_import',
                        array(
                            'url' => "http://abstimmungen.grosserrat-basel.ch/".$subel->href,
                            'content' => $text
                        ),
                        array(
                            '%s',
                            '%s'
                        )
                    );
                    $durchzaehler++;
                    echo $durchzaehler."<br/>";

                }
                $subi++;

            }
        }
        $i++;

        //echo $suburl."<br>";
    }
}

*/

Für die Analyse der einzelnen PDFs habe ich, kurz zusammengefasst, zwei Durchgänge gemacht. Beim ersten Durchgang habe ich anhand der einzelnen Grossräte das Ergebnis der einzelnen Fraktionen gezählt. Dies wäre zwar auch im PDF erfasst, jedoch schwieriger auslesbar als die einzelnen Stimmen der Grossräte. Zudem lässt sich mit der gewählten Methode auch das Abstimmungsverhalten der einzelnen Grossräte (oder weitere Verhaltensweisen wie das Abwesenheitsverhalten) eruieren. Die Resultete der Fraktionen habe ich in einem mehrdimensionalen Array gespeichert (php gibt zwar einen Fehler aus, lässt dies aber zu – für mich eine super Hilfe beim Analysieren solcher Daten)

$parteien_hilfsarray["N"][$partei]++;

Danach als Doppel-Loop analysiert, welche Fraktionen einander widersprochen haben:

foreach($fraktionen as $fraktion2){
    //doppel-loop für matrix
    //=wenn die aktuelle partei (erster loop) "N" gesagt hat
    if ($parteien_hilfsarray["N"][$fraktion] > $parteien_hilfsarray["J"][$fraktion]) {
        //=wenn die zweite-loop-partei "J" gesagt hat

        if ($parteien_hilfsarray["J"][$fraktion2] > $parteien_hilfsarray["N"][$fraktion2]) {
            //dann "umstritten" zwischen diesen zwei parteien Nein - Ja
            $parteienmatrix[$fraktion][$fraktion2]++;
        }
    }
}

Und dies schliesslich ausgegeben:

echo "<table border=1><tr><td>Uneinigkeit</td>";
foreach($fraktionen as $fraktion){
    echo "<td>".$fraktion."</td>";
}
echo "</tr>";
foreach($fraktionen as $fraktion){
    echo "<tr><td>".$fraktion."</td>";
    foreach($fraktionen as $fraktion2){
            $parteienabweichung = $parteienmatrix[$fraktion][$fraktion2]+$parteienmatrix[$fraktion2][$fraktion];
        echo "<td>".$parteienabweichung."</td>";
    }
    echo "</tr>";

}
echo "</table>";

Was folgende Ausgabe ergibt:

Uneinigkeit CVP/EVP SP LDP FDP SVP GB GLP
CVP/EVP 0 450 200 207 338 504 210
SP 450 0 548 573 712 144 384
LDP 200 548 0 139 251 617 277
FDP 207 573 139 0 234 618 266
SVP 338 712 251 234 0 745 385
GB 504 144 617 618 745 0 417
GLP 210 384 277 266 385 417 0

aufgearbeitet für Online:

und für Print:
cssnqtvweaahcls

Erste Erfahrungen mit Instant Articles

Facebook hat vor einigen Wochen „Instant Articles“ für die Allgemeinheit geöffnet. Es ist schwierig abzuschätzen, wie gross der Einfluss von IA auf die Medienlandschaft allgemein und spezifisch in der Schweiz sein wird, aber es ist gut vorstellbar, dass es ziemlich heftig werden könnte. Wenn man beobachtet, wie unverfroren Facebook derzeit Live-Videos pusht, dann ist der Konzern eine Algorithmus-Anpassung davon entfernt, dasselbe mit den „Instant Articles“ zu tun – diese also im Gegensatz zu normalen Links massiv zu bevorzugen. Und wenn man dann noch das tatsächlich bereits jetzt unvergleichliche Nutzererlebnis beim Klick auf einen IA-Link dazurechnet, dann…

…dann lohnt es sich definitiv, als Medienunternehmen, aber auch als digital interessierter Journalist, einen Blick auf „Instant Articles“ zu werfen und erste Erfahrungen zu sammeln.

Aus dieser Überlegung habe ich vor einem guten Monat meine Website „Instant Articles“-ready gemacht und einige Artikel für das „IA“-Review bei Facebook angemeldet. Ich veröffentliche hier bei Interesse gerne eine Anleitung, wie das für eine Wordpress-Site funktioniert.

Mittlerweile konnte ich erste Erfahrungen sammeln, und es zeigt sich, dass „Instant Articles“ vom Algorithmus (noch) nicht deutlich bevorzugt werden, aber dass sie besser gelesen, geliked und geteilt werden als Website-Links. Dies lässt sich auch an ersten Zahlen ablesen, wobei die Datengrundlage natürlich zu dünn ist, um verlässliche Angaben machen zu können.

Dazu ein Vergleich zweier Posts und die dazugehörigen Zahlen:

Normaler Link:


Reichweite: 556 | Reaktionen: 3 | Klicks: 26

Instant-Article-Link:


Reichweite: 1038 | Reaktionen: 7 | Klicks: 120

Fazit: Instant-Articles bringen doppelte Reichweite, mehr Interaktionen und ein massiver Anstieg bei den Klicks. Natürlich ist die Datengrundlage, wie bereits erwähnt, äusserst dünn – aber zumindest die Zahlen für den Post mit dem normalen Link entsprechen dem Durchschnitt meiner Facebook-Seite. Habt ihr auch schon Erfahrungen mit Instant-Articles gesammelt? Und wie schätzt ihr deren Bedeutung für die Medienunternehmen ein? Über eine Diskussion in den Kommentaren würde ich mich freuen.