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.

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

Redezeit im Grossen Rat

Ziemlich tricky war die Ermittlung der Redezeiten von Politquerulant Eric Weber und anderen Plappermäulern im Basler Kantonsparlament. Unter http://protokolle.grosserrat-basel.ch/ sind zwar alle Wortmeldungen als mp3-Files abgelegt, die Indizierung brachte aber einiges an Arbeit mit sich. Ich habe es folgendermassen gelöst (ein php-Wordpress-Template):

loop durch die Startseite, jeder Link wird gescraped (als /script/tocTab.js, eine Javascript-Datei)

Jede Linie des Files wird nach dem Namen durchsucht, wenn gefunden, wird eine URL auf die mp3-Datei im Browser ausgegeben.

Diese URLs habe ich dann in jdownloader kopiert und in ein Verzeichnis heruntergeladen. Als Wiedergabeliste in einem Audio-Player ergibt sich dann die jeweilige Total-Dauer. Um alle Files in ein einziges mp3 zusammenzufassen, wie ich es für Weber gemacht habe, war das Tool mp3wrap von grosser Hilfe. Per Eingabeaufforderung akzeptiert dieses Tool bis zu 100 mp3-Filenamen (erstellt aus der ursprünglichen Link-Liste und kopiert) und fügt diese zu einer einzelnen Datei zusammen.

Ein unerwartetes Problem bot sich beim Hochladen des Mega-MP3 (17 Stunden, 900 Megabytes): Soundcloud akzeptierte das File auch im Pro-Account, sodass nur eine Einbindung via FTP und HTML blieb – so wie hier:

<audio controls>
 <source src="http://www.samuelhufschmid.ch/weber_MP3WRAP.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>

Network Graph FC Basel 2015/16

Network Graph anhand aller Assist-Scorer-Beziehungen in der Saison 2015/16. Daten via sfl.ch (php-Scraper), Darstellung: Google Fusion Tables. Pfeilrichtung = Passrichtung (wobei oranger Pfeil bei „mehr gespielte als erhaltene Assists“, blauer Pfeil bei „mehr erhaltene als gespielte Assists“.

Todo: Grösse Punkte = Anzahl Assists/Tore statt Anzahl Verknüpfungen (wie aktuell).

Durchsuchbare Liste mit Angular

Die Basellandschaftliche Zeitung hat auf einer ganzen Seite sämtliche Gymnasiasten aufgelistet, die 2014 die Matur bestanden haben. Um dem Leser, der sich nur für einzelne Namen von ihm bekannten Personen interessiert, habe ich mittels AngularJS eine durchsuchbare Online-Version gemacht.

Zunächst mussten die Daten mittels php-Script in eine JSON-Datei umgewandelt werden. Der Quellcode dazu:

<?php
header('Content-Type: application/json');
$output = array();
$gymarray = array();
$schuelerarray = array();
$arr = array(" ---- hier die Daten eingefügt, als Array-Elemente und die Gymnasien mittels || abgetrennt");

foreach($arr as $code) {
    $gym = explode("||",$code);
    //für jedes gymnasium ist der name des gyms als $gym[0] und der ganze rest als $gym[1] verfügbar
    $allesaussergym = explode("–",$gym[1]);
    //jedes Profil ist ein Element in $allesaussergym
    foreach($allesaussergym as $profilundschueler) {
        //durchläuft jedes Profil einzeln
        $aufgeteilt = explode(":",$profilundschueler);
            //der name des Profils ist als $aufgeteilt[0] verfügbar, alle Schüler als $aufgeteilt[1]
            $schueler = explode(";",$aufgeteilt[1]);
            foreach($schueler as $einzelnerschueler){
                //geht alle schüler eines profils durch, jeweils inkl. ort
                $schuelerarray[] = $einzelnerschueler;
                //erweitert das schuelerarray
            }
            $profilarraymitschueler = array($aufgeteilt[0],$schuelerarray);
            //ist ein array für jedes profil, mit [0] = profilname und [1] = array mit den Schülern
            $schuelerarray = [];

            $alleschuelereinesprofils[] = $profilarraymitschueler;
            //konstruiert ein array, das alle profile und schüler enthält
            $profilarraymitschueler = [];
    }
    $gymarray = array($gym[0], $alleschuelereinesprofils);
    $alleschuelereinesprofils = [];
$output[] = $gymarray;
}
$fp = fopen('maturanden.json', 'w');
fwrite($fp, json_encode($output, JSON_UNESCAPED_UNICODE));
fclose($fp);
?>

Das Script baut ein mehrdimensionales Array auf, das danach als JSON-File abgespeichert wird. Dieses wird danach in der AngularJS-Seite eingelesen. Der Quellcode dazu:

<html>
<head>
    <title>Maturanden 2014 BL</title>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.8/angular.min.js"></script>

    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- Bootstrap -->
    <script src="js.js"></script>
    <link href="bootstrap/css/bootstrap.min.css" rel="stylesheet" media="screen">

</head>
<body ng-app="MaturandenApp" ng-controller="MaturandenCtrl">
Search: <input ng-model="searchText">
    <ul>
        <li ng-repeat='gymnasien in maturanden' ng-show="(gymnasien[1] | filter:searchText).length">
            {{gymnasien[0]}}
            <ul>
                <li ng-repeat='profile in gymnasien[1]' ng-show="(profile | filter:searchText).length">{{profile[0]}}
                    <ul>
                        <li ng-repeat='schueler in profile[1] | filter:searchText'>{{schueler}}</li>

                    </ul>
                </li>
            </ul>
        </li>
    </ul>

</body>

---> + die dazugehörige .js-Datei:
var app = angular.module("MaturandenApp", []);

app.controller("MaturandenCtrl", function($scope, $http) {
    $http.get('maturanden.json').
        success(function(data, status, headers, config) {
            $scope.maturanden = data;
        }).
        error(function(data, status, headers, config) {
            // log error
        });

});

Die grösste Schwierigkeit war, nach einer Suche die Typen / Gymnasien auszublenden, wenn nach der Suche keine Schüler mit entsprechendem Namen aufgelistet werden. Dazu habe ich einen Filter auf das nächstfolgende Array angewendet und die Länge (.length) verwendet. Ist dieser = 0, wird ng-show = false und der Titel wird ausgeblendet.