Compare commits

...
17 Commits
Author SHA1 Message Date
Alexander Graf 00a6784029 Fix Profile.from_id() 2026-10-26 21:44:16 +01:00
Alexander Graf 56493a8a78 First Release Candidate for Version 4.16 2026-10-24 16:21:33 +02:00
Alexander Graf a7119ef1e3 Merge branch 'master' into upcoming/v4.16 2026-10-24 16:05:19 +02:00
André Koch-Kramer bf3f6e2bfb Remove PolarisFeedShareMenu relay provider from NodeIterator 2026-10-24 15:14:18 +02:00
Alexander Graf 5b27110476 NodeIterator: keep first_item besides first_node to spare wrapper invocations
The NodeIterator is sometimes invocated with node wrappers that are heavy to
invocate, e.g. because they trigger requests. Storing the resulting item
besides the node allows to avoid those extra requests.

In the FrozenNodeIterator, the node is still saved, rather than the item.
2026-10-24 12:03:46 +02:00
Alexander Graf 221cd7f8bc Add versionchanged directive for Profile.mediacount 2026-10-24 11:22:10 +02:00
Alexander Graf 328e93b0bc Annotate Profile.mediacount as Optional 2026-10-23 23:40:53 +02:00
Alexander Graf 653efed311 Query Post metadata through api/graphql when not logged in 2026-10-23 23:40:31 +02:00
Alexander Graf 133e5c442c Fetch clips play count only when Post.video_play_count is accessed 2026-10-23 23:40:13 +02:00
Alexander Graf 28d991f765 Fix Profile.mediacount from the logged-out profile page 2026-10-23 23:39:34 +02:00
Alexander Graf fa6baebc66 Raise LoginRequiredException in Profile.get_posts() for private profiles when not logged in 2026-10-23 23:36:53 +02:00
Alexander Graf c0e47314c2 Fix Profile.get_posts() when not logged in 2026-10-23 23:33:28 +02:00
Alexander Graf e628a8d04a Allow api/graphql queries when not logged in 2026-10-23 23:27:30 +02:00
Alexander Graf ca44660707 Fix Post.get_likes() with api/v1/media/{id}/likers endpoint 2026-10-23 22:03:54 +02:00
Alexander Graf e93795223a Add AI Policy 2026-10-23 21:16:27 +02:00
Alexander Graf 7d26ad6ab8 Minor improvements to contributing guidelines 2026-10-23 21:16:11 +02:00
Alexander Graf 5432c4cce1 Update copyright years 2026-10-21 22:56:21 +02:00
8 changed files with 98 additions and 53 deletions
+3
View File
@@ -20,5 +20,8 @@ describe:
- Further considerations
- Is it based on master (bugfix), upcoming/v4.X (new feature), or another pull request?
- Does it preserve backward compatibility and existing behavior?
- If generative AI was involved, ensure it follows our AI policy.
(also see https://instaloader.github.io/contributing.html)
-->
+2 -1
View File
@@ -1,6 +1,7 @@
The MIT License (MIT)
Copyright (c) 2016-2020 Alexander Graf and André Koch-Kramer.
Copyright (c) 2016-2026 Alexander Graf, André Koch-Kramer and the Instaloader
contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -15,7 +15,7 @@ sys.path.insert(0, os.path.abspath('..'))
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Instaloader'
copyright = '2023, Alexander Graf and Andre Koch-Kramer'
copyright = '2016-2026, Alexander Graf, Andre Koch-Kramer and the Instaloader contributors'
author = 'Alexander Graf and Andre Koch-Kramer'
# -- General configuration ---------------------------------------------------
+14 -1
View File
@@ -80,7 +80,7 @@ Improvements of the Instaloader source or its documentation can be proposed as a
review, validate, or selectively merge them.
- Be mindful of backward compatibility: changes should preserve existing intended
behavior whenever possible.
behavior whenever feasible.
- All Pull Requests are analyzed by `Pylint <https://www.pylint.org/>`__ for
error and syntax checking of the source and
@@ -100,6 +100,19 @@ Improvements of the Instaloader source or its documentation can be proposed as a
agrees that the change might be an improvement, or if you want to discuss
your basic proposal, before working on a pull request.
AI Policy
---------
- If generative AI was used to create notable portions of a contribution, the
author must disclose which parts were AI-assisted (e.g. research, testing,
coding, or documentation).
- The author must carefully review and understand the contribution before
submitting it.
- If the maintainers need more effort to review the contribution than it took
to create it, please do not submit it.
Proposing Features
------------------
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.16b1'
__version__ = '4.16rc1'
try:
+6 -4
View File
@@ -605,7 +605,7 @@ class InstaloaderContext:
return token_from_cookies() or ''
def _web_api_tokens(self) -> Dict[str, str]:
"""The tokens that the web app sends along with its api/graphql queries, from the logged-in start page."""
"""The tokens that the web app sends along with its api/graphql queries, from the start page."""
sessionid = self._session.cookies.get_dict().get("sessionid", "")
if sessionid not in self._web_api_tokens_cache:
html = self._get_page(self._session, "")
@@ -616,11 +616,13 @@ class InstaloaderContext:
("av", r'"IG_USER_EIMU":"?(\d+)'),
]:
match = re.search(pattern, html)
if match is None:
if match is not None:
tokens[name] = match.group(1)
# Logged out, the start page has no fb_dtsg, and the web app sends its queries without.
elif name != "fb_dtsg" or self.is_logged_in:
raise BadResponseException(
"The start page did not contain the token {}.".format(name)
)
tokens[name] = match.group(1)
self._web_api_tokens_cache[sessionid] = tokens
return self._web_api_tokens_cache[sessionid]
@@ -658,7 +660,7 @@ class InstaloaderContext:
:param variables: Variables for the Query.
:param referer: HTTP Referer, or None.
:param web_api: Query the web app's ``api/graphql`` endpoint, as the web app does, rather than
``graphql/query``. Requires login.
``graphql/query``.
:return: The server's response dictionary.
.. versionchanged:: 4.16
+14 -5
View File
@@ -104,6 +104,7 @@ class NodeIterator(Iterator[T]):
else:
self._data = self._query()
self._first_node: Optional[Dict] = None
self._first_item: Optional[T] = None
self._is_first = is_first
def _query(self, after: Optional[str] = None) -> Dict:
@@ -114,11 +115,11 @@ class NodeIterator(Iterator[T]):
return self._query_query_hash(self._query_hash, after)
def _query_doc_id(self, doc_id: str, after: Optional[str] = None) -> Dict:
pagination_variables: Dict[str, Any] = {'__relay_internal__pv__PolarisFeedShareMenurelayprovider': False}
pagination_variables: Dict[str, Any] = {}
if after is not None:
pagination_variables['after'] = after
pagination_variables['before'] = None
pagination_variables['first'] = 12
pagination_variables['first'] = NodeIterator._graphql_page_length
pagination_variables['last'] = None
data = self._edge_extractor(
self._context.doc_id_graphql_query(
@@ -156,10 +157,10 @@ class NodeIterator(Iterator[T]):
item = self._node_wrapper(node)
if self._is_first is not None:
if self._is_first(item, self.first_item):
self._first_node = node
self._first_node, self._first_item = node, item
else:
if self._first_node is None:
self._first_node = node
self._first_node, self._first_item = node, item
return item
if self._data.get('page_info', {}).get('has_next_page'):
query_response = self._query(self._data['page_info']['end_cursor'])
@@ -206,7 +207,15 @@ class NodeIterator(Iterator[T]):
.. versionchanged:: 4.9.2
What is considered the first item can be overridden.
"""
return self._node_wrapper(self._first_node) if self._first_node is not None else None
if self._first_item is None:
# Restore _first_item from _first_node. Needed after thawing, since
# FrozenNodeIterator stores the node, not the item.
self._first_item = (
self._node_wrapper(self._first_node)
if self._first_node is not None
else None
)
return self._first_item
@staticmethod
def page_length() -> int:
+57 -40
View File
@@ -279,7 +279,7 @@ class Post:
return None
@staticmethod
def _normalize_post_data(media: Dict[str, Any], context: 'InstaloaderContext') -> Dict[str, Any]:
def _normalize_post_data(media: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize a Polaris media item to a legacy-compatible node, preserving the source structure."""
media_types = {1: "GraphImage", 2: "GraphVideo", 8: "GraphSidecar"}
media_type = media.get("media_type")
@@ -306,10 +306,6 @@ class Post:
pic_json["video_duration"] = media.get("video_duration")
pic_json["video_view_count"] = media.get("view_count")
pic_json["video_play_count"] = media.get("play_count")
if media_type == 2 and pic_json["video_view_count"] is None:
pic_json["video_play_count"] = Post._fetch_play_count_from_clips(
context, media["user"]["pk"], media["code"]
)
caption = media.get("caption")
caption_text = caption.get("text") if isinstance(caption, dict) else None
pic_json["edge_media_to_caption"] = (
@@ -419,12 +415,14 @@ class Post:
"shortcode": self.shortcode,
"__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False,
},
# As the logged-out web app does.
web_api=not self._context.is_logged_in,
)
web_info = (resp.get("data") or {}).get("xdt_api__v1__media__shortcode__web_info") or {}
items = web_info.get("items")
if not items:
raise BadResponseException("Fetching Post metadata failed.")
self._full_metadata_dict = Post._normalize_post_data(items[0], self._context)
self._full_metadata_dict = Post._normalize_post_data(items[0])
if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict)
raise PostChangedException
@@ -697,7 +695,11 @@ class Post:
.. versionadded:: 4.14.3"""
if self.is_video:
return self._field('video_play_count')
play_count = self._field('video_play_count')
# Not logged in, the clips connection is null.
if play_count is None and self.video_view_count is None and self._context.is_logged_in:
play_count = Post._fetch_play_count_from_clips(self._context, str(self.owner_id), self.shortcode)
return play_count
return None
@property
@@ -879,19 +881,9 @@ class Post:
if self.likes == 0:
# Avoid doing additional requests if there are no comments
return
likes_edges = self._field('edge_media_preview_like', 'edges')
if self.likes == len(likes_edges):
# If the Post's metadata already contains all likes, don't do GraphQL requests to obtain them
yield from (Profile(self._context, like['node']) for like in likes_edges)
return
yield from NodeIterator(
self._context,
'1cb6ec562846122743b61e492c85999f',
lambda d: d['data']['shortcode_media']['edge_liked_by'],
lambda n: Profile(self._context, n),
{'shortcode': self.shortcode},
'https://www.instagram.com/p/{0}/'.format(self.shortcode),
)
data = self._context.get_json(f"api/v1/media/{self.mediaid}/likers/", {})
for user in data["users"]:
yield Profile(self._context, user)
@property
def is_sponsored(self) -> bool:
@@ -1035,16 +1027,24 @@ class Profile:
"""
if profile_id in context.profile_id_cache:
return context.profile_id_cache[profile_id]
# Resolve the current username from the user id, then load the full profile.
# The GraphQL user query previously used here started responding with HTTP 400.
try:
user = context.get_json(
"api/v1/users/{0}/info/".format(profile_id), params={}
).get('user')
user = context.doc_id_graphql_query(
'27937681195819736',
{
"id": str(profile_id),
"render_surface": "PROFILE",
"__relay_internal__pv__PolarisCannesGuardianExperienceEnabledrelayprovider": True,
"__relay_internal__pv__PolarisCASB976ProfileEnabledrelayprovider": False,
"__relay_internal__pv__PolarisRepostsConsumptionEnabledrelayprovider": False,
"__relay_internal__pv__PolarisWebSchoolsEnabledrelayprovider": False,
"enable_integrity_filters": True,
},
web_api=True,
).get('data', {}).get('user')
except QueryReturnedNotFoundException:
user = None
if user and user.get('username'):
profile = cls.from_username(context, user['username'])
profile = cls(context, cls._normalize_profile_data(user))
else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").")
@@ -1147,6 +1147,9 @@ class Profile:
normalized['id'] = normalized['pk']
if 'edge_owner_to_timeline_media' not in normalized and 'media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['media_count']}
# The logged-out profile page has all_media_count instead.
if 'edge_owner_to_timeline_media' not in normalized and 'all_media_count' in normalized:
normalized['edge_owner_to_timeline_media'] = {'count': normalized['all_media_count']}
if 'edge_felix_video_timeline' not in normalized:
normalized['edge_felix_video_timeline'] = {'count': 0}
if 'edge_followed_by' not in normalized and 'follower_count' in normalized:
@@ -1232,7 +1235,11 @@ class Profile:
return self._metadata('followed_by_viewer')
@property
def mediacount(self) -> int:
def mediacount(self) -> Optional[int]:
"""Number of posts, or None if unavailable (e.g. when not logged in).
.. versionchanged:: 4.16
Returns None if unavailable (e.g. when not logged in)."""
return self._metadata('edge_owner_to_timeline_media', 'count')
@property
@@ -1385,28 +1392,38 @@ class Profile:
"""Retrieve all posts from a profile.
:rtype: NodeIterator[Post]
:raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed."""
self._obtain_metadata()
:raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed.
:raises LoginRequiredException: When not logged in and the profile is private."""
if not self._context.is_logged_in:
# As the logged-out web app, take the first posts from the profile page, and query further ones
# (PolarisLoggedOutDesktopWWWProfilePostsTabContentQuery_connection) by the page's "id" of the
# profile, which is not its user ID.
page = self._context.get_page_data(f"{self.username}/")
# The page shows no posts of a private profile, rather than an error.
if any((data.get("xig_user_by_username") or {}).get("is_private") for data in page):
raise LoginRequiredException(f"Login required to access the posts of private profile {self.username}.")
user = next((data["xig_user_by_username"] for data in page
if "polaris_ordered_timeline_connection" in (data.get("xig_user_by_username") or {})),
None)
if user is None:
raise BadResponseException(f"The page of profile {self.username} did not contain its posts.")
return NodeIterator(
context=self._context,
edge_extractor=lambda d: d["data"]["user"]["edge_owner_to_timeline_media"],
node_wrapper=lambda n: Post(self._context, n, self),
edge_extractor=lambda d: d["data"]["node"]["polaris_ordered_timeline_connection"],
# The nodes lack most of the post's metadata, as in get_reels().
node_wrapper=lambda n: Post.from_shortcode(self._context, n["code"]),
query_variables={
"data": {
"count": 12,
"include_relationship_info": True,
"latest_besties_reel_media": True,
"latest_reel_media": True,
},
"id": self.userid,
"id": user["id"],
"__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False,
},
query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(),
doc_id="7950326061742207",
doc_id="28904005159232689",
query_hash=None,
first_data=self._metadata("edge_owner_to_timeline_media"),
first_data=user["polaris_ordered_timeline_connection"],
web_api=True,
)
self._obtain_metadata()
# Instagram answers with an empty timeline rather than an error in that case. Unlike followed_by_viewer,
# which defaults to False, friendship_status tells whether the follow state is actually known.
if (self.is_private and self.username != (self._context.username or '').lower() and