mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-29 20:51:57 -07:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00a6784029 | ||
|
|
56493a8a78 | ||
|
|
a7119ef1e3 | ||
|
|
bf3f6e2bfb | ||
|
|
5b27110476 | ||
|
|
221cd7f8bc | ||
|
|
328e93b0bc | ||
|
|
653efed311 | ||
|
|
133e5c442c | ||
|
|
28d991f765 | ||
|
|
fa6baebc66 | ||
|
|
c0e47314c2 | ||
|
|
e628a8d04a | ||
|
|
ca44660707 | ||
|
|
e93795223a | ||
|
|
7d26ad6ab8 | ||
|
|
5432c4cce1 |
@@ -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)
|
||||
|
||||
-->
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
|
||||
|
||||
|
||||
__version__ = '4.16b1'
|
||||
__version__ = '4.16rc1'
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user