Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions zeeguu/api/endpoints/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,143 @@
import json
from zeeguu.logging import log

import math
import requests
from io import BytesIO
from markupsafe import escape
from PIL import Image
from zeeguu.config import ZEEGUU_DATA_FOLDER
from zeeguu.core.audio_lessons import og_image

# Production origins for shared-article link previews (mirrors audio lessons):
# the share page lives on the web app; the card image is served by this API.
SHARE_WEB_ORIGIN = "https://zeeguu.org"
SHARE_API_ORIGIN = "https://api.zeeguu.org"


def _article_card_view(article):
"""The data the article OG card + text need, derived from article_info()."""
info = article.article_info()
metrics = info.get("metrics") or {}
word_count = metrics.get("word_count")
minutes = max(1, math.ceil(word_count / 200)) if word_count else None
lang_code = info.get("language")
language_name = (
Language.LANGUAGE_NAMES.get(lang_code, lang_code.upper()) if lang_code else None
)
source = article.feed.title if article.feed else (article.authors or None)
return {
"article_id": article.id,
"title": info.get("title"),
"language_name": language_name,
"cefr_level": metrics.get("cefr_level"),
"minutes": minutes,
"source": source,
"image_url": info.get("img_url"),
}


def _article_preview_texts(view):
"""(<title>, <description>) for the article's OG tags."""
title = (view.get("title") or "Article").strip()
parts = [p for p in [
f"{view['language_name']} article" if view.get("language_name") else "Article",
view.get("cefr_level"),
f"{view['minutes']} min read" if view.get("minutes") else None,
view.get("source"),
] if p]
return title, " · ".join(parts) + ". Read on Zeeguu."


def _fetch_preview_image(url):
"""Download the article's hero image for compositing; None on any failure."""
if not url:
return None
try:
resp = requests.get(url, timeout=6, headers={"User-Agent": "ZeeguuLinkPreview/1.0"})
resp.raise_for_status()
return Image.open(BytesIO(resp.content))
except Exception:
return None


def _og_preview_html(og_title, description, page_url, image_url):
title = escape(og_title)
desc = escape(description)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{desc}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Zeeguu">
<meta property="og:title" content="{title}">
<meta property="og:description" content="{desc}">
<meta property="og:url" content="{page_url}">
<meta property="og:image" content="{image_url}">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{title}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{title}">
<meta name="twitter:description" content="{desc}">
<meta name="twitter:image" content="{image_url}">
<link rel="canonical" href="{page_url}">
<meta http-equiv="refresh" content="0; url={page_url}">
</head>
<body>
<p>Opening this article on Zeeguu… <a href="{page_url}">Continue</a>.</p>
</body>
</html>
"""


@api.route("/shared_article_preview/<int:article_id>", methods=["GET"])
@cross_domain
def shared_article_preview(article_id):
"""Crawler-facing HTML with Open Graph tags for a shared article link. nginx
routes social-scraper user-agents on zeeguu.org/read/article?id=<id> here;
real users get the SPA. Public — article content is already public."""
article = Article.find_by_id(article_id)
page_url = f"{SHARE_WEB_ORIGIN}/read/article?id={article_id}"
if not article:
return flask.redirect(page_url, code=302)
og_title, description = _article_preview_texts(_article_card_view(article))
image_url = f"{SHARE_API_ORIGIN}/shared_article_image/{article_id}.png"
response = flask.Response(
_og_preview_html(og_title, description, page_url, image_url), mimetype="text/html")
response.headers["Cache-Control"] = "public, max-age=3600"
return response


@api.route("/shared_article_image/<int:article_id>.png", methods=["GET"])
@cross_domain
def shared_article_image(article_id):
"""1200x630 OG card for a shared article — its own photo under a scrim, or
the branded fallback. Rendered once and cached on disk."""
article = Article.find_by_id(article_id)
if not article:
return flask.Response("Not found", status=404)
view = _article_card_view(article)
url = view.get("image_url")
photo = _fetch_preview_image(url)
if url and photo is None:
# Transient image-fetch failure: serve the fallback now but DON'T cache it,
# so the photo card appears once the fetch later succeeds. Short TTL.
buffer = BytesIO()
og_image.render_article_card(view, None).save(buffer, format="PNG")
buffer.seek(0)
response = flask.send_file(buffer, mimetype="image/png")
response.headers["Cache-Control"] = "public, max-age=300"
return response
path = og_image.ensure_cached_article_card(view, ZEEGUU_DATA_FOLDER, photo)
response = flask.send_file(path, mimetype="image/png")
response.headers["Cache-Control"] = "public, max-age=86400"
return response


# ---------------------------------------------------------------------------
@api.route("/find_or_create_article", methods=("POST",))
Expand Down
106 changes: 106 additions & 0 deletions zeeguu/core/audio_lessons/og_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,109 @@ def ensure_cached_card(view, data_folder):
os.makedirs(os.path.dirname(path), exist_ok=True)
render_card(view).save(path, format="PNG")
return path


# --- Article cards: the article's own photo, full-bleed, with a scrim ---------

def _cover(image, target_w, target_h):
"""Scale to fill (target_w, target_h) and centre-crop — never distorts."""
iw, ih = image.size
scale = max(target_w / iw, target_h / ih)
image = image.resize((max(1, round(iw * scale)), max(1, round(ih * scale))))
nw, nh = image.size
left, top = (nw - target_w) // 2, (nh - target_h) // 2
return image.crop((left, top, left + target_w, top + target_h))


def _chip(draw, x, y, text, font, *, fill, fg, pad=(20, 10)):
"""Pill at (x, y); returns the x past its right edge."""
tw = draw.textlength(text, font=font)
asc, desc = font.getmetrics()
draw.rounded_rectangle((x, y, x + tw + 2 * pad[0], y + asc + desc + 2 * pad[1]),
radius=44, fill=fill)
draw.text((x + pad[0], y + pad[1]), text, font=font, fill=fg)
return x + tw + 2 * pad[0]


def _article_meta(view):
bits = []
if view.get("minutes"):
bits.append(f"{view['minutes']} min read")
if view.get("source"):
bits.append(view["source"])
return " · ".join(bits)


def render_article_card(view, photo=None):
"""Render the OG card for an article. With a photo: the article's own image
full-bleed under a bottom scrim, white text. Without one (no/failed image):
the warm branded card so it never breaks."""
language = view.get("language_name")
label = f"{language} Article" if language else "Article"
cefr = view.get("cefr_level")
meta = _article_meta(view)
title = (view.get("title") or "Article").strip()

if photo is not None:
img = _cover(photo.convert("RGB"), WIDTH, HEIGHT)
scrim = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
sdraw = ImageDraw.Draw(scrim)
for y in range(HEIGHT): # transparent up top → dark over the lower band
a = int(245 * max(0.0, (y - HEIGHT * 0.30) / (HEIGHT * 0.70)) ** 1.25)
sdraw.rectangle((0, y, WIDTH, y + 1), fill=(20, 14, 6, a))
img = Image.alpha_composite(img.convert("RGBA"), scrim).convert("RGB")
title_fill, meta_fill, wordmark_fill = WHITE, (240, 230, 215), WHITE
else:
img = _gradient_bg()
ImageDraw.Draw(img).rectangle((0, 0, 12, HEIGHT), fill=ORANGE)
title_fill, meta_fill, wordmark_fill = INK, (140, 110, 60), ORANGE_DEEP

draw = ImageDraw.Draw(img)

# Header: logo + wordmark (left), "<Language> Article" pill (right)
logo_size = 58
try:
logo = Image.open(_LOGO_PATH).convert("RGBA").resize((logo_size, logo_size))
img.paste(logo, (MARGIN, MARGIN), logo)
except OSError:
pass
wordmark = _font("ExtraBold", 30)
draw.text((MARGIN + logo_size + 16, MARGIN + (logo_size - sum(wordmark.getmetrics())) // 2),
"Zeeguu", font=wordmark, fill=wordmark_fill)
pill_font = _font("Bold", 30)
lw = draw.textlength(label, font=pill_font)
_chip(draw, WIDTH - MARGIN - lw - 44, MARGIN + 2, label, pill_font,
fill=ORANGE, fg=WHITE, pad=(22, 11))

# Bottom: CEFR chip + "<N> min read · <source>", with the title stacked above
by = HEIGHT - MARGIN - 44
meta_font = _font("Bold", 30)
cursor = MARGIN
if cefr:
cursor = _chip(draw, MARGIN, by, cefr, meta_font, fill=ORANGE, fg=WHITE) + 18
if meta:
draw.text((cursor, by + 10), meta, font=meta_font, fill=meta_fill)

title_font, lines, line_h = _fit_title(draw, title, WIDTH - 2 * MARGIN, 230, max_lines=2)
ty = by - 28 - len(lines) * line_h
for i, line in enumerate(lines):
draw.text((MARGIN, ty + i * line_h), line, font=title_font, fill=title_fill)

return img


def cached_article_card_path(data_folder, article_id):
return os.path.join(data_folder, "og-images", "shared-articles", f"{article_id}.png")


def ensure_cached_article_card(view, data_folder, photo=None):
"""Render (once) and cache the article card PNG; return its path, or None if
the view has no article_id."""
article_id = view.get("article_id")
if not article_id:
return None
path = cached_article_card_path(data_folder, article_id)
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
render_article_card(view, photo).save(path, format="PNG")
return path
Loading