Files
Kochbuch/recipes/instagram.py
T
Kyle DammannandClaude Opus 5.5 60785e2437 Add phase 5: link import, Instagram fallback and share target
- SSRF-safe fetcher: public IPs only, connects to the checked address,
  redirects re-validated, size/time limits, honest user agent
- schema.org/Recipe JSON-LD extraction (graphs, HowToSections, ISO
  durations, yields, keywords, images) without extra dependencies;
  AI fallback on plain page text when no structured recipe exists
- Instagram: one login-free request to the public embed page; when
  Instagram withholds the caption, the import page explains how to paste
  the text or import a screenshot instead
- Suggested source photo can be downloaded and attached on save
- Text import works without AI (text becomes the description)
- Installable PWA with manifest, icons and Android share target
- Offline tests for SSRF rules, parsers, Instagram and import views

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:53:28 +01:00

115 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Best-effort retrieval of an Instagram post caption without login.
Instagram has no login-free API for captions any more (the official oEmbed endpoint needs a
Meta app and no longer returns post content). We make a single, honest request to the
public embed page of the post and read the caption from it. Instagram frequently serves an
empty app shell to servers instead – then the caller falls back to "paste the caption" or
"import a screenshot". No login, no cookies, no evasion of bot detection.
"""
import re
from html.parser import HTMLParser
from urllib.parse import urlsplit
from .fetch import FetchError, fetch
from .schema_org import parse_page
HOSTS = {"instagram.com", "www.instagram.com", "m.instagram.com", "instagr.am", "www.instagr.am"}
_PATH_RE = re.compile(r"^/(?:[\w.]+/)?(?:p|reel|reels|tv)/([A-Za-z0-9_-]{5,40})(?:/|$)")
_OG_CAPTION_RE = re.compile(r':\s*["“](.+)["”]\s*\.?\s*$', re.DOTALL)
class InstagramUnavailable(Exception):
pass
def shortcode(url):
"""Shortcode of a post/reel URL, or None if the URL is not an Instagram post."""
try:
parts = urlsplit(url.strip())
except ValueError:
return None
if (parts.hostname or "").lower() not in HOSTS:
return None
match = _PATH_RE.match(parts.path)
return match.group(1) if match else None
def is_instagram(url):
return (urlsplit(url.strip()).hostname or "").lower() in HOSTS
class _EmbedCaptionParser(HTMLParser):
"""Reads <div class="Caption"> of the embed page, without username and comment links."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0 # >0 while inside the caption div
self.skip_depth = 0
self.parts = []
self.image = ""
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
classes = (attrs.get("class") or "").split()
if tag == "img" and "EmbeddedMediaImage" in classes and not self.image:
self.image = attrs.get("src", "")
if self.depth:
if tag == "br":
self.parts.append("\n")
return
if self.skip_depth or "CaptionUsername" in classes or "CaptionComments" in classes:
self.skip_depth += 1
if tag == "div":
self.depth += 1
elif tag == "div" and "Caption" in classes:
self.depth = 1
def handle_endtag(self, tag):
if not self.depth:
return
if self.skip_depth:
self.skip_depth -= 1
if tag == "div":
self.depth -= 1
def handle_data(self, data):
if self.depth and not self.skip_depth:
self.parts.append(data)
def caption(self):
text = "".join(self.parts)
text = re.sub(r"[ \t]+", " ", text)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def parse_embed(page_html):
"""Return (caption, image_url) from an embed page; caption may be empty."""
parser = _EmbedCaptionParser()
try:
parser.feed(page_html)
except Exception:
return "", ""
caption = parser.caption()
image = parser.image
if not caption:
meta = parse_page(page_html).meta
match = _OG_CAPTION_RE.search(meta.get("og:description", "") or meta.get("og:title", ""))
caption = match.group(1).strip() if match else ""
image = image or meta.get("og:image", "")
return caption, image
def fetch_caption(code):
"""Try to read the caption of a post. Raises InstagramUnavailable if Instagram doesn't deliver it."""
try:
result = fetch(f"https://www.instagram.com/p/{code}/embed/captioned/")
except FetchError as error:
raise InstagramUnavailable(error.user_message) from None
if result.status != 200:
raise InstagramUnavailable(f"Instagram antwortete mit Status {result.status}.")
caption, image = parse_embed(result.text)
if len(caption) < 20:
raise InstagramUnavailable("Instagram hat die Beschreibung nicht herausgegeben.")
return caption, image