From 247bddcc9f3c80142e78182160b847474d252156 Mon Sep 17 00:00:00 2001 From: Luis Fernando Illapa Date: Sun, 18 Jan 2026 11:08:58 -0500 Subject: [PATCH 01/28] Add progress counter to "Retrieving highlights" log line (#2634) Introduce progress output for highlight retrieval, displaying a counter of the current highlight index relative to the total number of highlights. This mirrors the existing per-item download counter and provides consistent, useful feedback when downloading multiple highlights. It uses the same dynamic-width format as other Instaloader progress indicators. To support this feature, the Iterator returned by get_highlights(user) is materialized into a list so the total number of highlights can be determined upfront. This does not alter functional behavior, since all highlights were already iterated over, and preserves ordering while enabling fixed-width formatted progress output. --- instaloader/instaloader.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index e9362b4..7e4b0e4 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -960,6 +960,11 @@ class Instaloader: .. versionchanged:: 4.3 Also downloads and saves the Highlight's cover pictures. + .. versionchanged:: 4.16 + Add progress output for highlight retrieval, displaying a counter of the current + highlight number and the total number of highlights being processed. This provides + feedback similar to the per-item download counter already present in this function. + :param user: ID or Profile of the user whose highlights should get downloaded. :param fast_update: If true, abort when first already-downloaded picture is encountered :param filename_target: Replacement for {target} in dirname_pattern and filename_pattern @@ -967,14 +972,20 @@ class Instaloader: :param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded :raises LoginRequiredException: If called without being logged in. """ - for user_highlight in self.get_highlights(user): + user_highlights = list(self.get_highlights(user)) + hl_size = len(user_highlights) + for hl_number, user_highlight in enumerate(user_highlights, start=1): name = user_highlight.owner_username highlight_target: Union[str, Path] = (filename_target if filename_target else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) / _PostPathFormatter.sanitize_path(user_highlight.title, self.sanitize_paths))) - self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name)) + self.context.log( + "[{0:{w}d}/{1:{w}d}] Retrieving highlights \"{2}\" from profile {3}".format( + hl_number, hl_size, user_highlight.title, name, w=len(str(hl_size)) + ) + ) self.download_highlight_cover(user_highlight, highlight_target) totalcount = user_highlight.itemcount count = 1 From f4e1f3c95e448a8855f651a2bf7a4b62668bef9e Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:25:42 +0000 Subject: [PATCH 02/28] Refactor InstaloaderContext.get_json() for readability Split it into small helpers and retry in a loop instead of recursively. This also fixes retried POST queries being repeated as GET. --- instaloader/instaloadercontext.py | 197 +++++++++++++++++------------- 1 file changed, 110 insertions(+), 87 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 433f2f6..8fd6e89 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -401,8 +401,69 @@ class InstaloaderContext: f" when accessing {resp.url}" ) + @staticmethod + def _rate_limit_key(path: str, params: Dict[str, Any], host: str) -> str: + """Determine the :class:`RateController` bucket that a request is accounted to.""" + if 'graphql/query' in path: + if 'query_hash' in params: + return params['query_hash'] + if 'doc_id' in params: + return params['doc_id'] + if host == 'i.instagram.com': + return 'iphone' + return 'other' + + def _raise_if_fatal_status(self, resp: requests.Response, host: str, path: str) -> None: + if resp.status_code not in self.fatal_status_codes: + return + redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else "" + body = "" + if resp.headers['Content-Type'].startswith('application/json'): + body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '') + raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format( + host, path, resp.status_code, resp.reason, redirect, body + )) + + def _follow_redirects(self, sess: requests.Session, resp: requests.Response, + host: str, path: str, params: Dict[str, Any]) -> requests.Response: + """Follow redirects that stay on *host*, and reject those to the login page.""" + while resp.is_redirect: + redirect_url = resp.headers['location'] + self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url)) + if (redirect_url.startswith('https://www.instagram.com/accounts/login') or + redirect_url.startswith('https://i.instagram.com/accounts/login')): + if not self.is_logged_in: + raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.") + raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " + + "some time, recreate the session and try again") + if not redirect_url.startswith('https://{}/'.format(host)): + break + resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/', + params=params, allow_redirects=False) + return resp + + def _raise_for_status(self, resp: requests.Response) -> None: + """Translate an unsuccessful HTTP status code into the corresponding exception.""" + if resp.status_code == 400: + with suppress(json.decoder.JSONDecodeError): + if resp.json().get("message") in [ + "feedback_required", + "checkpoint_required", + "challenge_required", + ]: + # Raise AbortDownloadException in case of substantial Instagram + # requirements to stop producing more requests + raise AbortDownloadException(self._response_error(resp)) + raise QueryReturnedBadRequestException(self._response_error(resp)) + if resp.status_code == 404: + raise QueryReturnedNotFoundException(self._response_error(resp)) + if resp.status_code == 429: + raise TooManyRequestsException(self._response_error(resp)) + if resp.status_code != 200: + raise ConnectionException(self._response_error(resp)) + def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com', - session: Optional[requests.Session] = None, _attempt=1, + session: Optional[requests.Session] = None, response_headers: Optional[Dict[str, Any]] = None, use_post: bool = False) -> Dict[str, Any]: """JSON request to Instagram. @@ -422,96 +483,58 @@ class InstaloaderContext: .. versionchanged:: 4.13 Added `use_post` parameter. + + .. versionchanged:: 4.16 + Removed the internal `_attempt` parameter; retries are no longer implemented by recursion. """ - is_graphql_query = 'query_hash' in params and 'graphql/query' in path - is_doc_id_query = 'doc_id' in params and 'graphql/query' in path - is_iphone_query = host == 'i.instagram.com' - is_other_query = not is_graphql_query and not is_doc_id_query and host == "www.instagram.com" - sess = session if session else self._session - try: - self.do_sleep() - if is_graphql_query: - self._rate_controller.wait_before_query(params['query_hash']) - if is_doc_id_query: - self._rate_controller.wait_before_query(params['doc_id']) - if is_iphone_query: - self._rate_controller.wait_before_query('iphone') - if is_other_query: - self._rate_controller.wait_before_query('other') - if use_post: - resp = sess.post('https://{0}/{1}'.format(host, path), data=params, allow_redirects=False) - else: - resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False) - if resp.status_code in self.fatal_status_codes: - redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else "" - body = "" - if resp.headers['Content-Type'].startswith('application/json'): - body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '') - raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format( - host, path, resp.status_code, resp.reason, redirect, body - )) - while resp.is_redirect: - redirect_url = resp.headers['location'] - self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url)) - if (redirect_url.startswith('https://www.instagram.com/accounts/login') or - redirect_url.startswith('https://i.instagram.com/accounts/login')): - if not self.is_logged_in: - raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.") - raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " + - "some time, recreate the session and try again") - if redirect_url.startswith('https://{}/'.format(host)): - resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/', - params=params, allow_redirects=False) - else: - break - if response_headers is not None: - response_headers.clear() - response_headers.update(resp.headers) - if resp.status_code == 400: - with suppress(json.decoder.JSONDecodeError): - if resp.json().get("message") in [ - "feedback_required", - "checkpoint_required", - "challenge_required", - ]: - # Raise AbortDownloadException in case of substantial Instagram - # requirements to stop producing more requests - raise AbortDownloadException(self._response_error(resp)) - raise QueryReturnedBadRequestException(self._response_error(resp)) - if resp.status_code == 404: - raise QueryReturnedNotFoundException(self._response_error(resp)) - if resp.status_code == 429: - raise TooManyRequestsException(self._response_error(resp)) - if resp.status_code != 200: - raise ConnectionException(self._response_error(resp)) - else: - resp_json = resp.json() - if 'status' in resp_json and resp_json['status'] != "ok": - raise ConnectionException(self._response_error(resp)) - return resp_json - except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err: - error_string = "JSON Query to {}: {}".format(path, err) - if _attempt == self.max_connection_attempts: - if isinstance(err, QueryReturnedNotFoundException): - raise QueryReturnedNotFoundException(error_string) from err - else: - raise ConnectionException(error_string) from err - self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False) + sess = session or self._session + rate_limit_key = self._rate_limit_key(path, params, host) + url = 'https://{0}/{1}'.format(host, path) + + # The failure we are currently retrying, if any. It drives the 429 backoff performed before the + # next attempt, and tells us whether a ^C means "skip this query" rather than "abort everything". + pending_error: Optional[BaseException] = None + pending_error_string = "" + + for attempt in range(1, self.max_connection_attempts + 1): try: - if isinstance(err, TooManyRequestsException): - if is_graphql_query: - self._rate_controller.handle_429(params['query_hash']) - if is_doc_id_query: - self._rate_controller.handle_429(params['doc_id']) - if is_iphone_query: - self._rate_controller.handle_429('iphone') - if is_other_query: - self._rate_controller.handle_429('other') - return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1, - response_headers=response_headers) + if isinstance(pending_error, TooManyRequestsException): + self._rate_controller.handle_429(rate_limit_key) + self.do_sleep() + self._rate_controller.wait_before_query(rate_limit_key) + + if use_post: + resp = sess.post(url, data=params, allow_redirects=False) + else: + resp = sess.get(url, params=params, allow_redirects=False) + + self._raise_if_fatal_status(resp, host, path) + resp = self._follow_redirects(sess, resp, host, path, params) + + if response_headers is not None: + response_headers.clear() + response_headers.update(resp.headers) + + self._raise_for_status(resp) + resp_json = resp.json() + if 'status' in resp_json and resp_json['status'] != "ok": + raise ConnectionException(self._response_error(resp)) + return resp_json + except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err: + error_string = "JSON Query to {}: {}".format(path, err) + if attempt == self.max_connection_attempts: + if isinstance(err, QueryReturnedNotFoundException): + raise QueryReturnedNotFoundException(error_string) from err + raise ConnectionException(error_string) from err + self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False) + pending_error, pending_error_string = err, error_string except KeyboardInterrupt: + if pending_error is None: + raise self.error("[skipped by user]", repeat_at_end=False) - raise ConnectionException(error_string) from err + raise ConnectionException(pending_error_string) from pending_error + + raise AssertionError("get_json() retry loop exited without result") def graphql_query(self, query_hash: str, variables: Dict[str, Any], referer: Optional[str] = None) -> Dict[str, Any]: From a6c279211de032ec57979fa6785c6688f60a0dc7 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:26:16 +0000 Subject: [PATCH 03/28] Do not crash on a fatal status response without Content-Type --- instaloader/instaloadercontext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 8fd6e89..53e5d05 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -418,7 +418,7 @@ class InstaloaderContext: return redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else "" body = "" - if resp.headers['Content-Type'].startswith('application/json'): + if resp.headers.get('Content-Type', '').startswith('application/json'): body = ': ' + resp.text[:500] + ('…' if len(resp.text) > 501 else '') raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}{}".format( host, path, resp.status_code, resp.reason, redirect, body From fb018c81caeea622b08707711353eb7c915719d5 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:26:51 +0000 Subject: [PATCH 04/28] Do not re-send POST data as query arguments when following a redirect --- instaloader/instaloadercontext.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 53e5d05..4ba676c 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -425,8 +425,11 @@ class InstaloaderContext: )) def _follow_redirects(self, sess: requests.Session, resp: requests.Response, - host: str, path: str, params: Dict[str, Any]) -> requests.Response: + host: str, path: str, params: Dict[str, Any], use_post: bool) -> requests.Response: """Follow redirects that stay on *host*, and reject those to the login page.""" + # Redirects are followed with GET. For a POST request, `params` is the request body and must not be + # turned into query arguments. + redirect_params = None if use_post else params while resp.is_redirect: redirect_url = resp.headers['location'] self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url)) @@ -439,7 +442,7 @@ class InstaloaderContext: if not redirect_url.startswith('https://{}/'.format(host)): break resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/', - params=params, allow_redirects=False) + params=redirect_params, allow_redirects=False) return resp def _raise_for_status(self, resp: requests.Response) -> None: @@ -509,7 +512,7 @@ class InstaloaderContext: resp = sess.get(url, params=params, allow_redirects=False) self._raise_if_fatal_status(resp, host, path) - resp = self._follow_redirects(sess, resp, host, path, params) + resp = self._follow_redirects(sess, resp, host, path, params, use_post) if response_headers is not None: response_headers.clear() From ba70aec490108c2c80b7f91c07a17606f11a47f2 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:09:20 +0000 Subject: [PATCH 05/28] Share the download status-code handling of get_raw() and head() Kept separate from _raise_for_status(), as CDN downloads follow a different status policy than the API. --- instaloader/instaloadercontext.py | 43 ++++++++++++++++--------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 4ba676c..c7ceeea 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -465,6 +465,23 @@ class InstaloaderContext: if resp.status_code != 200: raise ConnectionException(self._response_error(resp)) + def _raise_for_download_status(self, resp: requests.Response) -> None: + """Translate an unsuccessful status code of an anonymous download request into an exception. + + This is deliberately a different policy from :meth:`_raise_for_status`, which applies to the + JSON API: a download may be answered with 403 for an invalid URL signature, and is never + answered with the 400 or 429 that the API uses. + """ + if resp.status_code == 200: + return + if resp.status_code == 403: + # suspected invalid URL signature + raise QueryReturnedForbiddenException(self._response_error(resp)) + if resp.status_code == 404: + # 404 not worth retrying. + raise QueryReturnedNotFoundException(self._response_error(resp)) + raise ConnectionException(self._response_error(resp)) + def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com', session: Optional[requests.Session] = None, response_headers: Optional[Dict[str, Any]] = None, @@ -768,17 +785,9 @@ class InstaloaderContext: .. versionadded:: 4.2.1""" with self.get_anonymous_session() as anonymous_session: resp = anonymous_session.get(url, stream=True) - if resp.status_code == 200: - resp.raw.decode_content = True - return resp - else: - if resp.status_code == 403: - # suspected invalid URL signature - raise QueryReturnedForbiddenException(self._response_error(resp)) - if resp.status_code == 404: - # 404 not worth retrying. - raise QueryReturnedNotFoundException(self._response_error(resp)) - raise ConnectionException(self._response_error(resp)) + self._raise_for_download_status(resp) + resp.raw.decode_content = True + return resp def get_and_write_raw(self, url: str, filename: str) -> None: """Downloads and writes anonymously-requested raw data into a file. @@ -799,16 +808,8 @@ class InstaloaderContext: """ with self.get_anonymous_session() as anonymous_session: resp = anonymous_session.head(url, allow_redirects=allow_redirects) - if resp.status_code == 200: - return resp - else: - if resp.status_code == 403: - # suspected invalid URL signature - raise QueryReturnedForbiddenException(self._response_error(resp)) - if resp.status_code == 404: - # 404 not worth retrying. - raise QueryReturnedNotFoundException(self._response_error(resp)) - raise ConnectionException(self._response_error(resp)) + self._raise_for_download_status(resp) + return resp class RateController: From 309708225c2799cf1c8955bf3e761276ff23330d Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:09:34 +0000 Subject: [PATCH 06/28] Remove the dead _attempt parameter of get_raw() Unused since a9e79e9 moved the download retry into _retry_on_connection_error(). --- instaloader/instaloadercontext.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index c7ceeea..b4bfe3c 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -775,14 +775,21 @@ class InstaloaderContext: file.write(resp) os.replace(filename + '.temp', filename) - def get_raw(self, url: str, _attempt=1) -> requests.Response: + def get_raw(self, url: str) -> requests.Response: """Downloads a file anonymously. + This does not retry on its own; callers that need retrying are decorated with + ``_retry_on_connection_error``. + :raises QueryReturnedNotFoundException: When the server responds with a 404. :raises QueryReturnedForbiddenException: When the server responds with a 403. :raises ConnectionException: When download failed. - .. versionadded:: 4.2.1""" + .. versionadded:: 4.2.1 + + .. versionchanged:: 4.16 + Removed the `_attempt` parameter. + """ with self.get_anonymous_session() as anonymous_session: resp = anonymous_session.get(url, stream=True) self._raise_for_download_status(resp) From bafd1bf14c59618a5acb27f8b8b584f04349c3c4 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:12 +0000 Subject: [PATCH 07/28] Turn the retry in _retry_on_connection_error() into a loop --- instaloader/instaloader.py | 48 +++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index ec5925d..f95e1d5 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -85,31 +85,36 @@ def _requires_login(func: Callable) -> Callable: def _retry_on_connection_error(func: Callable) -> Callable: """Decorator to retry the function max_connection_attempts number of times. - Herewith-decorated functions need an ``_attempt`` keyword argument. - This is to decorate functions that do network requests that may fail. Note that :meth:`.get_json`, :meth:`.get_iphone_json`, :meth:`.graphql_query` and :meth:`.graphql_node_list` already have their own logic for retrying, hence functions that only use these for network access must not be decorated with this - decorator.""" + decorator. + + .. versionchanged:: 4.16 + Decorated functions no longer need to accept an ``_attempt`` keyword argument. + """ @wraps(func) - def call(instaloader, *args, **kwargs): - try: - return func(instaloader, *args, **kwargs) - except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err: - error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err) - if (kwargs.get('_attempt') or 1) == instaloader.context.max_connection_attempts: - raise ConnectionException(error_string) from None - instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False) + def call(instaloader, *args, **kwargs) -> Any: + # The failure we are currently retrying, if any. It tells us whether a ^C means "skip this + # download" rather than "abort everything", and whether to sleep before the next attempt. + pending_error_string: Optional[str] = None + for attempt in range(1, instaloader.context.max_connection_attempts + 1): try: - if kwargs.get('_attempt'): - kwargs['_attempt'] += 1 - else: - kwargs['_attempt'] = 2 - instaloader.context.do_sleep() - return call(instaloader, *args, **kwargs) + if pending_error_string is not None: + instaloader.context.do_sleep() + return func(instaloader, *args, **kwargs) + except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err: + error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err) + if attempt == instaloader.context.max_connection_attempts: + raise ConnectionException(error_string) from None + instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False) + pending_error_string = error_string except KeyboardInterrupt: + if pending_error_string is None: + raise instaloader.context.error("[skipped by user]", repeat_at_end=False) - raise ConnectionException(error_string) from None + raise ConnectionException(pending_error_string) from None + raise AssertionError("_retry_on_connection_error() retry loop exited without result") return call @@ -335,7 +340,7 @@ class Instaloader: @_retry_on_connection_error def download_pic(self, filename: str, url: str, mtime: datetime, - filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool: + filename_suffix: Optional[str] = None) -> bool: """Downloads and saves picture with given url under given directory with given timestamp. Returns true, if file was actually downloaded, i.e. updated.""" if filename_suffix is not None: @@ -519,8 +524,9 @@ class Instaloader: '{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension)) @_retry_on_connection_error - def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile], - _attempt: int = 1) -> None: + def download_title_pic( + self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile] + ) -> None: """Downloads and saves a picture that does not have an association with a Post or StoryItem, such as a Profile picture or a Highlight cover picture. Modification time is taken from the HTTP response headers. From 54033f9ea6e6b6dd1ad41e8f3545471c933bd230 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:39 +0000 Subject: [PATCH 08/28] Share the session setup of the two GraphQL query methods --- instaloader/instaloadercontext.py | 62 +++++++++++++++---------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index b4bfe3c..15af97e 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -556,6 +556,32 @@ class InstaloaderContext: raise AssertionError("get_json() retry loop exited without result") + @contextmanager + def _graphql_session(self, referer: Optional[str], csrf: Optional[str] = None) -> Iterator[requests.Session]: + """Short-lived session carrying the headers that the GraphQL endpoint expects.""" + 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'] + del tmpsession.headers['Content-Length'] + tmpsession.headers['authority'] = 'www.instagram.com' + tmpsession.headers['scheme'] = 'https' + tmpsession.headers['accept'] = '*/*' + if csrf is not None: + tmpsession.headers['x-csrftoken'] = csrf + if referer is not None: + tmpsession.headers['referer'] = urllib.parse.quote(referer) + yield tmpsession + + def _csrf_token(self) -> str: + """The csrftoken cookie value, requesting the start page once if we do not have one yet.""" + def token_from_cookies() -> Optional[str]: + return next((c.value for c in self._session.cookies if c.name == 'csrftoken' and c.value), None) + csrf = token_from_cookies() + if csrf: + return csrf + self._session.get('https://www.instagram.com/', timeout=self.request_timeout) + return token_from_cookies() or '' + def graphql_query(self, query_hash: str, variables: Dict[str, Any], referer: Optional[str] = None) -> Dict[str, Any]: """ @@ -569,18 +595,8 @@ class InstaloaderContext: .. versionchanged:: 4.13.1 Removed the `rhx_gis` parameter. """ - 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'] - del tmpsession.headers['Content-Length'] - tmpsession.headers['authority'] = 'www.instagram.com' - tmpsession.headers['scheme'] = 'https' - tmpsession.headers['accept'] = '*/*' - if referer is not None: - tmpsession.headers['referer'] = urllib.parse.quote(referer) - - variables_json = json.dumps(variables, separators=(',', ':')) - + variables_json = json.dumps(variables, separators=(',', ':')) + with self._graphql_session(referer) as tmpsession: resp_json = self.get_json('graphql/query', params={'query_hash': query_hash, 'variables': variables_json}, @@ -601,26 +617,8 @@ 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'] - del tmpsession.headers['Content-Length'] - 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) - - variables_json = json.dumps(variables, separators=(',', ':')) - + variables_json = json.dumps(variables, separators=(',', ':')) + with self._graphql_session(referer, csrf=self._csrf_token()) as tmpsession: resp_json = self.get_json('graphql/query', params={'variables': variables_json, 'doc_id': doc_id, From 1eab8cafdaac1157068f006fa3a65956a93e3c37 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:11:10 +0000 Subject: [PATCH 09/28] Report the login redirect in get_page_data() Instead of reporting "302 Found" for profiles that need a login. --- instaloader/instaloadercontext.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 15af97e..61a6084 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -424,6 +424,24 @@ class InstaloaderContext: host, path, resp.status_code, resp.reason, redirect, body )) + def _raise_if_login_redirect(self, resp: requests.Response) -> None: + """Turn a redirect to the login page into a meaningful exception. + + Instagram answers requests it will not serve anonymously with a redirect to the login page + rather than with an error status, so this needs to be checked wherever we do not follow + redirects automatically. + """ + if not resp.is_redirect: + return + redirect_url = resp.headers.get('location', '') + if not (redirect_url.startswith('https://www.instagram.com/accounts/login') or + redirect_url.startswith('https://i.instagram.com/accounts/login')): + return + if not self.is_logged_in: + raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.") + raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " + + "some time, recreate the session and try again") + def _follow_redirects(self, sess: requests.Session, resp: requests.Response, host: str, path: str, params: Dict[str, Any], use_post: bool) -> requests.Response: """Follow redirects that stay on *host*, and reject those to the login page.""" @@ -433,12 +451,7 @@ class InstaloaderContext: while resp.is_redirect: redirect_url = resp.headers['location'] self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url)) - if (redirect_url.startswith('https://www.instagram.com/accounts/login') or - redirect_url.startswith('https://i.instagram.com/accounts/login')): - if not self.is_logged_in: - raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.") - raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " + - "some time, recreate the session and try again") + self._raise_if_login_redirect(resp) if not redirect_url.startswith('https://{}/'.format(host)): break resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/', @@ -751,6 +764,7 @@ class InstaloaderContext: resp = anonymous_session.get(url, headers=headers, allow_redirects=False) except requests.exceptions.RequestException as err: raise ConnectionException("Page request to {}: {}".format(url, err)) from err + self._raise_if_login_redirect(resp) if resp.status_code == 404: raise QueryReturnedNotFoundException(self._response_error(resp)) if resp.status_code != 200: From 4948d7a63cdb3611578e19b03db9c5cf58d1ec3e Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:37:21 +0000 Subject: [PATCH 10/28] Share the page request of get_page_data() --- instaloader/instaloadercontext.py | 39 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 61a6084..369c635 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -742,6 +742,25 @@ class InstaloaderContext: return response + def _get_page(self, session: requests.Session, path: str) -> str: + """Load a page of ``www.instagram.com`` and return its HTML.""" + self.do_sleep() + self._rate_controller.wait_before_query('other') + url = 'https://www.instagram.com/{0}'.format(path) + # Instagram only embeds the data when the request looks like a browser's page navigation. + headers = {'Accept': 'text/html', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none'} + try: + resp = session.get(url, headers=headers, allow_redirects=False) + except requests.exceptions.RequestException as err: + raise ConnectionException("Page request to {}: {}".format(url, err)) from err + self._raise_if_login_redirect(resp) + if resp.status_code == 404: + raise QueryReturnedNotFoundException(self._response_error(resp)) + if resp.status_code != 200: + raise ConnectionException(self._response_error(resp)) + return resp.text + def get_page_data(self, path: str) -> List[Dict[str, Any]]: """Load a page of ``www.instagram.com`` as a logged-out visitor and return the data it embeds. @@ -753,24 +772,10 @@ class InstaloaderContext: :raises QueryReturnedNotFoundException: When the server responds with a 404. :raises ConnectionException: When the request failed. """ - self.do_sleep() - self._rate_controller.wait_before_query('other') - url = 'https://www.instagram.com/{0}'.format(path) - # Instagram only embeds the data when the request looks like a browser's page navigation. - headers = {'Accept': 'text/html', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-Site': 'none'} - try: - with self.get_anonymous_session() as anonymous_session: - resp = anonymous_session.get(url, headers=headers, allow_redirects=False) - except requests.exceptions.RequestException as err: - raise ConnectionException("Page request to {}: {}".format(url, err)) from err - self._raise_if_login_redirect(resp) - if resp.status_code == 404: - raise QueryReturnedNotFoundException(self._response_error(resp)) - if resp.status_code != 200: - raise ConnectionException(self._response_error(resp)) + with self.get_anonymous_session() as anonymous_session: + html = self._get_page(anonymous_session, path) data: List[Dict[str, Any]] = [] - for script in re.finditer(r'', resp.text, re.DOTALL): + for script in re.finditer(r'', html, re.DOTALL): with suppress(json.decoder.JSONDecodeError): data.extend(_embedded_query_data(json.loads(script.group(1)))) return data From 7d543dc644d17d21e51d88ee9fcb4848388321e4 Mon Sep 17 00:00:00 2001 From: Mayank Padia Date: Wed, 21 Oct 2026 19:56:17 +0530 Subject: [PATCH 11/28] Fix Profile.get_posts() when logged in by updating the posts query Instagram now answers doc_id 28975909992013618 with 400 "feedback_required". Use the query the web app currently sends for a profile's posts tab (PolarisProfilePostsTabContentQuery_connection), doc_id 29104886249145907, with its current relay provider variables. (cherry picked from commit 804894a0b1955a896ed17e74d3535924757ed3cc, #2785) Co-Authored-By: Claude Opus 5.5 --- instaloader/structures.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index bf6b1db..3100c6f 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1412,7 +1412,8 @@ class Profile: node_wrapper=lambda n: Post.from_iphone_struct(self._context, n), # The query and variables that the web app sends for a profile's posts tab # (PolarisProfilePostsTabContentQuery_connection). Instagram answers the - # previous query, 7898261790222653, with "execution error". + # previous queries, 7898261790222653 ("execution error") and 28975909992013618 + # (400 "feedback_required"), with errors. query_variables={ "data": { "count": 12, @@ -1425,12 +1426,14 @@ class Profile: "include_multi_captions": True, "username": self.username, "__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider": True, - "__relay_internal__pv__PolarisShortDramaEnabledrelayprovider": False, + "__relay_internal__pv__PolarisShortDramaEnabledrelayprovider": True, + "__relay_internal__pv__PolarisSEOIsCrawlerrelayprovider": False, + "__relay_internal__pv__PolarisThreadsStyleCommentsEnabledrelayprovider": False, "__relay_internal__pv__PolarisReelsRecoDebugOverlayEnabledrelayprovider": False, }, query_referer="https://www.instagram.com/{0}/".format(self.username), is_first=Profile._make_is_newest_checker(), - doc_id="28975909992013618", + doc_id="29104886249145907", query_hash=None, ) From d972d9a48c5f2fbe77c9062cb698287e704d4334 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:55:11 +0000 Subject: [PATCH 12/28] Query logged-in profile posts through the web app's api/graphql endpoint graphql/query answers with feedback_required; api/graphql needs the fb_dtsg, lsd and av tokens of the logged-in page. --- instaloader/instaloadercontext.py | 59 ++++++++++++++++++++++++------- instaloader/nodeiterator.py | 9 +++-- instaloader/structures.py | 2 ++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 369c635..858b65d 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -125,6 +125,9 @@ class InstaloaderContext: # Cache profile from id (mapping from id to Profile) self.profile_id_cache: Dict[int, Any] = dict() + # Tokens of the logged-in web page for api/graphql queries, by sessionid cookie + self._web_api_tokens_cache: Dict[str, Dict[str, str]] = dict() + @contextmanager def anonymous_copy(self): session = self._session @@ -404,7 +407,7 @@ class InstaloaderContext: @staticmethod def _rate_limit_key(path: str, params: Dict[str, Any], host: str) -> str: """Determine the :class:`RateController` bucket that a request is accounted to.""" - if 'graphql/query' in path: + if 'graphql/query' in path or path == 'api/graphql': if 'query_hash' in params: return params['query_hash'] if 'doc_id' in params: @@ -570,8 +573,11 @@ class InstaloaderContext: raise AssertionError("get_json() retry loop exited without result") @contextmanager - def _graphql_session(self, referer: Optional[str], csrf: Optional[str] = None) -> Iterator[requests.Session]: - """Short-lived session carrying the headers that the GraphQL endpoint expects.""" + def _graphql_session(self, referer: Optional[str], csrf: Optional[str] = None, + lsd: Optional[str] = None) -> Iterator[requests.Session]: + """Short-lived session carrying the headers that the GraphQL endpoint expects. + + Pass the page's `lsd` token for the web app's ``api/graphql`` endpoint.""" 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'] @@ -583,6 +589,9 @@ class InstaloaderContext: tmpsession.headers['x-csrftoken'] = csrf if referer is not None: tmpsession.headers['referer'] = urllib.parse.quote(referer) + if lsd is not None: + tmpsession.headers.update({'x-fb-lsd': lsd, 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin'}) yield tmpsession def _csrf_token(self) -> str: @@ -595,6 +604,26 @@ class InstaloaderContext: self._session.get('https://www.instagram.com/', timeout=self.request_timeout) 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.""" + sessionid = self._session.cookies.get_dict().get("sessionid", "") + if sessionid not in self._web_api_tokens_cache: + html = self._get_page(self._session, "") + tokens = {} + for name, pattern in [ + ("fb_dtsg", r'"DTSGInitialData",\[\],\{[^{}]*"token":"([^"]+)"'), + ("lsd", r'"LSD",\[\],\{[^{}]*"token":"([^"]+)"'), + ("av", r'"IG_USER_EIMU":"?(\d+)'), + ]: + match = re.search(pattern, html) + if match is None: + 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] + def graphql_query(self, query_hash: str, variables: Dict[str, Any], referer: Optional[str] = None) -> Dict[str, Any]: """ @@ -619,7 +648,7 @@ class InstaloaderContext: return resp_json def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any], - referer: Optional[str] = None) -> Dict[str, Any]: + referer: Optional[str] = None, web_api: bool = False) -> Dict[str, Any]: """ Do a doc_id-based GraphQL Query using method POST. @@ -628,17 +657,23 @@ class InstaloaderContext: :param doc_id: doc_id for the query. :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. :return: The server's response dictionary. + + .. versionchanged:: 4.16 + Added `web_api` parameter. """ variables_json = json.dumps(variables, separators=(',', ':')) - with self._graphql_session(referer, csrf=self._csrf_token()) as tmpsession: - resp_json = self.get_json('graphql/query', - params={'variables': variables_json, - 'doc_id': doc_id, - 'server_timestamps': 'true'}, - session=tmpsession, - use_post=True) - if 'status' not in resp_json: + params = {'variables': variables_json, + 'doc_id': doc_id, + 'server_timestamps': 'true', + **(self._web_api_tokens() if web_api else {})} + with self._graphql_session(referer, csrf=self._csrf_token(), lsd=params.get('lsd')) as tmpsession: + resp_json = self.get_json('api/graphql' if web_api else 'graphql/query', params=params, + session=tmpsession, use_post=True) + # api/graphql does not respond with a "status" field. + if not web_api and 'status' not in resp_json: self.error("GraphQL response did not contain a \"status\" field.") return resp_json diff --git a/instaloader/nodeiterator.py b/instaloader/nodeiterator.py index 244b50a..450beae 100644 --- a/instaloader/nodeiterator.py +++ b/instaloader/nodeiterator.py @@ -69,6 +69,9 @@ class NodeIterator(Iterator[T]): .. versionchanged: 4.13 Included support for `doc_id`-based queries (using POST method). + + .. versionchanged: 4.16 + Added `web_api` parameter, see :meth:`InstaloaderContext.doc_id_graphql_query`. """ _graphql_page_length = 12 @@ -83,10 +86,12 @@ class NodeIterator(Iterator[T]): query_referer: Optional[str] = None, first_data: Optional[Dict[str, Any]] = None, is_first: Optional[Callable[[T, Optional[T]], bool]] = None, - doc_id: Optional[str] = None): + doc_id: Optional[str] = None, + web_api: bool = False): self._context = context self._query_hash = query_hash self._doc_id = doc_id + self._web_api = web_api self._edge_extractor = edge_extractor self._node_wrapper = node_wrapper self._query_variables = query_variables if query_variables is not None else {} @@ -117,7 +122,7 @@ class NodeIterator(Iterator[T]): pagination_variables['last'] = None data = self._edge_extractor( self._context.doc_id_graphql_query( - doc_id, {**self._query_variables, **pagination_variables}, self._query_referer + doc_id, {**self._query_variables, **pagination_variables}, self._query_referer, self._web_api ) ) self._best_before = datetime.now() + NodeIterator._shelf_life diff --git a/instaloader/structures.py b/instaloader/structures.py index 3100c6f..eb238e8 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1414,6 +1414,7 @@ class Profile: # (PolarisProfilePostsTabContentQuery_connection). Instagram answers the # previous queries, 7898261790222653 ("execution error") and 28975909992013618 # (400 "feedback_required"), with errors. + # It must be sent to the web app's api/graphql endpoint. query_variables={ "data": { "count": 12, @@ -1435,6 +1436,7 @@ class Profile: is_first=Profile._make_is_newest_checker(), doc_id="29104886249145907", query_hash=None, + web_api=True, ) def get_saved_posts(self) -> NodeIterator[Post]: From 9252370503c536a2898e1afa7b61c3e80005df91 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:39:04 +0000 Subject: [PATCH 13/28] Raise PrivateProfileNotFollowedException in Profile.get_posts() --- instaloader/structures.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index eb238e8..2d5c3a7 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1384,7 +1384,8 @@ class Profile: def get_posts(self) -> NodeIterator[Post]: """Retrieve all posts from a profile. - :rtype: NodeIterator[Post]""" + :rtype: NodeIterator[Post] + :raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed.""" self._obtain_metadata() if not self._context.is_logged_in: return NodeIterator( @@ -1406,6 +1407,11 @@ class Profile: query_hash=None, first_data=self._metadata("edge_owner_to_timeline_media"), ) + # 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 + (self._node.get('friendship_status') or {}).get('following') is False): + raise PrivateProfileNotFollowedException("Profile {} is private but not followed.".format(self.username)) return NodeIterator( context=self._context, edge_extractor=lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"], From 0cce956fac158b4b8907799eb6a27bcd512815c2 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:56:37 +0000 Subject: [PATCH 14/28] Remove the suggestion-mode option from .pylintrc PyLint 4 removed it. --- .pylintrc | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.pylintrc b/.pylintrc index 9609887..5aed06b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -93,10 +93,6 @@ py-version=3.8 # Discover python modules and packages in the file system subtree. recursive=no -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no From 93ff75f51cedb33b581a3e096a75d310bf2a49d9 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:56:51 +0000 Subject: [PATCH 15/28] Annotate InstaloaderContext.username as Optional[str] --- instaloader/instaloadercontext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 858b65d..c045810 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -101,7 +101,7 @@ class InstaloaderContext: self.user_agent = user_agent if user_agent is not None else default_user_agent() self.request_timeout = request_timeout self._session = self.get_anonymous_session() - self.username = None + self.username: Optional[str] = None self.user_id = None self.sleep = sleep self.quiet = quiet From 6cca56ec714f2f0ae86a4fff8c1b665ef1b8ea1f Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:56:54 +0000 Subject: [PATCH 16/28] Do not let MyPy analyze the untyped browser_cookie3 module --- instaloader/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/__main__.py b/instaloader/__main__.py index 7b2670d..efe2f2f 100644 --- a/instaloader/__main__.py +++ b/instaloader/__main__.py @@ -16,7 +16,7 @@ from .instaloader import (get_default_session_filename, get_default_stamps_filen from .instaloadercontext import default_user_agent from .lateststamps import LatestStamps try: - import browser_cookie3 + import browser_cookie3 # type: ignore[import-untyped] bc3_library = True except ImportError: bc3_library = False From 3641b447969188964999523c45bbb9d57565e1d1 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:18:21 +0200 Subject: [PATCH 17/28] First Beta Release for Version 4.16 --- instaloader/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/__init__.py b/instaloader/__init__.py index 9ebc7c9..007c59b 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.4' +__version__ = '4.16b1' try: From e628a8d04ac08e33eb1fb6685c8617485fe5d9c2 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:55:24 +0000 Subject: [PATCH 18/28] Allow api/graphql queries when not logged in --- instaloader/instaloadercontext.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index c045810..6aa4292 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -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 From c0e47314c2ece10f4f52b03531bfd1e1478f7cb5 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:55:24 +0000 Subject: [PATCH 19/28] Fix Profile.get_posts() when not logged in --- instaloader/structures.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index 2d5c3a7..4c34f9b 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1386,27 +1386,33 @@ class Profile: :rtype: NodeIterator[Post] :raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed.""" - self._obtain_metadata() 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}/") + 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 From fa6baebc6626945ea68337fe343432cdbfea98ac Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:55:24 +0000 Subject: [PATCH 20/28] Raise LoginRequiredException in Profile.get_posts() for private profiles when not logged in --- instaloader/structures.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index 4c34f9b..e15c114 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1385,12 +1385,16 @@ class Profile: """Retrieve all posts from a profile. :rtype: NodeIterator[Post] - :raises PrivateProfileNotFollowedException: When logged in and the profile is private and not followed.""" + :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) From 28d991f7655b184f472ef8658237b20c1173f888 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:23:10 +0000 Subject: [PATCH 21/28] Fix Profile.mediacount from the logged-out profile page --- instaloader/structures.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/instaloader/structures.py b/instaloader/structures.py index e15c114..e0ff17f 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -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: From 133e5c442c8d1c06c19f58ed97cf0aea6fc40436 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:31 +0000 Subject: [PATCH 22/28] Fetch clips play count only when Post.video_play_count is accessed --- instaloader/structures.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index e0ff17f..e1d9f60 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -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"] = ( @@ -424,7 +420,7 @@ class Post: 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 +693,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 From 653efed311e03e812076c251cae44ae5fc9c441e Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:31 +0000 Subject: [PATCH 23/28] Query Post metadata through api/graphql when not logged in --- instaloader/structures.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/instaloader/structures.py b/instaloader/structures.py index e1d9f60..941b03c 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -415,6 +415,8 @@ 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") From 328e93b0bcdfbeb2102cb188c09d6238e2043bf5 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:06:04 +0000 Subject: [PATCH 24/28] Annotate Profile.mediacount as Optional --- instaloader/structures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index 941b03c..e335dbf 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1237,7 +1237,8 @@ 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).""" return self._metadata('edge_owner_to_timeline_media', 'count') @property From 221cd7f8bcb4b9623c855e326f58c773e9750a42 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:22:10 +0200 Subject: [PATCH 25/28] Add versionchanged directive for Profile.mediacount --- instaloader/structures.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index e335dbf..deb077e 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1238,7 +1238,10 @@ class Profile: @property def mediacount(self) -> Optional[int]: - """Number of posts, or None if unavailable (e.g. when not logged in).""" + """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 From bf3f6e2bfb4df2e457483d0a58b89f31cb169f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Koch-Kramer?= Date: Sat, 24 Oct 2026 15:07:13 +0200 Subject: [PATCH 26/28] Remove PolarisFeedShareMenu relay provider from NodeIterator --- instaloader/nodeiterator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/instaloader/nodeiterator.py b/instaloader/nodeiterator.py index 450beae..ebdcf86 100644 --- a/instaloader/nodeiterator.py +++ b/instaloader/nodeiterator.py @@ -114,11 +114,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( From 56493a8a78a916dc9f14504e085d937cafc39d39 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:21:33 +0200 Subject: [PATCH 27/28] First Release Candidate for Version 4.16 --- instaloader/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instaloader/__init__.py b/instaloader/__init__.py index 007c59b..c00ea98 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.16b1' +__version__ = '4.16rc1' try: From 00a6784029d9396fa2b9ec58277fcc80acd0ddb1 Mon Sep 17 00:00:00 2001 From: Alexander Graf <17130992+aandergr@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:33:58 +0000 Subject: [PATCH 28/28] Fix Profile.from_id() --- instaloader/structures.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/instaloader/structures.py b/instaloader/structures.py index 4e7cdee..2324869 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1027,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) + ").")