Naar hoofdinhoud
dataskippr
Terug naar blog
11 min lezen Data Engineering

Weer een sushirestaurant erbij: Thuisbezorgd scrapen en rapporteren in Power BI

Een side project uit 2021: 4000+ Thuisbezorgd-pagina's scrapen met Selenium, HTML omzetten naar JSON Lines en drie sushivragen beantwoorden in Power BI.

Anton Corredoira

Vorige week viel me op dat er alweer een nieuw sushirestaurant was geopend in mijn buurt. Toen ik de Thuisbezorgd-pagina van mijn postcode doorliep, bleek ik bij 11 verschillende restaurants sushi te kunnen bestellen. Voor een stad ter grootte van Gouda, met zo’n 70.000 inwoners, oogt dat als flink wat concurrentie. Het liet me met een paar vragen achter:

  • Bestaan er plekken waar via Thuisbezorgd geen sushi te krijgen is?
  • Waar vindt men het hoogste aantal sushirestaurants?
  • Vinden we de sushi die we bestellen eigenlijk wel lekker?

Dit is precies het soort side project waar ik graag aan werk. Het vraagt om data verzamelen van Thuisbezorgd.nl, duizenden HTML-pagina’s omzetten naar een bruikbare (semi-)gestructureerde databron, en daarna mag ik met de data spelen in Power BI.

Laten we met de resultaten beginnen en het technische deel voor later bewaren.

Resultaten

V1: Bestaan er plekken waar via Thuisbezorgd geen sushi te krijgen is?

Ja, die plekken bestaan. De groen/blauw gekleurde gebieden hebben geen enkele bezorgoptie voor sushi. Alle gemeenten behalve Schiermonnikoog (eiland in het noorden) hebben ten minste één restaurant op Thuisbezorgd. Alle andere groen/blauwe gebieden hebben dus wél restaurants op Thuisbezorgd, maar geen sushibezorging. Wie een eigen sushizaak op Thuisbezorgd wil beginnen, zou serieus naar Zaltbommel moeten kijken: een stad met bijna 12.000 inwoners en nul concurrentie voor sushibezorging op Thuisbezorgd. Andere gebieden zonder sushi liggen rond de Duitse grens en in de dunbevolkte provincies Zeeland en Friesland.

Kaart van Nederland met sushirestaurants per 1000 inwoners per gemeente

Data voor Sudwest-Fryslan ontbreekt, de esri-kaartvisual bleef het om onduidelijke redenen aan Den Haag koppelen zonder exact-match-modus. Ik moet nog uitzoeken welke waarde wél goed gemapt wordt.

V2: Waar vindt men het hoogste aantal sushirestaurants?

Amsterdam, en specifieker: postcode 1054. Wie sushi bestelt vanuit het 1054-gebied (rond de Overtoom en het Vondelpark) heeft keuze uit maar liefst 75 restaurants die sushi bezorgen. Dat is veel: bestelt u elke week sushi, dan bent u ruim anderhalf jaar bezig om elk restaurant één keer te proberen. En na die anderhalf jaar zijn er weer een paar nieuwe zaken bij, dus kunt u opnieuw beginnen.

Top postcodes naar aantal sushirestaurants, aangevoerd door Amsterdam 1054 met 75 restaurants

Groeperen we de data op gemeenteniveau, dan staan de grote steden uit de Randstad in de top 10, maar ook de grote zuidelijke steden zoals Eindhoven en Tilburg. Daarnaast duiken veel kleinere gemeenten op die naast een grote stad liggen. De data is gebaseerd op bezorgopties per postcode en niet op de fysieke locatie van de restaurants. Een restaurant in ‘s-Gravenhage dat aan zowel ‘s-Gravenhage als Rijswijk bezorgt, telt ook mee in Rijswijk en kan dus dubbel geteld worden.

Top gemeenten naar aantal sushirestaurants

V3: Vinden we de sushi die we bestellen eigenlijk wel lekker?

De reviewdata zegt JA, sushi is eten waar mensen blij van worden. Een sushirestaurant scoort gemiddeld een 4,13 en dat is een plek in de top 5.

Op basis van het overzicht met gemiddelde beoordelingen per keuken of soort eten kunt u ook vermoeden dat sommig eten zich beter leent voor bezorging dan ander eten. Eten dat goed tegen bezorgen kan, helpt de klantervaring duidelijk vooruit. Eten dat gevoelig is voor temperatuurverschillen en verstrijkende tijd krijgt slechtere beoordelingen. Restaurants met het type “patat” scoren bijvoorbeeld gemiddeld onder de 4. Ook Italiaanse pizza, burgers en Amerikaans eten krijgen magere cijfers. Ik heb gefilterd op de top 25 van soorten eten op basis van het aantal restaurants dat ze serveert, om categorieën met maar een paar restaurants uit te sluiten. Neem ik meer keukens en soorten eten mee, dan verschijnen ook Indiaas eten en curry bovenaan de lijst.

Gemiddelde beoordeling per soort eten, met sushi op 4,13 in de top 5

Beoordelingen voor de 25 populairste categorieën naar aantal restaurants

Data verzamelen

Vanaf het begin wist ik dat het verzamelen van de data de uitdaging zou worden. De eerste stap is uitzoeken hoe de Thuisbezorgd-website in elkaar zit. Wie op Thuisbezorgd een adres intypt, komt op een pagina met restaurants die in dat gebied bezorgen. Zoekt u naar restaurants die bezorgen op “Dam 1 Amsterdam”, dan komt u op de pagina met restaurants voor amsterdam-1012; het getal staat voor de eerste 4 cijfers van de postcode van dat adres.

Mijn plan:

  • Verzamel een lijst met alle postcodes van Nederland
  • Download voor elke postcode op de lijst de HTML-pagina van Thuisbezorgd
  • Zet alle losse pagina’s om naar één JSON-bestand
  • Laad het JSON-bestand in Power BI en bouw een bruikbaar datamodel
  • Beantwoord onze vragen

Thuisbezorgd.nl scrapen

Een lijst met alle postcodes vinden is eenvoudig. CBS publiceert veel data via zijn portalen, en deze ODataFeed is perfect voor ons doel. Ik schreef een python-functie die alle postcodes ophaalt, de eerste 4 cijfers pakt en ze als lijst teruggeeft. Die lijst met postcodes is de input voor de eigenlijke web scraper. Ik ga er niet diep op in, maar de flow van de web scraper (met Selenium) is simpel:

  • Ga eerst naar Thuisbezorgd.nl
  • Typ de postcode in met de methode .sendkeys() en druk op enter om het zoeken te starten.
  • Wacht 6 seconden zodat de hele pagina zeker goed gerenderd is.
  • Sla de paginabron op in een mappenstructuur die er zo uitziet: results/{postal_code}.html.
  • Ga terug naar Thuisbezorgd.nl en herhaal voor de volgende postcode

Thuisbezorgd gebruikt geen enkele vorm van paginering voor de resultaten, en dat maakt de scraping-klus lekker overzichtelijk: één obstakel minder. Ik koos ervoor om eerst de ruwe pagina’s op te slaan in plaats van de benodigde informatie direct als JSON weg te schrijven. Zo kan ik later extra dataelementen ophalen als ik meer informatie nodig heb: ik draai dan alleen het HTML-naar-JSON-proces opnieuw in plaats van alle 4000 pagina’s opnieuw te bezoeken. De web scraper draaide bijna 7 uur (4000 pagina’s maal 6 seconden wachttijd is zo’n 24.000 seconden, ook wel 6,7 uur). Stelt u zich voor dat u een verplicht attribuut vergeet op te halen en daarom het hele proces van 7 uur opnieuw moet draaien. Laten we dat niet doen.

4000+ HTML-bestanden omzetten naar JSON

De eenvoudige web scraper met Selenium leverde ruim 4000 HTML-bestanden op. Voor elke 4-cijferige postcode is er een HTML-bestand met de volledige pagina van het zoekresultaat. Hieronder staat een HTML-snippet van een restaurantelement op de Thuisbezorgd-website. Uit dit snippet halen we de informatie die we voor de analyse nodig hebben.

Voor elk restaurant halen we op:

  • name
  • kitchens
  • data_url
  • restaurant_id
  • restaurant_data_id
  • worst_rating
  • rating_value
  • best_rating
  • review_count
  • delivery_cost
  • average_delivery_time
<div class="restaurant js-restaurant" id="irestaurantQ50Q7OO" data-id="Q50Q7OO" itemscope="" itemtype="http://schema.org/Restaurant" data-url="/menu/hap-hum">
  <div class="logowrapper">
    <div class="baloon-container restaurantlabel"></div>
    <div class="logo-n">
      <a href="/menu/hap-hum" class="img-link">
        <img
          class="restlogo lazy-loaded"
          src="//static.thuisbezorgd.nl/images/restaurants/nl/Q50Q7OO/logo_465x320.png"
          data-src="//static.thuisbezorgd.nl/images/restaurants/nl/Q50Q7OO/logo_465x320.png"
          alt="Eethuis Hap-Hum - Voor een heerlijke maaltijd"
          data-was-processed="true"
        />
      </a>
    </div>
    <div class="review-rating">
      <div class="review-stars notranslate">
        <span style="width: 90%;" class="review-stars-range"> </span>
      </div>
      <span class="rating-total">(799)</span>
      <span class="rating-total-short">(799)</span>
    </div>
  </div>
  <div class="detailswrapper">
    <h2 class="restaurantname">
      <a class="restaurantname notranslate" href="/menu/hap-hum" itemprop="name">Eethuis Hap-Hum</a>
    </h2>
    <div itemprop="review" itemscope="" itemtype="http://schema.org/Review">
      <meta itemprop="name" content="Eethuis Hap-Hum" />
      <span itemprop="reviewRating" itemscope="" itemtype="http://schema.org/Rating">
        <meta itemprop="worstRating" content="1" />
        <meta itemprop="ratingValue" content="4" />
        <meta itemprop="bestRating" content="5" />
        <meta itemprop="reviewCount" content="799" />
      </span>
    </div>
    <div class="kitchens">
      <span>Spareribs, Snacks, Italiaanse pizza</span>
    </div>
    <div class="bottomwrapper details">
      <div class="delivery js-delivery-container">
        <div class="avgdeliverytime avgdeliverytimefull open closed">Gesloten voor bezorging</div>
        <div class="avgdeliverytime avgdeliverytimeabbr openAbbr closed">Gesloten voor bezorging</div>
      </div>
    </div>
  </div>
</div>

Voor het extraheren van de data gebruik ik de library BeautifulSoup. Daarmee kunt u data uit een HTML- of XML-bestand halen. Het HTML-snippet hierboven wordt verwerkt door de functie extract_restaurant_data. Een pagina bevat 0 of meer restaurants. extract_html_file leest het bestand en maakt een BeautifulSoup-object aan. Vervolgens worden met de functie findAll alle restaurants uit de HTML-bron gehaald. Voor elk restaurant wordt daarna extract_restaurant_data uitgevoerd. Die functie geeft een dictionary terug met alle dataelementen waarin we geïnteresseerd zijn.

def extract_restaurant_data(restaurant, file_name):
    restaurant_data = {
        "name": restaurant.find(itemprop='name').string if restaurant.find(itemprop='name') else None,
        "kitchens": restaurant.find('div', {'class':'kitchens'}).span.string.split(',') if restaurant.find('div', {'class':'kitchens'}) else None,
        "data_url": restaurant.get('data-url',None),
        "restaurant_id": restaurant.get('id', None),
        "restaurant_data_id": restaurant.get('data-id',None),
        "worst_rating": restaurant.find(itemprop="worstRating").get('content', None) if restaurant.find(itemprop="worstRating") else None,
        "rating_value": restaurant.find(itemprop="ratingValue").get('content', None) if restaurant.find(itemprop="ratingValue") else None,
        "best_rating": restaurant.find(itemprop="bestRating").get('content', None) if restaurant.find(itemprop="bestRating") else None,
        "review_count": restaurant.find(itemprop="reviewCount").get('content', None) if restaurant.find(itemprop="reviewCount") else None,
        "delivery_cost": restaurant.find('div',{'class':'delivery-cost'}).string if restaurant.find('div',{'class':'delivery-cost'}) else None,
        "average_delivery_time": restaurant.find('div',{'class':'avgdeliverytime'}).string if restaurant.find('div',{'class':'avgdeliverytime'}) else None,
        "is_chain": (True if "chains" in restaurant.find('img', {"class": "restlogo"})['data-src'] else False) if restaurant.find('img', {"class": "restlogo"}) else None,
        "source": file_name
    }
    return restaurant_data

def extract_html_file(file_name):
    output = []
    with open(file_name, encoding='utf-8') as f:
        content = f.read()
    bs = BeautifulSoup(content, "lxml")
    restaurants = bs.findAll('div', {'itemtype':"http://schema.org/Restaurant"})
    for restaurant in restaurants:
        output.append(extract_restaurant_data(restaurant, file_name))
    return output

Om het JSON-bestand te maken loopt het proces over de bestanden in de results-map van de webscraper. Bestanden die al succesvol verwerkt zijn, worden overgeslagen. Ik wist niet zeker hoelang dit proces zou duren, en als het om wat voor reden dan ook crasht, is het prettig om de verwerking te kunnen hervatten waar die stopte. De data wordt opgeslagen in het JSON Lines-formaat: elke regel in het bestand is een nieuw JSON-object. Dit formaat wordt ondersteund door Power BI en door big-data-technologie zoals Spark. Ik heb een regel toegevoegd die de voortgang print, want ik zie graag in de terminal hoe ver mijn script is.

with open('json_file/log_success.txt', 'r+', encoding='utf-8') as f:
    files = [x for x in os.listdir('results') if x not in [x.strip() for x in f.readlines()]]
total = len(files)
progress = 0
for file_name in files:
    try:
        result = extract_html_file(f'results/{file_name}')
        with open('json_file/restaurants.jsonl', 'a+', encoding='utf-8') as f:
            f.writelines(f'{json.dumps(x)}\n' for x in result)
        with open('json_file/log_success.txt', 'a+', encoding='utf-8') as f:
            f.write(f'{file_name}\n')
            progress += 1
        if progress % 20 == 0:
            print(f'Extracting {str(progress)} out of {str(total)}. Remaining: {str(total - progress)}')
    except Exception as e:
        with open('json_file/log_failed.txt', 'a+', encoding='utf-8') as f:
            f.write(f'{file_name} error: {e}\n')

Voorbeeldregels uit restaurants.jsonl in JSON Lines-formaat

JSON Lines-formaat (restaurants.jsonl)

Het Power BI-datamodel

Het model heb ik bewust simpel gehouden. Er zijn 4 bronbestanden.

  • restaurants.jsonl met alle Thuisbezorgd-data
  • gem2020.csv met gemeentedata (naam + sleutel)
  • pc6-gwb2020.csv met postcodes en hun bijbehorende gemeente
  • gem_inwoners_2020.csv met het inwonertal per gemeente

De CBS-data vindt u hier (gem2020.csv en pc-gwb2020.csv) en hier (gem_inwoners_2020.csv).

Uit restaurants.jsonl maakte ik base_dataset, waarin de JSON-structuur wordt omgezet naar kolommen. Vanuit de base_dataset maakte ik 4 datasets voor het model. Voor de kitchens heb ik de lijst met kitchens uitgeklapt naar nieuwe rijen. Elke rij in die tabel bevat een restaurantid en een kitchen_name.

Power BI-transformatieoverzicht van vier bronbestanden naar de modeltabellen

Overzicht van de datatransformaties

Primary keys?

Waar uw dataset ook vandaan komt, op datakwaliteitsproblemen stuit u altijd. Ik verwachtte dat restaurantid uniek was. Helaas bleek dat niet zo bij het selecteren van de unieke combinatie van restaurant_id en name. Ik dook in de data en het lijkt erop dat deze restaurants recent hernoemd zijn. Dat is waarschijnlijk gebeurd tijdens mijn dataverwerking van 7 uur. Op bijna 10.000 unieke restaurants verbaast het me niet dat er 4 kleine wijzigingen hadden in een tijdvenster van 7 uur, zeker nu er zoveel nieuwe restaurants bijkomen door de beperkingen op binnen eten.

Dubbele restaurant-ID's veroorzaakt door restaurants die tijdens het scrapen zijn hernoemd

Datamodel

Na het oplossen van het probleem (voorlopig door de 4 dubbele sleutels weg te filteren; als deze dataset blijft boeien, los ik het later echt op). De keuken van een restaurant staat in een aparte tabel, omdat één restaurant meerdere “kitchens” kan hebben. Definitiekundig is de term kitchen vreemd, maar het is wat de bron-HTML gebruikt. Kitchen is een mix van echte keukens en producten. Een restaurant kan zowel “Italiaans” als “Italiaanse pizza” als kitchen hebben. Er lijkt wel een limiet te zitten op het aantal kitchens dat een restaurant op Thuisbezorgd mag kiezen; die limiet lijkt 3 te zijn. Restaurants moeten hun kitchens dus strategisch kiezen om de kans te vergroten dat ze in zoekopdrachten van klanten verschijnen. Wie sushi verkoopt, hoeft “Japans” niet als kitchen op te nemen. Zeker niet als er naast sushi ook poke bowls en snacks in het assortiment zitten. De rest van het datamodel is rechttoe rechtaan. Restaurant_location lost de veel-op-veel-relatie op tussen gemeente en restaurant. municipality_inhabitants is later toegevoegd om het aantal restaurants per 1000 inwoners te berekenen.

Power BI-datamodel met tabellen voor restaurant, kitchen, locatie en gemeente

Conclusie

Nederland telt een hoop sushirestaurants, dus de situatie in Gouda is niet uniek. Leuk weetje: Gouda en Amsterdam hebben allebei precies 0,16 sushirestaurants die binnen de gemeente bezorgen per 1000 inwoners. Wat me ook verraste, was de spreiding van sushi over het hele land. In bijna alle gemeenten is sushi te bestellen. De uitzonderingen zijn op één hand te tellen en goed te verklaren, want het gaat vrijwel steeds om dunbevolkte gebieden. Deze dataset dekt alleen Thuisbezorgd; er kunnen lokale sushizaken bestaan die niet op Thuisbezorgd actief zijn. En een restaurant kan sushi verkopen zonder het als een van zijn “kitchens” te vermelden, wat mij vanuit marketingoogpunt een fout lijkt, maar het kan.

Het zou interessant zijn om deze dataset te vergelijken met een extract van een maand later. Dan weten we hoeveel reviews elk restaurant er in een maand bij kreeg, hoeveel restaurants van Thuisbezorgd verdwenen, hoeveel restaurants erbij kwamen, enzovoort.

Wordt vervolgd…