mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-23 08:59:38 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9421ad5fda | ||
|
|
1e89ebe038 | ||
|
|
3f4c12b54b | ||
|
|
dcc57f11c4 | ||
|
|
a9e79e90f9 | ||
|
|
a0b7804fd2 | ||
|
|
9e04ef3436 | ||
|
|
34416298a0 | ||
|
|
e39ffecea4 | ||
|
|
15fe3d5f2d | ||
|
|
c73b10843f | ||
|
|
b158c57b9e |
@@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2016-2018 Alexander Graf and André Koch-Kramer.
|
Copyright (c) 2016-2019 Alexander Graf and André Koch-Kramer.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
Vendored
+3
@@ -51,6 +51,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader">GitHub</a>
|
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader">GitHub</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item d-sm-none d-lg-block">
|
||||||
|
<a target="_blank" class="nav-link" href="https://saythanks.io/to/aandergr">Say Thanks!</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<form class="form-inline" action="search.html" method="get">
|
<form class="form-inline" action="search.html" method="get">
|
||||||
<div class="input-group my-2 my-sm-0 ml-2">
|
<div class="input-group my-2 my-sm-0 ml-2">
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ Exceptions
|
|||||||
|
|
||||||
.. autoexception:: BadCredentialsException
|
.. autoexception:: BadCredentialsException
|
||||||
|
|
||||||
|
.. autoexception:: PostChangedException
|
||||||
|
|
||||||
.. autoexception:: TooManyRequestsException
|
.. autoexception:: TooManyRequestsException
|
||||||
|
|
||||||
``InstaloaderContext`` (Low-level functions)
|
``InstaloaderContext`` (Low-level functions)
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ author = 'Alexander Graf and Andre Koch-Kramer'
|
|||||||
#
|
#
|
||||||
# This is also used if you do content translation via gettext catalogs.
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
# Usually you set "language" from the command line for these cases.
|
# Usually you set "language" from the command line for these cases.
|
||||||
language = None
|
language = "en"
|
||||||
|
|
||||||
# There are two options for replacing |today|: either, you set today to some
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
# non-false value, then it is used:
|
# non-false value, then it is used:
|
||||||
|
|||||||
@@ -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.2.1'
|
__version__ = '4.2.3'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -331,7 +331,6 @@ def main():
|
|||||||
g_how.add_argument('--user-agent',
|
g_how.add_argument('--user-agent',
|
||||||
help='User Agent to use for HTTP requests. Defaults to \'{}\'.'.format(default_user_agent()))
|
help='User Agent to use for HTTP requests. Defaults to \'{}\'.'.format(default_user_agent()))
|
||||||
g_how.add_argument('-S', '--no-sleep', action='store_true', help=SUPPRESS)
|
g_how.add_argument('-S', '--no-sleep', action='store_true', help=SUPPRESS)
|
||||||
g_how.add_argument('--graphql-rate-limit', type=int, help=SUPPRESS)
|
|
||||||
g_how.add_argument('--max-connection-attempts', metavar='N', type=int, default=3,
|
g_how.add_argument('--max-connection-attempts', metavar='N', type=int, default=3,
|
||||||
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
|
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
|
||||||
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
|
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
|
||||||
@@ -393,7 +392,6 @@ def main():
|
|||||||
compress_json=not args.no_compress_json,
|
compress_json=not args.no_compress_json,
|
||||||
post_metadata_txt_pattern=post_metadata_txt_pattern,
|
post_metadata_txt_pattern=post_metadata_txt_pattern,
|
||||||
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
|
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
|
||||||
graphql_rate_limit=args.graphql_rate_limit,
|
|
||||||
max_connection_attempts=args.max_connection_attempts,
|
max_connection_attempts=args.max_connection_attempts,
|
||||||
commit_mode=args.commit_mode)
|
commit_mode=args.commit_mode)
|
||||||
_main(loader,
|
_main(loader,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class ProfileNotExistsException(InstaloaderException):
|
|||||||
|
|
||||||
|
|
||||||
class ProfileHasNoPicsException(InstaloaderException):
|
class ProfileHasNoPicsException(InstaloaderException):
|
||||||
|
"""
|
||||||
|
.. deprecated:: 4.2.2
|
||||||
|
Not raised anymore.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -53,5 +57,10 @@ class ConnectionException(InstaloaderException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PostChangedException(InstaloaderException):
|
||||||
|
""".. versionadded:: 4.2.2"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TooManyRequestsException(ConnectionException):
|
class TooManyRequestsException(ConnectionException):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+61
-14
@@ -15,6 +15,9 @@ from hashlib import md5
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Callable, Iterator, List, Optional, Set, Union
|
from typing import Any, Callable, Iterator, List, Optional, Set, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext
|
||||||
from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file, load_structure_from_file
|
from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file, load_structure_from_file
|
||||||
@@ -47,6 +50,37 @@ def _requires_login(func: Callable) -> Callable:
|
|||||||
return call
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_on_connection_error(func: Callable) -> Callable:
|
||||||
|
"""Decorator to retry the function max_connection_attemps 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."""
|
||||||
|
@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)
|
||||||
|
try:
|
||||||
|
if kwargs.get('_attempt'):
|
||||||
|
kwargs['_attempt'] += 1
|
||||||
|
else:
|
||||||
|
kwargs['_attempt'] = 2
|
||||||
|
instaloader.context.do_sleep()
|
||||||
|
return call(instaloader, *args, **kwargs)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
instaloader.context.error("[skipped by user]", repeat_at_end=False)
|
||||||
|
raise ConnectionException(error_string) from None
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
class _ArbitraryItemFormatter(string.Formatter):
|
class _ArbitraryItemFormatter(string.Formatter):
|
||||||
def __init__(self, item: Any):
|
def __init__(self, item: Any):
|
||||||
self._item = item
|
self._item = item
|
||||||
@@ -118,11 +152,10 @@ class Instaloader:
|
|||||||
compress_json: bool = True,
|
compress_json: bool = True,
|
||||||
post_metadata_txt_pattern: str = None,
|
post_metadata_txt_pattern: str = None,
|
||||||
storyitem_metadata_txt_pattern: str = None,
|
storyitem_metadata_txt_pattern: str = None,
|
||||||
graphql_rate_limit: Optional[int] = None,
|
|
||||||
max_connection_attempts: int = 3,
|
max_connection_attempts: int = 3,
|
||||||
commit_mode: bool = False):
|
commit_mode: bool = False):
|
||||||
|
|
||||||
self.context = InstaloaderContext(sleep, quiet, user_agent, graphql_rate_limit, max_connection_attempts)
|
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts)
|
||||||
|
|
||||||
# configuration parameters
|
# configuration parameters
|
||||||
self.dirname_pattern = dirname_pattern or "{target}"
|
self.dirname_pattern = dirname_pattern or "{target}"
|
||||||
@@ -156,13 +189,10 @@ class Instaloader:
|
|||||||
save_metadata=self.save_metadata, compress_json=self.compress_json,
|
save_metadata=self.save_metadata, compress_json=self.compress_json,
|
||||||
post_metadata_txt_pattern=self.post_metadata_txt_pattern,
|
post_metadata_txt_pattern=self.post_metadata_txt_pattern,
|
||||||
storyitem_metadata_txt_pattern=self.storyitem_metadata_txt_pattern,
|
storyitem_metadata_txt_pattern=self.storyitem_metadata_txt_pattern,
|
||||||
graphql_rate_limit=self.context.graphql_count_per_slidingwindow,
|
|
||||||
max_connection_attempts=self.context.max_connection_attempts)
|
max_connection_attempts=self.context.max_connection_attempts)
|
||||||
new_loader.context.query_timestamps = self.context.query_timestamps
|
|
||||||
yield new_loader
|
yield new_loader
|
||||||
self.context.error_log.extend(new_loader.context.error_log)
|
self.context.error_log.extend(new_loader.context.error_log)
|
||||||
new_loader.context.error_log = [] # avoid double-printing of errors
|
new_loader.context.error_log = [] # avoid double-printing of errors
|
||||||
self.context.query_timestamps = new_loader.context.query_timestamps
|
|
||||||
new_loader.close()
|
new_loader.close()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -175,7 +205,9 @@ class Instaloader:
|
|||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def download_pic(self, filename: str, url: str, mtime: datetime, filename_suffix: Optional[str] = None) -> bool:
|
@_retry_on_connection_error
|
||||||
|
def download_pic(self, filename: str, url: str, mtime: datetime,
|
||||||
|
filename_suffix: Optional[str] = None, _attempt: int = 1) -> 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."""
|
||||||
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
|
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
|
||||||
@@ -285,7 +317,8 @@ class Instaloader:
|
|||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
self.context.log('geo', end=' ', flush=True)
|
self.context.log('geo', end=' ', flush=True)
|
||||||
|
|
||||||
def download_profilepic(self, profile: Profile) -> None:
|
@_retry_on_connection_error
|
||||||
|
def download_profilepic(self, profile: Profile, _attempt: int = 1) -> None:
|
||||||
"""Downloads and saves profile pic."""
|
"""Downloads and saves profile pic."""
|
||||||
|
|
||||||
def _epoch_to_string(epoch: datetime) -> str:
|
def _epoch_to_string(epoch: datetime) -> str:
|
||||||
@@ -898,6 +931,11 @@ class Instaloader:
|
|||||||
with error_handler(profile.username):
|
with error_handler(profile.username):
|
||||||
profile_name = profile.username
|
profile_name = profile.username
|
||||||
|
|
||||||
|
# Download profile picture
|
||||||
|
if profile_pic:
|
||||||
|
with self.context.error_catcher('Download profile picture of {}'.format(profile_name)):
|
||||||
|
self.download_profilepic(profile)
|
||||||
|
|
||||||
# Save metadata as JSON if desired.
|
# Save metadata as JSON if desired.
|
||||||
if self.save_metadata:
|
if self.save_metadata:
|
||||||
json_filename = '{0}/{1}_{2}'.format(self.dirname_pattern.format(profile=profile_name,
|
json_filename = '{0}/{1}_{2}'.format(self.dirname_pattern.format(profile=profile_name,
|
||||||
@@ -905,11 +943,6 @@ class Instaloader:
|
|||||||
profile_name, profile.userid)
|
profile_name, profile.userid)
|
||||||
self.save_metadata_json(json_filename, profile)
|
self.save_metadata_json(json_filename, profile)
|
||||||
|
|
||||||
# Download profile picture
|
|
||||||
if profile_pic:
|
|
||||||
with self.context.error_catcher('Download profile picture of {}'.format(profile_name)):
|
|
||||||
self.download_profilepic(profile)
|
|
||||||
|
|
||||||
# Catch some errors
|
# Catch some errors
|
||||||
if profile.is_private and (tagged or highlights or posts):
|
if profile.is_private and (tagged or highlights or posts):
|
||||||
if not self.context.is_logged_in:
|
if not self.context.is_logged_in:
|
||||||
@@ -939,8 +972,22 @@ class Instaloader:
|
|||||||
self.context.log('<skipped>')
|
self.context.log('<skipped>')
|
||||||
continue
|
continue
|
||||||
with self.context.error_catcher("Download {} of {}".format(post, profile_name)):
|
with self.context.error_catcher("Download {} of {}".format(post, profile_name)):
|
||||||
downloaded = self.download_post(post, target=profile_name)
|
# The PostChangedException gets raised if the Post's id/shortcode changed while obtaining
|
||||||
if fast_update and not downloaded:
|
# additional metadata. This is most likely the case if a HTTP redirect takes place while
|
||||||
|
# resolving the shortcode URL.
|
||||||
|
# The `post_changed` variable keeps the fast-update functionality alive: A Post which is
|
||||||
|
# obained after a redirect has probably already been downloaded as a previous Post of the
|
||||||
|
# same Profile.
|
||||||
|
# Observed in issue #225: https://github.com/instaloader/instaloader/issues/225
|
||||||
|
post_changed = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
downloaded = self.download_post(post, target=profile_name)
|
||||||
|
break
|
||||||
|
except PostChangedException:
|
||||||
|
post_changed = True
|
||||||
|
continue
|
||||||
|
if fast_update and not downloaded and not post_changed:
|
||||||
break
|
break
|
||||||
|
|
||||||
if stories and profiles:
|
if stories and profiles:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from typing import Any, Callable, Dict, Iterator, Optional, Union
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.utils
|
import requests.utils
|
||||||
import urllib3
|
|
||||||
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ class InstaloaderContext:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = None,
|
def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = None,
|
||||||
graphql_count_per_slidingwindow: Optional[int] = None, max_connection_attempts: int = 3):
|
max_connection_attempts: int = 3):
|
||||||
|
|
||||||
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._session = self.get_anonymous_session()
|
self._session = self.get_anonymous_session()
|
||||||
@@ -57,7 +56,6 @@ class InstaloaderContext:
|
|||||||
self.quiet = quiet
|
self.quiet = quiet
|
||||||
self.max_connection_attempts = max_connection_attempts
|
self.max_connection_attempts = max_connection_attempts
|
||||||
self._graphql_page_length = 50
|
self._graphql_page_length = 50
|
||||||
self.graphql_count_per_slidingwindow = graphql_count_per_slidingwindow or 200
|
|
||||||
self._root_rhx_gis = None
|
self._root_rhx_gis = None
|
||||||
self.two_factor_auth_pending = None
|
self.two_factor_auth_pending = None
|
||||||
|
|
||||||
@@ -65,7 +63,8 @@ class InstaloaderContext:
|
|||||||
self.error_log = []
|
self.error_log = []
|
||||||
|
|
||||||
# For the adaption of sleep intervals (rate control)
|
# For the adaption of sleep intervals (rate control)
|
||||||
self.query_timestamps = list()
|
self._graphql_query_timestamps = dict()
|
||||||
|
self._graphql_earliest_next_request_time = 0
|
||||||
|
|
||||||
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
||||||
self.raise_all_errors = False
|
self.raise_all_errors = False
|
||||||
@@ -195,7 +194,7 @@ class InstaloaderContext:
|
|||||||
csrf_token = session.cookies.get_dict()['csrftoken']
|
csrf_token = session.cookies.get_dict()['csrftoken']
|
||||||
session.headers.update({'X-CSRFToken': csrf_token})
|
session.headers.update({'X-CSRFToken': csrf_token})
|
||||||
# Not using self.get_json() here, because we need to access csrftoken cookie
|
# Not using self.get_json() here, because we need to access csrftoken cookie
|
||||||
self._sleep()
|
self.do_sleep()
|
||||||
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
||||||
data={'password': passwd, 'username': user}, allow_redirects=True)
|
data={'password': passwd, 'username': user}, allow_redirects=True)
|
||||||
try:
|
try:
|
||||||
@@ -261,11 +260,77 @@ class InstaloaderContext:
|
|||||||
self.username = user
|
self.username = user
|
||||||
self.two_factor_auth_pending = None
|
self.two_factor_auth_pending = None
|
||||||
|
|
||||||
def _sleep(self):
|
def do_sleep(self):
|
||||||
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
|
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
|
||||||
if self.sleep:
|
if self.sleep:
|
||||||
time.sleep(min(random.expovariate(0.7), 5.0))
|
time.sleep(min(random.expovariate(0.7), 5.0))
|
||||||
|
|
||||||
|
def _dump_query_timestamps(self, current_time: float):
|
||||||
|
"""Output the number of GraphQL queries grouped by their query_hash within the last time."""
|
||||||
|
windows = [10, 11, 15, 20, 30, 60]
|
||||||
|
print("GraphQL requests:", file=sys.stderr)
|
||||||
|
for query_hash, times in self._graphql_query_timestamps.items():
|
||||||
|
print(" {}".format(query_hash), file=sys.stderr)
|
||||||
|
for window in windows:
|
||||||
|
reqs_in_sliding_window = sum(t > current_time - window * 60 for t in times)
|
||||||
|
print(" last {} minutes: {} requests".format(window, reqs_in_sliding_window), file=sys.stderr)
|
||||||
|
|
||||||
|
def _graphql_request_count_per_sliding_window(self, query_hash: str) -> int:
|
||||||
|
"""Return how many GraphQL requests can be done within the sliding window."""
|
||||||
|
if self.is_logged_in:
|
||||||
|
max_reqs = {'1cb6ec562846122743b61e492c85999f': 20, '33ba35852cb50da46f5b5e889df7d159': 20}
|
||||||
|
else:
|
||||||
|
max_reqs = {'1cb6ec562846122743b61e492c85999f': 200, '33ba35852cb50da46f5b5e889df7d159': 200}
|
||||||
|
return max_reqs.get(query_hash) or min(max_reqs.values())
|
||||||
|
|
||||||
|
def _graphql_query_waittime(self, query_hash: str, current_time: float, untracked_queries: bool = False) -> int:
|
||||||
|
"""Calculate time needed to wait before GraphQL query can be executed."""
|
||||||
|
sliding_window = 660
|
||||||
|
if query_hash not in self._graphql_query_timestamps:
|
||||||
|
self._graphql_query_timestamps[query_hash] = []
|
||||||
|
self._graphql_query_timestamps[query_hash] = list(filter(lambda t: t > current_time - 60 * 60,
|
||||||
|
self._graphql_query_timestamps[query_hash]))
|
||||||
|
reqs_in_sliding_window = list(filter(lambda t: t > current_time - sliding_window,
|
||||||
|
self._graphql_query_timestamps[query_hash]))
|
||||||
|
count_per_sliding_window = self._graphql_request_count_per_sliding_window(query_hash)
|
||||||
|
if len(reqs_in_sliding_window) < count_per_sliding_window and not untracked_queries:
|
||||||
|
return max(0, self._graphql_earliest_next_request_time - current_time)
|
||||||
|
next_request_time = min(reqs_in_sliding_window) + sliding_window + 6
|
||||||
|
if untracked_queries:
|
||||||
|
self._graphql_earliest_next_request_time = next_request_time
|
||||||
|
return round(max(next_request_time, self._graphql_earliest_next_request_time) - current_time)
|
||||||
|
|
||||||
|
def _ratecontrol_graphql_query(self, query_hash: str, untracked_queries: bool = False):
|
||||||
|
"""Called before a GraphQL query is made in order to stay within Instagram's rate limits.
|
||||||
|
|
||||||
|
:param query_hash: The query_hash parameter of the query.
|
||||||
|
:param untracked_queries: True, if 429 has been returned to apply 429 logic.
|
||||||
|
"""
|
||||||
|
if not untracked_queries:
|
||||||
|
waittime = self._graphql_query_waittime(query_hash, time.monotonic(), untracked_queries)
|
||||||
|
assert waittime >= 0
|
||||||
|
if waittime > 10:
|
||||||
|
self.log('\nToo many queries in the last time. Need to wait {} seconds, until {:%H:%M}.'
|
||||||
|
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
|
||||||
|
time.sleep(waittime)
|
||||||
|
if query_hash not in self._graphql_query_timestamps:
|
||||||
|
self._graphql_query_timestamps[query_hash] = [time.monotonic()]
|
||||||
|
else:
|
||||||
|
self._graphql_query_timestamps[query_hash].append(time.monotonic())
|
||||||
|
else:
|
||||||
|
text_for_429 = ("HTTP error code 429 was returned because too many queries occured in the last time. "
|
||||||
|
"Please do not use Instagram in your browser or run multiple instances of Instaloader "
|
||||||
|
"in parallel.")
|
||||||
|
print(textwrap.fill(text_for_429), file=sys.stderr)
|
||||||
|
current_time = time.monotonic()
|
||||||
|
waittime = self._graphql_query_waittime(query_hash, current_time, untracked_queries)
|
||||||
|
assert waittime >= 0
|
||||||
|
if waittime > 10:
|
||||||
|
self.log('The request will be retried in {} seconds, at {:%H:%M}.'
|
||||||
|
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
|
||||||
|
self._dump_query_timestamps(current_time)
|
||||||
|
time.sleep(waittime)
|
||||||
|
|
||||||
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) -> Dict[str, Any]:
|
session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]:
|
||||||
"""JSON request to Instagram.
|
"""JSON request to Instagram.
|
||||||
@@ -279,32 +344,12 @@ class InstaloaderContext:
|
|||||||
:raises QueryReturnedNotFoundException: When the server responds with a 404.
|
:raises QueryReturnedNotFoundException: When the server responds with a 404.
|
||||||
:raises ConnectionException: When query repeatedly failed.
|
:raises ConnectionException: When query repeatedly failed.
|
||||||
"""
|
"""
|
||||||
def graphql_query_waittime(untracked_queries: bool = False) -> int:
|
|
||||||
sliding_window = 660
|
|
||||||
if not self.query_timestamps:
|
|
||||||
return sliding_window if untracked_queries else 0
|
|
||||||
current_time = time.monotonic()
|
|
||||||
self.query_timestamps = list(filter(lambda t: t > current_time - sliding_window, self.query_timestamps))
|
|
||||||
if len(self.query_timestamps) < self.graphql_count_per_slidingwindow and not untracked_queries:
|
|
||||||
return 0
|
|
||||||
return round(min(self.query_timestamps) + sliding_window - current_time) + 6
|
|
||||||
is_graphql_query = 'query_hash' in params and 'graphql/query' in path
|
is_graphql_query = 'query_hash' in params and 'graphql/query' in path
|
||||||
# some queries are not rate limited if invoked anonymously:
|
|
||||||
query_not_limited = is_graphql_query and not self.is_logged_in \
|
|
||||||
and params['query_hash'] in ['9ca88e465c3f866a76f7adee3871bdd8']
|
|
||||||
if is_graphql_query and not query_not_limited:
|
|
||||||
waittime = graphql_query_waittime()
|
|
||||||
if waittime > 0:
|
|
||||||
self.log('\nToo many queries in the last time. Need to wait {} seconds, until {:%H:%M}.'
|
|
||||||
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
|
|
||||||
time.sleep(waittime)
|
|
||||||
if self.query_timestamps is not None:
|
|
||||||
self.query_timestamps.append(time.monotonic())
|
|
||||||
else:
|
|
||||||
self.query_timestamps = [time.monotonic()]
|
|
||||||
sess = session if session else self._session
|
sess = session if session else self._session
|
||||||
try:
|
try:
|
||||||
self._sleep()
|
self.do_sleep()
|
||||||
|
if is_graphql_query:
|
||||||
|
self._ratecontrol_graphql_query(params['query_hash'])
|
||||||
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
|
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
|
||||||
while resp.is_redirect:
|
while resp.is_redirect:
|
||||||
redirect_url = resp.headers['location']
|
redirect_url = resp.headers['location']
|
||||||
@@ -342,20 +387,9 @@ class InstaloaderContext:
|
|||||||
if _attempt == self.max_connection_attempts:
|
if _attempt == self.max_connection_attempts:
|
||||||
raise ConnectionException(error_string) from err
|
raise ConnectionException(error_string) from err
|
||||||
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
|
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
|
||||||
text_for_429 = ("HTTP error code 429 was returned because too many queries occured in the last time. "
|
|
||||||
"Please do not use Instagram in your browser or run multiple instances of Instaloader "
|
|
||||||
"in parallel.")
|
|
||||||
try:
|
try:
|
||||||
if isinstance(err, TooManyRequestsException):
|
if isinstance(err, TooManyRequestsException):
|
||||||
print(textwrap.fill(text_for_429), file=sys.stderr)
|
self._ratecontrol_graphql_query(params['query_hash'], untracked_queries=True)
|
||||||
if is_graphql_query:
|
|
||||||
print("Made {} GraphQL requests.".format(len(self.query_timestamps)), file=sys.stderr)
|
|
||||||
waittime = graphql_query_waittime(untracked_queries=True)
|
|
||||||
if waittime > 0:
|
|
||||||
self.log('The request will be retried in {} seconds, at {:%H:%M}.'
|
|
||||||
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
|
|
||||||
time.sleep(waittime)
|
|
||||||
self._sleep()
|
|
||||||
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
|
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
self.error("[skipped by user]", repeat_at_end=False)
|
self.error("[skipped by user]", repeat_at_end=False)
|
||||||
@@ -440,15 +474,17 @@ class InstaloaderContext:
|
|||||||
:raises ConnectionException: When query repeatedly failed.
|
:raises ConnectionException: When query repeatedly failed.
|
||||||
|
|
||||||
.. versionadded:: 4.2.1"""
|
.. versionadded:: 4.2.1"""
|
||||||
tempsession = copy_session(self._session)
|
with copy_session(self._session) as tempsession:
|
||||||
tempsession.headers['User-Agent'] = 'Instagram 10.3.2 (iPhone7,2; iPhone OS 9_3_3; en_US; en-US; ' \
|
tempsession.headers['User-Agent'] = 'Instagram 10.3.2 (iPhone7,2; iPhone OS 9_3_3; en_US; en-US; ' \
|
||||||
'scale=2.00; 750x1334) AppleWebKit/420+'
|
'scale=2.00; 750x1334) AppleWebKit/420+'
|
||||||
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
||||||
tempsession.headers.pop(header, None)
|
tempsession.headers.pop(header, None)
|
||||||
return self.get_json(path, params, 'i.instagram.com', tempsession)
|
return self.get_json(path, params, 'i.instagram.com', tempsession)
|
||||||
|
|
||||||
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
|
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
|
||||||
"""Write raw response data into a file."""
|
"""Write raw response data into a file.
|
||||||
|
|
||||||
|
.. versionadded:: 4.2.1"""
|
||||||
self.log(filename, end=' ', flush=True)
|
self.log(filename, end=' ', flush=True)
|
||||||
with open(filename, 'wb') as file:
|
with open(filename, 'wb') as file:
|
||||||
if isinstance(resp, requests.Response):
|
if isinstance(resp, requests.Response):
|
||||||
@@ -461,32 +497,22 @@ class InstaloaderContext:
|
|||||||
|
|
||||||
: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 repeatedly failed."""
|
:raises ConnectionException: When download failed.
|
||||||
try:
|
|
||||||
with self.get_anonymous_session() as anonymous_session:
|
.. versionadded:: 4.2.1"""
|
||||||
resp = anonymous_session.get(url, stream=True)
|
with self.get_anonymous_session() as anonymous_session:
|
||||||
if resp.status_code == 200:
|
resp = anonymous_session.get(url, stream=True)
|
||||||
resp.raw.decode_content = True
|
if resp.status_code == 200:
|
||||||
return resp
|
resp.raw.decode_content = True
|
||||||
else:
|
return resp
|
||||||
if resp.status_code == 403:
|
else:
|
||||||
# suspected invalid URL signature
|
if resp.status_code == 403:
|
||||||
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
|
# suspected invalid URL signature
|
||||||
if resp.status_code == 404:
|
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
|
||||||
# 404 not worth retrying.
|
if resp.status_code == 404:
|
||||||
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
|
# 404 not worth retrying.
|
||||||
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
|
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
|
||||||
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
|
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
|
||||||
error_string = "URL {}: {}".format(url, err)
|
|
||||||
if _attempt == self.max_connection_attempts:
|
|
||||||
raise ConnectionException(error_string) from err
|
|
||||||
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
|
|
||||||
try:
|
|
||||||
self._sleep()
|
|
||||||
return self.get_raw(url, _attempt + 1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
self.error("[skipped by user]", repeat_at_end=False)
|
|
||||||
raise ConnectionException(error_string) from err
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
+13
-11
@@ -128,6 +128,9 @@ class Post:
|
|||||||
pic_json = self._context.get_json("p/{0}/".format(self.shortcode), params={})
|
pic_json = self._context.get_json("p/{0}/".format(self.shortcode), params={})
|
||||||
self._full_metadata_dict = pic_json['entry_data']['PostPage'][0]['graphql']['shortcode_media']
|
self._full_metadata_dict = pic_json['entry_data']['PostPage'][0]['graphql']['shortcode_media']
|
||||||
self._rhx_gis_str = pic_json['rhx_gis']
|
self._rhx_gis_str = pic_json['rhx_gis']
|
||||||
|
if self.shortcode != self._full_metadata_dict['shortcode']:
|
||||||
|
self._node.update(self._full_metadata_dict)
|
||||||
|
raise PostChangedException
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _full_metadata(self) -> Dict[str, Any]:
|
def _full_metadata(self) -> Dict[str, Any]:
|
||||||
@@ -396,28 +399,27 @@ class Profile:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_id(cls, context: InstaloaderContext, profile_id: int):
|
def from_id(cls, context: InstaloaderContext, profile_id: int):
|
||||||
"""Create a Profile instance from a given userid. If possible, use :meth:`Profile.from_username`
|
"""Create a Profile instance from a given userid. If possible, use :meth:`Profile.from_username`
|
||||||
or constructor directly rather than this method, since it does many requests.
|
or constructor directly rather than this method, since it requires more requests.
|
||||||
|
|
||||||
:param context: :attr:`Instaloader.context`
|
:param context: :attr:`Instaloader.context`
|
||||||
:param profile_id: userid
|
:param profile_id: userid
|
||||||
:raises: :class:`ProfileNotExistsException`, :class:`ProfileHasNoPicsException`
|
:raises: :class:`ProfileNotExistsException`
|
||||||
"""
|
"""
|
||||||
if profile_id in context.profile_id_cache:
|
if profile_id in context.profile_id_cache:
|
||||||
return context.profile_id_cache[profile_id]
|
return context.profile_id_cache[profile_id]
|
||||||
data = context.graphql_query("472f257a40c653c64c666ce877d59d2b",
|
data = context.graphql_query('7c16654f22c819fb63d1183034a5162f',
|
||||||
{'id': str(profile_id), 'first': 1},
|
{'user_id': str(profile_id),
|
||||||
|
'include_chaining': False,
|
||||||
|
'include_reel': True,
|
||||||
|
'include_suggested_users': False,
|
||||||
|
'include_logged_out_extras': False,
|
||||||
|
'include_highlight_reels': False},
|
||||||
rhx_gis=context.root_rhx_gis)['data']['user']
|
rhx_gis=context.root_rhx_gis)['data']['user']
|
||||||
if data:
|
if data:
|
||||||
data = data["edge_owner_to_timeline_media"]
|
profile = cls(context, data['reel']['owner'])
|
||||||
else:
|
else:
|
||||||
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
|
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
|
||||||
str(profile_id) + ").")
|
str(profile_id) + ").")
|
||||||
if not data['edges']:
|
|
||||||
if data['count'] == 0:
|
|
||||||
raise ProfileHasNoPicsException("Profile with ID {0}: no pics found.".format(str(profile_id)))
|
|
||||||
else:
|
|
||||||
raise LoginRequiredException("Login required to determine username (ID: " + str(profile_id) + ").")
|
|
||||||
profile = Post(context, data['edges'][0]['node']).owner_profile
|
|
||||||
context.profile_id_cache[profile_id] = profile
|
context.profile_id_cache[profile_id] = profile
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ OWN_USERNAME = "aandergr"
|
|||||||
NORMAL_MAX_COUNT = 2
|
NORMAL_MAX_COUNT = 2
|
||||||
PAGING_MAX_COUNT = 15
|
PAGING_MAX_COUNT = 15
|
||||||
PRIVATE_PROFILE = "aandergr"
|
PRIVATE_PROFILE = "aandergr"
|
||||||
|
PRIVATE_PROFILE_ID = 1706625676
|
||||||
|
EMPTY_PROFILE = "not_public"
|
||||||
|
EMPTY_PROFILE_ID = 1928659031
|
||||||
|
|
||||||
# Preserve query timestamps (rate control) between tests to not get rate limited
|
# Preserve query timestamps (rate control) between tests to not get rate limited
|
||||||
instaloadercontext_query_timestamps = list()
|
instaloadercontext_query_timestamps = dict()
|
||||||
|
|
||||||
|
|
||||||
class TestInstaloaderAnonymously(unittest.TestCase):
|
class TestInstaloaderAnonymously(unittest.TestCase):
|
||||||
@@ -32,11 +35,11 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
download_comments=True,
|
download_comments=True,
|
||||||
save_metadata=True)
|
save_metadata=True)
|
||||||
self.L.context.raise_all_errors = True
|
self.L.context.raise_all_errors = True
|
||||||
self.L.context.query_timestamps = instaloadercontext_query_timestamps
|
self.L.context.query_timestamps = instaloadercontext_query_timestamps.copy()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
global instaloadercontext_query_timestamps
|
global instaloadercontext_query_timestamps
|
||||||
instaloadercontext_query_timestamps = self.L.context.query_timestamps
|
instaloadercontext_query_timestamps = self.L.context.query_timestamps.copy()
|
||||||
self.L.close()
|
self.L.close()
|
||||||
os.chdir('/')
|
os.chdir('/')
|
||||||
print("Removing {}".format(self.dir))
|
print("Removing {}".format(self.dir))
|
||||||
@@ -83,10 +86,18 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
self.assertEqual(PUBLIC_PROFILE_ID,
|
self.assertEqual(PUBLIC_PROFILE_ID,
|
||||||
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
|
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
|
||||||
|
|
||||||
def test_get_username_by_id(self):
|
def test_get_username_by_id_private(self):
|
||||||
|
self.assertEqual(PRIVATE_PROFILE.lower(),
|
||||||
|
instaloader.Profile.from_id(self.L.context, PRIVATE_PROFILE_ID).username)
|
||||||
|
|
||||||
|
def test_get_username_by_id_public(self):
|
||||||
self.assertEqual(PUBLIC_PROFILE.lower(),
|
self.assertEqual(PUBLIC_PROFILE.lower(),
|
||||||
instaloader.Profile.from_id(self.L.context, PUBLIC_PROFILE_ID).username)
|
instaloader.Profile.from_id(self.L.context, PUBLIC_PROFILE_ID).username)
|
||||||
|
|
||||||
|
def test_get_username_by_id_empty(self):
|
||||||
|
self.assertEqual(EMPTY_PROFILE.lower(),
|
||||||
|
instaloader.Profile.from_id(self.L.context, EMPTY_PROFILE_ID).username)
|
||||||
|
|
||||||
def test_post_from_mediaid(self):
|
def test_post_from_mediaid(self):
|
||||||
for post in instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts():
|
for post in instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts():
|
||||||
post2 = instaloader.Post.from_mediaid(self.L.context, post.mediaid)
|
post2 = instaloader.Post.from_mediaid(self.L.context, post.mediaid)
|
||||||
@@ -130,6 +141,9 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
|
|||||||
def test_private_profile_paging(self):
|
def test_private_profile_paging(self):
|
||||||
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
|
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
|
||||||
|
|
||||||
|
def test_profile_pic_download(self):
|
||||||
|
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True)
|
||||||
|
|
||||||
def test_feed_download(self):
|
def test_feed_download(self):
|
||||||
self.L.download_feed_posts(NORMAL_MAX_COUNT)
|
self.L.download_feed_posts(NORMAL_MAX_COUNT)
|
||||||
|
|
||||||
@@ -166,10 +180,6 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
|
|||||||
for f in profile.get_followers():
|
for f in profile.get_followers():
|
||||||
print(f.username)
|
print(f.username)
|
||||||
|
|
||||||
def test_get_username_by_id(self):
|
|
||||||
self.assertEqual(PUBLIC_PROFILE.lower(),
|
|
||||||
instaloader.Profile.from_id(self.L.context, PUBLIC_PROFILE_ID).username)
|
|
||||||
|
|
||||||
def test_get_likes(self):
|
def test_get_likes(self):
|
||||||
for post in instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_posts():
|
for post in instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_posts():
|
||||||
for like in post.get_likes():
|
for like in post.get_likes():
|
||||||
|
|||||||
Reference in New Issue
Block a user