From 66165a9d5d18dea92fc0105a73a4944da0b8502a Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:26:09 +0200 Subject: [PATCH 01/10] Improve typing on import_session() --- instaloader/__main__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/instaloader/__main__.py b/instaloader/__main__.py index e76dfea..7b2670d 100644 --- a/instaloader/__main__.py +++ b/instaloader/__main__.py @@ -82,7 +82,7 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type): return filterfunc -def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''): +def get_cookies_from_instagram(domain: str, browser: str, cookie_file: Optional[str] = None): supported_browsers = { "brave": browser_cookie3.brave, "chrome": browser_cookie3.chrome, @@ -114,13 +114,10 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''): raise LoginException(f"No cookies found for Instagram in {browser}, " f"Are you logged in successfully in {browser}?") - if cookie_name: - return cookies.get(cookie_name, {}) - else: - return cookies + return cookies -def import_session(browser, instaloader, cookiefile): +def import_session(browser: str, instaloader: Instaloader, cookiefile: Optional[str]): cookie = get_cookies_from_instagram('instagram', browser, cookiefile) if cookie is not None: instaloader.context.update_cookies(cookie) From 618c23144ebc033460c74cc2f58c32e5a1108e72 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:11:48 +0200 Subject: [PATCH 02/10] Welcome SocialAPIs as sponsor --- .sponsor-logos/socialapis.png | Bin 0 -> 3012 bytes README.rst | 21 ++++++++++++++++++++- docs/.sponsor-logos | 1 + sponsors | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .sponsor-logos/socialapis.png create mode 120000 docs/.sponsor-logos create mode 120000 sponsors diff --git a/.sponsor-logos/socialapis.png b/.sponsor-logos/socialapis.png new file mode 100644 index 0000000000000000000000000000000000000000..f2d400cc5f8dae2d20650dedf748f9e076eea460 GIT binary patch literal 3012 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 + +.. list-table:: + :align: center + + * - |socialapis| + * - `SocialAPIs `__ + +| + +.. list-table:: + :align: center + + * - `@rocketapi-io `__ + +| See `Alex' GitHub Sponsors `__ page for how you can sponsor the development of Instaloader! +.. |socialapis| image:: ./.sponsor-logos/socialapis.png + :alt: SocialAPIs + :target: https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader + :height: 96px + .. current-sponsors-end It is a pleasure for us to share our Instaloader to the world, and we are proud diff --git a/docs/.sponsor-logos b/docs/.sponsor-logos new file mode 120000 index 0000000..0650adf --- /dev/null +++ b/docs/.sponsor-logos @@ -0,0 +1 @@ +../.sponsor-logos \ No newline at end of file diff --git a/sponsors b/sponsors new file mode 120000 index 0000000..93e6318 --- /dev/null +++ b/sponsors @@ -0,0 +1 @@ +docs/sponsors \ No newline at end of file From d3cd63978976cc6c5a1a7685f1f4edaf6f018774 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:21:12 +0200 Subject: [PATCH 03/10] Remove unneeded symlink --- sponsors | 1 - 1 file changed, 1 deletion(-) delete mode 120000 sponsors diff --git a/sponsors b/sponsors deleted file mode 120000 index 93e6318..0000000 --- a/sponsors +++ /dev/null @@ -1 +0,0 @@ -docs/sponsors \ No newline at end of file From b79a0a1e8b08adf9fd03376f7ef532ca101d83b3 Mon Sep 17 00:00:00 2001 From: npiriou <71148909+npiriou@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:49:03 +0700 Subject: [PATCH 04/10] Fix profile metadata GraphQL query flags (#2696) --- instaloader/structures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index 1f4ca60..30ccd37 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -990,8 +990,10 @@ class 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, } - data = self._context.doc_id_graphql_query('25980296051578533', variables) + data = self._context.doc_id_graphql_query('27937681195819736', variables) if data is None: raise QueryReturnedNotFoundException('GraphQL query returned None') user_data = data.get('data', {}).get('user') From db2ae98928a25cced21e0207ddbd19e7e725828a Mon Sep 17 00:00:00 2001 From: Mike Holzinger Date: Sun, 5 Jul 2026 15:04:29 -0400 Subject: [PATCH 05/10] Guard against null data in Post._obtain_metadata (#2705) When Instagram returns {"data": null} for a shortcode metadata query (e.g. under rate limiting, or for an unavailable post), response["data"]["xdt_shortcode_media"] raised a raw TypeError before the existing `if pic_json is None` check could run. Guard the intermediate "data" so the clean BadResponseException is raised instead, which the download loop already handles. --- instaloader/structures.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index 30ccd37..c1f95c4 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -319,9 +319,11 @@ class Post: def _obtain_metadata(self): if not self._full_metadata_dict: - pic_json = self._context.doc_id_graphql_query( + response = self._context.doc_id_graphql_query( "8845758582119845", {"shortcode": self.shortcode} - )["data"]["xdt_shortcode_media"] + ) + data = (response or {}).get("data") + pic_json = data.get("xdt_shortcode_media") if data else None if pic_json is None: raise BadResponseException("Fetching Post metadata failed.") try: From 297a88f25210096a7bae599c22c1e76b0499c7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Koch-Kramer?= Date: Sun, 5 Jul 2026 21:16:53 +0200 Subject: [PATCH 06/10] Release of version 4.15.2 --- instaloader/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/__init__.py b/instaloader/__init__.py index 02f552d..e2f56a2 100644 --- a/instaloader/__init__.py +++ b/instaloader/__init__.py @@ -1,7 +1,7 @@ """Download pictures (or videos) along with their captions and other metadata from Instagram.""" -__version__ = '4.15.1' +__version__ = '4.15.2' try: From ad03b392db9a965d1ece316ececab82b242cdad6 Mon Sep 17 00:00:00 2001 From: Joseph May Date: Wed, 8 Jul 2026 21:27:39 -0400 Subject: [PATCH 07/10] Fix caption mentions being missed after a newline _mention_regex required the character before '@' to be a non-word character other than '\n', so a mention at the start of a new line was never matched. The '\n' exclusion was introduced in 8784ac7 when the prefix went from '\W' to '[^\w\n]'; restoring '\W' matches mentions after a newline while still keeping email addresses out of the results, since the character before '@' in an address is a word character. Also affects Profile.biography_mentions and StoryItem.caption_mentions, which share the same regex. Fixes #2709 --- instaloader/structures.py | 2 +- test/instaloader_unittests.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index c1f95c4..6136070 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -152,7 +152,7 @@ _hashtag_regex = re.compile(r"(?:#)((?:\w){1,150})") # support Unicode and a word/beginning of string delimiter at the beginning to ensure # that no email addresses join the list of mentions. # http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/ -_mention_regex = re.compile(r"(?:^|[^\w\n]|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII) +_mention_regex = re.compile(r"(?:^|\W|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII) def _optional_normalize(string: Optional[str]) -> Optional[str]: diff --git a/test/instaloader_unittests.py b/test/instaloader_unittests.py index 0cb7c40..87d5990 100644 --- a/test/instaloader_unittests.py +++ b/test/instaloader_unittests.py @@ -210,5 +210,28 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously): break +class TestCaptionMentions(unittest.TestCase): + """Caption parsing, which requires no network access.""" + + @staticmethod + def _post_with_caption(caption: str) -> instaloader.Post: + return instaloader.Post(None, {"shortcode": "BhrEy4nBFVU", "caption": caption}) + + def test_caption_mentions(self): + for caption, expected in [ + ("@alice at the beginning", ["alice"]), + ("preceded by a space @bob", ["bob"]), + ("preceded by a newline\n@carol", ["carol"]), + ("multiple lines\n@dave\n@erin", ["dave", "erin"]), + ("no mentions here", []), + ]: + with self.subTest(caption=caption): + self.assertEqual(self._post_with_caption(caption).caption_mentions, expected) + + def test_caption_mentions_ignores_email_addresses(self): + post = self._post_with_caption("write to alice@example.com\ncontact @bob instead") + self.assertEqual(post.caption_mentions, ["bob"]) + + if __name__ == '__main__': unittest.main() From a4adeb083506cf53c5096451d4f5d8d1aa87e0a6 Mon Sep 17 00:00:00 2001 From: Markus Magnuson Date: Sun, 26 Jul 2026 10:18:00 +0200 Subject: [PATCH 08/10] Fix profile resolution (#2701) Fix Profile.from_id(), Profile.from_username() and Profile._obtain_metadata() by using `users/web_profile_info/` and `users/{profile_id}/info` queries. --- instaloader/instaloadercontext.py | 3 +- instaloader/structures.py | 56 +++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 045408a..a6641b1 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -191,7 +191,8 @@ class InstaloaderContext: 'Referer': 'https://www.instagram.com/', 'User-Agent': self.user_agent, 'X-Instagram-AJAX': '1', - 'X-Requested-With': 'XMLHttpRequest'} + 'X-Requested-With': 'XMLHttpRequest', + 'x-ig-app-id': '936619743392459'} if empty_session_only: del header['Host'] del header['Origin'] diff --git a/instaloader/structures.py b/instaloader/structures.py index 6136070..8cee76b 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -912,11 +912,20 @@ class Profile: :param username: Username :raises: :class:`ProfileNotExistsException` """ - data = context.doc_id_graphql_query("26347858941511777", {"hasQuery": True, "query": username})["data"] - if data: - for user in data["xdt_api__v1__fbsearch__non_profiled_serp"]["users"]: - if user["username"].lower() == username.lower(): - return cls(context, user) + # Resolve the profile through the web_profile_info endpoint, which works both + # anonymously and when logged in and returns the complete profile node + # (including the first page of posts). The GraphQL fbsearch query previously + # used here started responding with HTTP 400. + try: + data = context.get_json( + "api/v1/users/web_profile_info/", params={"username": username.lower()} + ).get("data") + except QueryReturnedNotFoundException: + data = None + if data and data.get("user"): + profile = cls(context, data["user"]) + profile._has_full_metadata = True + return profile raise ProfileNotExistsException("Profile {} does not exist.".format(username)) @@ -931,15 +940,16 @@ class Profile: """ if profile_id in context.profile_id_cache: return context.profile_id_cache[profile_id] - data = context.graphql_query('7c16654f22c819fb63d1183034a5162f', - {'user_id': str(profile_id), - 'include_chaining': False, - 'include_reel': True, - 'include_suggested_users': False, - 'include_logged_out_extras': False, - 'include_highlight_reels': False})['data']['user'] - if data: - profile = cls(context, data['reel']['owner']) + # 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') + except QueryReturnedNotFoundException: + user = None + if user and user.get('username'): + profile = cls.from_username(context, user['username']) else: raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " + str(profile_id) + ").") @@ -985,6 +995,24 @@ class Profile: def _obtain_metadata(self): try: if not self._has_full_metadata: + if not self._context.is_logged_in: + # Anonymous access: the web_profile_info endpoint returns the full + # node in the legacy format and still works, unlike the GraphQL + # profile query. + try: + data = self._context.get_json( + "api/v1/users/web_profile_info/", + params={"username": self.username}, + ).get('data') + except QueryReturnedNotFoundException as err: + raise ProfileNotExistsException( + 'Profile {} does not exist.'.format(self.username)) from err + user_data = data.get('user') if data else None + if user_data is None: + raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) + self._node = user_data + self._has_full_metadata = True + return user_id = self._node.get('id') or self._node.get('pk') variables = { "id": str(user_id), From 6dd77e7b56acdf0a0017d473678b15d3cd7a2c72 Mon Sep 17 00:00:00 2001 From: Elias Date: Sun, 26 Jul 2026 11:47:46 +0300 Subject: [PATCH 09/10] Fix Post metadata fetching (#2706) Use doc_id 27128499623469141 (PolarisPostRootQuery / xdt_api__v1__media__shortcode__web_info) which returns v1/iPhone API format. Converts the new format to the legacy GraphQL field names that the rest of the codebase expects. --- instaloader/instaloadercontext.py | 8 ++ instaloader/structures.py | 121 +++++++++++++++++++++++++----- 2 files changed, 112 insertions(+), 17 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index a6641b1..07b5378 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -541,6 +541,13 @@ class InstaloaderContext: :param referer: HTTP Referer, or None. :return: The server's response dictionary. """ + csrf = next((c.value for c in self._session.cookies + if c.name == 'csrftoken' and c.value), None) + if not csrf: + self._session.get('https://www.instagram.com/', timeout=self.request_timeout) + csrf = next((c.value for c in self._session.cookies + if c.name == 'csrftoken' and c.value), '') + with copy_session(self._session, self.request_timeout) as tmpsession: tmpsession.headers.update(self._default_http_header(empty_session_only=True)) del tmpsession.headers['Connection'] @@ -548,6 +555,7 @@ class InstaloaderContext: tmpsession.headers['authority'] = 'www.instagram.com' tmpsession.headers['scheme'] = 'https' tmpsession.headers['accept'] = '*/*' + tmpsession.headers['x-csrftoken'] = csrf if referer is not None: tmpsession.headers['referer'] = urllib.parse.quote(referer) diff --git a/instaloader/structures.py b/instaloader/structures.py index 8cee76b..3d2342c 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -258,6 +258,100 @@ class Post: fake_node["video_url"] = iphone_node["video_versions"][0]["url"] return fake_node + @staticmethod + def _fetch_play_count_from_clips(context: 'InstaloaderContext', user_id: str, + shortcode: str) -> Optional[int]: + """Fetch play_count for a reel via the clips connection endpoint as a fallback.""" + try: + resp = context.doc_id_graphql_query( + "27234427476213202", + {"data": {"include_feed_video": True, "page_size": 12, + "target_user_id": str(user_id)}}, + ) + edges = ((resp.get("data") or {}) + .get("xdt_api__v1__clips__user__connection_v2") or {}) + for edge in edges.get("edges") or []: + media = (edge.get("node") or {}).get("media") or {} + if media.get("code") == shortcode: + return media.get("play_count") + except (ConnectionException, BadResponseException): + pass + return None + + @staticmethod + def _normalize_post_data(media: Dict[str, Any], context: 'InstaloaderContext') -> 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") + typename = None + if isinstance(media_type, int): + typename = media_types.get(media_type) + if not typename: + raise BadResponseException(f"Unknown media_type in metadata: {media_type}.") + pic_json: Dict[str, Any] = media.copy() + pic_json["shortcode"] = media["code"] + pic_json["id"] = media["pk"] + pic_json["__typename"] = typename + pic_json["is_video"] = media_type == 2 + pic_json["taken_at_timestamp"] = media["taken_at"] + pic_json["owner"] = { + "id": media["user"]["pk"], + "username": media["user"].get("username", ""), + "full_name": media["user"].get("full_name", ""), + } + candidates = (media.get("image_versions2") or {}).get("candidates") or [] + pic_json["display_url"] = candidates[0]["url"] if candidates else None + video_versions = media.get("video_versions") or [] + pic_json["video_url"] = video_versions[0]["url"] if video_versions else None + 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"] = ( + {"edges": [{"node": {"text": caption_text}}]} if caption_text is not None + else {"edges": []} + ) + pic_json["edge_media_preview_like"] = {"count": media.get("like_count") or 0} + pic_json["edge_media_to_parent_comment"] = { + "count": media.get("comment_count") or 0, + "edges": [], + } + if media.get("has_liked") is not None: + pic_json["viewer_has_liked"] = media["has_liked"] + carousel = media.get("carousel_media") or [] + if carousel: + carousel_nodes = [] + for item in carousel: + item_type = item.get("media_type", 1) + node: Dict[str, Any] = { + "shortcode": item.get("code", ""), + "__typename": media_types.get(item_type, "GraphImage"), + "is_video": item_type == 2, + } + item_candidates = (item.get("image_versions2") or {}).get("candidates") or [] + node["display_url"] = item_candidates[0]["url"] if item_candidates else "" + item_videos = item.get("video_versions") or [] + node["video_url"] = item_videos[0]["url"] if item_videos else None + if item.get("accessibility_caption") is not None: + node["accessibility_caption"] = item["accessibility_caption"] + carousel_nodes.append({"node": node}) + pic_json["edge_sidecar_to_children"] = {"edges": carousel_nodes} + tagged = (media.get("usertags") or {}).get("in") or [] + if tagged: + pic_json["edge_media_to_tagged_user"] = { + "edges": [ + {"node": {"user": {"username": t["user"]["username"].lower()}}} + for t in tagged + if (t.get("user") or {}).get("username") + ] + } + return pic_json + @staticmethod def shortcode_to_mediaid(code: str) -> int: if len(code) > 11: @@ -319,25 +413,18 @@ class Post: def _obtain_metadata(self): if not self._full_metadata_dict: - response = self._context.doc_id_graphql_query( - "8845758582119845", {"shortcode": self.shortcode} + resp = self._context.doc_id_graphql_query( + "27128499623469141", + { + "shortcode": self.shortcode, + "__relay_internal__pv__PolarisAIGMMediaWebLabelEnabledrelayprovider": False, + }, ) - data = (response or {}).get("data") - pic_json = data.get("xdt_shortcode_media") if data else None - if pic_json is None: + 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.") - try: - xdt_types = { - "XDTGraphImage": "GraphImage", - "XDTGraphVideo": "GraphVideo", - "XDTGraphSidecar": "GraphSidecar", - } - pic_json["__typename"] = xdt_types[pic_json["__typename"]] - except KeyError as exc: - raise BadResponseException( - f"Unknown __typename in metadata: {pic_json['__typename']}." - ) from exc - self._full_metadata_dict = pic_json + self._full_metadata_dict = Post._normalize_post_data(items[0], self._context) if self.shortcode != self._full_metadata_dict['shortcode']: self._node.update(self._full_metadata_dict) raise PostChangedException From 543469296ec61e49c4e1d34edac39f043b16a5f1 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:01:52 +0200 Subject: [PATCH 10/10] Release of version 4.15.3 --- instaloader/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/__init__.py b/instaloader/__init__.py index e2f56a2..dced6cc 100644 --- a/instaloader/__init__.py +++ b/instaloader/__init__.py @@ -1,7 +1,7 @@ """Download pictures (or videos) along with their captions and other metadata from Instagram.""" -__version__ = '4.15.2' +__version__ = '4.15.3' try: