From 2c50972e08a8a0619701a1283e9e97d6c872adaa Mon Sep 17 00:00:00 2001 From: Alexander Graf Date: Fri, 20 Apr 2018 16:36:37 +0200 Subject: [PATCH] Update as-module.rst and docstrings --- docs/as-module.rst | 69 ++++++++++++------------- docs/conf.py | 8 --- instaloader/__init__.py | 1 + instaloader/instaloader.py | 49 ++++++++++++++++-- instaloader/instaloadercontext.py | 4 ++ instaloader/structures.py | 83 ++++++++++++++++++++++++------- 6 files changed, 148 insertions(+), 66 deletions(-) diff --git a/docs/as-module.rst b/docs/as-module.rst index e64f6d7..30d99e6 100644 --- a/docs/as-module.rst +++ b/docs/as-module.rst @@ -10,6 +10,9 @@ Python Module :mod:`instaloader` .. highlight:: python +.. contents:: + :backlinks: none + Instaloader exposes its internally used methods as a Python module, making it a **powerful and easy-to-use Python API for Instagram**, allowing to further customize obtaining media and metadata. @@ -42,34 +45,8 @@ Besides :func:`Instaloader.get_hashtag_posts`, there is Also, :class:`Post` instances can be created with :func:`Post.from_shortcode` and :func:`Post.from_mediaid`. -Further, information about profiles can be easily obtained. For example, you may -print a list of all followees and followers of a profile with:: - - # Print followees - print(PROFILE + " follows these profiles:") - for f in L.get_followees(PROFILE): - print("\t%s\t%s" % (f['username'], f['full_name'])) - - # Print followers - print("Followers of " + PROFILE + ":") - for f in L.get_followers(PROFILE): - print("\t%s\t%s" % (f['username'], f['full_name'])) - -Then, you may download all pictures of all followees with:: - - for f in L.get_followees(PROFILE): - L.download_profile(f['username']) - -Each Instagram profile has its own unique ID which stays unmodified even if a -user changes his/her username. To get said ID, given the profile's name, you may -call:: - - L.get_id_by_username(PROFILE_NAME) - A reference of the many methods provided by the :mod:`instaloader` module is -provided in the remainder of this document. Feel free to direct any issue or -contribution to -`Instaloader on Github `__. +provided in the remainder of this document. ``Instaloader`` (Main Class) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -77,24 +54,34 @@ contribution to .. autoclass:: Instaloader :no-show-inheritance: -``Profile`` Class -^^^^^^^^^^^^^^^^^ +Instagram Structures +^^^^^^^^^^^^^^^^^^^^ -.. autoclass:: Profile - :no-show-inheritance: +.. autofunction:: load_structure_from_file -``Post`` Class -^^^^^^^^^^^^^^ +.. autofunction:: save_structure_to_file + +``Post`` +"""""""" + +.. autofunction:: mediaid_to_shortcode + +.. autofunction:: shortcode_to_mediaid .. autoclass:: Post :no-show-inheritance: -Miscellaneous Functions -^^^^^^^^^^^^^^^^^^^^^^^ +``StoryItem`` +""""""""""""" -.. autofunction:: shortcode_to_mediaid +.. autoclass:: StoryItem + :no-show-inheritance: -.. autofunction:: mediaid_to_shortcode +``Profile`` +""""""""""" + +.. autoclass:: Profile + :no-show-inheritance: Exceptions ^^^^^^^^^^ @@ -102,6 +89,8 @@ Exceptions .. autoexception:: InstaloaderException :no-show-inheritance: +.. autoexception:: QueryReturnedBadRequestException + .. autoexception:: QueryReturnedNotFoundException .. autoexception:: QueryReturnedForbiddenException @@ -123,3 +112,9 @@ Exceptions .. autoexception:: ConnectionException .. autoexception:: TooManyRequestsException + +``InstaloaderContext`` (Low-level functions) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: InstaloaderContext + :no-show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index bfcec45..629efbe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -369,13 +369,5 @@ current_release_date = subprocess.check_output(["git", "log", "-1", "--tags", "- html_context = {'current_release': current_release, 'current_release_date': current_release_date} -def skip(app, what, name, obj, skip, options): - # Ensure constructors are documented - if name == "__init__": - return False - return skip - - def setup(app): - app.connect('autodoc-skip-member', skip) app.add_stylesheet("style.css") diff --git a/instaloader/__init__.py b/instaloader/__init__.py index c9dd4f8..7d51a70 100644 --- a/instaloader/__init__.py +++ b/instaloader/__init__.py @@ -14,5 +14,6 @@ else: from .exceptions import * from .instaloader import Instaloader +from .instaloadercontext import InstaloaderContext from .structures import (Post, Profile, Story, StoryItem, load_structure_from_file, mediaid_to_shortcode, save_structure_to_file, shortcode_to_mediaid) diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index 6064203..a3c9de9 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -71,6 +71,36 @@ class _PostPathFormatter(_ArbitraryItemFormatter): class Instaloader: + """Instaloader Class. + + :: + + L = Instaloader() + + # Optionally, login or load session + L.login(USER, PASSWORD) # (login) + L.interactive_login(USER) # (ask password on terminal) + L.load_session_from_file(USER) # (load session created w/ + # `instaloader -l USERNAME`) + + :mod:`instaloader` provides the :class:`Post` structure, which represents a + picture, video or sidecar (set of multiple pictures/videos) posted in a user's + profile. :class:`Instaloader` provides methods to iterate over Posts from a + certain source:: + + for post in L.get_hashtag_posts('cat'): + # post is an instance of Post + L.download_post(post, target='#cat') + + Besides :func:`Instaloader.get_hashtag_posts`, there is + :func:`Instaloader.get_feed_posts`, :func:`Profile.get_posts` and + :func:`Profile.get_saved_posts`. + Also, :class:`Post` instances can be created with :func:`Post.from_shortcode` + and :func:`Post.from_mediaid`. + + Also, this class provides methods :meth:`Instaloader.download_profile`, + :meth:`Instaloader.download_hashtag` and many more to download targets. + """ def __init__(self, sleep: bool = True, quiet: bool = False, @@ -119,6 +149,7 @@ class Instaloader: new_loader.close() def close(self): + """Close associated session objects and repeat error log.""" self.context.close() def __enter__(self): @@ -176,7 +207,7 @@ class Instaloader: self.context.log('comments', end=' ', flush=True) def save_caption(self, filename: str, mtime: datetime, caption: str) -> None: - """Updates picture caption""" + """Updates picture caption / Post metadata info""" filename += '.txt' caption += '\n' pcaption = caption.replace('\n', ' ').strip() @@ -214,6 +245,7 @@ class Instaloader: os.utime(filename, (datetime.now().timestamp(), mtime.timestamp())) def save_location(self, filename: str, location_json: Dict[str, str], mtime: datetime) -> None: + """Save post location name and Google Maps link.""" filename += '_location.txt' location_string = (location_json["name"] + "\n" + "https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location_json["lat"], @@ -250,7 +282,10 @@ class Instaloader: @_requires_login def save_session_to_file(self, filename: Optional[str] = None) -> None: - """Saves internally stored :class:`requests.Session` object.""" + """Saves internally stored :class:`requests.Session` object. + + :param filename: Filename, or None to use default filename. + """ if filename is None: filename = get_default_session_filename(self.context.username) dirname = os.path.dirname(filename) @@ -437,7 +472,10 @@ class Instaloader: @_requires_login def get_feed_posts(self) -> Iterator[Post]: - """Get Posts of the user's feed.""" + """Get Posts of the user's feed. + + :return: Iterator over Posts of the user's feed. + """ data = self.context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"] @@ -514,7 +552,10 @@ class Instaloader: @_requires_login def get_explore_posts(self) -> Iterator[Post]: - """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. + """ data = self.context.get_json('explore/', {}) yield from (Post(self.context, node) for node in self.context.graphql_node_list("df0dcc250c2b18d9fd27c5581ef33c7c", diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index f08aa52..c01c58a 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -139,9 +139,11 @@ class InstaloaderContext: return session def save_session_to_file(self, sessionfile): + """Not meant to be used directly, use :meth:`Instaloader.save_session_to_file`.""" pickle.dump(requests.utils.dict_from_cookiejar(self._session.cookies), sessionfile) def load_session_from_file(self, username, sessionfile): + """Not meant to be used directly, use :meth:`Instaloader.load_session_to_file`.""" session = requests.Session() session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile)) session.headers.update(self._default_http_header()) @@ -150,10 +152,12 @@ class InstaloaderContext: self.username = username def test_login(self) -> Optional[str]: + """Not meant to be used directly, use :meth:`Instaloader.test_login`.""" data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {}) return data["data"]["user"]["username"] if data["data"]["user"] is not None else None def login(self, user, passwd): + """Not meant to be used directly, use :meth:`Instaloader.login`.""" session = requests.Session() session.cookies.update({'sessionid': '', 'mid': '', 'ig_pr': '1', 'ig_vw': '1920', 'csrftoken': '', diff --git a/instaloader/structures.py b/instaloader/structures.py index 5dd4f01..9fe8ed9 100644 --- a/instaloader/structures.py +++ b/instaloader/structures.py @@ -28,23 +28,26 @@ class Post: Structure containing information about an Instagram post. Created by methods :meth:`Profile.get_posts`, :meth:`Instaloader.get_hashtag_posts`, - :meth:`Instaloader.get_feed_posts` and :meth:`Profile.get_saved_posts`. + :meth:`Instaloader.get_feed_posts` and :meth:`Profile.get_saved_posts`, which return iterators of Posts:: + + L = Instaloader() + for post in L.get_hashtag_posts(HASHTAG): + L.download_post(post, target='#'+HASHTAG) + + Might also be created with:: + + post = Post.from_shortcode(L.context, SHORTCODE) + This class unifies access to the properties associated with a post. It implements == and is hashable. - The properties defined here are accessible by the filter expressions specified with the :option:`--only-if` - parameter and exported into JSON files with :option:`--metadata-json`. + :param context: :attr:`Instaloader.context` used for additional queries if neccessary.. + :param node: Node structure, as returned by Instagram. + :param owner_profile: The Profile of the owner, if already known at creation. """ def __init__(self, context: InstaloaderContext, node: Dict[str, Any], owner_profile: Optional['Profile'] = None): - """Create a Post instance from a node structure as returned by Instagram. - - :param context: :class:`InstaloaderContext` instance used for additional queries if neccessary. - :param node: Node structure, as returned by Instagram. - :param owner_profile: The Profile of the owner, if already known at creation. - """ - assert 'shortcode' in node or 'code' in node self._context = context @@ -296,7 +299,7 @@ class Post: self._rhx_gis) def get_location(self) -> Optional[Dict[str, str]]: - """If the Post has a location, returns a dictionary with fields 'lat' and 'lng'.""" + """If the Post has a location, returns a dictionary with fields 'lat' and 'lng' and 'name'.""" loc_dict = self._field("location") if loc_dict is not None: location_json = self._context.get_json("explore/locations/{0}/".format(loc_dict["id"]), @@ -311,7 +314,25 @@ class Profile: Provides methods for accessing profile properties, as well as :meth:`Profile.get_posts` and for own profile :meth:`Profile.get_saved_posts`. - This class implements == and is hashable. + Get instances with :meth:`Post.owner_profile`, :meth:`StoryItem.owner_profile`, :meth:`Profile.get_followees`, + :meth:`Profile.get_followers` or:: + + L = Instaloader() + profile = Profile.from_username(L.context, USERNAME) + + Provides :meth:`Profile.get_posts` and for own profile :meth:`Profile.get_saved_posts` to iterate over associated + :class:`Post` objects:: + + for post in profile.get_posts(): + L.download_post(post, target=profile.username) + + :meth:`Profile.get_followees` and :meth:`Profile.get_followers`:: + + print("{} follows these profiles:".format(profile.username)) + for followee in profile.get_followees(): + print(followee.username) + + Also, this class implements == and is hashable. """ def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): assert 'username' in node @@ -321,6 +342,12 @@ class Profile: @classmethod def from_username(cls, context: InstaloaderContext, username: str): + """Create a Profile instance from a given username, raise exception if it does not exist. + + :param context: :attr:`Instaloader.context` + :param username: Username + :raises: :class:`ProfileNotExistsException` + """ # pylint:disable=protected-access profile = cls(context, {'username': username.lower()}) profile._obtain_metadata() # to raise ProfileNotExistException now in case username is invalid @@ -328,6 +355,13 @@ class Profile: @classmethod def from_id(cls, context: InstaloaderContext, profile_id: int): + """If logged in, create a Profile instance from a given userid. If possible, use :meth:`Profile.from_username` + or constructor directly rather than this method, since does many requests. + + :param context: :attr:`Instaloader.context` + :param profile_id: userid + :raises: :class:`ProfileNotExistsException`, :class:`LoginRequiredException`, :class:`ProfileHasNoPicsException` + """ if not context.is_logged_in: raise LoginRequiredException("--login=USERNAME required to obtain profile metadata from its ID number.") data = context.graphql_query("472f257a40c653c64c666ce877d59d2b", @@ -377,10 +411,12 @@ class Profile: @property def userid(self) -> int: + """User ID""" return int(self._metadata('id')) @property def username(self) -> str: + """Profile Name""" return self._metadata('username').lower() def __repr__(self): @@ -478,8 +514,7 @@ class Profile: def get_followers(self) -> Iterator['Profile']: """ Retrieve list of followers of given profile. - To use this, one needs to be logged in and private profiles has to be followed, - otherwise this returns an empty list. + To use this, one needs to be logged in and private profiles has to be followed. """ if not self._context.is_logged_in: raise LoginRequiredException("--login required to get a profile's followers.") @@ -494,8 +529,7 @@ class Profile: def get_followees(self) -> Iterator['Profile']: """ Retrieve list of followees (followings) of given profile. - To use this, one needs to be logged in and private profiles has to be followed, - otherwise this returns an empty list. + To use this, one needs to be logged in and private profiles has to be followed. """ if not self._context.is_logged_in: raise LoginRequiredException("--login required to get a profile's followees.") @@ -712,7 +746,16 @@ class Story: JsonExportable = Union[Post, Profile, StoryItem] -def save_structure_to_file(structure: JsonExportable, filename: str): +def save_structure_to_file(structure: JsonExportable, filename: str) -> None: + """Saves a :class:`Post`, :class:`Profile` or :class:`StoryItem` to a '.json' or '.json.xz' file such that it can + later be loaded by :func:`load_structure_from_file`. + + If the specified filename ends in '.xz', the file will be LZMA compressed. Otherwise, a pretty-printed JSON file + will be created. + + :param structure: :class:`Post`, :class:`Profile` or :class:`StoryItem` + :param filename: Filename, ends in '.json' or '.json.xz' + """ json_structure = {'node': structure.get_node(), 'instaloader': {'version': __version__, 'node_type': structure.__class__.__name__}} compress = filename.endswith('.xz') @@ -725,6 +768,12 @@ def save_structure_to_file(structure: JsonExportable, filename: str): def load_structure_from_file(context: InstaloaderContext, filename: str) -> JsonExportable: + """Loads a :class:`Post`, :class:`Profile` or :class:`StoryItem` from a '.json' or '.json.xz' file that + has been saved by :func:`save_structure_from_file`. + + :param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary. + :param filename: Filename, ends in '.json' or '.json.xz' + """ compressed = filename.endswith('.xz') if compressed: fp = lzma.open(filename, 'rt')