mirror of
https://github.com/instaloader/instaloader.git
synced 2026-11-03 05:55:54 -08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3641b44796 | ||
|
|
c56552c5e9 | ||
|
|
6cca56ec71 | ||
|
|
93ff75f51c | ||
|
|
0cce956fac | ||
|
|
9252370503 | ||
|
|
d972d9a48c | ||
|
|
7d543dc644 | ||
|
|
4948d7a63c | ||
|
|
1eab8cafda | ||
|
|
54033f9ea6 | ||
|
|
bafd1bf14c | ||
|
|
309708225c | ||
|
|
ba70aec490 | ||
|
|
fb018c81ca | ||
|
|
a6c279211d | ||
|
|
f4e1f3c95e | ||
|
|
21d10a7f3e | ||
|
|
c7d3e8dc0e | ||
|
|
0c143b6121 | ||
|
|
b806153ab6 | ||
|
|
247bddcc9f |
@@ -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,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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user