Compare commits

..
14 Commits
Author SHA1 Message Date
André Koch-Kramer b9c7713d6a Release of version 4.0.2 2018-05-14 12:47:20 +02:00
Alexander Graf 60d35a95df docs: note that --stories-only @login is bad
Motivated by #112, #118.
2018-05-14 11:04:48 +02:00
Alexander Graf 920dcd0407 Have Profile.{followers,followees,external_url} 2018-05-14 10:46:04 +02:00
Alexander Graf d19dd2c9d4 Fix internal references in docs/as-module
Further, document all attributes that are referred to, and ensure that there
will be no broken references again by letting Travis call Sphinx with -W -n.
2018-05-14 10:46:04 +02:00
Alexander Graf be43ebb5c5 Unittest for Profile.has_viewable_story 2018-05-14 10:42:31 +02:00
Alexander Graf 52eb6d8e27 Fix minor issues regarding last commit
- Close session from InstaloaderContext.anonymous_copy().

- Have graphql_count_per_slidingwindow default value in one place rather than
  three.
2018-05-14 10:27:34 +02:00
André Koch-Kramer 3211d63ec1 Replace deleted field has_highlight_reel
Since the property has_highlight_reel is no longer available throught
the previously used graphql query, this information needs to be obtained
in another way. Therefore the properties has_highlight_reels,
has_public_story and has_viewable_story were added to the Profile class.
Since has_public_story can be obtained throught graphql queries without
being rate limited when invoked anonymously, the ability to use an
anonymous copy of the context was added to to the InstaloaderContext class.
Fixes #116
2018-05-13 19:43:49 +02:00
André Koch-Kramer e1228501d2 Set graphql_count_per_slidingwindow to 20
Instagram tightened their rate limits again.
Implemented hidden switch --graphql-rate-limit for easier adjustment of
graphql_count_per_slidingwindow.
Fixes #118
2018-05-13 19:09:40 +02:00
Alexander Graf b8797cf337 Release of version 4.0.1 2018-05-08 19:16:55 +02:00
Alexander Graf fa06e90e09 Fix anonymous retry logic 2018-05-08 19:12:42 +02:00
Alexander Graf d464396d6e Adjust rate control behavior
Increment graphql_count_per_slidingwindow to 25; Let context._sleep() choose
sleep times from upper-bounded exponential distribution with similar expected
value as before, letting wait times between queries seem more natural.
2018-05-08 18:54:28 +02:00
Alexander Graf 9961312d34 Fix anonymous retry on profile that blocked viewer 2018-05-08 18:15:17 +02:00
Alexander Graf 211b2269ff download_profile: Request story only if has_highlight_reel
This is a simple modification to save GraphQL queries, hence to more likely
stay within rate limits without having to wait between profiles.

Fixes #112.
2018-05-08 18:10:28 +02:00
Alexander Graf b3df5a5f07 Fix bugs in Instaloader.update_comments()
- Cast id and created_at from JSON to int to be compatible with comments JSON
  files from Instaloader 3.3,
- Do not fail if comment JSON files are empty,
- Close file descriptors of comment JSON files when reading,
- Do no create empty comment JSON files if generating content fails.

Closes #114.
2018-05-08 17:39:00 +02:00
9 changed files with 147 additions and 38 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ install:
- pip install -r docs/requirements.txt - pip install -r docs/requirements.txt
script: script:
- python3 -m pylint -r n -d bad-whitespace,missing-docstring,too-many-arguments,locally-disabled,line-too-long,too-many-public-methods,too-many-lines,too-many-instance-attributes,too-many-locals,too-many-branches,too-many-statements,inconsistent-return-statements,invalid-name,wildcard-import,unused-wildcard-import instaloader - python3 -m pylint -r n -d bad-whitespace,missing-docstring,too-many-arguments,locally-disabled,line-too-long,too-many-public-methods,too-many-lines,too-many-instance-attributes,too-many-locals,too-many-branches,too-many-statements,inconsistent-return-statements,invalid-name,wildcard-import,unused-wildcard-import instaloader
- make -C docs html - make -C docs html SPHINXOPTS="-W -n"
deploy: deploy:
- provider: pypi - provider: pypi
user: aandergr user: aandergr
+7 -6
View File
@@ -73,7 +73,7 @@ metadata of a Profile. :class:`Profile` instances can be created with:
profile = Profile.from_username(L.context, USERNAME) profile = Profile.from_username(L.context, USERNAME)
- :meth:`Profile_from_userid` - :meth:`Profile.from_id`
given its User ID. This allows to easily lookup a Profile's username given given its User ID. This allows to easily lookup a Profile's username given
its ID:: its ID::
@@ -116,15 +116,12 @@ Additionally, the following trivial structures are defined:
.. autoclass:: PostSidecarNode .. autoclass:: PostSidecarNode
:no-show-inheritance: :no-show-inheritance:
:no-members:
.. autoclass:: PostComment .. autoclass:: PostComment
:no-show-inheritance: :no-show-inheritance:
:no-members:
.. autoclass:: PostLocation .. autoclass:: PostLocation
:no-show-inheritance: :no-show-inheritance:
:no-members:
User Stories User Stories
"""""""""""" """"""""""""
@@ -154,9 +151,15 @@ to/from JSON files.
Exceptions Exceptions
^^^^^^^^^^ ^^^^^^^^^^
.. currentmodule:: instaloader.exceptions
.. autoexception:: InstaloaderException .. autoexception:: InstaloaderException
:no-show-inheritance: :no-show-inheritance:
.. autoexception:: ConnectionException
.. currentmodule:: instaloader
.. autoexception:: QueryReturnedBadRequestException .. autoexception:: QueryReturnedBadRequestException
.. autoexception:: QueryReturnedNotFoundException .. autoexception:: QueryReturnedNotFoundException
@@ -177,8 +180,6 @@ Exceptions
.. autoexception:: BadCredentialsException .. autoexception:: BadCredentialsException
.. autoexception:: ConnectionException
.. autoexception:: TooManyRequestsException .. autoexception:: TooManyRequestsException
``InstaloaderContext`` (Low-level functions) ``InstaloaderContext`` (Low-level functions)
+5
View File
@@ -88,6 +88,11 @@ automatically **finds it by its unique ID** and renames the folder likewise.
download stories. Requires :option:`--login`. Does not imply download stories. Requires :option:`--login`. Does not imply
:option:`--no-profile-pic`. :option:`--no-profile-pic`.
.. note::
If possible, use ``:stories`` target rather than :option:`--stories-only`
with all your followees. ``:stories`` uses fewer API requests.
.. option:: --post-filter filter, --only-if filter .. option:: --post-filter filter, --only-if filter
Expression that, if given, must evaluate to True for each post to be Expression that, if given, must evaluate to True for each post to be
+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.0' __version__ = '4.0.2'
try: try:
+3 -1
View File
@@ -146,7 +146,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
stories, stories_only, post_filter=post_filter, stories, stories_only, post_filter=post_filter,
storyitem_filter=storyitem_filter) storyitem_filter=storyitem_filter)
except ProfileNotExistsException as err: except ProfileNotExistsException as err:
if not instaloader.context.is_logged_in: if instaloader.context.is_logged_in:
instaloader.context.log(err) instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.") instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
@@ -271,6 +271,7 @@ def main():
g_how.add_argument('--user-agent', g_how.add_argument('--user-agent',
help='User Agent to use for HTTP requests. Defaults to \'{}\'.'.format(default_user_agent())) help='User Agent to use for HTTP requests. Defaults to \'{}\'.'.format(default_user_agent()))
g_how.add_argument('-S', '--no-sleep', action='store_true', help=SUPPRESS) g_how.add_argument('-S', '--no-sleep', action='store_true', help=SUPPRESS)
g_how.add_argument('--graphql-rate-limit', type=int, help=SUPPRESS)
g_how.add_argument('--max-connection-attempts', metavar='N', type=int, default=3, g_how.add_argument('--max-connection-attempts', metavar='N', type=int, default=3,
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a ' help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry ' 'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
@@ -316,6 +317,7 @@ def main():
compress_json=not args.no_compress_json, compress_json=not args.no_compress_json,
post_metadata_txt_pattern=post_metadata_txt_pattern, post_metadata_txt_pattern=post_metadata_txt_pattern,
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern, storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
graphql_rate_limit=args.graphql_rate_limit,
max_connection_attempts=args.max_connection_attempts) max_connection_attempts=args.max_connection_attempts)
_main(loader, _main(loader,
args.profile, args.profile,
+28 -18
View File
@@ -91,6 +91,10 @@ class Instaloader:
txt file. txt file.
:param storyitem_metadata_txt_pattern: :option:`--storyitem-metadata-txt`, default is empty (=none) :param storyitem_metadata_txt_pattern: :option:`--storyitem-metadata-txt`, default is empty (=none)
:param max_connection_attempts: :option:`--max-connection-attempts` :param max_connection_attempts: :option:`--max-connection-attempts`
.. attribute:: context
The associated :class:`InstaloaderContext` with low-level communication functions and logging.
""" """
def __init__(self, def __init__(self,
@@ -106,9 +110,10 @@ class Instaloader:
compress_json: bool = True, compress_json: bool = True,
post_metadata_txt_pattern: str = None, post_metadata_txt_pattern: str = None,
storyitem_metadata_txt_pattern: str = None, storyitem_metadata_txt_pattern: str = None,
graphql_rate_limit: Optional[int] = None,
max_connection_attempts: int = 3): max_connection_attempts: int = 3):
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts) self.context = InstaloaderContext(sleep, quiet, user_agent, graphql_rate_limit, max_connection_attempts)
# configuration parameters # configuration parameters
self.dirname_pattern = dirname_pattern or "{target}" self.dirname_pattern = dirname_pattern or "{target}"
@@ -131,7 +136,8 @@ class Instaloader:
self.filename_pattern, self.download_videos, self.download_video_thumbnails, self.filename_pattern, self.download_videos, self.download_video_thumbnails,
self.download_geotags, self.download_comments, self.save_metadata, self.download_geotags, self.download_comments, self.save_metadata,
self.compress_json, self.post_metadata_txt_pattern, self.compress_json, self.post_metadata_txt_pattern,
self.storyitem_metadata_txt_pattern, self.context.max_connection_attempts) self.storyitem_metadata_txt_pattern, self.context.graphql_count_per_slidingwindow,
self.context.max_connection_attempts)
new_loader.context.query_timestamps = self.context.query_timestamps new_loader.context.query_timestamps = self.context.query_timestamps
yield new_loader yield new_loader
self.context.error_log.extend(new_loader.context.error_log) self.context.error_log.extend(new_loader.context.error_log)
@@ -178,27 +184,28 @@ class Instaloader:
def update_comments(self, filename: str, post: Post) -> None: def update_comments(self, filename: str, post: Post) -> None:
def _postcomment_asdict(comment): def _postcomment_asdict(comment):
return {'id': comment.id, return {'id': comment.id,
'created_at': 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()}
filename += '_comments.json' filename += '_comments.json'
try: try:
comments = json.load(open(filename)) with open(filename) as fp:
except FileNotFoundError: comments = json.load(fp)
except (FileNotFoundError, json.decoder.JSONDecodeError):
comments = list() comments = list()
comments.extend(_postcomment_asdict(comment) for comment in post.get_comments()) comments.extend(_postcomment_asdict(comment) for comment in post.get_comments())
if comments: if comments:
comments_list = sorted(sorted(list(comments), key=lambda t: int(t['id'])),
key=lambda t: int(t['created_at']), reverse=True)
unique_comments_list = [comments_list[0]]
#for comment in comments_list:
# if unique_comments_list[-1]['id'] != comment['id']:
# unique_comments_list.append(comment)
#file.write(json.dumps(unique_comments_list, indent=4))
for x, y in zip(comments_list[:-1], comments_list[1:]):
if x['id'] != y['id']:
unique_comments_list.append(y)
with open(filename, 'w') as file: with open(filename, 'w') as file:
comments_list = sorted(sorted(list(comments), key=lambda t: t['id']),
key=lambda t: t['created_at'], reverse=True)
unique_comments_list = [comments_list[0]]
#for comment in comments_list:
# if unique_comments_list[-1]['id'] != comment['id']:
# unique_comments_list.append(comment)
#file.write(json.dumps(unique_comments_list, indent=4))
for x, y in zip(comments_list[:-1], comments_list[1:]):
if x['id'] != y['id']:
unique_comments_list.append(y)
file.write(json.dumps(unique_comments_list, indent=4)) file.write(json.dumps(unique_comments_list, indent=4))
self.context.log('comments', end=' ', flush=True) self.context.log('comments', end=' ', flush=True)
@@ -698,9 +705,12 @@ class Instaloader:
# Download stories, if requested # Download stories, if requested
if download_stories or download_stories_only: if download_stories or download_stories_only:
with self.context.error_catcher("Download stories of {}".format(profile_name)): if profile.has_viewable_story:
self.download_stories(userids=[profile.userid], filename_target=profile_name, fast_update=fast_update, with self.context.error_catcher("Download stories of {}".format(profile_name)):
storyitem_filter=storyitem_filter) self.download_stories(userids=[profile.userid], filename_target=profile_name,
fast_update=fast_update, storyitem_filter=storyitem_filter)
else:
self.context.log("{} does not have any stories.".format(profile_name))
if download_stories_only: if download_stories_only:
return return
+22 -7
View File
@@ -46,8 +46,8 @@ class InstaloaderContext:
class :class:`Instaloader`. class :class:`Instaloader`.
""" """
def __init__(self, sleep: bool = True, quiet: bool = False, def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = None,
user_agent: Optional[str] = None, max_connection_attempts: int = 3): graphql_count_per_slidingwindow: Optional[int] = None, max_connection_attempts: int = 3):
self.user_agent = user_agent if user_agent is not None else default_user_agent() self.user_agent = user_agent if user_agent is not None else default_user_agent()
self._session = self.get_anonymous_session() self._session = self.get_anonymous_session()
@@ -56,6 +56,7 @@ class InstaloaderContext:
self.quiet = quiet self.quiet = quiet
self.max_connection_attempts = max_connection_attempts self.max_connection_attempts = max_connection_attempts
self._graphql_page_length = 50 self._graphql_page_length = 50
self.graphql_count_per_slidingwindow = graphql_count_per_slidingwindow or 20
self._root_rhx_gis = None self._root_rhx_gis = None
# error log, filled with error() and printed at the end of Instaloader.main() # error log, filled with error() and printed at the end of Instaloader.main()
@@ -67,6 +68,17 @@ class InstaloaderContext:
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher # Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
self.raise_all_errors = False self.raise_all_errors = False
@contextmanager
def anonymous_copy(self):
session = self._session
username = self.username
self._session = self.get_anonymous_session()
self.username = None
yield self
self._session.close()
self.username = username
self._session = session
@property @property
def is_logged_in(self) -> bool: def is_logged_in(self) -> bool:
"""True, if this Instaloader instance is logged in.""" """True, if this Instaloader instance is logged in."""
@@ -144,7 +156,7 @@ class InstaloaderContext:
pickle.dump(requests.utils.dict_from_cookiejar(self._session.cookies), sessionfile) pickle.dump(requests.utils.dict_from_cookiejar(self._session.cookies), sessionfile)
def load_session_from_file(self, username, sessionfile): def load_session_from_file(self, username, sessionfile):
"""Not meant to be used directly, use :meth:`Instaloader.load_session_to_file`.""" """Not meant to be used directly, use :meth:`Instaloader.load_session_from_file`."""
session = requests.Session() session = requests.Session()
session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile)) session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile))
session.headers.update(self._default_http_header()) session.headers.update(self._default_http_header())
@@ -185,7 +197,7 @@ class InstaloaderContext:
def _sleep(self): def _sleep(self):
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com.""" """Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
if self.sleep: if self.sleep:
time.sleep(random.uniform(0.5, 3)) time.sleep(min(random.expovariate(0.6), 5.0))
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com', def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]: session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]:
@@ -206,11 +218,14 @@ class InstaloaderContext:
return sliding_window if untracked_queries else 0 return sliding_window if untracked_queries else 0
current_time = time.monotonic() current_time = time.monotonic()
self.query_timestamps = list(filter(lambda t: t > current_time - sliding_window, self.query_timestamps)) self.query_timestamps = list(filter(lambda t: t > current_time - sliding_window, self.query_timestamps))
if len(self.query_timestamps) < 20 and not untracked_queries: if len(self.query_timestamps) < self.graphql_count_per_slidingwindow and not untracked_queries:
return 0 return 0
return round(min(self.query_timestamps) + sliding_window - current_time) + 6 return round(min(self.query_timestamps) + sliding_window - current_time) + 6
is_graphql_query = 'query_hash' in params and 'graphql/query' in path is_graphql_query = 'query_hash' in params and 'graphql/query' in path
if is_graphql_query: # some queries are not rate limited if invoked anonymously:
query_not_limited = is_graphql_query and not self.is_logged_in \
and params['query_hash'] in ['9ca88e465c3f866a76f7adee3871bdd8']
if is_graphql_query and not query_not_limited:
waittime = graphql_query_waittime() waittime = graphql_query_waittime()
if waittime > 0: if waittime > 0:
self.log('\nToo many queries in the last time. Need to wait {} seconds.'.format(waittime)) self.log('\nToo many queries in the last time. Need to wait {} seconds.'.format(waittime))
@@ -226,7 +241,7 @@ class InstaloaderContext:
while resp.is_redirect: while resp.is_redirect:
redirect_url = resp.headers['location'] redirect_url = resp.headers['location']
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url)) self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
if redirect_url.index('https://{}/'.format(host)) == 0: if redirect_url.startswith('https://{}/'.format(host)):
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/', resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
params=params, allow_redirects=False) params=params, allow_redirects=False)
else: else:
+74 -4
View File
@@ -12,8 +12,24 @@ from .instaloadercontext import InstaloaderContext
PostSidecarNode = namedtuple('PostSidecarNode', ['is_video', 'display_url', 'video_url']) PostSidecarNode = namedtuple('PostSidecarNode', ['is_video', 'display_url', 'video_url'])
PostSidecarNode.__doc__ = "Item of a Sidecar Post."
PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
PostSidecarNode.display_url.__doc__ = "URL of image or video thumbnail."
PostSidecarNode.video_url.__doc__ = "URL of video or None."
PostComment = namedtuple('PostComment', ['id', 'created_at_utc', 'text', 'owner']) PostComment = namedtuple('PostComment', ['id', 'created_at_utc', 'text', 'owner'])
PostComment.id.__doc__ = "ID number of comment."
PostComment.created_at_utc.__doc__ = ":class:`~datetime.datetime` when comment was created (UTC)."
PostComment.text.__doc__ = "Comment text."
PostComment.owner.__doc__ = "Owner :class:`Profile` of the comment."
PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng']) PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng'])
PostLocation.id.__doc__ = "ID number of location."
PostLocation.name.__doc__ = "Location name."
PostLocation.slug.__doc__ = "URL friendly variant of location name."
PostLocation.has_public_page.__doc__ = "Whether location has a public page."
PostLocation.lat.__doc__ = "Latitude (:class:`float`)."
PostLocation.lng.__doc__ = "Longitude (:class:`float`)."
class Post: class Post:
@@ -358,6 +374,8 @@ class Profile:
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
assert 'username' in node assert 'username' in node
self._context = context self._context = context
self._has_highlight_reels = None
self._has_public_story = None
self._node = node self._node = node
self._rhx_gis = None self._rhx_gis = None
@@ -365,6 +383,8 @@ class Profile:
def from_username(cls, context: InstaloaderContext, username: str): def from_username(cls, context: InstaloaderContext, username: str):
"""Create a Profile instance from a given username, raise exception if it does not exist. """Create a Profile instance from a given username, raise exception if it does not exist.
See also :meth:`Instaloader.check_profile_id`.
:param context: :attr:`Instaloader.context` :param context: :attr:`Instaloader.context`
:param username: Username :param username: Username
:raises: :class:`ProfileNotExistsException` :raises: :class:`ProfileNotExistsException`
@@ -412,8 +432,8 @@ class Profile:
metadata = self._context.get_json('{}/'.format(self.username), params={}) metadata = self._context.get_json('{}/'.format(self.username), params={})
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user'] self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user']
self._rhx_gis = metadata['rhx_gis'] self._rhx_gis = metadata['rhx_gis']
except QueryReturnedNotFoundException: except (QueryReturnedNotFoundException, KeyError) as err:
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) from err
def _metadata(self, *keys) -> Any: def _metadata(self, *keys) -> Any:
try: try:
@@ -461,6 +481,18 @@ class Profile:
def mediacount(self) -> int: def mediacount(self) -> int:
return self._metadata('edge_owner_to_timeline_media', 'count') return self._metadata('edge_owner_to_timeline_media', 'count')
@property
def followers(self) -> int:
return self._metadata('edge_followed_by', 'count')
@property
def followees(self) -> int:
return self._metadata('edge_follow', 'count')
@property
def external_url(self) -> Optional[str]:
return self._metadata('external_url')
@property @property
def biography(self) -> str: def biography(self) -> str:
return self._metadata('biography') return self._metadata('biography')
@@ -481,6 +513,44 @@ class Profile:
def has_blocked_viewer(self) -> bool: def has_blocked_viewer(self) -> bool:
return self._metadata('has_blocked_viewer') return self._metadata('has_blocked_viewer')
@property
def has_highlight_reels(self) -> bool:
"""
This becomes `True` if the :class:`Profile` has any stories currently available,
even if not viewable by the viewer.
"""
if not self._has_highlight_reels:
with self._context.anonymous_copy() as anonymous_context:
data = anonymous_context.get_json(path='api/v1/users/{}/info/'.format(self.userid),
params={}, host='i.instagram.com')
self._has_highlight_reels = data['user']['has_highlight_reels']
return self._has_highlight_reels
@property
def has_public_story(self) -> bool:
if not self._has_public_story:
self._obtain_metadata()
# query not rate limited if invoked anonymously:
with self._context.anonymous_copy() as anonymous_context:
data = anonymous_context.graphql_query('9ca88e465c3f866a76f7adee3871bdd8',
{'user_id': self.userid, 'include_chaining': False,
'include_reel': False, 'include_suggested_users': False,
'include_logged_out_extras': True,
'include_highlight_reels': False},
'https://www.instagram.com/{}/'.format(self.username),
self._rhx_gis)
self._has_public_story = data['data']['user']['has_public_story']
return self._has_public_story
@property
def has_viewable_story(self) -> bool:
"""
Some stories are private. This property determines if the :class:`Profile`
has at least one story which can be viewed using the associated :class:`InstaloaderContext`,
i.e. the viewer has privileges to view it.
"""
return self.has_public_story or self.followed_by_viewer and self.has_highlight_reels
@property @property
def has_requested_viewer(self) -> bool: def has_requested_viewer(self) -> bool:
return self._metadata('has_requested_viewer') return self._metadata('has_requested_viewer')
@@ -716,7 +786,7 @@ class Story:
def unique_id(self) -> str: def unique_id(self) -> str:
""" """
This ID only equals amongst :class:`Story` instances which have the same owner and the same set of This ID only equals amongst :class:`Story` instances which have the same owner and the same set of
:class:`StoryItem`s. For all other :class:`Story` instances this ID is different. :class:`StoryItem`. For all other :class:`Story` instances this ID is different.
""" """
if not self._unique_id: if not self._unique_id:
id_list = [item.mediaid for item in self.get_items()] id_list = [item.mediaid for item in self.get_items()]
@@ -799,7 +869,7 @@ def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
def load_structure_from_file(context: InstaloaderContext, filename: str) -> JsonExportable: 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 """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`. has been saved by :func:`save_structure_to_file`.
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary. :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' :param filename: Filename, ends in '.json' or '.json.xz'
+6
View File
@@ -4,6 +4,7 @@ import os
import shutil import shutil
import tempfile import tempfile
import unittest import unittest
from itertools import islice
import instaloader import instaloader
@@ -112,6 +113,11 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
def test_test_login(self): def test_test_login(self):
self.assertEqual(OWN_USERNAME, self.L.test_login()) self.assertEqual(OWN_USERNAME, self.L.test_login())
def test_followees_and_stories(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
for f in islice(profile.get_followees(), PAGING_MAX_COUNT):
self.L.download_profile(f.username, profile_pic=False, download_stories_only=True)
def test_get_followees(self): def test_get_followees(self):
profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME) profile = instaloader.Profile.from_username(self.L.context, OWN_USERNAME)
for f in profile.get_followees(): for f in profile.get_followees():