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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1bf8ef8b65
commit
60785e2437
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user