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