From 91d5d5f867a6d82de7460482f81c47f572fb9ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Koch-Kramer?= Date: Sat, 11 Aug 2018 07:25:33 +0200 Subject: [PATCH] Add class and functions for downloading highlights Requested in #162. --- docs/as-module.rst | 5 ++++ instaloader/__init__.py | 2 +- instaloader/instaloader.py | 54 ++++++++++++++++++++++++++++++++-- instaloader/structures.py | 59 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/docs/as-module.rst b/docs/as-module.rst index f912dcc..15a3faf 100644 --- a/docs/as-module.rst +++ b/docs/as-module.rst @@ -135,6 +135,11 @@ User Stories .. autoclass:: StoryItem :no-show-inheritance: +Highlights +"""""""""" + +.. autoclass:: Highlight + Profiles """""""" diff --git a/instaloader/__init__.py b/instaloader/__init__.py index df41aeb..d39eae5 100644 --- a/instaloader/__init__.py +++ b/instaloader/__init__.py @@ -15,5 +15,5 @@ else: from .exceptions import * from .instaloader import Instaloader from .instaloadercontext import InstaloaderContext -from .structures import (Post, PostSidecarNode, PostComment, PostLocation, Profile, Story, StoryItem, +from .structures import (Highlight, Post, PostSidecarNode, PostComment, PostLocation, Profile, Story, StoryItem, load_structure_from_file, save_structure_to_file) diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index 551c447..9c4c614 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -15,7 +15,7 @@ from typing import Any, Callable, Iterator, List, Optional, Set, Union from .exceptions import * from .instaloadercontext import InstaloaderContext -from .structures import JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file +from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file def get_default_session_filename(username: str) -> str: @@ -190,7 +190,7 @@ class Instaloader: def update_comments(self, filename: str, post: Post) -> None: def _postcomment_asdict(comment): - return {'id': comment.id, + return {'id': comment.unique_id, 'created_at': int(comment.created_at_utc.replace(tzinfo=timezone.utc).timestamp()), 'text': comment.text, 'owner': comment.owner._asdict()} @@ -488,6 +488,56 @@ class Instaloader: self.context.log() return downloaded + @_requires_login + def get_highlights(self, userid: int) -> Iterator[Highlight]: + """Get all highlights from a user. + To use this, one needs to be logged in + + :param userid: ID of the profile whose highlights should get fetched. + """ + + data = self.context.graphql_query("7c16654f22c819fb63d1183034a5162f", + {"user_id": userid, "include_chaining": False, "include_reel": False, + "include_suggested_users": False, "include_logged_out_extras": False, + "include_highlight_reels": True})["data"]["user"]['edge_highlight_reels'] + if data is None: + raise BadResponseException('Bad highlights reel JSON.') + yield from (Highlight(self.context, edge['node']) for edge in data['edges']) + + @_requires_login + def download_highlights(self, + userid: int, + fast_update: bool = False, + filename_target: Optional[str] = None, + storyitem_filter: Optional[Callable[[StoryItem], bool]] = None) -> None: + """ + Download available highlights from a user whose ID is given. + To use this, one needs to be logged in + + :param userid: ID of the profile whose highlights should get downloaded. + :param fast_update: If true, abort when first already-downloaded picture is encountered + :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 + :param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded + """ + for user_highlight in self.get_highlights(userid): + name = user_highlight.owner_username + self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name)) + totalcount = user_highlight.itemcount + count = 1 + for item in user_highlight.get_items(): + if storyitem_filter is not None and not storyitem_filter(item): + self.context.log("<{} skipped>".format(item), flush=True) + continue + self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True) + count += 1 + with self.context.error_catcher('Download highlights \"{}\" from user {}'.format(user_highlight.title, name)): + downloaded = self.download_storyitem(item, filename_target + if filename_target + else '{}/{}'.format(name, user_highlight.title)) + if fast_update and not downloaded: + break + @_requires_login def get_feed_posts(self) -> Iterator[Post]: """Get Posts of the user's feed. diff --git a/instaloader/structures.py b/instaloader/structures.py index 6336465..eee5472 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -782,7 +782,7 @@ class Story: # story is a Story object for item in story.get_items(): # item is a StoryItem object - L.download_storyitem(item, ':stores') + L.download_storyitem(item, ':stories') This class implements == and is hashable. @@ -805,7 +805,7 @@ class Story: return NotImplemented def __hash__(self) -> int: - return hash(self._unique_id) + return hash(self.unique_id) @property def unique_id(self) -> str: @@ -868,6 +868,61 @@ class Story: yield from (StoryItem(self._context, item, self.owner_profile) for item in reversed(self._node['items'])) +class Highlight(Story): + + def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): + super().__init__(context, node) + self._items = None + + def __repr__(self): + return ''.format(self.owner_username, self.title) + + @property + def unique_id(self) -> int: + """A unique ID identifying this set of highlights.""" + return int(self._node['id']) + + @property + def owner_profile(self) -> Profile: + """:class:`Profile` instance of the highlights' owner.""" + if not self._owner_profile: + self._owner_profile = Profile(self._context, self._node['owner']) + return self._owner_profile + + @property + def title(self) -> str: + """The title of these highlights.""" + return self._node['title'] + + @property + def cover_url(self) -> str: + """URL of the highlights' cover.""" + return self._node['cover_media']['thumbnail_src'] + + @property + def cover_cropped_url(self) -> str: + """URL of the cropped version of the cover.""" + return self._node['cover_media_cropped_thumbnail']['url'] + + def _fetch_items(self): + if not self._items: + self._items = self._context.graphql_query("45246d3fe16ccc6577e0bd297a5db1ab", + {"reel_ids": [], "tag_names": [], "location_ids": [], + "highlight_reel_ids": [str(self.unique_id)], + "precomposed_overlay": False})['data']['reels_media'][0]['items'] + + @property + def itemcount(self) -> int: + """Count of items associated with the :class:`Highlight` instance.""" + self._fetch_items() + return len(self._items) + + def get_items(self) -> Iterator[StoryItem]: + """Retrieve all associated highlight items.""" + self._fetch_items() + yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items) + + JsonExportable = Union[Post, Profile, StoryItem]