Wie in meinem Posting über das Web Crawling Framework “Scrapy” schon angekündigt, gibt es jetzt die hands-on Einführung anhand eines konkreten Beispiels. Da es sich um vergleichsweise langes und eher technisches Posting handelt, steigen wir direkt mit der ersten und ganz grundsätzlichen Frage ein: Was genau wollen wir erreichen?
Annahme: Die Redaktion eines Nachrichtenportals benötigt für einen Artikel über die Haushaltsschulden der deutschen Bundesländer eine HTML-Tabelle mit einer Liste aller Bundesländer und deren Schuldenstände.
Dieser Fall ist natürlich relativ trivial und wir könnten die Tabelle schnell manuell erstellen. Der Nachteil daran ist, dass die Daten innerhalb weniger Monate veralten würden und darüber hinaus manuell gepflegt werden müssten. Ein weiterer Nachteil ist, dass manuelles Abtippen nicht halb so viel Vergnügen bereitet wie die Erstellung einer automatischen Lösung, die jeder Zeit wieder verwendet und bei Bedarf weiter ausgebaut werden kann.
Als Datenquelle nutzen wir die Wikipedia. Es gibt natürlich Alternativen wie z. B. die Webseite des statistischen Bundesamtes, die sich in diesem Fall anbieten würden. Da die Wikipedia aber einen ganzen Haufen anderer Anwendungsfälle abdeckt, halte ich mich für dieses Beispiel an der wahrscheinlich am meisten gescrapten Webseite der Welt.
Weil wir möglichst effizient crawlen möchten und auf unserem Weg durch das HTML-Dickicht nicht unnötig viele Wikipedia-Seiten verarbeiten wollen, brauchen wir zunächst eine Übersicht über alle 16 Bundesländer mit Hyperlinks auf ihre jeweiligen Wikipedia-Seiten.
Dafür eignet sich am besten die Seite http://de.wikipedia.org/wiki/Bundesrepublik_Deutschland#Liste_der_L.C3.A4nder, die eine Liste aller Bundesländer enthält.
Um die Hyperlinks auf die dort aufgeführten Bundesländer sauber zu extrahieren, basteln wir uns einen XPath-Ausdruck zusammen. Ich mache das gerne mit dem Firefox Addon Firebug und der Firebug-Extension FirePath, da man so mit visuellem Feedback seine XPath-Ausdrücke zusammenbauen kann.
Das ganze schaut dann im Ergebnis so aus wie auf diesem Screenshot:
Auf dem Bild können wir den XPath-Ausdruck sehen, mit dem wir alle 16 Verweise innerhalb der Webseite selektieren können. Diesen Ausdruck speichern wir am besten in eine kleine Textdatei, um ihn später unserem Scrapy-Spider mitzugeben. Es lohnt sich eine kommentierte Sammlung von XPath-Ausdrücken anzulegen, da sie mitunter sehr komplex werden können.
Kleiner Hinweis: Die Bilder in diesem Posting können angeklickt werden, um in voller Größe (und Schärfe…) in einer Lightbox betrachtet zu werden.
Nachdem wir nun wissen wie wir mit einer Zeile XPath von der gewählten Startseite auf die 16 Unterseiten der einzelnen Bundesländer kommen, können wir uns um das Scraping des Schuldenstands kümmern. Dafür nutzen wir auch wieder FirePath und holen uns erst mal den Namen des jeweiligen Bundeslands.
Das ist einfach und sieht so aus:
Nach dieser kleinen Fingerübung fehlt unserem Scraper noch ein letzter Datensatz, damit wir später damit unsere Tabelle füllen können: Der Schuldenstand.
Und den holen wir uns so:
Wem die ominösen XPath-Ausdrücke Stirnrunzeln hervorrufen, der findet eine ganze Menge Lektüre zum Thema auf Amazon oder im Buchladen um die Ecke.
Nach dem wir die drei benötigten XPath-Ausdrücke erstellt haben, können wir uns jetzt um die Programmierung des Crawlers kümmern.
Im Folgenden gehe ich davon aus, dass die Installation von Scrapy (Howto) bereits erledigt worden ist. Außerdem lohnt sich auf jeden Fall das Durchspielen des gut gemachten Tutorials, das ein paar Details anschneidet, die dieses Blog posting nur streift. Vor allem die interaktive Nutzung von Scrapy mit der Scrapy shell ist sehr interessant und erleichtert das Verständnis wie Scrapy funktioniert.
Aber zurück zu unserem Wikipedia-Scraping. Als erstes generieren wir ein neues Scrapy Projekt. Wir wechseln dazu in ein Verzeichnis auf der Festplatte, in dem wir unsere Scrapy Projekte sammeln möchten. Das Anlegen unseres ersten Projekts passiert mit diesem Kommando:
scrapy startproject bundeslaender_fakten
Der Projektname “bundeslaender_fakten” kann frei gewählt werden. Wer eine bessere Idee als ich hat – was nicht schwer ist – der kann sich hier ausleben.
Das Kommandozeilen Interface (CLI) “scrapy” und sein Argument “startproject” haben jetzt einen Projektordner mit dem gewählten Projektnamen angelegt. Innerhalb dieses Ordners sind einige Dateien generiert worden. Der erstellte Dateibaum enthält acht automatisch erzeugte Dateien, die das Grundgerüst für unser Web Scraping Projekt darstellen:
├── bundeslaender_fakten
│ ├── __init__.py
│ ├── items.py
│ ├── pipelines.py
│ ├── settings.py
│ └── spiders
│ └── __init__.py
└── scrapy.cfg
2 directories, 6 files
- scrapy.cfg ist die Konfigurationsdatei für Server-Prozesse, wenn man Scrapy als Server nutzen möchte
- bundeslaender_fakten ist das selbst benannte Projektverzeichnis, für das du dir einen besseren Namen ausgedacht hast
- Die __init__.py Dateien machen die Ordner zu Python Packages
- items.py enthält die Datenfelder, in denen wir die gefundenen Informationen ablegen
- pipelines.py enthält Python-Code, mit dem wir gefundene Daten während des Crawlings prozessieren können
- settings.py enthält die Einstellungen für unser Projekt
- Im Verzeichnis spiders werden die mit dem “scrapy genspider” Kommando erzeugten Spider-Bots abgelegt
Als nächstes erzeugen wir unseren ersten Spider, mit dem wir die Wikipedia-Artikel crawlen werden. Das Kommando dafür lautet:
scrapy genspider de.wikipedia.org de.wikipedia.org
Laut der Scrapy-Dokumentation ist es sinnvoll, wenn der Spider so heißt wie die Domain, die gecrawlt werden soll. Wichtig ist, dass man nur innerhalb eines Projektverzeichnisses mit dem Scrapy-Kommando neue Spider generieren kann. Mit der Eingabe von “scrapy genspider -h” kann man sich die Optionen des “genspider” Kommandos anzeigen lassen.
Als Ausgabe sollten wir nun so etwas gesehen haben:
Created spider 'de.wikipedia.org' using template 'crawl' in module:
bundeslaender_fakten.spiders.de_wikipedia_org
Das bedeutet, dass der Spider erfolgreich angelegt wurde.
Wer das offizielle Scrapy-Tutorial durchgegangen ist, der weiß schon ganz genau wie der erzeugte Crawler aussieht. Da wir in diesem Fall möglichst sparsam und zielgenau crawlen möchten, folgt nun der durchkommentierte Python-Quellcode unseres Wikipedia-Crawlers, der sich vom automatisch erzeugten Crawler ein wenig unterscheidet. Hier finden sich auch die drei XPath-Ausdrücke wieder, die wir ganz am Anfang erstellt haben.
# -*- coding: utf-8 -*-
from scrapy.selector import HtmlXPathSelector
from scrapy.contrib.linkextractors.sgml import SgmlLinkExtractor
from scrapy.contrib.spiders import CrawlSpider, Rule
from bundeslaender_fakten.items import BundeslaenderFaktenItem
# Für die Hyperlink-Generierung auf Basis der XPath-Matches brauchen
# wir aus der Scrapy-Bibliothek das Request-Objekt
from scrapy.http import Request
class DeWikipediaOrgSpider(CrawlSpider):
name = 'de.wikipedia.org'
allowed_domains = ['de.wikipedia.org']
start_urls = ['http://de.wikipedia.org/wiki/Bundesrepublik_Deutschland']
rules = (
# Wir bleiben auf der Domain de.wikipedia.org und schränken den zu
# crawlenden Pfad auf "/wiki/" ein, damit der Crawler
# uns nicht ins Web ausbricht und anfängt externe Links zu crawlen
Rule(SgmlLinkExtractor(allow=r'/wiki/.+'), callback='parse_item', follow=True),
)
# Wir überschreiben die parse() Methode, um weitere Links ausschließlich
# via XPath-Ausdruck zu sammeln, damit wir uns auf die Länder beschränken
# und nicht plötzlich die gesamte de.wikipedia.org Domain crawlen
def parse(self, response):
hxs = HtmlXPathSelector(response)
i = BundeslaenderFaktenItem()
for hyperlink in hxs.select('//table[@class=contains(.,"wikitable sortable")]//td[2]/a[2]/@href').extract():
# Für jeden erfolgreichen Match unseres XPath-Ausdrucks, bekommen wir
# aus dem Wikipedia-HTML einen relativen(!) Link auf die Wikipedia-Seite
# eines Bundeslands zurück. Mit "http://"+name+hyperlink machen wir daraus
# einen absoluten Link und rufen self.parse erneut auf und holen uns
# auch diese Seite
absolute_link = "http://" + self.name + hyperlink
yield Request(absolute_link, callback=self.parse)
i['bundesland_name'] = hxs.select('//h1[@id="firstHeading"]/span/text()').extract()
i['schuldenstand'] = hxs.select('//tr[contains(.,"Schulden:")]//td[2]/text()').extract()
yield i
Bevor wir diesen Crawler nutzen können, müssen wir noch in der items.py Datei die entsprechenden Felder (Fields) anlegen.
# Define here the models for your scraped items
#
# See documentation in:
# http://doc.scrapy.org/topics/items.html
from scrapy.item import Item, Field
class BundeslaenderFaktenItem(Item):
# define the fields for your item here like:
# name = Field()
bundesland_name = Field()
schuldenstand = Field()
Wenn das erledigt ist können wir mit dem Crawlen beginnen. Dazu geben wir einfach das folgende Kommando ein:
scrapy crawl --loglevel=INFO de.wikipedia.org --output de.wikipedia.org.json
Wenn Probleme auftreten (z. B. die Datei de.wikipedia.org.json leer bleibt), sollte der Crawler erneut mit etwas umfassenderen Logfile-Output gestartet werden. Dafür ersetzt man einfach das “INFO” durch “DEBUG”. Dadurch erhält man Zeitstempel, Domainnamen, HTTP-Statuscodes, URLs und die konkreten Scraping-Ergebnisse für jede Webseite die verarbeitet wird.
Nach einem – hoffentlich erfolgreichen – Crawler-Durchlauf, sollte die Ausgabe auf der Kommandozeile so aussehen:
2012-04-12 15:03:27+0200 [scrapy] INFO: Scrapy 0.14.2 started (bot: bundeslaender_fakten)
2012-04-12 15:03:27+0200 [de.wikipedia.org] INFO: Spider opened
2012-04-12 15:03:27+0200 [de.wikipedia.org] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
cat 2012-04-12 15:03:28+0200 [de.wikipedia.org] INFO: Closing spider (finished)
2012-04-12 15:03:28+0200 [de.wikipedia.org] INFO: Stored jsonlines feed (17 items) in: de.wikipedia.org.json
2012-04-12 15:03:28+0200 [de.wikipedia.org] INFO: Dumping spider stats:
{'downloader/request_bytes': 4982,
'downloader/request_count': 17,
'downloader/request_method_count/GET': 17,
'downloader/response_bytes': 1242419,
'downloader/response_count': 17,
'downloader/response_status_count/200': 17,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2012, 4, 12, 13, 3, 28, 878834),
'item_scraped_count': 17,
'request_depth_max': 1,
'scheduler/memory_enqueued': 17,
'start_time': datetime.datetime(2012, 4, 12, 13, 3, 27, 616410)}
2012-04-12 15:03:28+0200 [de.wikipedia.org] INFO: Spider closed (finished)
2012-04-12 15:03:28+0200 [scrapy] INFO: Dumping global stats:
{}
und auf unserer Festplatte sollte außerdem die Datei “de.wikipedia.org.json” zu finden sein, die folgende JSON-Daten enthält:
[{"bundesland_name": ["Deutschland"], "schuldenstand": []},
{"bundesland_name": ["Sachsen"], "schuldenstand": ["6,5\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Sachsen-Anhalt"], "schuldenstand": ["20,5 Mrd. EUR "]},
{"bundesland_name": ["Schleswig-Holstein"], "schuldenstand": ["27,0\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Rheinland-Pfalz"], "schuldenstand": ["30,6 Mrd. EUR "]},
{"bundesland_name": ["Saarland"], "schuldenstand": ["11,6 Mrd. EUR "]},
{"bundesland_name": ["Th\u00fcringen"], "schuldenstand": ["16,2\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Baden-W\u00fcrttemberg"], "schuldenstand": ["58,2\u00a0Mrd.\u00a0\u20ac "]},
{"bundesland_name": ["Nordrhein-Westfalen"], "schuldenstand": ["184,96\u00a0Mrd. EUR "]},
{"bundesland_name": ["Mecklenburg-Vorpommern"], "schuldenstand": ["9,8 Mrd. EUR "]},
{"bundesland_name": ["Niedersachsen"], "schuldenstand": ["54,0\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Freie Hansestadt Bremen"], "schuldenstand": ["17,8 Mrd. EUR "]},
{"bundesland_name": ["Hessen"], "schuldenstand": ["37,1\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Hamburg"], "schuldenstand": ["24,9 Mrd. EUR "]},
{"bundesland_name": ["Bayern"], "schuldenstand": ["29,3\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Brandenburg"], "schuldenstand": ["18,1\u00a0Mrd.\u00a0EUR "]},
{"bundesland_name": ["Berlin"], "schuldenstand": []}]
Damit ist das wichtigste getan. Was haben wir bisher erreicht?
- Wir haben XPath-Ausdrücke für das Scraping erstellt
- Wir haben einen Spider generiert und so angepasst, dass er mit einem XPath-Ausdruck eine exakt eingegrenzte Liste von Hyperlinks einsammelt
- Anschließend hat der Spider die mit weiteren XPath Ausdrücken gefundenen Ergebnisse auf den 16 Zielseiten in eine Datendatei im JSON-Format geschrieben
Der aufmerksame Beobachter erkennt einen kleinen Schönheitsfehler: Für das Land Berlin konnten keine Daten gefunden werden. Sie existieren leider nicht auf der entsprechenden Wikipedia-Seite.
Mit diesen Problemen hat man immer zu kämpfen, wenn man sich mit gecrawlten Inhalten befasst. Deshalb ist es in der Regel sinnvoll für alle gescrapten Daten auch eine Alternative vorzuhalten. Nicht gefundene Texte sollten mit einem Platzhalter ersetzt werden. Das gleiche trifft auf Bilder und andere Elemente zu. Man kann sich nie darauf verlassen das eine Website, die gestern noch erfolgreich gecrawlt werden konnte, morgen noch genau so problemlos verarbeitet werden kann.
Wir erinnern uns daran, dass wir die gefundenen Daten in eine HTML-Tabelle verwandeln wollen. Hierfür habe ich ein simples Python-Script geschrieben, dass die gewonnen Daten mit dem JSON-Modul von Python lädt und mit einem trivialen HTML-Template in eine statische HTML-Datei auf die Festplatte speichert. Außerdem erzeugt es einen kleinen Zeitstempel am Fuß der Tabelle. Das bereits angesprochene “Berlin-Problem” nehmen wir ausnahmsweise so hin und kümmern uns später per Platzhalter-Text darum, dass die generierte HTML-Tabelle keinen blinden Fleck enthält.
Der Code um die Tabelle zu erzeugen kann so aussehen:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dieses Script nutzt die im JSON-Format gespeicherten
Daten des Crawlers um eine neue Webseite zu generieren,
die die gesammelten Scraping-Ergebnisse tabellarisch darstellt.
"""
import json, datetime
# Die gecrawlten Rohdaten im JSON-Format laden
# und als verschachtelte Liste verfügbar machen
json_data = open("de.wikipedia.org.json").read()
data = json.loads(json_data)
# HTML5 Kopf der Webseite als vorbereiten
html = u"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Schuldenstand der Bundesländer</title>
<style>
body {
font-family:"Lucida Sans Unicode", "Lucida Grande", Sans-Serif;
font-size: 10px;
line-height: 16px;
}
td {
border-bottom: 1px solid #000;
padding: 5px 15px 5px 5px;
}
</style>
</head>
<body>
<h1>Schuldenstand der Bundesländer<h1>
<h2>Quelle: de.wikipedia.org</h2>
<table>
"""
# Wir fangen bei +1 an zu zählen, da sonst "Deutschland"
# auch in der Liste auftauchen würde. Deutschland war aber nur
# die Einstiegseite, die auf die 16 Bundesländer verwiesen hat
line = 1 # Zeilennummern vorbereiten
for item in data[1:]:
html += "<tr>"
# Auf der linken Seite steht immer eine Zeilennummer
html += '<td style="padding: 5px 5px 5px 5px">{0}.</td>'.format(line)
line += 1 # Zeile +1
# Die Namen der Bundesländer, wie sie auf den
# Wikipedia-Seiten mit diesem XPath-Ausdruck:
# //h1[@id="firstHeading"]/span/text()
# gefunden und extrahiert wurden
for name in item['bundesland_name']:
html += "<td>" + name + "</td>"
# Nicht in allen Wikipedia-Seiten steht der Schuldenstand in der
# Tabelle. Das Scraping-Ergebnis ist an diesen Stellen eine leere
# Liste. Wir prüfen das und geben für diese Fälle eine Meldung aus
if not item['schuldenstand']:
html += "<td>" + "Keine Daten verfügbar" + "</td></tr>\n"
# Ausgabe der Schulden, die wir mit diesem XPath-Ausdruck:
# //tr[contains(.,"Schulden:")]//td[2]/text()
# gefunden und extrahiert haben
for schulden in item['schuldenstand']:
html += "<td>" + schulden + "</td>"
html += "</tr>\n"
# Fuß der HTML-Seite
timestamp = datetime.datetime.now()
html += """
</table>
<div><p>Generiert am: {0}</p></div>
</body>
</html>
""".format(timestamp.strftime("%d.%m.%Y %H:%M:%S"))
# Das fertige HTML in eine Datei schreiben. Das Ergebnis
# kann man sich anschließend im Browser anschauen
with open('./bundeslaender.html', 'w') as f:
f.write(html.encode('utf-8'))
Und die daraus resultierende Tabelle kann man sich auf diesem Bild anschauen:
Wie man sehen kann wurde der Schuldenstand von Berlin durch einen Platzhalter ersetzt. Es sticht außerdem eine zweite Ungereimtheit ins Auge: Auf der Wikipedia-Seite von Baden-Württemberg wurde das Euro-Symbol anstelle der drei Buchstaben “EUR” verwendet. Das sieht optisch nicht besonders schön aus, ist aber inhaltlich nicht falsch. Auch mit solchen “kleinen Ärgernissen” muss man rechnen, wenn man sich mit Web Scraping und Crawling beschäftigt.
Nichts desto Trotz handelt es sich dabei um ein sehr interessantes Betätigungsfeld mit wachsender Bedeutung und nicht zu unterschätzendem Spassfaktor. Und deshalb wünsche ich euch:
Viel Erfolg beim Scrapen!