Compare commits

..
24 Commits
Author SHA1 Message Date
André Koch-Kramer 92653dcdb9 Release of version 4.0.6 2018-08-02 16:55:59 +02:00
André Koch-Kramer 1b5c12c8fa Add unittest for Profile.get_tagged_posts() 2018-08-02 16:48:06 +02:00
Lars Lindqvist bc29ffbd44 Add meth:get_tagged_posts to Profile 2018-08-02 16:41:51 +02:00
André Koch-Kramer 50a5330fec Change download of stories when not using :stories
If using --stories or --stories-only the stories got donwloaded along
with the profiles one by one. Now, the stories get downloaded in a
similar aproach like when using the :stories target, i.e.
download_stories() gets only called once.
Profile.has_highlight_reels is broken and now always returns true.
This fixes #153.
2018-08-02 16:20:27 +02:00
André Koch-Kramer 1739844758 Fix login by allowing more http headers
Because Instagram is bombarding over hundret of headers on login it is
necessary to increase http.client._MAXHEADERS.
Fixes #152.
2018-07-30 17:50:22 +02:00
André Koch-Kramer 0ebb223214 Added BTC address to donations section
Motivated by #130.
2018-07-23 23:36:46 +02:00
Alexander Graf 3d4109ded3 travis disable pylint's no-else-return 2018-07-22 11:04:11 +02:00
Alexander Graf 8423e3bd00 Mention IRC channel #instaloader on Freenode 2018-07-22 10:55:29 +02:00
Alexander Graf 5b0f8b75cc Add AUTHORS.md listing Instaloader's authors 2018-07-22 10:54:54 +02:00
Alexander Graf 3cb33603e5 doc: Use Instaloader brand colors for links 2018-07-09 21:32:18 +02:00
Alexander Graf 47db7f7ffa Release of version 4.0.5 2018-07-09 10:18:27 +02:00
Alexander Graf 2567bec975 unittests: Test order of Profile.get_posts()
Motivated by #140.
2018-07-09 10:08:18 +02:00
Alexander Graf 2df1419d68 Fix file extension of profile picture
Fix #142.
2018-07-09 10:07:16 +02:00
Alexander Graf 8211c9daf1 Documentation Improvements
- Fix Spelling
- codesnippets: Improve introduction texts; Write subsection about
  metadata JSON files
2018-06-17 18:10:19 +02:00
Alexander Graf 969d4e3aa4 Post: Write all ever-obtained metadata into JSON
The Post._asdict() code assumed that Post._node always is a subset of
Post._full_metadata_dict. However, it turned out, that `edge_liked_by`,
`thumbnail_src` and `thumbnail_resources` may be contained in Post._node, but
not in Post._full_metadata_dict. Now the keys of both dictionaries are saved in
the JSON file.

Fixes #137.
2018-06-15 17:07:28 +02:00
Alexander Graf 4cf1ae847d Mention donation option in doc
(Suggested by #130)
2018-06-13 08:39:29 +02:00
André Koch-Kramer a276a0dc59 Release of version 4.0.4 2018-06-07 15:46:47 +02:00
André Koch-Kramer cc34b8340f Fix detection of sidecars and videos
This was noticeable when downloading hashtags.
Fixes #133
2018-06-07 15:32:23 +02:00
Alexander Graf 45fe509ac4 doc: Link codesnippets in basic-usage 2018-06-04 12:06:41 +02:00
Alexander Graf ae492ed68b New doc section: codesnippets / Advanced Examples
Presents code examples that use the instaloader module for more advanced tasks
than what is possible with the Instaloader command line interface.

Presents #46, #56, #110, #113, #120, #121.
2018-06-04 11:57:28 +02:00
Alexander Graf 0e433294ad unittests: Preserve query timestamps between tests
This avoids getting rate limited while running all the tests.
2018-05-30 13:16:16 +02:00
Alexander Graf a807408d8f Release of version 4.0.3 2018-05-23 08:57:36 +02:00
Alexander Graf 86035b8d2d Fix obtaining csrftoken for login
Fixes #122. Further fixes processing of login JSON response regarding error
handling.
2018-05-23 08:50:37 +02:00
Alexander Graf a3eb93c21a Profile: Cache iphone data structure if queried
Profile.get_profile_pic_url() and Profile.has_highlight_reels did the same
query without remembering the response, resulting in that query being made
twice when downloading stories and profile pic of a profile.
2018-05-19 09:52:07 +02:00
22 changed files with 527 additions and 100 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ install:
- pip install pylint requests
- pip install -r docs/requirements.txt
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,no-else-return instaloader
- make -C docs html SPHINXOPTS="-W -n"
deploy:
- provider: pypi
+8
View File
@@ -0,0 +1,8 @@
Authors
=======
Instaloader is written by
- Alexander Graf (@aandergr)
- André Koch-Kramer (@Thammus)
- Lars Lindqvist (@e5150)
+28 -1
View File
@@ -75,6 +75,33 @@ Disclaimer
Instaloader is in no way affiliated with, authorized, maintained or endorsed by Instagram or any of its affiliates or
subsidiaries. This is an independent and unofficial project. Use at your own risk.
Instaloader is licensed under an MIT license. Refer to ``LICENSE`` file for more information.
.. disclaimer-end
Instaloader is licensed under an MIT license. Refer to ``LICENSE`` file for more information.
Contributing
------------
As an open source project, Instaloader heavily depends on the contributions from
its community. See
`contributing <https://instaloader.github.io/contributing.html>`__
for how you may help Instaloader to become an even greater tool.
It is a pleasure for us to share our Instaloader to the world, and we are proud
to have attracted such an active and motivating community, with so many users
who share their suggestions and ideas with us. Buying a community-sponsored beer
or coffee from time to time is very likely to further raise our passion for the
development of Instaloader.
| For Donations, we provide a PayPal.Me link and a Bitcoin address.
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
For a direct and personal communication within Instaloader's community, we have
an IRC channel
**#instaloader** on **Freenode**.
Freenode provides a `Web IRC Client
<https://webchat.freenode.net/?channels=instaloader>`__, allowing to join our
channel without having an IRC client installed.
+8
View File
@@ -10,3 +10,11 @@ dd {
dl.option dd {
padding-left: 12px;
}
a {
color: #008d06;
}
a:hover {
color: #f48400;
}
+2
View File
@@ -50,6 +50,8 @@
<li><a href="https://github.com/instaloader/instaloader">Instaloader on GitHub</a></li>
<li role="separator" class="divider"></li>
<li style="padding-left: 20px;"><a class="github-button" href="https://github.com/instaloader/instaloader" data-icon="octicon-star" data-size="large" data-show-count="true" aria-label="Star Instaloader on GitHub">Star</a></li>
<li role="separator" class="divider"></li>
<li><a href="https://www.paypal.me/aandergr"><b>Donate (via Paypal)</b></a></li>
</ul>
</li>
</ul>
+3
View File
@@ -97,6 +97,9 @@ metadata of a Profile. :class:`Profile` instances can be created with:
A reference of the many methods provided by the :mod:`instaloader` module is
provided in the remainder of this document.
For a list of real code examples using the Instaloader module for advanced
tasks, see :ref:`codesnippets`.
``Instaloader`` (Main Class)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+5 -2
View File
@@ -68,7 +68,7 @@ Instaloader supports the following targets:
**download the user's stories**.
- ``"#hashtag"``
Posts with a certain **hashtag** (the quotes are usually neccessary),
Posts with a certain **hashtag** (the quotes are usually necessary),
- ``:stories``
The currently-visible **stories** of your followees (requires
@@ -158,7 +158,7 @@ Filter Posts
.. py:currentmodule:: instaloader
The options :option:`--post-filter` and :option:`--storyitem-filter`
allows to specify criterias that posts or story items have to
allows to specify criteria that posts or story items have to
meet to be downloaded. If not given, all posts are downloaded.
The filter string must be a
@@ -273,3 +273,6 @@ consider taking a look at the :ref:`python-module-instaloader`.
Instaloader exposes its internally used methods and structures, making it a
powerful and intuitive Python API for Instagram, allowing to further customize
obtaining media and metadata.
Also see :ref:`codesnippets`, where we collect example scripts that use
Instaloader to achieve more complex tasks.
+128
View File
@@ -0,0 +1,128 @@
.. _codesnippets:
Advanced Instaloader Examples
=============================
.. currentmodule:: instaloader
.. highlight:: python
.. contents::
:backlinks: none
Here we present code examples that use the :ref:`python-module-instaloader` for
more advanced Instagram downloading or metadata mining than what is possible
with the Instaloader command line interface.
.. For each code snippet:
- title
- brief description of what it does / motivation / how it works
- code, or link to code
- link to discussion issue
- link used methods
Download Posts in a Specific Period
-----------------------------------
To only download Instagram pictures (and metadata) that are within a specific
period, you can play around with :func:`~itertools.dropwhile` and
:func:`~itertools.takewhile` from :mod:`itertools` like in this snippet.
.. literalinclude:: codesnippets/121_since_until.py
See also :class:`Post`, :meth:`Instaloader.download_post`.
Discussed in :issue:`121`.
Likes of a Profile / Ghost Followers
------------------------------------
To obtain a list of your inactive followers, i.e. followers that did not like
any of your pictures, into a file you can use this approach.
.. literalinclude:: codesnippets/120_ghost_followers.py
See also :meth:`Profile.get_posts`, :meth:`Post.get_likes`,
:meth:`Profile.get_followers`, :meth:`Instaloader.load_session_from_file`,
:meth:`Profile.from_username`.
Discussed in :issue:`120`.
Track Deleted Posts
-------------------
This script uses Instaloader to obtain a list of currently-online Instagram and
compares it with the set of posts that you already have downloaded. It outputs
a list of posts which are online but not offline (i.e. not yet downloaded) and a
list of posts which are offline but not online (i.e. deleted in the profile).
.. literalinclude:: codesnippets/56_track_deleted.py
See also :func:`load_structure_from_file`, :meth:`Profile.from_username`,
:meth:`Profile.get_posts`, :class:`Post`.
Discussed in :issue:`56`.
Only one Post per User
----------------------
To download only the one most recent post from each user, this snippet creates a
:class:`set` that contains the users of which a post has already been
downloaded. While iterating the posts, it checks whether the post's owner
already is in the set. If not, the post is downloaded from Instagram and the
user is added to that set.
.. literalinclude:: codesnippets/113_only_one_per_user.py
See also :class:`Post`, :meth:`Instaloader.download_post`,
:attr:`Post.owner_profile`, :class:`Profile`.
Discussed in :issue:`113`.
Upgrade Images by Local Copies
------------------------------
The following script finds local versions of images fetched by Instaloader, in
order to upgrade the downloaded images by locally-found versions with better
quality. It uses image hashing to identify similar images.
`updgrade-instaloader-images.py <https://gist.github.com/pavelkryukov/15f93d19a99428a284a8bcec27e0187b>`__ (external link to GitHub Gist)
Discussed in :issue:`46`.
Add Captions to Images
----------------------
Instaloader does not modify the downloaded JPEG file. However, one could combine
it with an imaging library such as Pillow or PIL to render the caption on
Instagram pictures. The following shows an approach.
.. literalinclude:: codesnippets/110_pil_captions.py
See also :attr:`Post.caption`, :attr:`Post.url`, :meth:`Post.from_shortcode`,
:func:`load_structure_from_file`.
Discussed in :issue:`110`.
Metadata JSON Files
-------------------
The JSON files Instaloader saves along with each Post contain all the metadata
that has been retrieved from Instagram while downloading the picture and
associated required information.
With `jq <https://stedolan.github.io/jq/>`__, a command-line JSON processor, the
metadata can be easily post-processed. For example, Instaloader's JSON files can
be pretty-formatted with:
.. code-block:: none
xzcat 2018-05-13_11-18-45_UTC.json.xz | jq .node
However, Instaloader tries to do as few metadata requests as possible, so,
depending on how Instaloader has been invoked, it may occur that these files do
not contain the complete available metadata structure. Nevertheless, the file
can be loaded into Instaloader with :func:`load_structure_from_file` and the
required metadata then be accessed via the :class:`Post` or :class:`Profile`
attributes, which trigger an Instagram request if that particular information is
not present in the JSON file.
+20
View File
@@ -0,0 +1,20 @@
from io import BytesIO
from requests import get
from PIL import Image, ImageDraw
from instaloader import *
L = Instaloader()
# Load Post instance
post = load_structure_from_file(L.context, '2017-10-01_18-53-03_UTC.json.xz')
# or post = Post.from_shortcode(L.context, SHORTCODE)
# Render caption
image = Image.open(BytesIO(get(post.url).content))
draw = ImageDraw.Draw(image)
color = 'rgb(0, 0, 0)' # black color
draw.text((300,100), post.caption.encode('latin1', errors='ignore'), fill=color)
# Save image
image.save('test.jpg')
@@ -0,0 +1,14 @@
import instaloader
L = instaloader.Instaloader()
posts = L.get_hashtag_posts('milfgarden')
users = set()
for post in posts:
if not post.owner_profile in users:
L.download_post(post, '#milfgarden')
users.add(post.owner_profile)
else:
print("{} from {} skipped.".format(post, post.owner_profile))
+27
View File
@@ -0,0 +1,27 @@
import instaloader
L = instaloader.Instaloader()
USER = 'your_account'
PROFILE = USER
# Your preferred way of logging in:
L.load_session_from_file(USER)
profile = instaloader.Profile.from_username(L.context, PROFILE)
likes = set()
print('Fetching likes of all posts of profile {}.'.format(profile.username))
for post in profile.get_posts():
print(post)
likes = likes | set(post.get_likes())
print('Fetching followers of profile {}.'.format(profile.username))
followers = set(profile.get_followers())
ghosts = followers - likes
print('Storing ghosts into file.')
with open('/YOUR PATH/inactive-users.txt', 'w') as f:
for ghost in ghosts:
print(ghost.username, file=f)
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from itertools import dropwhile, takewhile
import instaloader
L = instaloader.Instaloader()
posts = L.get_hashtag_posts('milfgarden')
# or
# posts = instaloader.Profile.from_username(L.context, PROFILE).get_posts()
SINCE = datetime(2015, 5, 1)
UNTIL = datetime(2015, 3, 1)
for post in takewhile(lambda p: p.date > UNTIL, dropwhile(lambda p: p.date > SINCE, posts)):
print(post.date)
L.download_post(post, '#milfgarden')
+34
View File
@@ -0,0 +1,34 @@
from glob import glob
from sys import argv
from os import chdir
from instaloader import Instaloader, Post, Profile, load_structure_from_file
# Instaloader instantiation - you may pass additional arguments to the constructor here
L = Instaloader()
# If desired, load session previously saved with `instaloader -l USERNAME`:
#L.load_session_from_file(USERNAME)
try:
TARGET = argv[1]
except IndexError:
raise SystemExit("Pass profile name as argument!")
# Obtain set of posts that are on hard disk
chdir(TARGET)
offline_posts = set(filter(lambda s: isinstance(s, Post),
(load_structure_from_file(L.context, file)
for file in (glob('*.json.xz') + glob('*.json')))))
# Obtain set of posts that are currently online
post_iterator = Profile.from_username(L.context, TARGET).get_posts()
online_posts = set(post_iterator)
if online_posts - offline_posts:
print("Not yet downloaded posts:")
print(" ".join(str(p) for p in (online_posts - offline_posts)))
if offline_posts - online_posts:
print("Deleted posts:")
print(" ".join(str(p) for p in (offline_posts - online_posts)))
+4 -1
View File
@@ -39,7 +39,8 @@ import docs.sphinx_autodoc_typehints as sphinx_autodoc_typehints
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.githubpages',
'sphinx.ext.intersphinx'
'sphinx.ext.intersphinx',
'sphinx.ext.extlinks'
]
autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
@@ -49,6 +50,8 @@ autodoc_member_order = 'bysource'
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
'requests': ('http://docs.python-requests.org/en/master', None)}
extlinks = {'issue': ('https://github.com/instaloader/instaloader/issues/%s', 'issue #')}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
+43 -6
View File
@@ -1,3 +1,5 @@
.. _contributing:
Contributing to Instaloader
===========================
@@ -15,7 +17,7 @@ Reporting Bugs
If you encounter a bug, do not hesitate to report it in our
`Issue Tracker <https://github.com/instaloader/instaloader/issues>`__. Since
Instaloader is actively developed, the majority of bugs is fixed within only
**4 days** after being reported. When reporting a problem, please keep the
**3 days** after being reported. When reporting a problem, please keep the
following in mind:
- Ensure you **use the latest version** of Instaloader. The currently-installed
@@ -23,10 +25,10 @@ following in mind:
- Briefly **check whether the bug has already been reported**. If you find an
issue reporting the same bug you encountered, comment there rather than
opening a new issue. However, if unsure, it is advised to create a new issue.
opening a new issue. However, if unsure, please create a new issue.
- State **how the bug can be reproduced**, i.e. how Instaloader was invoked
when the problem occured (of course, you may anonymize profile names etc.).
when the problem occurred (of course, you may anonymize profile names etc.).
- Include all **error messages and tracebacks** in the report.
@@ -47,13 +49,13 @@ few things to consider:
you forked from the most recent branch.
- We use `Pylint <https://www.pylint.org/>`__ for error and syntax checking of
``instaloader.py``. The file ``.travis.yml`` in the project's root directory
the source. The file ``.travis.yml`` in the project's root directory
shows how it is invoked. Note that sometimes it might be better to disable a
warning rather than adapting the code to Pylint's desires.
- The documentation source is located in the ``docs`` folder. The file
``cli-options.rst`` is merely an RST-formatted copy of ``ìnstaloader --help``
output, of which the source is near the bottom of ``instaloader.py``.
``cli-options.rst`` is merely an RST-formatted copy of ``instaloader --help``
output, of which the source is in ``instaloader/__main__.py``.
- Feel free to contact us, even if you "only" have Proof-of-Concepts or
not-fully integrated changes. They already might be an advance for the
@@ -81,3 +83,38 @@ Please consider the following:
- **Be patient**. Naturally, bugs and pull requests have a higher priority than
feature suggestions. Keep in mind that this is a free software project, and
unfortunately we only have limited time to work on it.
Donations
---------
.. donations-start
It is a pleasure for us to share our Instaloader to the world, and we are proud
to have attracted such an active and motivating community, with so many users
who share their suggestions and ideas with us. Buying a community-sponsored beer
or coffee from time to time is very likely to further raise our passion for the
development of Instaloader.
| For donations, we provide a PayPal.Me link and a Bitcoin address.
| `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
.. donations-end
.. (Discussion in :issue:`130`)
IRC Channel
-----------
.. ircchannel-start
For a direct and personal communication within Instaloader's community, we have
an IRC channel
**#instaloader** on **Freenode**.
Freenode provides a `Web IRC Client
<https://webchat.freenode.net/?channels=instaloader>`__, allowing to join our
channel without having an IRC client installed.
.. ircchannel-end
+18
View File
@@ -59,6 +59,7 @@ Instaloader Documentation
basic-usage
cli-options
as-module
codesnippets
contributing
Useful Links
@@ -69,10 +70,27 @@ Useful Links
- `Issue Tracker / Bug Tracker <https://github.com/instaloader/instaloader/issues>`__
- `Version History <https://github.com/instaloader/instaloader/releases>`__
Disclaimer
----------
.. include:: ../README.rst
:start-after: disclaimer-start
:end-before: disclaimer-end
Contributing
------------
As an open source project, Instaloader heavily depends on the contributions from
its community. See :ref:`contributing` for how you may help Instaloader to
become an even greater tool.
.. include:: contributing.rst
:start-after: donations-start
:end-before: donations-end
.. include:: contributing.rst
:start-after: ircchannel-start
:end-before: ircchannel-end
..
* :ref:`genindex`
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.0.2'
__version__ = '4.0.6'
try:
+28 -21
View File
@@ -121,8 +121,9 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if target[0] == '@':
instaloader.context.log("Retrieving followees of %s..." % target[1:])
profile = Profile.from_username(instaloader.context, target[1:])
followees = profile.get_followees()
profiles.update([followee.username for followee in followees])
for followee in profile.get_followees():
instaloader.save_profile_id(followee)
profiles.add(followee)
elif target[0] == '#':
instaloader.download_hashtag(hashtag=target[1:], max_count=max_count, fast_update=fast_update,
post_filter=post_filter)
@@ -135,26 +136,32 @@ def _main(instaloader: Instaloader, targetlist: List[str],
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
post_filter=post_filter)
else:
profiles.add(target)
profiles.add(instaloader.check_profile_id(target))
if len(profiles) > 1:
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles), ' '.join(profiles)))
# Iterate through profiles list and download them
for target in profiles:
with instaloader.context.error_catcher(target):
try:
instaloader.download_profile(target, profile_pic, profile_pic_only, fast_update,
stories, stories_only, post_filter=post_filter,
storyitem_filter=storyitem_filter)
except ProfileNotExistsException as err:
if instaloader.context.is_logged_in:
instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader:
with instaloader.context.error_catcher():
anonymous_loader.download_profile(target, profile_pic, profile_pic_only,
fast_update, post_filter=post_filter)
else:
raise
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
' '.join([p.username for p in profiles])))
if not stories_only:
# Iterate through profiles list and download them
for target in profiles:
with instaloader.context.error_catcher(target):
try:
instaloader.download_profile(target, profile_pic, profile_pic_only,
fast_update, post_filter=post_filter)
except ProfileNotExistsException as err:
if instaloader.context.is_logged_in and not stories_only:
instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader:
with instaloader.context.error_catcher():
anonymous_loader.download_profile(target, profile_pic, profile_pic_only,
fast_update, post_filter=post_filter)
else:
raise
if stories or stories_only:
with instaloader.context.error_catcher("Download stories"):
instaloader.context.log("Downloading stories")
instaloader.download_stories(userids=list(profiles), fast_update=fast_update,
filename_target=None, storyitem_filter=storyitem_filter)
except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr)
# Save session if it is useful
+44 -24
View File
@@ -11,7 +11,7 @@ from contextlib import contextmanager, suppress
from datetime import datetime, timezone
from functools import wraps
from io import BytesIO
from typing import Callable, Iterator, List, Optional, Any
from typing import Any, Callable, Iterator, List, Optional, Union
from .exceptions import *
from .instaloadercontext import InstaloaderContext
@@ -266,18 +266,19 @@ class Instaloader:
def _epoch_to_string(epoch: datetime) -> str:
return epoch.strftime('%Y-%m-%d_%H-%M-%S')
profile_pic_url = profile.get_profile_pic_url()
profile_pic_url = profile.profile_pic_url
with self.context.get_anonymous_session() as anonymous_session:
date_object = datetime.strptime(anonymous_session.head(profile_pic_url).headers["Last-Modified"],
'%a, %d %b %Y %H:%M:%S GMT')
profile_pic_extension = 'jpg'
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
format_string_contains_key(self.dirname_pattern, 'target'))):
filename = '{0}/{1}_UTC_profile_pic.{2}'.format(self.dirname_pattern.format(profile=profile.username.lower(),
target=profile.username.lower()),
_epoch_to_string(date_object), profile_pic_url[-3:])
_epoch_to_string(date_object), profile_pic_extension)
else:
filename = '{0}/{1}_{2}_UTC_profile_pic.{3}'.format(self.dirname_pattern.format(), profile.username.lower(),
_epoch_to_string(date_object), profile_pic_url[-3:])
_epoch_to_string(date_object), profile_pic_extension)
if os.path.isfile(filename):
self.context.log(filename + ' already exists')
return None
@@ -320,7 +321,11 @@ class Instaloader:
return self.context.test_login()
def login(self, user: str, passwd: str) -> None:
"""Log in to instagram with given username and password and internally store session object"""
"""Log in to instagram with given username and password and internally store session object.
:raises InvalidArgumentException: If the provided username does not exist.
:raises BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed."""
self.context.login(user, passwd)
def download_post(self, post: Post, target: str) -> bool:
@@ -405,25 +410,26 @@ class Instaloader:
@_requires_login
def download_stories(self,
userids: Optional[List[int]] = None,
userids: Optional[List[Union[int, Profile]]] = None,
fast_update: bool = False,
filename_target: str = ':stories',
filename_target: Optional[str] = ':stories',
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None) -> None:
"""
Download available stories from user followees or all stories of users whose ID are given.
Does not mark stories as seen.
To use this, one needs to be logged in
:param userids: List of user IDs to be processed in terms of downloading their stories
:param userids: List of user IDs or Profiles to be processed in terms of downloading their stories
: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 should be used instead
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
"""
if not userids:
self.context.log("Retrieving all visible stories...")
for user_story in self.get_stories(userids):
for user_story in self.get_stories([p if isinstance(p, int) else p.userid for p in userids]):
name = user_story.owner_username
self.context.log("Retrieving stories from profile {}.".format(name))
totalcount = user_story.itemcount
@@ -435,7 +441,7 @@ class Instaloader:
self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
count += 1
with self.context.error_catcher('Download story from user {}'.format(name)):
downloaded = self.download_storyitem(item, filename_target)
downloaded = self.download_storyitem(item, filename_target if filename_target else name)
if fast_update and not downloaded:
break
@@ -606,6 +612,24 @@ class Instaloader:
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'))):
return '{0}/id'.format(self.dirname_pattern.format(profile=profile_name.lower(),
target=profile_name.lower()))
else:
return '{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower())
def save_profile_id(self, profile: Profile):
"""
Store ID of profile locally.
"""
os.makedirs(self.dirname_pattern.format(profile=profile.username,
target=profile.username), exist_ok=True)
with open(self._get_id_filename(profile.username), 'w') as text_file:
text_file.write(str(profile.userid) + "\n")
self.context.log("Stored ID {0} for profile {1}.".format(profile.userid, profile.username))
def check_profile_id(self, profile_name: str) -> Profile:
"""
Consult locally stored ID of profile with given name, check whether ID matches and whether name
@@ -618,12 +642,7 @@ class Instaloader:
with suppress(ProfileNotExistsException):
profile = Profile.from_username(self.context, profile_name)
profile_exists = profile is not None
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
format_string_contains_key(self.dirname_pattern, 'target'))):
id_filename = '{0}/id'.format(self.dirname_pattern.format(profile=profile_name.lower(),
target=profile_name.lower()))
else:
id_filename = '{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower())
id_filename = self._get_id_filename(profile_name)
try:
with open(id_filename, 'rb') as id_file:
profile_id = int(id_file.read())
@@ -652,15 +671,11 @@ class Instaloader:
except (FileNotFoundError, ValueError):
pass
if profile_exists:
os.makedirs(self.dirname_pattern.format(profile=profile_name.lower(),
target=profile_name.lower()), exist_ok=True)
with open(id_filename, 'w') as text_file:
text_file.write(str(profile.userid) + "\n")
self.context.log("Stored ID {0} for profile {1}.".format(profile.userid, profile_name))
self.save_profile_id(profile)
return profile
raise ProfileNotExistsException("Profile {0} does not exist.".format(profile_name))
def download_profile(self, profile_name: str,
def download_profile(self, profile_name: Union[str, Profile],
profile_pic: bool = True, profile_pic_only: bool = False,
fast_update: bool = False,
download_stories: bool = False, download_stories_only: bool = False,
@@ -671,7 +686,10 @@ class Instaloader:
# Get profile main page json
# check if profile does exist or name has changed since last download
# and update name and json data if necessary
profile = self.check_profile_id(profile_name.lower())
if isinstance(profile_name, str):
profile = self.check_profile_id(profile_name.lower())
else:
profile = profile_name
profile_name = profile.username
@@ -732,7 +750,9 @@ class Instaloader:
def interactive_login(self, username: str) -> None:
"""Logs in and internally stores session, asking user for password interactively.
:raises LoginRequiredException: when in quiet mode."""
:raises LoginRequiredException: when in quiet mode.
:raises InvalidArgumentException: If the provided username does not exist.
:raises ConnectionException: If connection to Instagram failed."""
if self.context.quiet:
raise LoginRequiredException("Quiet mode requires given password or valid session file.")
password = None
+32 -14
View File
@@ -170,29 +170,47 @@ class InstaloaderContext:
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`."""
"""Not meant to be used directly, use :meth:`Instaloader.login`.
:raises InvalidArgumentException: If the provided username does not exist.
:raises BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed."""
import http.client
# pylint:disable=protected-access
http.client._MAXHEADERS = 200
session = requests.Session()
session.cookies.update({'sessionid': '', 'mid': '', 'ig_pr': '1',
'ig_vw': '1920', 'csrftoken': '',
's_network': '', 'ds_user_id': ''})
session.headers.update(self._default_http_header())
self._sleep()
resp = session.get('https://www.instagram.com/')
session.headers.update({'X-CSRFToken': resp.cookies['csrftoken']})
session.headers.update({'X-CSRFToken': self.get_json('', {})['config']['csrf_token']})
# Not using self.get_json() here, because we need to access csrftoken cookie
self._sleep()
login = session.post('https://www.instagram.com/accounts/login/ajax/',
data={'password': passwd, 'username': user}, allow_redirects=True)
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
if login.status_code == 200:
self._session = session
if user == self.test_login():
self.username = user
if login.status_code != 200:
raise ConnectionException("Login error: {} {}".format(login.status_code, login.reason))
resp_json = login.json()
if resp_json['status'] != 'ok':
if 'message' in resp_json:
raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message']))
else:
self.username = None
self._session = None
raise BadCredentialsException('Login error! Check your credentials!')
else:
raise ConnectionException('Login error! Connection error!')
raise ConnectionException("Login error: \"{}\" status.".format(resp_json['status']))
if not resp_json['authenticated']:
if resp_json['user']:
# '{"authenticated": false, "user": true, "status": "ok"}'
raise BadCredentialsException('Login error: Wrong password.')
else:
# '{"authenticated": false, "user": false, "status": "ok"}'
# Raise InvalidArgumentException rather than BadCredentialException, because BadCredentialException
# triggers re-asking of password in Instaloader.interactive_login(), which makes no sense if the
# username is invalid.
raise InvalidArgumentException('Login error: User {} does not exist.'.format(user))
# '{"authenticated": true, "user": true, "userId": ..., "oneTapPrompt": false, "status": "ok"}'
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session
self.username = user
def _sleep(self):
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
+41 -21
View File
@@ -93,10 +93,9 @@ class Post:
return b64encode(mediaid.to_bytes(9, 'big'), b'-_').decode().replace('A', ' ').lstrip().replace(' ', 'A')
def _asdict(self):
node = self._node
if self._full_metadata_dict:
node = self._full_metadata_dict
else:
node = self._node
node.update(self._full_metadata_dict)
if self._owner_profile:
node['owner'] = self.owner_profile._asdict()
if self._location:
@@ -206,10 +205,7 @@ class Post:
@property
def typename(self) -> str:
"""Type of post, GraphImage, GraphVideo or GraphSidecar"""
if '__typename' in self._node:
return self._node['__typename']
# if __typename is not in node, it is an old image or video
return 'GraphImage'
return self._field('__typename')
def get_sidecar_nodes(self) -> Iterator[PostSidecarNode]:
"""Sidecar nodes of a Post with typename==GraphSidecar."""
@@ -374,10 +370,13 @@ class Profile:
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
assert 'username' in node
self._context = context
self._has_highlight_reels = None
self._has_public_story = None
self._node = node
self._rhx_gis = None
self._iphone_struct_ = None
if 'iphone_struct' in node:
# if loaded from JSON with load_structure_from_file()
self._iphone_struct_ = node['iphone_struct']
@classmethod
def from_username(cls, context: InstaloaderContext, username: str):
@@ -424,6 +423,8 @@ class Profile:
json_node.pop('edge_media_collections', None)
json_node.pop('edge_owner_to_timeline_media', None)
json_node.pop('edge_saved_media', None)
if self._iphone_struct_:
json_node['iphone_struct'] = self._iphone_struct_
return json_node
def _obtain_metadata(self):
@@ -448,6 +449,15 @@ class Profile:
d = d[key]
return d
@property
def _iphone_struct(self) -> Dict[str, Any]:
if not self._iphone_struct_:
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._iphone_struct_ = data['user']
return self._iphone_struct_
@property
def userid(self) -> int:
"""User ID"""
@@ -516,15 +526,11 @@ class Profile:
@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.
Always returns `True` since :issue:`153`.
Before broken, this indicated whether the :class:`Profile` had available stories.
"""
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
return True
@property
def has_public_story(self) -> bool:
@@ -563,17 +569,21 @@ class Profile:
def requested_by_viewer(self) -> bool:
return self._metadata('requested_by_viewer')
def get_profile_pic_url(self) -> str:
@property
def profile_pic_url(self) -> str:
"""Return URL of profile picture"""
try:
with self._context.get_anonymous_session() as anonymous_session:
data = self._context.get_json(path='api/v1/users/{0}/info/'.format(self.userid), params={},
host='i.instagram.com', session=anonymous_session)
return data["user"]["hd_profile_pic_url_info"]["url"]
return self._iphone_struct['hd_profile_pic_url_info']['url']
except (InstaloaderException, KeyError) as err:
self._context.error('{} Unable to fetch high quality profile pic.'.format(err))
return self._metadata("profile_pic_url_hd")
def get_profile_pic_url(self) -> str:
""".. deprecated:: 4.0.3
Use :attr:`profile_pic_url`."""
return self.profile_pic_url
def get_posts(self) -> Iterator[Post]:
"""Retrieve all posts from a profile."""
self._obtain_metadata()
@@ -600,6 +610,16 @@ class Profile:
self._rhx_gis,
self._metadata('edge_saved_media')))
def get_tagged_posts(self) -> Iterator[Post]:
"""Retrieve all posts where a profile is tagged."""
self._obtain_metadata()
yield from (Post(self._context, node, self) for node in
self._context.graphql_node_list("e31a871f7301132ceaab56507a66bbb7",
{'id': self.userid},
'https://www.instagram.com/{0}/'.format(self.username),
lambda d: d['data']['user']['edge_user_to_photos_of_you'],
self._rhx_gis))
def get_followers(self) -> Iterator['Profile']:
"""
Retrieve list of followers of given profile.
+21 -8
View File
@@ -16,6 +16,9 @@ NORMAL_MAX_COUNT = 2
PAGING_MAX_COUNT = 15
PRIVATE_PROFILE = "aandergr"
# Preserve query timestamps (rate control) between tests to not get rate limited
instaloadercontext_query_timestamps = list()
class TestInstaloaderAnonymously(unittest.TestCase):
@@ -27,23 +30,31 @@ class TestInstaloaderAnonymously(unittest.TestCase):
download_comments=True,
save_metadata=True)
self.L.context.raise_all_errors = True
self.L.context.query_timestamps = instaloadercontext_query_timestamps
def tearDown(self):
global instaloadercontext_query_timestamps
instaloadercontext_query_timestamps = self.L.context.query_timestamps
self.L.close()
os.chdir('/')
print("Removing {}".format(self.dir))
shutil.rmtree(self.dir)
def post_paging_test(self, iterator):
previous_post = None
for post in islice(iterator, PAGING_MAX_COUNT):
print(post)
if previous_post:
self.assertTrue(post.date_utc < previous_post.date_utc)
previous_post = post
@unittest.SkipTest
def test_public_profile_download(self):
self.L.download_profile(PUBLIC_PROFILE, profile_pic=False, fast_update=True)
self.L.download_profile(PUBLIC_PROFILE, profile_pic=False, fast_update=True)
def test_public_profile_paging(self):
for count, post in enumerate(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts()):
print(post)
if count == PAGING_MAX_COUNT:
break
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts())
def test_profile_pic_download(self):
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True)
@@ -65,6 +76,11 @@ class TestInstaloaderAnonymously(unittest.TestCase):
self.assertEqual(post, post2)
break
def test_public_profile_tagged_paging(self):
for post in islice(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_tagged_posts(),
PAGING_MAX_COUNT):
print(post)
class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
@@ -87,10 +103,7 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
print(item)
def test_private_profile_paging(self):
for count, post in enumerate(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts()):
print(post)
if count == PAGING_MAX_COUNT:
break
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
def test_feed_download(self):
self.L.download_feed_posts(NORMAL_MAX_COUNT)