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):
+ """(
, ) 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"""
+
+
+
+
+{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