mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-28 12:11:57 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92653dcdb9 | ||
|
|
1b5c12c8fa | ||
|
|
bc29ffbd44 | ||
|
|
50a5330fec | ||
|
|
1739844758 | ||
|
|
0ebb223214 | ||
|
|
3d4109ded3 | ||
|
|
8423e3bd00 | ||
|
|
5b0f8b75cc | ||
|
|
3cb33603e5 | ||
|
|
47db7f7ffa | ||
|
|
2567bec975 | ||
|
|
2df1419d68 | ||
|
|
8211c9daf1 | ||
|
|
969d4e3aa4 | ||
|
|
4cf1ae847d | ||
|
|
a276a0dc59 | ||
|
|
cc34b8340f | ||
|
|
45fe509ac4 | ||
|
|
ae492ed68b | ||
|
|
0e433294ad | ||
|
|
a807408d8f | ||
|
|
86035b8d2d | ||
|
|
a3eb93c21a | ||
|
|
b9c7713d6a | ||
|
|
60d35a95df | ||
|
|
920dcd0407 | ||
|
|
d19dd2c9d4 | ||
|
|
be43ebb5c5 | ||
|
|
52eb6d8e27 | ||
|
|
3211d63ec1 | ||
|
|
e1228501d2 |
+2
-2
@@ -8,8 +8,8 @@ install:
|
|||||||
- pip install pylint requests
|
- pip install pylint requests
|
||||||
- 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,no-else-return instaloader
|
||||||
- make -C docs html
|
- make -C docs html SPHINXOPTS="-W -n"
|
||||||
deploy:
|
deploy:
|
||||||
- provider: pypi
|
- provider: pypi
|
||||||
user: aandergr
|
user: aandergr
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Authors
|
||||||
|
=======
|
||||||
|
|
||||||
|
Instaloader is written by
|
||||||
|
|
||||||
|
- Alexander Graf (@aandergr)
|
||||||
|
- André Koch-Kramer (@Thammus)
|
||||||
|
- Lars Lindqvist (@e5150)
|
||||||
+28
-1
@@ -75,6 +75,33 @@ Disclaimer
|
|||||||
Instaloader is in no way affiliated with, authorized, maintained or endorsed by Instagram or any of its affiliates or
|
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.
|
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
|
.. 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.
|
||||||
|
|||||||
Vendored
+8
@@ -10,3 +10,11 @@ dd {
|
|||||||
dl.option dd {
|
dl.option dd {
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #008d06;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #f48400;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -50,6 +50,8 @@
|
|||||||
<li><a href="https://github.com/instaloader/instaloader">Instaloader on GitHub</a></li>
|
<li><a href="https://github.com/instaloader/instaloader">Instaloader on GitHub</a></li>
|
||||||
<li role="separator" class="divider"></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 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+10
-6
@@ -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::
|
||||||
|
|
||||||
@@ -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
|
A reference of the many methods provided by the :mod:`instaloader` module is
|
||||||
provided in the remainder of this document.
|
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)
|
``Instaloader`` (Main Class)
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
@@ -116,15 +119,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 +154,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 +183,6 @@ Exceptions
|
|||||||
|
|
||||||
.. autoexception:: BadCredentialsException
|
.. autoexception:: BadCredentialsException
|
||||||
|
|
||||||
.. autoexception:: ConnectionException
|
|
||||||
|
|
||||||
.. autoexception:: TooManyRequestsException
|
.. autoexception:: TooManyRequestsException
|
||||||
|
|
||||||
``InstaloaderContext`` (Low-level functions)
|
``InstaloaderContext`` (Low-level functions)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Instaloader supports the following targets:
|
|||||||
**download the user's stories**.
|
**download the user's stories**.
|
||||||
|
|
||||||
- ``"#hashtag"``
|
- ``"#hashtag"``
|
||||||
Posts with a certain **hashtag** (the quotes are usually neccessary),
|
Posts with a certain **hashtag** (the quotes are usually necessary),
|
||||||
|
|
||||||
- ``:stories``
|
- ``:stories``
|
||||||
The currently-visible **stories** of your followees (requires
|
The currently-visible **stories** of your followees (requires
|
||||||
@@ -158,7 +158,7 @@ Filter Posts
|
|||||||
.. py:currentmodule:: instaloader
|
.. py:currentmodule:: instaloader
|
||||||
|
|
||||||
The options :option:`--post-filter` and :option:`--storyitem-filter`
|
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.
|
meet to be downloaded. If not given, all posts are downloaded.
|
||||||
|
|
||||||
The filter string must be a
|
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
|
Instaloader exposes its internally used methods and structures, making it a
|
||||||
powerful and intuitive Python API for Instagram, allowing to further customize
|
powerful and intuitive Python API for Instagram, allowing to further customize
|
||||||
obtaining media and metadata.
|
obtaining media and metadata.
|
||||||
|
|
||||||
|
Also see :ref:`codesnippets`, where we collect example scripts that use
|
||||||
|
Instaloader to achieve more complex tasks.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -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
@@ -39,7 +39,8 @@ import docs.sphinx_autodoc_typehints as sphinx_autodoc_typehints
|
|||||||
extensions = [
|
extensions = [
|
||||||
'sphinx.ext.autodoc',
|
'sphinx.ext.autodoc',
|
||||||
'sphinx.ext.githubpages',
|
'sphinx.ext.githubpages',
|
||||||
'sphinx.ext.intersphinx'
|
'sphinx.ext.intersphinx',
|
||||||
|
'sphinx.ext.extlinks'
|
||||||
]
|
]
|
||||||
|
|
||||||
autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
|
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),
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
||||||
'requests': ('http://docs.python-requests.org/en/master', 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.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
|
|
||||||
|
|||||||
+43
-6
@@ -1,3 +1,5 @@
|
|||||||
|
.. _contributing:
|
||||||
|
|
||||||
Contributing to Instaloader
|
Contributing to Instaloader
|
||||||
===========================
|
===========================
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ Reporting Bugs
|
|||||||
If you encounter a bug, do not hesitate to report it in our
|
If you encounter a bug, do not hesitate to report it in our
|
||||||
`Issue Tracker <https://github.com/instaloader/instaloader/issues>`__. Since
|
`Issue Tracker <https://github.com/instaloader/instaloader/issues>`__. Since
|
||||||
Instaloader is actively developed, the majority of bugs is fixed within only
|
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:
|
following in mind:
|
||||||
|
|
||||||
- Ensure you **use the latest version** of Instaloader. The currently-installed
|
- 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
|
- Briefly **check whether the bug has already been reported**. If you find an
|
||||||
issue reporting the same bug you encountered, comment there rather than
|
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
|
- 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.
|
- Include all **error messages and tracebacks** in the report.
|
||||||
|
|
||||||
@@ -47,13 +49,13 @@ few things to consider:
|
|||||||
you forked from the most recent branch.
|
you forked from the most recent branch.
|
||||||
|
|
||||||
- We use `Pylint <https://www.pylint.org/>`__ for error and syntax checking of
|
- 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
|
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.
|
warning rather than adapting the code to Pylint's desires.
|
||||||
|
|
||||||
- The documentation source is located in the ``docs`` folder. The file
|
- The documentation source is located in the ``docs`` folder. The file
|
||||||
``cli-options.rst`` is merely an RST-formatted copy of ``ìnstaloader --help``
|
``cli-options.rst`` is merely an RST-formatted copy of ``instaloader --help``
|
||||||
output, of which the source is near the bottom of ``instaloader.py``.
|
output, of which the source is in ``instaloader/__main__.py``.
|
||||||
|
|
||||||
- Feel free to contact us, even if you "only" have Proof-of-Concepts or
|
- 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
|
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
|
- **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
|
feature suggestions. Keep in mind that this is a free software project, and
|
||||||
unfortunately we only have limited time to work on it.
|
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
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Instaloader Documentation
|
|||||||
basic-usage
|
basic-usage
|
||||||
cli-options
|
cli-options
|
||||||
as-module
|
as-module
|
||||||
|
codesnippets
|
||||||
contributing
|
contributing
|
||||||
|
|
||||||
Useful Links
|
Useful Links
|
||||||
@@ -69,10 +70,27 @@ Useful Links
|
|||||||
- `Issue Tracker / Bug Tracker <https://github.com/instaloader/instaloader/issues>`__
|
- `Issue Tracker / Bug Tracker <https://github.com/instaloader/instaloader/issues>`__
|
||||||
- `Version History <https://github.com/instaloader/instaloader/releases>`__
|
- `Version History <https://github.com/instaloader/instaloader/releases>`__
|
||||||
|
|
||||||
|
Disclaimer
|
||||||
|
----------
|
||||||
|
|
||||||
.. include:: ../README.rst
|
.. include:: ../README.rst
|
||||||
:start-after: disclaimer-start
|
:start-after: disclaimer-start
|
||||||
:end-before: disclaimer-end
|
: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`
|
* :ref:`genindex`
|
||||||
|
|||||||
@@ -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.1'
|
__version__ = '4.0.6'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+30
-21
@@ -121,8 +121,9 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
if target[0] == '@':
|
if target[0] == '@':
|
||||||
instaloader.context.log("Retrieving followees of %s..." % target[1:])
|
instaloader.context.log("Retrieving followees of %s..." % target[1:])
|
||||||
profile = Profile.from_username(instaloader.context, target[1:])
|
profile = Profile.from_username(instaloader.context, target[1:])
|
||||||
followees = profile.get_followees()
|
for followee in profile.get_followees():
|
||||||
profiles.update([followee.username for followee in followees])
|
instaloader.save_profile_id(followee)
|
||||||
|
profiles.add(followee)
|
||||||
elif target[0] == '#':
|
elif target[0] == '#':
|
||||||
instaloader.download_hashtag(hashtag=target[1:], max_count=max_count, fast_update=fast_update,
|
instaloader.download_hashtag(hashtag=target[1:], max_count=max_count, fast_update=fast_update,
|
||||||
post_filter=post_filter)
|
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,
|
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
|
||||||
post_filter=post_filter)
|
post_filter=post_filter)
|
||||||
else:
|
else:
|
||||||
profiles.add(target)
|
profiles.add(instaloader.check_profile_id(target))
|
||||||
if len(profiles) > 1:
|
if len(profiles) > 1:
|
||||||
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles), ' '.join(profiles)))
|
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
|
||||||
# Iterate through profiles list and download them
|
' '.join([p.username for p in profiles])))
|
||||||
for target in profiles:
|
if not stories_only:
|
||||||
with instaloader.context.error_catcher(target):
|
# Iterate through profiles list and download them
|
||||||
try:
|
for target in profiles:
|
||||||
instaloader.download_profile(target, profile_pic, profile_pic_only, fast_update,
|
with instaloader.context.error_catcher(target):
|
||||||
stories, stories_only, post_filter=post_filter,
|
try:
|
||||||
storyitem_filter=storyitem_filter)
|
instaloader.download_profile(target, profile_pic, profile_pic_only,
|
||||||
except ProfileNotExistsException as err:
|
fast_update, post_filter=post_filter)
|
||||||
if instaloader.context.is_logged_in:
|
except ProfileNotExistsException as err:
|
||||||
instaloader.context.log(err)
|
if instaloader.context.is_logged_in and not stories_only:
|
||||||
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
|
instaloader.context.log(err)
|
||||||
with instaloader.anonymous_copy() as anonymous_loader:
|
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
|
||||||
with instaloader.context.error_catcher():
|
with instaloader.anonymous_copy() as anonymous_loader:
|
||||||
anonymous_loader.download_profile(target, profile_pic, profile_pic_only,
|
with instaloader.context.error_catcher():
|
||||||
fast_update, post_filter=post_filter)
|
anonymous_loader.download_profile(target, profile_pic, profile_pic_only,
|
||||||
else:
|
fast_update, post_filter=post_filter)
|
||||||
raise
|
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:
|
except KeyboardInterrupt:
|
||||||
print("\nInterrupted by user.", file=sys.stderr)
|
print("\nInterrupted by user.", file=sys.stderr)
|
||||||
# Save session if it is useful
|
# Save session if it is useful
|
||||||
@@ -271,6 +278,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 +324,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,
|
||||||
|
|||||||
+53
-27
@@ -11,7 +11,7 @@ from contextlib import contextmanager, suppress
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from io import BytesIO
|
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 .exceptions import *
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext
|
||||||
@@ -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)
|
||||||
@@ -260,18 +266,19 @@ class Instaloader:
|
|||||||
def _epoch_to_string(epoch: datetime) -> str:
|
def _epoch_to_string(epoch: datetime) -> str:
|
||||||
return epoch.strftime('%Y-%m-%d_%H-%M-%S')
|
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:
|
with self.context.get_anonymous_session() as anonymous_session:
|
||||||
date_object = datetime.strptime(anonymous_session.head(profile_pic_url).headers["Last-Modified"],
|
date_object = datetime.strptime(anonymous_session.head(profile_pic_url).headers["Last-Modified"],
|
||||||
'%a, %d %b %Y %H:%M:%S GMT')
|
'%a, %d %b %Y %H:%M:%S GMT')
|
||||||
|
profile_pic_extension = 'jpg'
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||||
format_string_contains_key(self.dirname_pattern, 'target'))):
|
format_string_contains_key(self.dirname_pattern, 'target'))):
|
||||||
filename = '{0}/{1}_UTC_profile_pic.{2}'.format(self.dirname_pattern.format(profile=profile.username.lower(),
|
filename = '{0}/{1}_UTC_profile_pic.{2}'.format(self.dirname_pattern.format(profile=profile.username.lower(),
|
||||||
target=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:
|
else:
|
||||||
filename = '{0}/{1}_{2}_UTC_profile_pic.{3}'.format(self.dirname_pattern.format(), profile.username.lower(),
|
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):
|
if os.path.isfile(filename):
|
||||||
self.context.log(filename + ' already exists')
|
self.context.log(filename + ' already exists')
|
||||||
return None
|
return None
|
||||||
@@ -314,7 +321,11 @@ class Instaloader:
|
|||||||
return self.context.test_login()
|
return self.context.test_login()
|
||||||
|
|
||||||
def login(self, user: str, passwd: str) -> None:
|
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)
|
self.context.login(user, passwd)
|
||||||
|
|
||||||
def download_post(self, post: Post, target: str) -> bool:
|
def download_post(self, post: Post, target: str) -> bool:
|
||||||
@@ -399,25 +410,26 @@ class Instaloader:
|
|||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def download_stories(self,
|
def download_stories(self,
|
||||||
userids: Optional[List[int]] = None,
|
userids: Optional[List[Union[int, Profile]]] = None,
|
||||||
fast_update: bool = False,
|
fast_update: bool = False,
|
||||||
filename_target: str = ':stories',
|
filename_target: Optional[str] = ':stories',
|
||||||
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None) -> None:
|
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Download available stories from user followees or all stories of users whose ID are given.
|
Download available stories from user followees or all stories of users whose ID are given.
|
||||||
Does not mark stories as seen.
|
Does not mark stories as seen.
|
||||||
To use this, one needs to be logged in
|
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 fast_update: If true, abort when first already-downloaded picture is encountered
|
||||||
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
|
: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
|
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not userids:
|
if not userids:
|
||||||
self.context.log("Retrieving all visible stories...")
|
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
|
name = user_story.owner_username
|
||||||
self.context.log("Retrieving stories from profile {}.".format(name))
|
self.context.log("Retrieving stories from profile {}.".format(name))
|
||||||
totalcount = user_story.itemcount
|
totalcount = user_story.itemcount
|
||||||
@@ -429,7 +441,7 @@ class Instaloader:
|
|||||||
self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
|
self.context.log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
|
||||||
count += 1
|
count += 1
|
||||||
with self.context.error_catcher('Download story from user {}'.format(name)):
|
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:
|
if fast_update and not downloaded:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -600,6 +612,24 @@ class Instaloader:
|
|||||||
if fast_update and not downloaded:
|
if fast_update and not downloaded:
|
||||||
break
|
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:
|
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
|
Consult locally stored ID of profile with given name, check whether ID matches and whether name
|
||||||
@@ -612,12 +642,7 @@ class Instaloader:
|
|||||||
with suppress(ProfileNotExistsException):
|
with suppress(ProfileNotExistsException):
|
||||||
profile = Profile.from_username(self.context, profile_name)
|
profile = Profile.from_username(self.context, profile_name)
|
||||||
profile_exists = profile is not None
|
profile_exists = profile is not None
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
id_filename = self._get_id_filename(profile_name)
|
||||||
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())
|
|
||||||
try:
|
try:
|
||||||
with open(id_filename, 'rb') as id_file:
|
with open(id_filename, 'rb') as id_file:
|
||||||
profile_id = int(id_file.read())
|
profile_id = int(id_file.read())
|
||||||
@@ -646,15 +671,11 @@ class Instaloader:
|
|||||||
except (FileNotFoundError, ValueError):
|
except (FileNotFoundError, ValueError):
|
||||||
pass
|
pass
|
||||||
if profile_exists:
|
if profile_exists:
|
||||||
os.makedirs(self.dirname_pattern.format(profile=profile_name.lower(),
|
self.save_profile_id(profile)
|
||||||
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))
|
|
||||||
return profile
|
return profile
|
||||||
raise ProfileNotExistsException("Profile {0} does not exist.".format(profile_name))
|
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,
|
profile_pic: bool = True, profile_pic_only: bool = False,
|
||||||
fast_update: bool = False,
|
fast_update: bool = False,
|
||||||
download_stories: bool = False, download_stories_only: bool = False,
|
download_stories: bool = False, download_stories_only: bool = False,
|
||||||
@@ -665,7 +686,10 @@ class Instaloader:
|
|||||||
# Get profile main page json
|
# Get profile main page json
|
||||||
# check if profile does exist or name has changed since last download
|
# check if profile does exist or name has changed since last download
|
||||||
# and update name and json data if necessary
|
# 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
|
profile_name = profile.username
|
||||||
|
|
||||||
@@ -699,7 +723,7 @@ class Instaloader:
|
|||||||
|
|
||||||
# Download stories, if requested
|
# Download stories, if requested
|
||||||
if download_stories or download_stories_only:
|
if download_stories or download_stories_only:
|
||||||
if profile.has_highlight_reel:
|
if profile.has_viewable_story:
|
||||||
with self.context.error_catcher("Download stories of {}".format(profile_name)):
|
with self.context.error_catcher("Download stories of {}".format(profile_name)):
|
||||||
self.download_stories(userids=[profile.userid], filename_target=profile_name,
|
self.download_stories(userids=[profile.userid], filename_target=profile_name,
|
||||||
fast_update=fast_update, storyitem_filter=storyitem_filter)
|
fast_update=fast_update, storyitem_filter=storyitem_filter)
|
||||||
@@ -726,7 +750,9 @@ class Instaloader:
|
|||||||
def interactive_login(self, username: str) -> None:
|
def interactive_login(self, username: str) -> None:
|
||||||
"""Logs in and internally stores session, asking user for password interactively.
|
"""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:
|
if self.context.quiet:
|
||||||
raise LoginRequiredException("Quiet mode requires given password or valid session file.")
|
raise LoginRequiredException("Quiet mode requires given password or valid session file.")
|
||||||
password = None
|
password = None
|
||||||
|
|||||||
@@ -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,7 +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 = 25
|
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()
|
||||||
@@ -68,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."""
|
||||||
@@ -145,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())
|
||||||
@@ -159,29 +170,47 @@ class InstaloaderContext:
|
|||||||
return data["data"]["user"]["username"] if data["data"]["user"] is not None else None
|
return data["data"]["user"]["username"] if data["data"]["user"] is not None else None
|
||||||
|
|
||||||
def login(self, user, passwd):
|
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 = requests.Session()
|
||||||
session.cookies.update({'sessionid': '', 'mid': '', 'ig_pr': '1',
|
session.cookies.update({'sessionid': '', 'mid': '', 'ig_pr': '1',
|
||||||
'ig_vw': '1920', 'csrftoken': '',
|
'ig_vw': '1920', 'csrftoken': '',
|
||||||
's_network': '', 'ds_user_id': ''})
|
's_network': '', 'ds_user_id': ''})
|
||||||
session.headers.update(self._default_http_header())
|
session.headers.update(self._default_http_header())
|
||||||
self._sleep()
|
session.headers.update({'X-CSRFToken': self.get_json('', {})['config']['csrf_token']})
|
||||||
resp = session.get('https://www.instagram.com/')
|
# Not using self.get_json() here, because we need to access csrftoken cookie
|
||||||
session.headers.update({'X-CSRFToken': resp.cookies['csrftoken']})
|
|
||||||
self._sleep()
|
self._sleep()
|
||||||
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
||||||
data={'password': passwd, 'username': user}, allow_redirects=True)
|
data={'password': passwd, 'username': user}, allow_redirects=True)
|
||||||
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
|
if login.status_code != 200:
|
||||||
if login.status_code == 200:
|
raise ConnectionException("Login error: {} {}".format(login.status_code, login.reason))
|
||||||
self._session = session
|
resp_json = login.json()
|
||||||
if user == self.test_login():
|
if resp_json['status'] != 'ok':
|
||||||
self.username = user
|
if 'message' in resp_json:
|
||||||
|
raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
|
||||||
|
resp_json['message']))
|
||||||
else:
|
else:
|
||||||
self.username = None
|
raise ConnectionException("Login error: \"{}\" status.".format(resp_json['status']))
|
||||||
self._session = None
|
if not resp_json['authenticated']:
|
||||||
raise BadCredentialsException('Login error! Check your credentials!')
|
if resp_json['user']:
|
||||||
else:
|
# '{"authenticated": false, "user": true, "status": "ok"}'
|
||||||
raise ConnectionException('Login error! Connection error!')
|
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):
|
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."""
|
||||||
@@ -207,11 +236,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) < self._graphql_count_per_slidingwindow 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))
|
||||||
@@ -227,7 +259,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:
|
||||||
|
|||||||
+100
-16
@@ -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:
|
||||||
@@ -77,10 +93,9 @@ class Post:
|
|||||||
return b64encode(mediaid.to_bytes(9, 'big'), b'-_').decode().replace('A', ' ').lstrip().replace(' ', 'A')
|
return b64encode(mediaid.to_bytes(9, 'big'), b'-_').decode().replace('A', ' ').lstrip().replace(' ', 'A')
|
||||||
|
|
||||||
def _asdict(self):
|
def _asdict(self):
|
||||||
|
node = self._node
|
||||||
if self._full_metadata_dict:
|
if self._full_metadata_dict:
|
||||||
node = self._full_metadata_dict
|
node.update(self._full_metadata_dict)
|
||||||
else:
|
|
||||||
node = self._node
|
|
||||||
if self._owner_profile:
|
if self._owner_profile:
|
||||||
node['owner'] = self.owner_profile._asdict()
|
node['owner'] = self.owner_profile._asdict()
|
||||||
if self._location:
|
if self._location:
|
||||||
@@ -190,10 +205,7 @@ class Post:
|
|||||||
@property
|
@property
|
||||||
def typename(self) -> str:
|
def typename(self) -> str:
|
||||||
"""Type of post, GraphImage, GraphVideo or GraphSidecar"""
|
"""Type of post, GraphImage, GraphVideo or GraphSidecar"""
|
||||||
if '__typename' in self._node:
|
return self._field('__typename')
|
||||||
return self._node['__typename']
|
|
||||||
# if __typename is not in node, it is an old image or video
|
|
||||||
return 'GraphImage'
|
|
||||||
|
|
||||||
def get_sidecar_nodes(self) -> Iterator[PostSidecarNode]:
|
def get_sidecar_nodes(self) -> Iterator[PostSidecarNode]:
|
||||||
"""Sidecar nodes of a Post with typename==GraphSidecar."""
|
"""Sidecar nodes of a Post with typename==GraphSidecar."""
|
||||||
@@ -358,8 +370,13 @@ 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_public_story = None
|
||||||
self._node = node
|
self._node = node
|
||||||
self._rhx_gis = None
|
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
|
@classmethod
|
||||||
def from_username(cls, context: InstaloaderContext, username: str):
|
def from_username(cls, context: InstaloaderContext, username: str):
|
||||||
@@ -406,6 +423,8 @@ class Profile:
|
|||||||
json_node.pop('edge_media_collections', None)
|
json_node.pop('edge_media_collections', None)
|
||||||
json_node.pop('edge_owner_to_timeline_media', None)
|
json_node.pop('edge_owner_to_timeline_media', None)
|
||||||
json_node.pop('edge_saved_media', None)
|
json_node.pop('edge_saved_media', None)
|
||||||
|
if self._iphone_struct_:
|
||||||
|
json_node['iphone_struct'] = self._iphone_struct_
|
||||||
return json_node
|
return json_node
|
||||||
|
|
||||||
def _obtain_metadata(self):
|
def _obtain_metadata(self):
|
||||||
@@ -430,6 +449,15 @@ class Profile:
|
|||||||
d = d[key]
|
d = d[key]
|
||||||
return d
|
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
|
@property
|
||||||
def userid(self) -> int:
|
def userid(self) -> int:
|
||||||
"""User ID"""
|
"""User ID"""
|
||||||
@@ -463,6 +491,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')
|
||||||
@@ -484,8 +524,38 @@ class Profile:
|
|||||||
return self._metadata('has_blocked_viewer')
|
return self._metadata('has_blocked_viewer')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_highlight_reel(self) -> bool:
|
def has_highlight_reels(self) -> bool:
|
||||||
return self._metadata('has_highlight_reel')
|
"""
|
||||||
|
Always returns `True` since :issue:`153`.
|
||||||
|
|
||||||
|
Before broken, this indicated whether the :class:`Profile` had available stories.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@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:
|
||||||
@@ -499,17 +569,21 @@ class Profile:
|
|||||||
def requested_by_viewer(self) -> bool:
|
def requested_by_viewer(self) -> bool:
|
||||||
return self._metadata('requested_by_viewer')
|
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"""
|
"""Return URL of profile picture"""
|
||||||
try:
|
try:
|
||||||
with self._context.get_anonymous_session() as anonymous_session:
|
return self._iphone_struct['hd_profile_pic_url_info']['url']
|
||||||
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"]
|
|
||||||
except (InstaloaderException, KeyError) as err:
|
except (InstaloaderException, KeyError) as err:
|
||||||
self._context.error('{} Unable to fetch high quality profile pic.'.format(err))
|
self._context.error('{} Unable to fetch high quality profile pic.'.format(err))
|
||||||
return self._metadata("profile_pic_url_hd")
|
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]:
|
def get_posts(self) -> Iterator[Post]:
|
||||||
"""Retrieve all posts from a profile."""
|
"""Retrieve all posts from a profile."""
|
||||||
self._obtain_metadata()
|
self._obtain_metadata()
|
||||||
@@ -536,6 +610,16 @@ class Profile:
|
|||||||
self._rhx_gis,
|
self._rhx_gis,
|
||||||
self._metadata('edge_saved_media')))
|
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']:
|
def get_followers(self) -> Iterator['Profile']:
|
||||||
"""
|
"""
|
||||||
Retrieve list of followers of given profile.
|
Retrieve list of followers of given profile.
|
||||||
@@ -722,7 +806,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()]
|
||||||
@@ -805,7 +889,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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ NORMAL_MAX_COUNT = 2
|
|||||||
PAGING_MAX_COUNT = 15
|
PAGING_MAX_COUNT = 15
|
||||||
PRIVATE_PROFILE = "aandergr"
|
PRIVATE_PROFILE = "aandergr"
|
||||||
|
|
||||||
|
# Preserve query timestamps (rate control) between tests to not get rate limited
|
||||||
|
instaloadercontext_query_timestamps = list()
|
||||||
|
|
||||||
|
|
||||||
class TestInstaloaderAnonymously(unittest.TestCase):
|
class TestInstaloaderAnonymously(unittest.TestCase):
|
||||||
|
|
||||||
@@ -26,23 +30,31 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
download_comments=True,
|
download_comments=True,
|
||||||
save_metadata=True)
|
save_metadata=True)
|
||||||
self.L.context.raise_all_errors = True
|
self.L.context.raise_all_errors = True
|
||||||
|
self.L.context.query_timestamps = instaloadercontext_query_timestamps
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
global instaloadercontext_query_timestamps
|
||||||
|
instaloadercontext_query_timestamps = self.L.context.query_timestamps
|
||||||
self.L.close()
|
self.L.close()
|
||||||
os.chdir('/')
|
os.chdir('/')
|
||||||
print("Removing {}".format(self.dir))
|
print("Removing {}".format(self.dir))
|
||||||
shutil.rmtree(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
|
@unittest.SkipTest
|
||||||
def test_public_profile_download(self):
|
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)
|
||||||
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):
|
def test_public_profile_paging(self):
|
||||||
for count, post in enumerate(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts()):
|
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts())
|
||||||
print(post)
|
|
||||||
if count == PAGING_MAX_COUNT:
|
|
||||||
break
|
|
||||||
|
|
||||||
def test_profile_pic_download(self):
|
def test_profile_pic_download(self):
|
||||||
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True)
|
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True)
|
||||||
@@ -64,6 +76,11 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
self.assertEqual(post, post2)
|
self.assertEqual(post, post2)
|
||||||
break
|
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):
|
class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
|
||||||
|
|
||||||
@@ -86,10 +103,7 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
|
|||||||
print(item)
|
print(item)
|
||||||
|
|
||||||
def test_private_profile_paging(self):
|
def test_private_profile_paging(self):
|
||||||
for count, post in enumerate(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts()):
|
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
|
||||||
print(post)
|
|
||||||
if count == PAGING_MAX_COUNT:
|
|
||||||
break
|
|
||||||
|
|
||||||
def test_feed_download(self):
|
def test_feed_download(self):
|
||||||
self.L.download_feed_posts(NORMAL_MAX_COUNT)
|
self.L.download_feed_posts(NORMAL_MAX_COUNT)
|
||||||
@@ -112,6 +126,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():
|
||||||
|
|||||||
Reference in New Issue
Block a user