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
|
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 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_login import login_user, logout_user, current_user, login_required
|
||||||
from flask_babel import _
|
from flask_babel import _
|
||||||
|
@ -10,7 +11,7 @@ from app import db, constants
|
||||||
from app.activitypub.signature import RsaKeys, HttpSignature
|
from app.activitypub.signature import RsaKeys, HttpSignature
|
||||||
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
|
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, \
|
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.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, \
|
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
|
||||||
PostReplyVote, PostVote, File
|
PostReplyVote, PostVote, File
|
||||||
|
@ -211,6 +212,28 @@ def add_post(actor):
|
||||||
domain = domain_from_url(form.link_url.data)
|
domain = domain_from_url(form.link_url.data)
|
||||||
domain.post_count += 1
|
domain.post_count += 1
|
||||||
post.domain = domain
|
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':
|
elif form.type.data == 'image':
|
||||||
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
|
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
|
||||||
post.title = form.image_title.data
|
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,
|
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,
|
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'])
|
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
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.models import Community, File, BannedInstances, PostReply
|
||||||
from app.utils import get_request
|
from app.utils import get_request, gibberish
|
||||||
from sqlalchemy import desc, text
|
from sqlalchemy import desc, text
|
||||||
import os
|
import os
|
||||||
|
from opengraph_parse import parse_page
|
||||||
|
|
||||||
|
|
||||||
def search_for_community(address: str):
|
def search_for_community(address: str):
|
||||||
|
@ -143,3 +147,32 @@ def ensure_directory_exists(directory):
|
||||||
if not os.path.isdir(rebuild_directory):
|
if not os.path.isdir(rebuild_directory):
|
||||||
os.mkdir(rebuild_directory)
|
os.mkdir(rebuild_directory)
|
||||||
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_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})
|
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):
|
class PostReply(db.Model):
|
||||||
query_class = FullTextSearchQuery
|
query_class = FullTextSearchQuery
|
||||||
|
|
|
@ -20,5 +20,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-0 {
|
.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,
|
/* 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 */
|
nav, etc which are used site-wide */
|
||||||
.pl-0 {
|
.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/ */
|
/* 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 {
|
#breadcrumb_nav {
|
||||||
display: none;
|
font-size: 87%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@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 {
|
.post_list .post_teaser .meta_row a, .post_list .post_teaser .main_row a, .post_list .post_teaser .utilities_row a {
|
||||||
text-decoration: none;
|
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 {
|
.comments > .comment {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
|
|
@ -52,7 +52,7 @@ nav, etc which are used site-wide */
|
||||||
}
|
}
|
||||||
|
|
||||||
#breadcrumb_nav {
|
#breadcrumb_nav {
|
||||||
display: none;
|
font-size: 87%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
@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;
|
border-bottom: solid 2px $light-grey;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.url_thumbnail {
|
||||||
|
float: right;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post_image {
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.comments > .comment {
|
.comments > .comment {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
border-top: solid 1px $grey;
|
border-top: solid 1px $grey;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* */
|
/* */
|
||||||
.pl-0 {
|
.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/ */
|
/* 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">
|
<div class="voting_buttons">
|
||||||
{% include "community/_post_voting_buttons.html" %}
|
{% include "community/_post_voting_buttons.html" %}
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-2">{{ post.title }}</h1>
|
{% if post.type == POST_TYPE_LINK and post.image_id and not (post.url and 'youtube.com' in post.url) %}
|
||||||
{% if post.url %}
|
<div class="url_thumbnail">
|
||||||
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
|
<img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
|
||||||
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /></a>
|
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" />
|
||||||
</small></p>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<h1 class="mt-2">{{ post.title }}</h1>
|
||||||
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
|
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
|
||||||
{{ render_username(post.author) }}
|
{{ render_username(post.author) }}
|
||||||
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
|
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
|
||||||
</p>
|
</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">
|
<div class="post_image">
|
||||||
|
<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 }}"
|
<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>
|
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-10">
|
<div class="col col-md-10">
|
||||||
<div class="row main_row">
|
<div class="row main_row">
|
||||||
<div class="col{% if post.image_id %}-8{% endif %}">
|
<div class="col">
|
||||||
<h3>
|
<h3>
|
||||||
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}
|
<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>
|
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-camera"> </span>{% endif %}</a>
|
||||||
|
@ -14,14 +14,15 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
|
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
|
||||||
</div>
|
|
||||||
{% if post.image_id %}
|
{% 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 }}"
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
<div class="row utilities_row">
|
<div class="row utilities_row">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<a href="{{ url_for('community.show_post', post_id=post.id, _anchor='replies') }}"><span class="fe fe-reply"></span></a>
|
<a href="{{ url_for('community.show_post', post_id=post.id, _anchor='replies') }}"><span class="fe fe-reply"></span></a>
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
<table class="communities_table table table-striped table-hover w-100">
|
<table class="communities_table table table-striped table-hover w-100">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" colspan="2">{{ _('Name') }}</th>
|
<th scope="col" colspan="2">{{ _('Community') }}</th>
|
||||||
<th scope="col">{{ _('Posts') }}</th>
|
<th scope="col">{{ _('Posts') }}</th>
|
||||||
<th scope="col">{{ _('Comments') }}</th>
|
<th scope="col">{{ _('Comments') }}</th>
|
||||||
<th scope="col">{{ _('Active') }}</th>
|
<th scope="col">{{ _('Active') }}</th>
|
||||||
|
@ -44,8 +44,8 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for community in communities %}
|
{% for community in communities %}
|
||||||
<tr class="">
|
<tr class="">
|
||||||
<td><a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" /></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"><a href="/c/{{ community.link() }}">{{ community.display_name() }}</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_count }}</td>
|
||||||
<td>{{ community.post_reply_count }}</td>
|
<td>{{ community.post_reply_count }}</td>
|
||||||
<td>{{ moment(community.last_active).fromNow(refresh=True) }}</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:
|
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()
|
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
|
||||||
if create and domain is None:
|
if create and domain is None:
|
||||||
domain = Domain(name=parsed_url.hostname.lower())
|
domain = Domain(name=parsed_url.hostname.lower())
|
||||||
|
|
|
@ -24,3 +24,4 @@ beautifulsoup4==4.12.2
|
||||||
flask-caching==2.0.2
|
flask-caching==2.0.2
|
||||||
Pillow
|
Pillow
|
||||||
pillow-heif
|
pillow-heif
|
||||||
|
opengraph-parse=0.0.6
|
||||||
|
|
Loading…
Add table
Reference in a new issue