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>