Compare commits

...
22 Commits
Author SHA1 Message Date
Alexander Graf 3641b44796 First Beta Release for Version 4.16 2026-10-21 22:18:21 +02:00
Alexander Graf c56552c5e9 Merge branch 'master' into upcoming/v4.16 2026-10-21 22:01:45 +02:00
Alexander Graf 6cca56ec71 Do not let MyPy analyze the untyped browser_cookie3 module 2026-10-21 21:53:24 +02:00
Alexander Graf 93ff75f51c Annotate InstaloaderContext.username as Optional[str] 2026-10-21 21:53:18 +02:00
Alexander Graf 0cce956fac Remove the suggestion-mode option from .pylintrc
PyLint 4 removed it.
2026-10-21 21:53:12 +02:00
Alexander Graf 9252370503 Raise PrivateProfileNotFollowedException in Profile.get_posts() 2026-10-21 21:53:01 +02:00
Alexander Graf d972d9a48c 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.
2026-10-21 21:48:43 +02:00
Mayank PadiaandClaude Opus 5.5 7d543dc644 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 804894a0b1, #2785)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:48:43 +02:00
Alexander Graf 4948d7a63c Share the page request of get_page_data() 2026-10-21 21:48:43 +02:00
Alexander Graf 1eab8cafda Report the login redirect in get_page_data()
Instead of reporting "302 Found" for profiles that need a login.
2026-10-21 21:44:35 +02:00
Alexander Graf 54033f9ea6 Share the session setup of the two GraphQL query methods 2026-10-21 21:43:47 +02:00
Alexander Graf bafd1bf14c Turn the retry in _retry_on_connection_error() into a loop 2026-10-21 21:34:24 +02:00
Alexander Graf 309708225c Remove the dead _attempt parameter of get_raw()
Unused since a9e79e9 moved the download retry into
_retry_on_connection_error().
2026-10-21 21:31:13 +02:00
Alexander Graf ba70aec490 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.
2026-10-21 21:28:08 +02:00
Alexander Graf fb018c81ca Do not re-send POST data as query arguments when following a redirect 2026-10-21 21:28:08 +02:00
Alexander Graf a6c279211d Do not crash on a fatal status response without Content-Type 2026-10-21 21:22:27 +02:00
Alexander Graf f4e1f3c95e 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.
2026-10-21 21:21:14 +02:00
Alexander Graf 21d10a7f3e Merge branch 'master' into upcoming/v4.16 2026-10-12 22:07:09 +02:00
Alexander Graf c7d3e8dc0e Merge branch 'master' into upcoming/v4.16 2026-10-11 21:07:50 +02:00
Alexander Graf 0c143b6121 Merge branch 'master' into upcoming/v4.16 2026-07-26 11:19:13 +02:00
Alexander Graf b806153ab6 Merge branch 'master' into upcoming/v4.16 2026-03-21 22:07:52 +01:00
Luis Fernando Illapa 247bddcc9f 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.
2026-01-18 17:08:58 +01:00
7 changed files with 317 additions and 202 deletions
-4
View File
@@ -93,10 +93,6 @@ py-version=3.8
# Discover python modules and packages in the file system subtree. # Discover python modules and packages in the file system subtree.
recursive=no 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 # Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code. # active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no unsafe-load-any-extension=no
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram.""" """Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.15.4' __version__ = '4.16b1'
try: try:
+1 -1
View File
@@ -16,7 +16,7 @@ from .instaloader import (get_default_session_filename, get_default_stamps_filen
from .instaloadercontext import default_user_agent from .instaloadercontext import default_user_agent
from .lateststamps import LatestStamps from .lateststamps import LatestStamps
try: try:
import browser_cookie3 import browser_cookie3 # type: ignore[import-untyped]
bc3_library = True bc3_library = True
except ImportError: except ImportError:
bc3_library = False bc3_library = False
+40 -23
View File
@@ -85,31 +85,36 @@ def _requires_login(func: Callable) -> Callable:
def _retry_on_connection_error(func: Callable) -> Callable: def _retry_on_connection_error(func: Callable) -> Callable:
"""Decorator to retry the function max_connection_attempts number of times. """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 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 :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 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) @wraps(func)
def call(instaloader, *args, **kwargs): def call(instaloader, *args, **kwargs) -> Any:
try: # The failure we are currently retrying, if any. It tells us whether a ^C means "skip this
return func(instaloader, *args, **kwargs) # download" rather than "abort everything", and whether to sleep before the next attempt.
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err: pending_error_string: Optional[str] = None
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err) for attempt in range(1, instaloader.context.max_connection_attempts + 1):
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)
try: try:
if kwargs.get('_attempt'): if pending_error_string is not None:
kwargs['_attempt'] += 1 instaloader.context.do_sleep()
else: return func(instaloader, *args, **kwargs)
kwargs['_attempt'] = 2 except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
instaloader.context.do_sleep() error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
return call(instaloader, *args, **kwargs) 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: except KeyboardInterrupt:
if pending_error_string is None:
raise
instaloader.context.error("[skipped by user]", repeat_at_end=False) 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 return call
@@ -335,7 +340,7 @@ class Instaloader:
@_retry_on_connection_error @_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime, 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. """Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated.""" Returns true, if file was actually downloaded, i.e. updated."""
if filename_suffix is not None: if filename_suffix is not None:
@@ -519,8 +524,9 @@ class Instaloader:
'{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension)) '{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension))
@_retry_on_connection_error @_retry_on_connection_error
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile], def download_title_pic(
_attempt: int = 1) -> None: 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 """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. Profile picture or a Highlight cover picture. Modification time is taken from the HTTP response headers.
@@ -963,6 +969,11 @@ class Instaloader:
.. versionchanged:: 4.3 .. versionchanged:: 4.3
Also downloads and saves the Highlight's cover pictures. 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 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 fast_update: If true, abort when first already-downloaded picture is encountered
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern :param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
@@ -970,14 +981,20 @@ class Instaloader:
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded :param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
:raises LoginRequiredException: If called without being logged in. :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 name = user_highlight.owner_username
highlight_target: Union[str, Path] = (filename_target highlight_target: Union[str, Path] = (filename_target
if filename_target if filename_target
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) / else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
_PostPathFormatter.sanitize_path(user_highlight.title, _PostPathFormatter.sanitize_path(user_highlight.title,
self.sanitize_paths))) 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) self.download_highlight_cover(user_highlight, highlight_target)
totalcount = user_highlight.itemcount totalcount = user_highlight.itemcount
count = 1 count = 1
+253 -167
View File
@@ -101,7 +101,7 @@ class InstaloaderContext:
self.user_agent = user_agent if user_agent is not None else default_user_agent() self.user_agent = user_agent if user_agent is not None else default_user_agent()
self.request_timeout = request_timeout self.request_timeout = request_timeout
self._session = self.get_anonymous_session() self._session = self.get_anonymous_session()
self.username = None self.username: Optional[str] = None
self.user_id = None self.user_id = None
self.sleep = sleep self.sleep = sleep
self.quiet = quiet self.quiet = quiet
@@ -125,6 +125,9 @@ class InstaloaderContext:
# Cache profile from id (mapping from id to Profile) # Cache profile from id (mapping from id to Profile)
self.profile_id_cache: Dict[int, Any] = dict() 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 @contextmanager
def anonymous_copy(self): def anonymous_copy(self):
session = self._session session = self._session
@@ -401,8 +404,102 @@ class InstaloaderContext:
f" when accessing {resp.url}" 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 or path == 'api/graphql':
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.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
))
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."""
# 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))
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 + '/',
params=redirect_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 _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', 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, response_headers: Optional[Dict[str, Any]] = None,
use_post: bool = False) -> Dict[str, Any]: use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram. """JSON request to Instagram.
@@ -422,96 +519,110 @@ class InstaloaderContext:
.. versionchanged:: 4.13 .. versionchanged:: 4.13
Added `use_post` parameter. 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 sess = session or self._session
is_doc_id_query = 'doc_id' in params and 'graphql/query' in path rate_limit_key = self._rate_limit_key(path, params, host)
is_iphone_query = host == 'i.instagram.com' url = 'https://{0}/{1}'.format(host, path)
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 # The failure we are currently retrying, if any. It drives the 429 backoff performed before the
try: # next attempt, and tells us whether a ^C means "skip this query" rather than "abort everything".
self.do_sleep() pending_error: Optional[BaseException] = None
if is_graphql_query: pending_error_string = ""
self._rate_controller.wait_before_query(params['query_hash'])
if is_doc_id_query: for attempt in range(1, self.max_connection_attempts + 1):
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)
try: try:
if isinstance(err, TooManyRequestsException): if isinstance(pending_error, TooManyRequestsException):
if is_graphql_query: self._rate_controller.handle_429(rate_limit_key)
self._rate_controller.handle_429(params['query_hash']) self.do_sleep()
if is_doc_id_query: self._rate_controller.wait_before_query(rate_limit_key)
self._rate_controller.handle_429(params['doc_id'])
if is_iphone_query: if use_post:
self._rate_controller.handle_429('iphone') resp = sess.post(url, data=params, allow_redirects=False)
if is_other_query: else:
self._rate_controller.handle_429('other') resp = sess.get(url, params=params, allow_redirects=False)
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1,
response_headers=response_headers) self._raise_if_fatal_status(resp, host, path)
resp = self._follow_redirects(sess, resp, host, path, params, use_post)
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: except KeyboardInterrupt:
if pending_error is None:
raise
self.error("[skipped by user]", repeat_at_end=False) 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")
@contextmanager
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']
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)
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:
"""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 _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], def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]: referer: Optional[str] = None) -> Dict[str, Any]:
@@ -526,18 +637,8 @@ class InstaloaderContext:
.. versionchanged:: 4.13.1 .. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter. Removed the `rhx_gis` parameter.
""" """
with copy_session(self._session, self.request_timeout) as tmpsession: variables_json = json.dumps(variables, separators=(',', ':'))
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) with self._graphql_session(referer) as tmpsession:
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=(',', ':'))
resp_json = self.get_json('graphql/query', resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash, params={'query_hash': query_hash,
'variables': variables_json}, 'variables': variables_json},
@@ -547,7 +648,7 @@ class InstaloaderContext:
return resp_json return resp_json
def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any], 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. Do a doc_id-based GraphQL Query using method POST.
@@ -556,35 +657,23 @@ class InstaloaderContext:
:param doc_id: doc_id for the query. :param doc_id: doc_id for the query.
:param variables: Variables for the Query. :param variables: Variables for the Query.
:param referer: HTTP Referer, or None. :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. :return: The server's response dictionary.
.. versionchanged:: 4.16
Added `web_api` parameter.
""" """
csrf = next((c.value for c in self._session.cookies variables_json = json.dumps(variables, separators=(',', ':'))
if c.name == 'csrftoken' and c.value), None) params = {'variables': variables_json,
if not csrf: 'doc_id': doc_id,
self._session.get('https://www.instagram.com/', timeout=self.request_timeout) 'server_timestamps': 'true',
csrf = next((c.value for c in self._session.cookies **(self._web_api_tokens() if web_api else {})}
if c.name == 'csrftoken' and c.value), '') 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,
with copy_session(self._session, self.request_timeout) as tmpsession: session=tmpsession, use_post=True)
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) # api/graphql does not respond with a "status" field.
del tmpsession.headers['Connection'] if not web_api and 'status' not in resp_json:
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=(',', ':'))
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:
self.error("GraphQL response did not contain a \"status\" field.") self.error("GraphQL response did not contain a \"status\" field.")
return resp_json return resp_json
@@ -688,6 +777,25 @@ class InstaloaderContext:
return response 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]]: 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. """Load a page of ``www.instagram.com`` as a logged-out visitor and return the data it embeds.
@@ -699,23 +807,10 @@ class InstaloaderContext:
:raises QueryReturnedNotFoundException: When the server responds with a 404. :raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When the request failed. :raises ConnectionException: When the request failed.
""" """
self.do_sleep() with self.get_anonymous_session() as anonymous_session:
self._rate_controller.wait_before_query('other') html = self._get_page(anonymous_session, path)
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
if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp))
if resp.status_code != 200:
raise ConnectionException(self._response_error(resp))
data: List[Dict[str, Any]] = [] data: List[Dict[str, Any]] = []
for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', resp.text, re.DOTALL): for script in re.finditer(r'<script type="application/json"[^>]*>(.*?)</script>', html, re.DOTALL):
with suppress(json.decoder.JSONDecodeError): with suppress(json.decoder.JSONDecodeError):
data.extend(_embedded_query_data(json.loads(script.group(1)))) data.extend(_embedded_query_data(json.loads(script.group(1))))
return data return data
@@ -732,27 +827,26 @@ class InstaloaderContext:
file.write(resp) file.write(resp)
os.replace(filename + '.temp', filename) 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. """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 QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403. :raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When download failed. :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: with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, stream=True) resp = anonymous_session.get(url, stream=True)
if resp.status_code == 200: self._raise_for_download_status(resp)
resp.raw.decode_content = True resp.raw.decode_content = True
return resp 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))
def get_and_write_raw(self, url: str, filename: str) -> None: def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file. """Downloads and writes anonymously-requested raw data into a file.
@@ -773,16 +867,8 @@ class InstaloaderContext:
""" """
with self.get_anonymous_session() as anonymous_session: with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.head(url, allow_redirects=allow_redirects) resp = anonymous_session.head(url, allow_redirects=allow_redirects)
if resp.status_code == 200: self._raise_for_download_status(resp)
return resp 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))
class RateController: class RateController:
+7 -2
View File
@@ -69,6 +69,9 @@ class NodeIterator(Iterator[T]):
.. versionchanged: 4.13 .. versionchanged: 4.13
Included support for `doc_id`-based queries (using POST method). 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 _graphql_page_length = 12
@@ -83,10 +86,12 @@ class NodeIterator(Iterator[T]):
query_referer: Optional[str] = None, query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None, first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T, Optional[T]], bool]] = 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._context = context
self._query_hash = query_hash self._query_hash = query_hash
self._doc_id = doc_id self._doc_id = doc_id
self._web_api = web_api
self._edge_extractor = edge_extractor self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {} self._query_variables = query_variables if query_variables is not None else {}
@@ -117,7 +122,7 @@ class NodeIterator(Iterator[T]):
pagination_variables['last'] = None pagination_variables['last'] = None
data = self._edge_extractor( data = self._edge_extractor(
self._context.doc_id_graphql_query( 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 self._best_before = datetime.now() + NodeIterator._shelf_life
+15 -4
View File
@@ -1384,7 +1384,8 @@ class Profile:
def get_posts(self) -> NodeIterator[Post]: def get_posts(self) -> NodeIterator[Post]:
"""Retrieve all posts from a profile. """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() self._obtain_metadata()
if not self._context.is_logged_in: if not self._context.is_logged_in:
return NodeIterator( return NodeIterator(
@@ -1406,13 +1407,20 @@ class Profile:
query_hash=None, query_hash=None,
first_data=self._metadata("edge_owner_to_timeline_media"), 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( return NodeIterator(
context=self._context, context=self._context,
edge_extractor=lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"], edge_extractor=lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"],
node_wrapper=lambda n: Post.from_iphone_struct(self._context, n), 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 # The query and variables that the web app sends for a profile's posts tab
# (PolarisProfilePostsTabContentQuery_connection). Instagram answers the # (PolarisProfilePostsTabContentQuery_connection). Instagram answers the
# previous query, 7898261790222653, with "execution error". # 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={ query_variables={
"data": { "data": {
"count": 12, "count": 12,
@@ -1425,13 +1433,16 @@ class Profile:
"include_multi_captions": True, "include_multi_captions": True,
"username": self.username, "username": self.username,
"__relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider": True, "__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, "__relay_internal__pv__PolarisReelsRecoDebugOverlayEnabledrelayprovider": False,
}, },
query_referer="https://www.instagram.com/{0}/".format(self.username), query_referer="https://www.instagram.com/{0}/".format(self.username),
is_first=Profile._make_is_newest_checker(), is_first=Profile._make_is_newest_checker(),
doc_id="28975909992013618", doc_id="29104886249145907",
query_hash=None, query_hash=None,
web_api=True,
) )
def get_saved_posts(self) -> NodeIterator[Post]: def get_saved_posts(self) -> NodeIterator[Post]: