Foto CC BY-ND 2.0 von Deutsche Post DHL auf Flickr
Eine datenjournalistische Recherche gleicht einer Schnitzeljagd. Nicht immer sind die gewünschten Daten-Quellen erschlossen – und so ist viel Knobelarbeit gefragt. Das folgende Beispiel zeigt den Versuch, die Filial-Datenbank der Webseite der Deutschen Post zu scrapen. Ein Hinweis an die Profi-Coder: Ich bin Journalist und kein Programmierer – die Ruby-Scripts musste ich mir alle selbst mühsam erarbeiten. Ich freue mich deshalb über Anregungen und Vorschläge.
Der Wunsch: Ein Karte mit allen Filialen der Post.
Der Lösungsansatz: Inhalte der Web-Filialsuche http://standorte.deutschepost.de/Standortsuche scrapen und verarbeiten
1. Erkenntnis: URL enthält Platzhalter für Postleitzahlen
http://standorte.deutschepost.de/Standortsuche?postleitzahl=22765&standorttyp=filialen_verkaufspunkte&lang=de&original_entered_city=Hamburg&ort=Hamburg&period=0 |
Idee: Script, das alle möglichen (Postleit-)Zahlen von 0 bis 99999 aufruft und abfragt
Das Werkzeug: Ruby
Vorlage: Pro Publica Pfizer script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| # Call Ruby's OpenURI module which gives us a method to 'open' a specified webpage
require 'open-uri'
# This is the basic address of Pfizer's all-inclusive list. Adding on the iPageNo parameter will get us from page to page.
BASE_LIST_URL = 'http://www.pfizer.com/responsibility/working_with_hcp/payments_report.jsp?enPdNm=All&iPageNo='
# We found this by looking at Pfizer's listing
LAST_PAGE_NUMBER = 485
# create a subdirectory called 'pfizer-list-pages'
LIST_PAGES_SUBDIR = 'pfizer-list-pages'
Dir.mkdir(LIST_PAGES_SUBDIR) unless File.exists?(LIST_PAGES_SUBDIR)
# So, from 1 to 485, we'll open the same address on Pfizer's site, but change the last number
for page_number in 1..LAST_PAGE_NUMBER
page = open("#{BASE_LIST_URL}#{page_number}")
# create a new file into to which we copy the webpage contents
# and then write the contents of the downloaded page (with the readlines method) to this
# new file on our hard drive
file = File.open("#{LIST_PAGES_SUBDIR}/pfizer-list-page-#{page_number}.html", 'w')
# write to this new html file
file.write(page.readlines)
# close the file
file.close
# the previous three commands could be condensed to:
# File.open("#{LIST_PAGES_SUBDIR}/pfizer-list-page-#{page_number}.html", 'w'){|f| f.write(page.readlines)}
puts "Copied page #{page_number}"
# wait 4 seconds before getting the next page, to not overburden the website.
sleep 4
end |
Das von mir angepasste Ruby-Script: ddj1.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| require 'open-uri'
BASE_LIST_URL = 'http://standorte.deutschepost.de/Standortsuche?postleitzahl='
BASE_LIST_URL2 = '&standorttyp=filialen_verkaufspunkte&lang=de&period=0'
LAST_PAGE_NUMBER = 99999
LIST_PAGES_SUBDIR = 'post1'
Dir.mkdir(LIST_PAGES_SUBDIR) unless File.exists?(LIST_PAGES_SUBDIR)
for page_number in 0..LAST_PAGE_NUMBER page_number2 = page_number*1 page = open("#{BASE_LIST_URL}#{page_number2}#{BASE_LIST_URL2}")
file = File.open("#{LIST_PAGES_SUBDIR}/post2-list-page-#{page_number2}.html", 'w')
file.write(page.readlines)
file.close
puts "Copied page #{page_number2}"
sleep (4) end |
Probleme:
- Server Time-out
- zu viel Abfall aus nichtvorhandenen PLZs
- einzelne Datei für jede PLZ
Idee: Nur die tatsächlichen Postleitzahlen abfragen. Wir brauchen also eine Liste mit allen Postleitzahlen
Lösung: Wikipedia-Seite > Link Uni Paderborn > plzdat.gz
ftp://ftp.uni-paderborn.de/pub/doc/PLZ-Database/
Angepasstes Ruby Script: Externes Array von Postleitzahlen / Nokogiri / speichert in eine Datei
dd2.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| require 'nokogiri'
require 'open-uri'
# Setzt richtige Zeichenkodierung ein
class String
def astrip
self.gsub(/([\302|\240|\s|\n|\t])|(\ ?){1,}/, ' ').strip
end
end
# Array für Links
all_links = []
# Externes Array mit den Postleitzahlen (gespeichert in Text-Datei)
postpages = open('postleitzahlen.txt').map
# Erstellt Unterverzeichnis (falls noch nicht vorhanden)
Dir.mkdir('post2') unless File.exists?('post2')
# Get a Nokogiri::HTML:Document for the page we’re interested in...
postpages.each do |postpage|
doc = Nokogiri::HTML(open("http://standorte.deutschepost.de/Standortsuche?standorttyp=filialen_verkaufspunkte&lang=de&period=0&postleitzahl=#{postpage}"))
# Mit Firebug wird identifiziert, wie die CSS-Klasse/DIV benannt ist. Nokogiri speichert dann nur das ab.
doc.css('td.searchlist_left').each do |link|
# schreibt Datei – Argument 'a' sorgt dafür, dass neue Informationen am Ende der Datei angefügt werden
File.open("post2/plz2.txt", 'a'){|f| f.write link}
end
sleep 4
puts "Completed #{postpage}"
end |
Die txt-Datei kann in Notepad++ mit ein paar Makros gesäubert werden:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| Column 1,Column 2,Column 4
FILIALE;Bismarckstr. 51,40210 Düsseldorf
VERKAUFSPUNKT;Stresemannstr. 22,40210 Düsseldorf
FILIALE;Konrad-Adenauer-Platz 1,40210 Düsseldorf
FILIALE;Liesegangstr. 24,40211 Düsseldorf
VERKAUFSPUNKT;Klosterstr. 120,40211 Düsseldorf
FILIALE;Berliner Allee 52,40212 Düsseldorf
FILIALE;Graf-Adolf-Str. 20,40212 Düsseldorf
VERKAUFSPUNKT;Worringer Platz 3,40210 Düsseldorf
VERKAUFSPUNKT;Am Wehrhahn 1,40211 Düsseldorf
VERKAUFSPUNKT;Königsallee 12a,40212 Düsseldorf
VERKAUFSPUNKT;Königsallee 1-9,40212 Düsseldorf
VERKAUFSPUNKT;Am Wehrhahn 55,40211 Düsseldorf
FILIALE;Heinrich-Heine-Allee 22,40213 Düsseldorf
FILIALE;Friedrichstr. 29,40217 Düsseldorf
FILIALE;Pempelforter Str. 8,40211 Düsseldorf
FILIALE;Morsestr. 5,40215 Düsseldorf
VERKAUFSPUNKT;Sonnenstr. 19,40227 Düsseldorf
FILIALE;Birkenstr. 37,40233 Düsseldorf
VERKAUFSPUNKT;Corneliusstr. 85,40215 Düsseldorf
FILIALE;Mettmanner Str. 54,40233 Düsseldorf
VERKAUFSPUNKT;Oberbilker Allee 47,40223 Düsseldorf
VERKAUFSPUNKT;Kölner Str. 186-188,40227 Düsseldorf
FILIALE;Josefstr. 25,40227 Düsseldorf
FILIALE;Fürstenwall 126,40217 Düsseldorf
VERKAUFSPUNKT;Birkenstr. 77,40233 Düsseldorf
VERKAUFSPUNKT;Kölner Str. 216a,40227 Düsseldorf |
Das Resultat auf Fusion Tables:
https://www.google.com/fusiontables/DataSource?snapid=S594020TiS5
Problem:
Nicht jede Filiale ist eine „echte“ Filiale. In den vergangenen Jahren sind Postfilialen geschlossen worden und Einzelhändler und Kioske haben diese Aufgaben teilweise übernommen.
Idee: Detailansicht auf der Postseite zeigt evtl. den Zusatz („im Einzelhandel“)
Frage: Wie lassen sich alle Detailansichten scrapen?
Erkenntnis: URL der Detailansicht offenbart weitere Informationen.
1
| http://standorte.deutschepost.de/Standortsuche?postleitzahl=22765&standorttyp=filialen_verkaufspunkte&original_entered_city=Hamburg&ort=Hamburg&period=0&postleitzahl=22765&ort=Hamburg&standorttyp=filialen_verkaufspunkte&original_entered_city=Hamburg&period=0&lang=de&objecttype=branch&objectid=4090637&lang=de&markernumber=filiale_1&zoomlevel=16&pagenumber=1&userlat=53.5521244030599&printtype=locationsearch_print&userlng=9.93362990006599&sde=14 |
Hoffnung: Es gibt offenbar für jede Filiale eine Objekt-ID und vielleicht auch Lat/Long-Geodaten
Idee: Ruby-Script, das alle IDs durchforstet und abspeichert
ddj3.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| require 'nokogiri'
require 'open-uri'
all_links = []
# Erstellt Unterverzeichnis (falls noch nicht vorhanden)
Dir.mkdir('post3') unless File.exists?('post3')
# Get a Nokogiri::HTML:Document for the page we’re interested in...
for postpage in 70000..80000
doc = Nokogiri::HTML(open("http://standorte.deutschepost.de/Standortsuche?timestamp=1342713296827&visitorid=kpai8c56&do_search=1&standorttyp=filialen_verkaufspunkte&advert_pos=bottom&lang=de&objecttype=branch&lang=de&markernumber=filiale_1&zoomlevel=15&pagenumber=1&userlat=51.9548197268423&printtype=locationsearch_print&userlng=7.62617631351197&objectid=40#{postpage}"))
doc.css('div#info_adress').each do |link|
#speichert Namen und die Adresse der Filiale
File.open("post3/70001a.txt", 'a'){|f| f.write postpage}
#speichert letzte Seite, falls Script abbricht
File.open("post3/70001b.txt", 'a'){|f| f.write link}
end
sleep 2
puts "Completed #{postpage}"
end |
Ergebnis wird „geputzt“ und als CSV abgespeichert. Eine Extra-Spalte für den Fusion-Table-Marker wird angelegt und via Makro nach Filialtyp zugewiesen:
1
2
3
4
5
6
| Typ,Firma,Adresse,Marker Item
Verkaufspunkt für Brief- / Paketmarken,radiant - Gebr. Husmann oHG,"Kirchweyher Str. 2b,28844 Weyhe",small_green
Verkaufspunkt für Brief- / Paketmarken,PWZ Presse Shop,"Karpendiek 3,23970 Kritzow",small_green
Verkaufspunkt für Brief- / Paketmarken,Bernd Lämmermühle,"Tabbenstr. 7,49624 Löningen",small_green
Postfiliale (im Einzelhandel),No. 5 Pradel GmbH & Co. KG,"Erlabrunner Str. 36,97276 Margetshöchheim",small_yellow
Verkaufspunkt für Brief- / Paketmarken,Bahnhofsbuchhandlung F.Wittmann,"Bahnhofplatz 1,83435 Bad Reichenhall",small_green |
Ergebnis: Auf Fusion Tables sieht es dann so aus:
https://www.google.com/fusiontables/DataSource?snapid=S594029DStr
Probleme:
- Objekt-ID ist 7-stellig und nicht genau eingegrenzt: Scraping dauert ewig und ist evtl nicht komplett
- Geocoding dauert lange, ist Fehleranfällig
Idee: Nochmal ein genauerer Blick auf den Quelltext der Übersichtseite
Erkenntnis: Im Javascript-Header finden sich interessante Zeilen mit Objekt-ID und Position:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| PF.map.prePins.push({latlng: new VELatLong(53.979474830410325,10.360980327416417), iconnumber:0, objecttype:'branch', objectid:4090737, contentWidth:0, positionType:'', imagename:'filiale', resultId:0});
PF.map.prePins.push({latlng: new VELatLong(53.979615230410424,10.359535927416308), iconnumber:0, objecttype:'branch', objectid:4083244, contentWidth:0, positionType:'', imagename:'vk', resultId:1});
PF.map.prePins.push({latlng: new VELatLong(53.979631835355725,10.359455787173127), iconnumber:0, objecttype:'branch', objectid:4068748, contentWidth:0, positionType:'', imagename:'vk', resultId:2});
PF.map.prePins.push({latlng: new VELatLong(53.97893218468493,10.361084393889197), iconnumber:0, objecttype:'branch', objectid:4084055, contentWidth:0, positionType:'', imagename:'vk', resultId:3});
PF.map.prePins.push({latlng: new VELatLong(53.97844236330363,10.367625850562197), iconnumber:0, objecttype:'branch', objectid:3291567, contentWidth:0, positionType:'', imagename:'filiale', resultId:4});
PF.map.prePins.push({latlng: new VELatLong(53.98068536875552,10.355508925478247), iconnumber:0, objecttype:'branch', objectid:257167, contentWidth:0, positionType:'', imagename:'filiale', resultId:5});
PF.map.prePins.push({latlng: new VELatLong(53.97908163041033,10.370229427417168), iconnumber:0, objecttype:'branch', objectid:4092092, contentWidth:0, positionType:'', imagename:'vk', resultId:6});
PF.map.prePins.push({latlng: new VELatLong(53.987346630412326,10.375642027417667), iconnumber:0, objecttype:'branch', objectid:4089387, contentWidth:0, positionType:'', imagename:'filiale', resultId:7});
PF.map.prePins.push({latlng: new VELatLong(53.98812953041253,10.375245027417648), iconnumber:0, objecttype:'branch', objectid:4088603, contentWidth:0, positionType:'', imagename:'vk', resultId:8});
PF.map.prePins.push({latlng: new VELatLong(53.989304119555825,10.376350309162127), iconnumber:0, objecttype:'branch', objectid:4084077, contentWidth:0, positionType:'', imagename:'vk', resultId:9}); |
Idee: Ruby-Script, das von allen Postleitzahl-Seiten die Javascript-Header ausliest
ddj4.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| require 'nokogiri'
require 'open-uri'
# Setzt richtige Zeichenkodierung ein
class String
def astrip
self.gsub(/([\302|\240|\s|\n|\t])|(\ ?){1,}/, ' ').strip
end
end
# Array für Links
all_links = []
# Erstellt Unterverzeichnis (falls noch nicht vorhanden)
Dir.mkdir('post4') unless File.exists?('post4')
# Externes Array mit den Postleitzahlen (gespeichert in Text-Datei)
postpages = open('postleitzahlen.txt').map
# Get a Nokogiri::HTML:Document for the page we’re interested in...
postpages.each do |postpage|
doc = Nokogiri::HTML(open("http://standorte.deutschepost.de/Standortsuche?standorttyp=filialen_verkaufspunkte&lang=de&period=0&postleitzahl=#{postpage}"))
# Lädt den Javascript Header und speichert ihn am
doc.xpath('/html/head[//*[contains(text(), "PF.map.prePins.push")]]').each do |link|
File.open("post4/plz4.txt", 'a'){|f| f.write link}
end
sleep 4
puts "Completed #{postpage}"
end |
Das “geputzte” Ergebnis sieht wie folgt aus:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 50.9599813195;6.1226507128;4081088;post_office
50.9712877099;6.1134345271;4077823;post_office
50.9788853297;6.1407107169;4074360;post_office
50.9798320979;6.1356419293;4089970;post_office
50.9812356409;6.0758894722;4070412;post_office
50.988381727;6.1305285442;4077827;post_office
50.9933369826;6.186008294;4066232;small_yellow
50.9954168275;6.2849060194;4077719;post_office
50.9980580747;6.289733349;4078866;post_office
50.998329658;6.0929834893;4070416;post_office
51.0039864725;6.2413731409;4077703;post_office
51.0092162024;6.0563026952;4091269;post_office
51.0104309997;6.2031023111;4066236;small_yellow
51.0210804896;6.258467158;4077707;post_office
51.0263102195;6.0733967123;4091273;post_office |
Probleme:
- Positionen falsch
- IDs stimmen nicht
Erkenntnis: Erneuert Blick in den Javascript-Header offenbart:
1
2
3
4
5
6
7
8
9
| function convertPobj(pobj) {
if (typeof pobj!='undefined') {
if (typeof pobj.objectid!='undefined' ) {
pobj.objectid=pobj.objectid - 100;
pobj.latlng.Latitude-=0.42735042735042733;
pobj.latlng.Longitude-=0.42735042735042733;
}
}
} |
Diese Funktion sorgt dafür dass die Geo- und ID-Informationen der Liste verändert werden. Von den Object-IDs muss in diesem Fall der Wert 100 abgezogen werden, von den Lat/Long-Daten jeweils der Wert 0.42735042735042733.
Idee: Excel-Tabelle, die abweichende Position und ID errechnet
Ergebnis der Fusion Tables Karte:
https://www.google.com/fusiontables/DataSource?snapid=S594033Bf1F
Probleme:
- „Leere“ IDs
- Immer noch falsche Positionen
Erkenntnis: Javascript-Header haben unterschiedliche Abweichungswerte
Lösung: Ruby-Scipt, das Javascript-Header für jede Postleitzahl ausliest, die Abweichungswerte abspeichert. Im weiteren Schritt dann Test, ob ID existiert und auslesen der Detailinformationen (z.B. „im Einzelhandel“).
Stand der Dinge: Derzeit arbeite ich an diesem o.g. Script – wobei mittlerweile klar ist, dass es sich bei den IDs auch um Briefkästen und Packstationen handelt (vor allem die, die mit 40…. beginnen).