"""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
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