diff --git a/README.rst b/README.rst index 038eefb..8541668 100644 --- a/README.rst +++ b/README.rst @@ -57,7 +57,8 @@ :: - instaloader [--comments] [--geotags] [--stories] [--highlights] [--tagged] + instaloader [--comments] [--geotags] + [--stories] [--highlights] [--tagged] [--igtv] [--login YOUR-USERNAME] [--fast-update] profile | "#hashtag" | :stories | :feed | :saved diff --git a/docs/basic-usage.rst b/docs/basic-usage.rst index 04f3fbc..5933522 100644 --- a/docs/basic-usage.rst +++ b/docs/basic-usage.rst @@ -77,10 +77,13 @@ Instaloader supports the following targets: instructs Instaloader to also **download the user's stories**, - :option:`--highlights` - to **download highlights of each profile that is downloaded**, and + to **download highlights of each profile that is downloaded**, - :option:`--tagged` - to **download posts where the user is tagged**. + to **download posts where the user is tagged**, and + + - :option:`--igtv` + to **download IGTV videos**. - ``"#hashtag"`` Posts with a certain **hashtag** (the quotes are usually necessary), diff --git a/docs/cli-options.rst b/docs/cli-options.rst index 09ed0bc..2eb4edb 100644 --- a/docs/cli-options.rst +++ b/docs/cli-options.rst @@ -129,6 +129,12 @@ What to Download of each Profile .. versionadded:: 4.1 +.. option:: --igtv + + Also download IGTV videos. + + .. versionadded:: 4.3 + .. option:: --stories-only .. deprecated:: 4.1 diff --git a/docs/index.rst b/docs/index.rst index 3b06be1..c8aa419 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,7 +45,8 @@ See :ref:`install` for more options on how to install Instaloader. :: - instaloader [--comments] [--geotags] [--stories] [--highlights] [--tagged] + instaloader [--comments] [--geotags] + [--stories] [--highlights] [--tagged] [--igtv] [--login YOUR-USERNAME] [--fast-update] profile | "#hashtag" | %location_id | :stories | :feed | :saved diff --git a/instaloader/__main__.py b/instaloader/__main__.py index c01c44f..6297c99 100644 --- a/instaloader/__main__.py +++ b/instaloader/__main__.py @@ -19,7 +19,8 @@ def usage_string(): argv0 = os.path.basename(sys.argv[0]) argv0 = "instaloader" if argv0 == "__main__.py" else argv0 return """ -{0} [--comments] [--geotags] [--stories] [--highlights] [--tagged] +{0} [--comments] [--geotags] +{2:{1}} [--stories] [--highlights] [--tagged] [--igtv] {2:{1}} [--login YOUR-USERNAME] [--fast-update] {2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved {0} --help""".format(argv0, len(argv0), '') @@ -61,7 +62,10 @@ def _main(instaloader: Instaloader, targetlist: List[str], username: Optional[str] = None, password: Optional[str] = None, sessionfile: Optional[str] = None, download_profile_pic: bool = True, download_posts=True, - download_stories: bool = False, download_highlights: bool = False, download_tagged: bool = False, + download_stories: bool = False, + download_highlights: bool = False, + download_tagged: bool = False, + download_igtv: bool = False, fast_update: bool = False, max_count: Optional[int] = None, post_filter_str: Optional[str] = None, storyitem_filter_str: Optional[str] = None) -> None: @@ -158,7 +162,8 @@ def _main(instaloader: Instaloader, targetlist: List[str], try: profile = instaloader.check_profile_id(target) if instaloader.context.is_logged_in and profile.has_blocked_viewer: - if download_profile_pic or ((download_posts or download_tagged) and not profile.is_private): + if download_profile_pic or ((download_posts or download_tagged or download_igtv) + and not profile.is_private): raise ProfileNotExistsException("{} blocked you; But we download her anonymously." .format(target)) else: @@ -169,7 +174,7 @@ def _main(instaloader: Instaloader, targetlist: List[str], # 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): + download_tagged or download_igtv): instaloader.context.log(err) instaloader.context.log("Trying again anonymously, helps in case you are just blocked.") with instaloader.anonymous_copy() as anonymous_loader: @@ -185,14 +190,15 @@ def _main(instaloader: Instaloader, targetlist: List[str], if profiles and download_profile_pic and not instaloader.context.is_logged_in: instaloader.context.error("Warning: Use --login to download HD version of profile pictures.") instaloader.download_profiles(profiles, - download_profile_pic, download_posts, download_tagged, download_highlights, - download_stories, fast_update, post_filter, storyitem_filter) + download_profile_pic, download_posts, download_tagged, download_igtv, + download_highlights, download_stories, + fast_update, post_filter, storyitem_filter) 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_profile_pic, download_posts, download_tagged, download_igtv, fast_update=fast_update, post_filter=post_filter) except KeyboardInterrupt: print("\nInterrupted by user.", file=sys.stderr) @@ -287,6 +293,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('--igtv', action='store_true', + help='Also download IGTV videos.') g_cond = parser.add_argument_group("Which Posts to Download") @@ -411,6 +419,7 @@ def main(): download_stories=download_stories, download_highlights=args.highlights, download_tagged=args.tagged, + download_igtv=args.igtv, fast_update=args.fast_update, max_count=int(args.count) if args.count is not None else None, post_filter_str=args.post_filter, diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index 066e99c..9359552 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -917,6 +917,23 @@ class Instaloader: if fast_update and not downloaded: break + def download_igtv(self, profile: Profile, fast_update: bool = False, + post_filter: Optional[Callable[[Post], bool]] = None) -> None: + """Download IGTV videos of a profile. + + .. versionadded:: 4.3""" + self.context.log("Retrieving IGTV videos for profile {}.".format(profile.username)) + for number, post in enumerate(profile.get_igtv_posts()): + self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, profile.igtvcount, w=len(str(profile.igtvcount))), + end="", flush=True) + if post_filter is not None and not post_filter(post): + self.context.log('<{} skipped>'.format(post)) + continue + with self.context.error_catcher('Download IGTV {}'.format(post.shortcode)): + downloaded = self.download_post(post, target=profile.username) + if fast_update and not downloaded: + break + def _get_id_filename(self, profile_name: str) -> str: if ((format_string_contains_key(self.dirname_pattern, 'profile') or format_string_contains_key(self.dirname_pattern, 'target'))): @@ -989,7 +1006,10 @@ class Instaloader: def download_profiles(self, profiles: Set[Profile], profile_pic: bool = True, posts: bool = True, - tagged: bool = False, highlights: bool = False, stories: bool = False, + tagged: bool = False, + igtv: bool = False, + highlights: bool = False, + stories: bool = False, fast_update: bool = False, post_filter: Optional[Callable[[Post], bool]] = None, storyitem_filter: Optional[Callable[[Post], bool]] = None, @@ -1000,6 +1020,7 @@ class Instaloader: :param profile_pic: not :option:`--no-profile-pic`. :param posts: not :option:`--no-posts`. :param tagged: :option:`--tagged`. + :param igtv: :option:`--igtv`. :param highlights: :option:`--highlights`. :param stories: :option:`--stories`. :param fast_update: :option:`--fast-update`. @@ -1009,7 +1030,11 @@ class Instaloader: Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or catched and printed with :meth:`InstaloaderContext.error_catcher`. - .. versionadded:: 4.1""" + .. versionadded:: 4.1 + + .. versionchanged:: 4.3 + Add `igtv` parameter. + """ @contextmanager def _error_raiser(_str): @@ -1035,7 +1060,7 @@ class Instaloader: self.save_metadata_json(json_filename, profile) # Catch some errors - if profile.is_private and (tagged or highlights or posts): + if profile.is_private and (tagged or igtv or highlights or posts): if not self.context.is_logged_in: raise LoginRequiredException("--login=USERNAME required.") if not profile.followed_by_viewer and self.context.username != profile.username: @@ -1046,6 +1071,11 @@ class Instaloader: with self.context.error_catcher('Download tagged of {}'.format(profile_name)): self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter) + # Download IGTV, if requested + if igtv: + with self.context.error_catcher('Download IGTV of {}'.format(profile_name)): + self.download_igtv(profile, fast_update=fast_update, post_filter=post_filter) + # Download highlights, if requested if highlights: with self.context.error_catcher('Download highlights of {}'.format(profile_name)): diff --git a/instaloader/structures.py b/instaloader/structures.py index eaf933b..8160751 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -518,10 +518,11 @@ class Profile: def _asdict(self): json_node = self._node.copy() - # remove posts + # remove posts to avoid "Circular reference detected" exception json_node.pop('edge_media_collections', None) json_node.pop('edge_owner_to_timeline_media', None) json_node.pop('edge_saved_media', None) + json_node.pop('edge_felix_video_timeline', None) if self._iphone_struct_: json_node['iphone_struct'] = self._iphone_struct_ return json_node @@ -598,6 +599,10 @@ class Profile: def mediacount(self) -> int: return self._metadata('edge_owner_to_timeline_media', 'count') + @property + def igtvcount(self) -> int: + return self._metadata('edge_felix_video_timeline', 'count') + @property def followers(self) -> int: return self._metadata('edge_followed_by', 'count') @@ -741,6 +746,19 @@ class Profile: lambda d: d['data']['user']['edge_user_to_photos_of_you'], self._rhx_gis)) + def get_igtv_posts(self) -> Iterator[Post]: + """Retrieve all IGTV posts. + + .. versionadded:: 4.3""" + self._obtain_metadata() + yield from (Post(self._context, node, self) for node in + self._context.graphql_node_list('bc78b344a68ed16dd5d7f264681c4c76', + {'id': self.userid}, + 'https://www.instagram.com/{0}/channel/'.format(self.username), + lambda d: d['data']['user']['edge_felix_video_timeline'], + self._rhx_gis, + self._metadata('edge_felix_video_timeline'))) + def get_followers(self) -> Iterator['Profile']: """ Retrieve list of followers of given profile. diff --git a/test/instaloader_unittests.py b/test/instaloader_unittests.py index 91115da..f4dd0cd 100644 --- a/test/instaloader_unittests.py +++ b/test/instaloader_unittests.py @@ -12,6 +12,7 @@ import instaloader PROFILE_WITH_HIGHLIGHTS = 325732271 PUBLIC_PROFILE = "selenagomez" PUBLIC_PROFILE_ID = 460563723 +PUBLIC_PROFILE_WITH_IGTV = "natgeo" HASHTAG = "kitten" LOCATION = "362629379" OWN_USERNAME = "aandergr" @@ -104,6 +105,11 @@ class TestInstaloaderAnonymously(unittest.TestCase): PAGING_MAX_COUNT): print(post) + def test_public_profile_igtv(self): + for post in islice(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE_WITH_IGTV).get_igtv_posts(), + PAGING_MAX_COUNT): + print(post) + class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):