- 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]>
115 lines
4.0 KiB
Python
115 lines
4.0 KiB
Python
"""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
|