Beitrag vom 15. Oktober 2022, für den Neubau übersetzt und ergänzt. Die Beispiele zeigen den damaligen Stand und sind keine getestete Anleitung für die heutige Website. Beachte Nutzungsbedingungen, Zugriffsgrenzen und robots.txt des jeweiligen Anbieters; eine Begrenzung ist keine Aufforderung, sie zu umgehen.

Eine Schwierigkeit beim Suchen größerer Datenmengen ist eine begrenzte Trefferzahl. Hier geht es darum, mit dem Python-Paket geoiter ein geografisches Gebiet in kleinere Abfragen aufzuteilen – und anschließend zu zeigen, wie sich ein synchroner HTTP-Client asynchron einsetzen lässt.

Das Problem

Als Beispiel dient eine damalige Suche nach VW Golf in Deutschland: Die Oberfläche zeigte rund 31.333 Angebote an, ließ aber nur 20 Seiten mit je 20 Ergebnissen durchblättern.

Suchergebnis mit angezeigter Trefferzahl
Suchergebnis im ursprünglichen Beispiel von 2022.
Seitennavigation mit deaktivierter Weiter-Schaltfläche
Die Seitennavigation endet im ursprünglichen Beispiel bei Seite 20.

Eine größere Seitennummer direkt im URL-Parameter brachte damals keine weiteren Treffer. Ein möglicher Ansatz ist, das Gebiet in kleinere Suchbereiche aufzuteilen – vorausgesetzt, der Anbieter erlaubt diese Art der Abfrage.

Mittelpunkte mit geoiter

geoiter liefert Mittelpunkte innerhalb einer Grenze. Zunächst installieren wir das Paket:

python -m pip install geoiter

Mit den Beispieldaten erzeugen wir eine Liste der Mittelpunkte. Der ursprüngliche Beitrag vergaß hier die Klammern beim Aufruf von get_german_border(); das ist unten korrigiert.

# main.py
from geoiter.util.ressource_example import get_german_border
# this is a example list of coordinates. You may get you own from open street map or other geospatial services.
from geoiter import GeoIter

german_border = get_german_border()

gi = GeoIter(
    boundary=german_border,
    radius=100,
    comp_rate=20
    )

midpoints = list(gi) #  consume that iterator at once
print(len(midpoints), midpoints)

# prints:
# 18 [(54.184302636275454, 10.4540982), (54.14901311305548, 7.535786698090457), ...]

Mit einem Radius von 100 km ergab das Beispiel 18 Kreise. 30.000 Treffer geteilt durch 18 wären im Durchschnitt etwa 1.667 Treffer je Kreis – nicht 3.333. Bei höchstens 400 Treffern pro Abfrage wären bei völlig gleichmäßiger Verteilung rechnerisch mindestens 75 Abfragen nötig; reale Angebote sind aber ungleich verteilt. Ein Radius von 30 km war mein damaliger Versuch, keine Garantie für Vollständigkeit. Überlappungen erzeugen außerdem Duplikate.

Synchrones Beispiel

Das ursprüngliche Skript verwendet httpx und lxml, nimmt beispielhaft zwei Mittelpunkte und liest Suchseiten aus. Der folgende Code bleibt als historische Dokumentation erhalten. Er enthält bekannte Probleme: alte CSS-Selektoren und URL, eine unzuverlässige Seitenberechnung bei exakt durch 20 teilbaren Trefferzahlen und keine Duplikatbehandlung. Nicht ungeprüft ausführen.

# main.py
import math
import httpx  # or any other popular http client.
from lxml.html import fromstring  # or use bs4

from geoiter.util.ressource_example import get_german_border
# this is a example list of coordinates. You may get you own from open street map or other geospatial services.
from geoiter import GeoIter

german_border = get_german_border()

# we keep a reference here
query_radius = 30

gi = GeoIter(
    boundary=german_border,
    radius=query_radius,
    comp_rate=20
)

midpoints = list(gi)  # consume that iterator at once
print(len(midpoints), midpoints[:2])

base_url = r"https://www.autoscout24.com/lst/volkswagen/golf-(" \
           r"all)?sort=standard&desc=0&atype=C&ustate=N%2CU&powertype=kw "


def parse_offers(body_tree):
    # prints the results of one html page with max. 20 offers
    articles = body_tree.xpath("//article")
    for article in articles:
        title = article.xpath(".//h2/text()")[0]
        price = article.xpath(".//p/text()")[0]
        print(title, price)


def main():
    # here we limit the coordinates just for demo purposes.
    for coordinate in midpoints[100:102]:
        query_url = f"{base_url}&lon={coordinate[1]}&lat={coordinate[0]}&zipr={query_radius}"
        response = httpx.get(query_url)
        assert response.status_code == 200
        body_tree = fromstring(response.text)
        query_size = body_tree.xpath("//div[@class='ListHeader_top__jY34N']/h1/span/span/text()")[0]
        if query_size:
            parse_offers(body_tree)
            more_pages = int(query_size) // 20
            print(f"I need to scrape now {query_size} offers or {more_pages} more page(s).")

            for page in range(1, more_pages + 1):
                response = httpx.get(query_url + f"&page={page + 1}")
                body_tree = fromstring(response.text)
                parse_offers(body_tree)



if __name__ == '__main__':
    main()

Asynchrones Beispiel

Die folgende Variante nutzt httpx.AsyncClient und mehrere Tasks. Asynchron heißt nicht automatisch schneller und schon gar nicht unbegrenzt viele Anfragen: Parallelität und Wiederholungen müssen zum Server und dessen Vorgaben passen. Auch dieser historische Code ist nicht aktuell getestet und enthält zusätzlich den Fall, dass asyncio.wait() eine leere Taskmenge erhalten kann.

# main.py
import asyncio
import httpx  # or any other popular http client.
from lxml.html import fromstring  # or use bs4

from geoiter.util.ressource_example import get_german_border
# this is a example list of coordinates. You may get you own from open street map or other geospatial services.
from geoiter import GeoIter

german_border = get_german_border()

query_radius = 30

gi = GeoIter(
    boundary=german_border,
    radius=query_radius,
    comp_rate=20
)

midpoints = list(gi)  # consume that iterator at once
print(len(midpoints), midpoints[:2])

base_url = r"https://www.autoscout24.com/lst/volkswagen/golf-(" \
           r"all)?sort=standard&desc=0&atype=C&ustate=N%2CU&powertype=kw "


def parse_offers(body_tree):
    articles = body_tree.xpath("//article")
    for article in articles:
        title = article.xpath(".//h2/text()")[0]
        price = article.xpath(".//p/text()")[0]
        print(title, price)


async def fetch_page(client, query_url):
    response = await client.get(query_url)
    body_tree = fromstring(response.text)
    parse_offers(body_tree)


async def fetch_query_coro(client, query_url, loop):
    response = await client.get(query_url)
    assert response.status_code == 200
    body_tree = fromstring(response.text)
    query_size = body_tree.xpath("//div[@class='ListHeader_top__jY34N']/h1/span/span/text()")[0]
    if query_size:
        parse_offers(body_tree)
        more_pages = int(query_size) // 20
        print(f"I need to scrape now {query_size} offers or {more_pages} more page(s).")

        pending = set()
        for page in range(1, more_pages + 1):
            pending.add(
                loop.create_task(fetch_page(client, query_url + f"&page={page + 1}"))
            )
        done, pending = await asyncio.wait(pending)


async def main():
    client = httpx.AsyncClient()
    loop = asyncio.get_event_loop()
    async with client:
        pending = []
        for coordinate in midpoints[100:102]:
            query_url = f"{base_url}&lon={coordinate[1]}&lat={coordinate[0]}&zipr={query_radius}"
            pending.append(
                loop.create_task(fetch_query_coro(client, query_url, loop))
            )
        done, pending = await asyncio.wait(pending)


if __name__ == '__main__':
    asyncio.run(main(), debug=True)

Die Grundidee bleibt: geografische Teilbereiche erzeugen und Ergebnisse kontrolliert verarbeiten. Für einen tatsächlichen Einsatz zuerst eine erlaubte Schnittstelle verwenden, das Ergebnis deduplizieren, Grenzen respektieren und die Fehlerbehandlung ergänzen.