Files
vrobbler/vrobbler/apps/webpages/models.py

375 lines
12 KiB
Python

import logging
from typing import Dict, Optional
from uuid import uuid4
import pendulum
import requests
import trafilatura
from bs4 import BeautifulSoup
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from htmldate import find_date
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from scrobbles.tasks import push_scrobble_to_archivebox
from taggit.managers import TaggableManager
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
class Domain(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
root = models.CharField(max_length=255)
name = models.CharField(max_length=255, **BNULL)
tags = TaggableManager(blank=True, verbose_name="Tags")
def __str__(self) -> str:
if self.name:
return str(self.name)
return str(self.root)
def scrobbles_for_user(self, user_id):
from scrobbles.models import Scrobble
return Scrobble.objects.filter(web_page__domain=self, user_id=user_id).order_by(
"-timestamp"
)
def _fetch_url_raw(url: str) -> Optional[str]:
"""Fetch raw HTML for a URL.
Tries two strategies in order:
1. trafilatura (standard, works for most sites)
2. cloudscraper (handles Cloudflare JS challenges)
cloudscraper is lazy-imported so deployments that cannot compile
its dependencies (e.g. FreeBSD) still work.
"""
raw = trafilatura.fetch_url(url)
if raw:
return raw
logger.debug("trafilatura returned nothing for %s, trying cloudscraper", url)
try:
import cloudscraper
except ImportError:
logger.debug("cloudscraper not available")
else:
try:
scraper = cloudscraper.create_scraper()
resp = scraper.get(url, timeout=30)
resp.raise_for_status()
return resp.text
except Exception as exc:
logger.debug("cloudscraper failed for %s: %s", url, exc)
return None
class WebPage(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "WEBSITE_COMPLETION_PERCENT", 100)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
url = models.URLField(max_length=500)
date = models.DateField(**BNULL)
domain = models.ForeignKey(Domain, on_delete=models.DO_NOTHING, **BNULL)
extract = models.TextField(**BNULL)
image = models.ImageField(upload_to="webpage/", **BNULL)
image_small = ImageSpecField(
source="image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
image_medium = ImageSpecField(
source="image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def __str__(self) -> str:
if self.title:
return "{} ({})".format(self.title, self.domain)
if self.domain:
return "Unknown ({})".format(self.domain)
return str(self.uuid)
def _raw_domain(self):
self.url.split("//")[-1].split("/")[0]
def _update_extract_from_web(self, raw_text: str = "", force=True):
if not raw_text:
raw_text = _fetch_url_raw(self.url)
if not self.extract or force:
self.extract = trafilatura.extract(raw_text)
self.save(update_fields=["extract"])
def get_absolute_url(self):
return reverse("webpages:webpage_detail", kwargs={"slug": self.uuid})
def get_read_url(self):
return reverse("webpages:webpage_read", kwargs={"slug": self.uuid})
@property
def estimated_time_to_read_in_seconds(self):
if not self.extract:
return 600
words_per_minute = getattr(settings, "READING_WORDS_PER_MINUTE", 200)
words = len(self.extract.split(" "))
return int(words / words_per_minute) * 60
@property
def estimated_time_to_read_in_minutes(self):
return int(self.estimated_time_to_read_in_seconds / 60)
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Browsing", tags="earth_americas")
@property
def subtitle(self):
return self.domain
@property
def primary_image_url(self) -> str:
if self.image:
return self.image.url
return ""
def scrobble_for_user(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
scrobble_data = self.basic_scrobble_data(user_id)
logger.info(
"[scrobble_for_user] called for webpage",
extra={
"webpage_id": self.id,
"user_id": user_id,
"scrobble_data": scrobble_data,
"media_type": Scrobble.MediaType.WEBPAGE,
},
)
scrobble = Scrobble.create_or_update(self, user_id, scrobble_data)
push_scrobble_to_archivebox.delay(scrobble.id)
return scrobble
def scrobbles(self, user):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user=user, web_page=self).order_by("-timestamp")
def clean_title(self, title: str, save=True):
if len(title.split("|")) > 1:
title = title.split("|")[0]
if len(title.split("–")) > 1:
title = title.split("–")[0]
if len(title.split(" - ")) > 1:
title = title.split(" - ")[0]
self.title = title.strip()
if save:
self.save(update_fields=["title"])
def _update_domain_from_url(self, save=False):
domain = self.url.split("//")[-1].split("/")[0].split("www.")[-1]
self.domain, created = Domain.objects.get_or_create(root=domain)
if save:
self.save(update_fields=["domain"])
def _update_title_from_web(self, raw_text: str, save=False):
if not raw_text:
return
soup = BeautifulSoup(raw_text, "html.parser")
title_tag = soup.find("title")
if title_tag and title_tag.string:
self.title = title_tag.string
elif not self.title and self.extract:
first_line = self.extract.split("\n")[0]
self.title = first_line[:254]
if save:
self.save(update_fields=["title"])
def _update_date_from_web(self, save=False):
try:
date_str = find_date(str(self.url))
except ValueError:
date_str = ""
if date_str:
self.date = pendulum.parse(date_str).date()
if save:
self.save(update_fields=["date"])
def push_to_archivebox(self, user):
profile = user.profile
url = profile.archivebox_url
if not url:
return
username = profile.archivebox_username
password = profile.archivebox_password
login_url = requests.compat.urljoin(url, "admin/login/")
session = requests.Session()
response = session.get(login_url)
csrf_token = response.cookies.get_dict().get("csrftoken")
response = session.post(
login_url,
data={
"username": username,
"password": password,
"csrfmiddlewaretoken": csrf_token,
},
)
try:
response = session.post(
requests.compat.urljoin(url, "add/"),
data={
"url": self.url + "\n",
"tags": "vrobbler",
"depth": "0",
"parser": "auto",
},
timeout=2,
)
except requests.exceptions.ReadTimeout:
return
if response.status_code == 200:
logger.info("Website already exists in archive", extra={"url": self.url})
else:
raise Exception(
f"Failed to push URL to archivebox (Response {response.status_code})"
)
def _extract_image_url_from_html(self, raw_html: str) -> Optional[str]:
"""Extract og:image or twitter:image from HTML."""
if not raw_html:
return None
soup = BeautifulSoup(raw_html, "html.parser")
og_image = soup.find("meta", property="og:image")
if og_image and og_image.get("content"):
return og_image["content"]
twitter_image = soup.find("meta", attrs={"name": "twitter:image"})
if twitter_image and twitter_image.get("content"):
return twitter_image["content"]
return None
def _download_and_save_image(self, image_url: str) -> bool:
"""Download and save the webpage image."""
if not image_url:
return False
try:
response = requests.get(image_url, timeout=10)
response.raise_for_status()
filename = image_url.split("/")[-1].split("?")[0]
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
filename = "webpage_image.jpg"
self.image.save(filename, ContentFile(response.content), save=True)
return True
except Exception:
return False
def fetch_data_from_web(self, save=True, force=True):
raw_text = _fetch_url_raw(self.url)
if not self.extract or force:
self.extract = trafilatura.extract(
raw_text,
include_links=False,
include_comments=False,
)
if not self.title or force:
self._update_title_from_web(raw_text)
if not self.date or force:
self._update_date_from_web()
if not self.domain or force:
self._update_domain_from_url()
if not self.base_run_time_seconds or force:
self.base_run_time_seconds = self.estimated_time_to_read_in_seconds
if (not self.image or force) and raw_text:
image_url = self._extract_image_url_from_html(raw_text)
if image_url:
self._download_and_save_image(image_url)
if save:
self.save()
@classmethod
def find_or_create(cls, data_dict: Dict) -> "WebPage":
"""Given a data dict from an manual URL scrobble, does the heavy lifting of looking up
the url, creating if if doesn't exist yet.
"""
# TODO Add constants for all these data keys
if "url" not in data_dict.keys():
logger.error("No url in data dict")
return
webpage = cls.objects.filter(url=data_dict.get("url")).first()
if not webpage:
webpage = cls(url=data_dict.get("url"))
webpage.fetch_data_from_web(save=True)
else:
webpage._archive_and_refetch()
return webpage
def _archive_and_refetch(self):
"""Archive current content to HistoricalWebPage and re-fetch from web."""
if self.extract or self.date or self.domain:
HistoricalWebPage.objects.create(
webpage=self,
date=self.date,
domain=self.domain,
extract=self.extract,
)
self.extract = None
self.date = None
self.domain = None
self.title = None
self.base_run_time_seconds = None
self.image = None
self.fetch_data_from_web(save=True, force=True)
class HistoricalWebPage(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
webpage = models.ForeignKey(
WebPage, on_delete=models.CASCADE, related_name="historical_webpages"
)
date = models.DateField(**BNULL)
domain = models.ForeignKey(Domain, on_delete=models.DO_NOTHING, **BNULL)
extract = models.TextField(**BNULL)
def __str__(self) -> str:
if self.webpage.title:
return "{} ({}) - {}".format(
self.webpage.title, self.webpage.domain, self.created
)
return "{} - {}".format(self.webpage.url, self.created)