diff --git a/README.rst b/README.rst index 2e28575..dd81329 100644 --- a/README.rst +++ b/README.rst @@ -53,7 +53,7 @@ :: instaloader [--comments] [--geotags] - [--stories] [--highlights] [--tagged] [--igtv] + [--stories] [--highlights] [--tagged] [--reels] [--igtv] [--login YOUR-USERNAME] [--fast-update] profile | "#hashtag" | :stories | :feed | :saved diff --git a/docs/basic-usage.rst b/docs/basic-usage.rst index 7e349bb..d32527f 100644 --- a/docs/basic-usage.rst +++ b/docs/basic-usage.rst @@ -92,6 +92,9 @@ Instaloader supports the following targets: - :option:`--tagged` to **download posts where the user is tagged**, and + - :option:`--reels` + to **download Reels videos**. + - :option:`--igtv` to **download IGTV videos**. diff --git a/docs/cli-options.rst b/docs/cli-options.rst index 6ed95e6..7a8bf71 100644 --- a/docs/cli-options.rst +++ b/docs/cli-options.rst @@ -131,6 +131,12 @@ What to Download of each Profile .. versionadded:: 4.1 +.. option:: --reels + + Also download Reels videos. + + .. versionadded:: 4.14 + .. option:: --igtv Also download IGTV videos. diff --git a/docs/index.rst b/docs/index.rst index aa935bf..8668f16 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,7 +48,7 @@ See :ref:`install` for more options on how to install Instaloader. :: instaloader [--comments] [--geotags] - [--stories] [--highlights] [--tagged] [--igtv] + [--stories] [--highlights] [--tagged] [--reels] [--igtv] [--login YOUR-USERNAME] [--fast-update] profile | "#hashtag" | %location_id | :stories | :feed | :saved diff --git a/instaloader/__main__.py b/instaloader/__main__.py index 6e18e26..15e5bc1 100644 --- a/instaloader/__main__.py +++ b/instaloader/__main__.py @@ -37,7 +37,7 @@ def usage_string(): argv0 = "instaloader" if argv0 == "__main__.py" else argv0 return """ {0} [--comments] [--geotags] -{2:{1}} [--stories] [--highlights] [--tagged] [--igtv] +{2:{1}} [--stories] [--highlights] [--tagged] [--reels] [--igtv] {2:{1}} [--login YOUR-USERNAME] [--fast-update] {2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved {0} --help""".format(argv0, len(argv0), '') @@ -139,6 +139,7 @@ def _main(instaloader: Instaloader, targetlist: List[str], download_stories: bool = False, download_highlights: bool = False, download_tagged: bool = False, + download_reels: bool = False, download_igtv: bool = False, fast_update: bool = False, latest_stamps_file: Optional[str] = None, @@ -258,11 +259,13 @@ def _main(instaloader: Instaloader, targetlist: List[str], instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count, post_filter=post_filter) elif re.match(r"^[A-Za-z0-9._]+$", target): + download_profile_content = download_posts or download_tagged or download_reels or download_igtv try: profile = instaloader.check_profile_id(target, latest_stamps) if instaloader.context.is_logged_in and profile.has_blocked_viewer: - if download_profile_pic or ((download_posts or download_tagged or download_igtv) - and not profile.is_private): + if download_profile_pic or ( + download_profile_content and not profile.is_private + ): raise ProfileNotExistsException("{} blocked you; But we download her anonymously." .format(target)) else: @@ -272,8 +275,7 @@ def _main(instaloader: Instaloader, targetlist: List[str], except ProfileNotExistsException as err: # Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException, # check_profile_id() also does, since access to blocked profile may be responded with 404. - if instaloader.context.is_logged_in and (download_profile_pic or download_posts or - download_tagged or download_igtv): + if instaloader.context.is_logged_in and (download_profile_pic or download_profile_content): instaloader.context.log(err) instaloader.context.log("Trying again anonymously, helps in case you are just blocked.") with instaloader.anonymous_copy() as anonymous_loader: @@ -297,18 +299,35 @@ def _main(instaloader: Instaloader, targetlist: List[str], if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \ not instaloader.context.is_logged_in: instaloader.context.log("Hint: Login to download higher-quality versions of pictures.") - instaloader.download_profiles(profiles, - download_profile_pic, download_posts, download_tagged, download_igtv, - download_highlights, download_stories, - fast_update, post_filter, storyitem_filter, latest_stamps=latest_stamps) + instaloader.download_profiles( + profiles, + download_profile_pic, + download_posts, + download_tagged, + download_igtv, + download_highlights, + download_stories, + fast_update, + post_filter, + storyitem_filter, + latest_stamps=latest_stamps, + reels=download_reels, + ) if anonymous_retry_profiles: instaloader.context.log("Downloading anonymously: {}" .format(' '.join([p.username for p in anonymous_retry_profiles]))) with instaloader.anonymous_copy() as anonymous_loader: - anonymous_loader.download_profiles(anonymous_retry_profiles, - download_profile_pic, download_posts, download_tagged, download_igtv, - fast_update=fast_update, post_filter=post_filter, - latest_stamps=latest_stamps) + anonymous_loader.download_profiles( + anonymous_retry_profiles, + download_profile_pic, + download_posts, + download_tagged, + download_igtv, + fast_update=fast_update, + post_filter=post_filter, + latest_stamps=latest_stamps, + reels=download_reels + ) except KeyboardInterrupt: print("\nInterrupted by user.", file=sys.stderr) exit_code = ExitCode.USER_ABORTED @@ -409,6 +428,8 @@ def main(): help='Also download highlights of each profile that is downloaded. Requires login.') g_prof.add_argument('--tagged', action='store_true', help='Also download posts where each profile is tagged.') + g_prof.add_argument('--reels', action='store_true', + help='Also download Reels videos.') g_prof.add_argument('--igtv', action='store_true', help='Also download IGTV videos.') @@ -570,6 +591,7 @@ def main(): download_stories=download_stories, download_highlights=args.highlights, download_tagged=args.tagged, + download_reels=args.reels, download_igtv=args.igtv, fast_update=args.fast_update, latest_stamps_file=args.latest_stamps, diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index 30ea42b..e60b1c5 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -1281,6 +1281,31 @@ class Instaloader: if latest_stamps is not None and tagged_posts.first_item is not None: latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local) + def download_reels(self, profile: Profile, fast_update: bool = False, + post_filter: Optional[Callable[[Post], bool]] = None, + latest_stamps: Optional[LatestStamps] = None) -> None: + """Download reels videos of a profile. + + .. versionadded:: 4.14.0 + + """ + self.context.log("Retrieving reels videos for profile {}.".format(profile.username)) + posts_takewhile: Optional[Callable[[Post], bool]] = None + if latest_stamps is not None: + last_scraped = latest_stamps.get_last_reels_timestamp(profile.username) + posts_takewhile = lambda p: p.date_local > last_scraped + reels = profile.get_reels() + self.posts_download_loop( + reels, + profile.username, + fast_update, + post_filter, + owner_profile=profile, + takewhile=posts_takewhile, + ) + if latest_stamps is not None and reels.first_item is not None: + latest_stamps.set_last_reels_timestamp(profile.username, reels.first_item.date_local) + def download_igtv(self, profile: Profile, fast_update: bool = False, post_filter: Optional[Callable[[Post], bool]] = None, latest_stamps: Optional[LatestStamps] = None) -> None: @@ -1411,7 +1436,8 @@ class Instaloader: storyitem_filter: Optional[Callable[[Post], bool]] = None, raise_errors: bool = False, latest_stamps: Optional[LatestStamps] = None, - max_count: Optional[int] = None): + max_count: Optional[int] = None, + reels: bool = False): """High-level method to download set of profiles. :param profiles: Set of profiles to download. @@ -1429,6 +1455,7 @@ class Instaloader: catched and printed with :meth:`InstaloaderContext.error_catcher`. :param latest_stamps: :option:`--latest-stamps`. :param max_count: Maximum count of posts to download. + :param reels: :option:`--reels`. .. versionadded:: 4.1 @@ -1440,6 +1467,9 @@ class Instaloader: .. versionchanged:: 4.13 Add `max_count` parameter. + + .. versionchanged:: 4.14 + Add `reels` parameter. """ @contextmanager @@ -1483,6 +1513,12 @@ class Instaloader: self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter, latest_stamps=latest_stamps) + # Download reels, if requested + if reels: + with self.context.error_catcher('Download reels of {}'.format(profile_name)): + self.download_reels(profile, fast_update=fast_update, post_filter=post_filter, + latest_stamps=latest_stamps) + # Download IGTV, if requested if igtv: with self.context.error_catcher('Download IGTV of {}'.format(profile_name)): diff --git a/instaloader/lateststamps.py b/instaloader/lateststamps.py index 02959ba..dba9e7c 100644 --- a/instaloader/lateststamps.py +++ b/instaloader/lateststamps.py @@ -18,6 +18,7 @@ class LatestStamps: POST_TIMESTAMP = 'post-timestamp' TAGGED_TIMESTAMP = 'tagged-timestamp' IGTV_TIMESTAMP = 'igtv-timestamp' + REELS_TIMESTAMP = 'reels-timestamp' STORY_TIMESTAMP = 'story-timestamp' ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' @@ -87,6 +88,14 @@ class LatestStamps: """Sets timestamp of last download of a profile's tagged posts.""" self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp) + def get_last_reels_timestamp(self, profile_name: str) -> datetime: + """Returns timestamp of last download of a profile's reels posts.""" + return self._get_timestamp(profile_name, self.REELS_TIMESTAMP) + + def set_last_reels_timestamp(self, profile_name: str, timestamp: datetime): + """Sets timestamp of last download of a profile's reels posts.""" + self._set_timestamp(profile_name, self.REELS_TIMESTAMP, timestamp) + def get_last_igtv_timestamp(self, profile_name: str) -> datetime: """Returns timestamp of last download of a profile's igtv posts.""" return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP) diff --git a/instaloader/structures.py b/instaloader/structures.py index 1dd43d9..2006eb0 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -1232,6 +1232,30 @@ class Profile: is_first=Profile._make_is_newest_checker() ) + def get_reels(self) -> NodeIterator[Post]: + """Retrieve all reels from a profile. + + :rtype: NodeIterator[Post] + + .. versionadded:: 4.14.0 + + """ + self._obtain_metadata() + return NodeIterator( + context = self._context, + edge_extractor = lambda d: d['data']['xdt_api__v1__clips__user__connection_v2'], + # Reels post info is incomplete relative to regular posts so we create a Post from the shortcode + # and fetch the additional metadata with an additional API request per Reel + node_wrapper = lambda n: Post.from_shortcode(context=self._context, shortcode=n["media"]["code"]), + query_variables = {'data': { + 'page_size': 12, 'include_feed_video': True, "target_user_id": str(self.userid)}}, + query_referer = 'https://www.instagram.com/{0}/'.format(self.username), + is_first = Profile._make_is_newest_checker(), + # fb_api_req_friendly_name=PolarisProfileReelsTabContentQuery_connection + doc_id = '7845543455542541', + query_hash = None, + ) + def get_igtv_posts(self) -> NodeIterator[Post]: """Retrieve all IGTV posts.