mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-16 23:01:06 +02:00
feat(instagram): extract PolarisMedia relay JSON with carousel, tags, and location
This commit is contained in:
parent
69bf748c2c
commit
cb4adab852
2 changed files with 264 additions and 115 deletions
|
|
@ -35,6 +35,10 @@ _TYPE_MAP = {
|
||||||
"XDTGraphVideo": "Video",
|
"XDTGraphVideo": "Video",
|
||||||
"XDTGraphSidecar": "Sidecar",
|
"XDTGraphSidecar": "Sidecar",
|
||||||
}
|
}
|
||||||
|
# Mobile v1 ``media_type``: 1 = image, 2 = video, 8 = carousel/sidecar. Used by
|
||||||
|
# the single-post relay parser (the embedded PolarisMedia blob uses this int, not
|
||||||
|
# the GraphQL ``__typename`` the profile feed uses).
|
||||||
|
_MEDIA_TYPE = {1: "Image", 2: "Video", 8: "Sidecar"}
|
||||||
|
|
||||||
|
|
||||||
def _int(value: Any) -> int | None:
|
def _int(value: Any) -> int | None:
|
||||||
|
|
@ -108,6 +112,80 @@ def _shortcode(node: dict[str, Any]) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_ref(user: Any) -> dict[str, Any] | None:
|
||||||
|
"""A trimmed public-user dict (tagged users / coauthor producers), or None.
|
||||||
|
|
||||||
|
Normalizes the two anonymous dialects: the profile feed nests the handle
|
||||||
|
under ``edge_media_to_tagged_user...node.user`` / ``coauthor_producers`` while
|
||||||
|
the single-post relay blob uses ``usertags.in[].user`` — both carry the same
|
||||||
|
scalar user fields, so this trims them to one shape.
|
||||||
|
"""
|
||||||
|
if not isinstance(user, dict):
|
||||||
|
return None
|
||||||
|
ref = {
|
||||||
|
"username": user.get("username"),
|
||||||
|
"fullName": user.get("full_name"),
|
||||||
|
"id": user.get("id") or user.get("pk"),
|
||||||
|
"isVerified": user.get("is_verified"),
|
||||||
|
"profilePicUrl": user.get("profile_pic_url"),
|
||||||
|
}
|
||||||
|
return ref if ref["username"] or ref["id"] else None
|
||||||
|
|
||||||
|
|
||||||
|
def _iv2_url(iv2: Any) -> str | None:
|
||||||
|
"""First candidate URL from a mobile ``image_versions2`` container, or None."""
|
||||||
|
if isinstance(iv2, dict):
|
||||||
|
cands = iv2.get("candidates")
|
||||||
|
if isinstance(cands, list) and cands and isinstance(cands[0], dict):
|
||||||
|
url = cands[0].get("url")
|
||||||
|
return url if isinstance(url, str) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _location_ref(loc: Any) -> tuple[str | None, str | None]:
|
||||||
|
"""``(name, id)`` from a location node, or ``(None, None)``."""
|
||||||
|
if isinstance(loc, dict):
|
||||||
|
lid = loc.get("id") or loc.get("pk")
|
||||||
|
return loc.get("name"), (str(lid) if lid is not None else None)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_child(node: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map a profile-feed ``edge_sidecar_to_children`` child to a childPost dict."""
|
||||||
|
dims = node.get("dimensions") if isinstance(node.get("dimensions"), dict) else {}
|
||||||
|
is_video = bool(node.get("is_video"))
|
||||||
|
return {
|
||||||
|
"id": node.get("id"),
|
||||||
|
"shortCode": node.get("shortcode"),
|
||||||
|
"type": "Video" if is_video else "Image",
|
||||||
|
"displayUrl": node.get("display_url"),
|
||||||
|
"videoUrl": node.get("video_url") if is_video else None,
|
||||||
|
"alt": node.get("accessibility_caption"),
|
||||||
|
"dimensionsHeight": _int(dims.get("height")),
|
||||||
|
"dimensionsWidth": _int(dims.get("width")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _relay_child(node: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Map a single-post relay ``carousel_media`` child to a childPost dict."""
|
||||||
|
mt = node.get("media_type")
|
||||||
|
vv = node.get("video_versions")
|
||||||
|
video_url = (
|
||||||
|
vv[0].get("url") if isinstance(vv, list) and vv and isinstance(vv[0], dict) else None
|
||||||
|
)
|
||||||
|
is_video = mt == 2 or bool(video_url)
|
||||||
|
return {
|
||||||
|
"id": node.get("id"),
|
||||||
|
"shortCode": node.get("code"),
|
||||||
|
"type": _MEDIA_TYPE.get(mt) or ("Video" if is_video else "Image"),
|
||||||
|
"displayUrl": _iv2_url(node.get("image_versions2")) or node.get("display_uri"),
|
||||||
|
"videoUrl": video_url,
|
||||||
|
"alt": node.get("accessibility_caption"),
|
||||||
|
"dimensionsHeight": _int(node.get("original_height")),
|
||||||
|
"dimensionsWidth": _int(node.get("original_width")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_media(node: dict[str, Any]) -> dict[str, Any]:
|
def parse_media(node: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Map a raw timeline/feed media node to a flat media item dict."""
|
"""Map a raw timeline/feed media node to a flat media item dict."""
|
||||||
code = _shortcode(node)
|
code = _shortcode(node)
|
||||||
|
|
@ -116,6 +194,16 @@ def parse_media(node: dict[str, Any]) -> dict[str, Any]:
|
||||||
owner = node.get("owner") if isinstance(node.get("owner"), dict) else {}
|
owner = node.get("owner") if isinstance(node.get("owner"), dict) else {}
|
||||||
dims = node.get("dimensions") if isinstance(node.get("dimensions"), dict) else {}
|
dims = node.get("dimensions") if isinstance(node.get("dimensions"), dict) else {}
|
||||||
is_video = bool(node.get("is_video"))
|
is_video = bool(node.get("is_video"))
|
||||||
|
children = _edges(node.get("edge_sidecar_to_children"))
|
||||||
|
tagged = [
|
||||||
|
ref
|
||||||
|
for n in _edges(node.get("edge_media_to_tagged_user"))
|
||||||
|
if (ref := _user_ref(n.get("user")))
|
||||||
|
]
|
||||||
|
coauthors = [
|
||||||
|
ref for c in (node.get("coauthor_producers") or []) if (ref := _user_ref(c))
|
||||||
|
]
|
||||||
|
loc_name, loc_id = _location_ref(node.get("location"))
|
||||||
return {
|
return {
|
||||||
"id": node.get("id"),
|
"id": node.get("id"),
|
||||||
"type": _TYPE_MAP.get(typename) or ("Video" if is_video else "Image"),
|
"type": _TYPE_MAP.get(typename) or ("Video" if is_video else "Image"),
|
||||||
|
|
@ -129,14 +217,25 @@ def parse_media(node: dict[str, Any]) -> dict[str, Any]:
|
||||||
"dimensionsHeight": _int(dims.get("height")),
|
"dimensionsHeight": _int(dims.get("height")),
|
||||||
"dimensionsWidth": _int(dims.get("width")),
|
"dimensionsWidth": _int(dims.get("width")),
|
||||||
"displayUrl": node.get("display_url"),
|
"displayUrl": node.get("display_url"),
|
||||||
|
"images": [c.get("display_url") for c in children if c.get("display_url")],
|
||||||
|
"childPosts": [_feed_child(c) for c in children],
|
||||||
"videoUrl": node.get("video_url") if is_video else None,
|
"videoUrl": node.get("video_url") if is_video else None,
|
||||||
"alt": node.get("accessibility_caption"),
|
"alt": node.get("accessibility_caption"),
|
||||||
"likesCount": _likes_count(node),
|
"likesCount": _likes_count(node),
|
||||||
"videoViewCount": _int(node.get("video_view_count")) if is_video else None,
|
"videoViewCount": _int(node.get("video_view_count")) if is_video else None,
|
||||||
|
"videoDuration": node.get("video_duration") if is_video else None,
|
||||||
"timestamp": _utc_from_sec(node.get("taken_at_timestamp")),
|
"timestamp": _utc_from_sec(node.get("taken_at_timestamp")),
|
||||||
"ownerUsername": owner.get("username"),
|
"ownerUsername": owner.get("username"),
|
||||||
"ownerId": owner.get("id") or node.get("owner_id"),
|
"ownerId": owner.get("id") or node.get("owner_id"),
|
||||||
"ownerFullName": owner.get("full_name"),
|
"ownerFullName": owner.get("full_name"),
|
||||||
|
"isPinned": bool(node.get("pinned_for_users")),
|
||||||
|
"productType": node.get("product_type"),
|
||||||
|
"paidPartnership": node.get("is_paid_partnership"),
|
||||||
|
"taggedUsers": tagged,
|
||||||
|
"coauthorProducers": coauthors,
|
||||||
|
"musicInfo": node.get("clips_music_attribution_info"),
|
||||||
|
"locationName": loc_name,
|
||||||
|
"locationId": loc_id,
|
||||||
"isCommentsDisabled": node.get("comments_disabled"),
|
"isCommentsDisabled": node.get("comments_disabled"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +244,9 @@ def parse_profile(user: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Map a raw ``web_profile_info`` ``data.user`` to a flat profile item dict."""
|
"""Map a raw ``web_profile_info`` ``data.user`` to a flat profile item dict."""
|
||||||
username = user.get("username")
|
username = user.get("username")
|
||||||
latest = [parse_media(n) for n in _edges(user.get("edge_owner_to_timeline_media"))]
|
latest = [parse_media(n) for n in _edges(user.get("edge_owner_to_timeline_media"))]
|
||||||
|
related = [
|
||||||
|
ref for n in _edges(user.get("edge_related_profiles")) if (ref := _user_ref(n))
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"detailKind": "profile",
|
"detailKind": "profile",
|
||||||
"id": user.get("id"),
|
"id": user.get("id"),
|
||||||
|
|
@ -164,32 +266,34 @@ def parse_profile(user: dict[str, Any]) -> dict[str, Any]:
|
||||||
"verified": user.get("is_verified"),
|
"verified": user.get("is_verified"),
|
||||||
"profilePicUrl": user.get("profile_pic_url"),
|
"profilePicUrl": user.get("profile_pic_url"),
|
||||||
"profilePicUrlHD": user.get("profile_pic_url_hd"),
|
"profilePicUrlHD": user.get("profile_pic_url_hd"),
|
||||||
|
"relatedProfiles": related,
|
||||||
"latestPosts": latest,
|
"latestPosts": latest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Anonymous single-post extraction (/p/<shortcode>/, /reel/<shortcode>/) --------
|
# Anonymous single-post extraction (/p/<shortcode>/, /reel/<shortcode>/) --------
|
||||||
#
|
#
|
||||||
# Instagram serves logged-out visitors the post's public metadata inside the
|
# Instagram serves logged-out visitors the post's full metadata inside the
|
||||||
# document itself, not via a JSON XHR (the ``?__a=1`` API 404s / login-walls for
|
# document itself, not via a JSON XHR (the ``?__a=1`` API 404s / login-walls for
|
||||||
# anonymous callers). Two durable, anonymous surfaces carry it:
|
# anonymous callers). Two anonymous surfaces carry it, in order of fidelity:
|
||||||
# 1. ``<script type="application/ld+json">`` — schema.org VideoObject/ImageObject
|
# 1. An inline ``<script type="application/json">`` block embedding the mobile
|
||||||
# with author, caption (articleBody/caption), uploadDate, interactionStatistic
|
# v1 ``PolarisMedia`` object (pk, taken_at, media_type, like/comment counts,
|
||||||
# (likes/comments), and the media URL.
|
# caption, carousel_media, usertags, coauthor_producers, location, ...). This
|
||||||
|
# is the same shape the app's private API returns and is the real source.
|
||||||
# 2. Open Graph ``<meta property="og:*">`` — a lossy fallback (og:description
|
# 2. Open Graph ``<meta property="og:*">`` — a lossy fallback (og:description
|
||||||
# packs "N likes, M comments - author on DATE: caption").
|
# packs "N likes, M comments - author on DATE: caption"). Used only if the
|
||||||
# ld+json is preferred; og fills gaps. ponytail: pinned to these two surfaces —
|
# relay blob is somehow absent (e.g. a layout change).
|
||||||
# if a live probe shows a different embedded blob (e.g. a PolarisPost JSON), add a
|
# Live logged-out probes show NO ld+json on these pages, so it isn't parsed.
|
||||||
# branch here; the wiring in scraper._media_flow stays the same.
|
# ponytail: pinned to these two surfaces — if the relay shape drifts, update
|
||||||
|
# ``_find_media``; the wiring in scraper._media_flow stays the same.
|
||||||
|
|
||||||
_LDJSON_RE = re.compile(
|
_APP_JSON_RE = re.compile(
|
||||||
r'<script[^>]+type="application/ld\+json"[^>]*>(.*?)</script>',
|
r'<script type="application/json"[^>]*>(.*?)</script>', re.DOTALL
|
||||||
re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
)
|
||||||
_OG_RE = re.compile(
|
_OG_RE = re.compile(
|
||||||
r'<meta\s+property="og:([^"]+)"\s+content="([^"]*)"', re.IGNORECASE
|
r'<meta\s+property="og:([^"]+)"\s+content="([^"]*)"', re.IGNORECASE
|
||||||
)
|
)
|
||||||
# Anonymous /p/ pages carry NO ld+json — og tags are the real source. They
|
# og tags are the fallback source (used only when the relay blob is absent). They
|
||||||
# follow a fixed English shape because the fetch layer pins Accept-Language en-US:
|
# follow a fixed English shape because the fetch layer pins Accept-Language en-US:
|
||||||
# og:description = "{likes} likes, {comments} comments - {username} on {Month D, YYYY}: "{caption}""
|
# og:description = "{likes} likes, {comments} comments - {username} on {Month D, YYYY}: "{caption}""
|
||||||
# og:title = "{fullName} on Instagram: "{caption}""
|
# og:title = "{fullName} on Instagram: "{caption}""
|
||||||
|
|
@ -210,7 +314,7 @@ _OG_OWNER_DATE_RE = re.compile(
|
||||||
_OG_TITLE_RE = re.compile(r"^(.+?)\s+on Instagram:\s*(.*)$", re.DOTALL)
|
_OG_TITLE_RE = re.compile(r"^(.+?)\s+on Instagram:\s*(.*)$", re.DOTALL)
|
||||||
# The numeric media id (pk) rides in the App Link deep-link meta tags
|
# The numeric media id (pk) rides in the App Link deep-link meta tags
|
||||||
# (al:ios:url / al:android:url = "instagram://media?id=<pk>") on anonymous pages,
|
# (al:ios:url / al:android:url = "instagram://media?id=<pk>") on anonymous pages,
|
||||||
# even though og:* and ld+json omit it.
|
# even though the og:* tags omit it.
|
||||||
_MEDIA_ID_RE = re.compile(r"instagram://media\?id=(\d+)")
|
_MEDIA_ID_RE = re.compile(r"instagram://media\?id=(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -272,129 +376,173 @@ def _html_int(value: Any) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _ldjson_blocks(html: str) -> list[dict[str, Any]]:
|
|
||||||
"""Parse every ``application/ld+json`` script block into dicts."""
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for raw in _LDJSON_RE.findall(html):
|
|
||||||
try:
|
|
||||||
data = json.loads(raw.strip())
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
for node in data if isinstance(data, list) else [data]:
|
|
||||||
if isinstance(node, dict):
|
|
||||||
out.append(node)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _og_tags(html: str) -> dict[str, str]:
|
def _og_tags(html: str) -> dict[str, str]:
|
||||||
"""Map ``og:<key>`` -> content for the post document."""
|
"""Map ``og:<key>`` -> content for the post document."""
|
||||||
return {k.lower(): v for k, v in _OG_RE.findall(html)}
|
return {k.lower(): v for k, v in _OG_RE.findall(html)}
|
||||||
|
|
||||||
|
|
||||||
def _ld_interaction(node: dict[str, Any]) -> dict[str, int]:
|
def _find_media(root: Any, shortcode: str | None) -> dict[str, Any] | None:
|
||||||
"""Pull like/comment counts out of schema.org ``interactionStatistic``."""
|
"""Depth-first search a JSON tree for the post's mobile-v1 media object.
|
||||||
stats = node.get("interactionStatistic")
|
|
||||||
items = stats if isinstance(stats, list) else [stats] if stats else []
|
|
||||||
out: dict[str, int] = {}
|
|
||||||
for stat in items:
|
|
||||||
if not isinstance(stat, dict):
|
|
||||||
continue
|
|
||||||
itype = str(stat.get("interactionType") or "")
|
|
||||||
count = _html_int(stat.get("userInteractionCount"))
|
|
||||||
if count is None:
|
|
||||||
continue
|
|
||||||
if "Like" in itype:
|
|
||||||
out["likes"] = count
|
|
||||||
elif "Comment" in itype:
|
|
||||||
out["comments"] = count
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
Matches on ``code == shortcode`` (so a carousel *child* or a related post
|
||||||
def _ld_author_username(node: dict[str, Any]) -> str | None:
|
can't be picked instead of the target) plus ``taken_at`` and an id, which
|
||||||
"""Owner handle from a schema.org ``author`` (alternateName / identifier)."""
|
together uniquely identify the top-level ``PolarisMedia`` node.
|
||||||
author = node.get("author")
|
"""
|
||||||
author = author[0] if isinstance(author, list) and author else author
|
stack = [root]
|
||||||
if not isinstance(author, dict):
|
while stack:
|
||||||
return None
|
cur = stack.pop()
|
||||||
for key in ("alternateName", "identifier", "name"):
|
if isinstance(cur, dict):
|
||||||
val = author.get(key)
|
if (
|
||||||
if isinstance(val, dict):
|
cur.get("taken_at") is not None
|
||||||
val = val.get("value")
|
and ("pk" in cur or "id" in cur)
|
||||||
if isinstance(val, str) and val.strip():
|
and (shortcode is None or cur.get("code") == shortcode)
|
||||||
return val.strip().lstrip("@") or None
|
):
|
||||||
|
return cur
|
||||||
|
stack.extend(cur.values())
|
||||||
|
elif isinstance(cur, list):
|
||||||
|
stack.extend(cur)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_post(html: str | None, *, url: str, shortcode: str | None = None) -> dict[str, Any] | None:
|
def _relay_media(html: str, shortcode: str | None) -> dict[str, Any] | None:
|
||||||
|
"""Locate the embedded ``PolarisMedia`` object for this post, or ``None``.
|
||||||
|
|
||||||
|
The logged-out media payload is inlined as one of ~40 ``application/json``
|
||||||
|
script blocks. We only ``json.loads`` blocks that mention ``taken_at`` (and
|
||||||
|
the shortcode when known) so a single post fetch doesn't parse every blob.
|
||||||
|
"""
|
||||||
|
for raw in _APP_JSON_RE.findall(html):
|
||||||
|
if "taken_at" not in raw:
|
||||||
|
continue
|
||||||
|
if shortcode and shortcode not in raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
media = _find_media(data, shortcode)
|
||||||
|
if media is not None:
|
||||||
|
return media
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _media_from_relay(
|
||||||
|
media: dict[str, Any], *, url: str, shortcode: str | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Map an embedded mobile-v1 ``PolarisMedia`` object to a flat media item.
|
||||||
|
|
||||||
|
Same output shape as :func:`parse_media` (so it flows through
|
||||||
|
``InstagramMediaItem`` unchanged), sourced from the relay dialect
|
||||||
|
(``user``/``taken_at``/``usertags.in``/``carousel_media``/flat counts).
|
||||||
|
"""
|
||||||
|
mt = media.get("media_type")
|
||||||
|
cap = media.get("caption")
|
||||||
|
caption = (
|
||||||
|
cap.get("text") if isinstance(cap, dict) else (cap if isinstance(cap, str) else None)
|
||||||
|
)
|
||||||
|
carousel = media.get("carousel_media")
|
||||||
|
carousel = [c for c in carousel if isinstance(c, dict)] if isinstance(carousel, list) else []
|
||||||
|
vv = media.get("video_versions")
|
||||||
|
video_url = (
|
||||||
|
vv[0].get("url") if isinstance(vv, list) and vv and isinstance(vv[0], dict) else None
|
||||||
|
)
|
||||||
|
is_video = mt == 2 or bool(video_url)
|
||||||
|
owner = media.get("user") if isinstance(media.get("user"), dict) else {}
|
||||||
|
tagged = [
|
||||||
|
ref
|
||||||
|
for t in ((media.get("usertags") or {}).get("in") or [])
|
||||||
|
if isinstance(t, dict) and (ref := _user_ref(t.get("user")))
|
||||||
|
]
|
||||||
|
coauthors = [
|
||||||
|
ref for c in (media.get("coauthor_producers") or []) if (ref := _user_ref(c))
|
||||||
|
]
|
||||||
|
loc_name, loc_id = _location_ref(media.get("location"))
|
||||||
|
# The relay ``id`` is ``POLARIS_<pk>``; strip the prefix so single-post ids
|
||||||
|
# match the numeric pk that og-fallback + the al:ios meta also yield.
|
||||||
|
ident = media.get("id")
|
||||||
|
if isinstance(ident, str) and ident.startswith("POLARIS_"):
|
||||||
|
ident = ident[len("POLARIS_") :]
|
||||||
|
pk = media.get("pk")
|
||||||
|
media_id = ident or (str(pk) if pk is not None else None)
|
||||||
|
return {
|
||||||
|
"id": media_id,
|
||||||
|
"type": _MEDIA_TYPE.get(mt) or ("Video" if is_video else "Image"),
|
||||||
|
"shortCode": media.get("code") or shortcode,
|
||||||
|
"caption": caption,
|
||||||
|
"hashtags": list(dict.fromkeys(_HASHTAG_RE.findall(caption))) if caption else [],
|
||||||
|
"mentions": list(dict.fromkeys(_MENTION_RE.findall(caption))) if caption else [],
|
||||||
|
"url": url,
|
||||||
|
"commentsCount": _int(media.get("comment_count")),
|
||||||
|
"dimensionsHeight": _int(media.get("original_height")),
|
||||||
|
"dimensionsWidth": _int(media.get("original_width")),
|
||||||
|
"displayUrl": _iv2_url(media.get("image_versions2")) or media.get("display_uri"),
|
||||||
|
"images": [
|
||||||
|
u
|
||||||
|
for c in carousel
|
||||||
|
if (u := _iv2_url(c.get("image_versions2")) or c.get("display_uri"))
|
||||||
|
],
|
||||||
|
"childPosts": [_relay_child(c) for c in carousel],
|
||||||
|
"videoUrl": video_url,
|
||||||
|
"alt": media.get("accessibility_caption"),
|
||||||
|
"likesCount": _int(media.get("like_count")),
|
||||||
|
"videoViewCount": _int(media.get("view_count") or media.get("play_count"))
|
||||||
|
if is_video
|
||||||
|
else None,
|
||||||
|
"videoDuration": media.get("video_duration") if is_video else None,
|
||||||
|
"timestamp": _utc_from_sec(media.get("taken_at")),
|
||||||
|
"ownerUsername": owner.get("username"),
|
||||||
|
"ownerId": owner.get("id") or owner.get("pk"),
|
||||||
|
"ownerFullName": owner.get("full_name"),
|
||||||
|
"productType": media.get("product_type"),
|
||||||
|
"taggedUsers": tagged,
|
||||||
|
"coauthorProducers": coauthors,
|
||||||
|
"locationName": loc_name,
|
||||||
|
"locationId": loc_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_post(
|
||||||
|
html: str | None, *, url: str, shortcode: str | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
"""Map an anonymous ``/p/<code>/`` (or ``/reel/``) HTML page to a media dict.
|
"""Map an anonymous ``/p/<code>/`` (or ``/reel/``) HTML page to a media dict.
|
||||||
|
|
||||||
Prefers the embedded schema.org ``ld+json`` block, falling back to Open Graph
|
Prefers the embedded mobile-v1 ``PolarisMedia`` relay JSON (full fidelity),
|
||||||
meta tags for whatever it omits. Returns a dict shaped like
|
falling back to the lossy Open Graph meta tags only if that blob is absent.
|
||||||
:func:`parse_media` (so it flows through ``InstagramMediaItem`` unchanged), or
|
Returns a dict shaped like :func:`parse_media` (so it flows through
|
||||||
``None`` when the document carries neither surface (e.g. a login interstitial
|
``InstagramMediaItem`` unchanged), or ``None`` when the document carries
|
||||||
slipped past the fetch-layer redirect check — the caller treats ``None`` as
|
neither surface (e.g. a login interstitial slipped past the fetch-layer
|
||||||
"nothing to emit", never a silent success).
|
redirect check — the caller treats ``None`` as "nothing to emit", never a
|
||||||
|
silent success).
|
||||||
"""
|
"""
|
||||||
if not isinstance(html, str) or not html.strip():
|
if not isinstance(html, str) or not html.strip():
|
||||||
return None
|
return None
|
||||||
blocks = _ldjson_blocks(html)
|
|
||||||
|
media = _relay_media(html, shortcode)
|
||||||
|
if media is not None:
|
||||||
|
return _media_from_relay(media, url=url, shortcode=shortcode)
|
||||||
|
|
||||||
|
# Fallback: no embedded relay blob -> Open Graph meta only.
|
||||||
og = _og_tags(html)
|
og = _og_tags(html)
|
||||||
if not blocks and not og:
|
if not og:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
node = next(
|
|
||||||
(b for b in blocks if str(b.get("@type", "")).endswith(("Object", "Post"))),
|
|
||||||
blocks[0] if blocks else {},
|
|
||||||
)
|
|
||||||
# ld+json wins when present (richer, structured); og fills every gap. On
|
|
||||||
# anonymous /p/ pages ld+json is absent, so og_meta is the de-facto source.
|
|
||||||
og_meta = _parse_og_meta(og)
|
og_meta = _parse_og_meta(og)
|
||||||
|
caption = og_meta.get("caption")
|
||||||
counts = _ld_interaction(node)
|
video_url = og.get("video")
|
||||||
counts.setdefault("likes", og_meta.get("likes"))
|
|
||||||
counts.setdefault("comments", og_meta.get("comments"))
|
|
||||||
|
|
||||||
caption = (
|
|
||||||
node.get("articleBody")
|
|
||||||
or node.get("caption")
|
|
||||||
or node.get("description")
|
|
||||||
or og_meta.get("caption")
|
|
||||||
)
|
|
||||||
caption = caption if isinstance(caption, str) else None
|
|
||||||
|
|
||||||
video = node.get("video")
|
|
||||||
video = video[0] if isinstance(video, list) and video else video
|
|
||||||
video_url = (
|
|
||||||
video.get("contentUrl") if isinstance(video, dict) else None
|
|
||||||
) or og.get("video")
|
|
||||||
is_video = bool(video_url) or og.get("type") == "video.other"
|
is_video = bool(video_url) or og.get("type") == "video.other"
|
||||||
|
id_match = _MEDIA_ID_RE.search(html)
|
||||||
image = node.get("image")
|
|
||||||
image = image[0] if isinstance(image, list) and image else image
|
|
||||||
display_url = (
|
|
||||||
image.get("url") if isinstance(image, dict) else image
|
|
||||||
if isinstance(image, str)
|
|
||||||
else None
|
|
||||||
) or og.get("image")
|
|
||||||
|
|
||||||
media_id = node.get("identifier") if isinstance(node.get("identifier"), str) else None
|
|
||||||
if media_id is None:
|
|
||||||
id_match = _MEDIA_ID_RE.search(html)
|
|
||||||
media_id = id_match.group(1) if id_match else None
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": media_id,
|
"id": id_match.group(1) if id_match else None,
|
||||||
"type": "Video" if is_video else "Image",
|
"type": "Video" if is_video else "Image",
|
||||||
"shortCode": shortcode,
|
"shortCode": shortcode,
|
||||||
"caption": caption,
|
"caption": caption,
|
||||||
"hashtags": list(dict.fromkeys(_HASHTAG_RE.findall(caption))) if caption else [],
|
"hashtags": list(dict.fromkeys(_HASHTAG_RE.findall(caption))) if caption else [],
|
||||||
"mentions": list(dict.fromkeys(_MENTION_RE.findall(caption))) if caption else [],
|
"mentions": list(dict.fromkeys(_MENTION_RE.findall(caption))) if caption else [],
|
||||||
"url": url,
|
"url": url,
|
||||||
"commentsCount": counts.get("comments"),
|
"commentsCount": og_meta.get("comments"),
|
||||||
"displayUrl": display_url,
|
"displayUrl": og.get("image"),
|
||||||
"videoUrl": video_url if is_video else None,
|
"videoUrl": video_url if is_video else None,
|
||||||
"likesCount": counts.get("likes"),
|
"likesCount": og_meta.get("likes"),
|
||||||
"timestamp": node.get("uploadDate") or node.get("datePublished") or og_meta.get("timestamp"),
|
"timestamp": og_meta.get("timestamp"),
|
||||||
"ownerUsername": _ld_author_username(node) or og_meta.get("ownerUsername"),
|
"ownerUsername": og_meta.get("ownerUsername"),
|
||||||
"ownerFullName": og_meta.get("ownerFullName"),
|
"ownerFullName": og_meta.get("ownerFullName"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,9 +255,10 @@ async def _media_flow(
|
||||||
return
|
return
|
||||||
if resolved.kind in ("post", "reel"):
|
if resolved.kind in ("post", "reel"):
|
||||||
# Single-post extraction: the anonymous ``?__a=1`` JSON API 404s/login-
|
# Single-post extraction: the anonymous ``?__a=1`` JSON API 404s/login-
|
||||||
# walls, but the public /p/<code>/ document embeds the post's og-meta +
|
# walls, but the public /p/<code>/ document embeds the mobile-v1
|
||||||
# ld+json, which parse_post reads. Numeric-ID URLs can't be keyed this
|
# PolarisMedia JSON (og-meta fallback), which parse_post reads. Numeric-ID
|
||||||
# way (the page needs the shortCode), so they're skipped upstream.
|
# URLs can't be keyed this way (the page needs the shortCode), so they're
|
||||||
|
# skipped upstream.
|
||||||
if resolved.numeric_post_id:
|
if resolved.numeric_post_id:
|
||||||
return
|
return
|
||||||
html = await fetch_html(f"p/{resolved.value}/")
|
html = await fetch_html(f"p/{resolved.value}/")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue