mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 19:36:56 -08:00
generate thumbnails from og:image meta tag
This commit is contained in:
parent
a17b8785d3
commit
e97f3ee4ab
12 changed files with 148 additions and 25 deletions
|
@ -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'])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,5 +20,5 @@
|
|||
}
|
||||
|
||||
.pl-0 {
|
||||
padding-left: 0;
|
||||
padding-left: 0!important;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/ */
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -24,3 +24,4 @@ beautifulsoup4==4.12.2
|
|||
flask-caching==2.0.2
|
||||
Pillow
|
||||
pillow-heif
|
||||
opengraph-parse=0.0.6
|
||||
|
|
Loading…
Reference in a new issue