Compare commits

..
12 Commits
Author SHA1 Message Date
Alexander Graf 9421ad5fda Release of version 4.2.3 2019-02-16 22:25:43 +01:00
Alexander Graf 1e89ebe038 Adjust GraphQL rate control 2019-02-16 19:26:58 +01:00
André Koch-Kramer 3f4c12b54b Release of version 4.2.2 2019-02-10 21:13:29 +01:00
Alexander Graf dcc57f11c4 Fix falsely-raised PostChangedException 2019-02-10 20:55:07 +01:00
Alexander Graf a9e79e90f9 Fix retry logic for download of files
This fixes #235. The retry logic has been broken with commit
4bc0a94e12 in version 4.2.1.
2019-02-10 20:29:04 +01:00
André Koch-Kramer a0b7804fd2 Properly handle redirected posts
Resolves #225.
2019-02-10 18:24:33 +01:00
André Koch-Kramer 9e04ef3436 New Profile.from_id() that works without posts
The old method needed the target profile to have at least one post.
The new method works anonymously even for private profiles.
Closes #249.
2019-02-10 17:46:18 +01:00
Alexander Graf 34416298a0 profiles: First load profilepic, then save json
This way, the iphone_struct information is included in saved profile pictures.
2019-01-24 22:40:36 +01:00
Alexander Graf e39ffecea4 Minor doc changes
- Add saythanks.io link
- Set lang="en" attribute in html tag
2019-01-24 22:39:58 +01:00
Alexander Graf 15fe3d5f2d doc: versionadded directive to new {write,get}_raw 2019-01-17 18:39:54 +01:00
Alexander Graf c73b10843f Update year in LICENSE file 2019-01-17 18:12:32 +01:00
Alexander Graf b158c57b9e Unittest for HD profilepic and fix ResourceWarning 2019-01-17 18:10:14 +01:00
11 changed files with 209 additions and 112 deletions
+1 -1
View File
@@ -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
+3
View File
@@ -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">
+2
View File
@@ -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
View File
@@ -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 -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.2.1' __version__ = '4.2.3'
try: try:
-2
View File
@@ -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,
+9
View File
@@ -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
View File
@@ -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:
+100 -74
View File
@@ -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
View File
@@ -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
+18 -8
View File
@@ -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():