Skyr er dyrt, men altid på tilbud. På https://etilbudsavis.dk/search/skyr kan man finde de aktuelle tilbud fra tilbudsaviserne.
Jeg har udviklet en lille hjemmeside, der monitorerer priserne på skyr i Storkøbenhavn. Du kan finde den på https://wallnot.dk/skyr.

Sådan virker det…
Skyrpriser består af:
- En database med en model defineret i Django’s models.py
- Et python-script, cron_skyrpriser.py, der køres som job en gang i døgnet og skraber tilbud på skyr fra https://etilbudsavis.dk og gemmer dem i databasen
- Et view i Django’s views.py, der gør data fra databasen klar i en struktur, der er brugbar i sidens skabelon
- En skabelon (index.html), som indeholder sidens HTML-kode, stylesheet og det javascript der, ved hjælp af biblioteket Chart.js, genererer grafen over Skyrpriser
Jeg starter med datamodellen i models.py. Hovedtabellen hedder “Offer” og gemmer typen af skyr, hvilken butik, der er tale om, hvilken dato tilbuddet gælder og kiloprisen for tilbuddet:
from django.db import models from django.utils import timezone from django.contrib import admin class Offer(models.Model): skyr_type = models.CharField('Skyrtype', max_length=100) store = models.CharField('Butik', max_length=100) date = models.DateField('Dato') price_per_kilo = models.FloatField('Kilopris') added_at = models.DateTimeField('Tilføjelsesdato', default=timezone.now, editable=False) class OfferAdmin(admin.ModelAdmin): list_display = ('store','skyr_type','date') list_filter = ('store', 'skyr_type') search_fields = ['store', 'skyr_type']
Så kommer jeg til cron_skyrpriser.py. Jeg har brugt min browsers udviklerværktøjer til at finde ud af, hvordan jeg taler med API’et for etilbudsavis.dk og får data tilbage i JSON-format. Jeg henter de felter, jeg har brug for og gemmer dem i databasen, hvis de ikke allerede findes i databasen:
import requests from datetime import datetime, date, timedelta from bs4 import BeautifulSoup import psycopg2 from psycopg2 import Error import pytz now = datetime.now() cph = pytz.timezone('Europe/Copenhagen') # Connect to database try: connection = psycopg2.connect(user = "[slettet]", password = "[slettet]", host = "[slettet]", port = "", database = "[slettet]") cursor = connection.cursor() except (Exception, psycopg2.Error) as error: print ("Error while connecting to PostgreSQL", error) ### INSERT SKYR IN DATABASE FUNCTION ### def insert_in_database(connection, offer): with connection: with connection.cursor() as cur: try: sql = ''' SELECT * from skyrpriser_offer WHERE skyr_type = %s AND store = %s AND date = %s''' cur.execute(sql, (offer[0], offer[1], offer[2])) results = cur.fetchall() if not results: sql = ''' INSERT INTO skyrpriser_offer(skyr_type,store,date,price_per_kilo,added_at) VALUES(%s,%s,%s,%s,%s)''' cur.execute(sql, offer) except Error as e: print(e, offer) # Scrape prices of skyr and save to database def main(): url = "https://etilbudsavis.dk/api/squid/v2/sessions" session = requests.Session() headers = { 'authority': 'etilbudsavis.dk', 'accept': 'application/json', 'dnt': '1', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36', 'x-api-key': '[slettet]', 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty', 'referer': 'https://etilbudsavis.dk/search/skyr', 'accept-language': 'en-US,en;q=0.9', 'cookie': 'sgn-flags=^{^%^22flags^%^22:^{^}^}; sgn-consents=^[^]', } session.headers.update(headers) response = session.get(url) url = "https://etilbudsavis.dk/api/squid/v2/offers/search?query=skyr&r_lat=55.695497&r_lng=12.550145&r_radius=20000&r_locale=da_DK&limit=24&offset=0" response = session.get(url) response_json = response.json() for item in response_json: skyr_type = item['heading'] store = item['branding']['name'] valid_from = item['run_from'] valid_from = datetime.strptime(valid_from, '%Y-%m-%dT%H:%M:%S%z').astimezone(cph).date() valid_to = item['run_till'] valid_to = datetime.strptime(valid_to, '%Y-%m-%dT%H:%M:%S%z').astimezone(cph).date() price = item['pricing']['price'] amount = item['quantity']['size']['from'] measure = item['quantity']['unit']['symbol'] if measure == "g": price_per_kilo = price/amount*1000 elif measure == "kg": price_per_kilo = price/amount number_of_days = int((valid_to - valid_from).days) for day in range(number_of_days+1): date = valid_from + timedelta(day) offer = (skyr_type, store, date, price_per_kilo, now) insert_in_database(connection, offer) main() print("Opdaterede skyrpriser")
I Django’s views.py henter jeg databasetabellens indhold og gør dem klar vha. nogle løkker, som formentlig er ret ineffektive, men virker OK:
from django.shortcuts import render from .models import Offer from django.db.models import Max, Min from datetime import timedelta # Main page def skyrindex(request): offers = Offer.objects.all().order_by('date') context = {} if offers: date_min = Offer.objects.aggregate(Min('date'))['date__min'] date_max = Offer.objects.aggregate(Max('date'))['date__max'] number_of_days = (date_max - date_min).days dates = [] for i in range(number_of_days + 1): dates.append(date_min + timedelta(i)) structure = {} for offer in offers: if not offer.store in structure: structure[offer.store] = {} if not offer.skyr_type in structure[offer.store]: structure[offer.store][offer.skyr_type] = [] structure[offer.store][offer.skyr_type].append({offer.date: round(offer.price_per_kilo, 1)}) new_structure = {} for store, offer in structure.items(): new_structure[store] = {} for skyr_type, prices in offer.items(): new_structure[store][skyr_type] = [] for date in dates: have_price = False for price in prices: if date in price: new_structure[store][skyr_type].append({date: price[date]}) have_price = True if not have_price: new_structure[store][skyr_type].append({date: ","}) context = {'dates': dates, 'structure': structure, 'new_structure': new_structure} return render(request, 'skyrpriser/index.html', context)
Og til sidst har jeg så index.html, som også har nogle (for mig) ret komplicerede løkker for at strukturere data i et format som Javascript-bibliteket Chart.js kan forstå.
Jeg benytter mig af nogle, synes jeg, smarte features i Django’s løkke-funktioner:
- cycle: Gør det muligt at løbe igennem en prædefineret række værdier hver gang løkken køres, her brugt til at få en ny farve per linje i diagrammet.
- forloop.last: Den sidste gang en løkke kører, sættes variablen forloop.last. Det gør at jeg fx nemt kan sætte komma efter hver dato i diagrammets x-akse, undtagen efter den sidste dato på listen.
Her er index.html:
<h1>Skyrpriser</h1> <canvas id="myChart"></canvas> <script> var ctx = document.getElementById('myChart'); var myChart = new Chart(ctx, { type: 'line', data: { labels: [{% for date in dates %}'{{ date|date:"d. b" }}'{% if not forloop.last %}, {% endif %}{% endfor %}], datasets: [{% for store, offer in new_structure.items %} {% for skyr_type, prices in offer.items %} { fill: false, backgroundColor: {% cycle "'#e9ecef'," "'#ffc9c9'," "'#fcc2d7'," "'#eebefa'," "'#d0bfff'," "'#bac8ff'," "'#a5d8ff'," "'#99e9f2'," "'#96f2d7'," "'#b2f2bb'," "'#d8f5a2'," "'#ffec99'," "'#ffd8a8'," %} borderColor: {% cycle "'#e9ecef'," "'#ffc9c9'," "'#fcc2d7'," "'#eebefa'," "'#d0bfff'," "'#bac8ff'," "'#a5d8ff'," "'#99e9f2'," "'#96f2d7'," "'#b2f2bb'," "'#d8f5a2'," "'#ffec99'," "'#ffd8a8'," %} label: '{{ store|safe }}, {{ skyr_type|safe }}', data: [ {% for price in prices %} {% for date, cost in price.items %} {% if not cost == "," %} {{ cost|unlocalize }} {% endif %} {% endfor %} {% if not forloop.last %} , {% endif %} {% endfor %} ] }{% if not forloop.last %},{% endif %} {% endfor %} {% if not forloop.last %},{% endif %} {% endfor %}] }, options: { responsive: true, spanGaps: false, title: { display: true, text: 'Tilbud på Skyr over tid' }, tooltips: { mode: 'index', intersect: false, }, hover: { mode: 'nearest', intersect: true }, scales: { xAxes: [{ display: true, scaleLabel: { display: true, labelString: 'Dato' } }], yAxes: [{ display: true, scaleLabel: { display: true, labelString: 'Pris pr. kilo Skyr i kroner' } }] } } }); </script>