diff --git a/zeeguu/api/endpoints/article.py b/zeeguu/api/endpoints/article.py index 954ff0e6..240964e5 100644 --- a/zeeguu/api/endpoints/article.py +++ b/zeeguu/api/endpoints/article.py @@ -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): + """(, <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} + + + + + + + + + + + + + + + + + + + +

Opening this article on Zeeguu… Continue.

+ + +""" + + +@api.route("/shared_article_preview/", 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= 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/.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",)) diff --git a/zeeguu/core/audio_lessons/og_image.py b/zeeguu/core/audio_lessons/og_image.py index 0c5e6efb..c7aa5c0a 100644 --- a/zeeguu/core/audio_lessons/og_image.py +++ b/zeeguu/core/audio_lessons/og_image.py @@ -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), " 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 + " min read · ", 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