Compare commits

...
7 Commits
10 changed files with 65 additions and 29 deletions
-1
View File
@@ -4,7 +4,6 @@ python:
- "3.5" - "3.5"
- "3.6" - "3.6"
- "3.7" - "3.7"
- "3.8-dev"
install: install:
- pipenv sync --dev - pipenv sync --dev
script: script:
+2 -1
View File
@@ -89,6 +89,7 @@ Instaloader supports the following targets:
Posts tagged with a given location; the location ID is the numerical ID Posts tagged with a given location; the location ID is the numerical ID
Instagram labels a location with (e.g. Instagram labels a location with (e.g.
\https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/). \https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/).
Requires :option:`--login`.
.. versionadded:: 4.2 .. versionadded:: 4.2
@@ -122,7 +123,7 @@ downloads the pictures and videos and their captions. You can specify
- :option:`--geotags` - :option:`--geotags`
**download geotags** of each post and save them as **download geotags** of each post and save them as
Google Maps link, Google Maps link (requires :option:`--login`),
For a reference of all supported command line options, see For a reference of all supported command line options, see
:ref:`command-line-options`. :ref:`command-line-options`.
+1 -2
View File
@@ -61,8 +61,7 @@ What to Download of each Post
**Download geotags** when available. Geotags are stored as a text file with **Download geotags** when available. Geotags are stored as a text file with
the location's name and a Google Maps link. This requires an additional the location's name and a Google Maps link. This requires an additional
request to the Instagram server for each picture, which is why it is disabled request to the Instagram server for each picture. Requires :option:`--login`.
by default.
.. option:: --comments, -C .. option:: --comments, -C
+3 -1
View File
@@ -9,7 +9,9 @@ X_percentage = 10 # percentage of posts that should be downloaded
L = Instaloader() L = Instaloader()
profile = Profile.from_username(L.context, PROFILE) profile = Profile.from_username(L.context, PROFILE)
posts_sorted_by_likes = sorted(profile.get_posts(), key = lambda p: p.likes + p.comments) posts_sorted_by_likes = sorted(profile.get_posts(),
key=lambda p: p.likes + p.comments,
reverse=True)
for post in islice(posts_sorted_by_likes, ceil(profile.mediacount * X_percentage / 100)): for post in islice(posts_sorted_by_likes, ceil(profile.mediacount * X_percentage / 100)):
L.download_post(post, PROFILE) L.download_post(post, PROFILE)
+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.8' __version__ = '4.2.9'
try: try:
+6 -2
View File
@@ -98,6 +98,9 @@ def _main(instaloader: Instaloader, targetlist: List[str],
else: else:
instaloader.interactive_login(username) instaloader.interactive_login(username)
instaloader.context.log("Logged in as %s." % username) instaloader.context.log("Logged in as %s." % username)
# since 4.2.9 login is required for geotags
if instaloader.download_geotags and not instaloader.context.is_logged_in:
instaloader.context.error("Warning: Use --login to download geotags of posts.")
# Try block for KeyboardInterrupt (save session on ^C) # Try block for KeyboardInterrupt (save session on ^C)
profiles = set() profiles = set()
anonymous_retry_profiles = set() anonymous_retry_profiles = set()
@@ -223,7 +226,8 @@ def main():
help="Download all followees of profile. Requires --login. " help="Download all followees of profile. Requires --login. "
"Consider using :feed rather than @yourself.") "Consider using :feed rather than @yourself.")
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.") g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
g_targets.add_argument('_location', nargs='*', metavar='%location_id', help="Download %%location_id.") g_targets.add_argument('_location', nargs='*', metavar='%location_id',
help="Download %%location_id. Requires --login.")
g_targets.add_argument('_feed', nargs='*', metavar=":feed", g_targets.add_argument('_feed', nargs='*', metavar=":feed",
help="Download pictures from your feed. Requires --login.") help="Download pictures from your feed. Requires --login.")
g_targets.add_argument('_stories', nargs='*', metavar=":stories", g_targets.add_argument('_stories', nargs='*', metavar=":stories",
@@ -258,7 +262,7 @@ def main():
help='Download geotags when available. Geotags are stored as a ' help='Download geotags when available. Geotags are stored as a '
'text file with the location\'s name and a Google Maps link. ' 'text file with the location\'s name and a Google Maps link. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
'server for each picture, which is why it is disabled by default.') 'server for each picture. Requires --login.')
g_post.add_argument('-C', '--comments', action='store_true', g_post.add_argument('-C', '--comments', action='store_true',
help='Download and update comments for each post. ' help='Download and update comments for each post. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
+30 -8
View File
@@ -47,8 +47,6 @@ def _requires_login(func: Callable) -> Callable:
if not instaloader.context.is_logged_in: if not instaloader.context.is_logged_in:
raise LoginRequiredException("--login=USERNAME required.") raise LoginRequiredException("--login=USERNAME required.")
return func(instaloader, *args, **kwargs) return func(instaloader, *args, **kwargs)
docstring_text = ":raises LoginRequiredException: If called without being logged in.\n"
call.__doc__ = call.__doc__ + docstring_text if call.__doc__ is not None else docstring_text
return call return call
@@ -246,6 +244,7 @@ class Instaloader:
filename += '.json.xz' filename += '.json.xz'
else: else:
filename += '.json' filename += '.json'
os.makedirs(os.path.dirname(filename), exist_ok=True)
save_structure_to_file(structure, filename) save_structure_to_file(structure, filename)
if isinstance(structure, (Post, StoryItem)): if isinstance(structure, (Post, StoryItem)):
# log 'json ' message when saving Post or StoryItem # log 'json ' message when saving Post or StoryItem
@@ -256,7 +255,8 @@ class Instaloader:
return {'id': comment.id, return {'id': comment.id,
'created_at': int(comment.created_at_utc.replace(tzinfo=timezone.utc).timestamp()), 'created_at': int(comment.created_at_utc.replace(tzinfo=timezone.utc).timestamp()),
'text': comment.text, 'text': comment.text,
'owner': comment.owner._asdict()} 'owner': comment.owner._asdict(),
'likes_count': comment.likes_count}
def _postcomment_asdict(comment): def _postcomment_asdict(comment):
return {**_postcommentanswer_asdict(comment), return {**_postcommentanswer_asdict(comment),
@@ -273,11 +273,13 @@ class Instaloader:
for x, y in zip(comments_list[:-1], comments_list[1:]): for x, y in zip(comments_list[:-1], comments_list[1:]):
if x['id'] != y['id']: if x['id'] != y['id']:
unique_comments_list.append(y) unique_comments_list.append(y)
elif combine_answers: else:
combined_answers = unique_comments_list[-1].get('answers') or list() unique_comments_list[-1]['likes_count'] = y.get('likes_count')
if 'answers' in y: if combine_answers:
combined_answers.extend(y['answers']) combined_answers = unique_comments_list[-1].get('answers') or list()
unique_comments_list[-1]['answers'] = get_unique_comments(combined_answers) if 'answers' in y:
combined_answers.extend(y['answers'])
unique_comments_list[-1]['answers'] = get_unique_comments(combined_answers)
return unique_comments_list return unique_comments_list
filename += '_comments.json' filename += '_comments.json'
try: try:
@@ -374,6 +376,7 @@ class Instaloader:
os.path.getsize(filename) >= int(content_length))): os.path.getsize(filename) >= int(content_length))):
self.context.log(filename + ' already exists') self.context.log(filename + ' already exists')
return None return None
os.makedirs(os.path.dirname(filename), exist_ok=True)
self.context.write_raw(profile_pic_bytes if profile_pic_bytes else profile_pic_response, filename) self.context.write_raw(profile_pic_bytes if profile_pic_bytes else profile_pic_response, filename)
if date_object: if date_object:
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp())) os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
@@ -384,6 +387,7 @@ class Instaloader:
"""Saves internally stored :class:`requests.Session` object. """Saves internally stored :class:`requests.Session` object.
:param filename: Filename, or None to use default filename. :param filename: Filename, or None to use default filename.
:raises LoginRequiredException: If called without being logged in.
""" """
if filename is None: if filename is None:
assert self.context.username is not None assert self.context.username is not None
@@ -508,6 +512,7 @@ class Instaloader:
To use this, one needs to be logged in To use this, one needs to be logged in
:param userids: List of user IDs to be processed in terms of downloading their stories, or None. :param userids: List of user IDs to be processed in terms of downloading their stories, or None.
:raises LoginRequiredException: If called without being logged in.
""" """
if not userids: if not userids:
@@ -544,6 +549,7 @@ class Instaloader:
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern :param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
or None if profile name should be used instead or None if profile name should be used instead
: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.
""" """
if not userids: if not userids:
@@ -603,6 +609,7 @@ class Instaloader:
.. versionadded:: 4.1 .. versionadded:: 4.1
:param user: ID or Profile of the user whose highlights should get fetched. :param user: ID or Profile of the user whose highlights should get fetched.
:raises LoginRequiredException: If called without being logged in.
""" """
userid = user if isinstance(user, int) else user.userid userid = user if isinstance(user, int) else user.userid
@@ -632,6 +639,7 @@ class Instaloader:
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern :param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
or None if profile name and the highlights' titles should be used instead or None if profile name and the highlights' titles should be used instead
: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.
""" """
for user_highlight in self.get_highlights(user): for user_highlight in self.get_highlights(user):
name = user_highlight.owner_username name = user_highlight.owner_username
@@ -657,6 +665,7 @@ class Instaloader:
"""Get Posts of the user's feed. """Get Posts of the user's feed.
:return: Iterator over Posts of the user's feed. :return: Iterator over Posts of the user's feed.
:raises LoginRequiredException: If called without being logged in.
""" """
data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"] data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]
@@ -690,6 +699,7 @@ class Instaloader:
:param max_count: Maximum count of pictures to download :param max_count: Maximum count of pictures to download
: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 post_filter: function(post), which returns True if given picture should be downloaded :param post_filter: function(post), which returns True if given picture should be downloaded
:raises LoginRequiredException: If called without being logged in.
""" """
self.context.log("Retrieving pictures from your feed...") self.context.log("Retrieving pictures from your feed...")
count = 1 count = 1
@@ -715,6 +725,7 @@ class Instaloader:
:param max_count: Maximum count of pictures to download :param max_count: Maximum count of pictures to download
: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 post_filter: function(post), which returns True if given picture should be downloaded :param post_filter: function(post), which returns True if given picture should be downloaded
:raises LoginRequiredException: If called without being logged in.
""" """
self.context.log("Retrieving saved posts...") self.context.log("Retrieving saved posts...")
count = 1 count = 1
@@ -732,12 +743,17 @@ class Instaloader:
if fast_update and not downloaded: if fast_update and not downloaded:
break break
@_requires_login
def get_location_posts(self, location: str) -> Iterator[Post]: def get_location_posts(self, location: str) -> Iterator[Post]:
"""Get Posts which are listed by Instagram for a given Location. """Get Posts which are listed by Instagram for a given Location.
:return: Iterator over Posts of a location's posts :return: Iterator over Posts of a location's posts
:raises LoginRequiredException: If called without being logged in.
.. versionadded:: 4.2 .. versionadded:: 4.2
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram)
""" """
has_next_page = True has_next_page = True
end_cursor = None end_cursor = None
@@ -752,6 +768,7 @@ class Instaloader:
has_next_page = location_data['page_info']['has_next_page'] has_next_page = location_data['page_info']['has_next_page']
end_cursor = location_data['page_info']['end_cursor'] end_cursor = location_data['page_info']['end_cursor']
@_requires_login
def download_location(self, location: str, def download_location(self, location: str,
max_count: Optional[int] = None, max_count: Optional[int] = None,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
@@ -767,8 +784,12 @@ class Instaloader:
:param max_count: Maximum count of pictures to download :param max_count: Maximum count of pictures to download
:param post_filter: function(post), which returns True if given picture should be downloaded :param post_filter: function(post), which returns True if given picture should be 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
:raises LoginRequiredException: If called without being logged in.
.. versionadded:: 4.2 .. versionadded:: 4.2
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram)
""" """
self.context.log("Retrieving pictures for location {}...".format(location)) self.context.log("Retrieving pictures for location {}...".format(location))
count = 1 count = 1
@@ -790,6 +811,7 @@ class Instaloader:
"""Get Posts which are worthy of exploring suggested by Instagram. """Get Posts which are worthy of exploring suggested by Instagram.
:return: Iterator over Posts of the user's suggested posts. :return: Iterator over Posts of the user's suggested posts.
:raises LoginRequiredException: If called without being logged in.
""" """
data = self.context.get_json('explore/', {}) data = self.context.get_json('explore/', {})
yield from (Post(self.context, node) yield from (Post(self.context, node)
+1 -1
View File
@@ -212,7 +212,7 @@ class InstaloaderContext:
raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.") raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.")
if resp_json.get('checkpoint_url'): if resp_json.get('checkpoint_url'):
raise ConnectionException("Login: Checkpoint required. Point your browser to " raise ConnectionException("Login: Checkpoint required. Point your browser to "
"https://www.instagram.com{}, " "https://www.instagram.com{} - "
"follow the instructions, then retry.".format(resp_json.get('checkpoint_url'))) "follow the instructions, then retry.".format(resp_json.get('checkpoint_url')))
if resp_json['status'] != 'ok': if resp_json['status'] != 'ok':
if 'message' in resp_json: if 'message' in resp_json:
+12 -3
View File
@@ -17,11 +17,12 @@ PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
PostSidecarNode.display_url.__doc__ = "URL of image or video thumbnail." PostSidecarNode.display_url.__doc__ = "URL of image or video thumbnail."
PostSidecarNode.video_url.__doc__ = "URL of video or None." PostSidecarNode.video_url.__doc__ = "URL of video or None."
PostCommentAnswer = namedtuple('PostCommentAnswer', ['id', 'created_at_utc', 'text', 'owner']) PostCommentAnswer = namedtuple('PostCommentAnswer', ['id', 'created_at_utc', 'text', 'owner', 'likes_count'])
PostCommentAnswer.id.__doc__ = "ID number of comment." PostCommentAnswer.id.__doc__ = "ID number of comment."
PostCommentAnswer.created_at_utc.__doc__ = ":class:`~datetime.datetime` when comment was created (UTC)." PostCommentAnswer.created_at_utc.__doc__ = ":class:`~datetime.datetime` when comment was created (UTC)."
PostCommentAnswer.text.__doc__ = "Comment text." PostCommentAnswer.text.__doc__ = "Comment text."
PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment." PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment."
PostCommentAnswer.likes_count.__doc__ = "Number of likes on comment."
PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore
for field in PostCommentAnswer._fields: for field in PostCommentAnswer._fields:
@@ -340,7 +341,8 @@ class Post:
return PostCommentAnswer(id=int(node['id']), return PostCommentAnswer(id=int(node['id']),
created_at_utc=datetime.utcfromtimestamp(node['created_at']), created_at_utc=datetime.utcfromtimestamp(node['created_at']),
text=node['text'], text=node['text'],
owner=Profile(self._context, node['owner'])) owner=Profile(self._context, node['owner']),
likes_count=node['edge_liked_by']['count'])
def _postcommentanswers(node): def _postcommentanswers(node):
if 'edge_threaded_comments' not in node: if 'edge_threaded_comments' not in node:
@@ -408,10 +410,17 @@ class Post:
@property @property
def location(self) -> Optional[PostLocation]: def location(self) -> Optional[PostLocation]:
"""If the Post has a location, returns PostLocation namedtuple with fields 'id', 'lat' and 'lng' and 'name'.""" """
If the Post has a location, returns PostLocation namedtuple with fields 'id', 'lat' and 'lng' and 'name'.
.. versionchanged:: 4.2.9
Require being logged in (as required by Instagram), return None if not logged-in.
"""
loc = self._field("location") loc = self._field("location")
if self._location or not loc: if self._location or not loc:
return self._location return self._location
if not self._context.is_logged_in:
return None
location_id = int(loc['id']) location_id = int(loc['id'])
if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')): if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')):
loc = self._context.get_json("explore/locations/{0}/".format(location_id), loc = self._context.get_json("explore/locations/{0}/".format(location_id),
+9 -9
View File
@@ -77,15 +77,6 @@ class TestInstaloaderAnonymously(unittest.TestCase):
if count == PAGING_MAX_COUNT: if count == PAGING_MAX_COUNT:
break break
def test_location_download(self):
self.L.download_location(LOCATION, NORMAL_MAX_COUNT)
def test_location_paging(self):
for count, post in enumerate(self.L.get_location_posts(LOCATION)):
print(post)
if count == PAGING_MAX_COUNT:
break
def test_get_id_by_username(self): def test_get_id_by_username(self):
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)
@@ -193,6 +184,15 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
if count == PAGING_MAX_COUNT: if count == PAGING_MAX_COUNT:
break break
def test_location_download(self):
self.L.download_location(LOCATION, NORMAL_MAX_COUNT)
def test_location_paging(self):
for count, post in enumerate(self.L.get_location_posts(LOCATION)):
print(post)
if count == PAGING_MAX_COUNT:
break
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()