Folkets wallnot.dk

En bruger på wallnot.dk skrev til mig og foreslog at lade brugerne på siden vurdere kvaliteten af de artikler, siden linker til, ligesom på fx Hacker News. Idéen er at gode artikler så kan ligge øverst, mens metervaren synker ned i bunden – hvis altså folket har forstand på at vurdere den slags.

Jeg forsøgte at lave en sådan løsning, og den kan du nu prøve af.

Dynamik med JavaScript

For at gøre det helt smart og dynamisk, havde jeg brug for noget JavaScript, der kan fyre en stemme af sted, så snart en bruger klikker på ▲ eller ▼.

Jeg er ikke helt ferm til JavaScript, men jeg begynder at forstå det, og med god hjælp og lidt copy/paste fra forskellige kilder, landede jeg til sidst på noget kode, der ser ud til at virke.

Den første del henter en såkaldt CRSF-cookie, der sørger for, at man er nødt til at besøge Wallnot, inden man kan stemme på artikler, og at man ikke kan stemme på vegne af andre fra andre hjemmesider.

Den anden del sender en forespørgsel af sted med cookie-værdien og selve stemmen og opdaterer stemmeantallet på siden, når forespørgslen er behandlet.

function getCookie(name) {
	let cookieValue = null;
	if (document.cookie && document.cookie !== '') {
		const cookies = document.cookie.split(';');
		for (let i = 0; i < cookies.length; i++) {
			const cookie = cookies[i].trim();
			if (cookie.substring(0, name.length + 1) === (name + '=')) {
				cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
				break;
			}
		}
	}
	return cookieValue;
}
const csrftoken = getCookie('csrftoken');

document.querySelectorAll('.vote').forEach(function(el){
  el.addEventListener('click', function() {
		article_votes_id = this.id.substring(0, this.id.indexOf('_')) + '_votes';
		votes_to_replace = document.getElementById(article_votes_id)
		fetch('/process_vote', {
			method: "POST",
			headers: {
				"X-CSRFToken": csrftoken,
			},
			body: JSON.stringify({
				vote: this.id
			})
		}).then(function (response) {
        return response.json();
    })
        .then(function (data) {
			votes_to_replace.innerHTML = data.votes;
        })
        .catch(function (err) {
            console.log(err);
        });
  });
});

Behandling af forespørgslen

Forespørgslen sender et artikel-id af sted sammen med information om der er tale om en ▲-stemme eller en ▼-stemme.

I Djangos views.py skriver jeg en funktion, der kan modtage forespørgslen og returnerer stemmeantallet efter forespørgslen er behandlet. Funktionen sender JSON-data tilbage til mit JavaScript, hvis (og kun hvis) stemmen har et eksisterende artikel-id efterfulgt af enten “_up” eller “_down”. For alt andet svarer funktionen tilbage, at den er en tepotte og derfor ikke kan hjælpe:

def process_vote(request):
	if request.method == "POST":
		try:
			vote = json.loads(request.body.decode())['vote']
			article_id = vote[:vote.index('_')]
			article = Article.objects.get(id=article_id)
			if '_up' in vote:
				article.votes += 1
			elif '_down' in vote:
				article.votes -= 1
			else:
				return HttpResponse(status=418)
			article.save()
			votes = {'votes': article.votes}
			return JsonResponse(votes)
		except:
			return HttpResponse(status=418)
	return HttpResponse(status=418)

En sorteringsalgoritme

Som det allersidste havde jeg brug for at udvikle en sorteringsalgoritme, der tog højde for artiklers alder, som jeg kunne bruge i mit view. Den tog lidt tid at skrive, fordi det nogle gange kan være svært at regne ud, hvordan man med Djangos databaseforespørgselssyntaks kan lave de beregninger, man har brug for, direkte med forespørgslen til databasen.

Algoritmen gør sådan her:

  • Tager antal stemmer og lægger 1 til. Hvis alle artikler starter på 1, forhindrer jeg at artikler med et positivt antal stemmer altid vil ligge over artikler uden stemmer overhovedet.
  • Deler dette tal med 1 plus antal timer siden artiklens offentliggørelsestidspunkt.
  • Antal timer udregnes ved at tage antal dage siden offentliggørelsestidspunktet og gange med 24 og dertil lægge det yderligere antal timer fra det samlede interval i dage og timer siden offentliggørelsestidspunktet.
  • For at undgå at komme til at dele med 0, lægger jeg 1 til antal timer og tager den absolutte værdi af antal timer siden offentliggørelsestidspunktet. Det er nødvendigt, fordi medierne engang imellem offentliggør artikler med et publiceringstidspunkt i fremtiden.
  • Fordi jeg deler stemmer med antal timer siden offentliggørelse, vil en nyhed hurtigt miste sin “værdi”. Hvis Folkets Wallnot ikke bliver en kæmpe succes, kan det være at jeg skal dele med antal dage i stedet, sådan “straffen” for at være en gammel artikel ikke bliver ligeså mærkbar.

Her er algoritmen skrevet som forespørgsel i Django:

articles = Article.objects.filter(paywall_detected=False)
    .annotate(score=ExpressionWrapper((F('votes') + 1) /
    (1+Abs(ExtractDay(Now()-F('date'))*24 + ExtractHour(Now()-F('date')))),output_field=FloatField()))
    .order_by('-score','-date')

En lille kodeforbedring på Wallnot

I takt med at wallnot.dk har fået flere og flere funktionaliteter, og dermed også flere databasefelter, er koden bag blevet rodet.

Her er et eksempel på, hvordan min kode blev svær for mig selv at forstå, og hvad jeg gjorde for at gøre den lidt bedre.

For at hente artikler til Wallnot, besøger en robot avisers hjemmesider og finder gratis-artikler. Logikken er lidt forskellig fra medie til medie, for det er forskelligt, hvordan medierne afslører, om en artikel er bag en betalingsmur eller ej.

Det er også meget forskelligt, om aviserne benytter sig af “gratis artikler, der kræver login” (Politiken, Ingeniøren, Jyllands-Posten), og det er forskelligt, hvad risikoen er for dubletter (Ritzau-artikler går igen mange steder, men ikke alle. Jyllands-Posten og Finans kopierer artikler til og fra hinanden.)

Her er for eksempel den gamle logik for Danmarks Radio:

def dr():
	# Define medium
	medium = "dr"
	
	# Request site
	data = requests.get("https://www.dr.dk/nyheder/service/feeds/allenyheder/")
	result = data.text
	
	# Soup site and create a list of links and their titles
	soup = BeautifulSoup(result, "xml")
	
	# List of unique urls
	urllist = {link.text for link in soup.find_all('link') if "/nyheder/" in link.text and not any(term in link.text for term in excluded) and not any(term == link.text for term in mustnotbe)}
	
	# Loop that requests all article links, soups them and checks whether they have a paywall and generates a list with current free articles. Also gets titles of links.	
	for url in urllist:
		if not 'http' in url:
			url = 'https://dr.dk' + url	
		if url not in lastbatch and url not in newbatch:
			try:
				data = requests.get(url)
				result = data.text
				soup = BeautifulSoup(result, "lxml")
				node_id = soup.find('meta', attrs={'name':'ensighten:urn'})
				if node_id:
					id = node_id['content'][node_id['content'].find("article:")+8:]
					urlid = "dr_" + id
					if urlid not in newbatch and urlid not in lastbatch:
						title = soup.find('meta', attrs={'property':'og:title'})
						title = title['content']
						title = title.strip(" ")						
						#api_url = "https://www.dr.dk/tjenester/urd/tms/urn:dr:drupal:article:" + id
						#api_request = requests.get(api_url)
						timestamp = soup.find('meta', attrs={'property':'article:published_time'})['content']
						dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z')
						if '<p class="dre-article-body__paragraph dre-variables">/ritzau/</p>' in result:
							ritzau = True
							body = soup.find('div', attrs={'class': 'dre-article-body'})
							firstparagraph = body.find('p').get_text()
							firstparagraph = firstparagraph.strip(" ")
						else:
							ritzau = False

						if ritzau == True:	
							article = (title, urlid, dateofarticle, medium, url, datetime.now(), ritzau, firstparagraph)
						else:	
							article = (title, urlid, dateofarticle, medium, url, datetime.now(), ritzau)

						insert_unique_id_article(connection, article)
						newbatch.append(url)
						newbatch.append(urlid)				
			except Exception as e:
				print(url)
				print(e)
		else:
			newbatch.append(url)

Hvert medie har altså sin egen funktion til at hente artikler, og som du måske kan læse af koden, kalder hver funktionen en anden funktion, som indsætter artikler i databasen:

insert_unique_id_article(connection, article)
newbatch.append(url)

Problemet

Funktionen insert_unique_id_article var mildest talt blevet rodet.

Først blev indsat nogle ekstra værdier til article for at understøtte, at jeg for et tidspunkt valgte at artikler, der senere får en paywall, ikke slettes fra databasen, men blot markeres som med paywall.

Så havde den en overordnet logik, der skilte artikler, der skal tjekkes for dubletter fra artikler, der ikke skal.

Og under denne logik nogle forskellige variationer for, hvor mange ekstra variable og hvilke værdier, disse variable har, der tilføjes til hver article. Og mange forskellige sql-sætninger i forgreningen til rent faktisk at tilføje hver artikel til databasen.

Her er den uoverskuelige kode:

def insert_unique_id_article(connection, article):
	with connection:
		with connection.cursor() as cur:
			try:
				article += (False, "")	# Not currently behind a paywall, no current archive url, 
				if not article[6] and not article[3] == "jyllandsposten" and not article[3] == "finansdk":	# Ritzau is false and not jyllandsposten and not finansdk
					if len(article) == 10:	# article has loginwall variable inserted already
						article += (False, ) # Not a duplicate
						sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,loginwall,paywall_detected,archive_url,duplicate)
						VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
						cur.execute(sql, article)
					else:
						article += (False, False)	# Not behind loginwall, not a duplicate
						sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,paywall_detected,archive_url,loginwall,duplicate)
						VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
						cur.execute(sql, article)
				else:	# Ritzau is true or jyllandsposten or finans
					sql = ''' SELECT * from wall_article WHERE title ILIKE %s OR excerpt ILIKE %s'''
					article_title = "%"+article[0]+"%"
					article_excerpt = "%"+article[7]+"%"
					cur.execute(sql, (article_title, article_excerpt))
					results = cur.fetchall()
					if results:
						duplicate = False
						for result in results:
							timestamp = result[3].astimezone(pytz.utc)
							# Time in minutes between suspected duplicates is calculated
							if article[2] < timestamp:
								difference_in_minutes = (timestamp-article[2]).seconds/60
							elif article[2] > timestamp:
								difference_in_minutes = (article[2]-timestamp).seconds/60
							elif article[2] == timestamp:
								difference_in_minutes = 0
							
							# Less than or 5 hours between, mark duplicate and insert as duplicate
							if difference_in_minutes <= 300:
								duplicate = True				# important to avoid duplicate insert
								article += (True, False)		# a duplicate, no loginwall
								sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,paywall_detected,archive_url,duplicate,loginwall)
								VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
								cur.execute(sql, article)
								break
						# ONLY FOR NON-duplicates
						if not duplicate:
							article += (False, False) # Not a duplicate, no loginwall
							sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,paywall_detected,archive_url,duplicate,loginwall)
								VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
							cur.execute(sql, article)
					else:
						article += (False, False) # Not a duplicate, no loginwall
						sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,paywall_detected,archive_url,duplicate,loginwall)
						VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
						cur.execute(sql, article)
			except Error as e:
				# If unique_id is already in database and the article is a politiken share link,
				# the original URL is updated with the share url
				if e.pgcode == "23505" and "politiken.dk/del" in article[4]:
					connection.rollback()
					sql = ''' Update wall_article set url = %s, loginwall = %s where unique_id = %s '''
					cur.execute(sql, (article[4], True, article[1]))
				elif not e.pgcode == "23505":
					print(e, article)

Løsning

Jeg løste – eller måske formindskede – problemet ved at ensarte, hvor mange variable hvert medies funktion sender til funktionen insert_unique_id_article, sådan at medierne uanset om de fx bruger “login-mure” eller ej, sender en article af samme længde med samme variabelrækkefølge af sted til insert_unique_id_article.

Så nu går disse kodelinjer igen og er identiske, uanset hvilket medie, der er tale om:

article = (title, urlid, dateofarticle, medium, url, datetime.now(), ritzau, excerpt, loginwall)
insert_unique_id_article(connection, article)

Det har gjort insert_unique_id_article en del kortere og mere læsbar. Der er nu kun én mulig sql-sætning, færre mulige forgreninger og det er ensartet, hvor mange variable der tilføjes til article i hver forgrening:

def insert_unique_id_article(connection, article):
	with connection:
		with connection.cursor() as cur:
			try:
				sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,loginwall,paywall_detected,archive_url,duplicate)
				VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
				article += (False, "")	# Add defaults: Not currently behind a paywall, no current archive url, 
				if not article[6] and not article[3] == "jyllandsposten" and not article[3] == "finansdk":	# Ritzau is false and not jyllandsposten and not finansdk so no duplicate risk based on current newspaper practices
					article += (False, ) # Not a duplicate
					cur.execute(sql, article)
				else:	# Ritzau is true or jyllandsposten or finans, so run duplicate check
					search_sql = ''' SELECT * from wall_article WHERE title ILIKE %s OR excerpt ILIKE %s'''
					article_title = "%"+article[0]+"%"
					article_excerpt = "%"+article[7]+"%"
					cur.execute(search_sql, (article_title, article_excerpt))
					results = cur.fetchall()
					if results:
						duplicate = False
						for result in results:
							timestamp = result[3].astimezone(pytz.utc)
							# Time in minutes between suspected duplicates is calculated
							if article[2] < timestamp:
								difference_in_minutes = (timestamp-article[2]).seconds/60
							elif article[2] > timestamp:
								difference_in_minutes = (article[2]-timestamp).seconds/60
							elif article[2] == timestamp:
								difference_in_minutes = 0
							
							# Less than or 5 hours between, mark duplicate and insert as duplicate
							if difference_in_minutes <= 300:
								duplicate = True		# important to avoid duplicate insert
								break
						article += (duplicate, ) # Add duplicate status
						cur.execute(sql, article)
					else:
						article += (False, ) # Not a duplicate
						cur.execute(sql, article)
			except Error as e:
				# If unique_id is already in database and the article is a politiken share link,
				# the original URL is updated with the share url
				if e.pgcode == "23505" and "politiken.dk/del" in article[4]:
					connection.rollback()
					sql = ''' Update wall_article set url = %s, loginwall = %s where unique_id = %s '''
					cur.execute(sql, (article[4], True, article[1]))
				elif not e.pgcode == "23505":
					print(e, article)

En bedre løsning?

Min problematik handler rigtigt meget om vægtningen mellem graden af identisk kode, der går igen flere steder, og graden af abstraktion.

Det er rart og logisk for mig, at kunne arbejde med og rette fejl i hvert enkelt medie hver for sig. Men der er meget kode, der går igen for hvert medie, og også forskellig kode, der gør det samme, afhængig af hvornår jeg lige har haft fat i koden sidst.

Det er besværligt, at jeg, hvis jeg på et tidspunkt indfører en ny funktionalitet, der kræver et nyt databasefelt, hvis værdi kan være forskellig fra medie til medie, er nødt til at opdatere hvert enkelt medies funktion, for at sikre mig at længden på den artikel, der sendes til insert_unique_id_article er den samme for alle medier.

En løsning kunne være et lidt højere abstraktionsniveau, hvor jeg:

  • Laver article om til en ordbog (dictionary) i hvert medies funktion.
  • Sørger for at hvert medie sender article til en hjælpefunktion, der gør artiklen klar til at indsætte i databasen, ved at gennemgå ordbogens nøgler og tilføje de nøgler, der evt. mangler, for at artiklen har de nødvendige variable til at kunne indsættes. Funktionen kunne hedder prepare_article.
  • Sætter prepare_article til at sende artiklen til en endnu mere forenklet insert_unique_id_article.

En anden mulig løsning kunne være at kigge nærmere på modulet psycopg2, som er det modul, der lader mit Python-program tale med min database. Lige nu bruger jeg datatypen tuple når jeg indsætter data i databasen, og hvis der mangler værdier i min tuple i forhold til min datamodel for artikler, fungerer mit program ikke. Måske kan psycopg2 forstå dictionaries i stedet og fodres med standardværdier, der kan indsættes, hvis en artikel mangler felter fra datamodellen?

Det må jeg finde ud af, når jeg har tid, eller et problem der kræver, at jeg finder en bedre løsning.

RSS-feeds med Django

En Wallnot-bruger spurgte mig om ikke Wallnot burde have et RSS-feed? Jo da.

Det viste sig at den slags er indbygget i Django og meget nemt at lave.

Jeg oprettede feeds.py med to forskellige feeds. Et for alle artikler fra Wallnot, et for artikler for bestemte medier:

from django.contrib.syndication.views import Feed
from wall.models import Article
from django.urls import reverse

class RssFeed(Feed):
	title = "Nyeste artikler fra wallnot.dk"
	link = "/rss/"
	description = "De allernyeste artikler uden paywall fra wallnot.dk"
	def items(self):
		return Article.objects.filter(paywall_detected=False).order_by('-date')[:20]

	def item_title(self, item):
		return item.title

	def item_description(self, item):
		return "Artikel fra " + item.get_medium_display()

	def item_link(self, item):
		return item.url

class RssFeedMedium(Feed):
	title = "Nyeste artikler fra wallnot.dk"
	description = "De allernyeste artikler uden paywall fra wallnot.dk"
	def get_object(self, request, medium):
		return medium
	
	def link(self, obj):
		return "/rss/" + obj
	
	def items(self, obj):
		return Article.objects.filter(paywall_detected=False, medium=obj).order_by('-date')[:20]

	def item_title(self, item):
		return item.title

	def item_description(self, item):
		return "Artikel fra " + item.get_medium_display()

	def item_link(self, item):
		return item.url

Og pegede på dem fra urls.py:

from django.urls import path
from . import views
from . import feeds

urlpatterns = [
	path('', views.index, name='index'),
	path('links', views.linkindex, name='linkindex'),
	path('privatliv', views.privacy, name='privacy'),
	path('om', views.about, name='wabout'),
	path('rss', feeds.RssFeed(), name='rssfeed'),
	path('rss/<str:medium>', feeds.RssFeedMedium(), name='rssfeed_medium'),
]

Og vupti!

Du finder RSS-feeds på wallnot.dk lige ved siden af Twitter-logoet.

Kalender med ugenumre og helligdage

Jeg har lavet endnu et Django-eksperiment med https://ugenr.dk som det store forbillede.

En digital kalender

https://wallnot.dk/kalender kan du altid finde en Mayland-style-kalender med helligdage og ugenumre. Kalenderen understøtter år 1 til 9999.

Den er lavet med tre side-funktioner: En der altid viser nuværende halvår (kalindex), en der viser et hvilket som helst halvår (kalperiod) og en der kan vise et hvilket som helst år (kalyear). Alle tre funktioner kalder en fjerde funktion get-dates der returnerer datoer i kalenderen for det år og/eller halvår, der efterspørges.

Her er views.py:

from django.shortcuts import render
import datetime
from workalendar.europe import Denmark	# Module containing most Danish holidays
from django.http import Http404

# Function to return all calendar dates and other context data
def get_dates(year, period, now):
	now_isocalendar = now.isocalendar()
	
	### HOLIDAY LIST FOR YEAR IS GENERATED ###
		
	# Create dictionary with all holidays of the year
	holidays = Denmark().holidays(year)
	
	all_holidays = {}
	all_holidays[datetime.date(year,5,1)] = ["Første maj", "Særlig dag"]
	all_holidays[datetime.date(year,6,5)] = ["Grundlovsdag", "Særlig dag"]
	all_holidays[datetime.date(year,12,31)] = ["Nytårsaften", "Særlig dag"]

	holiday_lookup = {
						"New year": ["Nytårsdag", "Helligdag"],
						"Holy Thursday": ["Skærtorsdag", "Helligdag"],
						"Good Friday": ["Langfredag", "Helligdag"],
						"Easter Sunday": ["Påskedag", "Helligdag"],
						"Easter Monday": ["2. påskedag", "Helligdag"],
						"Store Bededag": ["Store bededag", "Helligdag"],
						"Ascension Thursday": ["Kr. himmelfart", "Helligdag"],
						"Pentecost Sunday": ["Pinsedag", "Helligdag"],
						"Pentecost Monday": ["2. pinsedag", "Helligdag"],
						"Christmas Eve": ["Juleaften", "Særlig dag"],
						"Christmas Day": ["1. juledag", "Helligdag"],
						"Second Day of Christmas": ["2. juledag", "Helligdag"],
					}
	
	for holiday in holidays:
		# Check for two holidays on same day
		if holiday[0] not in all_holidays:
			all_holidays[holiday[0]] = (holiday_lookup[holiday[1]][0], holiday_lookup[holiday[1]][1])
		# If two on the same day, names are concenated
		else:
			all_holidays[holiday[0]] = (holiday_lookup[holiday[1]][0] + "/" + all_holidays[holiday[0]][0] , holiday_lookup[holiday[1]][1])
	
	### DATES FOR YEAR ARE GENERATED IN A DAY AND MONTH DIMENSION ###
	
	# First dimension is maximum number of days in a month
	dates_in_year = {}
	for day in range(1,32):
		dates_in_year[day] = []
	
	# Second dimension is that date for each month
	for day in range(1,32):
		for month in period:
			# If the generated day actually is a valid date, day is added to dates_in_year dictionary
			try:
				date_to_add = datetime.date(year,month,day)
				date_isocalendar = date_to_add.isocalendar()
								
				# HOLIDAY LOGIC #
				# If day is special, get type of day and name of day
				if date_to_add in all_holidays:
					type_of_day = all_holidays[date_to_add][1]
					name_of_day = all_holidays[date_to_add][0]
				# If not, type of day is normal and no name
				else:
					type_of_day = "Normal dag"
					name_of_day = "Intet navn"
				
				# HTML BORDER CLASS LOGIC #
				html_class = ""
				
				# Year of date must be the same as year of current date
				if date_isocalendar[0] == now_isocalendar[0]:
					# Week number is the same as current week number
					if date_isocalendar[1] == now_isocalendar[1]:
						# All days get a red right and red left class
						html_class = "redleft redright"
						# Sunday also gets a red bottom class
						if date_isocalendar[2] == 7:
							html_class += " redbottom"
					# Date is Sunday in the week before current
					elif date_isocalendar[1] == now_isocalendar[1] - 1 and date_isocalendar[2] == 7:
						html_class += " redbottom"
					# Same date next month is in current week
					try:
						date_next_month = datetime.date(year,month + 1,day)
						date_next_month_isocalendar = date_next_month.isocalendar()
						# Week number is the same as current week number
						if date_next_month_isocalendar[1] == now_isocalendar[1]:
							html_class = "redright"
					except ValueError:
						pass
				date_data = (date_to_add, type_of_day, name_of_day, html_class)
				dates_in_year[day].append(date_data)
			# Except when that dates does not exist, e.g. february 30
			except ValueError:
				dates_in_year[day].append("NON-EXISTING DATE")
	
	context = {'year': str(year), 'next': year+1, 'previous': year-1, 'dates_in_year': dates_in_year, 'period': period, 'now': now}
	return context
	
# Main page
def kalindex(request):
	now = datetime.datetime.now()
	year = now.year
	month = now.month
	if month < 7:
		period = range(1,7)
	else:
		period = range(7,13)
	# Run function to get calendar dates
	context = get_dates(year, period, now)
	return render(request, 'kalender/index.html', context)

# Earlier or future year page
def kalyear(request, year):
	# If year is not an integer, a 404 error is thrown
	try:
		year = int(year)
	except ValueError:
		raise Http404
	# If year is between 1 and 10000, a calendar is rendered
	if year > 0 and year < 10000:
		now = datetime.datetime.now()
		period = range(1,13)
		# Run function to get calendar dates
		context = get_dates(year, period, now)
		return render(request, 'kalender/index.html', context)
	# If not, a 404 error is thrown
	else:
		raise Http404
	
# Earlier or future year page
def kalperiod(request, year, period):
	# If year is not an integer, a 404 error is thrown
	try:
		year = int(year)
	except ValueError:
		raise Http404
	# If year is between 1 and 10000, a calendar is rendered
	if year > 0 and year < 10000 and (period == "1" or period == "2"):
		if period == "1":
			period = range(1,7)
		elif period == "2":
			period = range(7,13)
		now = datetime.datetime.now()
		# Run function to get calendar dates
		context = get_dates(year, period, now)
		return render(request, 'kalender/index.html', context)
	# If not, a 404 error is thrown
	else:
		raise Http404	

Sidens skabelon index.html ser en lille smule rodet ud (af hensyn til at minimere sidens størrelse). Skabelonen genererer en tabel ved at gennemgå alle dagene i kalenderen og tilføje særlige layout-regler for lørdage, søndage, helligdage, den nuværende uge, dag og ugenummer osv.

Her er den del af den, der benytter sig af Djangos skabelon-funktioner. (Resten af koden kan du finde ved at bruge “view source” på https://wallnot.dk/kalender):

{% if period|length == 12 %}
{% if not year == "1" %}<a href="{% url 'kal_year' previous %}" title="Se kalender for året før">« forrige</a>{% endif %}<h1> Kalender for år {{ year }} </h1>{% if not year == "9999" %}<a href="{% url 'kal_year' next %}" title="Se kalender for året efter">næste »</a>{% endif %} <a class="calendartype" href="{% if now|date:"n" == "7" or now|date:"n" == "8" or now|date:"n" == "9" or now|date:"n" == "9" or now|date:"n" == "10" or now|date:"n" == "11" or now|date:"n" == "12" and now|date:"Y" == year %}{% url 'kal_period' year 2 %}{% else %}{% url 'kal_period' year 1 %}{% endif %}" title="Gå til halvårskalender">Til halvårskalender</a>
{% elif period|last == 6 %}
{% if not year == "1" %}<a href="{% url 'kal_period' previous 2 %}" title="Se kalender for halvåret før">« forrige </a>{% endif %}<h1> Kalender for år {{ year }}, første halvår </h1><a href="{% url 'kal_period' year 2 %}" title="Se kalender for halvåret efter">næste »</a> <a class="calendartype" href="{% url 'kal_year' year %}" title="Gå til helårskalender">Til helårskalender</a>
{% else %}
<a href="{% url 'kal_period' year 1 %}" title="Se kalender for halvåret før">« forrige</a><h1> Kalender for år {{ year }}, andet halvår </h1>{% if not year == "9999" %}<a href="{% url 'kal_period' next 1 %}" title="Se kalender for halvåret efter">næste »</a>{% endif %} <a class="calendartype" href="{% url 'kal_year' year %}" title="Gå til helårskalender">Til helårskalender</a>
{% endif %}

<p>I dag er det {{ now|date:"l" }} den {{ now|date }} i uge {{ now|date:"W" }}</p>

<table>
	<thead>
		<tr>
		{% if period|length == 12 %}
			<th>Januar</th>
			<th>Februar</th>
			<th>Marts</th>
			<th>April</th>
			<th>Maj</th>
			<th>Juni</th>
			<th>Juli</th>
			<th>August</th>
			<th>September</th>
			<th>Oktober</th>
			<th>November</th>
			<th>December</th>
		{% elif period|last == 6 %}	
			<th>Januar</th>
			<th>Februar</th>
			<th>Marts</th>
			<th>April</th>
			<th>Maj</th>
			<th>Juni</th>
		{% else %}
			<th>Juli</th>
			<th>August</th>
			<th>September</th>
			<th>Oktober</th>
			<th>November</th>
			<th>December</th>		
		{% endif %}	
		</tr>
	</thead>
	<tbody>
	{% for month, monthdays in dates_in_year.items %}
		<tr>
		{% for day in monthdays %}
			<td{% if day.1 == "Helligdag" or day.0|date:"w" == "0" %} class="holy{% if day.3 %} {{ day.3 }}{% endif %}"{% elif day == "NON-EXISTING DATE" %} class="noborder"{% elif year == now|date:"Y" and day.3 %} class="{{ day.3 }}"{% endif %}>
			
				<div title="{{ day.0|date:"l"|capfirst }}" class="weekday{% if day.0|date:"w" == "6" %} saturday{% endif %}{% if day.0|date == now|date %} red{% endif %}">{{ day.0|date:"D"|slice:":1"|upper }}</div>
	
				<div class="datenum{% if day.0|date:"w" == "6" %} saturday{% endif %}{% if day.0|date == now|date %} red{% endif %}">{{ day.0|date:"j" }}</div>

				{% if day.0|date:"w" == "1" %}<div title="Uge {{ day.0|date:"W" }}" class="weeknum{% if day.0|date:"Y W" == now|date:"Y W" %} red{% endif %}">{{ day.0|date:"W" }}</div>{% endif %}

				{% if day.1 == "Helligdag" or day.1 == "Særlig dag" %}<div title="{{ day.2 }}" class="named{% if "/" in day.2 and period|length == 12 %} named-small{% endif %}{% if day.0|date == now|date %} red{% endif %}">{{ day.2 }}</div>{% endif %}
				
			</td>
		{% endfor %}	
	</tr>
	{% endfor %}	
	</tbody>
</table>

Wallnot i version 2.0

En af Wallnots få (men trofaste) brugere, bad om arkiv- og søgefunktionalitet på Wallnot.

Det krævede en større omlægning af Wallnot fra:

  • En side, der viser links til et øjebliksbillede af gratisartikler fra forsiden af danske netaviser.

Til:

  • En side der løbende arkiverer links til gratisartikler fra danske netaviser

Det kræver:

  • En bagvedliggende database
  • Løbende vedligeholdelse så links, der ændrer status fra gratis- til betalingsartikler, fjernes fra siden

Den nye Wallnot har:

  • Søgefunktion på artikeloverskrifter
  • Arkiv, der hele tiden bliver større
  • Zetland- og delte Politiken-artikler fra de sidste par år. Zetlandarkivet er nærmest komplet.
  • En robot, der løbende tjekker links fra de sidste par dage for ændret betalingsmursstatus
  • Mulighed for at filtrere Ritzau-telegrammer og dubletartikler fra
  • Bevaret hurtig- og enkeltheden fra version 1.

Arkitekturen bag Wallnot version 2

Version 2 af Wallnot er udviklet i Django, mens robotterne der indsamler og vedligeholder links er skrevet i Python.

Selve omlægningen til Django er faktisk enkel.

I models.py beskrives datamodellen, altså felterne i den bagvedliggende database:

from django.db import models
from django.utils import timezone
from django.contrib import admin

# Create your models here.
class Article(models.Model):
	title = models.CharField('Overskrift', max_length=500)
	unique_id = models.CharField('Avisens artikel-id', max_length=20, unique=True, null=True, blank=True)
	date = models.DateTimeField('Publiceringstidspunkt')
	MEDIUM_CHOICES = (
		('politiken', 'Politiken'),
		('berlingske', 'Berlingske'),
		('jyllandsposten', 'Jyllandsposten'),
		('information', 'Information'),
		('kristeligtdagblad', 'Kristeligt Dagblad'),
		('weekendavisen', 'Weekendavisen'),
		('zetland', 'Zetland'),
		('finansdk', 'Finans.dk'),
		('borsen', 'Børsen'),
		('arbejderen', 'Arbejderen'),
	)
	medium = models.CharField('Medie', max_length=30, choices=MEDIUM_CHOICES)
	url = models.URLField('Adresse', max_length=400, unique=True)
	ritzau = models.BooleanField('Ritzautelegram', default=False, null=True, blank=True)
	excerpt = models.CharField('Første sætning', max_length=1000, null=True, blank=True)
	duplicate = models.BooleanField('Dublet', default=False, null=True, blank=True)
	user_reports_paywall = models.BooleanField('Brugerrapporteret paywall', default=False, null=True)
	created_at = models.DateTimeField('Tilføjet den', default=timezone.now, editable=False)

class ArticleAdmin(admin.ModelAdmin):
	list_display = ('title','unique_id','ritzau','duplicate','excerpt','date')
	list_filter = ('medium', 'user_reports_paywall', 'ritzau','duplicate')
	search_fields = ['title', 'unique_id', 'excerpt']

Derudover skal der bygges et view, der beskriver forespørgslen til databasen. Her i en forkortet udgave uden logikken bag brugerrapportering af links bag paywall:

from django.shortcuts import render
from django.core.paginator import Paginator
import requests
import json
from .models import Article

def index(request):
	articles = Article.objects.order_by('-date')
	searchterm = request.GET.get('q')
	medium = request.GET.get('m')
	ritzau = request.GET.get('r')
	duplicates = request.GET.get('d')
	newwindow = request.GET.get('w')
	if searchterm:
		firstsearchcharacter = searchterm[:1]
		# Exclude queries by adding ! to searchterm
		if firstsearchcharacter == "!":
			searchterm = searchterm[1:]
			articles = articles.exclude(title__iregex=searchterm)
			searchterm = "!" + searchterm
		# Perform normal regex-enabled search
		else:
			articles = articles.filter(title__iregex=searchterm)
	if medium:
		articles = articles.filter(medium=medium)
	if ritzau:
		articles = articles.exclude(ritzau=True)
	if not duplicates and not medium:
		articles = articles.exclude(duplicate=True)
	paginator = Paginator(articles, 80)
	page_number = request.GET.get('page')
	page_obj = paginator.get_page(page_number)
	context = {'request': request, 'page_obj': page_obj, 'medium': medium, 'searchterm': searchterm, 'ritzau': ritzau, 'newwindow': newwindow, 'duplicates': duplicates}
	return render(request, 'wall/index.html', context)

Til sidst skrives en skabelon (template) der omsætter data til HTML. Her er fx den ganske korte bid kode, der spytter artikellinks ud på siden:

{% for article in page_obj %}
	{% ifchanged article.date|date %}<h3>{{ article.date|date }}</h3>{% endifchanged %}
	<p>{{ article.date|date:"H:i" }}: <a href="{{ article.url }}"{% if newwindow %} target="_blank"{% endif %}>{{ article.title }}</a> {% if article.ritzau %}<small><sup> ritzau </sup></small> {% endif %}{% if article.duplicate and not medium %}<small><sup> dublet </sup></small> {% endif %}<img title="Giv besked hvis artiklen er bag en paywall" id="{{ article.id }}" class="myBtnt" src="{% static "wall/alert.svg" %}"/></p>
{% endfor %}

God fornøjelse med den nye Wallnot!

Sådan laver du en gratis Weekendavisen

Nu afslører jeg lige noget jeg opdagede, da jeg lavede https://wallnot.dk (som kun offentliggør gratisartikler): Weekendavisen er gratis!

Et lille udsnit af en betalingsartikel fra Weekendavisen.dk som flot struktureret JSON.

Selv om https://www.weekendavisen.dk/ ligner en typisk dansk netavis med gratis-artikler og paywall-artikler i én pærevælling, offentliggør Weekendavisen faktisk hele sit indhold. De ved det sikkert ikke selv – men udvikleren hos det smarte webbureau, der har udviklet deres side, ved det med sikkerhed.

Avisens oversigt over ugens avis – denne uge er det https://www.weekendavisen.dk/2019-51/oversigt – indeholder en fuldt offentlig JSON-streng med hele avisens indhold: fuld tekst, links til artikeloplæsninger, hele dynen.

Det er ret amatøragtigt.

Du ser det ikke i din browser når du besøger siden, men det er der.

Jeg har lavet et lille Python-script, der genererer din egen personlige Weekendavisen for den aktuelle uge i en fil, der hedder index.html. Det ser ikke særligt godt ud, der er kun de fulde tekster, ikke billeder og links til oplæsning – du kan selv arbejde videre med JSON-strengen, hvis du vil have det til at se flot ud.

Det kan være, jeg ødelægger det for mig selv, for hvis Weekendavisen retter fejlen, bliver jeg formentlig nødt til at omkode den del af wallnot.dk, der viser gratis Weekendavisen-artikler.

God fornøjelse med din gratis Weekendavisen.

# The Danish newspaper Weekendavisen.dk publishes all articles - even those supposedly behind a paywall - as json on their homepage.
# This small script creates an index.html file to read all articles from the current edition.

import requests
from bs4 import BeautifulSoup
import json

def weekendavisen():
	# Request front page
	data = requests.get("https://weekendavisen.dk")
	result = data.text

	# Soup site and create a list of links and their titles
	soup = BeautifulSoup(result, "html.parser")

	for a in soup.find_all('a'):
		if "/oversigt" in a['href']:
			overviewurl = a['href']

	edition = overviewurl[overviewurl.find(".dk/") + 4:overviewurl.find(".dk/") + 11]
	request = "https://weekendavisen.dk/" + edition + "/oversigt"

	# Request site and soup it
	data = requests.get(request)
	result = requests.utils.get_unicode_from_response(data) 
		
	soup = BeautifulSoup(result, "html.parser")
	content = soup.find('script', attrs={'class':'js-react-on-rails-component', 'data-component-name':"IndexPage"})
	jsonobject = content.string
		
	# Create json object
	jsondecode = json.loads(jsonobject)
	
	# Iterate through articles and articles to dictionary
	articlelist = []
	
	for section in jsondecode["sections"]:
		for item in section["items"]:
			summary = item["summary"]
			summary_output = '<b>' + summary[:summary.find(".") + 1] + '</b> ' + summary[summary.find(".") + 1:] + ''
			title = item["title"]
			title_output = '<h1><big>' + title + '</big></h1>'
			if item["type"] == "newsarticleplus":
				article = item["body"] + item["paidBody"]
			else:
				article = item["body"]
			output = summary_output + title_output + article

			articlelist.append(output)

	week_linkstr = ""
	for article in articlelist:
		week_linkstr += article
			
	return week_linkstr	

def htmlgenerator():
	htmlstart = '''<!DOCTYPE HTML>
	<head>
	<meta charset="utf-8"/>

	<title>Weekendavisen</title>

	</head>
	<body>'''
	
	htmlend = '</body></html>'
	
	finalhtml = htmlstart + week_links + htmlend

	# Saves to disc
	with open("./index.html", "wt", encoding="utf8") as fout:
		fout.write(finalhtml)	
			
week_links = weekendavisen()
htmlgenerator()

Wallnots Twitter-bot: Version 2

Det er ikke mange dage siden, at Wallnot.dk‘s Twitter-bot gik i luften. Du kan finde botten her og mit indlæg om den her.

Robotten virkede sådan set fint nok, men pga. en begrænsning i Twitter’s API på 250 forespørgsler per måned, kunne jeg kun opdatere 4 gange i døgnet, og det er jo ret sjældent (det gamle program lavede 2 forespørgsler, hver gang det blev kørt, dvs. 30 dage * 4 opdateringer * 2 forespørgsler = 240 forespørgsler).

Heldigvis fandt jeg TWINT, et Python-modul der gør det nemt at hente data fra Twitter uden at gøre brug af Twitter’s API med dets kedelige begrænsninger.

Med genbrug af det meste af min gamle kode, har jeg nu lavet en version af robotten, der kan køre lige så tit, jeg har lyst til. Jeg har foreløbig sat den til at køre 4 gange i timen.

For sjov skyld har jeg også tilføjet en række venlige adjektiver om abonnenterne på Politiken og Zetland, som programmet vælger tilfældigt mellem, hver gang det lægger et link på Twitter.

Den færdige kode

Her er den færdige kode, hvis du er interesseret.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com

import requests
from bs4 import BeautifulSoup
from datetime import datetime
from datetime import date
from datetime import timedelta
import json
import time
import random
import twint	# https://github.com/twintproject/twint
from TwitterAPI import TwitterAPI

# CONFIGURATION #
# List to store articles to post to Twitter
articlestopost = []

# Yesterday's date variable
yesterday = date.today() - timedelta(days=1)
since = yesterday.strftime("%Y-%m-%d")

# Twint configuration
c = twint.Config()
c.Hide_output = True
c.Store_object = True
c.Since = since

# API LOGIN
client_key = ''
client_secret = ''
access_token = ''
access_secret = ''
api = TwitterAPI(client_key, client_secret, access_token, access_secret)


# POLITIKEN #
# Run search
searchterm = "politiken.dk/del"
c.Search = searchterm
twint.run.Search(c)
tweets = twint.output.tweets_object

# Add urls in tweets to list and remove any duplicates from list
urllist = []
for tweet in tweets:
	for url in tweet.urls:
		if searchterm in url:
			urllist.append(url)

urllist = list(set(urllist))

# Only proces urls that were not in our last Twitter query
proceslist = []
with open("./pol_lastbatch.json", "r", encoding="utf8") as fin:
	lastbatch = list(json.load(fin))
	for url in urllist:
		if url not in lastbatch:
			proceslist.append(url)
# Save current query to use for next time
with open("./pol_lastbatch.json", "wt", encoding="utf8") as fout:
	lastbatch = json.dumps(urllist)
	fout.write(lastbatch)

# Request articles and get titles and dates and sort by dates
articlelist = []
titlecheck = []

for url in proceslist:
	try:
		data = requests.get(url)
		result = data.text
		if '"isAccessibleForFree": "True"' not in result:
			soup = BeautifulSoup(result, "lxml")
			# Finds titles and timestamps
			title = soup.find('meta', attrs={'property':'og:title'})
			title = title['content']
			timestamp = soup.find('meta', attrs={'property':'article:published_time'})
			timestamp = timestamp['content']
			dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z')
			realurl = data.history[0].headers['Location']
			if title not in titlecheck:
				articlelist.append({"title": title, "url": realurl, "date": dateofarticle})
				titlecheck.append(title)			
	except Exception as e:
		print(url)
		print(e)
			
articlelist_sorted = sorted(articlelist, key=lambda k: k['date']) 

# Check if article is already posted and update list of posted articles
with open("./pol_published.json", "r", encoding="utf8") as fin:
	alreadypublished = list(json.load(fin))
	# File below used for paywall.py to update wallnot.dk
	with open("./pol_full_share_links.json", "r", encoding="utf8") as finalready:	
		alreadypublishedalready = list(json.load(finalready))
		for art in articlelist_sorted:
			url = art['url']
			token = url.index("?shareToken")
			url = url[:token]
			if url not in alreadypublished:
				alreadypublished.append(url)
				articlestopost.append(art)
				alreadypublishedalready.append(art['url'])
		# Save updated already published links
		with open("./pol_published.json", "wt", encoding="utf8") as fout:
			alreadypublishedjson = json.dumps(alreadypublished)
			fout.write(alreadypublishedjson)
		with open("./pol_full_share_links.json", "wt", encoding="utf8") as fout:
			alreadypublishedjson = json.dumps(alreadypublishedalready)
			fout.write(alreadypublishedjson)


# ZETLAND #
# Run search
searchterm = "zetland.dk/historie"
c.Search = searchterm
twint.run.Search(c)
tweets = twint.output.tweets_object

# Add urls in tweets to list and remove any duplicates from list
urllist = []
for tweet in tweets:
	for url in tweet.urls:
		if searchterm in url:
			urllist.append(url)

urllist = list(set(urllist))

# Only proces urls that were not in our last Twitter query
proceslist = []
with open("./zet_lastbatch.json", "r", encoding="utf8") as fin:
	lastbatch = list(json.load(fin))
	for url in urllist:
		if url not in lastbatch:
			proceslist.append(url)
# Save current query to use for next time
with open("./zet_lastbatch.json", "wt", encoding="utf8") as fout:
	lastbatch = json.dumps(urllist)
	fout.write(lastbatch)

# Request articles and get titles and dates and sort by dates
articlelist = []
titlecheck = []

for url in proceslist:
	try:
		data = requests.get(url)
		result = data.text
		soup = BeautifulSoup(result, "lxml")
		title = soup.find('meta', attrs={'property':'og:title'})
		title = title['content']
		timestamp = soup.find('meta', attrs={'property':'article:published_time'})
		timestamp = timestamp['content']
		timestamp = timestamp[:timestamp.find("+")]
		dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.%f')
		if title not in titlecheck:
			articlelist.append({"title": title, "url": url, "date": dateofarticle})
			titlecheck.append(title)
	except Exception as e:
		print(url)
		print(e)
			
articlelist_sorted = sorted(articlelist, key=lambda k: k['date']) 

# Check if article is already posted and update list of posted articles
with open("./zet_published.json", "r", encoding="utf8") as fin:
	alreadypublished = list(json.load(fin))
	for art in articlelist_sorted:
		title = art['title']
		if title not in alreadypublished:
			alreadypublished.append(title)
			articlestopost.append(art)
	# Save updated already published links
	with open("./zet_published.json", "wt", encoding="utf8") as fout:
		alreadypublishedjson = json.dumps(alreadypublished, ensure_ascii=False)
		fout.write(alreadypublishedjson)


# POST TO TWITTER #
friendlyterms = ["flink","rar","gavmild","velinformeret","intelligent","sød","afholdt","bedårende","betagende","folkekær","godhjertet","henrivende","smagfuld","tækkelig","hjertensgod","graciøs","galant","tiltalende","prægtig","kær","godartet","human","indtagende","fortryllende","nydelig","venlig","udsøgt","klog","kompetent","dygtig","ejegod","afholdt","omsorgsfuld","elskværdig","prægtig","skattet","feteret"]
enjoyterms = ["God fornøjelse!", "Nyd den!", "Enjoy!", "God læsning!", "Interessant!", "Spændende!", "Vidunderligt!", "Fantastisk!", "Velsignet!", "Glæd dig!", "Læs den!", "Godt arbejde!", "Wauv!"]

if articlestopost:
	for art in articlestopost:
		if "zetland" in art['url']:
			medium = "Zetland"
		else:
			medium = "Politiken"
		friendlyterm = random.choice(friendlyterms)
		enjoyterm = random.choice(enjoyterms)
		status = "En " + friendlyterm + " abonnent på " + medium + " har delt en artikel. " + enjoyterm + " " + art['url']
		r = api.request('statuses/update', {'status': status})
		time.sleep(15)

Wallnots nye Twitter-robot

Opdatering: Jeg har lavet en ny, forbedret udgave af robottten. Læs om den her.

Både Zetland og Politiken har en feature, hvor abonnenter kan dele betalingsartikler med venner, bekendte og offentligheden. Artiklen får en unik URL, som låser op for betalingsmuren og lader alle og enhver læse artiklen.

Jeg tænkte at Wallnot – min hjemmeside med artikler, der ikke er bag betalingsmur – trængte til at være mere til stede på sociale medier.

Derfor har jeg lavet en robot, der gennemsøger Twitter for delte artikler fra Zetland og Politiken – og deler links’ne som tweets. Robotten opdater ca. på klokkeslettene 8.25, 12.25, 16.25 og 20.25. Det ville være skønt at kunne opdatere flere gange i døgnet, men så vil Twitter have penge.

Du finder Wallnots nye Twitter-robot her: https://twitter.com/wallnot_dk

Sådan ser det ud, når Wallnots robot tweeter.

Jeg brugte Python og modulet TwitterAPI. Hvis du selv vil køre programmet, skal du lave en udviklerkonto og en app hos Twitter. Se
https://developer.twitter.com/.

Her er det færdige program.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
# THIS PROGRAM POSTS NEW SHARED ARTICLES FROM ZETLAND.DK AND POLITIKEN.DK TO TWITTER

import requests
from bs4 import BeautifulSoup
from datetime import datetime
import json
import time
from nested_lookup import nested_lookup
from TwitterAPI import TwitterAPI

articlestopost = []

# API LOGIN - INSERT YOUR OWN VALUES HERE
client_key = ''
client_secret = ''
access_token = ''
access_secret = ''
api = TwitterAPI(client_key, client_secret, access_token, access_secret)


# POLITIKEN.DK SEARCH #
SEARCH_TERM = 'url:"politiken.dk/del/"'
PRODUCT = '30day'
LABEL = 'prod'

r = api.request('tweets/search/%s/:%s' % (PRODUCT, LABEL), 
                {'query':SEARCH_TERM})

tweet_data = json.loads(r.text)
prettyjson = json.dumps(tweet_data, ensure_ascii=False, indent=4) # Only needed for debugging to pretify json

# Looks for all instances of expanded_url in json	
linklist = list(set(nested_lookup('expanded_url', tweet_data)))

urllist = []
for link in linklist:
	if "politiken.dk/del" in link:
		urllist.append(link)

# Request articles and get titles and dates and sort by dates
articlelist = []
titlecheck = []

for url in urllist:
	try:
		data = requests.get(url)
		result = data.text
		if '"isAccessibleForFree": "True"' not in result:
			soup = BeautifulSoup(result, "lxml")
			# Finds titles and timestamps
			title = soup.find('meta', attrs={'property':'og:title'})
			title = title['content']
			timestamp = soup.find('meta', attrs={'property':'article:published_time'})
			timestamp = timestamp['content']
			dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z')
			realurl = data.history[0].headers['Location']
			if title not in titlecheck:
				articlelist.append({"title": title, "url": realurl, "date": dateofarticle})
				titlecheck.append(title)			
	except:
		print(url)
			
articlelist_sorted = sorted(articlelist, key=lambda k: k['date'], reverse=True) 

# Check if article is already posted and update list of posted articles
with open("./pol_published.json", "r", encoding="utf8") as fin:
	alreadypublished = list(json.load(fin))
	for art in articlelist_sorted:
		url = art['url']
		token = url.index("?shareToken")
		url = url[:token]
		if url not in alreadypublished:
			alreadypublished.append(url)
			articlestopost.append(art)
	# Save updated already published links
	with open("./pol_published.json", "wt", encoding="utf8") as fout:
		alreadypublishedjson = json.dumps(alreadypublished)
		fout.write(alreadypublishedjson)

# ZETLAND.DK SEARCH #
SEARCH_TERM = 'url:"zetland.dk/historie"'
PRODUCT = '30day'
LABEL = 'prod'

r = api.request('tweets/search/%s/:%s' % (PRODUCT, LABEL), 
                {'query':SEARCH_TERM})

tweet_data = json.loads(r.text)
prettyjson = json.dumps(tweet_data, ensure_ascii=False, indent=4) # Only needed for debugging to pretify json

# Looks for all instances of expanded_url in json	
linklist = list(set(nested_lookup('expanded_url', tweet_data)))

urllist = []
for link in linklist:
	if "zetland.dk/historie" in link:
		urllist.append(link)

# Request articles and get titles and dates and sort by dates
articlelist = []
titlecheck = []

for url in urllist:
	try:
		data = requests.get(url)
		result = data.text

		# Soup site and create a dictionary of links and their titles and dates
		articledict = {}
		soup = BeautifulSoup(result, "lxml")

		title = soup.find('meta', attrs={'property':'og:title'})
		title = title['content']
		
		timestamp = soup.find('meta', attrs={'property':'article:published_time'})
		timestamp = timestamp['content']
		timestamp = timestamp[:timestamp.find("+")]
		dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.%f')
		
		if title not in titlecheck:
			articlelist.append({"title": title, "url": url, "date": dateofarticle})
			titlecheck.append(title)
	except:
		print(url)
			
articlelist_sorted = sorted(articlelist, key=lambda k: k['date'], reverse=True) 

# Check if article is already posted and update list of posted articles
with open("./zet_published.json", "r", encoding="utf8") as fin:
	alreadypublished = list(json.load(fin))
	for art in articlelist_sorted:
		title = art['title']
		if title not in alreadypublished:
			alreadypublished.append(title)
			articlestopost.append(art)
	# Save updated already published links
	with open("./zet_published.json", "wt", encoding="utf8") as fout:
		alreadypublishedjson = json.dumps(alreadypublished, ensure_ascii=False)
		fout.write(alreadypublishedjson)


# POST TO TWITTER #
if articlestopost:
	for art in articlestopost:
		if "zetland" in art['url']:
			medium = "Zetland"
		else:
			medium = "Politiken"
		status = "En flink abonnent på " + medium + " har delt en betalingsartikel. God fornøjelse! " + art['url']
		r = api.request('statuses/update', {'status': status})
		time.sleep(5)

Sådan trækker jeg links til gratis Zetland-artikler ud fra Zetlands Twitter-konto til wallnot.dk

wallnot.dk udgiver jeg en liste over gratisartikler fra en lang række medier, der benytter sig af betalingsmure/paywall. Siden er ment som en service til brugere, der ved, at de gerne vil læse nyhedsartikler, og at de ikke vil betale for dem.

Zetland er ikke som de andre aviser. Der er ikke en forside med links til alle nypublicerede artikler. I stedet bruger Zetland Twitter til at lægge appetitvækkere ud.

Jeg syntes det var ærgerligt ikke at have Zetland med på Wallnot, så i stedet for at kigge efter links på forsiden, som Wallnot gør hos de andre medier, brugte jeg Twitters API til at trække artikellinks ud.

Her kan du se, hvordan jeg gjorde. Hvis du gerne vil prøve programmet af, skal du registrere dig som udvikler på Twitter.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" This program uses the Twitter API to get a list of free articles from Zetland """

import requests
from bs4 import BeautifulSoup
from datetime import datetime
from datetime import date
import json
from nested_lookup import nested_lookup
import base64


# GETS TWITTER DATA #

# Key and secret from Twitter developer account: https://developer.twitter.com/en/apply/user
client_key = ''
client_secret = ''

# Key and secret encoding, preparing for Twitter request
key_secret = '{}:{}'.format(client_key, client_secret).encode('ascii')
b64_encoded_key = base64.b64encode(key_secret)
b64_encoded_key = b64_encoded_key.decode('ascii')

base_url = 'https://api.twitter.com/'
auth_url = '{}oauth2/token'.format(base_url)

auth_headers = {
	'Authorization': 'Basic {}'.format(b64_encoded_key),
	'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}

auth_data = {
	'grant_type': 'client_credentials'
}

auth_resp = requests.post(auth_url, headers=auth_headers, data=auth_data)
auth_resp.json().keys()
access_token = auth_resp.json()['access_token']

search_headers = {
	'Authorization': 'Bearer {}'.format(access_token)
}

# Search parameters for Zetland tweets
search_params = {
	'user_id': 452898921,
	'count': 35,
	'tweet_mode': 'extended',
	'exclude_replies': 'true',
	'trim_user': 'true'
}

# Request url for searching user timelines
search_url = '{}1.1/statuses/user_timeline.json'.format(base_url)

# Request to Twitter
search_resp = requests.get(search_url, headers=search_headers, params=search_params)

# Response from Twitter in json format
tweet_data = search_resp.json()
#prettyjson = json.dumps(tweet_data, ensure_ascii=False, indent=4) # Only needed for debugging to pretify json

# Looks for all instances of expanded_url (that is, links) in json	
linklist = list(set(nested_lookup('expanded_url', tweet_data)))

# Populates a list of links to Zetland articles
urllist = []
for link in linklist:
	if "zetland.dk/historie" in link:
		urllist.append(link)

		
# GETS ARTICLE DATA FROM ZETLAND #
		
# Requests articles and get titles and dates and sort by dates directly from Zetland site
articlelist = []
titlecheck = []

for url in urllist:
	try:
		data = requests.get(url)
		result = data.text

		# Soup site and create a dictionary of links and their titles and dates
		articledict = {}
		soup = BeautifulSoup(result, "lxml")

		title = soup.find('meta', attrs={'property':'og:title'})
		title = title['content']
		
		timestamp = soup.find('meta', attrs={'property':'article:published_time'})
		timestamp = timestamp['content']
		timestamp = timestamp[:timestamp.find("+")]
		dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S.%f')
		
		if title not in titlecheck:
			articlelist.append({"title": title, "url": url, "date": dateofarticle})
			titlecheck.append(title)
	except:
		print(url)


# PREPARES LIST OF ARTICLES FOR WALLNOT.DK #

# Sort articles by date (newest first)		
articlelist_sorted = sorted(articlelist, key=lambda k: k['date'], reverse=True) 

# Removes articles older than approximately three months
articlelist_recent = []
now = datetime.now()
for article in articlelist_sorted:
	timesincelast = now - article["date"]
	if timesincelast.days < 92:
		articlelist_recent.append(article)

# Converts dates to friendly format for display and outputs articles as html paragraphs
zet_linkstr = ""
for article in articlelist_recent:
	friendlydate = article["date"].strftime("%d/%m")
	zet_linkstr += '<p>' + friendlydate + ': ' + '<a href="' + article["url"] + '">' + article["title"] + '</a></p>\n' 

# Prints list of articles	
print(zet_linkstr)