Prisovervågning på Skyr med Python og Django

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 ser hjemmesiden med historiske og (et lille bitte stykke) fremtidige priser på skyr ud.

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>