AutoScout24 Mining (Teil 1) – Webscraping mit Python

Wer schon mal über diese Seite gestolpert ist, weiß vielleicht, dass ich schon mal ein Tutorial zum Thema Webscraping mit Python bezogen auf Immobiliendaten geschrieben habe. Genauer gesagt ging es um ImmobilienScout24. Darum liegt es nah, auch mal eine andere Seite der Scout24-Gruppe unter die Lupe zu nehmen. In diesem Tutorial lernst du, wie du pro Tag Daten von ca. 30.000 Inseraten aus acht Ländern sammeln kannst. Und das, ohne dich irgendwo anzumelden oder etwas unterschreiben zu müssen.

Die Logik des Webscrapers

Die Logik des Webscrapers ist relativ simpel. Er guckt in jeder Länderkategorie von autoscout24 immer wieder nach neuen Inseraten, besucht die jeweiligen URLs und speichert die relevanten Daten in CSV-Dateien ab.

Im Detail heißt das:

Der Scraper besucht autoscout24.de und sucht nach Autos (das sage ich hier explizit, weil auf der Seite auch LKW, Motorräder und Wohnmobile angeboten werden). Die Suche beinhaltet als einziges Filterkriterium das Land, in dem das Auto angeboten wird. Die verfügbaren Länder sind Deutschland, Österreich, Belgien, Spanien, Frankreich, Italien, Luxemburg und Niederlande.

Außerdem sortieren wir die Angebote nach “Neueste Angebote zuerst”. Das ist wichtig, weil wir ja möglichst bei jeder Iteration neue Angebote sehen und speichern wollen. Der Filter und die Sortierung sind praktischerweise schon Bestandteil der URL, die der Scraper aufruft. Die erste Ergebnisseite hat beispielsweise die URL 'https://www.autoscout24.de/lst/?sort=age&desc=1&ustate=N%2CU&size=20&page=1&cy=D'. Der Parametersort=age bedeutet, dass nach dem Alter der Angebote sortiert. Der Parametercy=D filtert die Ergebnisse nach Angeboten aus Deutschland. Auf der ersten Ergebnisseite sehen wir die 20 neuesten Inserate aus Deutschland. Leider können wir nicht alle Ergebnisse auf einer Seite anzeigen lassen. Deshalb muss der Scraper alle 20 Ergebnisseiten durchgehen. Dabei speichert er alle Angebots-URLs, die er nicht vorher schon besucht hatte – maximal 400 – in einer Liste. Dann iteriert der Webscraper über die Liste von URLs und durchsucht dabei den Quellcode jedes Angebots.

Die wichtigsten Features sind:

  • Preis
  • Hersteller
  • Modell
  • Ausstattung
  • Zustand (neu / gebraucht)
  • Leistung (PS & kW)
  • Erstzulassung
  • Kilometerstand
  • Kraftstoffverbrauch/Stromverbrauch
  • Gewicht
  • Gänge
  • CO2-Emissionen
  • Händler/Privat
  • Antriebsart (Front, Heck, Allrad)
  • Anzahl Türen
  • Farbe
  • Anzahl Zylinder
  • Anzahl Fahrzeughalter
  • Angebotsdatum
  • Land
  • PLZ mit Ort

Diese und noch mehr Daten werden aus dem HTML-Code extrahiert und dann strukturiert in einer CSV-Datei abgelegt. Jede CSV beinhaltet also Daten von allen neuen Inseraten, die in den vorher festgelegten Ländern im jetzigen Durchgang gefunden wurden. Dann startet der neue Durchgang.

Was brauchst du?

Für Webscraping mit Python brauchst du… natürlich Python. Ich habe den Code mit Python 3.7 getestet, wahrscheinlich funktioniert er aber mit jeder 3.x-Version. Als einzige externe Libraries brauchst du:

1. BeautifulSoup: Mit diesem Modul kannst du HTML-Code parsen, hiermit machst du also das eigentliche Webscraping.

2. Pandas: Die am häufigsten genutzte Python-Library für Datenanalyse.

Beide kannst du ganz einfach mit pip installieren. Eine Ordnerstruktur musst du nicht selbst erstellen. Alle Ordner, in denen später Dateien abgelegt werden, werden vom Scraper selbst angelegt.

Der Code

Jetzt kommen wir zum eigentlichen Code. Ich erkläre ihn erst einmal Stück für Stück, am Ende gibt es den Code aber noch mal komplett zum kopieren.

Importe

from bs4 import BeautifulSoup, SoupStrainer #HTML parsing
import urllib.request #aufrufen von URLs
from time import sleep #damit legen wir den Scraper schlafen
import json #lesen und schreiben von JSON-Dateien
from datetime import datetime #um den Daten Timestamps zu geben
import re #regular expressions
import os #Dateipfade erstellen und lesen
import pandas as pd #Datenanalyse und -manipulation

Zuerst legen wir die Ordner an, in die später Dateien gelegt werden sollen.

folders = ["data/visited/","data/autos/"]

for folder in folders:
    if not os.path.isdir(folder):
        os.mkdir(folder)
        print(path, "erstellt.")
    else:
        print(folder,"existiert bereits")

Dann legen wir eine JSON-Datei an, in die später alle schon besuchten URLs geschrieben werden. Damit vermeiden wir, nicht immer wieder dieselben Angebote aufzurufen.

path_to_visited_urls = "data/visited/visited_urls.json"

if not os.path.isfile(path_to_visited_urls):
    with open(path_to_visited_urls,"w") as file:
        json.dump([],file)

Die Länder, aus denen wir Daten sammeln wollen, legen wir in ein Dictionary.

countries = {"Deutschland": "D",
             "Oesterreich": "A",
             "Belgien" : "B",
             "Spanien": "E",
             "Frankreich": "F",
             "Italien": "I",
             "Luxemburg": "L",
             "Niederlande": "NL"}

Ich finde es immer hilfreich, während ein Scraper läuft, den Fortschritt anzuzeigen. Deshalb konstruieren wir einen Counter für die Anzahl der gespeicherten Auto-Angebote und einen Counter für den Zyklus, in dem wir uns gerade befinden. Ein Zyklus bedeutet, dass wir einmal alle aktuellen Angebote aus allen Ländern gesammelt haben.

car_counter=1
cycle_counter=0

Endlosschleife

Alles, was jetzt kommt, passiert in einer Endlosschleife. Das Skript stoppt erst, wenn du es willst.

while True:
    with open(path_to_visited_urls) as file:
        visited_urls = json.load(file)
    
    if len(visited_urls) > 100000:
        visited_urls = []

Die Datei mit besuchten URLs wird geöffnet. Beim ersten Durchlauf dürfte sie noch leer sein. Sobald die Liste mehr als 100.000 URLs enthält, wird sie wieder geleert. Das verhindert die immer länger werdenden Ladezeiten der Datei. Außerdem können wir uns relativ sicher sein, dass bei einer so langen Liste die zuerst gespeicherten URLs schon gar nicht mehr unter den aktuellsten Angeboten zu finden sind. Natürlich werden wir ein paar Duplikate haben, kurz nachdem die Liste geleert wird. Das können wir aber verschmerzen. Besser als Angebote zu verpassen, weil das Skript immer langsamer wird.

Jetzt erstellen wir ein leeres Dictionary, in welches später wieder alle Dictionaries der Inserate geschrieben werden.

    multiple_cars_dict = {}

Der Zyklus-Counter wird um 1 erhöht.

    cycle_counter+=1

Ergebnisseiten nach URLs durchsuchen

Eine doppelte For-Schleife wird geöffnet, in der der Scraper für jedes der Länder über alle 20 Ergebnisseiten der aktuellsten Inserate iteriert. Darin erstellen wir eine leere Liste, in die die gefundenen Angebots-URLs geschrieben werden.

    for country in countries:
        car_URLs = []
        for page in range(1,21):

Bei Webscraping mit Python arbeite ich oft mit einer try-Logik. Man kann sich nie zu 100% sicher sein, dass die URL, die man eben noch gesehen hat, auch aufrufbar ist. Als erstes legen wir die URL fest, auf der nach aktuellen Angeboten gesucht werden soll. Die einzigen Variablen in der URL sind die Seite (1 – 20) und das Land. Dann rufen wir die erste Seite auf und erstellen ein BeautifulSoup-Objekt. Dies kann entweder den ganzen HTML-Code oder nur Teile beinhalten. In unserem Fall haben wir durch das SoupStrainer-Objekt dem Modul gesagt, dass es nur <a>-Tags – also Tags mit Links – parsen soll. Das BeautifulSoup-Objekt hat viele praktische Methoden zum Parsen des Codes.

Falls die Seite nicht aufrufbar ist, wird der Grund für den Fehler ausgegeben und die Seite wird übersprungen. Einen Fehler gibt es fast nie, aber auch eben nur fast. Manchmal gibt es halt einen 404er, aber nicht öfter als alle 1000 URLs.

            try:
                url = 'https://www.autoscout24.de/lst/?sort=age&desc=1&ustate=N%2CU&size=20&page='+str(page)+ '&cy=' + countries[country] +'&atype=C&'
                only_a_tags = SoupStrainer("a")
                soup = BeautifulSoup(urllib.request.urlopen(url).read(),'lxml', parse_only=only_a_tags)
            except Exception as e:
                print("Übersicht: " + str(e) +" "*50, end="\r")
                pass

Jetzt durchsuchen wir mit der find_all()-Methode die Seite nach URLs. Mit dieser Methode können wir ganz einfach nach beliebigen Arten von HTML-Tags suchen und diese auflisten. Jedes Element der resultierenden Liste hat wiederum unter anderem eine get()-Methode, mit der wir zum Beispiel die Attribute des Tags auslesen können. Hier interessiert uns das href-Attribut mit der URL. Jede URL, die den String “/angebote/” beinhaltet, kommt in die Liste car_URLs. Das sind nämlich die URLs mit den Inseraten, deren Daten wir speichern wollen.

            for link in soup.find_all("a"):
                if r"/angebote/" in str(link.get("href")):
                    car_URLs.append(link.get("href"))

Relevante URLs abspeichern

Aus der entstandenen Liste entfernen wir die Duplikate und schreiben dann alle Elemente, die nicht schon in der Liste der besuchten URLs liegen, nach car_URLs_unique. Das ist dann die Inseratsliste, die der Scraper Auto für Auto abarbeitet.

            car_URLs_unique = [car for car in list(set(car_URLs)) if car not in visited_urls]

Unser Webscraper klappert also erst alle 20 Ergebnisseiten mit den neuesten Inseraten eines Landes ab und speichert die URLs, bevor er die einzelnen Angebote parst. Jetzt wird erst mal der Fortschritt ausgegeben.

            print(f'Lauf {cycle_counter} | {country} | Seite {page} | {len(car_URLs_unique)} neue URLs', end="\r")
        print("")

Achtung, Hackertrick! Mit dem Argument end="\r" wird die letzte Ausgabe mit der neuen überschrieben. Das verhindert, dass immer wieder eine neue Zeile aufgemacht wird und man eine ewig lange Spalte mit uninteressanten Prints sieht.

Angebote scrapen

Das Iterieren über die Ergebnislisten ist beendet, jetzt lesen wir die Angebote aus. Der Scraper geht die Liste jedoch nur durch, sofern sie nicht leer ist. Ansonsten legt er sich 60 Sekunden schlafen. Mehr dazu unten. Und auch hier lassen wir uns den Fortschritt des Scrapers ausgeben.

        if len(car_URLs_unique)>0:
            for URL in car_URLs_unique:
                print(f'Lauf {cycle_counter} | {country} | Auto {car_counter}'+' '*50, end="\r")

Und auch hier passiert wieder einiges in einem try-Block, weil URLs aufgerufen werden, die auch mal nicht mehr erreichbar sein können. Wir legen ein car_dict-Dictionary an, in das alle Daten aus dem aktuellen Inserat fließen. Als erstes schreiben wir das Land und einen Zeitstempel in das Dictionary. Danach instanziieren wir wieder ein BeautifulSoup-Objekt. Nur dass wir dieses Mal den ganzen HTML-Code parsen wollen.

                try:
                    car_counter+=1
                    car_dict = {}
                    car_dict["country"] = country
                    car_dict["date"] = str(datetime.now())
                    car = BeautifulSoup(urllib.request.urlopen('https://www.autoscout24.de' + URL).read(), 'lxml')

Die meisten Daten, an die wir ran wollen, liegen zwischen <dt>- und <dd>-Tags. Erklärung zu den Tags findest du hier. Jedenfalls schmeißen wir einfach den ganzen Inhalt in unser Dictionary.

                    for key, value in zip(car.findAll("dt"), car.findAll("dd")):
                        car_dict[key.text.replace("\n","")] = value.text.replace("\n","")

Die nächsten Daten ziehen wir aus <div>-Elementen mit spezifischen Attributen, die wir innerhalb der find()-Methode in einem Dictionary angeben können. Den Preis müssen wir schon von allem befreien, was keine Zahl ist. Ansonsten würde der Preis durch die enthaltenen Escape-Characters (hier \n) beim CSV-Export rausfliegen und das wollen natürlich nicht.

                    car_dict["haendler"] = car.find("div",attrs={"class":"cldt-vendor-contact-box", "data-vendor-type":"dealer"}) != None

                    car_dict["privat"] = car.find("div",attrs={"class":"cldt-vendor-contact-box", "data-vendor-type":"privateseller"}) != None

                    car_dict["ort"] = car.find("div",attrs={"class":"sc-grid-col-12", "data-item-name":"vendor-contact-city"}).text
                    
                    car_dict["price"] =  "".join(re.findall(r'[0-9]+',car.find("div",attrs={"class":"cldt-price"}).text))

Auch die Ausstattungsmerkmale der Fahrzeuge befinden sich in div-Elementen mit spezifischen Attributen. Und auch hier säubern wir die Daten schon ein wenig und befreien sie von Escape-Characters (\n).

                    ausstattung = []

                    for i in car.find_all("div",attrs={"class":"cldt-equipment-block sc-grid-col-3 sc-grid-col-m-4 sc-grid-col-s-12 sc-pull-left"}):
                        for span in i.find_all("span"):
                            ausstattung.append(i.text)

                    ausstattung2 = []

                    for element in list(set(ausstattung)):
                        austattung_liste = element.split("\n")
                        ausstattung2.extend(austattung_liste)

                    car_dict["ausstattung_liste"] = sorted(list(set(ausstattung2)))

Rohdaten ablegen

Das war’s auch schon mit dem Scrapen der Detailseite. Das Dictionary der Fahrzeugeigenschaften schreiben wir in ein übergeordnetes Dictionary, bei dem wir als Key die URL des jeweiligen Fahrzeugs nutzen. Außerdem fügen die URL der Liste der besuchten Seiten hinzu.

                    multiple_cars_dict[URL] = car_dict
                    visited_urls.append(URL)

Jetzt fangen wir die Exception ein und geben sie aus. Meistens – wie oben schon beschrieben – ein HTTP Error, bei dem die Seite nicht mehr gefunden wird.

                except Exception as e:
                    print("Detailseite: " + str(e) + " "*50)
                    pass
            print("")

Bis jetzt haben wir uns lange in dem Teil der IF-Bedingung befunden, der ausgeführt wird, falls der Scraper neue URLs auf den Ergebnisseiten findet. Wenn der Scraper jedoch über alle Ergebnisseiten iteriert und dabei nichts neues gefunden hat, legen wir ihn 60 Sekungen lang schlafen.

    else:
        print("\U0001F634")
        sleep(60)

Nach dem Durchsuchen aller Ergebnisseiten aller Länder speichern wir die erfassten Daten als DataFrame in einer CSV-Datei. Bedingung hierfür ist, dass überhaupt Daten gefunden wurden. Das ist eigentlich immer der, die IF-Bedingung hier dient nur als zusätzliche Sicherheit, damit der Code nicht unerwünscht unterbrochen wird. Abschließend wird die neue, ergänzte Liste der besuchten URLs als JSON abgespeichert.

    if len(multiple_cars_dict)>0:
        df = pd.DataFrame(multiple_cars_dict).T
        df.to_csv("data/autos/"+re.sub("[.,:,-, ]","_",str(datetime.now()))+".csv",sep=";",index_label="url")
    else:
        print("Keine Daten")
    with open("data/visited/visited_urls.json", "w") as file:
        json.dump(visited_urls, file)

Webscraping erfolgreich!

Das war der erste Teil zum Thema Webscraping mit Python am Beispiel von autoscout24.de. Wir haben die Seite durchsucht, Daten gesammelt und diese strukturiert lokal abgelegt. Allerdings fehlt noch wichtiger Schritt, bevor man die Daten auch gut analysieren. Unsere Daten sind nämlich noch richtig unsauber. Und genau diesem Thema widmen wir uns im nächsten Beitrag!

Der komplette Code

Wie versprochen findest du hier den kompletten Code zum kopieren und selbst ausprobieren.

from bs4 import BeautifulSoup, SoupStrainer #HTML parsing
import urllib.request #aufrufen von URLs
from time import sleep #damit legen wir den Scraper schlafen
import json #lesen und schreiben von JSON-Dateien
from datetime import datetime #um den Daten Timestamps zu geben
import re #regular expressions
import os #Dateipfade erstellen und lesen
import pandas as pd #Datenanalyse und -manipulation

folders = ["data/visited/","data/autos/"]

for folder in folders:
    if not os.path.isdir(folder):
        os.mkdir(folder)
        print(path, "erstellt.")
    else:
        print(folder,"existiert bereits")

path_to_visited_urls = "data/visited/visited_urls.json"

if not os.path.isfile(path_to_visited_urls):
    with open(path_to_visited_urls,"w") as file:
        json.dump([],file)

countries = {"Deutschland": "D",
             "Oesterreich": "A",
             "Belgien" : "B",
             "Spanien": "E",
             "Frankreich": "F",
             "Italien": "I",
             "Luxemburg": "L",
             "Niederlande": "NL"}

#countries = {"Deutschland": "D"}

car_counter=1
cycle_counter=0

while True:

    with open(path_to_visited_urls) as file:
        visited_urls = json.load(file)
    
    if len(visited_urls) > 100000:
        visited_urls = []
    
    multiple_cars_dict = {}
    
    cycle_counter+=1

    for country in countries:
        
        car_URLs = []
        
        for page in range(1,21):

            try:
                url = 'https://www.autoscout24.de/lst/?sort=age&desc=1&ustate=N%2CU&size=20&page='+str(page)+ '&cy=' + countries[country] +'&atype=C&'
                only_a_tags = SoupStrainer("a")
                soup = BeautifulSoup(urllib.request.urlopen(url).read(),'lxml', parse_only=only_a_tags)
            except Exception as e:
                print("Übersicht: " + str(e) +" "*50, end="\r")
                pass

            for link in soup.find_all("a"):
                if r"/angebote/" in str(link.get("href")):
                    car_URLs.append(link.get("href"))

            car_URLs_unique = [car for car in list(set(car_URLs)) if car not in visited_urls]
            
            print(f'Lauf {cycle_counter} | {country} | Seite {page} | {len(car_URLs_unique)} neue URLs', end="\r")
        print("")
        if len(car_URLs_unique)>0:

            for URL in car_URLs_unique:
                print(f'Lauf {cycle_counter} | {country} | Auto {car_counter}'+' '*50, end="\r")
                try:
                    car_counter+=1

                    car_dict = {}
                    car_dict["country"] = country
                    car_dict["date"] = str(datetime.now())                    
                    car = BeautifulSoup(urllib.request.urlopen('https://www.autoscout24.de'+URL).read(),'lxml')
                    
                    for key, value in zip(car.find_all("dt"),car.find_all("dd")):
                        car_dict[key.text.replace("\n","")] = value.text.replace("\n","")

                    car_dict["haendler"] = car.find("div",attrs={"class":"cldt-vendor-contact-box",
                                                                 "data-vendor-type":"dealer"}) != None

                    car_dict["privat"] = car.find("div",attrs={"class":"cldt-vendor-contact-box",
                                                               "data-vendor-type":"privateseller"}) != None

                    car_dict["ort"] = car.find("div",attrs={"class":"sc-grid-col-12",
                                                            "data-item-name":"vendor-contact-city"}).text
                    
                    car_dict["price"] =  "".join(re.findall(r'[0-9]+',car.find("div",attrs={"class":"cldt-price"}).text))
                    
                    ausstattung = []

                    for i in car.find_all("div",attrs={"class":"cldt-equipment-block sc-grid-col-3 sc-grid-col-m-4 sc-grid-col-s-12 sc-pull-left"}):
                        for span in i.find_all("span"):
                            ausstattung.append(i.text)

                    ausstattung2 = []

                    for element in list(set(ausstattung)):
                        austattung_liste = element.split("\n")
                        ausstattung2.extend(austattung_liste)

                    car_dict["ausstattung_liste"] = sorted(list(set(ausstattung2)))

                    multiple_cars_dict[URL] = car_dict
                    visited_urls.append(URL)
                except Exception as e:
                    print("Detailseite: " + str(e) + " "*50)
                    pass
            print("")
        
        else:
            print("\U0001F634")
            sleep(60)
    
    if len(multiple_cars_dict)>0:
        df = pd.DataFrame(multiple_cars_dict).T
        df.to_csv("data/autos/"+re.sub("[.,:,-, ]","_",str(datetime.now()))+".csv",sep=";",index_label="url")
    else:
        print("Keine Daten")
    with open("data/visited/visited_urls.json", "w") as file:
        json.dump(visited_urls, file)

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

*

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.