generate thumbnails from og:image meta tag

This commit is contained in:
rimu 2023-11-29 20:32:07 +13:00
parent a17b8785d3
commit e97f3ee4ab
12 changed files with 148 additions and 25 deletions

View file

@ -1,5 +1,6 @@
from datetime import date, datetime, timedelta
import requests
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _
@ -10,7 +11,7 @@ from app import db, constants
from app.activitypub.signature import RsaKeys, HttpSignature
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies, \
get_comment_branch, post_reply_count, ensure_directory_exists
get_comment_branch, post_reply_count, ensure_directory_exists, opengraph_parse, url_to_thumbnail_file
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
PostReplyVote, PostVote, File
@ -211,6 +212,28 @@ def add_post(actor):
domain = domain_from_url(form.link_url.data)
domain.post_count += 1
post.domain = domain
valid_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
unused, file_extension = os.path.splitext(form.link_url.data) # do not use _ here instead of 'unused'
# this url is a link to an image - generate a thumbnail of it
if file_extension in valid_extensions:
file = url_to_thumbnail_file(form.link_url.data)
if file:
post.image = file
db.session.add(file)
else:
# check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag
opengraph = opengraph_parse(form.link_url.data)
if opengraph and opengraph.get('og:image', '') != '':
filename = opengraph.get('og:image')
valid_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
unused, file_extension = os.path.splitext(filename)
if file_extension.lower() in valid_extensions:
file = url_to_thumbnail_file(filename)
if file:
file.alt_text = opengraph.get('og:title')
post.image = file
db.session.add(file)
elif form.type.data == 'image':
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
post.title = form.image_title.data
@ -304,7 +327,8 @@ def show_post(post_id: int):
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE)
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE,
POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE)
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])

View file

@ -1,11 +1,15 @@
from datetime import datetime
from typing import List
from app import db
import requests
from PIL import Image, ImageOps
from app import db, cache
from app.models import Community, File, BannedInstances, PostReply
from app.utils import get_request
from app.utils import get_request, gibberish
from sqlalchemy import desc, text
import os
from opengraph_parse import parse_page
def search_for_community(address: str):
@ -143,3 +147,32 @@ def ensure_directory_exists(directory):
if not os.path.isdir(rebuild_directory):
os.mkdir(rebuild_directory)
rebuild_directory += '/'
@cache.memoize(timeout=50)
def opengraph_parse(url):
try:
return parse_page(url)
except Exception as ex:
return None
def url_to_thumbnail_file(filename) -> File:
unused, file_extension = os.path.splitext(filename)
response = requests.get(filename, timeout=5)
if response.status_code == 200:
new_filename = gibberish(15)
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
final_place = os.path.join(directory, new_filename + file_extension)
with open(final_place, 'wb') as f:
f.write(response.content)
with Image.open(final_place) as img:
img = ImageOps.exif_transpose(img)
img.thumbnail((150, 150))
img.save(final_place)
thumbnail_width = img.width
thumbnail_height = img.height
return File(file_name=new_filename + file_extension, thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height, thumbnail_path=final_place,
source_url=filename)

View file

@ -375,6 +375,12 @@ class Post(db.Model):
db.session.execute(text('DELETE FROM post_reply WHERE post_id = :post_id'), {'post_id': self.id})
db.session.execute(text('DELETE FROM post_vote WHERE post_id = :post_id'), {'post_id': self.id})
def youtube_embed(self):
if self.url:
vpos = self.url.find('v=')
if vpos != -1:
return self.url[vpos + 2:vpos + 13]
class PostReply(db.Model):
query_class = FullTextSearchQuery

View file

@ -20,5 +20,5 @@
}
.pl-0 {
padding-left: 0;
padding-left: 0!important;
}

View file

@ -1,7 +1,7 @@
/* This file contains SCSS used for creating the general structure of pages. Selectors should be things like body, h1,
nav, etc which are used site-wide */
.pl-0 {
padding-left: 0;
padding-left: 0 !important;
}
/* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */
@ -303,7 +303,7 @@ fieldset legend {
}
#breadcrumb_nav {
display: none;
font-size: 87%;
}
@media (min-width: 992px) {
@ -370,6 +370,26 @@ fieldset legend {
.post_list .post_teaser .meta_row a, .post_list .post_teaser .main_row a, .post_list .post_teaser .utilities_row a {
text-decoration: none;
}
.post_list .post_teaser .thumbnail {
padding-left: 0;
padding-right: 0;
}
.post_list .post_teaser .thumbnail img {
position: absolute;
right: 70px;
height: 70px;
margin-top: -47px;
}
.url_thumbnail {
float: right;
margin-top: 6px;
margin-right: 6px;
}
.post_image img {
max-width: 100%;
}
.comments > .comment {
margin-left: 0;

View file

@ -52,7 +52,7 @@ nav, etc which are used site-wide */
}
#breadcrumb_nav {
display: none;
font-size: 87%;
}
@include breakpoint(tablet) {
@ -131,12 +131,35 @@ nav, etc which are used site-wide */
}
}
.thumbnail {
padding-left: 0;
padding-right: 0;
img {
position: absolute;
right: 70px;
height: 70px;
margin-top: -47px;
}
}
border-bottom: solid 2px $light-grey;
padding-top: 8px;
padding-bottom: 8px;
}
}
.url_thumbnail {
float: right;
margin-top: 6px;
margin-right: 6px;
}
.post_image {
img {
max-width: 100%;
}
}
.comments > .comment {
margin-left: 0;
border-top: solid 1px $grey;

View file

@ -1,6 +1,6 @@
/* */
.pl-0 {
padding-left: 0;
padding-left: 0 !important;
}
/* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */

View file

@ -39,22 +39,37 @@
<div class="voting_buttons">
{% include "community/_post_voting_buttons.html" %}
</div>
<h1 class="mt-2">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /></a>
</small></p>
{% if post.type == POST_TYPE_LINK and post.image_id and not (post.url and 'youtube.com' in post.url) %}
<div class="url_thumbnail">
<img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" />
</div>
{% endif %}
<h1 class="mt-2">{{ post.title }}</h1>
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
{{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
</p>
{% if post.image_id %}
{% if post.type == POST_TYPE_LINK %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /></a>
</small></p>
{% if 'youtube.com' in post.url %}
<div style="padding-bottom: 56.25%; position: relative;"><iframe style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;" src="https://www.youtube.com/embed/{{ post.youtube_embed() }}?rel=0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" height="100%" frameborder="0"></iframe></div>
{% endif %}
{% elif post.type == POST_TYPE_IMAGE %}
<div class="post_image">
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.view_url() }}" alt="{{ post.image.alt_text }}"
width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
</div>
{% else %}
{% if post.image_id and not (post.url and 'youtube.com' in post.url) %}
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>

View file

@ -2,7 +2,7 @@
<div class="row">
<div class="col col-md-10">
<div class="row main_row">
<div class="col{% if post.image_id %}-8{% endif %}">
<div class="col">
<h3>
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-camera"> </span>{% endif %}</a>
@ -14,13 +14,14 @@
{% endif %}
</h3>
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
</div>
{% if post.image_id %}
<div class="col-4 text-right">
<div class="thumbnail">
<a href="{{ url_for('community.show_post', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
height="50" /></a>
</div>
{% endif %}
</div>
</div>
<div class="row utilities_row">
<div class="col-6">

View file

@ -34,7 +34,7 @@
<table class="communities_table table table-striped table-hover w-100">
<thead>
<tr>
<th scope="col" colspan="2">{{ _('Name') }}</th>
<th scope="col" colspan="2">{{ _('Community') }}</th>
<th scope="col">{{ _('Posts') }}</th>
<th scope="col">{{ _('Comments') }}</th>
<th scope="col">{{ _('Active') }}</th>
@ -44,8 +44,8 @@
<tbody>
{% for community in communities %}
<tr class="">
<td><a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" /></a></td>
<td scope="row"><a href="/c/{{ community.link() }}">{{ community.display_name() }}</a></td>
<td width="46"><a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" /></a></td>
<td scope="row" class="pl-0"><a href="/c/{{ community.link() }}">{{ community.display_name() }}</a></td>
<td>{{ community.post_count }}</td>
<td>{{ community.post_reply_count }}</td>
<td>{{ moment(community.last_active).fromNow(refresh=True) }}</td>

View file

@ -161,7 +161,7 @@ def markdown_to_text(markdown_text) -> str:
def domain_from_url(url: str, create=True) -> Domain:
parsed_url = urlparse(url)
parsed_url = urlparse(url.lower().replace('www.', ''))
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
if create and domain is None:
domain = Domain(name=parsed_url.hostname.lower())

View file

@ -24,3 +24,4 @@ beautifulsoup4==4.12.2
flask-caching==2.0.2
Pillow
pillow-heif
opengraph-parse=0.0.6