Compare commits

...
35 Commits
Author SHA1 Message Date
Alexander Graf 58a80c349d Release of version 3.3 2018-03-13 16:34:05 +01:00
Alexander Graf 5f34fca9e7 Merge branch 'v3.3-dev' 2018-03-13 16:22:37 +01:00
Alexander Graf fd6389249c Describe return type of Post.get_{likes,comments} 2018-03-13 16:19:26 +01:00
Alexander Graf 374f13adbf Introduce Instaloader.GRAPHQL_PAGE_LENGTH 2018-03-13 16:10:38 +01:00
Alexander Graf 17e443a7e2 Post.get_likes() to iterate over likes of Post
Wished in issue #70.
2018-03-13 15:57:12 +01:00
André Koch-Kramer 4628f1cc8d Reimplemented test_login() via graphql query
Fixes # 76.
2018-03-13 15:37:35 +01:00
Alexander Graf e97411a502 Mention :saved in all places of documentation 2018-03-13 15:36:27 +01:00
Alexander Graf 304cde64fe Link to github pages doc hosting rather than rtd 2018-03-13 15:11:36 +01:00
Alexander Graf 3dbce922c3 travis: remove checks on python 3.7 and nightly
These failed because of Sphinx.
2018-03-13 14:11:55 +01:00
Alexander Graf 4e57ac77f0 docs: Obtain current tag via git instead of gh api 2018-03-13 14:03:49 +01:00
André Koch-Kramer de960b2b57 Mentione target :saved in README, docs and --help 2018-03-13 13:53:21 +01:00
Alexander Graf 9b60adeb45 Adjust github repo references
New repo URL: https://github.com/instaloader/instaloader
2018-03-13 13:29:44 +01:00
Alexander Graf 0333bb5e99 Travis to push doc to instaloader.github.io 2018-03-13 13:22:10 +01:00
André Koch-Kramer b1edaddb31 Support for new target :saved
If logged in, Instaloader is now able to download posts which are marked
as saved. This feature was suggested in #78.
2018-03-13 12:02:45 +01:00
André Koch-Kramer 01c2a2b1cb Release of version 3.2.2 2018-02-15 16:13:36 +01:00
André Koch-Kramer 541b29b2e9 Remove signatures from profile pic URLs
Fixes #75 as suggested by @e5150.
2018-02-15 16:10:04 +01:00
André Koch-Kramer 6f3afd2fa1 Removed redundant character escapes in RegExp 2018-02-15 15:01:17 +01:00
André Koch-Kramer 882d460a67 Merge branch 'bug_69_fix' 2018-02-15 14:56:42 +01:00
André Koch-Kramer 9b11ca18d1 Added backward compatibility for --fast-update
This is related to bug #69.
Filenames containing the old date encoding without UTC are now
recognized when using the `--fast-update` switch.
Also non-picture files using the old date encoding should now be
recognized even when not using the `--fast-update` switch, excluding
geotags and the post metadata JSON files. The files containing comments
get renamed to the new format when touched. Caption files are only
renamed, if they get updated. Stored picture files do not get renamed at
all.
2018-01-28 16:40:13 +01:00
Alexander Graf 39bd7c55f6 doc: link next section at the end of each page
This makes it easier to go through the documentation and it
pushes readthedocs's advertising further apart from the
content.
2018-01-13 19:15:32 +01:00
André Koch-Kramer 3654d6bf6d Use UTC in filenames per default
This adresses bug #69.
All fields in the filename pattern that should be replaced by a date
encoding string (excluding {post.date_local}) are now evaluated using
UTC while ensuring that the replacing string indicates the use of UTC.
2018-01-10 22:26:49 +01:00
Alexander Graf 1ca368fa45 Mention Arch+Gentoo pkgs in doc/install section 2018-01-07 22:08:51 +01:00
Alexander Graf 698ea51cb9 Add contribution guidelines to documentation 2018-01-07 17:26:35 +01:00
Alexander Graf e6be0179b7 Support {date_utc} filename_pattern
{date_utc} encodes the post creation date in UTC rather than the
current local timezone, as {date} does.

This was proposed in #69. Encoding the post creation date in
local time zone induces problems regarding --fast-update when the
time zone is changed.
2018-01-07 14:52:03 +01:00
Alexander Graf 97e1bd2bf0 Disable Pylint's new inconsistent-return-statements 2017-12-21 14:03:44 +01:00
Alexander Graf 5695bca9f3 Current version and release date in doc sidebar 2017-12-21 13:53:06 +01:00
Alexander Graf 75d9dda4ba --no-video-thumbnails option
Closes #61.
2017-12-14 19:16:39 +01:00
Alexander Graf 41a50ffa5a Refactor download_story() into a method 2017-12-14 16:17:46 +01:00
Alexander Graf 3daaa6fbbc --no-profile-pic to disable profile pic download
(Suggested in #61)
2017-12-14 15:55:53 +01:00
André Koch-Kramer 341030058b Release of version 3.2.1 2017-12-11 21:21:07 +01:00
André Koch-Kramer fd98e8e78f Reduced GraphQL query chunks to 200
Fixes #59.
2017-12-11 21:11:18 +01:00
Alexander Graf e24c6a2cad Add description meta tags to documentation 2017-12-03 15:20:51 +01:00
Alexander Graf 01e549275e doc: ownership verification tag 2017-11-17 10:06:53 +01:00
Alexander Graf 37780986fc doc: Page intro improvements
The introductionary sections of as-module and cli-options have been
rewritten. Further, Instaloader's short-description is now included in
the sidebar.
2017-11-14 11:18:30 +01:00
André Koch-Kramer 9f34e3303e Added basic usage to README and documentation 2017-11-10 18:55:52 +01:00
17 changed files with 563 additions and 204 deletions
+19 -10
View File
@@ -4,17 +4,26 @@ python:
- "3.5-dev" - "3.5-dev"
- "3.6" - "3.6"
- "3.6-dev" - "3.6-dev"
- "3.7-dev"
- "nightly"
install: install:
- pip install pylint requests - pip install pylint requests
- 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 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 instaloader
- make -C docs html
deploy: deploy:
provider: pypi - provider: pypi
user: aandergr user: aandergr
on: on:
tags: true tags: true
python: 3.6 python: 3.6
password: password:
secure: KBsATtXNnEzqr0X2ALdyx2zaYj2EUfK8YjP4jEOilBELpJ4evHYXVgRkn9CKXGsD7LKbWmR3gz2rr8NZ6U42pHw0fpQHizz91MpJSKbNY7RZZh4UVWMDh81izeglchWAF+m3Wdpxpo/GBDbrZIbRQFhPgB+kjSzJo3uiCS+yVXtGG+6csJHzd0WkJjsVHz+XSdxyOiLy/qFF3nmY6ZrVrgw6WbX2YLbx9je6DxK3YRrnBqgMgH7Ui2xWWbgJCk3KKpQTMiMKnTa9mdeMJAd26YMCz1gG/X7ck6VNly/6bs5+Zq7/bU0dSolhPBhNthyccYdS0IeGPdPF3YqatblJ4fLGTfHn8oJs3/T11yIW9pvOT3UftEynPOUX+en2Qnb/3WfgwUfVts8i8DAYgEFLGEzbF5XMGy4gwAD/bZ0JW0pmgAG2PZsdOokAt+ydlNj/uxq1GFW2doZbul6k4t03p1mik6itYscvuhKoEm8Yw6wr1P9aDvMR6FFpSu4gDt2XrKmRmWkSXxpNeCfZujTjyAnISxLVQGSY7BG5K0Z2HbDtqqNz9nj4WgNGzZmngiRofCevWR+vPIDUDW7NCX03csfcFS9RRllBO/BrL7NVUV+Qp9dfTQYp+8SqYLWgHtDoXdgyMREjJaAmbx2u/TYTY8bADZ5F3vGUpv2j5hP3NTU= secure: KBsATtXNnEzqr0X2ALdyx2zaYj2EUfK8YjP4jEOilBELpJ4evHYXVgRkn9CKXGsD7LKbWmR3gz2rr8NZ6U42pHw0fpQHizz91MpJSKbNY7RZZh4UVWMDh81izeglchWAF+m3Wdpxpo/GBDbrZIbRQFhPgB+kjSzJo3uiCS+yVXtGG+6csJHzd0WkJjsVHz+XSdxyOiLy/qFF3nmY6ZrVrgw6WbX2YLbx9je6DxK3YRrnBqgMgH7Ui2xWWbgJCk3KKpQTMiMKnTa9mdeMJAd26YMCz1gG/X7ck6VNly/6bs5+Zq7/bU0dSolhPBhNthyccYdS0IeGPdPF3YqatblJ4fLGTfHn8oJs3/T11yIW9pvOT3UftEynPOUX+en2Qnb/3WfgwUfVts8i8DAYgEFLGEzbF5XMGy4gwAD/bZ0JW0pmgAG2PZsdOokAt+ydlNj/uxq1GFW2doZbul6k4t03p1mik6itYscvuhKoEm8Yw6wr1P9aDvMR6FFpSu4gDt2XrKmRmWkSXxpNeCfZujTjyAnISxLVQGSY7BG5K0Z2HbDtqqNz9nj4WgNGzZmngiRofCevWR+vPIDUDW7NCX03csfcFS9RRllBO/BrL7NVUV+Qp9dfTQYp+8SqYLWgHtDoXdgyMREjJaAmbx2u/TYTY8bADZ5F3vGUpv2j5hP3NTU=
- provider: pages
skip-cleanup: true
github-token: $GITHUB_TOKEN
local-dir: docs/_build/html
repo: instaloader/instaloader.github.io
target-branch: master
on:
branch: master
python: 3.6
+10 -4
View File
@@ -9,8 +9,8 @@ Instaloader
**Instaloader** **Instaloader**
- downloads **public and private profiles, hashtags, user stories and - downloads **public and private profiles, hashtags, user stories,
feeds**, feeds and saved media**,
- downloads **comments, geotags and captions** of each post, - downloads **comments, geotags and captions** of each post,
@@ -20,7 +20,13 @@ Instaloader
- allows **fine-grained customization** of filters and where to store - allows **fine-grained customization** of filters and where to store
downloaded media. downloaded media.
`Instaloader Documentation <https://instaloader.readthedocs.io/>`__ ::
instaloader [--comments] [--geotags] [--stories]
[--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | :stories | :feed | :saved
`Instaloader Documentation <https://instaloader.github.io/>`__
How to Automatically Download Pictures from Instagram How to Automatically Download Pictures from Instagram
@@ -59,7 +65,7 @@ temporary directory, which will be reused later the next time ``--login``
is given. So you can download private profiles **non-interactively** when you is given. So you can download private profiles **non-interactively** when you
already have a valid session cookie file. already have a valid session cookie file.
`Instaloader Documentation <https://instaloader.readthedocs.io/basic-usage.html>`__ `Instaloader Documentation <https://instaloader.github.io/basic-usage.html>`__
Disclaimer Disclaimer
+2 -2
View File
@@ -3,13 +3,13 @@
This directory contains sources for the Instaloader documentation. This directory contains sources for the Instaloader documentation.
The documentation is hosted at: The documentation is hosted at:
https://instaloader.readthedocs.io/ https://instaloader.github.io/
## Build ## Build
The documentation is created with [Sphinx](http://www.sphinx-doc.org/). To build it, use The documentation is created with [Sphinx](http://www.sphinx-doc.org/). To build it, use
``` ```
pip3 install sphinx sphinx-autodoc-typehints pip3 install -r requirements.txt
make html make html
``` ```
+2 -1
View File
@@ -1 +1,2 @@
<h2>Instaloader</h2> <h2><a href="index.html">Instaloader</a></h2>
<p>Download pictures (or videos) along with their captions and other metadata from Instagram.</p>
+1
View File
@@ -1,4 +1,5 @@
{% extends "!layout.html" %} {% extends "!layout.html" %}
{% block extrahead %} {% block extrahead %}
<meta name="google-site-verification" content="FNUsDdkOJM9obm9QEdrNBVd9AtAzHzA-aaSMCUrS6C8" />
<script async defer src="https://buttons.github.io/buttons.js"></script> <script async defer src="https://buttons.github.io/buttons.js"></script>
{% endblock %} {% endblock %}
+9 -4
View File
@@ -4,15 +4,20 @@
<li class="toctree-l1"><a class="reference internal" href="{{ next.link|e }}">{{ next.title }}</a></li> <li class="toctree-l1"><a class="reference internal" href="{{ next.link|e }}">{{ next.title }}</a></li>
</ul> </ul>
{% endif %} {% endif %}
<h3>Current Release</h3>
<ul>
<li class="toctree-l1"><a href="https://github.com/instaloader/instaloader/releases/tag/v{{current_release}}">Version <b>{{current_release}}</b></a></li>
<li>Released on <b>{{current_release_date}}</b>.</li>
</ul>
<h3>Links</h3> <h3>Links</h3>
<ul> <ul>
<li><a href="https://github.com/Thammus/instaloader/issues">Issue Tracker</a></li> <li><a href="https://github.com/instaloader/instaloader/issues">Issue Tracker</a></li>
<li><a href="https://github.com/Thammus/instaloader/releases">Version History</a></li> <li><a href="https://github.com/instaloader/instaloader/releases">Version History</a></li>
<li><a href="https://pypi.python.org/pypi/instaloader">Instaloader on PyPI</a></li> <li><a href="https://pypi.python.org/pypi/instaloader">Instaloader on PyPI</a></li>
<li><a href="https://github.com/Thammus/instaloader">Instaloader on GitHub</a></li> <li><a href="https://github.com/instaloader/instaloader">Instaloader on GitHub</a></li>
</ul> </ul>
<ul> <ul>
<li> <li>
<a class="github-button" href="https://github.com/Thammus/Instaloader" data-icon="octicon-star" data-size="large" data-show-count="true" aria-label="Star Thammus/Instaloader on GitHub">Star</a> <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/Instaloader on GitHub">Star</a>
</li> </li>
</ul> </ul>
+10
View File
@@ -0,0 +1,10 @@
{% extends "!page.html" %}
{% block body %}
{{ super() }}
{% if next and prev %}
<h2>Next Section</h2>
<p>
<a class="reference internal" href="{{ next.link|e }}">{{ next.title }}</a>
</p>
{% endif %}
{% endblock %}
+45 -43
View File
@@ -1,3 +1,8 @@
.. meta::
:description:
Instaloader can also be used as a powerful and easy-to-use
Python API for Instagram, allowing to download media and metadata.
Python Module :mod:`instaloader` Python Module :mod:`instaloader`
-------------------------------- --------------------------------
@@ -5,68 +10,65 @@ Python Module :mod:`instaloader`
.. highlight:: python .. highlight:: python
You may also use parts of Instaloader as library to do other interesting Instaloader exposes its internally used methods as a Python module, making it a
things. **powerful and easy-to-use Python API for Instagram**, allowing to further
customize obtaining media and metadata.
For example, to get a list of all followees and a list of all followers of a profile, do Start with getting an instance of :class:`Instaloader`::
.. code:: python
import instaloader import instaloader
# Get instance # Get instance
loader = instaloader.Instaloader() L = instaloader.Instaloader()
# Login # Optionally, login or load session
loader.interactive_login(USERNAME) L.login(USER, PASSWORD) # (login)
L.interactive_login(USER) # (ask password on terminal)
L.load_session_from_file(USER) # (load session created w/
# `instaloader -l USERNAME`)
:mod:`instaloader` provides the :class:`Post` structure, which represents a
picture, video or sidecar (set of multiple pictures/videos) posted in a user's
profile. :class:`Instaloader` provides methods to iterate over Posts from a
certain source::
for post in L.get_hashtag_posts('cat'):
# post is an instance of instaloader.Post
L.download_post(post, target='#cat')
Besides :func:`Instaloader.get_hashtag_posts`, there is
:func:`Instaloader.get_feed_posts` and :func:`Instaloader.get_profile_posts`.
Also, :class:`Post` instances can be created with :func:`Post.from_shortcode`
and :func:`Post.from_mediaid`.
Further, information about profiles can be easily obtained. For example, you may
print a list of all followees and followers of a profile with::
# Print followees # Print followees
print(PROFILE + " follows these profiles:") print(PROFILE + " follows these profiles:")
for f in loader.get_followees(PROFILE): for f in L.get_followees(PROFILE):
print("\t%s\t%s" % (f['username'], f['full_name'])) print("\t%s\t%s" % (f['username'], f['full_name']))
# Print followers # Print followers
print("Followers of " + PROFILE + ":") print("Followers of " + PROFILE + ":")
for f in loader.get_followers(PROFILE): for f in L.get_followers(PROFILE):
print("\t%s\t%s" % (f['username'], f['full_name'])) print("\t%s\t%s" % (f['username'], f['full_name']))
Then, you may download all pictures of all followees with Then, you may download all pictures of all followees with::
.. code:: python for f in L.get_followees(PROFILE):
L.download_profile(f['username'])
for f in loader.get_followees(PROFILE): Each Instagram profile has its own unique ID which stays unmodified even if a
loader.download_profile(f['username']) user changes his/her username. To get said ID, given the profile's name, you may
call::
You could also download your last 20 liked pics with L.get_id_by_username(PROFILE_NAME)
.. code:: python
loader.download_feed_posts(max_count=20, fast_update=True,
filter_func=lambda post: post.viewer_has_liked)
To download the last 20 pictures with hashtag #cat, do
.. code:: python
loader.download_hashtag('cat', max_count=20)
Generally, :class:`Instaloader` provides methods to iterate over the Posts from
a certain source.
.. code:: python
for post in loader.get_hashtag_posts('cat'):
# post is an instance of instaloader.Post
loader.download_post(post, target='#cat')
Each Instagram profile has its own unique ID which stays unmodified even
if a user changes his/her username. To get said ID, given the profile's
name, you may call
.. code:: python
loader.get_id_by_username(PROFILE_NAME)
A reference of the many methods provided by the :mod:`instaloader` module is
provided in the remainder of this document. Feel free to direct any issue or
contribution to
`Instaloader on Github <https://github.com/instaloader/instaloader>`__.
``Instaloader`` (Main Class) ``Instaloader`` (Main Class)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+4 -1
View File
@@ -63,6 +63,9 @@ Instaloader supports the following targets:
``:feed`` ``:feed``
Your **feed** (requires :option:`--login`), Your **feed** (requires :option:`--login`),
``:saved``
Posts which are marked as **saved** (requires :option:`--login`),
``@profile`` ``@profile``
All profiles that are followed by ``profile``, i.e. the *followees* of All profiles that are followed by ``profile``, i.e. the *followees* of
``profile`` (requires :option:`--login`). ``profile`` (requires :option:`--login`).
@@ -95,7 +98,7 @@ pattern, the token ``{target}`` is replaced by the target name, and
:option:`--filename-pattern` configures the path of the post's files relative :option:`--filename-pattern` configures the path of the post's files relative
to the target directory. The default is ``--filename-pattern={date}``. to the target directory. The default is ``--filename-pattern={date}``.
The tokens ``{target}`` and ``{profile}`` are replaced like in the The tokens ``{target}`` and ``{profile}`` are replaced like in the
dirname pattern. Further, the tokens ``{date}`` and ``{shortcode}`` are dirname pattern. Further, the tokens ``{date}``, ``{date_utc}`` and ``{shortcode}`` are
defined. Additionally, in case of not downloading stories, the attributes of defined. Additionally, in case of not downloading stories, the attributes of
:class:`.Post` can be used, e.g. ``{post.owner_id}`` or ``{post.mediaid}``. :class:`.Post` can be used, e.g. ``{post.owner_id}`` or ``{post.mediaid}``.
+27 -15
View File
@@ -1,33 +1,44 @@
Command Line Options Command Line Options
==================== ====================
The following flags can be given to Instaloader to specify how profiles should Instaloader is invoked with::
be downloaded.
To get a list of all flags, their abbreviations and their descriptions, $ instaloader [options] target [target ...]
run ``instaloader --help``.
where ``target`` is a ``profile``, a ``"#hashtag"``, ``@profile`` (all profiles
that *profile* is following), or if logged in ``:feed`` (pictures from your
feed), ``:stories`` (stories of your followees) or ``:saved`` (collection of
posts marked as saved).
Here we explain the additional options that can be given to Instaloader to
customize its behavior. To get a list of all flags, their abbreviations and
their descriptions, you may also run ``instaloader --help``.
What to Download What to Download
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
Specify a list of profiles or #hashtags. For each of these, Instaloader Specify a list of targets (profiles, #hashtags, ``:feed``, ``:stories`` or
creates a folder and downloads all posts along with the pictures's ``:saved``). For each of these, Instaloader creates a folder and stores all
captions and the current **profile picture**. If an already-downloaded profile posts along with the pictures's captions and the current **profile picture**
has been renamed, Instaloader automatically **finds it by its unique ID** and there. If an already-downloaded profile has been renamed, Instaloader
renames the folder likewise. automatically **finds it by its unique ID** and renames the folder likewise.
Instead of a *profile* or a *#hashtag*, the special targets
``:feed`` (pictures from your feed) and
``:stories`` (stories of your followees) can be specified.
.. option:: --profile-pic-only .. option:: --profile-pic-only
Only download profile picture. Only download profile picture.
.. option:: --no-profile-pic
Do not download profile picture.
.. option:: --no-videos .. option:: --no-videos
Do not download videos. Do not download videos.
.. option:: --no-video-thumbnails
Do not download thumbnails of videos.
.. option:: --geotags .. option:: --geotags
**Download geotags** when available. Geotags are stored as a text file with **Download geotags** when available. Geotags are stored as a text file with
@@ -66,7 +77,8 @@ Instead of a *profile* or a *#hashtag*, the special targets
.. option:: --stories-only .. option:: --stories-only
Rather than downloading regular posts of each specified profile, only Rather than downloading regular posts of each specified profile, only
download stories. Requires :option:`--login`. download stories. Requires :option:`--login`. Does not imply
:option:`--no-profile-pic`.
.. option:: --only-if filter .. option:: --only-if filter
@@ -133,7 +145,7 @@ How to Download
Prefix of filenames. Posts are stored in the directory whose pattern is given Prefix of filenames. Posts are stored in the directory whose pattern is given
with ``--dirname-pattern``. ``{profile}`` is replaced by the profile name, with ``--dirname-pattern``. ``{profile}`` is replaced by the profile name,
``{target}`` is replaced by the target you specified, i.e. either ``:feed``, ``{target}`` is replaced by the target you specified, i.e. either ``:feed``,
``#hashtag`` or the profile name. Also, the fields ``{date}`` and ``#hashtag`` or the profile name. Also, the fields ``{date}``, ``{date_utc}`` and
``{shortcode}`` can be specified. In case of not downloading stories, the ``{shortcode}`` can be specified. In case of not downloading stories, the
attributes of the :class:`.Post` class can be used in addition, e.g. attributes of the :class:`.Post` class can be used in addition, e.g.
``{post.owner_id}`` or ``{post.mediaid}``. ``{post.owner_id}`` or ``{post.mediaid}``.
+7
View File
@@ -18,6 +18,7 @@
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
# #
import os import os
import subprocess
import sys import sys
sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('..'))
@@ -352,3 +353,9 @@ texinfo_documents = [
# If true, do not generate a @detailmenu in the "Top" node's menu. # If true, do not generate a @detailmenu in the "Top" node's menu.
# #
# texinfo_no_detailmenu = False # texinfo_no_detailmenu = False
current_release = subprocess.check_output(["git", "describe", "--abbrev=0"]).decode("ascii")[1:-1]
current_release_date = subprocess.check_output(["git", "log", "-1", "--tags", "--format=%ad", "--date=format:%e %b %Y"]).decode("ascii")[:-1]
html_context = {'current_release': current_release, 'current_release_date': current_release_date}
+83
View File
@@ -0,0 +1,83 @@
Contributing to Instaloader
===========================
As an open source project, Instaloader heavily depends on the contributions from
its community. In this document, we advise on how you may help Instaloader to
become an even greater tool.
Instaloader's development is organized on
`GitHub <https://github.com/instaloader/instaloader>`__, where Issues and Pull
Requests are discussed.
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
following in mind:
- Ensure you **use the latest version** of Instaloader. The currently-installed
version can be found out with ``instaloader --version``.
- 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.
- 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.).
- Include all **error messages and tracebacks** in the report.
- If not obvious, describe **which behavior you expected**
instead of what actually happened.
- If you think an issue has been closed accidentally or inappropriately, feel
free to reopen it.
Writing Code or Improving the Documentation
-------------------------------------------
Changes of the Instaloader source can be proposed as a
`Pull Request <https://github.com/instaloader/instaloader/pulls>`__. There are only
few things to consider:
- Sometimes, the most current code is not in the ``master`` branch. Check that
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
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``.
- 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
project.
Suggesting Features
-------------------
If you have an idea of how Instaloader should be enhanced, but do not want to
implement the feature yourself, feel free to open a ticket in our
`Issue Tracker <https://github.com/instaloader/instaloader/issues>`__.
Please consider the following:
- Instaloader already has plenty of features. **Check the documentation**
beforehand to ensure your desired suggestion is not already implemented.
- Briefly **ensure that your idea has not already been suggested**. If you find
an issue suggesting the same or a similar feature, share your thoughts in a
comment there, instead of opening a new issue.
- If possible, provide us a **use case of the feature**: How could the user
invoke the new function? Which problem would it solve? If new information is
obtained, how would it be further processed?
- **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.
+22 -6
View File
@@ -1,6 +1,14 @@
.. meta::
:description:
Command line tool to download pictures (and videos) from Instagram.
Instaloader downloads public and private profiles, hashtags, user stories,
feeds, saved media, comments, geotags, captions and other metadata of each post.
Instaloader Instaloader
=========== ===========
.. highlight:: none
**Instaloader** is a tool to download pictures (or videos) along with **Instaloader** is a tool to download pictures (or videos) along with
their captions and other metadata from Instagram. their captions and other metadata from Instagram.
@@ -12,8 +20,8 @@ With `Python <https://www.python.org/>`__ installed, do::
**Instaloader** **Instaloader**
- downloads **public and private profiles, hashtags, user stories and - downloads **public and private profiles, hashtags, user stories,
feeds**, feeds and saved media**,
- downloads **comments, geotags and captions** of each post, - downloads **comments, geotags and captions** of each post,
@@ -23,9 +31,16 @@ With `Python <https://www.python.org/>`__ installed, do::
- allows **fine-grained customization** of filters and where to store - allows **fine-grained customization** of filters and where to store
downloaded media, downloaded media,
- is free `open source <https://github.com/Thammus/instaloader>`__ - is free `open source <https://github.com/instaloader/instaloader>`__
software written in Python. software written in Python.
::
instaloader [--comments] [--geotags] [--stories]
[--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" |
:stories | :feed | :saved
Table of Contents Table of Contents
----------------- -----------------
@@ -37,14 +52,15 @@ Table of Contents
basic-usage basic-usage
cli-options cli-options
as-module as-module
contributing
Useful Links Useful Links
------------ ------------
- `Git Repository (on GitHub) <https://github.com/Thammus/instaloader>`__ - `Git Repository (on GitHub) <https://github.com/instaloader/instaloader>`__
- `PyPI Project Page <https://pypi.python.org/pypi/instaloader>`__ - `PyPI Project Page <https://pypi.python.org/pypi/instaloader>`__
- `Issue Tracker / Bug Tracker <https://github.com/Thammus/instaloader/issues>`__ - `Issue Tracker / Bug Tracker <https://github.com/instaloader/instaloader/issues>`__
- `Version History <https://github.com/Thammus/instaloader/releases>`__ - `Version History <https://github.com/instaloader/instaloader/releases>`__
.. include:: ../README.rst .. include:: ../README.rst
:start-after: disclaimer-start :start-after: disclaimer-start
+14 -6
View File
@@ -12,11 +12,11 @@ installed, you may install Instaloader using
pip3 install instaloader pip3 install instaloader
Alternatively, to get the most current version of Instaloader from our Alternatively, to get the most current version of Instaloader from our
`Git repository <https://github.com/Thammus/instaloader>`__: `Git repository <https://github.com/instaloader/instaloader>`__:
:: ::
pip3 install git+https://github.com/Thammus/instaloader pip3 install git+https://github.com/instaloader/instaloader
(pass ``--upgrade`` to upgrade if Instaloader is already installed) (pass ``--upgrade`` to upgrade if Instaloader is already installed)
@@ -24,7 +24,15 @@ Instaloader requires
`requests <http://python-requests.org/>`__, which `requests <http://python-requests.org/>`__, which
will be installed automatically, if it is not already installed. will be installed automatically, if it is not already installed.
If you do not want to use pip, even though it is highly recommended, **Alternative methods for installing Instaloader:**
and prefer installing Instaloader manually,
`Download the Source <https://github.com/Thammus/instaloader/releases>`__, - If you do not want to use pip, even though it is highly recommended,
extract the Zip or Tarball and execute ``instaloader.py`` from there. and prefer to **install Instaloader manually**,
`Download the Source <https://github.com/instaloader/instaloader/releases>`__,
extract the Zip or Tarball and execute ``instaloader.py`` from there.
- On **Arch Linux**, you may install Instaloader using the
`Instaloader AUR package <https://aur.archlinux.org/packages/instaloader/>`__.
- On **Gentoo Linux**, you may install Instaloader using the
`Instaloader Ebuild <https://bugs.gentoo.org/642100>`__.
+2
View File
@@ -1 +1,3 @@
requests
sphinx
sphinx-autodoc-typehints sphinx-autodoc-typehints
+305 -111
View File
@@ -22,14 +22,14 @@ from datetime import datetime
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
import requests import requests
import requests.utils import requests.utils
import urllib3 import urllib3
__version__ = '3.2' __version__ = '3.3'
try: try:
@@ -53,6 +53,10 @@ class QueryReturnedNotFoundException(InstaloaderException):
pass pass
class QueryReturnedForbiddenException(InstaloaderException):
pass
class ProfileNotExistsException(InstaloaderException): class ProfileNotExistsException(InstaloaderException):
pass pass
@@ -267,10 +271,15 @@ class Post:
return int(self._field('owner', 'id')) return int(self._field('owner', 'id'))
@property @property
def date(self) -> datetime: def date_local(self) -> datetime:
"""Timestamp when the post was created.""" """Timestamp when the post was created (local time zone)."""
return datetime.fromtimestamp(self._node["date"] if "date" in self._node else self._node["taken_at_timestamp"]) return datetime.fromtimestamp(self._node["date"] if "date" in self._node else self._node["taken_at_timestamp"])
@property
def date_utc(self) -> datetime:
"""Timestamp when the post was created (UTC)."""
return datetime.utcfromtimestamp(self._node["date"] if "date" in self._node else self._node["taken_at_timestamp"])
@property @property
def url(self) -> str: def url(self) -> str:
"""URL of the picture / video thumbnail of the post""" """URL of the picture / video thumbnail of the post"""
@@ -355,7 +364,11 @@ class Post:
return self._field('edge_media_to_comment', 'count') return self._field('edge_media_to_comment', 'count')
def get_comments(self) -> Iterator[Dict[str, Any]]: def get_comments(self) -> Iterator[Dict[str, Any]]:
"""Iterate over all comments of the post.""" """Iterate over all comments of the post.
Each comment is represented by a dictionary having the keys text, created_at, id and owner, which is a
dictionary with keys username, profile_pic_url and id.
"""
if self.comments == 0: if self.comments == 0:
# Avoid doing additional requests if there are no comments # Avoid doing additional requests if there are no comments
return return
@@ -367,6 +380,23 @@ class Post:
'https://www.instagram.com/p/' + self.shortcode + '/', 'https://www.instagram.com/p/' + self.shortcode + '/',
lambda d: d['data']['shortcode_media']['edge_media_to_comment']) lambda d: d['data']['shortcode_media']['edge_media_to_comment'])
def get_likes(self) -> Iterator[Dict[str, Any]]:
"""Iterate over all likes of the post.
Each like is represented by a dictionary having the keys username, followed_by_viewer, id, is_verified,
requested_by_viewer, followed_by_viewer, profile_pic_url.
"""
if self.likes == 0:
# Avoid doing additional requests if there are no comments
return
likes_edges = self._field('edge_media_preview_like', 'edges')
if self.likes == len(likes_edges):
# If the Post's metadata already contains all likes, don't do GraphQL requests to obtain them
yield from (like['node'] for like in likes_edges)
yield from self._instaloader.graphql_node_list("1cb6ec562846122743b61e492c85999f", {'shortcode': self.shortcode},
'https://www.instagram.com/p/' + self.shortcode + '/',
lambda d: d['data']['shortcode_media']['edge_liked_by'])
def get_location(self) -> Optional[Dict[str, str]]: def get_location(self) -> Optional[Dict[str, str]]:
"""If the Post has a location, returns a dictionary with fields 'lat' and 'lng'.""" """If the Post has a location, returns a dictionary with fields 'lat' and 'lng'."""
loc_dict = self._field("location") loc_dict = self._field("location")
@@ -410,12 +440,15 @@ class Tristate(Enum):
class Instaloader: class Instaloader:
GRAPHQL_PAGE_LENGTH = 200
def __init__(self, def __init__(self,
sleep: bool = True, quiet: bool = False, sleep: bool = True, quiet: bool = False,
user_agent: Optional[str] = None, user_agent: Optional[str] = None,
dirname_pattern: Optional[str] = None, dirname_pattern: Optional[str] = None,
filename_pattern: Optional[str] = None, filename_pattern: Optional[str] = None,
download_videos: Tristate = Tristate.always, download_videos: Tristate = Tristate.always,
download_video_thumbnails: Tristate = Tristate.always,
download_geotags: Tristate = Tristate.no_extra_query, download_geotags: Tristate = Tristate.no_extra_query,
save_captions: Tristate = Tristate.no_extra_query, save_captions: Tristate = Tristate.no_extra_query,
download_comments: Tristate = Tristate.no_extra_query, download_comments: Tristate = Tristate.no_extra_query,
@@ -429,9 +462,19 @@ class Instaloader:
self.sleep = sleep self.sleep = sleep
self.quiet = quiet self.quiet = quiet
self.dirname_pattern = dirname_pattern if dirname_pattern is not None else '{target}' self.dirname_pattern = dirname_pattern if dirname_pattern is not None else '{target}'
self.filename_pattern = filename_pattern.replace('{date}', '{date:%Y-%m-%d_%H-%M-%S}') \ if filename_pattern is not None:
if filename_pattern is not None else '{date:%Y-%m-%d_%H-%M-%S}' filename_pattern = re.sub(r"({(?:post\.)?date)([:}])", r"\1_utc\2", filename_pattern)
self.filename_pattern_old = filename_pattern.replace('{date_utc}', '{date_utc:%Y-%m-%d_%H-%M-%S}')
self.filename_pattern_old = re.sub(r"(?i)({(?:post\.)?date_utc:[^}]*?)_UTC",
r"\1", self.filename_pattern_old)
filename_pattern = re.sub(r"(?i)({(date_utc|post\.date_utc):(?![^}]*UTC[^}]*).*?)}",
r"\1_UTC}", filename_pattern)
self.filename_pattern = filename_pattern.replace('{date_utc}', '{date_utc:%Y-%m-%d_%H-%M-%S_UTC}')
else:
self.filename_pattern = '{date_utc:%Y-%m-%d_%H-%M-%S_UTC}'
self.filename_pattern_old = '{date_utc:%Y-%m-%d_%H-%M-%S}'
self.download_videos = download_videos self.download_videos = download_videos
self.download_video_thumbnails = download_video_thumbnails
self.download_geotags = download_geotags self.download_geotags = download_geotags
self.save_captions = save_captions self.save_captions = save_captions
self.download_comments = download_comments self.download_comments = download_comments
@@ -454,7 +497,9 @@ class Instaloader:
"""Yield an anonymous, otherwise equally-configured copy of an Instaloader instance; Then copy its error log.""" """Yield an anonymous, otherwise equally-configured copy of an Instaloader instance; Then copy its error log."""
new_loader = Instaloader(self.sleep, self.quiet, self.user_agent, new_loader = Instaloader(self.sleep, self.quiet, self.user_agent,
self.dirname_pattern, self.filename_pattern, self.dirname_pattern, self.filename_pattern,
self.download_videos, self.download_geotags, self.download_videos,
self.download_video_thumbnails,
self.download_geotags,
self.save_captions, self.download_comments, self.save_captions, self.download_comments,
self.save_metadata, self.max_connection_attempts) self.save_metadata, self.max_connection_attempts)
new_loader.previous_queries = self.previous_queries new_loader.previous_queries = self.previous_queries
@@ -507,6 +552,9 @@ class Instaloader:
resp.raw.decode_content = True resp.raw.decode_content = True
shutil.copyfileobj(resp.raw, file) shutil.copyfileobj(resp.raw, file)
else: else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
if resp.status_code == 404: if resp.status_code == 404:
# 404 not worth retrying. # 404 not worth retrying.
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url)) raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
@@ -626,12 +674,12 @@ class Instaloader:
session.headers.update(self._default_http_header(empty_session_only=True)) session.headers.update(self._default_http_header(empty_session_only=True))
return session return session
def graphql_query(self, query_id: int, variables: Dict[str, Any], def graphql_query(self, query_identifier: Union[int, str], variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]: referer: Optional[str] = None) -> Dict[str, Any]:
""" """
Do a GraphQL Query. Do a GraphQL Query.
:param query_id: Query ID. :param query_identifier: Query ID or Hash.
:param variables: Variables for the Query. :param variables: Variables for the Query.
:param referer: HTTP Referer, or None. :param referer: HTTP Referer, or None.
:return: The server's response dictionary. :return: The server's response dictionary.
@@ -645,8 +693,9 @@ class Instaloader:
tmpsession.headers['accept'] = '*/*' tmpsession.headers['accept'] = '*/*'
if referer is not None: if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer) tmpsession.headers['referer'] = urllib.parse.quote(referer)
resp_json = self.get_json('graphql/query', params={'query_id': query_id, resp_json = self.get_json('graphql/query',
'variables': json.dumps(variables, separators=(',', ':'))}, params={'query_id' if isinstance(query_identifier, int) else 'query_hash': query_identifier,
'variables': json.dumps(variables, separators=(',', ':'))},
session=tmpsession) session=tmpsession)
if 'status' not in resp_json: if 'status' not in resp_json:
self.error("GraphQL response did not contain a \"status\" field.") self.error("GraphQL response did not contain a \"status\" field.")
@@ -673,17 +722,18 @@ class Instaloader:
his/her username. To get said ID, given the profile's name, you may call this function.""" his/her username. To get said ID, given the profile's name, you may call this function."""
return int(self.get_profile_metadata(profile)['user']['id']) return int(self.get_profile_metadata(profile)['user']['id'])
def graphql_node_list(self, query_id: int, query_variables: Dict[str, Any], query_referer: Optional[str], def graphql_node_list(self, query_identifier: Union[int, str], query_variables: Dict[str, Any],
query_referer: Optional[str],
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Iterator[Dict[str, Any]]: edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Iterator[Dict[str, Any]]:
"""Retrieve a list of GraphQL nodes.""" """Retrieve a list of GraphQL nodes."""
query_variables['first'] = 500 query_variables['first'] = Instaloader.GRAPHQL_PAGE_LENGTH
data = self.graphql_query(query_id, query_variables, query_referer) data = self.graphql_query(query_identifier, query_variables, query_referer)
while True: while True:
edge_struct = edge_extractor(data) edge_struct = edge_extractor(data)
yield from [edge['node'] for edge in edge_struct['edges']] yield from [edge['node'] for edge in edge_struct['edges']]
if edge_struct['page_info']['has_next_page']: if edge_struct['page_info']['has_next_page']:
query_variables['after'] = edge_struct['page_info']['end_cursor'] query_variables['after'] = edge_struct['page_info']['end_cursor']
data = self.graphql_query(query_id, query_variables, query_referer) data = self.graphql_query(query_identifier, query_variables, query_referer)
else: else:
break break
@@ -712,17 +762,24 @@ class Instaloader:
lambda d: d['data']['user']['edge_follow']) lambda d: d['data']['user']['edge_follow'])
def download_pic(self, filename: str, url: str, mtime: datetime, def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None) -> bool: filename_alt: Optional[str] = None, filename_suffix: Optional[str] = None) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp. """Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated.""" Returns true, if file was actually downloaded, i.e. updated."""
urlmatch = re.search('\\.[a-z0-9]*\\?', url) urlmatch = re.search('\\.[a-z0-9]*\\?', url)
file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1] file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1]
if filename_suffix is not None: if filename_suffix is not None:
filename += '_' + filename_suffix filename += '_' + filename_suffix
if filename_alt is not None:
filename_alt += '_' + filename_suffix
filename += '.' + file_extension filename += '.' + file_extension
if os.path.isfile(filename): if os.path.isfile(filename):
self._log(filename + ' exists', end=' ', flush=True) self._log(filename + ' exists', end=' ', flush=True)
return False return False
if filename_alt is not None:
filename_alt += '.' + file_extension
if os.path.isfile(filename_alt):
self._log(filename_alt + 'exists', end=' ', flush=True)
return False
self._get_and_write_raw(url, filename) self._get_and_write_raw(url, filename)
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp())) os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
return True return True
@@ -733,15 +790,20 @@ class Instaloader:
json.dump(post, fp=open(filename, 'w'), indent=4, default=Post.json_encoder) json.dump(post, fp=open(filename, 'w'), indent=4, default=Post.json_encoder)
self._log('json', end=' ', flush=True) self._log('json', end=' ', flush=True)
def update_comments(self, filename: str, post: Post) -> None: def update_comments(self, filename: str, post: Post, filename_alt: Optional[str] = None) -> None:
filename += '_comments.json'
try: try:
comments = json.load(open(filename)) filename_current = filename + '_comments.json'
comments = json.load(open(filename_current))
except FileNotFoundError: except FileNotFoundError:
comments = list() try:
filename_current = filename_alt + '_comments.json'
comments = json.load(open(filename_current))
except (FileNotFoundError, TypeError):
filename_current = filename + '_comments.json'
comments = list()
comments.extend(post.get_comments()) comments.extend(post.get_comments())
if comments: if comments:
with open(filename, 'w') as file: with open(filename_current, 'w') as file:
comments_list = sorted(sorted(list(comments), key=lambda t: t['id']), comments_list = sorted(sorted(list(comments), key=lambda t: t['id']),
key=lambda t: t['created_at'], reverse=True) key=lambda t: t['created_at'], reverse=True)
unique_comments_list = [comments_list[0]] unique_comments_list = [comments_list[0]]
@@ -754,17 +816,25 @@ class Instaloader:
if x['id'] != y['id']: if x['id'] != y['id']:
unique_comments_list.append(y) unique_comments_list.append(y)
file.write(json.dumps(unique_comments_list, indent=4)) file.write(json.dumps(unique_comments_list, indent=4))
os.rename(filename_current, filename + '_comments.json')
self._log('comments', end=' ', flush=True) self._log('comments', end=' ', flush=True)
def save_caption(self, filename: str, mtime: datetime, caption: str) -> None: def save_caption(self, filename: str, mtime: datetime, caption: str, filename_alt: Optional[str] = None) -> None:
"""Updates picture caption""" """Updates picture caption"""
filename += '.txt' filename += '.txt'
if filename_alt is not None:
filename_alt += '.txt'
pcaption = caption.replace('\n', ' ').strip() pcaption = caption.replace('\n', ' ').strip()
caption = caption.encode("UTF-8") caption = caption.encode("UTF-8")
pcaption = '[' + ((pcaption[:29] + u"\u2026") if len(pcaption) > 31 else pcaption) + ']' pcaption = '[' + ((pcaption[:29] + u"\u2026") if len(pcaption) > 31 else pcaption) + ']'
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
with open(filename, 'rb') as file: try:
file_caption = file.read() with open(filename, 'rb') as file:
file_caption = file.read()
except FileNotFoundError:
if filename_alt is not None:
with open(filename_alt, 'rb') as file:
file_caption = file.read()
if file_caption.replace(b'\r\n', b'\n') == caption.replace(b'\r\n', b'\n'): if file_caption.replace(b'\r\n', b'\n') == caption.replace(b'\r\n', b'\n'):
try: try:
self._log(pcaption + ' unchanged', end=' ', flush=True) self._log(pcaption + ' unchanged', end=' ', flush=True)
@@ -772,15 +842,22 @@ class Instaloader:
self._log('txt unchanged', end=' ', flush=True) self._log('txt unchanged', end=' ', flush=True)
return None return None
else: else:
def get_filename(index): def get_filename(file, index):
return filename if index == 0 else (filename[:-4] + '_old_' + return file if index == 0 else (file[:-4] + '_old_' +
(str(0) if index < 10 else str()) + str(index) + filename[-4:]) (str(0) if index < 10 else str()) + str(index) + file[-4:])
i = 0 i = 0
while os.path.isfile(get_filename(i)): file_exists_list = []
while True:
file_exists_list.append(1 if os.path.isfile(get_filename(filename, i)) else 0)
if not file_exists_list[i] and filename_alt is not None:
file_exists_list[i] = 2 if os.path.isfile(get_filename(filename_alt, i)) else 0
if not file_exists_list[i]:
break
i = i + 1 i = i + 1
for index in range(i, 0, -1): for index in range(i, 0, -1):
os.rename(get_filename(index - 1), get_filename(index)) os.rename(get_filename(filename if file_exists_list[index - 1] % 2 else filename_alt, index - 1),
get_filename(filename, index))
try: try:
self._log(pcaption + ' updated', end=' ', flush=True) self._log(pcaption + ' updated', end=' ', flush=True)
except UnicodeEncodeError: except UnicodeEncodeError:
@@ -803,8 +880,11 @@ class Instaloader:
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp())) os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
self._log('geo', end=' ', flush=True) self._log('geo', end=' ', flush=True)
def download_profilepic(self, name: str, url: str) -> None: def download_profilepic(self, name: str, profile_metadata: Dict[str, Any]) -> None:
"""Downloads and saves profile pic with given url.""" """Downloads and saves profile pic."""
url = profile_metadata["user"]["profile_pic_url_hd"] if "profile_pic_url_hd" in profile_metadata["user"] \
else profile_metadata["user"]["profile_pic_url"]
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')
@@ -822,8 +902,13 @@ class Instaloader:
if os.path.isfile(filename): if os.path.isfile(filename):
self._log(filename + ' already exists') self._log(filename + ' already exists')
return None return None
url = re.sub(r'/s([1-9][0-9]{2})x\1/', '/s2048x2048/', url) url_best = re.sub(r'/s([1-9][0-9]{2})x\1/', '/s2048x2048/', url)
self._get_and_write_raw(url, filename) url_best = re.sub(r'/vp/[a-f0-9]{32}/[A-F0-9]{8}/', '/', url_best) # remove signature
try:
self._get_and_write_raw(url_best, filename)
except (QueryReturnedForbiddenException, QueryReturnedNotFoundException) as err:
self.error('{} Retrying with lower quality version.'.format(err))
self._get_and_write_raw(url, filename)
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp())) os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
self._log('') # log output of _get_and_write_raw() does not produce \n self._log('') # log output of _get_and_write_raw() does not produce \n
@@ -858,11 +943,10 @@ class Instaloader:
self.session = session self.session = session
self.username = username self.username = username
def test_login(self, session: Optional[requests.Session]) -> Optional[str]: def test_login(self) -> Optional[str]:
"""Returns the Instagram username to which given :class:`requests.Session` object belongs, or None.""" """Returns the Instagram username to which given :class:`requests.Session` object belongs, or None."""
if session: data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})
data = self.get_json('', params={'__a': 1}, session=session) return data["data"]["user"]["username"] if "username" in data["data"]["user"] else None
return data['graphql']['user']['username'] if 'graphql' in data else None
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"""
@@ -879,10 +963,12 @@ class Instaloader:
data={'password': passwd, 'username': user}, allow_redirects=True) data={'password': passwd, 'username': user}, allow_redirects=True)
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']}) session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
if login.status_code == 200: if login.status_code == 200:
if user == self.test_login(session): self.session = session
if user == self.test_login():
self.username = user self.username = user
self.session = session
else: else:
self.username = None
self.session = None
raise BadCredentialsException('Login error! Check your credentials!') raise BadCredentialsException('Login error! Check your credentials!')
else: else:
raise ConnectionException('Login error! Connection error!') raise ConnectionException('Login error! Connection error!')
@@ -903,53 +989,68 @@ class Instaloader:
profilename = post.owner_username if needs_profilename else None profilename = post.owner_username if needs_profilename else None
dirname = self.dirname_pattern.format(profile=profilename, target=target.lower()) dirname = self.dirname_pattern.format(profile=profilename, target=target.lower())
filename = dirname + '/' + self.filename_pattern.format(profile=profilename, target=target.lower(), filename = dirname + '/' + self.filename_pattern.format(profile=profilename, target=target.lower(),
date=post.date, shortcode=post.shortcode, date_utc=post.date_utc,
shortcode=post.shortcode,
post=post) post=post)
filename_old = dirname + '/' + self.filename_pattern_old.replace("{post.date_utc", "{date_utc") \
.format(profile=profilename, target=target.lower(),
date_utc=post.date_local,
shortcode=post.shortcode,
post=post)
os.makedirs(os.path.dirname(filename), exist_ok=True) os.makedirs(os.path.dirname(filename), exist_ok=True)
# Download the image(s) / video thumbnail and videos within sidecars if desired # Download the image(s) / video thumbnail and videos within sidecars if desired
downloaded = False
if post.typename == 'GraphSidecar': if post.typename == 'GraphSidecar':
edge_number = 1 edge_number = 1
downloaded = True
for edge in post.get_sidecar_edges(): for edge in post.get_sidecar_edges():
edge_downloaded = self.download_pic(filename=filename, # Download picture or video thumbnail
if not edge['node']['is_video'] or self.download_video_thumbnails is Tristate.always:
downloaded |= self.download_pic(filename=filename,
filename_alt=filename_old,
url=edge['node']['display_url'], url=edge['node']['display_url'],
mtime=post.date, mtime=post.date_local,
filename_suffix=str(edge_number)) filename_suffix=str(edge_number))
# Additionally download video if available and desired # Additionally download video if available and desired
if edge['node']['is_video'] and self.download_videos is Tristate.always: if edge['node']['is_video'] and self.download_videos is Tristate.always:
self.download_pic(filename=filename, downloaded |= self.download_pic(filename=filename,
url=edge['node']['video_url'], filename_alt=filename_old,
mtime=post.date, url=edge['node']['video_url'],
filename_suffix=str(edge_number)) mtime=post.date_local,
downloaded = downloaded and edge_downloaded filename_suffix=str(edge_number))
edge_number += 1 edge_number += 1
elif post.typename in ['GraphImage', 'GraphVideo']: elif post.typename == 'GraphImage':
downloaded = self.download_pic(filename=filename, url=post.url, mtime=post.date) downloaded = self.download_pic(filename=filename, filename_alt=filename_old,
url=post.url, mtime=post.date_local)
elif post.typename == 'GraphVideo':
if self.download_video_thumbnails is Tristate.always:
downloaded = self.download_pic(filename=filename, filename_alt=filename_old,
url=post.url, mtime=post.date_local)
else: else:
self.error("Warning: {0} has unknown typename: {1}".format(post, post.typename)) self.error("Warning: {0} has unknown typename: {1}".format(post, post.typename))
downloaded = False
# Save caption if desired # Save caption if desired
if self.save_captions is not Tristate.never: if self.save_captions is not Tristate.never:
if post.caption: if post.caption:
self.save_caption(filename, post.date, post.caption) self.save_caption(filename=filename, filename_alt=filename_old,
mtime=post.date_local, caption=post.caption)
else: else:
self._log("<no caption>", end=' ', flush=True) self._log("<no caption>", end=' ', flush=True)
# Download video if desired # Download video if desired
if post.is_video and self.download_videos is Tristate.always: if post.is_video and self.download_videos is Tristate.always:
self.download_pic(filename=filename, url=post.video_url, mtime=post.date) downloaded |= self.download_pic(filename=filename, filename_alt=filename_old,
url=post.video_url, mtime=post.date_local)
# Download geotags if desired # Download geotags if desired
if self.download_geotags is Tristate.always: if self.download_geotags is Tristate.always:
location = post.get_location() location = post.get_location()
if location: if location:
self.save_location(filename, location, post.date) self.save_location(filename, location, post.date_local)
# Update comments if desired # Update comments if desired
if self.download_comments is Tristate.always: if self.download_comments is Tristate.always:
self.update_comments(filename, post) self.update_comments(filename=filename, filename_alt=filename_old, post=post)
# Save metadata as JSON if desired. It might require an extra query, depending on which information has been # Save metadata as JSON if desired. It might require an extra query, depending on which information has been
# already obtained. Regarding Tristate interpretation, we always assume that it requires an extra query. # already obtained. Regarding Tristate interpretation, we always assume that it requires an extra query.
@@ -1025,44 +1126,61 @@ class Instaloader:
for item in user_stories["items"]: for item in user_stories["items"]:
self._log("[%3i/%3i] " % (count, totalcount), end="", flush=True) self._log("[%3i/%3i] " % (count, totalcount), end="", flush=True)
count += 1 count += 1
with self._error_catcher('Download story from user {}'.format(name)):
shortcode = item["code"] if "code" in item else "no_code" downloaded = self.download_story(item, filename_target, name)
date = datetime.fromtimestamp(item["taken_at"])
dirname = self.dirname_pattern.format(profile=name, target=filename_target)
filename = dirname + '/' + self.filename_pattern.format(profile=name, target=filename_target,
date=date,
shortcode=shortcode)
os.makedirs(os.path.dirname(filename), exist_ok=True)
with self._error_catcher('Download story {} from user {}'.format(shortcode, name)):
if "image_versions2" in item:
url = item["image_versions2"]["candidates"][0]["url"]
downloaded = self.download_pic(filename=filename,
url=url,
mtime=date)
else:
self._log("Warning: Unable to find story image.")
downloaded = False
if "caption" in item and item["caption"] is not None and \
self.save_captions is not Tristate.never:
caption = item["caption"]
if isinstance(caption, dict) and "text" in caption:
caption = caption["text"]
self.save_caption(filename, date, caption)
else:
self._log("<no caption>", end=' ', flush=True)
if "video_versions" in item and self.download_videos is Tristate.always:
downloaded = self.download_pic(filename=filename,
url=item["video_versions"][0]["url"],
mtime=date)
if item["story_locations"] and self.download_geotags is not Tristate.never:
location = item["story_locations"][0]["location"]
if location:
self.save_location(filename, location, date)
self._log()
if fast_update and not downloaded: if fast_update and not downloaded:
break break
def download_story(self, item: Dict[str, Any], target: str, profile: str) -> bool:
"""Download one user story.
:param item: Story item, as in story['items'] for story in :meth:`get_stories`
:param target: Replacement for {target} in dirname_pattern and filename_pattern
:param profile: Owner profile name
:return: True if something was downloaded, False otherwise, i.e. file was already there
"""
shortcode = item["code"] if "code" in item else "no_code"
date_local = datetime.fromtimestamp(item["taken_at"])
date_utc = datetime.utcfromtimestamp(item["taken_at"])
dirname = self.dirname_pattern.format(profile=profile, target=target)
filename = dirname + '/' + self.filename_pattern.format(profile=profile, target=target,
date_utc=date_utc,
shortcode=shortcode)
filename_old = dirname + '/' + self.filename_pattern_old.format(profile=profile, target=target,
date_utc=date_local,
shortcode=shortcode)
os.makedirs(os.path.dirname(filename), exist_ok=True)
downloaded = False
if "image_versions2" in item:
if "video_versions" not in item or self.download_video_thumbnails is Tristate.always:
url = item["image_versions2"]["candidates"][0]["url"]
downloaded = self.download_pic(filename=filename,
filename_alt=filename_old,
url=url,
mtime=date_local)
else:
self._log("Warning: Unable to find story image.")
if "caption" in item and item["caption"] is not None and \
self.save_captions is not Tristate.never:
caption = item["caption"]
if isinstance(caption, dict) and "text" in caption:
caption = caption["text"]
self.save_caption(filename=filename, filename_alt=filename_old, mtime=date_local, caption=caption)
else:
self._log("<no caption>", end=' ', flush=True)
if "video_versions" in item and self.download_videos is Tristate.always:
downloaded |= self.download_pic(filename=filename,
filename_alt=filename_old,
url=item["video_versions"][0]["url"],
mtime=date_local)
if item["story_locations"] and self.download_geotags is not Tristate.never:
location = item["story_locations"][0]["location"]
if location:
self.save_location(filename, location, date_local)
self._log()
return downloaded
def get_feed_posts(self) -> Iterator[Post]: def get_feed_posts(self) -> Iterator[Post]:
"""Get Posts of the user's feed.""" """Get Posts of the user's feed."""
@@ -1122,6 +1240,57 @@ class Instaloader:
if fast_update and not downloaded: if fast_update and not downloaded:
break break
def get_saved_posts(self) -> Iterator[Post]:
"""Get Posts that are marked as saved by the user."""
data = self.get_profile_metadata(self.username)
user_id = data["user"]["id"]
while True:
if "graphql" in data:
is_edge = True
saved_media = data["graphql"]["user"]["edge_saved_media"]
elif "data" in data:
is_edge = True
saved_media = data["data"]["user"]["edge_saved_media"]
else:
is_edge = False
saved_media = data["user"]["saved_media"]
if is_edge:
yield from (Post(self, edge["node"]) for edge in saved_media["edges"])
else:
yield from (Post(self, node) for node in saved_media["nodes"])
if not saved_media["page_info"]["has_next_page"]:
break
data = self.graphql_query("f883d95537fbcd400f466f63d42bd8a1",
{'id': user_id, 'first': Instaloader.GRAPHQL_PAGE_LENGTH,
'after': saved_media["page_info"]["end_cursor"]})
def download_saved_posts(self, max_count: int = None, fast_update: bool = False,
filter_func: Optional[Callable[[Post], bool]] = None) -> None:
"""Download user's saved pictures.
:param max_count: Maximum count of pictures to download
:param fast_update: If true, abort when first already-downloaded picture is encountered
:param filter_func: function(post), which returns True if given picture should be downloaded
"""
count = 1
for post in self.get_saved_posts():
if max_count is not None and count > max_count:
break
name = post.owner_username
if filter_func is not None and not filter_func(post):
self._log("<pic by {} skipped".format(name), flush=True)
continue
self._log("[{:>3}] {} ".format(count, name), end=str(), flush=True)
count += 1
with self._error_catcher('Download saved posts'):
downloaded = self.download_post(post, target=':saved')
if fast_update and not downloaded:
break
def get_hashtag_posts(self, hashtag: str) -> Iterator[Post]: def get_hashtag_posts(self, hashtag: str) -> Iterator[Post]:
"""Get Posts associated with a #hashtag.""" """Get Posts associated with a #hashtag."""
yield from (Post(self, node) for node in yield from (Post(self, node) for node in
@@ -1230,7 +1399,7 @@ class Instaloader:
# We do not use self.graphql_node_list() here, because profile_metadata # We do not use self.graphql_node_list() here, because profile_metadata
# lets us obtain the first 12 nodes 'for free' # lets us obtain the first 12 nodes 'for free'
data = self.graphql_query(17888483320059182, {'id': profile_metadata['user']['id'], data = self.graphql_query(17888483320059182, {'id': profile_metadata['user']['id'],
'first': 500, 'first': Instaloader.GRAPHQL_PAGE_LENGTH,
'after': end_cursor}, 'after': end_cursor},
'https://www.instagram.com/{0}/'.format(profile_name)) 'https://www.instagram.com/{0}/'.format(profile_name))
media = data['data']['user']['edge_owner_to_timeline_media'] media = data['data']['user']['edge_owner_to_timeline_media']
@@ -1240,7 +1409,8 @@ class Instaloader:
end_cursor = media['page_info']['end_cursor'] end_cursor = media['page_info']['end_cursor']
def download_profile(self, name: str, def download_profile(self, name: str,
profile_pic_only: bool = False, fast_update: bool = False, profile_pic: bool = True, profile_pic_only: bool = False,
fast_update: bool = False,
download_stories: bool = False, download_stories_only: bool = False, download_stories: bool = False, download_stories_only: bool = False,
filter_func: Optional[Callable[[Post], bool]] = None) -> None: filter_func: Optional[Callable[[Post], bool]] = None) -> None:
"""Download one profile""" """Download one profile"""
@@ -1261,8 +1431,9 @@ class Instaloader:
profile_metadata = self.get_profile_metadata(name) profile_metadata = self.get_profile_metadata(name)
# Download profile picture # Download profile picture
with self._error_catcher('Download profile picture of {}'.format(name)): if profile_pic or profile_pic_only:
self.download_profilepic(name, profile_metadata["user"]["profile_pic_url"]) with self._error_catcher('Download profile picture of {}'.format(name)):
self.download_profilepic(name, profile_metadata)
if profile_pic_only: if profile_pic_only:
return return
@@ -1320,7 +1491,8 @@ class Instaloader:
def main(self, profilelist: List[str], username: Optional[str] = None, password: Optional[str] = None, def main(self, profilelist: List[str], username: Optional[str] = None, password: Optional[str] = None,
sessionfile: Optional[str] = None, max_count: Optional[int] = None, sessionfile: Optional[str] = None, max_count: Optional[int] = None,
profile_pic_only: bool = False, fast_update: bool = False, profile_pic: bool = True, profile_pic_only: bool = False,
fast_update: bool = False,
stories: bool = False, stories_only: bool = False, stories: bool = False, stories_only: bool = False,
filter_str: Optional[str] = None) -> None: filter_str: Optional[str] = None) -> None:
"""Download set of profiles, hashtags etc. and handle logging in and session files if desired.""" """Download set of profiles, hashtags etc. and handle logging in and session files if desired."""
@@ -1338,7 +1510,7 @@ class Instaloader:
if sessionfile is not None: if sessionfile is not None:
print(err, file=sys.stderr) print(err, file=sys.stderr)
self._log("Session file does not exist yet - Logging in.") self._log("Session file does not exist yet - Logging in.")
if not self.is_logged_in or username != self.test_login(self.session): if not self.is_logged_in or username != self.test_login():
if password is not None: if password is not None:
self.login(username, password) self.login(username, password)
else: else:
@@ -1376,6 +1548,14 @@ class Instaloader:
self.download_stories(fast_update=fast_update) self.download_stories(fast_update=fast_update)
else: else:
self.error("--login=USERNAME required to download {}.".format(pentry)) self.error("--login=USERNAME required to download {}.".format(pentry))
elif pentry == ":saved":
if username is not None:
self._log("Retrieving saved posts...")
with self._error_catcher():
self.download_saved_posts(fast_update=fast_update, max_count=max_count,
filter_func=filter_func)
else:
self.error("--login=USERNAME required to download {}.".format(pentry))
else: else:
targets.add(pentry) targets.add(pentry)
if len(targets) > 1: if len(targets) > 1:
@@ -1384,7 +1564,7 @@ class Instaloader:
for target in targets: for target in targets:
with self._error_catcher(): with self._error_catcher():
try: try:
self.download_profile(target, profile_pic_only, fast_update, stories, stories_only, self.download_profile(target, profile_pic, profile_pic_only, fast_update, stories, stories_only,
filter_func=filter_func) filter_func=filter_func)
except ProfileNotExistsException as err: except ProfileNotExistsException as err:
if username is not None: if username is not None:
@@ -1392,8 +1572,8 @@ class Instaloader:
self._log("Trying again anonymously, helps in case you are just blocked.") self._log("Trying again anonymously, helps in case you are just blocked.")
with self.anonymous_copy() as anonymous_loader: with self.anonymous_copy() as anonymous_loader:
with self._error_catcher(): with self._error_catcher():
anonymous_loader.download_profile(target, profile_pic_only, fast_update, anonymous_loader.download_profile(target, profile_pic, profile_pic_only,
filter_func=filter_func) fast_update, filter_func=filter_func)
else: else:
raise err raise err
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -1409,9 +1589,9 @@ class Instaloader:
def main(): def main():
parser = ArgumentParser(description=__doc__, add_help=False, parser = ArgumentParser(description=__doc__, add_help=False,
epilog="Report issues at https://github.com/Thammus/instaloader/issues. " epilog="Report issues at https://github.com/instaloader/instaloader/issues. "
"The complete documentation can be found at " "The complete documentation can be found at "
"https://instaloader.readthedocs.io/.") "https://instaloader.github.io/.")
g_what = parser.add_argument_group('What to Download', g_what = parser.add_argument_group('What to Download',
'Specify a list of profiles or #hashtags. For each of these, Instaloader ' 'Specify a list of profiles or #hashtags. For each of these, Instaloader '
@@ -1423,13 +1603,18 @@ def main():
g_what.add_argument('profile', nargs='*', metavar='profile|#hashtag', g_what.add_argument('profile', nargs='*', metavar='profile|#hashtag',
help='Name of profile or #hashtag to download. ' help='Name of profile or #hashtag to download. '
'Alternatively, if --login is given: @<profile> to download all followees of ' 'Alternatively, if --login is given: @<profile> to download all followees of '
'<profile>; the special targets :feed to ' '<profile>; the special targets '
'download pictures from your feed; or :stories to download the stories of your ' ':feed to download pictures from your feed; '
'followees.') ':stories to download the stories of your followees; or '
':saved to download the posts marked as saved.')
g_what.add_argument('-P', '--profile-pic-only', action='store_true', g_what.add_argument('-P', '--profile-pic-only', action='store_true',
help='Only download profile picture.') help='Only download profile picture.')
g_what.add_argument('--no-profile-pic', action='store_true',
help='Do not download profile picture.')
g_what.add_argument('-V', '--no-videos', action='store_true', g_what.add_argument('-V', '--no-videos', action='store_true',
help='Do not download videos.') help='Do not download videos.')
g_what.add_argument('--no-video-thumbnails', action='store_true',
help='Do not download thumbnails of videos.')
g_what.add_argument('-G', '--geotags', action='store_true', g_what.add_argument('-G', '--geotags', action='store_true',
help='Download geotags when available. Geotags are stored as a ' help='Download geotags when available. Geotags are stored as a '
'text file with the location\'s name and a Google Maps link. ' 'text file with the location\'s name and a Google Maps link. '
@@ -1450,7 +1635,7 @@ def main():
help='Also download stories of each profile that is downloaded. Requires --login.') help='Also download stories of each profile that is downloaded. Requires --login.')
g_what.add_argument('--stories-only', action='store_true', g_what.add_argument('--stories-only', action='store_true',
help='Rather than downloading regular posts of each specified profile, only download ' help='Rather than downloading regular posts of each specified profile, only download '
'stories. Requires --login.') 'stories. Requires --login. Does not imply --no-profile-pic.')
g_what.add_argument('--only-if', metavar='filter', g_what.add_argument('--only-if', metavar='filter',
help='Expression that, if given, must evaluate to True for each post to be downloaded. Must be ' help='Expression that, if given, must evaluate to True for each post to be downloaded. Must be '
'a syntactically valid python expression. Variables are evaluated to ' 'a syntactically valid python expression. Variables are evaluated to '
@@ -1523,6 +1708,7 @@ def main():
"eventually --only-if=viewer_has_liked.") "eventually --only-if=viewer_has_liked.")
download_videos = Tristate.always if not args.no_videos else Tristate.no_extra_query download_videos = Tristate.always if not args.no_videos else Tristate.no_extra_query
download_video_thumbnails = Tristate.always if not args.no_video_thumbnails else Tristate.never
download_comments = Tristate.always if args.comments else Tristate.no_extra_query download_comments = Tristate.always if args.comments else Tristate.no_extra_query
save_captions = Tristate.no_extra_query if not args.no_captions else Tristate.never save_captions = Tristate.no_extra_query if not args.no_captions else Tristate.never
save_metadata = Tristate.always if args.metadata_json else Tristate.never save_metadata = Tristate.always if args.metadata_json else Tristate.never
@@ -1539,13 +1725,21 @@ def main():
loader = Instaloader(sleep=not args.no_sleep, quiet=args.quiet, loader = Instaloader(sleep=not args.no_sleep, quiet=args.quiet,
user_agent=args.user_agent, user_agent=args.user_agent,
dirname_pattern=args.dirname_pattern, filename_pattern=args.filename_pattern, dirname_pattern=args.dirname_pattern, filename_pattern=args.filename_pattern,
download_videos=download_videos, download_geotags=download_geotags, download_videos=download_videos, download_video_thumbnails=download_video_thumbnails,
download_geotags=download_geotags,
save_captions=save_captions, download_comments=download_comments, save_captions=save_captions, download_comments=download_comments,
save_metadata=save_metadata, max_connection_attempts=args.max_connection_attempts) save_metadata=save_metadata, max_connection_attempts=args.max_connection_attempts)
loader.main(args.profile, args.login.lower() if args.login is not None else None, args.password, loader.main(args.profile,
args.sessionfile, username=args.login.lower() if args.login is not None else None,
int(args.count) if args.count is not None else None, password=args.password,
args.profile_pic_only, args.fast_update, args.stories, args.stories_only, args.only_if) sessionfile=args.sessionfile,
max_count=int(args.count) if args.count is not None else None,
profile_pic=not args.no_profile_pic,
profile_pic_only=args.profile_pic_only,
fast_update=args.fast_update,
stories=args.stories,
stories_only=args.stories_only,
filter_str=args.only_if)
except InstaloaderException as err: except InstaloaderException as err:
raise SystemExit("Fatal error: %s" % err) raise SystemExit("Fatal error: %s" % err)
+1 -1
View File
@@ -38,7 +38,7 @@ setup(
name='instaloader', name='instaloader',
version=get_version(), version=get_version(),
py_modules=['instaloader'], py_modules=['instaloader'],
url='https://instaloader.readthedocs.io/', url='https://instaloader.github.io/',
license='MIT', license='MIT',
author='Alexander Graf, André Koch-Kramer', author='Alexander Graf, André Koch-Kramer',
author_email='mail@agraf.me, koch-kramer@web.de', author_email='mail@agraf.me, koch-kramer@web.de',